diff --git a/.bumpversion.toml b/.bumpversion.toml index b5098372c68..3ddd5d2eade 100644 --- a/.bumpversion.toml +++ b/.bumpversion.toml @@ -1,5 +1,5 @@ [tool.bumpversion] -current_version = "3.0.0-beta.2" +current_version = "14.0.0-beta.6" parse = "(?P\\d+)\\.(?P\\d+)\\.(?P\\d+)(-(?P(beta|rc))\\.(?P\\d+))?" serialize = [ "{major}.{minor}.{patch}-{prerelease}.{prerelease_num}", @@ -55,6 +55,11 @@ filename = "Cargo.toml" search = 'lance-datagen = {{ version = "={current_version}"' replace = 'lance-datagen = {{ version = "={new_version}"' +[[tool.bumpversion.files]] +filename = "Cargo.toml" +search = 'lance-derive = {{ version = "={current_version}"' +replace = 'lance-derive = {{ version = "={new_version}"' + [[tool.bumpversion.files]] filename = "Cargo.toml" search = 'lance-encoding = {{ version = "={current_version}"' @@ -75,6 +80,11 @@ filename = "Cargo.toml" search = 'lance-index = {{ version = "={current_version}"' replace = 'lance-index = {{ version = "={new_version}"' +[[tool.bumpversion.files]] +filename = "Cargo.toml" +search = 'lance-index-core = {{ version = "={current_version}"' +replace = 'lance-index-core = {{ version = "={new_version}"' + [[tool.bumpversion.files]] filename = "Cargo.toml" search = 'lance-io = {{ version = "={current_version}"' @@ -92,13 +102,13 @@ replace = 'lance-namespace = {{ version = "={new_version}"' [[tool.bumpversion.files]] filename = "Cargo.toml" -search = 'lance-namespace-datafusion = {{ version = "={current_version}"' -replace = 'lance-namespace-datafusion = {{ version = "={new_version}"' +search = 'lance-namespace-impls = {{ version = "={current_version}"' +replace = 'lance-namespace-impls = {{ version = "={new_version}"' [[tool.bumpversion.files]] filename = "Cargo.toml" -search = 'lance-namespace-impls = {{ version = "={current_version}"' -replace = 'lance-namespace-impls = {{ version = "={new_version}"' +search = 'lance-select = {{ version = "={current_version}"' +replace = 'lance-select = {{ version = "={new_version}"' [[tool.bumpversion.files]] filename = "Cargo.toml" @@ -115,6 +125,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 +160,3 @@ replace = 'version = "{new_version}"' filename = "java/pom.xml" search = "{current_version}" replace = "{new_version}" - diff --git a/.cargo/config.toml b/.cargo/config.toml index 1d9c9ecc9da..4fef1d7e0fa 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -9,12 +9,27 @@ debug = true codegen-units = 16 lto = "thin" +[profile.release-no-lto] +inherits = "release" +debug = true +lto = false +# Prioritize compile time when LTO is not relevant to the measurement. +codegen-units = 16 + [profile.bench] inherits = "release" lto = "thin" codegen-units = 16 [target.x86_64-unknown-linux-gnu] +# The default target is haswell. This is an old enough target +# that portability is high but a new enough target that we gain +# some of the most common SIMD optimizations. +# +# On certain paths we use explicit SIMD and runtime dispatch to +# opt-in to even higher CPU targets. The target specified here +# is for all code that is NOT using explicit SIMD (still the large +# majority of code). rustflags = ["-C", "target-cpu=haswell", "-C", "target-feature=+avx2,+fma,+f16c"] [target.aarch64-apple-darwin] diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 00000000000..9779b0ff8e4 --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,17 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +language: en-US +early_access: false +reviews: + profile: quiet + high_level_summary: false + poem: false + review_status: true + auto_review: + enabled: true + auto_incremental_review: true + ignore_title_keywords: + - WIP + - Draft + drafts: false + base_branches: + - main diff --git a/.gitattributes b/.gitattributes index 18cf7bed94a..a447781cffc 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,4 @@ -notebooks/** linguist-vendored \ No newline at end of file +notebooks/** linguist-vendored +Cargo.lock linguist-generated +**/Cargo.lock linguist-generated +rust/lance-file/test_data/**/*.lance binary diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index adfbf97021a..2a11c81a548 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -5,6 +5,7 @@ docs/src/format/file/ @file-spec-team # table specification protos/table.proto @table-spec-team +protos/fragment_metadata.proto @table-spec-team protos/rowids.proto @table-spec-team docs/src/format/table/ @table-spec-team diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 00000000000..0e7363697b0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,79 @@ +name: Bug report +description: Report incorrect, unexpected, or crashing behavior. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report! Please search + [existing issues](https://github.com/lance-format/lance/issues) first to + avoid filing a duplicate. + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: reproduction + attributes: + label: Steps to reproduce + description: >- + A minimal, self-contained code snippet or sequence of steps that + reproduces the problem. The more we can copy-paste and run, the faster + we can fix it. + placeholder: | + 1. Write a dataset with `...` + 2. Scan with filter `...` + 3. Observe `...` + render: python + validations: + required: true + - type: textarea + id: expected + attributes: + label: Expected behavior + description: What did you expect to happen? + validations: + required: false + - type: input + id: lance-version + attributes: + label: Lance version + description: >- + Output of `python -c "import lance; print(lance.__version__)"`, or the + `lance`/`pylance` version from your `Cargo.toml` / `pyproject.toml`. + placeholder: "e.g. 0.40.0" + validations: + required: true + - type: dropdown + id: language + attributes: + label: Language binding + multiple: true + options: + - Python + - Rust + - Java + - Other / not sure + validations: + required: true + - type: input + id: environment + attributes: + label: Environment + description: OS, architecture, and storage backend (local, S3, GCS, Azure, ...). + placeholder: "e.g. Ubuntu 22.04, x86_64, S3" + validations: + required: false + - type: textarea + id: logs + attributes: + label: Logs / traceback + description: >- + Any relevant log output or stack trace. This will be automatically + formatted as code, so no need for backticks. + render: shell + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 00000000000..32389799971 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,7 @@ +blank_issues_enabled: true +contact_links: + - name: Question / usage help + url: https://discord.gg/lance + about: >- + For questions and general discussion, please ask in the Lance Discord + rather than opening an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 00000000000..41d6278fc02 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: Feature request +about: Suggest a new capability or an improvement to an existing one. +labels: feature +--- + + diff --git a/.github/ISSUE_TEMPLATE/performance_issue.md b/.github/ISSUE_TEMPLATE/performance_issue.md new file mode 100644 index 00000000000..65fcd0be6f4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/performance_issue.md @@ -0,0 +1,12 @@ +--- +name: Performance issue +about: Report slow operations, high memory use, or a performance regression. +labels: performance +--- + + 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/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000000..05bedad56a6 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,91 @@ +version: 2 +updates: + # Rust — one grouped version-bump PR per directory each week, and security + # advisories that land together in a directory batched into a single PR + # instead of one PR per advisory. + - package-ecosystem: "cargo" + directories: + - "/" + - "/python" + - "/java/lance-jni" + versioning-strategy: lockfile-only + # Matches the 48h floor enforced by ci/check_dependency_age.py, so grouped + # bumps do not land a version that CI will then reject. Cooldown does not + # apply to security updates, which we still want as fast as possible. + cooldown: + default-days: 2 + schedule: + interval: "weekly" + day: "wednesday" + groups: + cargo: + applies-to: version-updates + patterns: + - "*" + cargo-security: + applies-to: security-updates + patterns: + - "*" + + # Python (uv) — same weekly-grouped + grouped-security treatment. + - package-ecosystem: "uv" + directory: "/python" + versioning-strategy: lockfile-only + schedule: + interval: "weekly" + day: "wednesday" + groups: + uv: + applies-to: version-updates + patterns: + - "*" + uv-security: + applies-to: security-updates + patterns: + - "*" + + # Standalone Rust lockfiles for dev/test fixtures. Dependabot security updates + # scan these regardless of config, so we group them; open-pull-requests-limit: 0 + # disables the (unwanted) weekly version bumps for these crates. + - package-ecosystem: "cargo" + directories: + - "/memtest" + - "/test_data/fri_straddle_pre_6610/datagen" + open-pull-requests-limit: 0 + schedule: + interval: "weekly" + day: "wednesday" + groups: + cargo-fixtures-security: + applies-to: security-updates + patterns: + - "*" + + # Java (Maven). Security updates fire regardless of config; group them and + # skip version bumps (limit 0) — we bump Java deps manually. + - package-ecosystem: "maven" + directory: "/java" + open-pull-requests-limit: 0 + schedule: + interval: "weekly" + day: "wednesday" + groups: + maven-security: + applies-to: security-updates + patterns: + - "*" + + # Python benchmark scripts (pip / requirements.txt). Group security updates + # and skip version bumps (limit 0). + - package-ecosystem: "pip" + directories: + - "/benchmarks/*" + open-pull-requests-limit: 0 + schedule: + interval: "weekly" + day: "wednesday" + groups: + pip-security: + applies-to: security-updates + patterns: + - "*" diff --git a/.github/labeler-area.yml b/.github/labeler-area.yml new file mode 100644 index 00000000000..2215c52bc19 --- /dev/null +++ b/.github/labeler-area.yml @@ -0,0 +1,100 @@ +# Area labels (A-*) applied by actions/labeler@v5 based on changed paths. +# See .github/workflows/labeler.yml. Title/body conventional-commit labels are +# handled separately by srvaroa/labeler via .github/labeler.yml. +# +# A PR may carry multiple A-* labels (e.g. a Java + namespace change gets both). + +A-java: + - changed-files: + - any-glob-to-any-file: + - "java/**" + - "rust/lance-jni/**" + +A-namespace: + - changed-files: + - any-glob-to-any-file: + - "rust/lance-namespace*/**" + +A-index: + - changed-files: + - any-glob-to-any-file: + - "rust/lance-index/**" + - "rust/lance-linalg/**" + - "rust/lance-tokenizer/**" + +A-encoding: + - changed-files: + - any-glob-to-any-file: + - "rust/lance-encoding/**" + - "rust/lance-io/**" + - "rust/lance-file/**" + +# On-disk format: persisted proto definitions and the format spec docs. Keep the +# explicit proto lists here in sync. Execution-plan schemas (ann, +# filtered_read, and table_identifier) are intentionally excluded. Format docs +# are excluded from A-docs (see below) so format changes get A-format only. +A-format: + - changed-files: + - any-glob-to-any-file: + - "protos/encodings_v2_0.proto" + - "protos/encodings_v2_1.proto" + - "protos/file.proto" + - "protos/file2.proto" + - "protos/index.proto" + - "protos/index_old.proto" + - "protos/rowids.proto" + - "protos/table.proto" + - "protos/fragment_metadata.proto" + - "protos/transaction.proto" + - "docs/src/format/**" + +# Drives the format-spec vote gate (.github/workflows/format-vote-gate.yml): +# any change to persisted proto definitions or the spec docs requires a PMC +# vote. Execution-only wire schemas such as filtered_read.proto are not format +# changes. A PMC member waives a trivial edit with the `format-waived` label. +format-change: + - changed-files: + - any-glob-to-any-file: + - "protos/encodings_v2_0.proto" + - "protos/encodings_v2_1.proto" + - "protos/file.proto" + - "protos/file2.proto" + - "protos/index.proto" + - "protos/index_old.proto" + - "protos/rowids.proto" + - "protos/table.proto" + - "protos/fragment_metadata.proto" + - "protos/transaction.proto" + - "docs/src/format/**" + +# Lockfiles are intentionally not excluded: a pure dependency bump gets both +# A-python and A-deps. Over-labeling beats dropping the area signal on PRs that +# touch python code alongside a lockfile. +A-python: + - changed-files: + - any-glob-to-any-file: + - "python/**" + +# Format spec docs (docs/src/format/**) are excluded so they get A-format only. +# Caveat: a PR that touches both format docs and other docs in the same change +# won't get A-docs, since the exclusion applies across all changed files. +A-docs: + - all: + - changed-files: + - any-glob-to-any-file: + - "docs/**" + - all-globs-to-all-files: + - "!docs/src/format/**" + +A-ci: + - changed-files: + - any-glob-to-any-file: + - ".github/**" + +A-deps: + - changed-files: + - any-glob-to-any-file: + - "**/Cargo.lock" + - "**/uv.lock" + - "deny.toml" + - "**/pyproject.toml" diff --git a/.github/labeler-issues.yml b/.github/labeler-issues.yml new file mode 100644 index 00000000000..625b321cd6c --- /dev/null +++ b/.github/labeler-issues.yml @@ -0,0 +1,28 @@ +version: 1 +# Never remove labels a template or a human already applied. +appendOnly: true +# Content-based labels for issues, applied by srvaroa/labeler via +# .github/workflows/issue-labeler.yml. This covers issues that bypass the +# .github/ISSUE_TEMPLATE forms entirely — those filed via `gh issue create`, +# the REST API, or an agent, which never see the web-UI template chooser. +# +# The primary signal is a leading marker in the title (e.g. "bug:", "[feature]", +# "perf -"), which many people type by hand; a modest body keyword match is a +# secondary fallback. Rules are OR'd (one label per matching entry), so an +# unmatched issue gets no label and is left for human / LLM triage rather than +# guessed at. appendOnly means matches stack harmlessly with template labels. +labels: +# --- Primary signal: a leading bug/feature/perf marker in the title --- +- label: bug + title: "(?i)^\\s*[\\[(]?\\s*bug\\b" +- label: feature + title: "(?i)^\\s*[\\[(]?\\s*(feature|feat)\\b" +- label: performance + title: "(?i)^\\s*[\\[(]?\\s*(perf|performance)\\b" +# --- Secondary fallback: keywords anywhere in the body --- +- label: bug + body: "(?i)(panic|segfault|traceback|stack ?trace|crash|corrupt|incorrect result|wrong result)" +- label: performance + body: "(?i)(regression|latency|throughput|\\bOOM\\b|out of memory|memory usage|too slow)" +- label: feature + body: "(?i)(feature request|would be (nice|great|useful)|it would be nice|support for|add .+ support)" diff --git a/.github/labeler.yml b/.github/labeler.yml index 36431cc07e3..2b0bfbfd2c8 100644 --- a/.github/labeler.yml +++ b/.github/labeler.yml @@ -1,8 +1,11 @@ version: 1 appendOnly: true -# Labels are applied based on conventional commits standard +# Title/body labels based on the conventional commits standard # https://www.conventionalcommits.org/en/v1.0.0/ # These labels are later used in release notes. See .github/release.yml +# +# Path-based area labels (A-*) are handled separately by actions/labeler. +# See .github/labeler-area.yml and .github/workflows/labeler.yml. labels: # If the PR title has an ! before the : it will be considered a breaking change # For example, `feat!: add new feature` will be considered a breaking change @@ -22,9 +25,3 @@ labels: title: "^ci(\\(.+\\))?!?:.*" - label: chore title: "^(chore|test|build|style)(\\(.+\\))?!?:.*" -- label: java - files: - - "java\\/.*" -- label: python - files: - - "python\\/.*" diff --git a/.github/nextest/daily-code-coverage.toml b/.github/nextest/daily-code-coverage.toml new file mode 100644 index 00000000000..ee49194bd2e --- /dev/null +++ b/.github/nextest/daily-code-coverage.toml @@ -0,0 +1,3 @@ +[profile.daily-code-coverage] +slow-timeout = { period = "60s", terminate-after = 30, grace-period = "10s" } +global-timeout = "5h30m" diff --git a/.github/scripts/migrate-area-labels.sh b/.github/scripts/migrate-area-labels.sh new file mode 100755 index 00000000000..1c13a8e3f6b --- /dev/null +++ b/.github/scripts/migrate-area-labels.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# +# One-time migration to the A-* area-label convention (issue #6986). +# +# Maps the existing area-ish labels onto the A-* names used by +# .github/labeler-area.yml. GitHub preserves a label on all existing +# issues/PRs through a rename, so 1:1 renames are non-destructive. Merges +# (two old labels -> one A-* label) rename the higher-traffic label in place +# and re-tag the smaller label's items onto it before deleting it. +# +# Run once, manually, by a maintainer with triage rights, AFTER the auto-labeler +# is merged (actions/labeler applies existing labels but does not create them): +# +# REPO=lance-format/lance ./.github/scripts/migrate-area-labels.sh +# +# Idempotent-ish: re-running skips steps whose target already exists. +# +# Not migrated (intentional): +# - ci, documentation: kept as title-based conventional-commit labels that +# feed release notes (.github/release.yml); A-ci / A-docs are separate +# path-based area labels created fresh below. +# - rust: left as-is; A-core was dropped, so core changes stay unlabelled. + +set -euo pipefail + +REPO="${REPO:-lance-format/lance}" +AREA_COLOR="006b75" # shared color for the A-* family + +label_exists() { + gh label list --repo "$REPO" --limit 500 --json name -q '.[].name' | grep -Fxq "$1" +} + +# rename_label OLD NEW DESCRIPTION +rename_label() { + local old="$1" new="$2" desc="$3" + if label_exists "$new"; then + echo "skip rename: '$new' already exists" + return + fi + if ! label_exists "$old"; then + echo "skip rename: source '$old' not found" + return + fi + echo "rename '$old' -> '$new'" + gh label edit "$old" --repo "$REPO" --name "$new" --description "$desc" --color "$AREA_COLOR" +} + +# ensure_label NAME DESCRIPTION +ensure_label() { + local name="$1" desc="$2" + if label_exists "$name"; then + echo "update '$name'" + gh label edit "$name" --repo "$REPO" --description "$desc" --color "$AREA_COLOR" + return + fi + echo "create '$name'" + gh label create "$name" --repo "$REPO" --description "$desc" --color "$AREA_COLOR" +} + +# merge_label SOURCE TARGET -- re-tag SOURCE's items onto TARGET, then delete SOURCE +merge_label() { + local source="$1" target="$2" + if ! label_exists "$source"; then + echo "skip merge: source '$source' not found" + return + fi + if ! label_exists "$target"; then + echo "ERROR: merge target '$target' is missing; run the rename step first" >&2 + exit 1 + fi + echo "merge '$source' -> '$target'" + + local issues prs + issues=$(gh issue list --repo "$REPO" --label "$source" --state all --limit 1000 --json number -q '.[].number') + prs=$(gh pr list --repo "$REPO" --label "$source" --state all --limit 1000 --json number -q '.[].number') + + if [ "$(printf '%s\n' "$issues" | grep -c .)" -ge 1000 ] || [ "$(printf '%s\n' "$prs" | grep -c .)" -ge 1000 ]; then + echo "ERROR: '$source' has >=1000 items; re-tag manually to avoid truncation" >&2 + exit 1 + fi + + for n in $issues; do + gh issue edit "$n" --repo "$REPO" --add-label "$target" >/dev/null + done + for n in $prs; do + gh pr edit "$n" --repo "$REPO" --add-label "$target" >/dev/null + done + + gh label delete "$source" --repo "$REPO" --yes +} + +# --- 1:1 renames (non-destructive) --- +rename_label java "A-java" "Java bindings + JNI" +rename_label python "A-python" "Python bindings" + +# --- merges: rename the higher-traffic label, fold in the smaller one --- +rename_label vector "A-index" "Vector index, linalg, tokenizer" +merge_label indexes "A-index" + +rename_label file-storage "A-encoding" "Encoding, IO, file reader/writer" +rename_label format "A-format" "On-disk format: protos and format spec docs" + +rename_label dependencies "A-deps" "Dependency updates" +merge_label "python:uv" "A-deps" + +# --- brand-new labels (no existing source) --- +ensure_label "A-namespace" "Namespace impls" +ensure_label "A-docs" "Documentation" +ensure_label "A-ci" "CI / build workflows" + +echo "Done. Current A-* labels:" +gh label list --repo "$REPO" --limit 500 --json name -q '.[].name' | grep '^A-' | sort diff --git a/.github/workflows/approve-rc.yml b/.github/workflows/approve-rc.yml index 622cd65a174..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 @@ -27,7 +30,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 +76,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..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' @@ -24,7 +28,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 +40,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..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 @@ -11,12 +14,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 +35,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..b7b1acd8579 100644 --- a/.github/workflows/buf-publish.yml +++ b/.github/workflows/buf-publish.yml @@ -11,16 +11,19 @@ on: default: '' type: string +permissions: + contents: read + 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..d6e6e0f1ada 100644 --- a/.github/workflows/build_linux_wheel/action.yml +++ b/.github/workflows/build_linux_wheel/action.yml @@ -3,7 +3,7 @@ name: build-linux-wheel description: "Build a manylinux wheel for lance" inputs: python-minor-version: - description: "9, 10, 11, 12" + description: "10, 11, 12, 13" required: true args: description: "--release" @@ -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 @@ -41,14 +41,12 @@ runs: args: ${{ inputs.args }} maturin-version: "1.10.2" before-script-linux: | - set -e - yum install -y openssl-devel \ - && curl -L https://github.com/protocolbuffers/protobuf/releases/download/v24.4/protoc-24.4-linux-$(uname -m).zip > /tmp/protoc.zip \ - && unzip /tmp/protoc.zip -d /usr/local \ - && rm /tmp/protoc.zip + set -euo pipefail + yum install -y openssl-devel + bash "${GITHUB_WORKSPACE}/.github/workflows/build_linux_wheel/install-protoc.sh" - 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 @@ -60,14 +58,12 @@ runs: args: ${{ inputs.args }} maturin-version: "1.10.2" before-script-linux: | - set -e - yum install -y openssl-devel clang \ - && curl -L https://github.com/protocolbuffers/protobuf/releases/download/v24.4/protoc-24.4-linux-$(uname -m).zip > /tmp/protoc.zip \ - && unzip /tmp/protoc.zip -d /usr/local \ - && rm /tmp/protoc.zip + set -euo pipefail + yum install -y openssl-devel clang + bash "${GITHUB_WORKSPACE}/.github/workflows/build_linux_wheel/install-protoc.sh" - 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 @@ -76,8 +72,6 @@ runs: args: ${{ inputs.args }} maturin-version: "1.10.2" before-script-linux: | - set -e - yum install -y openssl-devel clang \ - && curl -L https://github.com/protocolbuffers/protobuf/releases/download/v24.4/protoc-24.4-linux-aarch_64.zip > /tmp/protoc.zip \ - && unzip /tmp/protoc.zip -d /usr/local \ - && rm /tmp/protoc.zip + set -euo pipefail + yum install -y openssl-devel clang + bash "${GITHUB_WORKSPACE}/.github/workflows/build_linux_wheel/install-protoc.sh" diff --git a/.github/workflows/build_linux_wheel/install-protoc.sh b/.github/workflows/build_linux_wheel/install-protoc.sh new file mode 100755 index 00000000000..2d5bf4ced40 --- /dev/null +++ b/.github/workflows/build_linux_wheel/install-protoc.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +set -euo pipefail + +version="${PROTOC_VERSION:-24.4}" +install_dir="${PROTOC_INSTALL_DIR:-/usr/local}" +machine="${1:-$(uname -m)}" + +case "${machine}" in + aarch64 | arm64) + asset_arch="aarch_64" + ;; + x86_64) + asset_arch="x86_64" + ;; + *) + echo "Unsupported protoc architecture: ${machine}" >&2 + exit 1 + ;; +esac + +zip_path="/tmp/protoc-${version}-linux-${asset_arch}.zip" +url="https://github.com/protocolbuffers/protobuf/releases/download/v${version}/protoc-${version}-linux-${asset_arch}.zip" + +for attempt in 1 2 3 4 5; do + rm -f "${zip_path}" + + if curl -fsSL --connect-timeout 15 --max-time 120 -o "${zip_path}" "${url}" \ + && unzip -tq "${zip_path}" >/dev/null; then + break + fi + + if [[ "${attempt}" == "5" ]]; then + echo "Failed to download a valid protoc archive from ${url}" >&2 + exit 1 + fi + + sleep "$((attempt * 2))" +done + +unzip -q -o "${zip_path}" -d "${install_dir}" +rm -f "${zip_path}" + +if [[ "$(uname -s)" == "Linux" ]]; then + "${install_dir}/bin/protoc" --version +else + test -f "${install_dir}/bin/protoc" +fi diff --git a/.github/workflows/build_mac_wheel/action.yml b/.github/workflows/build_mac_wheel/action.yml index 3a3af5917fd..0cac76c49cf 100644 --- a/.github/workflows/build_mac_wheel/action.yml +++ b/.github/workflows/build_mac_wheel/action.yml @@ -3,7 +3,7 @@ name: build_wheel description: "Build a lance wheel" inputs: python-minor-version: - description: "9, 10, 11, 12" + description: "10, 11, 12, 13" required: true args: description: "--release" @@ -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..94475059c75 100644 --- a/.github/workflows/build_windows_wheel/action.yml +++ b/.github/workflows/build_windows_wheel/action.yml @@ -3,7 +3,7 @@ name: build_wheel description: "Build a lance wheel" inputs: python-minor-version: - description: "9, 10, 11, 12" + description: "10, 11, 12, 13" required: true args: description: "--release" @@ -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..bc3222d1c21 100644 --- a/.github/workflows/cargo-publish.yml +++ b/.github/workflows/cargo-publish.yml @@ -11,11 +11,6 @@ on: description: "Tag to publish (e.g., v1.0.0)" required: true type: string - skip_check_repo: - description: "Skip checking if packages have been modified (useful for backfilling missed releases)" - required: false - type: boolean - default: false env: # This env var is used by Swatinem/rust-cache@v2 for the cache @@ -24,11 +19,15 @@ env: CARGO_INCREMENTAL: "0" RUSTFLAGS: "-C debuginfo=0" +permissions: + contents: read + jobs: build: # Needs additional disk space for the full build. - runs-on: warp-ubuntu-latest-x64-8x + runs-on: ubuntu-24.04-8x permissions: + contents: read id-token: write timeout-minutes: 60 env: @@ -39,7 +38,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 +59,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,14 +85,19 @@ 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 + # lance-arrow-stats and lance-arrow-scalar are versioned with Arrow + # (currently 58.0.0) and released on Arrow's cadence, not Lance's, so + # they are excluded from the per-release publish. Bump and publish them + # explicitly when their source changes. + - name: Publish crates if: steps.check_version.outputs.skip != 'true' - with: - # registry-token: ${{ steps.auth.outputs.token }} - registry-token: ${{ secrets.CARGO_REGISTRY_TOKEN }} - args: "--all-features" - path: . - check-repo: ${{ github.event_name != 'workflow_dispatch' || inputs.skip_check_repo != true }} + env: + CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} + run: | + cargo publish --workspace \ + --exclude lance-arrow-stats \ + --exclude lance-arrow-scalar \ + --all-features report-failure: name: Report Workflow Failure runs-on: ubuntu-latest @@ -102,7 +107,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 deleted file mode 100644 index ebe55cd146e..00000000000 --- a/.github/workflows/ci-benchmarks.yml +++ /dev/null @@ -1,75 +0,0 @@ -name: Run Regression Benchmarks - -on: - workflow_dispatch: - push: - branches: - - main - -jobs: - bench_regress: - timeout-minutes: 120 - runs-on: warp-custom-gcp-storage-benchmark - env: - # Need up-to-date compilers for kernels - CC: clang-18 - CXX: clang++-18 - defaults: - run: - shell: bash - working-directory: python - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - lfs: true - - name: Authenticate with GCS - uses: "google-github-actions/auth@v2" - with: - credentials_json: "${{ secrets.GCLOUD_BENCH_STORAGE_USER_KEY }}" - - name: Install bencher - uses: bencherdev/bencher@main - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: 3.11 # Ray does not support 3.12 yet. - - uses: Swatinem/rust-cache@v2 - with: - workspaces: python - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev - - name: Build - run: | - python -m venv venv - source venv/bin/activate - pip install maturin duckdb requests pytest pytest-benchmark datasets - maturin develop --locked --release --features datagen - - name: Build memtest - run: | - source venv/bin/activate - make -C ../memtest build-release - - name: Generate datasets - run: | - source venv/bin/activate - python python/ci_benchmarks/datagen/gen_all.py - - name: Run benchmarks - run: | - source venv/bin/activate - bencher run --project weston-lancedb --token ${{ secrets.LANCE_BENCHER_TOKEN }} --adapter python_pytest \ - --branch main --testbed google-genoa --err --file results.json "python -mpytest --benchmark-json \ - results.json python/ci_benchmarks" - - name: Run IO/memory benchmarks - run: | - source venv/bin/activate - LIB_PATH=$(lance-memtest) - LD_PRELOAD=$LIB_PATH pytest python/ci_benchmarks \ - -k "io_mem_" \ - --benchmark-stats-json io_mem_stats.json - - name: Upload IO/memory stats to bencher - run: | - source venv/bin/activate - bencher run --project weston-lancedb --token ${{ secrets.LANCE_BENCHER_TOKEN }} \ - --adapter json --branch main --testbed google-genoa \ - --err --file io_mem_stats.json diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml new file mode 100644 index 00000000000..80ebff1589f --- /dev/null +++ b/.github/workflows/ci-scripts.yml @@ -0,0 +1,44 @@ +name: CI scripts + +# Tests for helper scripts under ci/ that aren't covered by the language test +# suites (e.g. the format-spec vote gate logic). + +on: + push: + branches: + - main + - release/** + pull_request: + branches: + - main + - release/** + paths: + - ci/format_vote_gate.py + - ci/test_format_vote_gate.py + - ci/test_labeler_area.py + - ci/check_proto_comments.py + - ci/test_check_proto_comments.py + - ci/check_dependency_age.py + - ci/test_check_dependency_age.py + - .github/labeler-area.yml + - .github/workflows/format-vote-gate.yml + - .github/workflows/ci-scripts.yml + +permissions: + contents: read + +jobs: + format-vote-gate: + name: Format vote gate unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install test dependencies + run: pip install pytest PyYAML + - name: Run tests + run: pytest ci/test_format_vote_gate.py ci/test_labeler_area.py ci/test_check_proto_comments.py ci/test_check_dependency_age.py diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml deleted file mode 100644 index 691a6c6a3a3..00000000000 --- a/.github/workflows/claude-code-review.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Claude Code Review - -on: - pull_request_target: - types: [opened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" - -jobs: - claude-review: - # 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' - - runs-on: ubuntu-latest - permissions: - contents: read - pull-requests: write - issues: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 1 - - - name: Run Claude Code Review - id: claude-review - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.CLAUDE_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - prompt: | - REPO: ${{ github.repository }} - PR NUMBER: ${{ github.event.pull_request.number }} - - Please review this pull request and provide feedback on: - - Code quality and best practices - - Potential bugs or issues - - Performance considerations - - Security concerns - - Test coverage - - Please note that the attention of contributors and maintainers is the MOST valuable resource. - Less is more: focus on the most important aspects. - - - Your review output SHOULD be concise and clear. - - You SHOULD only highlight P0 and P1 level issues, such as severe bugs, performance degradation, or security concerns. - - You MUST not reiterate detailed changes in your review. - - You MUST not repeat aspects of the PR that are already well done. - - Use the repository's CLAUDE.md for more guidance on style and conventions. - - Use `gh pr comment` with your Bash tool to leave your review as a comment on the PR. - - # 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:*)" - --model "claude-opus-4-5" diff --git a/.github/workflows/claude.yml b/.github/workflows/claude.yml deleted file mode 100644 index 52198958deb..00000000000 --- a/.github/workflows/claude.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Claude Code - -on: - issue_comment: - types: [created] - pull_request_review_comment: - types: [created] - issues: - types: [opened, assigned] - pull_request_review: - types: [submitted] - -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'))) - 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 - with: - fetch-depth: 1 - - - name: Run Claude Code - id: claude - uses: anthropics/claude-code-action@v1 - with: - anthropic_api_key: ${{ secrets.CLAUDE_TOKEN }} - github_token: ${{ secrets.GITHUB_TOKEN }} - - # This is an optional setting that allows Claude to read CI results on PRs - additional_permissions: | - actions: read - - # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. - # prompt: 'Update the pull request description to include a summary of changes.' - - 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:*)" - --model "claude-opus-4-5" diff --git a/.github/workflows/codex-backport-pr.yml b/.github/workflows/codex-backport-pr.yml new file mode 100644 index 00000000000..1f917380954 --- /dev/null +++ b/.github/workflows/codex-backport-pr.yml @@ -0,0 +1,179 @@ +# 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. + +name: Codex Backport PR + +on: + workflow_dispatch: + inputs: + pr_urls: + description: "Comma-separated PR URLs to backport in order (e.g., https://github.com/lancedb/lance/pull/1234,https://github.com/lancedb/lance/pull/5678)" + required: true + type: string + release_branch: + description: "Release branch to backport to (e.g., release/v2.0)" + required: true + type: string + guidelines: + description: "Additional guidelines for the backport (optional)" + required: false + type: string + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + backport: + runs-on: ubuntu-24.04-4x + timeout-minutes: 60 + env: + CC: clang + CXX: clang++ + steps: + - name: Show inputs + run: | + echo "pr_urls = ${{ inputs.pr_urls }}" + echo "release_branch = ${{ inputs.release_branch }}" + echo "guidelines = ${{ inputs.guidelines }}" + + - name: Checkout Repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + + - name: Install Codex CLI + run: npm install -g @openai/codex + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: stable + components: clippy, rustfmt + + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libssl-dev + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + pip install maturin ruff pytest pyarrow pandas polars + + - name: Set up Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: '11' + cache: maven + + - name: Configure git user + run: | + git config user.name "lance-community" + git config user.email "community@lance.org" + + - name: Run Codex to backport PRs + env: + PR_URLS: ${{ inputs.pr_urls }} + RELEASE_BRANCH: ${{ inputs.release_branch }} + GUIDELINES: ${{ inputs.guidelines }} + GITHUB_TOKEN: ${{ secrets.LANCE_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.LANCE_RELEASE_TOKEN }} + OPENAI_API_KEY: ${{ secrets.CODEX_TOKEN }} + run: | + set -euo pipefail + + cat </tmp/codex-prompt.txt + You are running inside the lance repository on a GitHub Actions runner. Your task is to backport one or more merged PRs to a release branch. + + Input parameters: + - PR URLs (comma-separated, apply in order): ${PR_URLS} + - Release branch: ${RELEASE_BRANCH} + - Additional guidelines: ${GUIDELINES:-"None provided"} + + Follow these steps exactly: + + 1. Parse the comma-separated PR URLs into a list. Trim any whitespace around each URL. The URL format is https://github.com/lancedb/lance/pull/. + + 2. For each PR URL in order, extract the PR number and use "gh pr view --json state,mergeCommit,title,number" to verify the PR is merged. If any PR is not merged (state != "MERGED"), exit with an error message explaining that only merged PRs can be backported. + + 3. Store all PR numbers, titles, and merge commit SHAs for later use. + + 4. Verify the release branch exists with "git ls-remote --heads origin ${RELEASE_BRANCH}". If it doesn't exist, exit with an error. + + 5. Checkout the release branch: "git checkout ${RELEASE_BRANCH}" and pull latest: "git pull origin ${RELEASE_BRANCH}". + + 6. Create a new branch for the backport. If there's only one PR, use "backport/pr--to-${RELEASE_BRANCH//\//-}". If there are multiple PRs, use "backport/pr--and-more-to-${RELEASE_BRANCH//\//-}". + + 7. For each PR in order, cherry-pick its merge commit: "git cherry-pick -m 1 ". + - If there are conflicts, try to resolve them. Inspect conflicting files with "git status" and "git diff". + - For simple conflicts, fix them and continue with "git add -A && git cherry-pick --continue". + - If conflicts are too complex to resolve automatically, abort and exit with a clear error message indicating which PR caused the conflict. + + 8. Run "cargo fmt --all" to ensure formatting is correct. + + 9. Run "cargo clippy --workspace --tests --benches -- -D warnings" to check for issues. Fix any warnings and rerun until clean. + + 10. Run ONLY the tests related to the changes in these PRs: + - Use "git diff --name-only ${RELEASE_BRANCH}...HEAD" to see all files changed across all cherry-picked commits. + - For Rust changes: Run tests for the affected crates only (e.g., "cargo test -p lance-core" if lance-core files changed). + - For Python changes (python/** files): Build with "cd python && make build" then run "uv run pytest" on the specific test files that were modified, or related test files. + - For Java changes (java/** files): Run "cd java && mvn test" for the affected modules. + - If test files themselves were modified, run those specific tests. + - Do NOT run the full test suite - only run tests related to the changed files. + + 11. If additional guidelines are provided, follow them as well when making decisions or resolving issues. + + 12. Stage any additional changes with "git add -A" and amend the last commit if needed: "git commit --amend --no-edit". + + 13. Push the branch: "git push origin ". If the remote branch exists, delete it first with "gh api -X DELETE repos/lancedb/lance/git/refs/heads/" then push. Do NOT use "git push --force" or "git push -f". + + 14. Create a pull request targeting "${RELEASE_BRANCH}": + - If single PR: Title should be the same as the original PR title. + - If multiple PRs: Title should be "Backport multiple PRs to ${RELEASE_BRANCH}" or similar descriptive title. + - First, write the PR body to /tmp/pr-body.md using a heredoc (cat <<'PREOF' > /tmp/pr-body.md). The body should list all backported PRs: + "Backport of the following PRs: + - + - + ... + + This PR backports the changes from the original PRs to the ${RELEASE_BRANCH} branch." + - Then run "gh pr create --base ${RELEASE_BRANCH} --title '' --body-file /tmp/pr-body.md". + + 15. Display the new PR URL, "git status --short", and a summary of what was done including which PRs were backported. + + Constraints: + - Use bash commands for all operations. + - Do not merge the PR. + - Do not modify GitHub workflow files. + - If any command fails, diagnose and attempt to fix the issue instead of aborting immediately. + - env "GH_TOKEN" is available, use "gh" tools for GitHub-related operations. + EOF + + printenv OPENAI_API_KEY | codex login --with-api-key + codex --config shell_environment_policy.ignore_default_excludes=true exec --dangerously-bypass-approvals-and-sandbox "$(cat /tmp/codex-prompt.txt)" diff --git a/.github/workflows/codex-fix-ci.yml b/.github/workflows/codex-fix-ci.yml new file mode 100644 index 00000000000..70e4bdf6d98 --- /dev/null +++ b/.github/workflows/codex-fix-ci.yml @@ -0,0 +1,181 @@ +# 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. + +name: Codex Fix CI + +on: + workflow_dispatch: + inputs: + workflow_run_url: + description: "Failing CI workflow run URL (e.g., https://github.com/lancedb/lance/actions/runs/12345678)" + required: true + type: string + branch: + description: "Branch to fix (e.g., main, release/v2.0, or feature-branch)" + required: true + type: string + guidelines: + description: "Additional guidelines for the fix (optional)" + required: false + type: string + +permissions: + contents: write + pull-requests: write + actions: read + +jobs: + fix-ci: + runs-on: ubuntu-24.04-4x + timeout-minutes: 60 + env: + CC: clang + CXX: clang++ + steps: + - name: Show inputs + run: | + echo "workflow_run_url = ${{ inputs.workflow_run_url }}" + echo "branch = ${{ inputs.branch }}" + echo "guidelines = ${{ inputs.guidelines }}" + + - name: Checkout Repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + persist-credentials: true + + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 20 + + - name: Install Codex CLI + run: npm install -g @openai/codex + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@631a55b12751854ce901bb631d5902ceb48146f7 # stable + with: + toolchain: stable + components: clippy, rustfmt + + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y protobuf-compiler libssl-dev + + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.11' + + - name: Install Python dependencies + run: | + pip install maturin ruff pytest pyarrow pandas polars + + - name: Set up Java + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 + with: + distribution: temurin + java-version: '11' + cache: maven + + - name: Configure git user + run: | + git config user.name "lance-community" + git config user.email "community@lance.org" + + - name: Run Codex to fix CI failure + env: + WORKFLOW_RUN_URL: ${{ inputs.workflow_run_url }} + BRANCH: ${{ inputs.branch }} + GUIDELINES: ${{ inputs.guidelines }} + GITHUB_TOKEN: ${{ secrets.LANCE_RELEASE_TOKEN }} + GH_TOKEN: ${{ secrets.LANCE_RELEASE_TOKEN }} + OPENAI_API_KEY: ${{ secrets.CODEX_TOKEN }} + run: | + set -euo pipefail + + cat <<EOF >/tmp/codex-prompt.txt + You are running inside the lance repository on a GitHub Actions runner. Your task is to fix a CI failure. + + Input parameters: + - Failing workflow run URL: ${WORKFLOW_RUN_URL} + - Branch to fix: ${BRANCH} + - Additional guidelines: ${GUIDELINES:-"None provided"} + + Follow these steps exactly: + + 1. Extract the run ID from the workflow URL. The URL format is https://github.com/lancedb/lance/actions/runs/<run_id>. + + 2. Use "gh run view <run_id> --json jobs,conclusion,name" to get information about the failed run. + + 3. Identify which jobs failed. For each failed job, use "gh run view <run_id> --job <job_id> --log-failed" to get the failure logs. + + 4. Analyze the failure logs to understand what went wrong. Common failures include: + - Compilation errors + - Test failures + - Clippy warnings treated as errors + - Formatting issues + - Dependency issues + + 5. Based on the analysis, fix the issues in the codebase: + - For compilation errors: Fix the code that doesn't compile + - For test failures: Fix the failing tests or the code they test + - For clippy warnings: Apply the suggested fixes + - For formatting issues: Run "cargo fmt --all" + - For other issues: Apply appropriate fixes + + 6. After making fixes, verify them locally: + - Run "cargo fmt --all" to ensure formatting is correct + - Run "cargo clippy --workspace --tests --benches -- -D warnings" to check for issues + - Run ONLY the specific failing tests to confirm they pass now: + - For Rust test failures: Run the specific test with "cargo test -p <crate> <test_name>" + - For Python test failures: Build with "cd python && make build" then run "uv run pytest <specific_test_file>::<test_name>" + - For Java test failures: Run "cd java && mvn test -Dtest=<TestClass>#<testMethod>" + - Do NOT run the full test suite - only run the tests that were failing + + 7. If the additional guidelines are provided, follow them as well. + + 8. Inspect "git status --short" and "git diff" to review your changes. + + 9. Create a fix branch: "git checkout -b codex/fix-ci-<run_id>". + + 10. Stage all changes with "git add -A" and commit with message "fix: resolve CI failures from run <run_id>". + + 11. Push the branch: "git push origin codex/fix-ci-<run_id>". If the remote branch exists, delete it first with "gh api -X DELETE repos/lancedb/lance/git/refs/heads/codex/fix-ci-<run_id>" then push. Do NOT use "git push --force" or "git push -f". + + 12. Create a pull request targeting "${BRANCH}": + - Title: "ci: <short summary describing the fix>" (e.g., "ci: fix clippy warnings in lance-core" or "ci: resolve test flakiness in vector search") + - First, write the PR body to /tmp/pr-body.md using a heredoc (cat <<'PREOF' > /tmp/pr-body.md). The body should include: + - Link to the failing workflow run + - Summary of what failed + - Description of the fixes applied + - Then run "gh pr create --base ${BRANCH} --body-file /tmp/pr-body.md". + + 13. Display the new PR URL, "git status --short", and a summary of what was fixed. + + Constraints: + - Use bash commands for all operations. + - Do not merge the PR. + - Do not modify GitHub workflow files unless they are the cause of the failure. + - If any command fails, diagnose and attempt to fix the issue instead of aborting immediately. + - If you cannot fix the issue automatically, create the PR anyway with a clear explanation of what you tried and what remains to be fixed. + - env "GH_TOKEN" is available, use "gh" tools for GitHub-related operations. + EOF + + printenv OPENAI_API_KEY | codex login --with-api-key + codex --config shell_environment_policy.ignore_default_excludes=true exec --dangerously-bypass-approvals-and-sandbox "$(cat /tmp/codex-prompt.txt)" diff --git a/.github/workflows/compat-pair.yml b/.github/workflows/compat-pair.yml new file mode 100644 index 00000000000..07bb0f49741 --- /dev/null +++ b/.github/workflows/compat-pair.yml @@ -0,0 +1,78 @@ +# On-demand cross-version index compatibility run between two arbitrary refs. +# +# The scheduled path lives in nightly_run.yml (compat-sequence job), which ages the two +# previous majors into HEAD nightly. This workflow is the manual escape +# hatch for any other pairing: each ref (version, sha, branch, or tag) is provisioned by +# the framework -- a published release installs a wheel, anything else is built from a +# worktree via maturin -- so two arbitrary refs can be compared even when neither has a +# published wheel. The suite ages an index under the writer ref and exercises it under the +# reader, searching maintenance-op sequences for panics or correctness divergence. +name: Compat (index ref pair) + +on: + workflow_dispatch: + inputs: + from_ref: + description: "Writer ref (version / sha / branch). Blank = 2nd most recent release." + required: false + default: "" + to_ref: + description: "Reader ref (version / sha / branch). Blank = most recent release." + required: false + default: "" + kinds: + description: "Comma-separated index kinds (INVERTED,BTREE,IVF_PQ,...) or 'all'." + required: false + default: "all" + max_length: + description: "Max maintenance-op sequence length to search (deeper = slower)." + required: false + default: "5" + +jobs: + compat-pair: + runs-on: ubuntu-latest + timeout-minutes: 120 + defaults: + run: + working-directory: python + env: + COMPAT_FROM_REF: ${{ inputs.from_ref }} + COMPAT_TO_REF: ${{ inputs.to_ref }} + COMPAT_MAX_LENGTH: ${{ inputs.max_length || '5' }} + steps: + # Full history so arbitrary refs can be checked out for builds. + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.11" + # Toolchain for the build-from-source provisioning path (refs without a wheel). + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - name: Install host deps + run: pip install pytest pytest-xdist pyarrow packaging maturin + - name: Resolve kinds + id: kinds + env: + KINDS_IN: ${{ inputs.kinds }} + run: | + if [ -z "$KINDS_IN" ] || [ "$KINDS_IN" = "all" ]; then + echo "value=INVERTED,BTREE,BITMAP,LABEL_LIST,NGRAM,ZONEMAP,BLOOMFILTER,IVF_PQ" >> "$GITHUB_OUTPUT" + else + echo "value=$KINDS_IN" >> "$GITHUB_OUTPUT" + fi + # Oversubscribe (4x cores): each scenario writes a small dataset to disk and the + # reader spends most of its time in short subprocess round-trips, so a worker waiting + # on disk or the sub-venv pipe overlaps another's compute. + - name: Run compat suite (${{ inputs.from_ref }} -> ${{ inputs.to_ref }}) + env: + COMPAT_KINDS: ${{ steps.kinds.outputs.value }} + run: | + python -m pytest python/tests/compat/test_index_sequence.py \ + --run-compat -n "$(( $(nproc) * 4 ))" -v --no-header diff --git a/.github/workflows/create-rc.yml b/.github/workflows/create-rc.yml index 86f3df2c88c..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 @@ -24,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 @@ -84,7 +87,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 5b7ccf5454c..1a6fb9b78fb 100644 --- a/.github/workflows/create-release-branch.yml +++ b/.github/workflows/create-release-branch.yml @@ -3,12 +3,20 @@ name: Create Release Branch on: workflow_dispatch: inputs: + source_release_branch: + description: 'Source release branch (optional, e.g., release/v1.3). Leave empty to create from main.' + required: false + type: string + default: '' dry_run: description: 'Dry run (simulate without pushing)' required: true default: false type: boolean +permissions: + contents: read + jobs: create-release-branch: runs-on: ubuntu-latest @@ -23,9 +31,9 @@ jobs: run: echo "${{ toJSON(github.event.inputs) }}" - name: Check out repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - ref: main + ref: ${{ inputs.source_release_branch || 'main' }} token: ${{ secrets.LANCE_RELEASE_TOKEN }} fetch-depth: 0 lfs: true @@ -39,16 +47,25 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_REPOSITORY: ${{ github.repository }} run: | - bash ci/create_release_branch.sh + bash ci/create_release_branch.sh "${{ inputs.source_release_branch }}" - name: Push changes (if not dry run) if: ${{ !inputs.dry_run }} run: | git push origin "${{ steps.create_branch.outputs.RELEASE_BRANCH }}" - git push origin main git push origin "${{ steps.create_branch.outputs.RC_TAG }}" - # Push release root tag (may already exist remotely if created during beta publish) - git push origin "${{ steps.create_branch.outputs.RELEASE_ROOT_TAG }}" || echo "Release root tag already exists remotely" + # When creating from main: push main and release root tag + # When creating from release branch: push minor release root tag + if [ -z "${{ inputs.source_release_branch }}" ]; then + git push origin main + # Push release root tag (may already exist remotely if created during beta publish) + git push origin "${{ steps.create_branch.outputs.RELEASE_ROOT_TAG }}" || echo "Release root tag already exists remotely" + else + # Push minor release root tag if it was created + if [ -n "${{ steps.create_branch.outputs.MINOR_RELEASE_ROOT_TAG }}" ]; then + git push origin "${{ steps.create_branch.outputs.MINOR_RELEASE_ROOT_TAG }}" + fi + fi - name: Generate Release Notes (if not dry run) if: ${{ !inputs.dry_run }} @@ -76,7 +93,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 }} @@ -106,9 +123,18 @@ jobs: echo "- **Target Version:** ${{ steps.create_branch.outputs.RC_VERSION }}" >> $GITHUB_STEP_SUMMARY echo "- **RC Tag:** ${{ steps.create_branch.outputs.RC_TAG }}" >> $GITHUB_STEP_SUMMARY echo "- **Release Branch:** ${{ steps.create_branch.outputs.RELEASE_BRANCH }}" >> $GITHUB_STEP_SUMMARY - echo "- **Release Root Tag:** ${{ steps.create_branch.outputs.RELEASE_ROOT_TAG }}" >> $GITHUB_STEP_SUMMARY - echo "- **Main Version:** ${{ steps.create_branch.outputs.MAIN_VERSION }}" >> $GITHUB_STEP_SUMMARY - echo "- **Source Branch:** main (HEAD)" >> $GITHUB_STEP_SUMMARY + + if [ -z "${{ inputs.source_release_branch }}" ]; then + echo "- **Release Root Tag:** ${{ steps.create_branch.outputs.RELEASE_ROOT_TAG }}" >> $GITHUB_STEP_SUMMARY + echo "- **Main Version:** ${{ steps.create_branch.outputs.MAIN_VERSION }}" >> $GITHUB_STEP_SUMMARY + echo "- **Source Branch:** main (HEAD)" >> $GITHUB_STEP_SUMMARY + else + echo "- **Source Branch:** ${{ inputs.source_release_branch }}" >> $GITHUB_STEP_SUMMARY + echo "- **Release Notes Base:** ${{ steps.create_branch.outputs.PREVIOUS_TAG }}" >> $GITHUB_STEP_SUMMARY + if [ -n "${{ steps.create_branch.outputs.MINOR_RELEASE_ROOT_TAG }}" ]; then + echo "- **Minor Release Root Tag:** ${{ steps.create_branch.outputs.MINOR_RELEASE_ROOT_TAG }}" >> $GITHUB_STEP_SUMMARY + fi + fi echo "- **Dry Run:** ${{ inputs.dry_run }}" >> $GITHUB_STEP_SUMMARY if [ "${{ inputs.dry_run }}" == "true" ]; then @@ -122,7 +148,11 @@ jobs: echo "" >> $GITHUB_STEP_SUMMARY echo "**What happened:**" >> $GITHUB_STEP_SUMMARY echo "1. Created release branch ${{ steps.create_branch.outputs.RELEASE_BRANCH }} at ${{ steps.create_branch.outputs.RC_TAG }}" >> $GITHUB_STEP_SUMMARY - echo "2. Bumped main to ${{ steps.create_branch.outputs.MAIN_VERSION }} (unreleased)" >> $GITHUB_STEP_SUMMARY + if [ -z "${{ inputs.source_release_branch }}" ]; then + echo "2. Bumped main to ${{ steps.create_branch.outputs.MAIN_VERSION }} (unreleased)" >> $GITHUB_STEP_SUMMARY + else + echo "2. Created from ${{ inputs.source_release_branch }} (main unchanged)" >> $GITHUB_STEP_SUMMARY + fi echo "" >> $GITHUB_STEP_SUMMARY echo "**Next steps:**" >> $GITHUB_STEP_SUMMARY echo "1. Review and vote in the discussion thread" >> $GITHUB_STEP_SUMMARY diff --git a/.github/workflows/daily-code-coverage.yml b/.github/workflows/daily-code-coverage.yml new file mode 100644 index 00000000000..62871834b62 --- /dev/null +++ b/.github/workflows/daily-code-coverage.yml @@ -0,0 +1,74 @@ +name: Daily Code Coverage + +on: + schedule: + - cron: "0 2 * * *" + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: daily-code-coverage + cancel-in-progress: false + +jobs: + coverage: + runs-on: ubuntu-24.04-8x + timeout-minutes: 360 + env: + CC: clang + CXX: clang++ + RUSTFLAGS: "-D warnings" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Setup Rust toolchain + run: | + rustup toolchain install nightly-2026-07-13 --component llvm-tools-preview + rustup default nightly-2026-07-13 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + key: daily-code-coverage + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # cargo-llvm-cov + with: + tool: cargo-llvm-cov + - name: Install cargo-nextest + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # nextest + with: + tool: nextest + - name: Start DynamoDB and S3 + run: docker compose -f docker-compose.yml up -d --wait + - name: Run coverage tests + env: + NEXTEST_PROFILE: daily-code-coverage + run: | + ALL_FEATURES=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -) + cargo +nightly-2026-07-13 llvm-cov nextest \ + --codecov \ + --output-path coverage.codecov \ + --cargo-profile ci \ + --locked \ + --workspace \ + --features "${ALL_FEATURES}" \ + --config-file .github/nextest/daily-code-coverage.toml + - name: Upload coverage artifact + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: daily-rust-coverage + path: coverage.codecov + retention-days: 14 + if-no-files-found: error + - name: Upload coverage to Codecov + uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: coverage.codecov + flags: unittests + name: daily-code-coverage + fail_ci_if_error: true diff --git a/.github/workflows/docs-check.yml b/.github/workflows/docs-check.yml deleted file mode 100644 index 02c0ce3b751..00000000000 --- a/.github/workflows/docs-check.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Check docs - -on: - push: - branches: - - main - - release/** - pull_request: - branches: - - main - - release/** - paths: - - docs/** - - .github/workflows/docs-check.yml - -env: - RUSTFLAGS: "-C debuginfo=0" - # according to: https://matklad.github.io/2021/09/04/fast-rust-builds.html - # CI builds are faster with incremental disabled. - CARGO_INCREMENTAL: "0" - -jobs: - # Single deploy job since we're just deploying - check-docs: - runs-on: ubuntu-24.04 - steps: - - name: Checkout - uses: actions/checkout@v4 - - name: "Set up Python" - uses: actions/setup-python@v5 - with: - python-version-file: "docs/pyproject.toml" - - name: Install uv - uses: astral-sh/setup-uv@v6 - with: - enable-cache: true - - name: Check links - working-directory: docs - run: | - uv run mkdocs-linkcheck src diff --git a/.github/workflows/docs-deploy.yml b/.github/workflows/docs-deploy.yml index 583996cab36..aeaea0bbcba 100644 --- a/.github/workflows/docs-deploy.yml +++ b/.github/workflows/docs-deploy.yml @@ -24,114 +24,73 @@ 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 + repository: jackye1995/lance-namespace + ref: ns-catalog-split 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 + repository: jackye1995/lance-namespace-impls + ref: jack/ns-catalog-split path: lance-namespace-impls + - name: Checkout lance-duckdb + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: lance-format/lance-duckdb + path: lance-duckdb + - name: Checkout lance-trino + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: lance-format/lance-trino + path: lance-trino + - name: Checkout lance-context + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + repository: lance-format/lance-context + path: lance-context - name: Configure Git Credentials run: | 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 - run: | - cp -r lance-namespace/docs/src docs/src/format/namespace - cat >> docs/src/format/.pages << 'EOF' - - Namespace Spec: namespace - EOF - cp docs/src/format/namespace/rest.yaml docs/src/rest.yaml - - name: Copy lance-namespace-impls docs - run: | - # Copy implementation specs to the integrations folder - cp lance-namespace-impls/docs/src/*.md docs/src/format/namespace/integrations/ - - # Copy .pages from lance-namespace-impls and append template entry - cp lance-namespace-impls/docs/src/.pages docs/src/format/namespace/integrations/.pages - echo " - Template for New Integrations: template.md" >> docs/src/format/namespace/integrations/.pages - - name: Copy lance-huggingface docs - run: | - cp -r lance-huggingface/docs/src docs/src/integrations/huggingface - cat >> docs/src/integrations/.pages << 'EOF' - - Huggingface: huggingface - EOF - - name: Copy lance-spark docs + - name: Assemble full docs website + env: + LANCE_NAMESPACE_REPO: ${{ github.workspace }}/lance-namespace + LANCE_NAMESPACE_IMPLS_REPO: ${{ github.workspace }}/lance-namespace-impls + LANCE_SPARK_REPO: ${{ github.workspace }}/lance-spark + LANCE_RAY_REPO: ${{ github.workspace }}/lance-ray + LANCE_TRINO_REPO: ${{ github.workspace }}/lance-trino + LANCE_DUCKDB_REPO: ${{ github.workspace }}/lance-duckdb + LANCE_HUGGINGFACE_REPO: ${{ github.workspace }}/lance-huggingface + LANCE_CONTEXT_REPO: ${{ github.workspace }}/lance-context run: | - cp -r lance-spark/docs/src docs/src/integrations/spark - cat >> docs/src/integrations/.pages << 'EOF' - - Apache Spark: spark - EOF - - name: Copy lance-ray docs - run: | - cp -r lance-ray/docs/src docs/src/integrations/ray - cat >> docs/src/integrations/.pages << 'EOF' - - Ray: ray - EOF - - name: Copy contributing docs - run: | - mkdir -p docs/src/community/project-specific/lance - - # Lance project files - cp CONTRIBUTING.md docs/src/community/project-specific/lance/general.md - cp release_process.md docs/src/community/project-specific/lance/release.md - cp rust/CONTRIBUTING.md docs/src/community/project-specific/lance/rust.md - cp python/CONTRIBUTING.md docs/src/community/project-specific/lance/python.md - cp docs/CONTRIBUTING.md docs/src/community/project-specific/lance/docs.md - - # External project files - cp lance-ray/CONTRIBUTING.md docs/src/community/project-specific/ray.md - cp lance-spark/CONTRIBUTING.md docs/src/community/project-specific/spark.md - cp lance-namespace/CONTRIBUTING.md docs/src/community/project-specific/namespace.md - cp lance-namespace-impls/CONTRIBUTING.md docs/src/community/project-specific/namespace-impls.md || true - - # Create .pages for project-specific - cat > docs/src/community/project-specific/.pages << 'EOF' - nav: - - index.md - - Lance: lance - - Lance Namespace: namespace.md - - Lance Namespace Impls: namespace-impls.md - - Lance Ray: ray.md - - Lance Spark: spark.md - EOF - - # Create .pages for lance subfolder - cat > docs/src/community/project-specific/lance/.pages << 'EOF' - nav: - - General: general.md - - Release: release.md - - Rust: rust.md - - Python: python.md - - Docs: docs.md - EOF + docs/make-full-website.sh - name: Deploy working-directory: docs run: uv run mkdocs gh-deploy --force diff --git a/.github/workflows/docs-link-check.yml b/.github/workflows/docs-link-check.yml new file mode 100644 index 00000000000..a92bf4812d7 --- /dev/null +++ b/.github/workflows/docs-link-check.yml @@ -0,0 +1,166 @@ +name: Check doc links + +# Checking external links is inherently noisy: third-party sites rate-limit +# automated clients, reject non-browser user agents, and go down temporarily. +# Blocking pull requests on that trades a lot of false failures for very little +# signal, so this runs on a schedule and reports findings in a single tracking +# issue instead of failing anyone's build. +on: + schedule: + - cron: "0 7 * * *" + workflow_dispatch: + +# The report lives in one repository-global issue, so runs must not overlap: a +# lookup racing a create produces duplicate issues, and a healthy run closing +# the issue while a failing run only rewrites its body would leave a broken +# report closed. The group is deliberately ref-independent so that a manual +# dispatch serializes against the scheduled run. +concurrency: + group: docs-link-check + cancel-in-progress: false + +permissions: {} + +env: + REPORT_TITLE: "Docs link checker report" + +jobs: + scan: + name: Scan links + runs-on: ubuntu-24.04 + # lychee-action is pinned by SHA, but its wrapper downloads the lychee + # release tarball at run time without verifying a digest, and hands the + # resulting binary a GitHub token. Release assets remain replaceable, so + # that binary is confined to a job whose token can only read public + # content; everything that writes runs in the report job below. + permissions: + contents: read + outputs: + exit_code: ${{ steps.lychee.outputs.exit_code }} + steps: + - name: Checkout + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # workflow_dispatch can run from any ref, but the report is + # repository-global. Always measure the default branch so a manual + # run from a topic branch cannot close a report that main warrants, + # or overwrite it with branch-only findings. + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - name: Check links + id: lychee + uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 + with: + # Restricted to http(s) on purpose. Relative links resolve against + # the assembled multi-repo site (docs/make-full-website.sh pulls + # docs/src/format/catalog, format/namespace, integrations/spark and + # friends from other repositories), so they cannot be verified from + # this checkout alone and would report as broken on every run. + # + # crates.io answers automated clients with 403/404 no matter the user + # agent, so it is excluded rather than reported every day. + args: >- + --scheme https + --scheme http + --no-progress + --max-retries 3 + --timeout 20 + --exclude '^https://crates\.io/' + 'docs/src/**/*.md' + format: markdown + output: ./lychee/out.md + jobSummary: true + # The report, not a red build, is the signal for broken links. The + # report job below still fails the run if the check itself breaks. + fail: false + + - name: Upload report + if: steps.lychee.outputs.exit_code == 2 + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: link-report + path: ./lychee/out.md + retention-days: 7 + + report: + name: Update report issue + needs: scan + runs-on: ubuntu-24.04 + # Deliberately no checkout: this job needs the report artifact and the + # issues API, not the repository contents. + permissions: + issues: write + env: + EXIT_CODE: ${{ needs.scan.outputs.exit_code }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - name: Classify checker result + # lychee exits 0 when every link resolves and 2 when links fail. + # Anything else (1 runtime, 3 bad config) means the check never + # produced a link verdict, which must surface as a failed run rather + # than be published as "broken documentation links". + run: | + case "$EXIT_CODE" in + 0|2) + echo "lychee exit code $EXIT_CODE" + ;; + *) + echo "::error::lychee exited with '$EXIT_CODE': the link check did not complete. Leaving the report issue untouched." + exit 1 + ;; + esac + + - name: Find existing report issue + id: report + # Matched on title alone, and through search rather than a listing: + # the issue action applies labels in a separate call after creating the + # issue, so a label filter misses a half-created report, and this + # repository has far more open issues than one listing page holds. + run: | + number=$(gh issue list --repo "$GITHUB_REPOSITORY" --state open \ + --search "in:title \"$REPORT_TITLE\" author:app/github-actions" \ + --limit 50 --json number,title \ + --jq "[.[] | select(.title == \"$REPORT_TITLE\") | .number] | first // empty") + echo "number=$number" >> "$GITHUB_OUTPUT" + + - name: Download report + if: env.EXIT_CODE == 2 + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: link-report + path: ./lychee + + - name: Compose report + if: env.EXIT_CODE == 2 + run: | + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + { + echo "Broken documentation links found by [\`$GITHUB_WORKFLOW\`]($run_url)." + echo + echo "This issue is rewritten by every scheduled run and closed automatically once all links resolve." + echo + echo "Entries can be false positives: some sites rate-limit or block automated clients while working fine in a browser. Confirm before editing the docs, and add persistent offenders to \`--exclude\` in \`.github/workflows/docs-link-check.yml\`." + echo + cat ./lychee/out.md + } > ./lychee/issue.md + + - name: Report broken links + if: env.EXIT_CODE == 2 + uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0 + with: + # Empty on the first failing run, which creates the issue; afterwards + # the same issue is updated in place. + issue-number: ${{ steps.report.outputs.number }} + title: ${{ env.REPORT_TITLE }} + content-filepath: ./lychee/issue.md + labels: documentation + + - name: Close report issue once links are healthy + if: env.EXIT_CODE == 0 && steps.report.outputs.number != '' + env: + ISSUE_NUMBER: ${{ steps.report.outputs.number }} + run: | + run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" + gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \ + --comment "All documentation links resolved in [the latest run]($run_url)." diff --git a/.github/workflows/file_verification.yml b/.github/workflows/file_verification.yml index 94f36443ba8..425cfc12196 100644 --- a/.github/workflows/file_verification.yml +++ b/.github/workflows/file_verification.yml @@ -8,20 +8,20 @@ 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@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" - - name: Install Build Requirements - run: | - sudo apt update - sudo apt install -y protobuf-compiler + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Validate AWS Credentials run: | @@ -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 }} @@ -51,8 +51,8 @@ jobs: run: | python -m venv venv source venv/bin/activate - pip install pyarrow maturin - maturin develop --release + pip install pyarrow maturin==1.13.3 uv + maturin develop --uv --release - name: Test Lance File Write Read Round Trip run: | diff --git a/.github/workflows/format-vote-gate.yml b/.github/workflows/format-vote-gate.yml new file mode 100644 index 00000000000..f771948636b --- /dev/null +++ b/.github/workflows/format-vote-gate.yml @@ -0,0 +1,96 @@ +name: Format spec vote gate + +# Structurally enforces the PMC vote required for Lance format-specification +# changes (see https://lance.org/community/voting/). The path labeler +# (.github/labeler-area.yml) applies the `format-change` label to PRs that touch +# the persisted format protos or `docs/src/format/**`; execution-only wire +# schemas are excluded. This gate reads that label and blocks merging until the +# PR has 3 binding +1 votes from PMC members (PR approvals, excluding the +# author) with at least one of them on the head commit, has no outstanding veto +# (a PMC "Request changes" review), and the 72-hour voting period has elapsed. +# That period starts once the PR is labeled and ready for review, and pauses +# over weekends. Approvals carry over across pushes; the member who approves the +# head commit is vouching that nothing substantive changed since the others +# voted. +# +# That carry-over depends on a branch-protection setting the gate cannot read +# with its token: turning on "Dismiss stale pull request approvals when new +# commits are pushed" makes GitHub report the earlier approvals as DISMISSED, +# which this gate does not count. The gate then quietly reverts to requiring all +# 3 approvals on the head commit — it fails closed, but anyone wondering why +# should look there first. +# +# The gate publishes its verdict as the `format-spec-vote` commit status on the +# PR head. To make it a merge blocker, an org admin must add `format-spec-vote` +# as a required status check in the branch protection rules for `main` (and any +# release branches). The status is posted on *every* PR — success immediately +# for non-format PRs — so a required check is never left pending forever. +# +# A PMC member may waive a trivial edit (typo, wording, formatting) by applying +# the `format-waived` label. +# +# Uses pull_request_target so the token can post statuses/comments on fork-based +# PRs. It never checks out or executes PR code: it reads the trusted base +# checkout (for the PMC roster and this script) and queries the API only. +# +# There is deliberately no `pull_request_review` trigger. A run triggered by a +# fork PR's review event gets a read-only GITHUB_TOKEN no matter what the +# `permissions` block below asks for, so it cannot post the status — and format +# proposals from non-committers are exactly the fork-PR case. Approvals are +# therefore picked up by the `schedule` sweep instead, or by a manual +# `workflow_dispatch` run for a voter who does not want to wait for it. The +# gate's PR comment links to that dispatch page. + +on: + pull_request_target: + types: + - opened + - reopened + - synchronize + - labeled + - unlabeled + # The voting clock starts when a PR leaves draft, so both draft + # transitions have to re-evaluate the gate. + - ready_for_review + - converted_to_draft + schedule: + # Re-evaluate open format-change PRs so votes and the voting-period clock are + # re-checked when no PR event fires: reviews land without one (see the note + # above), and the period routinely elapses days after the third approval. + # Every 15 minutes, because this is the only path that observes a vote. + - cron: "*/15 * * * *" + workflow_dispatch: + inputs: + pr: + description: "PR number to re-check (blank = every open format-change PR)" + required: false + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }} + cancel-in-progress: true + +permissions: + contents: read + pull-requests: write + issues: write + statuses: write + +jobs: + gate: + name: Evaluate format spec vote + runs-on: ubuntu-latest + steps: + - name: Checkout base + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Install dependencies + run: pip install PyGithub PyYAML + - name: Evaluate vote + run: python ci/format_vote_gate.py + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GATE_PR: ${{ inputs.pr }} diff --git a/.github/workflows/issue-labeler.yml b/.github/workflows/issue-labeler.yml new file mode 100644 index 00000000000..36f5a9cdbca --- /dev/null +++ b/.github/workflows/issue-labeler.yml @@ -0,0 +1,29 @@ +name: Issue Labeler + +# Applies bug / feature / performance labels to issues based on their title and +# body. This complements the .github/ISSUE_TEMPLATE forms, which only apply +# labels for issues opened through the web UI; this catches issues opened via +# `gh issue create`, the REST API, or an agent, which bypass templates. +# +# Issues never originate from a fork, so unlike the PR labelers this uses a +# plain `issues` trigger (no pull_request_target) with a scoped GITHUB_TOKEN. +on: + issues: + types: [opened, edited] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true + +jobs: + label: + name: Apply issue labels + permissions: + issues: write + runs-on: ubuntu-latest + steps: + - uses: srvaroa/labeler@bf262763a8a8e191f5847873aecc0f29df84f957 # v1.14.0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + config_path: .github/labeler-issues.yml diff --git a/.github/workflows/java-publish.yml b/.github/workflows/java-publish.yml index 30d07658d17..c2f0c7f6deb 100644 --- a/.github/workflows/java-publish.yml +++ b/.github/workflows/java-publish.yml @@ -24,23 +24,40 @@ on: required: false type: string +permissions: + contents: read + jobs: - linux-arm64: - name: Build on Linux Arm64 - runs-on: warp-ubuntu-latest-arm64-8x + build-linux: + name: Build on Linux ${{ matrix.arch }} + runs-on: ${{ matrix.runner }} timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - arch: x86-64 + runner: ubuntu-24.04 + docker_platform: linux/amd64 + protoc_arch: x86_64 + artifact: liblance_jni_linux_x86_64.zip + - arch: arm64 + runner: ubuntu-24.04-arm64-8x + docker_platform: linux/arm64 + protoc_arch: aarch_64 + artifact: liblance_jni_linux_arm_64.zip 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 + - name: Build and run in Debian 10 container run: | - docker run --platform linux/arm64 -v ${{ github.workspace }}:/workspace -w /workspace debian:10 bash -c " + docker run --platform ${{ matrix.docker_platform }} -v ${{ github.workspace }}:/workspace -w /workspace debian:10 bash -c " set -ex # Update sources.list to use archive repositories for Debian 10 (EOL) @@ -78,7 +95,7 @@ jobs: unzip # https://github.com/databendlabs/databend/issues/8035 - PROTOC_ZIP=protoc-3.15.0-linux-aarch_64.zip + PROTOC_ZIP=protoc-3.15.0-linux-${{ matrix.protoc_arch }}.zip curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.15.0/\$PROTOC_ZIP unzip -o \$PROTOC_ZIP -d /usr/local rm -f \$PROTOC_ZIP @@ -95,107 +112,50 @@ jobs: export CXX=clang++ ldd --version - cargo build --release + cargo build --release --locked " - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: liblance_jni_linux_arm_64.zip + name: ${{ matrix.artifact }} path: java/lance-jni/target/release/liblance_jni.so retention-days: 1 if-no-files-found: error - linux-x86: - name: Build on Linux x86-64 - runs-on: ubuntu-24.04 + build-macos: + name: Build on MacOS Arm64 + runs-on: warp-macos-14-arm64-6x 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 - - name: Check glibc version outside docker - run: ldd --version - - name: Build and run in Debian 10 X86-64 container - run: | - docker run --platform linux/amd64 -v ${{ github.workspace }}:/workspace -w /workspace debian:10 bash -c " - - set -ex - # Update sources.list to use archive repositories for Debian 10 (EOL) - echo 'deb http://archive.debian.org/debian/ buster main' > /etc/apt/sources.list - echo 'deb http://archive.debian.org/debian-security buster/updates main' >> /etc/apt/sources.list - echo 'deb http://archive.debian.org/debian/ buster-updates main' >> /etc/apt/sources.list - apt-get update - - DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends --assume-yes \ - apt-transport-https \ - ca-certificates \ - curl \ - gpg \ - bash \ - less \ - openssl \ - libssl-dev \ - pkg-config \ - libsqlite3-dev \ - libsqlite3-0 \ - libreadline-dev \ - git \ - cmake \ - dh-autoreconf \ - clang \ - g++ \ - libc++-dev \ - libc++abi-dev \ - libprotobuf-dev \ - libncurses5-dev \ - libncursesw5-dev \ - libudev-dev \ - libhidapi-dev \ - zip \ - unzip - - # https://github.com/databendlabs/databend/issues/8035 - PROTOC_ZIP=protoc-3.15.0-linux-x86_64.zip - curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v3.15.0/\$PROTOC_ZIP - unzip -o \$PROTOC_ZIP -d /usr/local - rm -f \$PROTOC_ZIP - protoc --version - - curl https://sh.rustup.rs -sSf | sh -s -- -y --default-toolchain stable - source \$HOME/.cargo/env - cargo --version - - cd java/lance-jni - - # https://github.com/rustls/rustls/issues/1967 - export CC=clang - export CXX=clang++ - ldd --version - - cargo build --release - " - - uses: actions/upload-artifact@v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - uses: Homebrew/actions/setup-homebrew@50b8c2ab4a835c38897ed2c56c293b07167c0b59 # master 2026-03-07 + - name: Install dependencies + run: brew install protobuf + - name: Build native lib + working-directory: java/lance-jni + run: cargo build --release --locked + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: - name: liblance_jni_linux_x86_64.zip - path: java/lance-jni/target/release/liblance_jni.so + name: liblance_jni_darwin_aarch64.zip + path: java/lance-jni/target/release/liblance_jni.dylib retention-days: 1 if-no-files-found: error - macos-arm64: - name: Build on MacOS Arm64 and release - runs-on: warp-macos-14-arm64-6x - timeout-minutes: 60 + publish: + name: Publish Java packages + runs-on: ubuntu-latest + timeout-minutes: 30 needs: - - linux-arm64 - - linux-x86 + - build-linux + - build-macos steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ inputs.ref || github.ref }} - - uses: Swatinem/rust-cache@v2 - name: Set up Java 11 - uses: actions/setup-java@v4 + uses: actions/setup-java@c1e323688fd81a25caa38c78aa6df2d33d3e20d9 # v4 with: distribution: corretto java-version: 11 @@ -205,18 +165,16 @@ jobs: server-password: SONATYPE_TOKEN gpg-private-key: ${{ secrets.GPG_PRIVATE_KEY }} gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }} - - uses: Homebrew/actions/setup-homebrew@master - - name: Install dependencies - run: | - brew install protobuf - brew install gpg - - name: Download artifact - uses: actions/download-artifact@v4 + - name: Download artifacts + 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 + mkdir -p ./java/target/classes/nativelib/linux-x86-64 \ + ./java/target/classes/nativelib/linux-aarch64 \ + ./java/target/classes/nativelib/darwin-aarch64 cp ./liblance_jni_linux_x86_64.zip/liblance_jni.so ./java/target/classes/nativelib/linux-x86-64/liblance_jni.so cp ./liblance_jni_linux_arm_64.zip/liblance_jni.so ./java/target/classes/nativelib/linux-aarch64/liblance_jni.so + cp ./liblance_jni_darwin_aarch64.zip/liblance_jni.dylib ./java/target/classes/nativelib/darwin-aarch64/liblance_jni.dylib - name: Set github run: | git config --global user.email "Lance Github Runner" @@ -227,7 +185,7 @@ jobs: inputs.mode == 'dry_run' working-directory: java run: | - mvn --batch-mode -DskipTests -Drust.release.build=true package + mvn --batch-mode -DskipTests -Dskip.build.jni=true package - name: Publish with Java 11 if: | github.event_name == 'release' || @@ -237,20 +195,32 @@ jobs: echo "use-agent" >> ~/.gnupg/gpg.conf echo "pinentry-mode loopback" >> ~/.gnupg/gpg.conf export GPG_TTY=$(tty) - mvn --batch-mode -DskipTests -Drust.release.build=true -DpushChanges=false -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} deploy -P deploy-to-ossrh -P shade-jar + mvn --batch-mode -DskipTests -Dskip.build.jni=true -DpushChanges=false -Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }} deploy -P deploy-to-ossrh env: SONATYPE_USER: ${{ secrets.SONATYPE_USER }} SONATYPE_TOKEN: ${{ secrets.SONATYPE_TOKEN }} + - name: Upload Java packages to R2 + if: github.event_name == 'release' || inputs.mode == 'release' + run: | + unzip -q java/target/central-publishing/central-bundle.zip -d "$RUNNER_TEMP/maven" + aws s3 cp "$RUNNER_TEMP/maven/" "s3://$R2_BUCKET/" \ + --recursive --endpoint-url "$R2_ENDPOINT" --only-show-errors + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: auto + R2_ENDPOINT: ${{ vars.R2_ENDPOINT }} + R2_BUCKET: ${{ vars.R2_BUCKET }} report-failure: name: Report Workflow Failure runs-on: ubuntu-latest - needs: [linux-arm64, linux-x86, macos-arm64] + needs: [build-linux, build-macos, publish] if: always() && (github.event_name == 'release' || github.event_name == 'workflow_dispatch') permissions: 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..07b5a9a86aa 100644 --- a/.github/workflows/java.yml +++ b/.github/workflows/java.yml @@ -20,63 +20,68 @@ env: CARGO_TERM_COLOR: always RUST_BACKTRACE: "1" +permissions: + contents: read + jobs: rust-clippy-fmt: runs-on: ubuntu-24.04 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 java/lance-jni -> ../target/rust-maven-plugin/lance-jni - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev - - uses: rui314/setup-mold@v1 - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 - name: Run cargo fmt working-directory: java/lance-jni run: cargo fmt --check - name: Rust Clippy working-directory: java/lance-jni - run: cargo clippy --all-targets -- -D warnings + run: cargo clippy --all-targets --locked -- -D warnings build-and-test-java: - runs-on: warp-ubuntu-latest-x64-4x + runs-on: ubuntu-24.04-4x timeout-minutes: 60 strategy: matrix: - java-version: [11, 17, 21] + java-version: [11, 17, 21, 25] name: Build and Test with Java ${{ matrix.java-version }} steps: - name: Checkout repository - uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc # 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 - - name: Install cargo-llvm-cov - uses: taiki-e/install-action@cargo-llvm-cov - - uses: Swatinem/rust-cache@v2 + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - 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 }} cache: "maven" + # Skip the format gate on Java 25: google-java-format 1.22.0 (pinned via + # spotless) reaches into com.sun.tools.javac internals and throws + # NoSuchMethodError on JDK 25's javac. No google-java-format release runs + # on JDK 25 without also dropping JDK 11/17 support, so we exclude 25 from + # the (JDK-independent, already-enforced-on-11/17/21) style check instead. - name: Running code style check with Java ${{ matrix.java-version }} + if: matrix.java-version != '25' working-directory: java run: | mvn spotless:check @@ -88,4 +93,7 @@ jobs: env: LANCE_INTEGRATION_TEST: "1" run: | - mvn install + # spotless is also bound into the build lifecycle, so on Java 25 the + # format gate must be skipped here too (-Dspotless.skip=true), not just + # in the standalone step above. + mvn install ${{ matrix.java-version == '25' && '-Dspotless.skip=true' || '' }} diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml new file mode 100644 index 00000000000..bb46af89fb5 --- /dev/null +++ b/.github/workflows/labeler.yml @@ -0,0 +1,24 @@ +name: Area Labeler + +on: + # pull_request_target is required so labels can be applied to PRs from forks. + pull_request_target: + types: [opened, synchronize, reopened] + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + label: + name: Apply area labels + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - uses: actions/labeler@8558fd74291d67161a8a78ce36a881fa63b766a9 # v5.0.0 + with: + configuration-path: .github/labeler-area.yml + # Match dotfiles/dot-folders (e.g. .github/**) for A-ci. + dot: true diff --git a/.github/workflows/license-header-check.yml b/.github/workflows/license-header-check.yml index 1e321687681..2e6e26bbbeb 100644 --- a/.github/workflows/license-header-check.yml +++ b/.github/workflows/license-header-check.yml @@ -12,14 +12,35 @@ on: - rust/** - python/** - protos/** +permissions: + contents: read + jobs: build: 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 + env: + # Pin to a specific release and download the asset directly from the + # release CDN. This avoids the flaky dynamic "latest" tag lookup in the + # upstream install.sh (it fetches the releases page and parses tag_name, + # which intermittently returns empty and fails the job). + LHC_VERSION: "1.5.0" + run: | + set -euo pipefail + mkdir -p ./bin + base="https://github.com/lluissm/license-header-checker/releases/download/v${LHC_VERSION}" + asset="license-header-checker_${LHC_VERSION}_linux_amd64.tar.gz" + curl -sfSL "${base}/${asset}" -o "/tmp/${asset}" + curl -sfSL "${base}/checksums.txt" -o /tmp/lhc-checksums.txt + # Verify the download against the release checksums before extracting so a + # corrupted or tampered asset cannot be executed. This restores the + # integrity check the upstream install.sh performed via hash_sha256_verify. + (cd /tmp && sha256sum -c --ignore-missing lhc-checksums.txt) + tar -xzf "/tmp/${asset}" -C ./bin license-header-checker + rm -f "/tmp/${asset}" /tmp/lhc-checksums.txt - 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..b980d5ef1d5 100644 --- a/.github/workflows/nightly_run.yml +++ b/.github/workflows/nightly_run.yml @@ -5,13 +5,18 @@ 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' + if: github.repository == 'lance-format/lance' + permissions: + actions: write 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 @@ -19,17 +24,17 @@ jobs: jumbo-tests: # jumbo tests need more resources - runs-on: warp-ubuntu-latest-x64-8x - if: github.repository == 'lancedb/lance' + runs-on: ubuntu-24.04-8x + if: github.repository == 'lance-format/lance' timeout-minutes: 60 permissions: contents: read steps: - - uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Run Jumbo String/Binary Tests run: | echo "Running jumbo tests for Lance 2.0 and 2.1..." @@ -39,3 +44,69 @@ jobs: # Run only the jumbo tests in release mode with limited parallelism # to avoid OOM issues (these tests use >5GiB memory each) cargo test --release --package lance-encoding -- --ignored jumbo --test-threads=1 + + # Cross-version index maintenance-sequence search (see python/tests/compat/compat_sequence.py). + # Ages an index under the latest release of each of the two previous majors and exercises it + # under this commit, searching op sequences for panics or correctness divergence. The search is + # slow and unbounded in depth, so it runs nightly with a generous timeout rather than blocking + # merges; the manual escape hatch for arbitrary ref pairs lives in compat-pair.yml. + compat-sequence: + if: github.repository == 'lance-format/lance' + timeout-minutes: 360 + runs-on: ubuntu-24.04 + name: Index Sequence Compat + defaults: + run: + shell: bash + working-directory: python + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: 3.13 + # Toolchain to build the reader (HEAD) wheel from source -- no upstream build job to + # borrow a wheel from here, unlike the old post-merge path in python.yml. + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + workspaces: python + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - name: Install host deps + run: pip install pytest pytest-xdist pyarrow packaging maturin + # Build HEAD once and feed it to the suite as the prebuilt reader, so the two writer + # passes below don't each recompile it. + - name: Build reader wheel (HEAD) + run: python -m maturin build --release -m Cargo.toml --out wheels + # Age under each of the two previous majors (writer wheels from PyPI) and read under + # this commit's freshly built wheel. Length 5 is the shallowest depth that reaches the + # ENT-1662 sequence; -n oversubscribes cores since the work is I/O-bound. + - name: Run sequence search (previous two majors -> HEAD) + env: + COMPAT_MAX_LENGTH: "5" + COMPAT_TO_REF: HEAD + COMPAT_PREBUILT_REF: HEAD + run: | + set -euo pipefail + wheel=$(ls "$PWD"/wheels/pylance-*.whl | head -1) + refs=$(PYTHONPATH=python/tests python -c " + from compat.compat_decorator import pylance_stable_versions + latest = {} + for v in pylance_stable_versions(): + latest[v.major] = v + print(' '.join(str(latest[m]) for m in sorted(latest)[-2:])) + ") + echo "reader wheel: $wheel" + echo "writer refs : $refs" + status=0 + for from_ref in $refs; do + echo "::group::$from_ref -> HEAD" + COMPAT_FROM_REF="$from_ref" COMPAT_PREBUILT_WHEEL="$wheel" \ + python -m pytest python/tests/compat/test_index_sequence.py \ + --run-compat -n "$(( $(nproc) * 4 ))" -v --no-header || status=1 + echo "::endgroup::" + done + exit $status diff --git a/.github/workflows/notebook.yml b/.github/workflows/notebook.yml index 6efec33e92c..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 @@ -24,12 +27,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/proto-lint.yml b/.github/workflows/proto-lint.yml new file mode 100644 index 00000000000..1485d4e4741 --- /dev/null +++ b/.github/workflows/proto-lint.yml @@ -0,0 +1,30 @@ +name: Protobuf lint +on: + push: + branches: + - main + - release/** + pull_request: + branches: + - main + - release/** + paths: + - "**/*.proto" + - ci/check_proto_comments.py + - .github/workflows/proto-lint.yml +permissions: + contents: read + +jobs: + comment-style: + name: Multi-line comment style + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.12" + - name: Check .proto comment style + run: python ci/check_proto_comments.py diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml index 0ef837e124d..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 @@ -27,7 +30,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 +83,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..77c76d6fc69 100644 --- a/.github/workflows/pypi-publish.yml +++ b/.github/workflows/pypi-publish.yml @@ -26,13 +26,16 @@ on: - ".github/workflows/build_windows_wheel/**" - ".github/workflows/upload_wheel/**" +permissions: + contents: read + jobs: linux: timeout-minutes: 60 name: Python Linux 3.${{ matrix.python-minor-version }} ${{ matrix.config.platform }} manylinux${{ matrix.config.manylinux }} strategy: matrix: - python-minor-version: ["9"] + python-minor-version: ["10"] config: - platform: x86_64 manylinux: "2_17" @@ -45,20 +48,20 @@ 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@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 +85,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 @@ -98,20 +101,20 @@ jobs: runs-on: ${{ matrix.config.runner }} strategy: matrix: - python-minor-version: ["9"] + python-minor-version: ["10"] config: - target: aarch64-apple-darwin runner: warp-macos-14-arm64-6x 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 +136,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 @@ -146,18 +149,18 @@ 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"] + python-minor-version: ["10"] 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 +183,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 +203,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..4802e6c8b06 100644 --- a/.github/workflows/python.yml +++ b/.github/workflows/python.yml @@ -11,6 +11,8 @@ on: - release/** paths: - Cargo.* + - .cargo/config.toml + - clippy.toml - python/** - rust/** - protos/** @@ -19,6 +21,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 @@ -39,7 +44,7 @@ env: jobs: lint: timeout-minutes: 45 - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-4x" defaults: run: shell: bash @@ -49,31 +54,31 @@ 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 }} - 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: | ruff format --check --diff python ruff check python pyright - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Lint Rust run: | ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v protoc | sort | uniq | paste -s -d "," -` @@ -84,99 +89,188 @@ jobs: python -m venv venv source venv/bin/activate pip install torch tqdm --index-url https://download.pytorch.org/whl/cpu - pip install maturin - maturin develop --profile ci --locked --extras tests,ray + pip install maturin==1.13.3 uv + maturin develop --uv --profile ci --locked --extras tests,ray - name: Run doctest run: | source venv/bin/activate pytest --doctest-modules python/lance + + python-rust-test: + timeout-minutes: 30 + name: pylance crate Rust tests + runs-on: "ubuntu-24.04-4x" + defaults: + run: + shell: bash + working-directory: python + env: + # Need up-to-date compilers for kernels + CC: clang-18 + CXX: clang++-18 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: 3.11 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + workspaces: python + prefix-key: ${{ env.CACHE_PREFIX }} + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + # The default `extension-module` feature leaves libpython unlinked, which the + # host interpreter resolves at load time but a test binary cannot. Disabling it + # links libpython so these tests run instead of only compiling. + - name: Test Rust + run: cargo test --profile ci --locked --no-default-features + + linux-wheel: + timeout-minutes: 45 + name: Python Linux x86_64 wheel + runs-on: "ubuntu-24.04-4x" + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + fetch-depth: 0 + lfs: true + - name: Set up Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: "3.10" + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + with: + workspaces: python + prefix-key: ${{ env.CACHE_PREFIX }} + # python/Cargo.toml enables abi3-py310, so this wheel is reusable by + # every CPython version in the Linux test matrix. + - name: Build cp310 ABI3 wheel + uses: ./.github/workflows/build_linux_wheel + with: + python-minor-version: "10" + args: "--profile ci" + - name: Upload wheel + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: linux-x86_64-wheel + path: python/target/wheels/pylance-*.whl + if-no-files-found: error + + linux-memtest: + timeout-minutes: 15 + name: Python Linux x86_64 memtest + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 + with: + cache: "false" + rustflags: "" + - name: Build memtest + working-directory: memtest + run: cargo build --release + - name: Upload memtest + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: linux-x86_64-memtest + path: memtest/target/release/libmemtest.so + if-no-files-found: error + linux: + needs: [linux-wheel, linux-memtest] timeout-minutes: 45 strategy: matrix: - python-minor-version: ["9", "13"] + python-minor-version: ["10", "13", "14"] 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 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 + - name: Download wheel + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - workspaces: python - prefix-key: ${{ env.CACHE_PREFIX }} - - uses: ./.github/workflows/build_linux_wheel + name: linux-x86_64-wheel + path: python/target/wheels + - name: Download memtest + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - args: "--profile ci" + name: linux-x86_64-memtest + path: memtest/python/memtest - uses: ./.github/workflows/run_tests with: memtest: true - - name: Upload wheels as artifacts - if: ${{ matrix.python-minor-version == '13' }} - uses: actions/upload-artifact@v4 - with: - name: linux-wheels - path: python/target/wheels/pylance-*.whl compat: - needs: linux + needs: linux-wheel timeout-minutes: 60 - runs-on: ubuntu-24.04 + runs-on: ubuntu-24.04-8x name: Compatibility Tests defaults: run: 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 + python-version: 3.14 + - name: Download wheel + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: linux-wheels - path: python/wheels + name: linux-x86_64-wheel + path: python/target/wheels - name: Install dependencies run: | - pip install $(ls wheels/pylance-*.whl)[tests,ray] + pip install $(ls target/wheels/pylance-*.whl)[tests,ray] + # Nearly every test here shells out to an old pylance in its own venv, so the + # job spends most of its time waiting on subprocesses rather than on CPU. + # Oversubscribing cores follows what nightly_run.yml already does with this + # suite. Leaving COMPAT_TEMP_VENV unset is what makes that pay off: the venvs + # then land in the shared cache directory, where VenvFactory's flock lets all + # workers reuse one venv per version instead of each building its own. - name: Run compatibility tests run: | - make compattest - env: - COMPAT_TEMP_VENV: 1 + make compattest PYTEST_WORKERS=$(( $(nproc) * 2 )) linux-arm: timeout-minutes: 45 - runs-on: warp-ubuntu-latest-arm64-4x - name: Python Linux 3.13 ARM + runs-on: ubuntu-24.04-arm64-4x + name: Python Linux 3.14 ARM defaults: run: 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 + python-version: 3.14 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -192,22 +286,22 @@ jobs: - uses: ./.github/workflows/run_tests mac: timeout-minutes: 45 - name: Python macOS 3.13 ARM + name: Python macOS 3.14 ARM runs-on: "warp-macos-14-arm64-6x" defaults: run: 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 + python-version: "3.14" + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 with: workspaces: python prefix-key: ${{ env.CACHE_PREFIX }} @@ -219,18 +313,18 @@ jobs: skip-torch: "true" windows: - runs-on: windows-latest + runs-on: windows-latest-4x timeout-minutes: 90 defaults: run: 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 }} @@ -239,28 +333,27 @@ jobs: args: "--profile ci" - uses: ./.github/workflows/run_tests aws-integtest: + needs: linux-wheel timeout-minutes: 45 - runs-on: "warp-ubuntu-latest-x64-4x" + runs-on: "ubuntu-24.04-4x" defaults: run: 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 + - name: Download wheel + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - workspaces: python - prefix-key: ${{ env.CACHE_PREFIX }} - - uses: ./.github/workflows/build_linux_wheel - with: - args: "--profile ci" + name: linux-x86_64-wheel + path: python/target/wheels - name: Install dependencies run: | pip install ray[data] diff --git a/.github/workflows/recurring-tests.yml b/.github/workflows/recurring-tests.yml deleted file mode 100644 index df99d6513ef..00000000000 --- a/.github/workflows/recurring-tests.yml +++ /dev/null @@ -1,121 +0,0 @@ -name: Recurring Tests - -on: - schedule: - - cron: "0 0 * * 0" # Runs at 00:00 UTC every Sunday - workflow_dispatch: - -jobs: - get-pylance-versions: - runs-on: ubuntu-latest - outputs: - matrix: ${{ steps.set-matrix.outputs.matrix }} - steps: - - name: Fetch latest 2 stable pylance versions - id: set-matrix - run: | - # Get all versions from PyPI - pypi_versions=$(curl -s https://pypi.org/pypi/pylance/json | jq -r '.releases | keys_unsorted | .[]') - - # Use only PyPI versions - all_versions=$(echo -e "$pypi_versions" | sort -u -V) - - # Get latest 2 stable versions - stable_versions=$(echo "$all_versions" | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -n 2) - - # Create matrix array - matrix_versions=() - while IFS= read -r version; do - if [ -n "$version" ]; then - matrix_versions+=("$version") - fi - done <<< "$stable_versions" - - # Create JSON array manually - json_array="[" - for i in "${!matrix_versions[@]}"; do - if [ $i -gt 0 ]; then - json_array="$json_array," - fi - json_array="$json_array\"${matrix_versions[$i]}\"" - done - json_array="$json_array]" - - matrix="{\"pylance-version\": $json_array}" - echo "matrix=$matrix" >> $GITHUB_OUTPUT - - # This job is used to test the recurring tests on the latest 2 stable versions of Lance, - # and the back-compat of the recurring tests. - recurring-linux: - needs: get-pylance-versions - name: "Recurring: Linux (Pylance ${{ matrix.pylance-version }})" - runs-on: ubuntu-24.04 - timeout-minutes: 7200 - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.get-pylance-versions.outputs.matrix) }} - defaults: - run: - shell: bash - working-directory: python - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - lfs: true - - name: Install protobuf - run: | - sudo apt update - sudo apt install -y protobuf-compiler - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - name: Install dependencies - working-directory: python - shell: bash - run: | - pip install -e ".[tests]" - - uses: Swatinem/rust-cache@v2 - with: - workspaces: python - - name: Install Pylance - run: | - pip install pylance==${{ matrix.pylance-version }} - - name: Run recurring tests - id: run_recurring_tests - run: pytest -vvv -s python/tests/recurring/test_recurring.py - - name: Upgrade Pylance - run: pip install -e ".[tests]" - - name: Run recurring tests again - run: pytest -vvv -s python/tests/recurring/test_recurring.py - - # This job is used to test the recurring tests on the main branch. - recurring-linux-fresh-main: - name: "Recurring: Linux (main)" - runs-on: ubuntu-24.04 - timeout-minutes: 7200 - defaults: - run: - shell: bash - working-directory: python - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - lfs: true - - name: Install protobuf - run: | - sudo apt update - sudo apt install -y protobuf-compiler - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.13" - - uses: Swatinem/rust-cache@v2 - with: - workspaces: python - - name: Install Lance - run: pip install -e ".[tests]" - - name: Run recurring tests - run: pytest -vvv -s python/tests/recurring/test_recurring.py diff --git a/.github/workflows/run_tests/action.yml b/.github/workflows/run_tests/action.yml index 64d009f7a2d..1a6786f84e2 100644 --- a/.github/workflows/run_tests/action.yml +++ b/.github/workflows/run_tests/action.yml @@ -11,14 +11,18 @@ inputs: default: "false" memtest: required: false - description: "Run memtest" + description: "Install and preload the prebuilt memtest library" default: "false" + pytest-workers: + required: false + description: "pytest-xdist worker count; 'auto' matches the runner's cores" + default: "auto" runs: using: "composite" 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 @@ -36,9 +40,10 @@ runs: if: inputs.memtest == 'true' shell: bash run: | - make build-release - echo "LD_PRELOAD=$(lance-memtest)" >> $GITHUB_ENV + test -f python/memtest/libmemtest.so + pip install -e . + echo "LD_PRELOAD=$(lance-memtest)" >> "$GITHUB_ENV" - name: Run python tests shell: bash working-directory: python - run: make test + run: make test PYTEST_WORKERS=${{ inputs.pytest-workers }} diff --git a/.github/workflows/rust-benchmark.yml b/.github/workflows/rust-benchmark.yml index 7f26fb4e305..6877fc89d7b 100644 --- a/.github/workflows/rust-benchmark.yml +++ b/.github/workflows/rust-benchmark.yml @@ -35,12 +35,12 @@ jobs: runs-on: warp-ubuntu-latest-arm64-8x timeout-minutes: 120 steps: - - name: Apt-get - run: | - sudo apt update - sudo apt install -y protobuf-compiler + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - 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 17eae173579..41b482e3c83 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -12,9 +12,18 @@ on: - rust/** - protos/** - .github/workflows/rust.yml + - rust-toolchain.toml - Cargo.toml - Cargo.lock + - python/Cargo.lock + - java/lance-jni/Cargo.lock + - .cargo/config.toml + - clippy.toml - deny.toml + - ci/check_dependency_age.py + +permissions: + contents: read concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} @@ -31,36 +40,56 @@ 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 run: cargo fmt -- --check + package: + name: Check crates are publishable + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: actions-rust-lang/setup-rust-toolchain@a0b538fa0b742a6aa35d6e2c169b4bd06d225a98 # v1 + # Catches manifest problems that only surface at publish time (missing + # readme/license files, path dependencies without a version, excluded + # files a crate needs). `--no-verify` skips building each packaged crate, + # which the other jobs already cover. Keep the excludes in sync with + # .github/workflows/cargo-publish.yml. + - name: Package crates + run: | + cargo package --workspace --no-verify \ + --exclude lance-arrow-stats \ + --exclude lance-arrow-scalar \ + --all-features + rustdoc: runs-on: ubuntu-24.04 timeout-minutes: 30 steps: - - uses: actions/checkout@v4 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Check documentation run: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps clippy: permissions: + contents: read checks: write runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v4 - - uses: Swatinem/rust-cache@v2 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Get features run: | ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | sort | uniq | paste -s -d "," -` @@ -72,15 +101,19 @@ 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 + # Runs after cargo-deny so it can reuse the index cargo-deny already + # fetched. The script falls back to the network when that misses. + - name: Check dependency age + run: python3 ci/check_dependency_age.py linux-build: - runs-on: "warp-ubuntu-latest-x64-4x" - timeout-minutes: 60 + runs-on: "ubuntu-24.04-8x" + timeout-minutes: 75 env: # Need up-to-date compilers for kernels CC: clang @@ -88,106 +121,90 @@ jobs: # Treat warnings as errors to catch issues early RUSTFLAGS: "-D warnings" steps: - - uses: actions/checkout@v4 - # pin the toolchain version to avoid surprises + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - name: Setup rust toolchain run: | - rustup toolchain install nightly - rustup default nightly - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - - name: Install dependencies + rustup toolchain install stable + rustup default stable + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - name: Install cargo-nextest + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # nextest + with: + tool: nextest + - name: Build tests run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev - - name: Start DynamodDB and S3 + ALL_FEATURES=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -) + cargo nextest run \ + --cargo-profile ci \ + --locked \ + --workspace \ + --features "${ALL_FEATURES}" \ + --no-run + - name: Start DynamoDB 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 - 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 - with: - token: ${{ secrets.CODECOV_TOKEN }} - codecov_yml_path: codecov.yml - files: coverage.codecov - flags: unittests - fail_ci_if_error: false + ALL_FEATURES=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -) + cargo nextest run \ + --cargo-profile ci \ + --locked \ + --workspace \ + --features "${ALL_FEATURES}" + linux-arm: - runs-on: warp-ubuntu-latest-arm64-4x + runs-on: ubuntu-24.04-arm64-8x 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 - - name: Install dependencies - run: | - sudo apt -y -qq update - sudo apt install -y protobuf-compiler libssl-dev pkg-config + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Build 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 "," -` + ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -` cargo test --profile ci --locked --features ${ALL_FEATURES} --no-run - name: Start DynamodDB and S3 run: docker compose -f docker-compose.yml up -d --wait - 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 "," -` + ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -` cargo test --profile ci --locked --features ${ALL_FEATURES} - query-integration-tests: - runs-on: warp-ubuntu-latest-x64-4x - timeout-minutes: 75 - env: - # 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 - - name: Setup rust toolchain - run: | - rustup toolchain install stable - rustup default stable - - uses: rui314/setup-mold@v1 - - uses: Swatinem/rust-cache@v2 - with: - cache-targets: false - cache-workspace-crates: true - - name: Install dependencies - run: | - sudo apt -y -qq update - sudo apt install -y protobuf-compiler libssl-dev pkg-config - - name: Build query integration tests - run: | - cargo build --locked -p lance --no-default-features --features fp16kernels,slow_tests --tests --test integration_tests - - name: Run query integration tests - 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 CC: clang CXX: clang++ steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Set up Rust + run: | + rustup update stable + rustup default stable # Remote cargo.lock to force a fresh build - name: Remove Cargo.lock run: rm -f Cargo.lock - - uses: rui314/setup-mold@v1 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - uses: rui314/setup-mold@725a8794d15fc7563f59595bd9556495c0564878 # v1 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Build all 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 "," -` + ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -` cargo build --profile ci --benches --features ${ALL_FEATURES} --tests mac-build: runs-on: warp-macos-14-arm64-6x @@ -200,7 +217,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: @@ -211,24 +228,30 @@ 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: Install cargo-nextest + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # nextest + with: + tool: nextest + # macOS runner capacity is the scarcest in CI, so this job is kept as short + # as it can be. nextest runs each test in its own process and schedules + # across all cores, rather than one test binary at a time. Benchmarks are + # only compile-checked here, which build-no-lock already does on Linux; + # nothing about bench compilation is macOS-specific. - name: Build tests run: | - cargo test --profile ci --locked --features fp16kernels,cli,dynamodb,substrait --no-run + cargo nextest run --cargo-profile ci --locked --features fp16kernels,cli,dynamodb,substrait --no-run - name: Run tests run: | - cargo test --profile ci --features fp16kernels,cli,dynamodb,substrait - - name: Check benchmarks - run: | - cargo check --profile ci --benches --features fp16kernels,cli,dynamodb,substrait + cargo nextest run --cargo-profile ci --features fp16kernels,cli,dynamodb,substrait windows-build: - runs-on: warp-windows-latest-x64-4x + runs-on: windows-latest-4x defaults: 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: | @@ -238,12 +261,108 @@ jobs: 7z x protoc.zip Add-Content $env:GITHUB_PATH "C:\protoc\bin" shell: powershell + - name: Install cargo-nextest + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # nextest + with: + tool: nextest + # Same reasoning as mac-build: nextest schedules across all cores instead + # of running one test binary at a time, and build-no-lock already + # compile-checks the benchmarks on Linux. - name: Build tests - run: cargo test --profile ci --locked --no-run + run: cargo nextest run --cargo-profile ci --locked --no-run - name: Run tests - run: cargo test --profile ci - - name: Check benchmarks - run: cargo check --profile ci --benches + run: cargo nextest run --cargo-profile ci + + qemu-pre-haswell: + # Verifies that lance-linalg's runtime SIMD dispatch still works + # correctly when the binary is built with the lower x86-64-v2 baseline + # (the legacy build path documented in CONTRIBUTING.md). Emulates a + # Nehalem CPU under qemu-user; catches any accidental AVX2/FMA + # instructions that leak past the runtime dispatch. + # + # The published-wheel default baseline (`target-cpu=haswell`) is set in + # `.cargo/config.toml`; this job overrides RUSTFLAGS for one job to + # exercise the legacy path without affecting any other build. + name: pre-Haswell SIGILL check (qemu Nehalem) + runs-on: ubuntu-24.04 + timeout-minutes: 60 + env: + CC: clang + CXX: clang++ + RUSTFLAGS: "-C target-cpu=x86-64-v2" + CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUNNER: "qemu-x86_64 -cpu Nehalem" + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + - name: Setup rust toolchain + run: | + rustup toolchain install stable + rustup default stable + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Restore QEMU 8.2.10 + id: qemu-cache + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: ${{ runner.temp }}/lance-qemu/8.2.10/qemu-x86_64 + key: qemu-user-8.2.10-x86_64-linux-user-${{ runner.os }}-${{ runner.arch }}-v1 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc + - name: Install QEMU build dependencies + if: steps.qemu-cache.outputs.cache-hit != 'true' + run: | + sudo apt update + sudo apt install -y \ + ninja-build \ + pkg-config \ + libglib2.0-dev \ + python3-venv \ + curl \ + xz-utils + - name: Build QEMU 8.2.10 + if: steps.qemu-cache.outputs.cache-hit != 'true' + run: | + QEMU_VERSION=8.2.10 + QEMU_ARCHIVE="qemu-${QEMU_VERSION}.tar.xz" + QEMU_BIN_DIR="${RUNNER_TEMP}/lance-qemu/${QEMU_VERSION}" + QEMU_SOURCE_DIR="${RUNNER_TEMP}/qemu-${QEMU_VERSION}" + + # Ubuntu 24.04 ships QEMU 8.2.2, which is affected by + # https://gitlab.com/qemu-project/qemu/-/issues/2170. + curl --fail --location --silent --show-error \ + "https://download.qemu.org/${QEMU_ARCHIVE}" \ + --output "${RUNNER_TEMP}/${QEMU_ARCHIVE}" + echo "37b4a643da8ed6015eef35f5d7f06e7259d9c95359965a0a98e9667c621ab2bb ${RUNNER_TEMP}/${QEMU_ARCHIVE}" \ + | sha256sum --check - + tar --extract \ + --file "${RUNNER_TEMP}/${QEMU_ARCHIVE}" \ + --directory "${RUNNER_TEMP}" + mkdir "${QEMU_SOURCE_DIR}/build" + ( + cd "${QEMU_SOURCE_DIR}/build" + ../configure --target-list=x86_64-linux-user --disable-docs + ninja qemu-x86_64 + ) + mkdir -p "${QEMU_BIN_DIR}" + install -m 0755 \ + "${QEMU_SOURCE_DIR}/build/qemu-x86_64" \ + "${QEMU_BIN_DIR}/qemu-x86_64" + - name: Add QEMU to PATH + run: | + QEMU_BIN_DIR="${RUNNER_TEMP}/lance-qemu/8.2.10" + echo "${QEMU_BIN_DIR}" >> "${GITHUB_PATH}" + QEMU_VERSION_OUTPUT="$("${QEMU_BIN_DIR}/qemu-x86_64" --version)" + echo "${QEMU_VERSION_OUTPUT}" + if [[ "${QEMU_VERSION_OUTPUT}" != *"version 8.2.10"* ]]; then + echo "Expected QEMU 8.2.10, got: ${QEMU_VERSION_OUTPUT}" >&2 + exit 1 + fi + - name: Build lance-linalg lib tests in release mode + run: | + cargo test --release -p lance-linalg --lib --no-run + - name: Run lance-linalg lib tests under qemu Nehalem + run: | + cargo test --release -p lance-linalg --lib msrv: # Check the minimum supported Rust version @@ -251,20 +370,20 @@ jobs: runs-on: ubuntu-24.04 strategy: matrix: - msrv: ["1.88.0"] # This should match up with rust-version in Cargo.toml + msrv: ["1.94.0"] # This should match up with rust-version in Cargo.toml env: # Need up-to-date compilers for kernels CC: clang CXX: clang++ steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: submodules: true - - uses: Swatinem/rust-cache@v2 - - name: Install dependencies - run: | - sudo apt update - sudo apt install -y protobuf-compiler libssl-dev + - uses: Swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 # v2 + - name: Install protoc + uses: taiki-e/install-action@66068bfca13dcb2ea07c3f613ca2836a37c755d5 # protoc + with: + tool: protoc - name: Install ${{ matrix.msrv }} run: | rustup toolchain install ${{ matrix.msrv }} @@ -272,5 +391,5 @@ jobs: env: RUSTUP_TOOLCHAIN: ${{ matrix.msrv }} 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 "," -` + ALL_FEATURES=`cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | .features | keys | .[]' | grep -v -e protoc | sort | uniq | paste -s -d "," -` cargo check --profile ci --workspace --tests --benches --features ${ALL_FEATURES} 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..52f52e66f39 100644 --- a/.github/workflows/typos.yml +++ b/.github/workflows/typos.yml @@ -5,13 +5,16 @@ on: - main - release/** +permissions: + contents: read + jobs: run: name: Spell Check with Typos 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/.gitignore b/.gitignore index ce58f8c89e2..b916613b9ce 100644 --- a/.gitignore +++ b/.gitignore @@ -105,10 +105,25 @@ test_data/venv **/*.profraw *.lance +!test_data/**/*.lance +!rust/lance-file/test_data/**/*.lance # Pytest benchmarks .benchmarks/ # Auto-built docs +docs/src/format/catalog/** docs/src/format/namespace/** +docs/src/format/layout.png +docs/src/integrations/duckdb/** +docs/src/integrations/huggingface/** +docs/src/integrations/ray/** docs/src/integrations/spark/** +docs/src/integrations/trino/** +docs/src/community/project-specific/.pages +docs/src/community/project-specific/lance/** +docs/src/community/project-specific/namespace.md +docs/src/community/project-specific/namespace-impls.md +docs/src/community/project-specific/ray.md +docs/src/community/project-specific/spark.md +docs/src/community/project-specific/trino.md diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 09c956152fe..cc0185627ae 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,3 +15,22 @@ repos: rev: v1.26.0 hooks: - id: typos + + # Catch a Cargo.toml change that wasn't reflected in the matching Cargo.lock. + # Runs fully offline (`--frozen` = `--locked` + `--offline`) and does not compile, + # so it stays fast. Checks all three lockfiles since a workspace dep change can + # touch the excluded python/ and java/ locks too. + - repo: local + hooks: + - id: cargo-lock-sync + name: Cargo.lock in sync with Cargo.toml (offline) + entry: bash -c 'for m in Cargo.toml python/Cargo.toml java/lance-jni/Cargo.toml; do cargo metadata --frozen --format-version 1 --manifest-path "$m" >/dev/null 2>&1 || { echo "Cargo.lock is out of date for $m. Refresh it (e.g. cargo check --manifest-path $m) and commit the updated lockfile."; exit 1; }; done' + language: system + files: '(^|/)Cargo\.(toml|lock)$' + pass_filenames: false + + - id: proto-comment-style + name: Multi-line .proto comments use /* ... */ + entry: python3 ci/check_proto_comments.py --fix + language: system + files: '\.proto$' diff --git a/.typos.toml b/.typos.toml index b3b5bc6c7d2..26c1eb7b8f8 100644 --- a/.typos.toml +++ b/.typos.toml @@ -1,5 +1,9 @@ [default] -extend-ignore-re = ["(?Rm)^.*(#|//)\\s*spellchecker:disable-line$"] +extend-ignore-re = [ + "(?Rm)^.*(#|//)\\s*spellchecker:disable-line$", + # Base64 bodies of PEM fixtures (certificates, keys) are not words. + "(?s)-----BEGIN [A-Z ]+-----.*?-----END [A-Z ]+-----", +] [default.extend-words] DNE = "DNE" @@ -13,11 +17,17 @@ rabit = "rabit" flate = "flate" Ines = "Ines" alph = "alph" +caf = "caf" [default.expect] nprobs = "nprobes" nprob = "nprobe" [files] -extend-exclude = ["notebooks/*.ipynb"] +extend-exclude = [ + "notebooks/*.ipynb", + "*_THIRD_PARTY_LICENSES.*", + "rust/lance-file/test_data/exact_versions/*.lance", + "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/AGENTS.md b/AGENTS.md index 7d862eecd2d..23a1b976b63 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,135 +1,95 @@ # AGENTS.md -This file provides guidance to coding agents collaborating on this repository. +Lance is a modern columnar data format optimized for ML workflows and datasets, providing high-performance random access, vector search, zero-copy automatic versioning, and ecosystem integrations. The vision is to become the de facto standard columnar data format for machine learning and large language models. -## Project Overview +Also see directory-specific guidelines: [rust/](rust/AGENTS.md) | [python/](python/AGENTS.md) | [java/](java/AGENTS.md) | [protos/](protos/AGENTS.md) | [docs/src/format/](docs/src/format/AGENTS.md) -Lance is a modern columnar data format optimized for ML workflows and datasets. It provides: +## File Format Stability and Compatibility -- High-performance random access -- Vector search -- Zero-copy, automatic versioning -- Ecosystem integrations +- Treat every file format marked stable as a durable compatibility contract. All changes to a stable format must preserve both backward and forward compatibility. +- Treat every file format marked unstable as disposable. It may change freely; do not add compatibility code, migrations, fallbacks, or tests for files written by earlier unstable revisions. +- Evaluate compatibility against the latest released stable version while continuing to honor all stable format contracts. Changes that exist only on the current branch or `main` are not compatibility constraints; do not compromise a cleaner or more complete design to preserve those intermediate states. -## Project Vision +### Legacy Compatibility Boundaries -The de facto standard columnar data format for machine learning and large language models. +- Treat formats and code paths that current writers no longer emit as frozen compatibility surfaces. Preserve their existing read behavior, but exclude them from new feature design unless legacy support is explicitly required. +- Implement new features in the current format and write paths. Do not extend legacy writers, retrofit new capabilities into legacy readers, or reuse legacy implementations as the foundation for new code. +- Avoid refactoring or otherwise modifying legacy code during feature work. If a shared boundary makes a legacy change unavoidable, isolate the change, preserve existing behavior, and add targeted regression coverage using released historical fixtures. -## Project Requirements +## Development Commands -- Always use English in code, examples, and comments. -- Features should be implemented concisely, maintainably, and efficiently. -- Code is not just for execution, but also for readability. -- Only add meaningful comments and tests. - -## Architecture - -The project is organized as a Rust workspace with Python and Java bindings. Rust crates (workspace members unless noted) include: - -- `rust/examples/` - Sample binaries and demonstrations. -- `rust/lance/` - Main Lance library implementing the columnar format. -- `rust/lance-arrow/` - Apache Arrow integration layer. -- `rust/lance-core/` - Core types, traits, and utilities. -- `rust/lance-datagen/` - Data generation helpers for tests and benchmarks. -- `rust/lance-encoding/` - Data encoding and compression algorithms. -- `rust/lance-file/` - File format reading/writing. -- `rust/lance-geo/` - Geospatial data support. -- `rust/lance-index/` - Vector and scalar indexing implementations. -- `rust/lance-io/` - I/O operations and object store integration. -- `rust/lance-linalg/` - Linear algebra operations for vector search. -- `rust/lance-namespace/` - Namespace/catalog interfaces. -- `rust/lance-namespace-impls/` - Concrete namespace/catalog implementations. -- `rust/lance-table/` - Table format and operations. -- `rust/lance-test-macros/` - Procedural macros for testing. -- `rust/lance-testing/` - Shared test utilities. -- `rust/lance-tools/` - CLI and developer tooling. -- `rust/compression/bitpacking/` - Bit-packing codec implementation. -- `rust/compression/fsst/` - Fast string compression (FSST). -- `rust/lance-datafusion/` - DataFusion integration helpers (present in repo; built separately from the default workspace). -- `python/` - Python bindings using PyO3/maturin. -- `java/` - Java bindings using JNI. - -## Common Development Commands - -### Rust Development - -* Check for build errors: `cargo check --workspace --tests --benches` -* Run tests: `cargo test --workspace` -* Run specific test: `cargo test -p <package> <test_name>` +### Rust + +* Check: `cargo check --workspace --tests --benches` +* Test: `cargo test --workspace` or `cargo test -p <package> <test_name>` * Lint: `cargo clippy --all --tests --benches -- -D warnings` * Format: `cargo fmt --all` -* Output code coverage report for a crate: `cargo +nightly llvm-cov -q -p lance-core --branch` -* Create HTML coverage report for a crate: `cargo +nightly llvm-cov -q -p lance-core --branch --html` -* Print lines in file missing coverage: `cargo +nightly llvm-cov -q -p lance-core --show-missing-lines | grep rust/lance-core/src/datatypes/schema.rs` -* Show detailed coverage for a file: `python ci/coverage.py -p lance-core -f rust/lance-core/src/datatypes/schema.rs` - -### Python Development - -Use the makefile for most actions: - -* Build: `maturin develop` -* Test: `make test` -* Run single test: `pytest python/tests/<test_file>.py::<test_name>` -* Doctest: `make doctest` -* Lint: `make lint` -* Format: `make format` - -### Integration Testing - -```bash -# Start required services -cd test_data && docker compose up -d - -# Run S3/DynamoDB tests -AWS_DEFAULT_REGION=us-east-1 pytest --run-integration python/tests/test_s3_ddb.py - -# Performance profiling -maturin develop --release -m python/Cargo.toml -E benchmarks -python python/benchmarks/test_knn.py --iterations 100 -``` - -## Key Technical Details - -1. **Async-first Architecture**: Heavy use of tokio and async/await throughout Rust codebase -2. **Arrow-native**: All data operations work directly with Apache Arrow arrays -3. **Version Control**: Every write creates a new version with manifest tracking -4. **Indexing**: Supports both vector indices (for similarity search) and scalar indices (BTree, inverted) -5. **Encoding**: Custom encodings optimized for ML data patterns -6. **Object Store**: Unified interface for local, S3, Azure, GCS storage - -## Development Notes - -- All public APIs should have comprehensive documentation with examples -- Performance-critical code uses SIMD optimizations where available -- Always rebuild Python extension after Rust changes using `maturin develop` -- Integration tests require Docker for local S3/DynamoDB emulation -- Use feature flags to control dependencies (e.g., `datafusion` for SQL support) - -## Development tips - -Code standards: -* Be mindful of memory use: - * When dealing with streams of `RecordBatch`, avoid collecting all data into - memory whenever possible. - * Use `RoaringBitmap` instead `HashSet<u32>`. - -Tests: -* When writing unit tests, prefer using the `memory://` URI instead of creating - a temporary directory. -* Use rstest to generate parameterized tests to cover more cases with fewer lines - of code. - * Use syntax `#[case::{name}(...)]` to provide human-readable names for each case. -* For backwards compatibility, use the `test_data` directory to check in datasets - written with older library version. - * Check in a `datagen.py` that creates the test data. It should assert the - version of Lance used as part of the script. - * Use `pip install pylance=={version}` and then run `python datagen.py` to - create the dataset. The data files should be checked into git. - * Use `copy_test_data_to_tmp` to read this data in Lance -* Avoid using `ignore` in doctests. For APIs with complex inputs, like methods on - `Dataset`, instead write Rust doctests that just compile a function. This - guarantees that the example code compiles and is in sync with the API. For example: +* Coverage: `cargo +nightly llvm-cov -q -p <crate> --branch` +* Coverage HTML: `cargo +nightly llvm-cov -q -p <crate> --branch --html` +* Coverage for file: `python ci/coverage.py -p <crate> -f <file_path>` +* Use repository-defined Cargo profiles instead of ad hoc LTO overrides. +* Use `release-with-debug` for benchmarks and profiling so optimized builds keep debug symbols without a rebuild. +* Use `release-no-lto` only for local debugging, IO-bound benchmarks, or compile-time-sensitive performance investigation where LTO would not affect the measured bottleneck. + +## 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. +## Coding Standards + +### General + +- Always use English in code, examples, and comments. +- Code is for readability, not just execution. Only add meaningful comments and tests. +- 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. +- 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<u32>`. + +### Cross-Language Bindings + +- Keep Python and Java bindings as thin wrappers — centralize validation and logic in the Rust core. +- Keep parameter names consistent across all bindings (Rust, Python, Java) — rename everywhere or nowhere. +- Never break public API signatures — deprecate with `#[deprecated]`/`@deprecated` and add a new method. +- Replace mutually exclusive boolean flags with a single enum/mode parameter. + +### Naming + +- Name variables after what the value *is* (e.g., `partition_id` not `mask`) — precise names act as inline docs. +- Drop redundant prefixes when the struct/module already implies the domain. +- Use `indices` (not `indexes`) consistently in all APIs and docs. +- Use storage-agnostic terms in API names (e.g., `base` not `bucket`). +- When renaming a type/struct/enum, update all references (methods, fields, variables, test names). + +### Error Handling + +- Validate inputs and reject invalid values with descriptive errors at API boundaries — never silently clamp or adjust. +- Validate mutually exclusive options in builders/configs — throw a clear error if both are set. +- Include full context in error messages: variable names, values, sizes, types. + +### Dependencies + +- Prefer implementing functionality with the standard library or existing workspace dependencies before adding new external crates. +- Keep `Cargo.lock` changes intentional; revert unrelated dependency bumps. Pin broken deps with a comment linking the upstream issue. +- The repo has three lockfiles: the root `Cargo.lock`, `python/Cargo.lock`, and `java/lance-jni/Cargo.lock` (the latter two are excluded from the workspace). A `workspace.dependencies` change must be reflected in all three — refresh the excluded ones with `cargo check --manifest-path python/Cargo.toml` and `cargo check --manifest-path java/lance-jni/Cargo.toml`, then commit the updated lockfiles. The `cargo-lock-sync` pre-commit hook catches a miss offline. +- Gate optional/domain-specific deps behind Cargo feature flags. Prefer separate crates for domain functionality (geo, NLP). + +## Testing Standards + +- **All bugfixes and features must have corresponding tests. We do not merge code without tests.** +- Keep local unit tests lightweight: each test case should finish within one second on typical developer hardware. Split independent parameter matrices and use the smallest fixture or model that preserves the asserted behavior; do not relax assertions, coverage, or recall thresholds to meet the budget. +- Use `rstest` (Rust) or `@pytest.mark.parametrize` (Python) for tests that differ only in inputs. Use `#[case::{name}(...)]` for readable case names. +- Replace `print()` in tests with `assert` — prints don't catch regressions. +- Extend existing tests instead of adding overlapping new ones. Add to existing test files. +- Link a GitHub issue when skipping a test — never bare `@pytest.mark.skip` or `@Ignore` without a tracking URL. +- Include multi-fragment scenarios for dataset operations (reads, indexes, scans). +- Cover NULL edge cases in index tests: null items, all-null collections, empty collections, null columns. +- Vector index tests must assert recall metrics (>=0.5 threshold), not just verify creation succeeds. +- For backwards compatibility, use the `test_data` directory with checked-in datasets from older versions. Include a `datagen.py` that asserts the Lance version used. Use `copy_test_data_to_tmp` to read this data. +- Avoid `ignore` in doctests — write Rust doctests that compile a function instead: ``` /// ``` /// # use lance::{Dataset, Result}; @@ -139,34 +99,31 @@ Tests: /// # } /// ``` ``` -* Code coverage can be skipped for test utilities and non-critical paths using - `#[cfg_attr(coverage, coverage(off))]`. +- Skip coverage for test utilities using `#[cfg_attr(coverage, coverage(off))]`. + +## Documentation Standards + +- All public APIs must have documentation with examples. Link to relevant structs and methods. +- Use ASCII tree diagrams for hierarchical structures (encoding layers, file formats, storage layouts). +- Keep doc examples in sync with actual API signatures — update when refactoring. +- Indent content under MkDocs admonition directives (`!!! note`, etc.) with 4 spaces. +- Proofread comments and docs for typos before committing. + +## Filing Issues + +- When opening an issue with `gh issue create` or the API, classify it and pass the matching label: `--label bug`, `--label feature`, or `--label performance`. These paths bypass the `.github/ISSUE_TEMPLATE` forms, so the label is not applied automatically. +- Prefix the title to match, e.g. `bug: ...`, `feature: ...`, or `perf: ...`. A content-based labeler (`.github/workflows/issue-labeler.yml`) uses this as a fallback signal, but an explicit `--label` is the reliable path. + +## Pull Requests + +- Before creating a PR, search for similar PRs and inspect any PRs linked to the issue being addressed. If a matching PR exists, verify its current status and scope before proceeding to avoid creating duplicate work. +- PR titles must follow the Conventional Commits specification because `.github/workflows/pr-title.yml` validates the PR title and body with commitlint. Use prefixes like `feat:`, `fix:`, `docs:`, `perf:`, `ci:`, `test:`, `build:`, `style:`, or `chore:`; add a scope when useful. +- Before creating or updating a PR, run the lint checks for every touched language surface, even when they are expensive. For Rust changes, run `cargo fmt --all` and `cargo clippy --all --tests --benches -- -D warnings`. For Python changes, follow the environment workflow in `python/AGENTS.md` and run `uv run make lint` from `python/`. If a required lint check cannot be run, state the blocker explicitly in the PR summary. ## Review Guidelines -Please note that the attention of contributors and maintainers is the MOST valuable resource. -Less is more: focus on the most important aspects. - -- Your review output SHOULD be concise and clear. -- You SHOULD only highlight P0 and P1 level issues, such as severe bugs, performance degradation, or security concerns. -- You MUST not reiterate detailed changes in your review. -- You MUST not repeat aspects of the PR that are already well done. - -Please consider the following when reviewing code contributions. - -### Rust API design -* Design public APIs so they can be evolved easily in the future without breaking - changes. Often this means using builder patterns or options structs instead of - long argument lists. -* For public APIs, prefer inputs that use `Into<T>` or `AsRef<T>` traits to allow - more flexible inputs. For example, use `name: Into<String>` instead of `name: String`, - so we don't have to write `func("my_string".to_string())`. - -### Testing -* Ensure all new public APIs have documentation and examples. -* Ensure that all bugfixes and features have corresponding tests. **We do not merge - code without tests.** - -### Documentation -* New features must include updates to the rust documentation comments. Link to - relevant structs and methods to increase the value of documentation. +Contributor and maintainer attention is the most valuable resource. Less is more. + +- Be concise and clear. Focus on P0/P1 issues: severe bugs, performance degradation, security concerns. +- Do not reiterate detailed changes or repeat what's already well done. +- Check naming consistency, error handling patterns, and test coverage. diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cf332215e49..0c946ba2a48 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,7 +1,7 @@ # Guide for New Contributors This is a guide for new contributors to the Lance project. -Even if you have no previous experience with python, rust, and open source, you can still make an non-trivial +Even if you have no previous experience with python, rust, and open source, you can still make a non-trivial impact by helping us improve documentation, examples, and more. For experienced developers, the issues you can work on run the gamut from warm-ups to serious challenges in python and rust. @@ -11,7 +11,7 @@ If you have any questions, please join our [Discord](https://discord.gg/zMM32dvN 1. Join our Discord and say hi 2. Setup your development environment -3. Pick an issue to work on. See https://github.com/lancedb/lance/contribute for good first issues. +3. Pick an issue to work on. See https://github.com/lance-format/lance/contribute for good first issues. 4. Have fun! ## Development Environment @@ -19,19 +19,29 @@ If you have any questions, please join our [Discord](https://discord.gg/zMM32dvN Currently Lance is implemented in Rust and comes with a Python wrapper. So you'll want to make sure you setup both. 1. Install Rust: https://www.rust-lang.org/tools/install -2. Install Python 3.9+: https://www.python.org/downloads/ -3. Install protoctol buffers: https://grpc.io/docs/protoc-installation/ (make sure you have version 3.20 or higher) +2. Install Python 3.10+: https://www.python.org/downloads/ +3. Install protocol buffers: https://grpc.io/docs/protoc-installation/ (make sure you have version 3.20 or higher) 4. Install commit hooks: a. Install pre-commit: https://pre-commit.com/#install b. Run `pre-commit install` in the root of the repo +## Building for legacy x86_64 hosts (pre-Haswell) + +The default workspace build targets `haswell` (AVX2 + FMA + F16C), matching the published wheels. To build a binary that runs on pre-Haswell silicon (Sandy Bridge / Ivy Bridge / Westmere on Intel, Bulldozer / Piledriver / Steamroller on AMD — i.e. CPUs without AVX2), set the baseline yourself at build time: + +```sh +RUSTFLAGS="-C target-cpu=x86-64-v2" cargo build --release +``` + +Runtime SIMD dispatch in `lance-linalg::distance` will then pick the appropriate tier (scalar / AVX / AVX+FMA / AVX2+FMA / AVX-512) based on the host. From Python, use `lance.simd_info()` to verify which tier was selected. + ## Sample Workflow 1. Fork the repo -2. Pick [Github issue](https://github.com/lancedb/lance/issues) +2. Pick [Github issue](https://github.com/lance-format/lance/issues) 3. Create a branch for the issue 4. Make your changes -5. Create a pull request from your fork to lancedb/lance +5. Create a pull request from your fork to lance-format/lance 6. Get feedback and iterate 7. Merge! 8. Go back to step 2 diff --git a/Cargo.lock b/Cargo.lock index cc98925d15e..3ae1ac4ec71 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,12 +2,6 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "RustyXML" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5ace29ee3216de37c0546865ad08edef58b0f9e76838ed8959a84a990e58c5" - [[package]] name = "addr2line" version = "0.25.1" @@ -23,21 +17,15 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -46,7 +34,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "const-random", "getrandom 0.3.4", "once_cell", @@ -74,23 +62,17 @@ dependencies = [ [[package]] name = "all_asserts" -version = "2.3.3" +version = "2.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "514ce16346f9fc96702fd52f2ae7e383b185516ee6f556efd7c3176be8fe7bea" +checksum = "62ec8a4dbfbd8acfe181f030be8174b00d3d7dc3447f7dc53ead6cc96e89fe52" [[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" +name = "alloca" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" dependencies = [ - "alloc-no-stdlib", + "cc", ] [[package]] @@ -116,9 +98,9 @@ checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" [[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", @@ -131,15 +113,15 @@ 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" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -166,9 +148,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -181,9 +163,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -202,9 +184,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.2.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2b10dcb159faf30d3f81f6d56c1211a5bea2ca424eabe477648a44b993320e" +checksum = "378530e55cd479eda3c14eb345310799717e6f76d0c332041e8487022166b471" dependencies = [ "arrow-arith", "arrow-array", @@ -223,9 +205,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288015089e7931843c80ed4032c5274f02b37bcb720c4a42096d50b390e70372" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ "arrow-array", "arrow-buffer", @@ -237,9 +219,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ca404ea6191e06bf30956394173337fa9c35f445bd447fe6c21ab944e1a23c" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash", "arrow-buffer", @@ -248,7 +230,7 @@ dependencies = [ "chrono", "chrono-tz", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -256,9 +238,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36356383099be0151dacc4245309895f16ba7917d79bdb71a7148659c9206c56" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -268,9 +250,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8e372ed52bd4ee88cc1e6c3859aa7ecea204158ac640b10e187936e7e87074" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ "arrow-array", "arrow-buffer", @@ -290,9 +272,9 @@ dependencies = [ [[package]] name = "arrow-csv" -version = "57.2.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4100b729fe656f2e4fb32bc5884f14acf9118d4ad532b7b33c1132e4dce896" +checksum = "e94e8cf7e517657a52b91ea1263acf38c4ca62a84655d72458a3359b12ab97de" dependencies = [ "arrow-array", "arrow-cast", @@ -305,9 +287,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf87f4ff5fc13290aa47e499a8b669a82c5977c6a1fedce22c7f542c1fd5a597" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", @@ -318,9 +300,9 @@ dependencies = [ [[package]] name = "arrow-ipc" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3ca63edd2073fcb42ba112f8ae165df1de935627ead6e203d07c99445f2081" +checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b" dependencies = [ "arrow-array", "arrow-buffer", @@ -328,24 +310,25 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex", "zstd", ] [[package]] name = "arrow-json" -version = "57.2.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a36b2332559d3310ebe3e173f75b29989b4412df4029a26a30cc3f7da0869297" +checksum = "205ca2119e6d679d5c133c6f30e68f027738d95ed948cf77677ea69c7800036b" dependencies = [ "arrow-array", "arrow-buffer", "arrow-cast", - "arrow-data", + "arrow-ord", "arrow-schema", + "arrow-select", "chrono", "half", - "indexmap", + "indexmap 2.14.0", "itoa", "lexical-core", "memchr", @@ -358,9 +341,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c4e0530272ca755d6814218dffd04425c5b7854b87fa741d5ff848bf50aa39" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ "arrow-array", "arrow-buffer", @@ -371,9 +354,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b07f52788744cc71c4628567ad834cadbaeb9f09026ff1d7a4120f69edf7abd3" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ "arrow-array", "arrow-buffer", @@ -384,20 +367,20 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb63203e8e0e54b288d0d8043ca8fa1013820822a27692ef1b78a977d879f2c" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "serde_core", "serde_json", ] [[package]] name = "arrow-select" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96d8a1c180b44ecf2e66c9a2f2bbcb8b1b6f14e165ce46ac8bde211a363411b" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash", "arrow-array", @@ -409,9 +392,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.2.0" +version = "58.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ad6a81add9d3ea30bf8374ee8329992c7fd246ffd8b7e2f48a3cea5aa0cc9a" +checksum = "29dd7cda3ab9692f43a2e4acc444d760cc17b12bb6d8232ddf64e9bab7c06b42" dependencies = [ "arrow-array", "arrow-buffer", @@ -434,17 +417,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "async-channel" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" -dependencies = [ - "concurrent-queue", - "event-listener 2.5.3", - "futures-core", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -459,9 +431,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -469,53 +441,17 @@ dependencies = [ "tokio", ] -[[package]] -name = "async-io" -version = "2.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" -dependencies = [ - "autocfg", - "cfg-if", - "concurrent-queue", - "futures-io", - "futures-lite 2.6.1", - "parking", - "polling", - "rustix 1.1.3", - "slab", - "windows-sys 0.61.2", -] - [[package]] name = "async-lock" version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" dependencies = [ - "event-listener 5.4.1", + "event-listener", "event-listener-strategy", "pin-project-lite", ] -[[package]] -name = "async-process" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" -dependencies = [ - "async-channel 2.5.0", - "async-io", - "async-lock", - "async-signal", - "async-task", - "blocking", - "cfg-if", - "event-listener 5.4.1", - "futures-lite 2.6.1", - "rustix 1.1.3", -] - [[package]] name = "async-recursion" version = "1.1.1" @@ -524,42 +460,18 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "async-signal" -version = "0.2.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c070bbf59cd3570b6b2dd54cd772527c7c3620fce8be898406dd3ed6adc64c" -dependencies = [ - "async-io", - "async-lock", - "atomic-waker", - "cfg-if", - "futures-core", - "futures-io", - "rustix 1.1.3", - "signal-hook-registry", - "slab", - "windows-sys 0.61.2", + "syn 2.0.119", ] -[[package]] -name = "async-task" -version = "4.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" - [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.3", ] [[package]] @@ -571,6 +483,16 @@ dependencies = [ "loom", ] +[[package]] +name = "asyncband" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a214ba60d6231afd0e805e3c27c45a1626d9debaa5a5061c45a1ea1b2f1ed0" +dependencies = [ + "hashbrown 0.17.1", + "slab", +] + [[package]] name = "atoi" version = "2.0.0" @@ -588,15 +510,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.13" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -611,9 +533,9 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand", "hex", - "http 1.4.0", + "http 1.4.2", "ring", "time", "tokio", @@ -624,9 +546,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -636,9 +558,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -646,21 +568,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.6.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -672,9 +595,10 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "bytes-utils", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "percent-encoding", @@ -685,9 +609,9 @@ dependencies = [ [[package]] name = "aws-sdk-dynamodb" -version = "1.104.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04c47115cc8d46dcc94a9a81e7a3384cea859283c1a737729691d4221f11584" +checksum = "561bf86e858a2759c6876b517b13f3f4051a6484abbb0d8a1f4dfc5d902cc85a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -700,18 +624,18 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-s3" -version = "1.122.0" +version = "1.124.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94c2ca0cba97e8e279eb6c0b2d0aa10db5959000e602ab2b7c02de6b85d4c19b" +checksum = "744c09d75dfec039a05cf8e117c995ded3b0baffa6eb83f3ed7075a01d8d8947" dependencies = [ "aws-credential-types", "aws-runtime", @@ -728,25 +652,25 @@ dependencies = [ "aws-smithy-xml", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", - "lru 0.16.3", + "lru 0.16.4", "percent-encoding", "regex-lite", - "sha2", + "sha2 0.10.9", "tracing", "url", ] [[package]] name = "aws-sdk-sso" -version = "1.93.0" +version = "1.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcb38bb33fc0a11f1ffc3e3e85669e0a11a37690b86f77e75306d8f369146a0" +checksum = "00c5ff27c6ba2cbd95e6e26e2e736676fdf6bcf96495b187733f521cfe4ce448" dependencies = [ "aws-credential-types", "aws-runtime", @@ -759,18 +683,18 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.95.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ada8ffbea7bd1be1f53df1dadb0f8fdb04badb13185b3321b929d1ee3caad09" +checksum = "4d186f1e5a3694a188e5a0640b3115ccc6e084d104e16fd6ba968dca072ffef8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -783,18 +707,18 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", - "fastrand 2.3.0", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.97.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" dependencies = [ "aws-credential-types", "aws-runtime", @@ -808,18 +732,18 @@ dependencies = [ "aws-smithy-types", "aws-smithy-xml", "aws-types", - "fastrand 2.3.0", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.8" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" dependencies = [ "aws-credential-types", "aws-smithy-eventstream", @@ -827,27 +751,22 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "crypto-bigint 0.5.5", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", - "http 1.4.0", - "p256", + "http 1.4.2", "percent-encoding", - "ring", - "sha2", - "subtle", + "sha2 0.10.9", "time", "tracing", - "zeroize", ] [[package]] name = "aws-smithy-async" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" dependencies = [ "futures-util", "pin-project-lite", @@ -856,30 +775,30 @@ dependencies = [ [[package]] name = "aws-smithy-checksums" -version = "0.64.3" +version = "0.64.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddcf418858f9f3edd228acb8759d77394fed7531cce78d02bdda499025368439" +checksum = "180dddf5ef0f52a2f99e2fada10e16ea610e507ef6148a42bdc4d5867596aa00" dependencies = [ "aws-smithy-http", "aws-smithy-types", "bytes", "crc-fast", "hex", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "md-5", + "md-5 0.10.6", "pin-project-lite", - "sha1", - "sha2", + "sha1 0.10.6", + "sha2 0.10.9", "tracing", ] [[package]] name = "aws-smithy-eventstream" -version = "0.60.18" +version = "0.60.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35b9c7354a3b13c66f60fe4616d6d1969c9fd36b1b5333a5dfb3ee716b33c588" +checksum = "1c0b3e587fbaa5d7f7e870544508af8ce82ea47cd30376e69e1e37c4ac746f79" dependencies = [ "aws-smithy-types", "bytes", @@ -888,9 +807,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.3" +version = "0.63.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" dependencies = [ "aws-smithy-eventstream", "aws-smithy-runtime-api", @@ -899,7 +818,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -910,57 +829,51 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.9" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", - "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", + "h2", + "http 1.4.2", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.36", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.62.3" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" dependencies = [ "aws-smithy-types", "urlencoding", @@ -968,9 +881,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.10.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -979,9 +892,9 @@ dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", "bytes", - "fastrand 2.3.0", + "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -993,15 +906,15 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.3" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" dependencies = [ "aws-smithy-async", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -1010,16 +923,16 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -1036,18 +949,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.11" +version = "1.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -1067,10 +980,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -1100,7 +1013,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1112,120 +1025,13 @@ dependencies = [ "tracing", ] -[[package]] -name = "azure_core" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b552ad43a45a746461ec3d3a51dfb6466b4759209414b439c165eb6a6b7729e" -dependencies = [ - "async-trait", - "base64 0.22.1", - "bytes", - "dyn-clone", - "futures", - "getrandom 0.2.17", - "hmac", - "http-types", - "once_cell", - "paste", - "pin-project", - "quick-xml 0.31.0", - "rand 0.8.5", - "reqwest", - "rustc_version", - "serde", - "serde_json", - "sha2", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_identity" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88ddd80344317c40c04b603807b63a5cefa532f1b43522e72f480a988141f744" -dependencies = [ - "async-lock", - "async-process", - "async-trait", - "azure_core", - "futures", - "oauth2", - "pin-project", - "serde", - "time", - "tracing", - "tz-rs", - "url", - "uuid", -] - -[[package]] -name = "azure_storage" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59f838159f4d29cb400a14d9d757578ba495ae64feb07a7516bf9e4415127126" -dependencies = [ - "RustyXML", - "async-lock", - "async-trait", - "azure_core", - "bytes", - "serde", - "serde_derive", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_storage_blobs" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97e83c3636ae86d9a6a7962b2112e3b19eb3903915c50ce06ff54ff0a2e6a7e4" -dependencies = [ - "RustyXML", - "azure_core", - "azure_storage", - "azure_svc_blobstorage", - "bytes", - "futures", - "serde", - "serde_derive", - "serde_json", - "time", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "azure_svc_blobstorage" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e6c6f20c5611b885ba94c7bae5e02849a267381aecb8aee577e8c35ff4064c6" -dependencies = [ - "azure_core", - "bytes", - "futures", - "log", - "once_cell", - "serde", - "serde_json", - "time", -] - [[package]] name = "backon" version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" dependencies = [ - "fastrand 2.3.0", + "fastrand", "gloo-timers", "tokio", ] @@ -1237,7 +1043,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" dependencies = [ "addr2line", - "cfg-if", + "cfg-if 1.0.4", "libc", "miniz_oxide", "object", @@ -1245,12 +1051,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" @@ -1263,6 +1063,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64-simd" version = "0.8.0" @@ -1292,26 +1098,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bit-set" version = "0.8.0" @@ -1335,9 +1121,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitpacking" @@ -1350,9 +1136,9 @@ dependencies = [ [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1366,21 +1152,21 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1392,6 +1178,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1402,75 +1197,79 @@ dependencies = [ ] [[package]] -name = "blocking" -version = "1.6.2" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "async-channel 2.5.0", - "async-task", - "futures-io", - "futures-lite 2.6.1", - "piper", + "tinyvec", ] [[package]] -name = "bon" -version = "3.8.2" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "bon-macros", - "rustversion", + "memchr", + "regex-automata", + "serde", ] [[package]] -name = "bon-macros" -version = "3.8.2" +name = "bumpalo" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" -dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", -] +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "brotli" -version = "8.0.2" +name = "bytecheck" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", ] [[package]] -name = "brotli-decompressor" -version = "5.0.0" +name = "bytecheck_derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "bumpalo" -version = "3.19.1" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "byteorder" @@ -1480,9 +1279,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-utils" @@ -1511,9 +1310,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -1523,18 +1322,18 @@ dependencies = [ [[package]] name = "cedarwood" -version = "0.4.6" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d910bedd62c24733263d0bed247460853c9d22e8956bd4cd964302095e04e90" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" dependencies = [ "smallvec", ] [[package]] -name = "census" -version = "0.4.2" +name = "cfg-if" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfg-if" @@ -1548,11 +1347,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1605,15 +1415,15 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[package]] name = "clap" -version = "4.5.57" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6899ea499e3fb9305a65d5ebf6e3d2248c5fab291f300ad0a704fbe142eae31a" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" dependencies = [ "clap_builder", "clap_derive", @@ -1621,9 +1431,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.57" +version = "4.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b12c8b680195a62a8364d16b8447b01b6c2c8f9aaf68bee653be34d4245e238" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" dependencies = [ "anstream", "anstyle", @@ -1633,63 +1443,88 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.3", ] [[package]] name = "clap_lex" -version = "0.7.7" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3e64b0cc0439b12df2fa678eae89a1c56a529fd067a9115f7827f1fffd22b32" +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", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[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" -version = "7.2.2" +name = "colored" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "unicode-segmentation", - "unicode-width", + "windows-sys 0.61.2", ] [[package]] -name = "compression-codecs" -version = "0.4.36" +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ - "compression-core", - "flate2", + "bytes", + "memchr", +] + +[[package]] +name = "comfy-table" +version = "7.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "958c5d6ecf1f214b4c2bbbbf6ab9523a864bd136dcf71a7e8904799acfe1ad47" +dependencies = [ + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", "memchr", ] [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -1719,6 +1554,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1740,10 +1581,19 @@ dependencies = [ ] [[package]] -name = "const_fn" -version = "0.4.11" +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f8a2ca5ac02d09563609681103aada9e1777d54fc57a5acd7a41404f9c93b6e" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] [[package]] name = "constant_time_eq" @@ -1778,21 +1628,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core2" -version = "0.4.0" +name = "countio" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" dependencies = [ - "memchr", + "futures-io", ] [[package]] name = "cpp_demangle" -version = "0.5.1" +version = "0.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0667304c32ea56cb4cd6d2d7c0cfe9a2f8041229db8c033af7f8d69492429def" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1804,6 +1654,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" @@ -1815,9 +1674,9 @@ dependencies = [ [[package]] name = "crc-catalog" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" +checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc-fast" @@ -1826,18 +1685,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2fd92aca2c6001b1bf5ba0ff84ee74ec8501b52bbef0cac80bf25a6c1d87a83d" dependencies = [ "crc", - "digest", + "digest 0.10.7", "rustversion", - "spin 0.10.0", -] - -[[package]] -name = "crc32c" -version = "0.6.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" -dependencies = [ - "rustc_version", + "spin 0.10.1", ] [[package]] @@ -1846,31 +1696,29 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] name = "criterion" -version = "0.5.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" dependencies = [ + "alloca", "anes", "cast", "ciborium", "clap", "criterion-plot", - "futures", - "is-terminal", - "itertools 0.10.5", + "itertools 0.13.0", "num-traits", - "once_cell", "oorandom", + "page_size", "plotters", "rayon", "regex", "serde", - "serde_derive", "serde_json", "tinytemplate", "tokio", @@ -1879,12 +1727,12 @@ dependencies = [ [[package]] name = "criterion-plot" -version = "0.5.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" dependencies = [ "cast", - "itertools 0.10.5", + "itertools 0.13.0", ] [[package]] @@ -1908,18 +1756,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] @@ -1947,35 +1795,22 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] -name = "crypto-bigint" -version = "0.4.9" +name = "crypto-common" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef2b4b23cddf68b89b8f8069890e8c270d54e2d5fe1b143820234805e4cb17ef" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" 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", + "typenum", ] [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ - "generic-array", - "typenum", + "hybrid-array", ] [[package]] @@ -1999,6 +1834,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "daachorse" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db756b5eb7d81d31f31f660f4132f8cf5698de52fca144c143d0ae0cbb5f2e06" + [[package]] name = "darling" version = "0.14.4" @@ -2054,7 +1914,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2067,7 +1927,7 @@ dependencies = [ "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2089,7 +1949,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2100,22 +1960,16 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.119", ] -[[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" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -2125,14 +1979,13 @@ dependencies = [ [[package]] name = "datafusion" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba7cb113e9c0bedf9e9765926031e132fa05a1b09ba6e93a6d1a4d7044457b8" +checksum = "754ef4e8f073922a26f5b23133b9db4829342362b09be0bc94309cf261c2f098" dependencies = [ "arrow", "arrow-schema", "async-trait", - "bytes", "chrono", "datafusion-catalog", "datafusion-catalog-listing", @@ -2142,7 +1995,6 @@ dependencies = [ "datafusion-datasource-arrow", "datafusion-datasource-csv", "datafusion-datasource-json", - "datafusion-datasource-parquet", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", @@ -2160,14 +2012,11 @@ dependencies = [ "datafusion-session", "datafusion-sql", "futures", + "indexmap 2.14.0", "itertools 0.14.0", "log", "object_store", "parking_lot", - "parquet", - "rand 0.9.2", - "regex", - "rstest 0.26.1", "sqlparser", "tempfile", "tokio", @@ -2177,9 +2026,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a3a799f914a59b1ea343906a0486f17061f39509af74e874a866428951130d" +checksum = "06afd1e38dd27bbb1258685a1fc6524df6aff4e07b25b393a47de59635178d99" dependencies = [ "arrow", "async-trait", @@ -2202,9 +2051,9 @@ dependencies = [ [[package]] name = "datafusion-catalog-listing" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db1b113c80d7a0febcd901476a57aef378e717c54517a163ed51417d87621b0" +checksum = "f0668fb32c12065ec242be0e5b4bc62bd7a06a0be3ecd83791ef877e4be67e02" dependencies = [ "arrow", "async-trait", @@ -2221,37 +2070,37 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "tokio", ] [[package]] name = "datafusion-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c10f7659e96127d25e8366be7c8be4109595d6a2c3eac70421f380a7006a1b0" +checksum = "ca43b263cdff57042cfa8fb817fb3469f4878933380dccff25f5e793580abbf9" dependencies = [ - "ahash", "arrow", "arrow-ipc", + "arrow-schema", "chrono", + "foldhash 0.2.0", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", "libc", "log", "object_store", - "parquet", - "paste", "sqlparser", "tokio", + "uuid", "web-time", ] [[package]] name = "datafusion-common-runtime" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92065bbc6532c6651e2f7dd30b55cba0c7a14f860c7e1d15f165c41a1868d95" +checksum = "05f0ba2b864792bdca4d76c59a1de0ab6e1b61946596b9936888dbd6360035f2" dependencies = [ "futures", "log", @@ -2260,9 +2109,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde13794244bc7581cd82f6fff217068ed79cdc344cafe4ab2c3a1c3510b38d6" +checksum = "b840a8bce0bcbf5afad02946d438591e7c373f7afccaf3d874c04485772514dd" dependencies = [ "arrow", "async-trait", @@ -2282,16 +2131,17 @@ dependencies = [ "itertools 0.14.0", "log", "object_store", - "rand 0.9.2", + "parking_lot", + "rand 0.9.5", "tokio", "url", ] [[package]] name = "datafusion-datasource-arrow" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804fa9b4ecf3157982021770617200ef7c1b2979d57bec9044748314775a9aea" +checksum = "a24cc0b9cf6e367f27f27406eff13abf48a11b72446aaa40b3105c0ded5c17d9" dependencies = [ "arrow", "arrow-ipc", @@ -2313,9 +2163,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-csv" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a1641a40b259bab38131c5e6f48fac0717bedb7dc93690e604142a849e0568" +checksum = "e1abe56b2a7a2d1d6de5117dd1a203181e267f28529faa5da546947621b697d7" dependencies = [ "arrow", "async-trait", @@ -2336,31 +2186,9 @@ dependencies = [ [[package]] name = "datafusion-datasource-json" -version = "51.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeacdb00c1d37271176f8fb6a1d8ce096baba16ea7a4b2671840c5c9c64fe85" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "futures", - "object_store", - "tokio", -] - -[[package]] -name = "datafusion-datasource-parquet" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d0b60ffd66f28bfb026565d62b0a6cbc416da09814766a3797bba7d85a3cd9" +checksum = "9c3e467f0611ad7bdd5aad17c63c9bb6182d04e5282e5496d897ea2b49c024ba" dependencies = [ "arrow", "async-trait", @@ -2370,55 +2198,51 @@ dependencies = [ "datafusion-datasource", "datafusion-execution", "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", "datafusion-physical-expr-common", "datafusion-physical-plan", - "datafusion-pruning", "datafusion-session", "futures", - "itertools 0.14.0", - "log", "object_store", - "parking_lot", - "parquet", "tokio", + "tokio-stream", ] [[package]] name = "datafusion-doc" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b99e13947667b36ad713549237362afb054b2d8f8cc447751e23ec61202db07" +checksum = "d69bb69d8769e34f76839c960dbde24c1ac0c885a79b6c3c2287bdc56ec67891" [[package]] name = "datafusion-execution" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63695643190679037bc946ad46a263b62016931547bf119859c511f7ff2f5178" +checksum = "d8eac0a09bc8d263f52025cad9e001da4d8138d633fa288edda4d06b1772eae6" dependencies = [ "arrow", + "arrow-buffer", "async-trait", "dashmap", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr-common", "futures", "log", "object_store", "parking_lot", - "rand 0.9.2", + "rand 0.9.5", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a4787cbf5feb1ab351f789063398f67654a6df75c4d37d7f637dc96f951a91" +checksum = "eeb14d374767ee0fc62dc79a5ba8bcf8a63c14e993c7d992d0e63adfa23d77d3" dependencies = [ "arrow", + "arrow-schema", "async-trait", "chrono", "datafusion-common", @@ -2427,31 +2251,29 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", "serde_json", "sqlparser", ] [[package]] name = "datafusion-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce2fb1b8c15c9ac45b0863c30b268c69dc9ee7a1ee13ecf5d067738338173dc" +checksum = "b7b19a8c95522bee8cbb313d74263b85e355d2b52f42e67ef5694bf5de9e9356" dependencies = [ "arrow", "datafusion-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", ] [[package]] name = "datafusion-functions" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794a9db7f7b96b3346fc007ff25e994f09b8f0511b4cf7dff651fadfe3ebb28f" +checksum = "5f64c983bbbdcb729d921a2b2ac3375598719b5cc0c30345ad664936f3176fc7" dependencies = [ "arrow", "arrow-buffer", @@ -2459,31 +2281,32 @@ dependencies = [ "blake2", "blake3", "chrono", + "chrono-tz", "datafusion-common", "datafusion-doc", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", "datafusion-macros", + "datafusion-physical-expr-common", "hex", "itertools 0.14.0", "log", - "md-5", + "md-5 0.11.0", + "memchr", "num-traits", - "rand 0.9.2", + "rand 0.9.5", "regex", - "sha2", - "unicode-segmentation", + "sha2 0.11.0", "uuid", ] [[package]] name = "datafusion-functions-aggregate" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25210520a9dcf9c2b2cbbce31ebd4131ef5af7fc60ee92b266dc7d159cb305" +checksum = "89bc17041e424a47ed062f43df24d84aab8b57c4c3221e5c1a5eef46d6c5718b" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-doc", @@ -2493,18 +2316,18 @@ dependencies = [ "datafusion-macros", "datafusion-physical-expr", "datafusion-physical-expr-common", + "foldhash 0.2.0", "half", "log", - "paste", + "num-traits", ] [[package]] name = "datafusion-functions-aggregate-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f4a66f3b87300bb70f4124b55434d2ae3fe80455f3574701d0348da040b55d" +checksum = "97dd2a9e865c6108059f5b37b77934f84b50bfb108f837bd0e5c9536e03f0545" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr-common", @@ -2513,9 +2336,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae5c06eed03918dc7fe7a9f082a284050f0e9ecf95d72f57712d1496da03b8c4" +checksum = "75f0bdfeef16d96417b9632ef855645376b242e9006a126dfd0bedfc54a93f5f" dependencies = [ "arrow", "arrow-ord", @@ -2529,32 +2352,34 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-macros", "datafusion-physical-expr-common", + "hashbrown 0.17.1", "itertools 0.14.0", + "itoa", "log", - "paste", + "memchr", ] [[package]] name = "datafusion-functions-table" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4fed1d71738fbe22e2712d71396db04c25de4111f1ec252b8f4c6d3b25d7f5" +checksum = "f4e4941673c917819616877e9993da4503e4f4739812be0bc32c5356184c6383" dependencies = [ "arrow", "async-trait", "datafusion-catalog", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr", "datafusion-physical-plan", "parking_lot", - "paste", ] [[package]] name = "datafusion-functions-window" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d92206aa5ae21892f1552b4d61758a862a70956e6fd7a95cb85db1de74bc6d1" +checksum = "12dd2e16c12b84b6f6b41b19f55b366dd1c46876bb35b86896c6349067379e8d" dependencies = [ "arrow", "datafusion-common", @@ -2565,14 +2390,13 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "log", - "paste", ] [[package]] name = "datafusion-functions-window-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53ae9bcc39800820d53a22d758b3b8726ff84a5a3e24cecef04ef4e5fdf1c7cc" +checksum = "4cdc5e4b6f8b6ef823cc1c761f85088ad4c884fe8df64df3cbcc6b2b84698441" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2580,20 +2404,20 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1063ad4c9e094b3f798acee16d9a47bd7372d9699be2de21b05c3bd3f34ab848" +checksum = "1a3614234dd93578c92428cb4f408e020874f0d2b7e6c90c928d9d28b5df2ceb" dependencies = [ "datafusion-doc", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "datafusion-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35f9ec5d08b87fd1893a30c2929f2559c2f9806ca072d8fefca5009dc0f06a" +checksum = "a0635620b050b81bb92764e99250868f654e2cd5ad1bece413283b3f73c83179" dependencies = [ "arrow", "chrono", @@ -2601,7 +2425,7 @@ dependencies = [ "datafusion-expr", "datafusion-expr-common", "datafusion-physical-expr", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", "log", "regex", @@ -2610,11 +2434,10 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30cc8012e9eedcb48bbe112c6eff4ae5ed19cf3003cb0f505662e88b7014c5d" +checksum = "8cabf7a86eb70b816729e33c81bf7767c936ee1226f607a114f5dac2decac8d0" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr", @@ -2622,19 +2445,19 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-physical-expr-common", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "parking_lot", - "paste", "petgraph", + "tokio", ] [[package]] name = "datafusion-physical-expr-adapter" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9ff2dbd476221b1f67337699eff432781c4e6e1713d2aefdaa517dfbf79768" +checksum = "de222e04f7e6744501555a54ab0abe26bfdfebee380af79a9bdc175704246859" dependencies = [ "arrow", "datafusion-common", @@ -2647,23 +2470,26 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90da43e1ec550b172f34c87ec68161986ced70fd05c8d2a2add66eef9c276f03" +checksum = "72d0d0057fc5a502d45c870cb6d47c66eb7bdd5edb1bd71ad6f3f724975ac2a8" dependencies = [ - "ahash", "arrow", + "chrono", "datafusion-common", "datafusion-expr-common", - "hashbrown 0.14.5", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", + "parking_lot", + "pin-project", ] [[package]] name = "datafusion-physical-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce9804f799acd7daef3be7aaffe77c0033768ed8fdbf5fb82fc4c5f2e6bc14e6" +checksum = "86046eed10950c5f9aaed9acfd148e9bd2e1dfdfe4f9aef607d1447b271e4183" dependencies = [ "arrow", "datafusion-common", @@ -2679,30 +2505,32 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0acf0ad6b6924c6b1aa7d213b181e012e2d3ec0a64ff5b10ee6282ab0f8532ac" +checksum = "9bc84da934c903407ba297971ebcc020c4c1a38aafd765d6c144c76eff3fa6a1" dependencies = [ - "ahash", "arrow", + "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "async-trait", - "chrono", "datafusion-common", "datafusion-common-runtime", "datafusion-execution", "datafusion-expr", + "datafusion-functions", "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr", "datafusion-physical-expr-common", "futures", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "log", + "num-traits", "parking_lot", "pin-project-lite", "tokio", @@ -2710,9 +2538,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2c2498a1f134a9e11a9f5ed202a2a7d7e9774bd9249295593053ea3be999db" +checksum = "eb63eeac6de19be40f487b65dd84e546195f783c5a9928618e0c4f2a3569b0d7" dependencies = [ "arrow", "datafusion-common", @@ -2721,15 +2549,14 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "datafusion-physical-plan", - "itertools 0.14.0", "log", ] [[package]] name = "datafusion-session" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f96eebd17555386f459037c65ab73aae8df09f464524c709d6a3134ad4f4776" +checksum = "5f961d209177f91bd014db5cbb2c33b7d28a2597b9003e77f17aeb712964315a" dependencies = [ "async-trait", "datafusion-common", @@ -2741,16 +2568,17 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fc195fe60634b2c6ccfd131b487de46dc30eccae8a3c35a13f136e7f440414f" +checksum = "b1d71cb454da682b2af7488e1fc1ddd72ee1b28f19297b8ccad73f0a21ee9a69" dependencies = [ "arrow", "bigdecimal", "chrono", "datafusion-common", "datafusion-expr", - "indexmap", + "datafusion-functions-nested", + "indexmap 2.14.0", "log", "regex", "sqlparser", @@ -2758,9 +2586,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2505af06d103a55b4e8ded0c6aeb6c72a771948da939c0bd3f8eee67af475a9c" +checksum = "f047a6fbf967b6b523758a48d2377bb5f9373a20e7ae4c4326d3c569f80ba3d7" dependencies = [ "async-recursion", "async-trait", @@ -2774,7 +2602,6 @@ dependencies = [ "substrait", "tokio", "url", - "uuid", ] [[package]] @@ -2804,54 +2631,23 @@ dependencies = [ "uuid", ] -[[package]] -name = "deepsize" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" -dependencies = [ - "deepsize_derive", -] - -[[package]] -name = "deepsize_derive" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" -dependencies = [ - "proc-macro2", - "quote", - "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] @@ -2894,7 +2690,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2914,7 +2710,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core 0.20.2", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -2929,19 +2725,22 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] [[package]] -name = "dirs" -version = "5.0.1" +name = "digest" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "dirs-sys 0.4.1", + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", ] [[package]] @@ -2950,19 +2749,7 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys 0.5.0", -] - -[[package]] -name = "dirs-sys" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" -dependencies = [ - "libc", - "option-ext", - "redox_users 0.4.6", - "windows-sys 0.48.0", + "dirs-sys", ] [[package]] @@ -2973,19 +2760,19 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users 0.5.2", + "redox_users", "windows-sys 0.61.2", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -3003,12 +2790,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" @@ -3031,43 +2812,11 @@ 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" +version = "1.16.0" 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", -] +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encode_unicode" @@ -3076,102 +2825,44 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" [[package]] -name = "encoding" -version = "0.2.33" +name = "encoding_rs" +version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "encoding-index-japanese", - "encoding-index-korean", - "encoding-index-simpchinese", - "encoding-index-singlebyte", - "encoding-index-tradchinese", + "cfg-if 1.0.4", ] [[package]] -name = "encoding-index-japanese" -version = "1.20141219.5" +name = "encoding_rs_io" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" +checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" dependencies = [ - "encoding_index_tests", + "encoding_rs", ] [[package]] -name = "encoding-index-korean" -version = "1.20141219.5" +name = "endian-type" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" -dependencies = [ - "encoding_index_tests", -] +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" [[package]] -name = "encoding-index-simpchinese" -version = "1.20141219.5" +name = "env_filter" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ - "encoding_index_tests", + "log", + "regex", ] [[package]] -name = "encoding-index-singlebyte" -version = "1.20141219.5" +name = "env_logger" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding-index-tradchinese" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding_index_tests" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" - -[[package]] -name = "encoding_rs" -version = "0.8.35" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" -dependencies = [ - "cfg-if", -] - -[[package]] -name = "encoding_rs_io" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cc3c5651fb62ab8aa3103998dade57efdd028544bd300516baa31840c252a83" -dependencies = [ - "encoding_rs", -] - -[[package]] -name = "env_filter" -version = "0.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" -dependencies = [ - "log", - "regex", -] - -[[package]] -name = "env_logger" -version = "0.11.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -3197,7 +2888,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -3227,23 +2918,16 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" - -[[package]] -name = "event-listener" -version = "2.5.3" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -3254,7 +2938,7 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" dependencies = [ - "event-listener 5.4.1", + "event-listener", "pin-project-lite", ] @@ -3264,47 +2948,11 @@ 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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" -dependencies = [ - "instant", -] - [[package]] name = "fastrand" -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" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -3336,7 +2984,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "rustc_version", ] @@ -3348,7 +2996,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -3401,19 +3048,18 @@ 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" -version = "0.8.4" +name = "frostem" +version = "1.20260804.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" -dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", -] +checksum = "6d0ae10cfccbae085dd8612669ccc116fe88bd5dfe39d202a8fa68c24c1e546f" [[package]] name = "fs_extra" @@ -3423,12 +3069,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", - "lance-datagen", - "rand 0.9.2", - "rand_xoshiro", + "rand 0.9.5", "test-log", "tokio", ] @@ -3450,9 +3094,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -3465,9 +3109,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -3475,15 +3119,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -3492,72 +3136,44 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" - -[[package]] -name = "futures-lite" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" -dependencies = [ - "fastrand 1.9.0", - "futures-core", - "futures-io", - "memchr", - "parking", - "pin-project-lite", - "waker-fn", -] - -[[package]] -name = "futures-lite" -version = "2.6.1" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" -dependencies = [ - "fastrand 2.3.0", - "futures-core", - "futures-io", - "parking", - "pin-project-lite", -] +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-timer" -version = "3.0.3" +version = "3.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -3567,18 +3183,26 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "gearhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "libc", "log", "rustversion", @@ -3625,9 +3249,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", @@ -3638,9 +3262,9 @@ dependencies = [ [[package]] name = "geoarrow-array" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1cc4106ac0a0a512c398961ce95d8150475c84a84e17c4511c3643fa120a17" +checksum = "dafe7b7de3fab1a8b7099fd6a6434ca955fa65065f9c19f0f8a133693f3c2b0e" dependencies = [ "arrow-array", "arrow-buffer", @@ -3654,9 +3278,9 @@ dependencies = [ [[package]] name = "geoarrow-expr-geo" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa84300361ce57fb875bcaa6e32b95b0aff5c6b1af692b936bdd58ff343f4394" +checksum = "8e4a62ac19c86827c6ec81ea584594b3ee96db5a8119b9774d3466c6b373c434" dependencies = [ "arrow-array", "arrow-buffer", @@ -3668,9 +3292,9 @@ dependencies = [ [[package]] name = "geoarrow-schema" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97be4e9f523f92bd6a0e0458323f4b783d073d011664decd8dbf05651704f34" +checksum = "4d4a7edb2a1d87024a93805332a9c8184a0354836271d42c0d18cf628a5e3cd0" dependencies = [ "arrow-schema", "geo-traits", @@ -3681,12 +3305,13 @@ dependencies = [ [[package]] name = "geodatafusion" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773cfa1fb0d7f7661b76b3fde00f3ffd8e0ff7b3635096f0ff6294fe5ca62a2b" +checksum = "fecbdd00d0fff2b04635c1b1e4129c217908f0c2d17539e0a2275308afce2552" dependencies = [ "arrow-arith", "arrow-array", + "arrow-buffer", "arrow-schema", "datafusion", "geo", @@ -3701,41 +3326,30 @@ dependencies = [ [[package]] name = "geographiclib-rs" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f611040a2bb37eaa29a78a128d1e92a378a03e0b6e66ae27398d42b1ba9a7841" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" dependencies = [ "libm", ] [[package]] name = "geohash" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb94b1a65401d6cbf22958a9040aa364812c26674f841bee538b12c135db1e6" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" dependencies = [ "geo-types", "libm", ] -[[package]] -name = "getrandom" -version = "0.1.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" -dependencies = [ - "cfg-if", - "libc", - "wasi 0.9.0+wasi-snapshot-preview1", -] - [[package]] name = "getrandom" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -3748,11 +3362,23 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", - "js-sys", + "cfg-if 1.0.4", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] @@ -3762,6 +3388,26 @@ version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "glob" version = "0.3.3" @@ -3781,68 +3427,79 @@ dependencies = [ ] [[package]] -name = "google-cloud-auth" -version = "0.18.0" +name = "goosefs-sdk" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5572275b7f06b6fde8eec61a23d87c83aae362bee586bbeb8773b3f98658ae81" +checksum = "e1ea4eee6dcbc31b25ab4fd577adc55b677d2bed3aa3016c44c58fbe1b2298a5" dependencies = [ + "arc-swap", "async-trait", - "base64 0.22.1", - "derive_builder 0.20.2", - "http 1.4.0", - "reqwest", - "rustls 0.23.36", - "rustls-pemfile", + "bytes", + "dashmap", + "fastrand", + "futures", + "hostname", + "io-uring", + "itoa", + "libc", + "lru 0.18.5", + "memmap2", + "moka", + "prost", + "prost-types", + "rand 0.9.5", + "reqwest 0.12.28", "serde", - "serde_json", "thiserror 2.0.18", - "time", "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tracing", + "uuid", + "xxhash-rust", ] [[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" +name = "goosefs-sdk" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "1e471f7bce40936cf2023855c7350e31e11b3f703eb7cb2ce73c9ffc451f87d2" dependencies = [ + "arc-swap", + "async-trait", "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", + "dashmap", + "fastrand", + "futures", + "hostname", + "itoa", + "prost", + "rand 0.9.5", + "serde", + "thiserror 2.0.18", "tokio", - "tokio-util", + "tokio-stream", + "tonic", + "tonic-prost", "tracing", + "uuid", + "xxhash-rust", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap", + "http 1.4.2", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3855,7 +3512,8 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "bytemuck", + "cfg-if 1.0.4", "crunchy", "num-traits", "zerocopy", @@ -3870,15 +3528,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -3886,8 +3546,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", ] @@ -3902,6 +3560,23 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heapless" version = "0.8.0" @@ -3936,16 +3611,16 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "629d8f3bbeda9d148036d6b0de0a3ab947abd08ce90626327fc3547a49d59d97" dependencies = [ - "dirs 6.0.0", + "dirs", "futures", - "http 1.4.0", + "http 1.4.2", "indicatif", "libc", "log", "native-tls", "num_cpus", - "rand 0.9.2", - "reqwest", + "rand 0.9.5", + "reqwest 0.12.28", "serde", "serde_json", "thiserror 2.0.18", @@ -3955,28 +3630,58 @@ dependencies = [ ] [[package]] -name = "hmac" -version = "0.12.1" +name = "hf-xet" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ - "digest", + "anyhow", + "async-trait", + "bytes", + "http 1.4.2", + "more-asserts", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "windows-sys 0.61.2", + "digest 0.10.7", ] [[package]] -name = "htmlescape" -version = "0.3.1" +name = "hmac" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" +dependencies = [ + "digest 0.11.3", +] + +[[package]] +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "windows-link", +] [[package]] name = "http" @@ -3991,9 +3696,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -4017,7 +3722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -4028,31 +3733,11 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] -[[package]] -name = "http-types" -version = "2.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" -dependencies = [ - "anyhow", - "async-channel 1.9.0", - "base64 0.13.1", - "futures-lite 1.13.0", - "infer", - "pin-project-lite", - "rand 0.7.3", - "serde", - "serde_json", - "serde_qs", - "serde_urlencoded", - "url", -] - [[package]] name = "httparse" version = "1.10.1" @@ -4067,52 +3752,36 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] -name = "hyper" -version = "0.14.32" +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" 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", + "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -4120,35 +3789,32 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", + "http 1.4.2", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", + "tower-service", + "webpki-roots 1.0.8", ] [[package]] -name = "hyper-rustls" -version = "0.27.7" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "hyper", "hyper-util", - "rustls 0.23.36", - "rustls-native-certs", - "rustls-pki-types", + "pin-project-lite", "tokio", - "tokio-rustls 0.26.4", "tower-service", - "webpki-roots 1.0.6", ] [[package]] @@ -4159,7 +3825,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "native-tls", "tokio", @@ -4177,14 +3843,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", - "hyper 1.8.1", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", "system-configuration", "tokio", "tower-service", @@ -4270,35 +3936,58 @@ 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", ] +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[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", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + [[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", @@ -4310,15 +3999,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", @@ -4330,18 +4019,20 @@ 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", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -4349,6 +4040,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "ident_case" version = "1.0.1" @@ -4368,59 +4080,35 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] -name = "include-flate" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" -dependencies = [ - "include-flate-codegen", - "include-flate-compress", - "libflate", - "zstd", -] - -[[package]] -name = "include-flate-codegen" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" -dependencies = [ - "include-flate-compress", - "libflate", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", - "zstd", -] - -[[package]] -name = "include-flate-compress" -version = "0.3.1" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ - "libflate", - "zstd", + "autocfg", + "hashbrown 0.12.3", + "serde", ] [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -4436,12 +4124,6 @@ dependencies = [ "web-time", ] -[[package]] -name = "infer" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" - [[package]] name = "inferno" version = "0.11.21" @@ -4449,7 +4131,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "232929e1d75fe899576a3d5c7416ad0d88dbfbb3c3d6aa00873a7408a50ddb88" dependencies = [ "ahash", - "indexmap", + "indexmap 2.14.0", "is-terminal", "itoa", "log", @@ -4470,15 +4152,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "instant" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" -dependencies = [ - "cfg-if", -] - [[package]] name = "integer-encoding" version = "3.0.4" @@ -4486,20 +4159,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] -name = "ipnet" -version = "2.11.0" +name = "io-uring" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "libc", +] [[package]] -name = "iri-string" -version = "0.7.10" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is-terminal" @@ -4518,15 +4192,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" -[[package]] -name = "itertools" -version = "0.10.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" -dependencies = [ - "either", -] - [[package]] name = "itertools" version = "0.11.0" @@ -4565,27 +4230,27 @@ 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" -version = "0.8.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348294e44ee7e3c42685da656490f8febc7359632544019621588902216da95c" +checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5" dependencies = [ "phf_codegen", ] [[package]] name = "jieba-rs" -version = "0.8.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766bd7012aa5ba49411ebdf4e93bddd59b182d2918e085d58dec5bb9b54b7105" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" dependencies = [ + "bytecount", "cedarwood", - "include-flate", "jieba-macros", "phf 0.13.1", "regex", @@ -4594,35 +4259,37 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "wasm-bindgen", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "jiff-tzdb" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -4633,6 +4300,55 @@ dependencies = [ "jiff-tzdb", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if 1.0.4", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +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.119", +] + [[package]] name = "jobserver" version = "0.1.34" @@ -4645,19 +4361,20 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if 1.0.4", + "futures-util", "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", @@ -4666,47 +4383,51 @@ dependencies = [ "jiff", "nom 8.0.0", "num-traits", - "ordered-float 5.1.0", - "rand 0.9.2", - "ryu", + "ordered-float 5.3.0", + "rand 0.9.5", "serde", "serde_json", + "zmij", ] [[package]] -name = "jsonwebtoken" -version = "9.3.1" +name = "kanaria" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "c0f9d9652540055ac4fded998a73aca97d965899077ab1212587437da44196ff" dependencies = [ - "base64 0.22.1", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", + "bitflags 1.3.2", ] [[package]] -name = "kanaria" -version = "0.2.0" +name = "konst" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0f9d9652540055ac4fded998a73aca97d965899077ab1212587437da44196ff" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" dependencies = [ - "bitflags 1.3.2", + "const_panic", + "konst_proc_macros", + "typewit", ] +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + [[package]] name = "lance" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "all_asserts", "approx", + "arc-swap", "arrow", "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -4719,11 +4440,13 @@ dependencies = [ "aws-credential-types", "aws-sdk-dynamodb", "aws-sdk-s3", + "blake3", "byteorder", "bytes", "chrono", "clap", "criterion", + "crossbeam-queue", "crossbeam-skiplist", "dashmap", "datafusion", @@ -4731,53 +4454,59 @@ dependencies = [ "datafusion-functions", "datafusion-physical-expr", "datafusion-physical-plan", - "deepsize", - "dirs 5.0.1", + "datafusion-substrait", "either", "env_logger", + "fst", "futures", "geo-types", "geoarrow-array", "geoarrow-schema", "half", "humantime", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", + "lance-bitpacking", "lance-core", "lance-datafusion", "lance-datagen", "lance-encoding", "lance-file", - "lance-geo", "lance-index", "lance-io", "lance-linalg", "lance-namespace", + "lance-select", "lance-table", "lance-test-macros", "lance-testing", - "lapack", + "lance-tokenizer", "libc", "log", "lzma-sys", "mock_instant", "moka", "object_store", - "paste", + "parquet", "permutation", "pin-project", "pprof", "pretty_assertions", "prost", + "prost-build", "prost-types", - "rand 0.9.2", + "protobuf-src", + "rand 0.9.5", + "rayon", + "reqwest 0.12.28", "roaring", - "rstest 0.23.0", + "rstest", + "rustc-hash", "semver", "serde", "serde_json", + "serial_test", "snafu", - "tantivy", "tempfile", "test-log", "tokio", @@ -4793,62 +4522,94 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", - "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", + "bytemuck", "bytes", + "futures", "getrandom 0.2.17", "half", "jsonb", "num-traits", - "rand 0.9.2", -] - -[[package]] -name = "lance-bitpacking" -version = "3.0.0-beta.2" -dependencies = [ - "arrayref", - "paste", - "seq-macro", + "rand 0.9.5", ] [[package]] -name = "lance-core" -version = "3.0.0-beta.2" +name = "lance-arrow-scalar" +version = "58.0.0" dependencies = [ "arrow-array", "arrow-buffer", - "arrow-schema", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "half", + "proptest", + "rstest", +] + +[[package]] +name = "lance-arrow-stats" +version = "58.0.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "arrow-select", + "lance-arrow-scalar", + "proptest", + "rstest", +] + +[[package]] +name = "lance-bitpacking" +version = "14.0.0-beta.6" +dependencies = [ + "arrayref", + "bitpacking", + "crunchy", + "paste", + "seq-macro", +] + +[[package]] +name = "lance-core" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", "async-trait", - "byteorder", + "blake3", "bytes", - "chrono", + "criterion", "datafusion-common", "datafusion-sql", - "deepsize", "futures", - "itertools 0.13.0", "lance-arrow", - "lance-testing", + "lance-derive", "libc", + "libm", "log", - "mock_instant", "moka", "num_cpus", "object_store", "pin-project", "proptest", "prost", - "rand 0.9.2", + "quick_cache", + "rand 0.9.5", "roaring", - "rstest 0.23.0", + "rstest", "serde_json", "snafu", "tempfile", @@ -4856,16 +4617,18 @@ dependencies = [ "tokio-stream", "tokio-util", "tracing", + "twox-hash", "url", ] [[package]] name = "lance-datafusion" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", @@ -4877,6 +4640,7 @@ dependencies = [ "datafusion-physical-expr", "datafusion-substrait", "futures", + "half", "jsonb", "lance-arrow", "lance-core", @@ -4885,14 +4649,16 @@ dependencies = [ "log", "pin-project", "prost", - "snafu", + "prost-build", + "protobuf-src", + "rstest", "tokio", "tracing", ] [[package]] name = "lance-datagen" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4903,16 +4669,25 @@ dependencies = [ "futures", "half", "hex", - "pprof", - "rand 0.9.2", - "rand_distr 0.5.1", + "lance-testing", + "rand 0.9.5", + "rand_distr", "rand_xoshiro", - "random_word", + "rstest", +] + +[[package]] +name = "lance-derive" +version = "14.0.0-beta.6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "lance-encoding" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4930,7 +4705,7 @@ dependencies = [ "futures", "hex", "hyperloglogplus", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", "lance-bitpacking", "lance-core", @@ -4939,17 +4714,14 @@ dependencies = [ "log", "lz4", "num-traits", - "pprof", "proptest", "prost", "prost-build", - "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.5", "rand_xoshiro", - "rstest 0.23.0", - "snafu", - "strum 0.26.3", + "rstest", + "serial_test", "test-log", "tokio", "tracing", @@ -4959,7 +4731,7 @@ dependencies = [ [[package]] name = "lance-examples" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "all_asserts", "arrow", @@ -4969,7 +4741,7 @@ dependencies = [ "env_logger", "futures", "hf-hub", - "itertools 0.13.0", + "itertools 0.14.0", "lance", "lance-core", "lance-datagen", @@ -4977,7 +4749,7 @@ dependencies = [ "lance-linalg", "object_store", "parquet", - "rand 0.9.2", + "rand 0.9.5", "tempfile", "tokenizers", "tokio", @@ -4985,11 +4757,12 @@ dependencies = [ [[package]] name = "lance-file" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-data", "arrow-schema", "arrow-select", @@ -4999,7 +4772,6 @@ dependencies = [ "bytes", "criterion", "datafusion-common", - "deepsize", "futures", "lance-arrow", "lance-core", @@ -5011,16 +4783,15 @@ dependencies = [ "log", "num-traits", "object_store", - "pprof", "pretty_assertions", "proptest", "prost", "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", - "rstest 0.23.0", - "snafu", + "rand 0.9.5", + "rstest", + "serde", "test-log", "tokio", "tracing", @@ -5028,7 +4799,7 @@ dependencies = [ [[package]] name = "lance-geo" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -5042,19 +4813,20 @@ dependencies = [ [[package]] name = "lance-index" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "approx", + "arc-swap", "arrow", "arrow-arith", "arrow-array", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", - "async-channel 2.5.0", + "async-channel", "async-recursion", "async-trait", - "bitpacking", "bitvec", "bytes", "chrono", @@ -5064,9 +4836,7 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "datafusion-sql", - "deepsize", - "dirs 6.0.0", + "dirs", "env_logger", "fst", "futures", @@ -5075,43 +4845,48 @@ dependencies = [ "geoarrow-array", "geoarrow-schema", "half", - "itertools 0.13.0", + "hashbrown 0.16.1", + "hex", + "itertools 0.14.0", "jieba-rs", "jsonb", "lance-arrow", + "lance-arrow-stats", + "lance-bitpacking", "lance-core", "lance-datafusion", "lance-datagen", "lance-encoding", "lance-file", "lance-geo", + "lance-index-core", "lance-io", "lance-linalg", + "lance-select", "lance-table", "lance-testing", - "libm", - "lindera", - "lindera-tantivy", + "lance-tokenizer", + "libc", + "libsais-rs", "log", "ndarray", "num-traits", "object_store", - "pprof", "prost", "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", - "rand_distr 0.5.1", + "rand 0.9.5", + "rand_distr", "rangemap", "rayon", + "regex-syntax", "roaring", - "rstest 0.23.0", + "rstest", "serde", "serde_json", + "serial_test", "smallvec", - "snafu", - "tantivy", "tempfile", "test-log", "tokio", @@ -5120,19 +4895,36 @@ dependencies = [ "uuid", ] +[[package]] +name = "lance-index-core" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-schema", + "arrow-select", + "async-trait", + "bytes", + "datafusion", + "datafusion-common", + "datafusion-expr", + "futures", + "lance-core", + "lance-io", + "lance-select", + "prost-types", + "roaring", + "serde", + "serde_json", + "tokio", +] + [[package]] name = "lance-io" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", - "arrow-arith", "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", "arrow-schema", - "arrow-select", - "async-recursion", "async-trait", "aws-config", "aws-credential-types", @@ -5140,35 +4932,47 @@ dependencies = [ "bytes", "chrono", "criterion", - "deepsize", "futures", - "lance-arrow", + "goosefs-sdk 0.2.2", + "http 1.4.2", + "io-uring", "lance-core", "lance-namespace", + "lance-testing", "log", + "metrics", + "metrics-util", "mock_instant", "mockall", + "moka", "object_store", "object_store_opendal", "opendal", + "opendal-service-goosefs", "path_abs", "pin-project", - "pprof", "prost", - "rand 0.9.2", - "rstest 0.23.0", + "rand 0.9.5", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", + "rstest", "serde", - "shellexpand", - "snafu", + "serde_json", + "serial_test", + "tempfile", "test-log", "tokio", "tracing", + "tracing-mock", "url", + "uuid", + "wiremock", ] [[package]] name = "lance-linalg" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "approx", "arrow-array", @@ -5176,42 +4980,40 @@ dependencies = [ "arrow-schema", "cc", "criterion", - "deepsize", "half", "lance-arrow", "lance-core", "lance-testing", "num-traits", - "pprof", "proptest", - "rand 0.9.2", + "rand 0.9.5", + "rayon", + "rstest", ] [[package]] name = "lance-namespace" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", - "tempfile", - "tokio", ] [[package]] name = "lance-namespace-datafusion" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ - "arrow", "arrow-array", "arrow-schema", "async-trait", "dashmap", "datafusion", - "datafusion-sql", "lance", "lance-namespace", "lance-namespace-impls", @@ -5221,64 +5023,84 @@ dependencies = [ [[package]] name = "lance-namespace-impls" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-ipc", "arrow-schema", "async-trait", "aws-config", - "aws-credential-types", "aws-sdk-sts", "axum", - "azure_core", - "azure_identity", - "azure_storage", - "azure_storage_blobs", "base64 0.22.1", "bytes", "chrono", + "datafusion-common", + "datafusion-physical-plan", "futures", - "google-cloud-auth", + "hmac 0.12.1", "lance", "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", + "lance-table", "log", "object_store", - "rand 0.9.2", - "reqwest", - "rstest 0.23.0", + "quick-xml 0.40.1", + "rand 0.9.5", + "reqwest 0.12.28", + "ring", + "roaring", + "rstest", + "rustls-pki-types", "serde", "serde_json", - "sha2", - "snafu", + "sha2 0.10.9", "tempfile", - "time", "tokio", "tower", "tower-http 0.5.2", "url", + "uuid", "wiremock", ] [[package]] name = "lance-namespace-reqwest-client" -version = "0.4.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2acdba67f84190067532fce07b51a435dd390d7cdc1129a05003e5cb3274cf0" +checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1" dependencies = [ - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", + "serde_with", "url", ] +[[package]] +name = "lance-select" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "byteorder", + "criterion", + "itertools 0.14.0", + "lance-core", + "proptest", + "roaring", + "tracing", +] + [[package]] name = "lance-table" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -5288,30 +5110,31 @@ dependencies = [ "async-trait", "aws-credential-types", "aws-sdk-dynamodb", + "blake3", "byteorder", "bytes", "chrono", "criterion", - "deepsize", "futures", "lance-arrow", "lance-core", "lance-datagen", "lance-file", "lance-io", + "lance-select", + "lance-testing", "log", "object_store", - "pprof", "pretty_assertions", "proptest", "prost", "prost-build", "prost-types", "protobuf-src", - "rand 0.9.2", + "rand 0.9.5", "rangemap", "roaring", - "rstest 0.23.0", + "rstest", "semver", "serde", "serde_json", @@ -5324,73 +5147,61 @@ dependencies = [ [[package]] name = "lance-test-macros" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "lance-testing" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-schema", + "criterion", "lance-arrow", "num-traits", - "rand 0.9.2", + "pprof", + "rand 0.9.5", +] + +[[package]] +name = "lance-tokenizer" +version = "14.0.0-beta.6" +dependencies = [ + "frostem", + "icu_segmenter", + "jieba-rs", + "lindera", + "serde", + "stop-words", + "unicode-normalization", ] [[package]] name = "lance-tools" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "clap", "lance-core", "lance-file", "lance-io", "object_store", - "snafu", "tokio", "url", ] -[[package]] -name = "lapack" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad676a6b4df7e76a9fd80a0c50c619a3948d6105b62a0ab135f064d99c51d207" -dependencies = [ - "lapack-sys", - "libc", - "num-complex", -] - -[[package]] -name = "lapack-sys" -version = "0.14.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "447f56c85fb410a7a3d36701b2153c1018b1d2b908c5fbaf01c1b04fac33bcbe" -dependencies = [ - "libc", -] - [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin 0.9.8", + "spin 0.9.9", ] -[[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" @@ -5450,33 +5261,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[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" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" -dependencies = [ - "core2", - "hashbrown 0.16.1", - "rle-decode-fast", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -5486,182 +5273,114 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall 0.7.0", +] + +[[package]] +name = "libsais-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fe164dbd47ea0c20e78a121c980ef673326905f1d4fba55e3645a20ef6717f" +dependencies = [ + "rayon", ] [[package]] name = "lindera" -version = "0.44.1" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50aba4ef41052280722f2120f65606b9218e8718032a3c752b953c4d8091f02e" +checksum = "74cda79d7161e99b414e4d292ff673cc3f8d22f070d8be3b6185c033363a9216" dependencies = [ "anyhow", - "bincode", "byteorder", "csv", + "daachorse", "kanaria", - "lindera-cc-cedict", "lindera-dictionary", - "lindera-ipadic", - "lindera-ipadic-neologd", - "lindera-ko-dic", - "lindera-unidic", + "log", "once_cell", + "percent-encoding", "regex", "serde", "serde_json", - "serde_yaml", - "strum 0.27.2", - "strum_macros 0.27.2", + "serde_yaml_ng", + "strum", + "strum_macros", "unicode-blocks", "unicode-normalization", "unicode-segmentation", - "yada", -] - -[[package]] -name = "lindera-cc-cedict" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d77e7a0830fd60f23828ad914439997288c1d2cdd9e269be67f967c27b56350" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", + "url", ] [[package]] name = "lindera-dictionary" -version = "0.44.1" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489cc70922782af3fd397c0e130846caefe1c15b27c2211aac8f88a9f4590aaf" +checksum = "e2385456ca9fe87c29072c5f156b52fdd5e28d5b5738ddfb3979501dbd736530" dependencies = [ "anyhow", - "bincode", "byteorder", "csv", + "daachorse", "derive_builder 0.20.2", - "encoding", "encoding_rs", "encoding_rs_io", - "flate2", "glob", "log", - "md5", "memmap2", + "num_cpus", "once_cell", - "rand 0.9.2", - "reqwest", + "regex", + "rkyv", "serde", - "tar", + "serde_json", + "strum", + "strum_macros", "thiserror 2.0.18", - "tokio", - "yada", ] [[package]] -name = "lindera-ipadic" -version = "0.44.1" +name = "link-section" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78870521431dfaf0f94ddd3484fa08367e9d354fc8c708572f2f00007225ddfa" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", -] +checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" [[package]] -name = "lindera-ipadic-neologd" -version = "0.44.1" +name = "linktime-proc-macro" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abcb3dc3056e5c683e12c2c5e8d40076f7ecfd7bd46f5fc0e4ae9e58152b5d85" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", -] +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" [[package]] -name = "lindera-ko-dic" -version = "0.44.1" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e99316158bab14f0256d912055521ca784f76c63e7460db8a74775c5dc1f8bc2" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", -] +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] -name = "lindera-tantivy" -version = "0.44.1" +name = "litemap" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d0d17afa51b4f4cd2f3e50d4c0845800f3a774ed7f706612fbd91eba0c84b41" -dependencies = [ - "lindera", - "tantivy", - "tantivy-tokenizer-api", -] +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" [[package]] -name = "lindera-unidic" -version = "0.44.1" +name = "lock_api" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52672945166c14276bbba25e4ec79d7e126db1b503c0a6aa07ffc0141ae15cfa" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" - -[[package]] -name = "litemap" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" - -[[package]] -name = "lock_api" -version = "0.4.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" dependencies = [ "scopeguard", ] [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -5669,7 +5388,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "generator", "scoped-tls", "tracing", @@ -5678,20 +5397,20 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] name = "lru" -version = "0.16.3" +version = "0.18.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dc47f592c06f33f8e3aea9591776ec7c9f9e4124778ff8a3c3b87159f7e593" +checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.17.1", ] [[package]] @@ -5721,15 +5440,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" - -[[package]] -name = "lz4_flex" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" dependencies = [ "twox-hash", ] @@ -5795,40 +5508,74 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ - "cfg-if", - "digest", + "cfg-if 1.0.4", + "digest 0.10.7", ] [[package]] -name = "md5" -version = "0.8.0" +name = "md-5" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if 1.0.4", + "digest 0.11.3", +] [[package]] -name = "measure_time" -version = "0.9.0" +name = "mea" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" dependencies = [ - "log", + "slab", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "aho-corasick", + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "metrics", + "ordered-float 4.6.0", + "quanta", + "radix_trie", + "rand 0.9.5", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -5863,9 +5610,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -5874,17 +5621,17 @@ dependencies = [ [[package]] name = "mock_instant" -version = "0.6.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" +checksum = "9bb517913cfcfb9eeda59f36020269075a152701a01606c612f547e4890be399" [[package]] name = "mockall" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39a6bfcc6c8c7eed5ee98b9c3e33adc726054389233e201c95dab2d41a3839d2" +checksum = "f58d964098a5f9c6b63d0798e5372fd04708193510a7af313c22e9f29b7b620b" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "downcast", "fragile", "mockall_derive", @@ -5894,28 +5641,28 @@ dependencies = [ [[package]] name = "mockall_derive" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ca3004c2efe9011bd4e461bd8256445052b9615405b4f7ea43fc8ca5c20898" +checksum = "ca41ce716dda6a9be188b385aa78ee5260fc25cd3802cb2a8afdc6afbe6b6dbf" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "moka" -version = "0.12.13" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", "crossbeam-epoch", "crossbeam-utils", "equivalent", - "event-listener 5.4.1", + "event-listener", "futures-util", "parking_lot", "portable-atomic", @@ -5943,9 +5690,15 @@ checksum = "e4db6d5580af57bf992f59068d4ea26fd518574ff48d7639b255a36f9de6e7e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "multimap" version = "0.10.1" @@ -5953,24 +5706,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "murmurhash32" -version = "0.3.1" +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "native-tls" -version = "0.2.14" +version = "0.2.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" dependencies = [ "libc", "log", "openssl", - "openssl-probe 0.1.6", + "openssl-probe", "openssl-sys", "schannel", - "security-framework 2.11.1", + "security-framework", "security-framework-sys", "tempfile", ] @@ -5990,6 +5757,15 @@ dependencies = [ "rawpointer", ] +[[package]] +name = "nibble_vec" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" +dependencies = [ + "smallvec", +] + [[package]] name = "nix" version = "0.26.4" @@ -5997,7 +5773,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b" dependencies = [ "bitflags 1.3.2", - "cfg-if", + "cfg-if 1.0.4", "libc", ] @@ -6020,6 +5796,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -6050,7 +5835,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -6066,9 +5851,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-format" @@ -6122,9 +5907,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", @@ -6132,48 +5917,48 @@ 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", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] -name = "num_threads" -version = "0.1.7" +name = "number_prefix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c7398b9c8b70908f6371f47ed36737907c87c52af34c268fed0bf0ceb92ead9" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "libc", + "bitflags 2.13.0", ] [[package]] -name = "number_prefix" -version = "0.4.0" +name = "objc2-io-kit" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "830b246a0e5f20af87141b25c173cd1b609bd7779a4617d6ec582abaf90870f3" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] [[package]] -name = "oauth2" -version = "4.4.2" +name = "objc2-system-configuration" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38841cdd844847e3e7c8d29cef9dcfed8877f8f56f9071f77843ecf3baf937f" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" dependencies = [ - "base64 0.13.1", - "chrono", - "getrandom 0.2.17", - "http 0.2.12", - "rand 0.8.5", - "serde", - "serde_json", - "serde_path_to_error", - "sha2", - "thiserror 1.0.69", - "url", + "objc2-core-foundation", ] [[package]] @@ -6187,30 +5972,32 @@ dependencies = [ [[package]] name = "object_store" -version = "0.12.5" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" dependencies = [ "async-trait", "base64 0.22.1", "bytes", "chrono", "form_urlencoded", - "futures", - "http 1.4.0", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", "http-body-util", "httparse", "humantime", - "hyper 1.8.1", + "hyper", "itertools 0.14.0", - "md-5", + "md-5 0.10.6", "parking_lot", "percent-encoding", - "quick-xml 0.38.4", - "rand 0.9.2", - "reqwest", + "quick-xml 0.39.4", + "rand 0.10.1", + "reqwest 0.12.28", "ring", - "rustls-pemfile", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", @@ -6225,14 +6012,15 @@ dependencies = [ [[package]] name = "object_store_opendal" -version = "0.55.0" +version = "0.58.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113ab0769e972eee585e57407b98de08bda5354fa28e8ba4d89038d6cb6a8991" +checksum = "88f165780495c17aa3ce86846600504198c3fffd99073521552751c2430fa6ac" dependencies = [ "async-trait", "bytes", "chrono", "futures", + "mea", "object_store", "opendal", "pin-project", @@ -6241,9 +6029,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" @@ -6259,11 +6047,11 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "onig" -version = "6.5.1" +version = "6.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "336b9c63443aceef14bea841b899035ae3abe89b7c486aaf4c5bd8aafedac3f0" +checksum = "0cc3cbf698f9438986c11a880c90a6d04b9de27575afd28bbf45b154b6c709e2" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "libc", "once_cell", "onig_sys", @@ -6271,9 +6059,9 @@ dependencies = [ [[package]] name = "onig_sys" -version = "69.9.1" +version = "69.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f86c6eef3d6df15f23bcfb6af487cbd2fed4e5581d58d5bf1f5f8b7f6727dc" +checksum = "1e68317604e77e53b85896388e1a803c1d21b74c899ec9e5e1112db90735edd7" dependencies = [ "cc", "pkg-config", @@ -6287,208 +6075,453 @@ checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" [[package]] name = "opendal" -version = "0.55.0" +version = "0.58.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33dbff14cc9bb085224256d6a81289d2f3202e85b06f408d42534b42162a4231" +dependencies = [ + "ctor", + "opendal-core", + "opendal-http-transport-reqwest", + "opendal-layer-concurrent-limit", + "opendal-layer-logging", + "opendal-layer-retry", + "opendal-layer-timeout", + "opendal-service-azblob", + "opendal-service-azdls", + "opendal-service-cos", + "opendal-service-gcs", + "opendal-service-goosefs", + "opendal-service-hf", + "opendal-service-oss", + "opendal-service-s3", + "opendal-service-tos", +] + +[[package]] +name = "opendal-core" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +checksum = "48dbcef97d3eb7591db2c18d5cae95c836bcce07359b98d98dd6f4e861eb77b7" dependencies = [ "anyhow", - "backon", - "base64 0.22.1", + "asyncband", + "base64 0.23.0", "bytes", - "crc32c", "futures", - "getrandom 0.2.17", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", "jiff", "log", - "md-5", + "md-5 0.11.0", "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest", + "quick-xml 0.41.0", + "reqsign-core", "serde", "serde_json", - "sha2", "tokio", "url", "uuid", + "web-time", ] [[package]] -name = "openssl" -version = "0.10.75" +name = "opendal-http-transport-reqwest" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +checksum = "85663452ea32bbc17e8f79ab29788c846d116ec7de31451be9c787e462dcb36c" dependencies = [ - "bitflags 2.10.0", - "cfg-if", - "foreign-types", - "libc", - "once_cell", - "openssl-macros", - "openssl-sys", + "bytes", + "futures", + "http 1.4.2", + "http-body 1.0.1", + "opendal-core", + "reqwest 0.13.4", ] [[package]] -name = "openssl-macros" -version = "0.1.1" +name = "opendal-layer-concurrent-limit" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +checksum = "03f9e144b5228d741c3763ade8711d9b72e5fb6d998e779f2d7a09da0b5a3eba" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.114", + "asyncband", + "futures", + "http 1.4.2", + "opendal-core", ] [[package]] -name = "openssl-probe" -version = "0.1.6" +name = "opendal-layer-logging" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" +checksum = "c2de17c61cd32e9d8d7d8efb91795e714dbccbafbc3c4e219e1542f4d6324161" +dependencies = [ + "log", + "opendal-core", +] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "opendal-layer-retry" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "e94db301964a25366090484d61e6da16d5979cc8faf02c3e12210dc74fafed38" +dependencies = [ + "backon", + "log", + "opendal-core", +] [[package]] -name = "openssl-sys" -version = "0.9.111" +name = "opendal-layer-timeout" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +checksum = "08956ddda07465449bfd48825f4f0f25e0351278ac974eaa659895d9d74f2c80" dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", + "opendal-core", + "tokio", ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "opendal-service-azblob" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "6d278d2fb57661947d1c9fb44047e432b2782c48dc085b7abc99fe6e18c26cf8" +dependencies = [ + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml 0.41.0", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "sha2 0.11.0", + "uuid", +] [[package]] -name = "ordered-float" -version = "2.10.1" +name = "opendal-service-azdls" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +checksum = "2d564484a8f7d091827e825cfc91ed45bd48e64d262451ee041fe843db81bd8a" dependencies = [ - "num-traits", + "asyncband", + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml 0.41.0", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "serde_json", ] [[package]] -name = "ordered-float" -version = "5.1.0" +name = "opendal-service-azure-common" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "cfcc1bfdac4f54d9018c462dd32ad9e2f68fdf584f825c811550c8ffc87894cd" dependencies = [ - "num-traits", + "http 1.4.2", + "opendal-core", ] [[package]] -name = "ordered-multimap" -version = "0.7.3" +name = "opendal-service-cos" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +checksum = "bb021c128ebde42e6f3e719d4cfed27e994017aa503a7a1e5818bdb61fd67dc9" dependencies = [ - "dlv-list", - "hashbrown 0.14.5", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml 0.41.0", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-tencent-cos", + "serde", ] [[package]] -name = "outref" -version = "0.5.2" +name = "opendal-service-gcs" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +checksum = "da1f2a8c975fd22fea01f0409bcb8774f1ac02ad79863a3490098203121555d3" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "quick-xml 0.41.0", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", + "serde", + "serde_json", + "tokio", +] [[package]] -name = "ownedbytes" -version = "0.9.0" +name = "opendal-service-goosefs" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +checksum = "89fd71b80078f2983bd363e322fbebfa6a46d5fc56f17e4f23d76d5edb31226b" dependencies = [ - "stable_deref_trait", + "bytes", + "goosefs-sdk 0.1.9", + "log", + "opendal-core", + "serde", + "tokio", ] [[package]] -name = "p256" -version = "0.11.1" +name = "opendal-service-hf" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51f44edd08f51e2ade572f141051021c5af22677e42b7dd28a88155151c33594" +checksum = "c8a3c8ec0c2918fa23f258fa28822f222ec8c1e3a501ded665b78bc65a9d7734" dependencies = [ - "ecdsa", - "elliptic-curve", - "sha2", + "bytes", + "hf-xet", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "serde", + "serde_json", ] [[package]] -name = "parking" -version = "2.2.1" +name = "opendal-service-oss" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +checksum = "8e3ce7a2ceb925e0f28f545b169eb57d04cec6116aae94b8584d2bdbe7d456c6" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml 0.41.0", + "reqsign-aliyun-oss", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "opendal-service-s3" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "c64335f9f24ccb62ac36f1d976342b48611a75ba61979813a4f78a4ebd94de42" dependencies = [ - "lock_api", - "parking_lot_core", + "base64 0.23.0", + "bytes", + "crc-fast", + "http 1.4.2", + "log", + "md-5 0.11.0", + "opendal-core", + "quick-xml 0.41.0", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "url", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "opendal-service-tos" +version = "0.58.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "70c3c507c3a565b2feb4c7b5f653436acd34ca59ffc842a7671b5498b13b76bf" dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", + "bytes", + "http 1.4.2", + "opendal-core", + "quick-xml 0.41.0", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-volcengine-tos", + "serde", + "serde_json", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall", + "smallvec", "windows-link", ] [[package]] name = "parquet" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6a2926a30477c0b95fea6c28c3072712b139337a242c2cc64817bdc20a8854" +checksum = "d298093b2dec60289dce0684c986d0f7679e9dd15771c2c65406e1aaf604a704" dependencies = [ "ahash", "arrow-array", "arrow-buffer", - "arrow-cast", "arrow-data", "arrow-ipc", "arrow-schema", "arrow-select", "base64 0.22.1", - "brotli", "bytes", "chrono", - "flate2", "futures", "half", - "hashbrown 0.16.1", - "lz4_flex 0.12.0", + "hashbrown 0.17.1", "num-bigint", "num-integer", "num-traits", - "object_store", "paste", "seq-macro", - "simdutf8", - "snap", "thrift", "tokio", "twox-hash", - "zstd", ] [[package]] @@ -6552,8 +6585,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -6595,7 +6628,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "serde", ] @@ -6634,7 +6667,7 @@ version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" dependencies = [ - "fastrand 2.3.0", + "fastrand", "phf_shared 0.13.1", ] @@ -6658,29 +6691,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -6688,26 +6721,15 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" -[[package]] -name = "piper" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" -dependencies = [ - "atomic-waker", - "fastrand 2.3.0", - "futures-io", -] - [[package]] name = "pkcs1" 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]] @@ -6718,21 +6740,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", + "sha2 0.10.9", + "spki", ] [[package]] @@ -6741,17 +6753,17 @@ 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]] 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 = "plotters" @@ -6781,20 +6793,6 @@ dependencies = [ "plotters-backend", ] -[[package]] -name = "polling" -version = "3.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" -dependencies = [ - "cfg-if", - "concurrent-queue", - "hermit-abi", - "pin-project-lite", - "rustix 1.1.3", - "windows-sys 0.61.2", -] - [[package]] name = "portable-atomic" version = "1.13.1" @@ -6803,19 +6801,21 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[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 = [ + "serde_core", + "writeable", "zerovec", ] @@ -6827,14 +6827,13 @@ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" [[package]] name = "pprof" -version = "0.14.1" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afad4d4df7b31280028245f152d5a575083e2abb822d05736f5e47653e77689f" +checksum = "38a01da47675efa7673b032bf8efd8214f1917d89685e07e395ab125ea42b187" dependencies = [ "aligned-vec", "backtrace", - "cfg-if", - "criterion", + "cfg-if 1.0.4", "findshlibs", "inferno", "libc", @@ -6842,10 +6841,10 @@ dependencies = [ "nix", "once_cell", "smallvec", - "spin 0.10.0", + "spin 0.10.1", "symbolic-demangle", "tempfile", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] @@ -6859,9 +6858,9 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", "predicates-core", @@ -6869,15 +6868,15 @@ dependencies = [ [[package]] name = "predicates-core" -version = "1.0.9" +version = "1.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "727e462b119fe9c93fd0eb1429a5f7647394014cf3c04ab2c0350eeb09095ffa" +checksum = "cad38746f3166b4031b1a0d39ad9f954dd291e7854fcc0eed52ee41a0b50d144" [[package]] name = "predicates-tree" -version = "1.0.12" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72dd2d6d381dfb73a193c7fca536518d7caee39fc8503f74e7dc0be0531b425c" +checksum = "d0de1b847b39c8131db0467e9df1ff60e6d0562ab8e9a16e568ad0fdb372e2f2" dependencies = [ "predicates-core", "termtree", @@ -6900,62 +6899,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.119", ] [[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", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] [[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", - "bitflags 2.10.0", + "bitflags 2.13.0", "num-traits", - "rand 0.9.2", + "rand 0.9.5", "rand_chacha 0.9.0", "rand_xorshift", "regex-syntax", @@ -6966,9 +6941,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -6976,9 +6951,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -6989,28 +6964,28 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.114", + "syn 2.0.119", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] @@ -7024,6 +6999,41 @@ dependencies = [ "cmake", ] +[[package]] +name = "ptr_meta" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + [[package]] name = "quick-error" version = "1.2.3" @@ -7041,9 +7051,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.31.0" +version = "0.39.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" dependencies = [ "memchr", "serde", @@ -7051,24 +7061,35 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.37.5" +version = "0.40.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "2474bd2e5029e7ccb6abb2ba48cf2383a333851dedf495901544281590c7da7f" dependencies = [ "memchr", - "serde", ] [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -7081,8 +7102,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -7091,17 +7112,19 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -7119,16 +7142,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -7139,6 +7162,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" @@ -7146,23 +7175,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] -name = "rand" -version = "0.7.3" +name = "radix_trie" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" dependencies = [ - "getrandom 0.1.16", - "libc", - "rand_chacha 0.2.2", - "rand_core 0.5.1", - "rand_hc", + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", ] [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -7171,22 +7206,23 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.2.2" +name = "rand" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" dependencies = [ - "ppv-lite86", - "rand_core 0.5.1", + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", ] [[package]] @@ -7209,15 +7245,6 @@ dependencies = [ "rand_core 0.9.5", ] -[[package]] -name = "rand_core" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" -dependencies = [ - "getrandom 0.1.16", -] - [[package]] name = "rand_core" version = "0.6.4" @@ -7237,14 +7264,10 @@ dependencies = [ ] [[package]] -name = "rand_distr" -version = "0.4.3" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -7253,16 +7276,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.5", ] [[package]] -name = "rand_hc" -version = "0.2.0" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.5.1", + "rand_core 0.10.1", ] [[package]] @@ -7284,23 +7307,28 @@ dependencies = [ ] [[package]] -name = "random_word" -version = "0.5.2" +name = "rangemap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" + +[[package]] +name = "rapidhash" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47a395bdb55442b883c89062d6bcff25dc90fa5f8369af81e0ac6d49d78cf81" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" dependencies = [ - "ahash", - "brotli", - "paste", - "rand 0.9.2", - "unicase", + "rustversion", ] [[package]] -name = "rangemap" -version = "1.7.1" +name = "raw-cpuid" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.0", +] [[package]] name = "rawpointer" @@ -7310,9 +7338,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", @@ -7340,50 +7368,59 @@ dependencies = [ ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "redb" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" dependencies = [ - "bitflags 2.10.0", + "libc", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] name = "redox_users" -version = "0.4.6" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" dependencies = [ "getrandom 0.2.17", "libredox", - "thiserror 1.0.69", + "thiserror 2.0.18", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "ref-cast" +version = "1.0.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.18", + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -7410,9 +7447,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" @@ -7431,37 +7468,170 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" [[package]] -name = "reqsign" -version = "0.16.5" +name = "rend" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqsign-aliyun-oss" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "9c0f9f69a519dd6958c4b43606bb8e1278cdc76d611fc8fed4b796eee548dc0f" +dependencies = [ + "anyhow", + "form_urlencoded", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-aws-core" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" +dependencies = [ + "bytes", + "form_urlencoded", + "hex", + "http 1.4.2", + "log", + "percent-encoding", + "quick-xml 0.41.0", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", + "serde_urlencoded", + "sha1 0.11.0", +] + +[[package]] +name = "reqsign-aws-v4" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "quick-xml 0.41.0", + "reqsign-aws-core", + "reqsign-core", + "serde", +] + +[[package]] +name = "reqsign-azure-storage" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ebd8524185ce9c64063e3095f83968acfa90922f00c601a4a0f3aca15b077e" dependencies = [ "anyhow", - "async-trait", "base64 0.22.1", - "chrono", + "bytes", "form_urlencoded", - "getrandom 0.2.17", + "http 1.4.2", + "log", + "pem", + "percent-encoding", + "reqsign-core", + "rsa", + "serde", + "serde_json", + "sha1 0.11.0", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +dependencies = [ + "anyhow", + "base64 0.23.0", + "bytes", + "futures", "hex", - "hmac", - "home", - "http 1.4.0", - "jsonwebtoken", + "hmac 0.13.0", + "http 1.4.2", + "jiff", "log", - "once_cell", + "mea", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.5", - "reqwest", "rsa", - "rust-ini", "serde", "serde_json", - "sha1", - "sha2", + "sha1 0.11.0", + "sha2 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +dependencies = [ + "anyhow", + "reqsign-core", "tokio", ] +[[package]] +name = "reqsign-google" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96da0b579b846d358090cb06b9e3c2ad1375529efbe3e0c45f96bd7bcf043ea" +dependencies = [ + "form_urlencoded", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-aws-v4", + "reqsign-core", + "rsa", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "reqsign-tencent-cos" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6497dd9f6e3d1349b420521484099b284f95e8d3a65f088fccef42493a7b644" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-volcengine-tos" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7e7a3a0635e0aa9ef851577c63cba5b3c98fb584f6a6637c2fd5cc50702c99" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", +] + [[package]] name = "reqwest" version = "0.12.28" @@ -7473,12 +7643,12 @@ dependencies = [ "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.4.2", "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", @@ -7489,7 +7659,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -7498,35 +7668,78 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.8", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", - "webpki-roots 1.0.6", + "webpki-roots 1.0.8", ] [[package]] -name = "rfc6979" -version = "0.3.1" +name = "reqwest" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7743f17af12fa0b03b803ba12cd6a8d9483a587e89c69445e3909655c0b9fabb" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ - "crypto-bigint 0.4.9", - "hmac", - "zeroize", + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.2", + "reqwest 0.13.4", + "thiserror 2.0.18", + "tower-service", ] [[package]] name = "rgb" -version = "0.8.52" +version = "0.8.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce" +checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" dependencies = [ "bytemuck", ] @@ -7538,7 +7751,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", @@ -7546,16 +7759,40 @@ dependencies = [ ] [[package]] -name = "rle-decode-fast" -version = "1.0.3" +name = "rkyv" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "roaring" -version = "0.10.12" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", @@ -7573,17 +7810,17 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", - "pkcs8 0.10.2", + "pkcs8", "rand_core 0.6.4", - "sha2", - "signature 2.2.0", - "spki 0.7.3", + "sha2 0.10.9", + "signature", + "spki", "subtle", "zeroize", ] @@ -7599,18 +7836,6 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rstest" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a2c585be59b6b5dd66a9d2084aa1d8bd52fbdb806eafdeffb52791147862035" -dependencies = [ - "futures", - "futures-timer", - "rstest_macros 0.23.0", - "rustc_version", -] - [[package]] name = "rstest" version = "0.26.1" @@ -7619,25 +7844,7 @@ checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" dependencies = [ "futures-timer", "futures-util", - "rstest_macros 0.26.1", -] - -[[package]] -name = "rstest_macros" -version = "0.23.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "825ea780781b15345a146be27eaefb05085e337e869bff01b4306a4fd4a9ad5a" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.114", - "unicode-ident", + "rstest_macros", ] [[package]] @@ -7646,7 +7853,7 @@ version = "0.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "glob", "proc-macro-crate", "proc-macro2", @@ -7654,7 +7861,7 @@ dependencies = [ "regex", "relative-path", "rustc_version", - "syn 2.0.114", + "syn 2.0.119", "unicode-ident", ] @@ -7664,20 +7871,10 @@ version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "ordered-multimap", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-demangle" version = "0.1.27" @@ -7686,9 +7883,9 @@ checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -7701,104 +7898,87 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.21.12" +version = "0.23.45" 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.36" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ - "openssl-probe 0.2.1", + "openssl-probe", "rustls-pki-types", "schannel", - "security-framework 3.5.1", -] - -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", + "security-framework", ] [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "ring", - "untrusted", + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -7826,9 +8006,15 @@ dependencies = [ [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe-transmute" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" [[package]] name = "salsa20" @@ -7850,9 +8036,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", ] @@ -7869,6 +8055,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -7878,7 +8088,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -7901,53 +8111,16 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "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 = "2.11.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" -dependencies = [ - "bitflags 2.10.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "sha2 0.10.9", ] [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -7956,9 +8129,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -7966,9 +8139,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", @@ -7982,9 +8155,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -7992,22 +8165,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.3", ] [[package]] @@ -8018,15 +8191,16 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -8045,17 +8219,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "serde_qs" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" -dependencies = [ - "percent-encoding", - "serde", - "thiserror 1.0.69", -] - [[package]] name = "serde_repr" version = "0.1.20" @@ -8064,19 +8227,19 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8091,28 +8254,109 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", "itoa", "ryu", "serde", "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "sha1" version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -8121,9 +8365,20 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -8137,18 +8392,20 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ - "dirs 6.0.0", + "bstr", + "dirs", + "os_str_bytes", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -8160,64 +8417,49 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[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" -version = "0.1.5" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] -name = "simple_asn1" -version = "0.6.3" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "sketches-ddsketch" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" -dependencies = [ - "serde", -] +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "slab" @@ -8227,55 +8469,39 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snafu" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "snap" -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", + "syn 2.0.119", ] [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -8291,11 +8517,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", @@ -8303,29 +8529,19 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spin" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" 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" @@ -8333,7 +8549,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der 0.7.10", + "der", ] [[package]] @@ -8350,9 +8566,9 @@ dependencies = [ [[package]] name = "sqlparser" -version = "0.59.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4591acadbcf52f0af60eafbb2c003232b2b4cd8de5f0e9437cb8b1b59046cc0f" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" dependencies = [ "log", "sqlparser_derive", @@ -8360,20 +8576,36 @@ dependencies = [ [[package]] name = "sqlparser_derive" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "stable_deref_trait" version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] [[package]] name = "std_prelude" @@ -8387,11 +8619,20 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" +[[package]] +name = "stop-words" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68df56303396bcfb639455b3c166804aeb7994005010aab5e9e8a1277b8871d" +dependencies = [ + "serde_json", +] + [[package]] name = "str_stack" -version = "0.1.0" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9091b6114800a5f2141aee1d1b9d6ca3592ac062dc5decb3764ec5895a47b4eb" +checksum = "7f446288b699d66d0fd2e30d1cfe7869194312524b3b9252594868ed26ef056a" [[package]] name = "strsim" @@ -8407,54 +8648,33 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" -dependencies = [ - "strum_macros 0.26.4", -] - -[[package]] -name = "strum" -version = "0.27.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] - -[[package]] -name = "strum_macros" -version = "0.26.4" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", + "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "substrait" -version = "0.62.2" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fc4b483a129b9772ccb9c3f7945a472112fdd9140da87f8a4e7f1d44e045d0" +checksum = "e620ff4d5c02fd6f7752931aa74b16a26af66a63022cc1ad412c77edbe0bab47" dependencies = [ "heck", + "indexmap 2.14.0", "pbjson", "pbjson-build", "pbjson-types", @@ -8463,12 +8683,12 @@ dependencies = [ "prost-build", "prost-types", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_yaml", - "syn 2.0.114", + "syn 2.0.119", "typify", "walkdir", ] @@ -8481,9 +8701,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "symbolic-common" -version = "12.17.2" +version = "12.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "751a2823d606b5d0a7616499e4130a516ebd01a44f39811be2b9600936509c23" +checksum = "332615d90111d8eeaf86a84dc9bbe9f65d0d8c5cf11b4caccedc37754eb0dcfd" dependencies = [ "debugid", "memmap2", @@ -8493,15 +8713,21 @@ dependencies = [ [[package]] name = "symbolic-demangle" -version = "12.17.2" +version = "12.18.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79b237cfbe320601dd24b4ac817a5b68bb28f5508e33f08d42be0682cadc8ac9" +checksum = "912017718eb4d21930546245af9a3475c9dccf15675a5c215664e76621afc471" dependencies = [ "cpp_demangle", "rustc-demangle", "symbolic-common", ] +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -8515,9 +8741,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -8541,7 +8778,21 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", +] + +[[package]] +name = "sysinfo" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +dependencies = [ + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] @@ -8550,7 +8801,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -8571,179 +8822,22 @@ 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.5", - "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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "fastrand 2.3.0", - "getrandom 0.3.4", + "fastrand", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -8755,9 +8849,9 @@ checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" [[package]] name = "test-log" -version = "0.2.19" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37d53ac171c92a39e4769491c4b4dde7022c60042254b5fc044ae409d34a24d4" +checksum = "9b9c218384242b5c89b68303ab6f6fc53a312d923f0c14dc6bb860c6aeee40f1" dependencies = [ "env_logger", "test-log-macros", @@ -8765,14 +8859,24 @@ dependencies = [ ] [[package]] -name = "test-log-macros" -version = "0.2.19" +name = "test-log-core" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be35209fd0781c5401458ab66e4f98accf63553e8fae7425503e92fdd319783b" +checksum = "c26ef8b00e4d382e59f6a8ddb3cd790b3a5bb29f21a358a9a69ea2f29f13f27b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", +] + +[[package]] +name = "test-log-macros" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "944ad38adcbb71eaa682c56bceeb079e4ca82b4b3edc2a0fde5cb297b77dac8d" +dependencies = [ + "syn 2.0.119", + "test-log-core", ] [[package]] @@ -8801,7 +8905,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8812,7 +8916,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8830,7 +8934,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -8846,16 +8950,12 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", - "js-sys", - "libc", "num-conv", - "num_threads", "powerfmt", "serde_core", "time-core", @@ -8864,15 +8964,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -8889,11 +8989,12 @@ 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", + "serde_core", "zerovec", ] @@ -8909,9 +9010,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", ] @@ -8941,7 +9042,7 @@ dependencies = [ "monostate", "onig", "paste", - "rand 0.8.5", + "rand 0.8.6", "rayon", "rayon-cond", "regex", @@ -8957,9 +9058,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -8967,20 +9068,20 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -8994,12 +9095,13 @@ dependencies = [ ] [[package]] -name = "tokio-rustls" -version = "0.24.1" +name = "tokio-retry" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ - "rustls 0.21.12", + "pin-project-lite", + "rand 0.10.1", "tokio", ] @@ -9009,50 +9111,76 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", + "rustls", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.119", +] + [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -9060,13 +9188,52 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.6+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" dependencies = [ "winnow", ] +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "h2", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + [[package]] name = "tower" version = "0.5.3" @@ -9075,9 +9242,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -9089,9 +9259,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytes", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -9102,25 +9272,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -9147,6 +9317,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -9155,7 +9338,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -9180,32 +9363,55 @@ dependencies = [ ] [[package]] -name = "tracing-log" +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-mock" +version = "0.1.0-beta.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a31739d4ff16a8634c5463c75d5bf9e500596958a245d1ee5b6b98ac37658d" +dependencies = [ + "tracing", + "tracing-core", +] + +[[package]] +name = "tracing-serde" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" dependencies = [ - "log", - "once_cell", + "serde", "tracing-core", ] [[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", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -9226,18 +9432,24 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "twox-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" dependencies = [ - "rand 0.9.2", + "rand 0.10.1", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typewit" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "typify" @@ -9260,11 +9472,11 @@ dependencies = [ "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.119", "thiserror 2.0.18", "unicode-ident", ] @@ -9277,24 +9489,15 @@ checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.119", "typify-impl", ] -[[package]] -name = "tz-rs" -version = "0.6.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33851b15c848fad2cf4b105c6bb66eb9512b6f6c44a4b13f57c53c73c707e2b4" -dependencies = [ - "const_fn", -] - [[package]] name = "unarray" version = "0.1.4" @@ -9315,9 +9518,9 @@ checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -9339,9 +9542,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -9367,12 +9570,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "ureq" version = "2.12.1" @@ -9384,7 +9581,7 @@ dependencies = [ "log", "native-tls", "once_cell", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "serde", "serde_json", @@ -9403,7 +9600,6 @@ dependencies = [ "idna", "percent-encoding", "serde", - "serde_derive", ] [[package]] @@ -9432,11 +9628,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -9460,12 +9656,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vsimd" version = "0.8.0" @@ -9481,12 +9671,6 @@ dependencies = [ "libc", ] -[[package]] -name = "waker-fn" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" - [[package]] name = "walkdir" version = "2.5.0" @@ -9508,32 +9692,44 @@ dependencies = [ [[package]] name = "wasi" -version = "0.9.0+wasi-snapshot-preview1" +version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" +version = "0.14.7+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -9542,23 +9738,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -9566,22 +9758,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -9599,11 +9791,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -9619,24 +9824,46 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "webpki-root-certs" +version = "1.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webpki-roots" version = "0.26.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" dependencies = [ - "webpki-roots 1.0.6", + "webpki-roots 1.0.8", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "bf85cb06032201fa7c6f829d7db5a7e5aa45bcc0655327713065f6f0576731bf" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -9668,6 +9895,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -9681,6 +9929,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -9689,7 +9948,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -9700,7 +9959,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] @@ -9709,6 +9968,16 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + [[package]] name = "windows-registry" version = "0.6.1" @@ -9738,15 +10007,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-sys" version = "0.52.0" @@ -9783,21 +10043,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" -dependencies = [ - "windows_aarch64_gnullvm 0.48.5", - "windows_aarch64_msvc 0.48.5", - "windows_i686_gnu 0.48.5", - "windows_i686_msvc 0.48.5", - "windows_x86_64_gnu 0.48.5", - "windows_x86_64_gnullvm 0.48.5", - "windows_x86_64_msvc 0.48.5", -] - [[package]] name = "windows-targets" version = "0.52.6" @@ -9832,10 +10077,13 @@ dependencies = [ ] [[package]] -name = "windows_aarch64_gnullvm" -version = "0.48.5" +name = "windows-threading" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] [[package]] name = "windows_aarch64_gnullvm" @@ -9849,12 +10097,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[package]] -name = "windows_aarch64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" - [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -9867,12 +10109,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[package]] -name = "windows_i686_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" - [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -9897,12 +10133,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[package]] -name = "windows_i686_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" - [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -9915,12 +10145,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[package]] -name = "windows_x86_64_gnu" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" - [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -9933,12 +10157,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" - [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -9951,12 +10169,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[package]] -name = "windows_x86_64_msvc" -version = "0.48.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" - [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -9971,9 +10183,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] @@ -9988,9 +10200,9 @@ dependencies = [ "base64 0.22.1", "deadpool", "futures", - "http 1.4.0", + "http 1.4.2", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "log", "once_cell", @@ -10003,9 +10215,9 @@ dependencies = [ [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wkb" @@ -10034,9 +10246,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" @@ -10048,13 +10260,144 @@ dependencies = [ ] [[package]] -name = "xattr" -version = "1.6.1" +name = "xet-client" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "crc32fast", + "futures", + "http 1.4.2", + "hyper", + "more-asserts", + "rand 0.10.1", + "redb", + "reqwest 0.13.4", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio_with_wasm", + "tracing", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "countio", + "futures", + "futures-util", + "getrandom 0.4.3", + "heapify", + "itertools 0.14.0", + "lz4_flex", + "more-asserts", + "rand 0.10.1", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "gearhash", + "http 1.4.2", + "itertools 0.14.0", + "more-asserts", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "url", + "uuid", + "web-time", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor", + "dirs", + "futures", + "git-version", + "humantime", + "konst", "libc", - "rustix 1.1.3", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "tracing-appender", + "tracing-subscriber", + "web-time", + "whoami", + "winapi", ] [[package]] @@ -10065,15 +10408,9 @@ checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "xxhash-rust" -version = "0.8.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" - -[[package]] -name = "yada" -version = "0.5.1" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed111bd9e48a802518765906cbdadf0b45afb72b9c81ab049a3b86252adffdd" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yansi" @@ -10083,9 +10420,9 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -10094,80 +10431,82 @@ 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", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" 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", - "syn 2.0.114", + "syn 2.0.119", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[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", "zerofrom", + "zerovec", ] [[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 = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -10175,26 +10514,20 @@ 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", - "syn 2.0.114", + "syn 2.0.119", ] -[[package]] -name = "zlib-rs" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" - [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/Cargo.toml b/Cargo.toml index b080335a735..94e6f161939 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,26 +9,32 @@ members = [ "rust/lance-file", "rust/lance-geo", "rust/lance-index", + "rust/lance-index-core", "rust/lance-io", "rust/lance-linalg", "rust/lance-namespace", "rust/lance-namespace-impls", "rust/lance-namespace-datafusion", + "rust/lance-select", + "rust/lance-tokenizer", "rust/lance-table", + "rust/lance-derive", "rust/lance-test-macros", "rust/lance-testing", "rust/lance-tools", "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. -resolver = "2" +resolver = "3" [workspace.package] -version = "3.0.0-beta.2" -edition = "2021" +version = "14.0.0-beta.6" +edition = "2024" authors = ["Lance Devs <dev@lance.org>"] license = "Apache-2.0" repository = "https://github.com/lance-format/lance" @@ -47,54 +53,67 @@ categories = [ "development-tools", "science", ] -rust-version = "1.88.0" +rust-version = "1.94.0" [workspace.dependencies] +arc-swap = "1.7" libc = "0.2.176" -lance = { version = "=3.0.0-beta.2", path = "./rust/lance", default-features = false } -lance-arrow = { version = "=3.0.0-beta.2", path = "./rust/lance-arrow" } -lance-core = { version = "=3.0.0-beta.2", path = "./rust/lance-core" } -lance-datafusion = { version = "=3.0.0-beta.2", path = "./rust/lance-datafusion" } -lance-datagen = { version = "=3.0.0-beta.2", path = "./rust/lance-datagen" } -lance-encoding = { version = "=3.0.0-beta.2", path = "./rust/lance-encoding" } -lance-file = { version = "=3.0.0-beta.2", path = "./rust/lance-file" } -lance-geo = { version = "=3.0.0-beta.2", path = "./rust/lance-geo" } -lance-index = { version = "=3.0.0-beta.2", path = "./rust/lance-index" } -lance-io = { version = "=3.0.0-beta.2", path = "./rust/lance-io", default-features = false } -lance-linalg = { version = "=3.0.0-beta.2", path = "./rust/lance-linalg" } -lance-namespace = { version = "=3.0.0-beta.2", path = "./rust/lance-namespace" } -lance-namespace-impls = { version = "=3.0.0-beta.2", path = "./rust/lance-namespace-impls" } -lance-namespace-datafusion = { version = "=3.0.0-beta.2", path = "./rust/lance-namespace-datafusion" } -lance-namespace-reqwest-client = { version = "=0.4.5" } -lance-table = { version = "=3.0.0-beta.2", path = "./rust/lance-table" } -lance-test-macros = { version = "=3.0.0-beta.2", path = "./rust/lance-test-macros" } -lance-testing = { version = "=3.0.0-beta.2", path = "./rust/lance-testing" } +lance = { version = "=14.0.0-beta.6", path = "./rust/lance", default-features = false } +lance-arrow = { version = "=14.0.0-beta.6", path = "./rust/lance-arrow" } +lance-core = { version = "=14.0.0-beta.6", path = "./rust/lance-core" } +lance-datafusion = { version = "=14.0.0-beta.6", path = "./rust/lance-datafusion" } +lance-datagen = { version = "=14.0.0-beta.6", path = "./rust/lance-datagen" } +lance-derive = { version = "=14.0.0-beta.6", path = "./rust/lance-derive" } +lance-encoding = { version = "=14.0.0-beta.6", path = "./rust/lance-encoding" } +lance-file = { version = "=14.0.0-beta.6", path = "./rust/lance-file" } +lance-geo = { version = "=14.0.0-beta.6", path = "./rust/lance-geo" } +lance-index = { version = "=14.0.0-beta.6", path = "./rust/lance-index" } +lance-index-core = { version = "=14.0.0-beta.6", path = "./rust/lance-index-core" } +lance-io = { version = "=14.0.0-beta.6", path = "./rust/lance-io", default-features = false } +lance-linalg = { version = "=14.0.0-beta.6", path = "./rust/lance-linalg" } +lance-namespace = { version = "=14.0.0-beta.6", path = "./rust/lance-namespace" } +lance-namespace-impls = { version = "=14.0.0-beta.6", path = "./rust/lance-namespace-impls" } +lance-namespace-reqwest-client = "0.13.0" +lance-select = { version = "=14.0.0-beta.6", path = "./rust/lance-select" } +lance-tokenizer = { version = "=14.0.0-beta.6", path = "./rust/lance-tokenizer" } +lance-table = { version = "=14.0.0-beta.6", path = "./rust/lance-table" } +lance-test-macros = { version = "=14.0.0-beta.6", path = "./rust/lance-test-macros" } +lance-testing = { version = "=14.0.0-beta.6", path = "./rust/lance-testing" } +all_asserts = "2.3.1" approx = "0.5.1" # Note that this one does not include pyarrow -arrow = { version = "57.0.0", optional = false, features = ["prettyprint"] } -arrow-arith = "57.0.0" -arrow-array = "57.0.0" -arrow-buffer = "57.0.0" -arrow-cast = "57.0.0" -arrow-data = "57.0.0" -arrow-ipc = { version = "57.0.0", features = ["zstd"] } -arrow-ord = "57.0.0" -arrow-row = "57.0.0" -arrow-schema = "57.0.0" -arrow-select = "57.0.0" +arrow = { version = "58.0.0", optional = false, features = ["prettyprint"] } +lance-arrow-scalar = { version = "=58.0.0", path = "./rust/arrow-scalar" } +lance-arrow-stats = { version = "=58.0.0", path = "./rust/arrow-stats" } +arrow-arith = "58.0.0" +arrow-array = "58.0.0" +arrow-buffer = "58.0.0" +arrow-cast = "58.0.0" +arrow-data = "58.0.0" +arrow-ipc = { version = "58.0.0", features = ["zstd"] } +arrow-ord = "58.0.0" +arrow-row = "58.0.0" +arrow-schema = "58.0.0" +arrow-select = "58.0.0" async-recursion = "1.0" 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", + "bytemuck", ] } -lance-bitpacking = { version = "=3.0.0-beta.2", path = "./rust/compression/bitpacking" } +lance-bitpacking = { version = "=14.0.0-beta.6", path = "./rust/compression/bitpacking" } +bitpacking = "0.9" bitvec = "1" +blake3 = "1.8.5" +bytemuck = { version = "1", default-features = false, features = [ + "extern_crate_alloc", +] } bytes = "1.11.1" byteorder = "1.5" clap = { version = "4", features = ["derive"] } @@ -103,86 +122,102 @@ chrono = { version = "0.4.41", default-features = false, features = [ "now", "serde", ] } -criterion = { version = "0.5", features = [ +criterion = { version = "0.8.2", features = [ "async", "async_tokio", "html_reports", ] } crossbeam-queue = "0.3" crossbeam-skiplist = "0.1" -datafusion = { version = "51.0.0", default-features = false, features = [ - "nested_expressions", - "regex_expressions", - "unicode_expressions", +dashmap = "6" +datafusion = { version = "54.0.0", default-features = false, features = [ "crypto_expressions", - "encoding_expressions", "datetime_expressions", + "encoding_expressions", + "nested_expressions", + "regex_expressions", "sql", "string_expressions", + "unicode_expressions", ] } -datafusion-common = "51.0.0" -datafusion-functions = { version = "51.0.0", features = ["regex_expressions"] } -datafusion-sql = "51.0.0" -datafusion-expr = "51.0.0" -datafusion-ffi = "51.0.0" -datafusion-execution = "51.0.0" -datafusion-optimizer = "51.0.0" -datafusion-physical-expr = "51.0.0" -datafusion-physical-plan = "51.0.0" -datafusion-substrait = "51.0.0" -deepsize = "0.2.0" +datafusion-common = "54.0.0" +datafusion-functions = { version = "54.0.0", default-features = false, features = ["regex_expressions"] } +datafusion-sql = "54.0.0" +datafusion-expr = "54.0.0" +datafusion-physical-expr = "54.0.0" +datafusion-physical-plan = "54.0.0" +datafusion-substrait = { version = "54.0.0", default-features = false } dirs = "6.0.0" either = "1.0" +env_logger = "0.11.7" fst = { version = "0.4.7", features = ["levenshtein"] } -fsst = { version = "=3.0.0-beta.2", path = "./rust/compression/fsst" } +fsst = { version = "=14.0.0-beta.6", path = "./rust/compression/fsst" } futures = "0.3" -geoarrow-array = "0.7" -geoarrow-schema = "0.7" -geodatafusion = "0.2.0" +geoarrow-array = "0.8" +geoarrow-schema = "0.8" +geodatafusion = "0.5.0" geo-traits = "0.3.0" geo-types = "0.7.16" +hex = "0.4.3" http = "1.1.0" humantime = "2.2.0" hyperloglogplus = { version = "0.4.1", features = ["const-loop"] } -itertools = "0.13" -jieba-rs = { version = "0.8.1", default-features = false } +icu_segmenter = { version = "2.2", default-features = false, features = ["compiled_data"] } +io-uring = "0.7" +itertools = "0.14" +jieba-rs = { version = "0.10.0", default-features = false } jsonb = { version = "0.5.3", default-features = false, features = ["databend"] } libm = "0.2.15" log = "0.4" -mockall = { version = "0.13.1" } +metrics = { version = "0.24" } +metrics-util = { version = "0.19" } +mockall = { version = "0.14.0" } mock_instant = { version = "0.6.0" } moka = { version = "0.12", features = ["future", "sync"] } ndarray = { version = "0.16.1", features = ["matrixmultiply-threading"] } num-traits = "0.2" -object_store = { version = "0.12.3" } -opendal = { version = "0.55" } -object_store_opendal = { version = "0.55" } +# Pin 0.2.2 so Cargo unifies past OpenDAL 0.59.2's `^0.2.1` req. 0.2.2 keeps +# `metadata_cache_enabled` off until GOOSEFS_METADATA_CACHE_ENABLED=true. +goosefs-sdk = { version = "^0.2.1", default-features = false } +# Direct dependency so consumers can enable cache features on the same +# `opendal-service-goosefs` build as `opendal/services-goosefs`. +# Cache features (`metadata-cache`, `page-cache-io-uring`) belong on the +# consumer (`lance-io`'s `goosefs` feature), not this workspace pin. +opendal-service-goosefs = { version = "0.58.1", default-features = false } +object_store = { version = "0.13.2" } +opendal = { version = "0.58.1" } +object_store_opendal = { version = "0.58" } +parquet = { version = "58.0.0", default-features = false, features = [ + "arrow", + "async", +] } pin-project = "1.0" path_abs = "0.5" -pprof = { version = "0.14.0", features = ["flamegraph", "criterion"] } +pprof = { version = "0.15.0", features = ["flamegraph"] } +proc-macro2 = "1.0.67" proptest = "1.3.1" prost = "0.14.1" prost-build = "0.14.1" prost-types = "0.14.1" +protobuf-src = "2.1" +quote = "1.0.33" rand = { version = "0.9.1", features = ["small_rng"] } rand_distr = { version = "0.5.1" } rand_xoshiro = "0.7.0" rangemap = { version = "1.0" } rayon = "1.10" -roaring = "0.10.1" -rstest = "0.23.0" -rustc_version = "0.4" +regex-syntax = "0.8.10" +reqwest = { version = "0.12", default-features = false, features = ["json"] } +roaring = "0.11.4" +rstest = "0.26.1" serde = { version = "^1" } serde_json = { version = "1" } semver = "1.0" -shellexpand = "3.0" -slatedb = "0.3" -snafu = "0.8" -strum = "0.26" -tantivy = { version = "0.24.1", features = ["stopwords"] } -lindera = { version = "0.44.0" } -lindera-tantivy = { version = "0.44.0" } -tempfile = "3" +serial_test = "3" +snafu = "0.9" +syn = { version = "2.0.37", features = ["full"] } +lindera = { version = "3.0.7" } +tempfile = "3.10" test-log = { version = "0.2.15" } tokio = { version = "1.23", features = [ "rt-multi-thread", @@ -195,6 +230,8 @@ tokio-util = { version = "0.7.16" } tower = "0.5" tower-http = "0.5" tracing = "0.1" +tracing-mock = { version = "=0.1.0-beta.3" } +twox-hash = "2.0" url = "2.5.7" uuid = { version = "1.2", features = ["v4", "serde"] } wiremock = "0.6" @@ -221,6 +258,7 @@ incremental = false [workspace.lints.rust] unexpected_cfgs = { level = "warn", check-cfg = ['cfg(coverage,coverage_nightly)'] } +unsafe_op_in_unsafe_fn = "allow" [workspace.lints.clippy] all = { level = "deny", priority = -1 } @@ -244,3 +282,4 @@ multiple-crate-versions = "allow" # We use Vec<Range<u64>> in a lot of places and it is very common to use a single range in the vec. single_range_in_vec_init = "allow" large_futures = "deny" +disallowed_macros = "deny" diff --git a/LICENSE b/LICENSE index 79de57d6670..cd4c38213a9 100644 --- a/LICENSE +++ b/LICENSE @@ -226,3 +226,30 @@ under the MIT license: SOFTWARE. https://github.com/pola-rs/polars/blob/main/LICENSE + +-------------------------------------------------------------------------------- + +This project includes code adapted from the quickwit-oss/bitpacking crate, which +is licensed under the MIT license: + + Copyright (c) 2016 Paul Masurel + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. + +https://github.com/quickwit-oss/bitpacking/blob/main/LICENSE diff --git a/Makefile b/Makefile new file mode 100644 index 00000000000..bee88752aa1 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +.PHONY: licenses + +licenses: + cargo about generate about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c about.toml + cd python && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml + cd python && uv sync --all-extras && uv tool run pip-licenses --python .venv/bin/python --format=markdown --with-urls --output-file=PYTHON_THIRD_PARTY_LICENSES.md + cd java/lance-jni && cargo about generate ../../about.hbs -o ../RUST_THIRD_PARTY_LICENSES.html -c ../../about.toml + cd java && ./mvnw license:add-third-party -q diff --git a/README.md b/README.md index 4fe2608c9a5..08716c84ead 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,8 @@ <a href="https://lance.org">Documentation</a> • <a href="https://lance.org/community">Community</a> • -<a href="https://discord.gg/lance">Discord</a> +<a href="https://discord.gg/lance">Discord</a> • +<a href="https://groups.google.com/a/lance.org/g/dev">Mailing List</a> [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 @@ -46,7 +47,7 @@ The key features of Lance include: * **Data evolution:** Efficiently add columns with backfilled values without full table rewrites, perfect for ML feature engineering. -* **Zero-copy versioning:** ACID transactions, time travel, and automatic versioning without needing extra infrastructure. +* **Zero-copy versioning:** Automatic versioning with ACID transactions, time travel, tags, and branches—no extra infrastructure needed. * **Rich ecosystem integrations:** Apache Arrow, Pandas, Polars, DuckDB, Apache Spark, Ray, Trino, Apache Flink, and open catalogs (Apache Polaris, Unity Catalog, Apache Gravitino). @@ -55,6 +56,17 @@ For more details, see the full [Lance format specification](https://lance.org/fo > [!TIP] > Lance is in active development and we welcome contributions. Please see our [contributing guide](https://lance.org/community/contributing/) for more information. +## File format stability + +Lance releases frequently because the SDKs, integrations, and performance work are moving quickly. This does not mean the Lance file format changes incompatibly in every release. The Lance file format is identified by the `data_storage_version` stored in each dataset, and stable storage versions are a long-term compatibility contract. + +* Once a dataset is written with a stable `data_storage_version`, future Lance releases will continue to support reading that storage version. +* SDK and API compatibility is separate from file format compatibility. SDK/API changes follow semantic versioning and are documented in the [migration guide](https://lance.org/guide/migration/). +* Older Lance releases may not understand file format versions introduced later. If you run mixed Lance versions, pin `data_storage_version` for deterministic writes. +* The `next` file format alias is unstable and should only be used for experimentation, never for production data. + +For production, write data with a stable `data_storage_version`. See the [format versioning guide](https://lance.org/format/file/versioning/) for the current compatibility matrix. + ## Quick Start **Installation** @@ -66,12 +78,9 @@ pip install pylance To install a preview release: ```shell -pip install --pre --extra-index-url https://pypi.fury.io/lance-format/pylance +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/RUST_THIRD_PARTY_LICENSES.html b/RUST_THIRD_PARTY_LICENSES.html new file mode 100644 index 00000000000..e7db17f2115 --- /dev/null +++ b/RUST_THIRD_PARTY_LICENSES.html @@ -0,0 +1,18551 @@ +<html> + +<head> + <style> + @media (prefers-color-scheme: dark) { + body { + background: #333; + color: white; + } + a { + color: skyblue; + } + } + .container { + font-family: sans-serif; + max-width: 800px; + margin: 0 auto; + } + .intro { + text-align: center; + } + .licenses-list { + list-style-type: none; + margin: 0; + padding: 0; + } + .license-used-by { + margin-top: -10px; + } + .license-text { + max-height: 200px; + overflow-y: scroll; + white-space: pre-wrap; + } + </style> +</head> + +<body> + <main class="container"> + <div class="intro"> + <h1>Third Party Licenses</h1> + <p>This page lists the licenses of the projects used in cargo-about.</p> + </div> + + <h2>Overview of licenses:</h2> + <ul class="licenses-overview"> + <li><a href="#Apache-2.0">Apache License 2.0</a> (615)</li> + <li><a href="#MIT">MIT License</a> (157)</li> + <li><a href="#Unicode-3.0">Unicode License v3</a> (19)</li> + <li><a href="#BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</a> (9)</li> + <li><a href="#ISC">ISC License</a> (8)</li> + <li><a href="#Zlib">zlib License</a> (7)</li> + <li><a href="#BSD-2-Clause">BSD 2-Clause "Simplified" License</a> (3)</li> + <li><a href="#CDLA-Permissive-2.0">Community Data License Agreement Permissive 2.0</a> (3)</li> + <li><a href="#MPL-2.0">Mozilla Public License 2.0</a> (3)</li> + <li><a href="#0BSD">BSD Zero Clause License</a> (2)</li> + <li><a href="#BSL-1.0">Boost Software License 1.0</a> (1)</li> + <li><a href="#CC0-1.0">Creative Commons Zero v1.0 Universal</a> (1)</li> + <li><a href="#CDDL-1.0">Common Development and Distribution License 1.0</a> (1)</li> + </ul> + + <h2>All license text:</h2> + <ul class="licenses-list"> + <li class="license"> + <h3 id="0BSD">BSD Zero Clause License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/museun/mock_instant ">mock_instant 0.6.0</a></li> + </ul> + <pre class="license-text">Copyright (C) 2020 by museun <museun@outlook.com> + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="0BSD">BSD Zero Clause License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/oyvindln/adler2 ">adler2 2.0.1</a></li> + </ul> + <pre class="license-text">Copyright (C) Jonas Schievink <jonasschievink@gmail.com> + +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jhpratt/powerfmt ">powerfmt 0.2.0</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 Jacob Pratt et al. + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jhpratt/deranged ">deranged 0.5.8</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2024 Jacob Pratt et al. + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/oxidecomputer/serde_tokenstream ">serde_tokenstream 0.2.3</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License.</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-arith 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-array 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-buffer 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-cast 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-csv 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-data 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-ipc 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-json 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-ord 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-row 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-schema 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-select 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-string 58.3.0</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">arrow 58.3.0</a></li> + <li><a href=" https://github.com/tormol/encode_unicode ">encode_unicode 1.0.0</a></li> + <li><a href=" https://github.com/hsivonen/encoding_rs ">encoding_rs 0.8.35</a></li> + <li><a href=" https://github.com/mitsuhiko/fragile ">fragile 2.1.0</a></li> + <li><a href=" https://github.com/thomcc/rust-more-asserts ">more-asserts 0.3.1</a></li> + <li><a href=" https://github.com/apache/arrow-rs ">parquet 58.3.0</a></li> + <li><a href=" https://github.com/Stoeoef/spade ">spade 2.15.1</a></li> + <li><a href=" https://github.com/nvzqz/static-assertions-rs ">static_assertions 1.1.0</a></li> + <li><a href=" https://github.com/Lokathor/tinyvec ">tinyvec 1.11.0</a></li> + <li><a href=" https://github.com/hsivonen/utf8_iter ">utf8_iter 1.0.4</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">zeroize 1.8.2</a></li> + <li><a href=" https://github.com/RustCrypto/utils/tree/master/zeroize/derive ">zeroize_derive 1.4.3</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/huggingface/hf-hub ">hf-hub 0.4.3</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/apache/arrow-rs-object-store ">object_store 0.13.2</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/apache/datafusion ">datafusion-catalog-listing 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-catalog 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-common-runtime 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-common 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-datasource-arrow 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-datasource-csv 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-datasource-json 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-datasource-parquet 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-datasource 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-doc 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-execution 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-expr-common 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-expr 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-aggregate-common 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-aggregate 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-nested 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-table 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-window-common 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions-window 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-functions 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-macros 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-optimizer 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-physical-expr-adapter 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-physical-expr-common 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-physical-expr 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-physical-optimizer 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-physical-plan 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-pruning 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-session 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-sql 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion-substrait 53.1.0</a></li> + <li><a href=" https://github.com/apache/datafusion ">datafusion 53.1.0</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + +This project includes code from Apache Aurora. + +* dev/release/{release,changelog,release-candidate} are based on the scripts from + Apache Aurora + +Copyright: 2016 The Apache Software Foundation. +Home page: https://aurora.apache.org/ +License: http://www.apache.org/licenses/LICENSE-2.0 +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/la10736/rstest ">rstest 0.23.0</a></li> + <li><a href=" https://github.com/la10736/rstest ">rstest_macros 0.23.0</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-19 Michele d'Amico + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jeffparsons/rangemap ">rangemap 1.7.1</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors) + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/brendanzab/approx ">approx 0.5.1</a></li> + </ul> + <pre class="license-text"> + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/wvwwvwwv/scalable-delayed-dealloc/ ">sdd 3.0.10</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, April 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2024-present Changgyoo Park + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-collections 0.3.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-core 0.62.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-future 0.3.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-implement 0.60.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-interface 0.59.3</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-link 0.2.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-numerics 0.3.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-registry 0.6.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-result 0.4.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-strings 0.5.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-sys 0.52.0</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-sys 0.59.0</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-sys 0.60.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-sys 0.61.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-targets 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-targets 0.53.5</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows-threading 0.2.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows 0.62.2</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_aarch64_gnullvm 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_aarch64_gnullvm 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_aarch64_msvc 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_aarch64_msvc 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_gnu 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_gnu 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_gnullvm 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_gnullvm 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_msvc 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_i686_msvc 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_gnu 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_gnu 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_gnullvm 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_gnullvm 0.53.1</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_msvc 0.52.6</a></li> + <li><a href=" https://github.com/microsoft/windows-rs ">windows_x86_64_msvc 0.53.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright (c) Microsoft Corporation. + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/akhilles/crc-catalog.git ">crc-catalog 2.5.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Akhil Velagapudi + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/danielhenrymantilla/macro_rules_attribute-rs ">macro_rules_attribute-proc_macro 0.2.2</a></li> + <li><a href=" https://github.com/danielhenrymantilla/macro_rules_attribute-rs ">macro_rules_attribute 0.2.2</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Daniel Henry-Mantilla <daniel.henry.mantilla@gmail.com> + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tikv/pprof-rs ">pprof 0.14.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 TiKV Project Authors. + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/moka-rs/moka ">moka 0.12.15</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 - 2026 Tatsuya Kawano + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Soveu/tinyvec_macros ">tinyvec_macros 0.1.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Tomasz "Soveu" Marx + + 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. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Xuanwo/backon ">backon 1.6.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2021 Datafuse Labs + + 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.</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/google/zerocopy ">zerocopy-derive 0.8.48</a></li> + <li><a href=" https://github.com/google/zerocopy ">zerocopy 0.8.48</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 The Fuchsia Authors + + 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. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/daxpedda/web-time ">web-time 1.1.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2023 dAxpeDDa + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/enarx/ciborium ">ciborium-io 0.2.2</a></li> + <li><a href=" https://github.com/enarx/ciborium ">ciborium-ll 0.2.2</a></li> + <li><a href=" https://github.com/enarx/ciborium ">ciborium 0.2.2</a></li> + <li><a href=" https://github.com/awesomized/crc-fast-rust ">crc-fast 1.9.0</a></li> + <li><a href=" https://github.com/Narsil/esaxx-rs ">esaxx-rs 0.1.10</a></li> + <li><a href=" https://github.com/databendlabs/jsonb ">jsonb 0.5.6</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal 0.56.0</a></li> + <li><a href=" https://github.com/cberner/redb ">redb 3.1.3</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-aliyun-oss 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-aws-v4 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-azure-storage 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-core 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-file-read-tokio 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-google 3.0.0</a></li> + <li><a href=" https://github.com/apache/opendal-reqsign ">reqsign-tencent-cos 3.0.0</a></li> + <li><a href=" https://github.com/rustls/rustls-platform-verifier ">rustls-platform-verifier 0.7.0</a></li> + <li><a href=" https://github.com/huggingface/spm_precompiled ">spm_precompiled 0.1.4</a></li> + <li><a href=" https://github.com/huggingface/tokenizers ">tokenizers 0.15.2</a></li> + <li><a href=" https://github.com/cameron1024/unarray ">unarray 0.1.4</a></li> + <li><a href=" https://github.com/algesten/ureq ">ureq 2.12.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/MSxDOS/ntapi ">ntapi 0.4.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + </pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/krisprice/ipnet ">ipnet 2.12.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2017 Juniper Networks, Inc. + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/bikeshedder/deadpool ">deadpool-runtime 0.1.4</a></li> + <li><a href=" https://github.com/bikeshedder/deadpool ">deadpool 0.12.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2019 Michael P. Jung + + 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. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/utkarshkukreti/diff.rs ">diff 0.1.13</a></li> + <li><a href=" https://github.com/retep998/winapi-rs ">winapi 0.3.9</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-cli/anstyle.git ">anstream 1.0.0</a></li> + <li><a href=" https://github.com/rust-cli/anstyle.git ">anstyle-parse 1.0.0</a></li> + <li><a href=" https://github.com/rust-cli/anstyle.git ">anstyle-query 1.1.5</a></li> + <li><a href=" https://github.com/rust-cli/anstyle.git ">anstyle-wincon 3.0.11</a></li> + <li><a href=" https://github.com/rust-cli/anstyle.git ">anstyle 1.0.14</a></li> + <li><a href=" https://github.com/clap-rs/clap ">clap 4.6.1</a></li> + <li><a href=" https://github.com/clap-rs/clap ">clap_builder 4.6.0</a></li> + <li><a href=" https://github.com/clap-rs/clap ">clap_derive 4.6.1</a></li> + <li><a href=" https://github.com/clap-rs/clap ">clap_lex 1.1.0</a></li> + <li><a href=" https://github.com/rust-cli/anstyle.git ">colorchoice 1.0.5</a></li> + <li><a href=" https://github.com/srijs/rust-crc32fast ">crc32fast 1.5.0</a></li> + <li><a href=" https://github.com/colin-kiegel/rust-derive-builder ">derive_builder 0.12.0</a></li> + <li><a href=" https://github.com/colin-kiegel/rust-derive-builder ">derive_builder_core 0.12.0</a></li> + <li><a href=" https://github.com/colin-kiegel/rust-derive-builder ">derive_builder_macro 0.12.0</a></li> + <li><a href=" https://github.com/rust-cli/env_logger ">env_filter 1.0.1</a></li> + <li><a href=" https://github.com/rust-cli/env_logger ">env_logger 0.11.10</a></li> + <li><a href=" https://github.com/sfackler/foreign-types ">foreign-types-shared 0.1.1</a></li> + <li><a href=" https://github.com/sfackler/foreign-types ">foreign-types 0.3.2</a></li> + <li><a href=" https://github.com/srijs/rust-gearhash ">gearhash 0.1.3</a></li> + <li><a href=" https://github.com/KokaKiwi/rust-hex ">hex 0.4.3</a></li> + <li><a href=" https://github.com/chronotope/humantime ">humantime 2.3.0</a></li> + <li><a href=" https://github.com/polyfill-rs/is_terminal_polyfill ">is_terminal_polyfill 1.70.2</a></li> + <li><a href=" https://github.com/jni-rs/jni-sys ">jni-sys 0.4.1</a></li> + <li><a href=" https://github.com/rust-native-tls/rust-native-tls ">native-tls 0.2.18</a></li> + <li><a href=" https://github.com/polyfill-rs/once_cell_polyfill ">once_cell_polyfill 1.70.2</a></li> + <li><a href=" https://crates.io/crates/openssl-macros ">openssl-macros 0.1.1</a></li> + <li><a href=" https://github.com/rust-openssl/rust-openssl ">openssl 0.10.80</a></li> + <li><a href=" https://github.com/assert-rs/predicates-rs ">predicates-core 1.0.10</a></li> + <li><a href=" https://github.com/assert-rs/predicates-rs ">predicates-tree 1.0.13</a></li> + <li><a href=" https://github.com/assert-rs/predicates-rs ">predicates 3.1.4</a></li> + <li><a href=" https://github.com/rust-pretty-assertions/rust-pretty-assertions ">pretty_assertions 1.4.1</a></li> + <li><a href=" http://github.com/tailhook/quick-error ">quick-error 1.2.3</a></li> + <li><a href=" https://github.com/sfackler/rust-socks ">socks 0.3.4</a></li> + <li><a href=" https://github.com/toml-rs/toml ">toml_datetime 1.1.1+spec-1.1.0</a></li> + <li><a href=" https://github.com/toml-rs/toml ">toml_edit 0.25.11+spec-1.1.0</a></li> + <li><a href=" https://github.com/toml-rs/toml ">toml_parser 1.1.2+spec-1.1.0</a></li> + <li><a href=" https://github.com/swgillespie/unicode-categories ">unicode_categories 0.1.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/ohsayan/all_asserts ">all_asserts 2.3.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2019 Sayan Nandan + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/wvwwvwwv/scalable-concurrent-containers/ ">scc 2.4.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2020-2024 Changgyoo Park + + 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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/awslabs/aws-sdk-rust ">aws-sdk-s3 1.124.0</a></li> + <li><a href=" https://github.com/awslabs/aws-sdk-rust ">aws-sdk-sso 1.95.0</a></li> + <li><a href=" https://github.com/awslabs/aws-sdk-rust ">aws-sdk-ssooidc 1.97.0</a></li> + <li><a href=" https://github.com/awslabs/aws-sdk-rust ">aws-sdk-sts 1.99.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved. + + 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. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Xudong-Huang/generator-rs.git ">generator 0.8.8</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mrhooray/crc-rs.git ">crc 3.3.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0 January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-channel 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-core 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-executor 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-io 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-macro 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-sink 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-task 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures-util 0.3.32</a></li> + <li><a href=" https://github.com/rust-lang/futures-rs ">futures 0.3.32</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright (c) 2016 Alex Crichton +Copyright (c) 2017 The Tokio Authors + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/paholg/typenum ">typenum 1.20.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2014 Paho Lurie-Gregg + +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.</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/seanmonstar/reqwest ">reqwest 0.12.28</a></li> + <li><a href=" https://github.com/seanmonstar/reqwest ">reqwest 0.13.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2016 Sean McArthur + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/SergioBenitez/yansi ">yansi 1.0.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 Sergio Benitez + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/http ">http 0.2.12</a></li> + <li><a href=" https://github.com/hyperium/http ">http 1.4.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 http-rs authors + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rustls/tokio-rustls ">tokio-rustls 0.26.4</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2017 quininer kel + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/bcmyers/num-format ">num-format 0.4.4</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018 Brian Myers + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-lang-nursery/pin-utils ">pin-utils 0.1.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2018 The pin-utils authors + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/cryptocorrosion/cryptocorrosion ">ppv-lite86 0.2.21</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 The CryptoCorrosion Contributors + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/io-uring ">io-uring 0.7.12</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019 quininer kel + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/shepmaster/snafu ">snafu-derive 0.9.0</a></li> + <li><a href=" https://github.com/shepmaster/snafu ">snafu 0.9.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2019- Jake Goulding + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/strawlab/iana-time-zone ">iana-time-zone-haiku 0.1.2</a></li> + <li><a href=" https://github.com/strawlab/iana-time-zone ">iana-time-zone 0.1.65</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2020 Andrew Straw + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/ridiculousfish/regress ">regress 0.10.5</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2020 ridiculous_fish + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Alexhuszagh/fast-float-rust ">fast-float2 0.2.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Ivan Smirnov + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rustls/pki-types ">rustls-pki-types 1.14.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2023 Dirkjan Ochtman + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/akubera/bigdecimal-rs ">bigdecimal 0.4.10</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2023 The BigDecimal-rs Contributors + + +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. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/RazrFalcon/memmap2-rs ">memmap2 0.9.10</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2015] [Dan Burkert] + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dcchut/async-recursion ">async-recursion 1.1.1</a></li> + <li><a href=" https://github.com/RustCrypto/RSA ">rsa 0.9.10</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/gimli-rs/addr2line ">addr2line 0.25.1</a></li> + <li><a href=" https://github.com/tkaitchuck/ahash ">ahash 0.8.12</a></li> + <li><a href=" https://github.com/vorner/arc-swap ">arc-swap 1.9.1</a></li> + <li><a href=" https://github.com/bluss/arrayvec ">arrayvec 0.7.6</a></li> + <li><a href=" https://github.com/smol-rs/async-channel ">async-channel 2.5.0</a></li> + <li><a href=" https://github.com/Nullus157/async-compression ">async-compression 0.4.42</a></li> + <li><a href=" https://github.com/smol-rs/async-lock ">async-lock 3.4.2</a></li> + <li><a href=" https://github.com/smol-rs/atomic-waker ">atomic-waker 1.1.2</a></li> + <li><a href=" https://github.com/cuviper/autocfg ">autocfg 1.5.0</a></li> + <li><a href=" https://github.com/rust-lang/backtrace-rs ">backtrace 0.3.76</a></li> + <li><a href=" https://github.com/marshallpierce/rust-base64 ">base64 0.13.1</a></li> + <li><a href=" https://github.com/marshallpierce/rust-base64 ">base64 0.22.1</a></li> + <li><a href=" https://github.com/bitflags/bitflags ">bitflags 1.3.2</a></li> + <li><a href=" https://github.com/bitflags/bitflags ">bitflags 2.11.1</a></li> + <li><a href=" https://github.com/BurntSushi/bstr ">bstr 1.12.1</a></li> + <li><a href=" https://github.com/fitzgen/bumpalo ">bumpalo 3.20.2</a></li> + <li><a href=" https://github.com/vorner/bytes-utils ">bytes-utils 0.1.4</a></li> + <li><a href=" https://github.com/japaric/cast.rs ">cast 0.3.0</a></li> + <li><a href=" https://github.com/rust-lang/cc-rs ">cc 1.2.62</a></li> + <li><a href=" https://github.com/alexcrichton/cfg-if ">cfg-if 0.1.10</a></li> + <li><a href=" https://github.com/rust-lang/cfg-if ">cfg-if 1.0.4</a></li> + <li><a href=" https://github.com/rust-lang/cmake-rs ">cmake 0.1.58</a></li> + <li><a href=" https://github.com/Nullus157/async-compression ">compression-codecs 0.4.38</a></li> + <li><a href=" https://github.com/Nullus157/async-compression ">compression-core 0.4.32</a></li> + <li><a href=" https://github.com/smol-rs/concurrent-queue ">concurrent-queue 2.5.0</a></li> + <li><a href=" https://github.com/tkaitchuck/constrandom ">const-random-macro 0.1.16</a></li> + <li><a href=" https://github.com/tkaitchuck/constrandom ">const-random 0.1.18</a></li> + <li><a href=" https://github.com/servo/core-foundation-rs ">core-foundation-sys 0.8.7</a></li> + <li><a href=" https://github.com/servo/core-foundation-rs ">core-foundation 0.10.1</a></li> + <li><a href=" https://github.com/servo/core-foundation-rs ">core-foundation 0.9.4</a></li> + <li><a href=" https://github.com/gimli-rs/cpp_demangle ">cpp_demangle 0.4.5</a></li> + <li><a href=" https://github.com/bheisler/criterion.rs ">criterion-plot 0.5.0</a></li> + <li><a href=" https://github.com/bheisler/criterion.rs ">criterion 0.5.1</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-channel 0.5.15</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-deque 0.8.6</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-epoch 0.9.18</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-queue 0.3.12</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-skiplist 0.1.3</a></li> + <li><a href=" https://github.com/crossbeam-rs/crossbeam ">crossbeam-utils 0.8.21</a></li> + <li><a href=" https://github.com/getsentry/rust-debugid ">debugid 0.8.0</a></li> + <li><a href=" https://github.com/yaahc/displaydoc ">displaydoc 0.2.5</a></li> + <li><a href=" https://github.com/rayon-rs/either ">either 1.15.0</a></li> + <li><a href=" https://github.com/indexmap-rs/equivalent ">equivalent 1.0.2</a></li> + <li><a href=" https://github.com/lambda-fairy/rust-errno ">errno 0.3.14</a></li> + <li><a href=" https://github.com/smol-rs/event-listener-strategy ">event-listener-strategy 0.5.4</a></li> + <li><a href=" https://github.com/smol-rs/event-listener ">event-listener 5.4.1</a></li> + <li><a href=" https://github.com/smol-rs/fastrand ">fastrand 2.4.1</a></li> + <li><a href=" https://github.com/rust-lang/cc-rs ">find-msvc-tools 0.1.9</a></li> + <li><a href=" https://github.com/gimli-rs/findshlibs ">findshlibs 0.10.2</a></li> + <li><a href=" https://github.com/petgraph/fixedbitset ">fixedbitset 0.5.7</a></li> + <li><a href=" https://github.com/rust-lang/flate2-rs ">flate2 1.1.9</a></li> + <li><a href=" https://github.com/servo/rust-fnv ">fnv 1.0.7</a></li> + <li><a href=" https://github.com/servo/rust-url ">form_urlencoded 1.2.2</a></li> + <li><a href=" https://github.com/async-rs/futures-timer ">futures-timer 3.0.3</a></li> + <li><a href=" https://github.com/georust/geohash.rs ">geohash 0.13.1</a></li> + <li><a href=" https://github.com/gimli-rs/gimli ">gimli 0.32.3</a></li> + <li><a href=" https://github.com/rust-lang/glob ">glob 0.3.3</a></li> + <li><a href=" https://github.com/japaric/hash32 ">hash32 0.3.1</a></li> + <li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.14.5</a></li> + <li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.15.5</a></li> + <li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.16.1</a></li> + <li><a href=" https://github.com/rust-lang/hashbrown ">hashbrown 0.17.1</a></li> + <li><a href=" https://github.com/rust-embedded/heapless ">heapless 0.8.0</a></li> + <li><a href=" https://github.com/withoutboats/heck ">heck 0.5.0</a></li> + <li><a href=" https://github.com/hermit-os/hermit-rs ">hermit-abi 0.5.2</a></li> + <li><a href=" https://github.com/seanmonstar/httparse ">httparse 1.10.1</a></li> + <li><a href=" https://github.com/rustls/hyper-rustls ">hyper-rustls 0.27.9</a></li> + <li><a href=" https://github.com/hyperium/hyper-tls ">hyper-tls 0.6.0</a></li> + <li><a href=" https://github.com/servo/rust-url/ ">idna 1.1.0</a></li> + <li><a href=" https://github.com/hsivonen/idna_adapter ">idna_adapter 1.2.2</a></li> + <li><a href=" https://github.com/indexmap-rs/indexmap ">indexmap 2.14.0</a></li> + <li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.10.5</a></li> + <li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.11.0</a></li> + <li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.12.1</a></li> + <li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.13.0</a></li> + <li><a href=" https://github.com/rust-itertools/itertools ">itertools 0.14.0</a></li> + <li><a href=" https://github.com/rust-lang/jobserver-rs ">jobserver 0.1.34</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/js-sys ">js-sys 0.3.98</a></li> + <li><a href=" https://github.com/rust-lang-nursery/lazy-static.rs ">lazy_static 1.5.0</a></li> + <li><a href=" https://github.com/sunfishcode/linux-raw-sys ">linux-raw-sys 0.12.1</a></li> + <li><a href=" https://github.com/Amanieu/parking_lot ">lock_api 0.4.14</a></li> + <li><a href=" https://github.com/rust-lang/log ">log 0.4.29</a></li> + <li><a href=" https://github.com/alexcrichton/xz2-rs ">lzma-sys 0.1.20</a></li> + <li><a href=" https://github.com/bluss/matrixmultiply/ ">matrixmultiply 0.3.10</a></li> + <li><a href=" https://github.com/hyperium/mime ">mime 0.3.17</a></li> + <li><a href=" https://github.com/asomers/mockall ">mockall 0.13.1</a></li> + <li><a href=" https://github.com/asomers/mockall ">mockall_derive 0.13.1</a></li> + <li><a href=" https://github.com/havarnov/multimap ">multimap 0.10.1</a></li> + <li><a href=" https://github.com/rust-ndarray/ndarray ">ndarray 0.16.1</a></li> + <li><a href=" https://github.com/dignifiedquire/num-bigint ">num-bigint-dig 0.8.6</a></li> + <li><a href=" https://github.com/rust-num/num-bigint ">num-bigint 0.4.6</a></li> + <li><a href=" https://github.com/rust-num/num-complex ">num-complex 0.4.6</a></li> + <li><a href=" https://github.com/rust-num/num-integer ">num-integer 0.1.46</a></li> + <li><a href=" https://github.com/rust-num/num-iter ">num-iter 0.1.45</a></li> + <li><a href=" https://github.com/rust-num/num-traits ">num-traits 0.2.19</a></li> + <li><a href=" https://github.com/seanmonstar/num_cpus ">num_cpus 1.17.0</a></li> + <li><a href=" https://github.com/gimli-rs/object ">object 0.37.3</a></li> + <li><a href=" https://github.com/matklad/once_cell ">once_cell 1.21.4</a></li> + <li><a href=" https://github.com/rustls/openssl-probe ">openssl-probe 0.2.1</a></li> + <li><a href=" https://github.com/smol-rs/parking ">parking 2.2.1</a></li> + <li><a href=" https://github.com/Amanieu/parking_lot ">parking_lot 0.12.5</a></li> + <li><a href=" https://github.com/Amanieu/parking_lot ">parking_lot_core 0.9.12</a></li> + <li><a href=" https://github.com/servo/rust-url/ ">percent-encoding 2.3.2</a></li> + <li><a href=" https://github.com/petgraph/petgraph ">petgraph 0.8.3</a></li> + <li><a href=" https://github.com/rust-lang/pkg-config-rs ">pkg-config 0.3.33</a></li> + <li><a href=" https://github.com/proptest-rs/proptest ">proptest 1.11.0</a></li> + <li><a href=" https://github.com/tokio-rs/prost ">prost-build 0.14.3</a></li> + <li><a href=" https://github.com/tokio-rs/prost ">prost-derive 0.14.3</a></li> + <li><a href=" https://github.com/tokio-rs/prost ">prost-types 0.14.3</a></li> + <li><a href=" https://github.com/tokio-rs/prost ">prost 0.14.3</a></li> + <li><a href=" https://github.com/bluss/rawpointer/ ">rawpointer 0.2.1</a></li> + <li><a href=" https://github.com/cuviper/rayon-cond ">rayon-cond 0.3.0</a></li> + <li><a href=" https://github.com/rayon-rs/rayon ">rayon-core 1.13.0</a></li> + <li><a href=" https://github.com/rayon-rs/rayon ">rayon 1.12.0</a></li> + <li><a href=" https://github.com/rust-lang/regex ">regex-automata 0.4.14</a></li> + <li><a href=" https://github.com/rust-lang/regex ">regex-lite 0.1.9</a></li> + <li><a href=" https://github.com/rust-lang/regex ">regex-syntax 0.8.10</a></li> + <li><a href=" https://github.com/rust-lang/regex ">regex 1.12.3</a></li> + <li><a href=" https://github.com/briansmith/ring ">ring 0.17.14</a></li> + <li><a href=" https://github.com/RoaringBitmap/roaring-rs ">roaring 0.11.4</a></li> + <li><a href=" https://github.com/georust/robust ">robust 1.2.0</a></li> + <li><a href=" https://github.com/rust-lang/rustc-demangle ">rustc-demangle 0.1.27</a></li> + <li><a href=" https://github.com/djc/rustc-version-rs ">rustc_version 0.4.1</a></li> + <li><a href=" https://github.com/bytecodealliance/rustix ">rustix 1.1.4</a></li> + <li><a href=" https://github.com/rustls/rustls-native-certs ">rustls-native-certs 0.8.3</a></li> + <li><a href=" https://github.com/rustls/rustls ">rustls 0.23.40</a></li> + <li><a href=" https://github.com/altsysrq/rusty-fork ">rusty-fork 0.3.1</a></li> + <li><a href=" https://github.com/alexcrichton/scoped-tls ">scoped-tls 1.0.1</a></li> + <li><a href=" https://github.com/bluss/scopeguard ">scopeguard 1.2.0</a></li> + <li><a href=" https://github.com/kornelski/rust-security-framework ">security-framework-sys 2.17.0</a></li> + <li><a href=" https://github.com/kornelski/rust-security-framework ">security-framework 3.7.0</a></li> + <li><a href=" https://github.com/jonasbb/serde_with/ ">serde_with 3.20.0</a></li> + <li><a href=" https://github.com/jonasbb/serde_with/ ">serde_with_macros 3.20.0</a></li> + <li><a href=" https://gitlab.com/ijackson/rust-shellexpand ">shellexpand 3.1.2</a></li> + <li><a href=" https://github.com/vorner/signal-hook ">signal-hook-registry 1.4.8</a></li> + <li><a href=" https://github.com/seancroach/simd_cesu8 ">simd_cesu8 1.1.1</a></li> + <li><a href=" https://github.com/servo/rust-smallvec ">smallvec 1.15.1</a></li> + <li><a href=" https://github.com/rust-lang/socket2 ">socket2 0.6.3</a></li> + <li><a href=" https://github.com/apache/datafusion-sqlparser-rs ">sqlparser 0.61.0</a></li> + <li><a href=" https://github.com/sqlparser-rs/sqlparser-rs ">sqlparser_derive 0.5.0</a></li> + <li><a href=" https://github.com/storyyeller/stable_deref_trait ">stable_deref_trait 1.2.1</a></li> + <li><a href=" https://github.com/Stebalien/str_stack ">str_stack 0.1.1</a></li> + <li><a href=" https://gitlab.com/chris-morgan/symlink ">symlink 0.1.0</a></li> + <li><a href=" https://github.com/dtolnay/syn ">syn 1.0.109</a></li> + <li><a href=" https://github.com/mullvad/system-configuration-rs ">system-configuration-sys 0.6.0</a></li> + <li><a href=" https://github.com/mullvad/system-configuration-rs ">system-configuration 0.7.0</a></li> + <li><a href=" https://github.com/Stebalien/tempfile ">tempfile 3.27.0</a></li> + <li><a href=" https://github.com/bluss/thread-tree ">thread-tree 0.3.3</a></li> + <li><a href=" https://github.com/Amanieu/thread_local-rs ">thread_local 1.1.9</a></li> + <li><a href=" https://github.com/bheisler/TinyTemplate ">tinytemplate 1.2.1</a></li> + <li><a href=" https://github.com/seanmonstar/unicase ">unicase 2.9.0</a></li> + <li><a href=" https://github.com/n1t0/unicode-normalization ">unicode-normalization-alignments 0.1.12</a></li> + <li><a href=" https://github.com/unicode-rs/unicode-normalization ">unicode-normalization 0.1.25</a></li> + <li><a href=" https://github.com/unicode-rs/unicode-segmentation ">unicode-segmentation 1.13.2</a></li> + <li><a href=" https://github.com/unicode-rs/unicode-width ">unicode-width 0.2.2</a></li> + <li><a href=" https://github.com/servo/rust-url ">url 2.5.8</a></li> + <li><a href=" https://github.com/uuid-rs/uuid ">uuid 1.23.1</a></li> + <li><a href=" https://github.com/SergioBenitez/version_check ">version_check 0.9.5</a></li> + <li><a href=" https://github.com/alexcrichton/wait-timeout ">wait-timeout 0.2.1</a></li> + <li><a href=" https://github.com/bytecodealliance/wasi ">wasi 0.11.1+wasi-snapshot-preview1</a></li> + <li><a href=" https://github.com/bytecodealliance/wasi-rs ">wasi 0.14.7+wasi-0.2.4</a></li> + <li><a href=" https://github.com/bytecodealliance/wasi-rs ">wasip2 1.0.3+wasi-0.2.9</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/futures ">wasm-bindgen-futures 0.4.71</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro-support ">wasm-bindgen-macro-support 0.2.121</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/macro ">wasm-bindgen-macro 0.2.121</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/shared ">wasm-bindgen-shared 0.2.121</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen ">wasm-bindgen 0.2.121</a></li> + <li><a href=" https://github.com/wasm-bindgen/wasm-bindgen/tree/master/crates/web-sys ">web-sys 0.3.98</a></li> + <li><a href=" https://github.com/LukeMathWalker/wiremock-rs ">wiremock 0.6.5</a></li> + <li><a href=" https://github.com/bytecodealliance/wit-bindgen ">wit-bindgen 0.51.0</a></li> + <li><a href=" https://github.com/bytecodealliance/wit-bindgen ">wit-bindgen 0.57.1</a></li> + <li><a href=" https://github.com/georust/wkb ">wkb 0.9.2</a></li> + <li><a href=" https://github.com/georust/wkt ">wkt 0.14.0</a></li> + <li><a href=" https://github.com/RazrFalcon/xmlparser ">xmlparser 0.13.6</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/contain-rs/bit-set ">bit-set 0.8.0</a></li> + <li><a href=" https://github.com/contain-rs/bit-vec ">bit-vec 0.8.0</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-core 1.0.6</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-parse-float 1.0.6</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-parse-integer 1.0.6</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-util 1.0.7</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-write-float 1.0.6</a></li> + <li><a href=" https://github.com/Alexhuszagh/rust-lexical ">lexical-write-integer 1.0.6</a></li> + <li><a href=" https://github.com/Alexhuszagh/minimal-lexical ">minimal-lexical 0.2.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/RustCrypto/block-ciphers ">aes 0.8.4</a></li> + <li><a href=" https://github.com/RustCrypto/formats ">base64ct 1.8.3</a></li> + <li><a href=" https://github.com/RustCrypto/hashes ">blake2 0.10.6</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">block-buffer 0.10.4</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">block-padding 0.3.3</a></li> + <li><a href=" https://github.com/RustCrypto/block-modes ">cbc 0.1.2</a></li> + <li><a href=" https://github.com/RustCrypto/stream-ciphers ">chacha20 0.10.0</a></li> + <li><a href=" https://github.com/RustCrypto/traits ">cipher 0.4.4</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/const-oid ">const-oid 0.9.6</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">cpufeatures 0.2.17</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">cpufeatures 0.3.0</a></li> + <li><a href=" https://github.com/RustCrypto/traits ">crypto-common 0.1.7</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/der ">der 0.7.10</a></li> + <li><a href=" https://github.com/RustCrypto/traits ">digest 0.10.7</a></li> + <li><a href=" https://github.com/RustCrypto/MACs ">hmac 0.12.1</a></li> + <li><a href=" https://github.com/RustCrypto/utils ">inout 0.1.4</a></li> + <li><a href=" https://github.com/RustCrypto/hashes ">md-5 0.10.6</a></li> + <li><a href=" https://github.com/RustCrypto/password-hashes/tree/master/pbkdf2 ">pbkdf2 0.12.2</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/pem-rfc7468 ">pem-rfc7468 0.7.0</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/pkcs1 ">pkcs1 0.7.5</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/pkcs5 ">pkcs5 0.7.1</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/pkcs8 ">pkcs8 0.10.2</a></li> + <li><a href=" https://github.com/RustCrypto/stream-ciphers ">salsa20 0.10.2</a></li> + <li><a href=" https://github.com/RustCrypto/password-hashes/tree/master/scrypt ">scrypt 0.11.0</a></li> + <li><a href=" https://github.com/RustCrypto/hashes ">sha1 0.10.6</a></li> + <li><a href=" https://github.com/RustCrypto/hashes ">sha2 0.10.9</a></li> + <li><a href=" https://github.com/RustCrypto/traits/tree/master/signature ">signature 2.2.0</a></li> + <li><a href=" https://github.com/RustCrypto/formats/tree/master/spki ">spki 0.7.3</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-random/rand_core ">rand_core 0.10.1</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand_core 0.6.4</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand_core 0.9.5</a></li> + <li><a href=" https://github.com/rust-random/rand_distr ">rand_distr 0.5.1</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-random/getrandom ">getrandom 0.2.17</a></li> + <li><a href=" https://github.com/rust-random/getrandom ">getrandom 0.3.4</a></li> + <li><a href=" https://github.com/rust-random/getrandom ">getrandom 0.4.2</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand_chacha 0.3.1</a></li> + <li><a href=" https://github.com/d-e-s-o/test-log.git ">test-log-core 0.2.20</a></li> + <li><a href=" https://github.com/d-e-s-o/test-log.git ">test-log-macros 0.2.20</a></li> + <li><a href=" https://github.com/d-e-s-o/test-log.git ">test-log 0.2.20</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/bkchr/proc-macro-crate ">proc-macro-crate 3.5.0</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/LICENSE-2.0 + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mcgoo/vcpkg-rs ">vcpkg 0.2.15</a></li> + </ul> + <pre class="license-text"> Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. + +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mmastrac/rust-ctor ">ctor-proc-macro 0.0.7</a></li> + <li><a href=" https://github.com/mmastrac/rust-ctor ">ctor 0.6.3</a></li> + <li><a href=" https://github.com/mmastrac/rust-ctor ">dtor-proc-macro 0.0.6</a></li> + <li><a href=" https://github.com/mmastrac/rust-ctor ">dtor 0.1.1</a></li> + </ul> + <pre class="license-text">Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Lokathor/bytemuck ">bytemuck 1.25.0</a></li> + </ul> + <pre class="license-text">Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + + "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/pyfisch/httpdate ">httpdate 1.0.3</a></li> + <li><a href=" https://github.com/jeremysalwen/rust-permutations ">permutation 0.4.1</a></li> + </ul> + <pre class="license-text">Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, +and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by +the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all +other entities that control, are controlled by, or are under common +control with that entity. For the purposes of this definition, +"control" means (i) the power, direct or indirect, to cause the +direction or management of such entity, whether by contract or +otherwise, or (ii) ownership of fifty percent (50%) or more of the +outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity +exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, +including but not limited to software source code, documentation +source, and configuration files. + +"Object" form shall mean any form resulting from mechanical +transformation or translation of a Source form, including but +not limited to compiled object code, generated documentation, +and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or +Object form, made available under the License, as indicated by a +copyright notice that is included in or attached to the work +(an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object +form, that is based on (or derived from) the Work and for which the +editorial revisions, annotations, elaborations, or other modifications +represent, as a whole, an original work of authorship. For the purposes +of this License, Derivative Works shall not include works that remain +separable from, or merely link (or bind by name) to the interfaces of, +the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including +the original version of the Work and any modifications or additions +to that Work or Derivative Works thereof, that is intentionally +submitted to Licensor for inclusion in the Work by the copyright owner +or by an individual or Legal Entity authorized to submit on behalf of +the copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent +to the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control systems, +and issue tracking systems that are managed by, or on behalf of, the +Licensor for the purpose of discussing and improving the Work, but +excluding communication that is conspicuously marked or otherwise +designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity +on behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare Derivative Works of, +publicly display, publicly perform, sublicense, and distribute the +Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, +use, offer to sell, sell, import, and otherwise transfer the Work, +where such license applies only to those patent claims licensable +by such Contributor that are necessarily infringed by their +Contribution(s) alone or by combination of their Contribution(s) +with the Work to which such Contribution(s) was submitted. If You +institute patent litigation against any entity (including a +cross-claim or counterclaim in a lawsuit) alleging that the Work +or a Contribution incorporated within the Work constitutes direct +or contributory patent infringement, then any patent licenses +granted to You under this License for that Work shall terminate +as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the +Work or Derivative Works thereof in any medium, with or without +modifications, and in Source or Object form, provided that You +meet the following conditions: + +(a) You must give any other recipients of the Work or +Derivative Works a copy of this License; and + +(b) You must cause any modified files to carry prominent notices +stating that You changed the files; and + +(c) You must retain, in the Source form of any Derivative Works +that You distribute, all copyright, patent, trademark, and +attribution notices from the Source form of the Work, +excluding those notices that do not pertain to any part of +the Derivative Works; and + +(d) If the Work includes a "NOTICE" text file as part of its +distribution, then any Derivative Works that You distribute must +include a readable copy of the attribution notices contained +within such NOTICE file, excluding those notices that do not +pertain to any part of the Derivative Works, in at least one +of the following places: within a NOTICE text file distributed +as part of the Derivative Works; within the Source form or +documentation, if provided along with the Derivative Works; or, +within a display generated by the Derivative Works, if and +wherever such third-party notices normally appear. The contents +of the NOTICE file are for informational purposes only and +do not modify the License. You may add Your own attribution +notices within Derivative Works that You distribute, alongside +or as an addendum to the NOTICE text from the Work, provided +that such additional attribution notices cannot be construed +as modifying the License. + +You may add Your own copyright statement to Your modifications and +may provide additional or different license terms and conditions +for use, reproduction, or distribution of Your modifications, or +for any such Derivative Works as a whole, provided Your use, +reproduction, and distribution of the Work otherwise complies with +the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, +any Contribution intentionally submitted for inclusion in the Work +by You to the Licensor shall be under the terms and conditions of +this License, without any additional terms or conditions. +Notwithstanding the above, nothing herein shall supersede or modify +the terms of any separate license agreement you may have executed +with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade +names, trademarks, service marks, or product names of the Licensor, +except as required for reasonable and customary use in describing the +origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or +agreed to in writing, Licensor provides the Work (and each +Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Work and assume any +risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, +unless required by applicable law (such as deliberate and grossly +negligent acts) or agreed to in writing, shall any Contributor be +liable to You for damages, including any direct, indirect, special, +incidental, or consequential damages of any character arising as a +result of this License or out of the use or inability to use the +Work (including but not limited to damages for loss of goodwill, +work stoppage, computer failure or malfunction, or any and all +other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing +the Work or Derivative Works thereof, You may choose to offer, +and charge a fee for, acceptance of support, warranty, indemnity, +or other liability obligations and/or rights consistent with this +License. However, in accepting such obligations, You may act only +on Your own behalf and on Your sole responsibility, not on behalf +of any other Contributor, and only if You agree to indemnify, +defend, and hold each Contributor harmless for any liability +incurred by, or claims asserted against, such Contributor by reason +of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following +boilerplate notice, with the fields enclosed by brackets "[]" +replaced with your own identifying information. (Don't include +the brackets!) The text should be enclosed in the appropriate +comment syntax for the file format. We also recommend that a +file or class name and description of purpose be included on the +same "printed page" as the copyright notice for easier +identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/lance-format/lance ">lance-arrow-scalar 58.0.0</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-arrow-stats 58.0.0</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-bitpacking 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">fsst 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-examples 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-arrow 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-core 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-datafusion 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-datagen 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-encoding 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-file 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-geo 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-index 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-io 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-linalg 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-namespace 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-namespace-datafusion 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-namespace-impls 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-table 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-test-macros 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-testing 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-tokenizer 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/lance-format/lance ">lance-tools 7.0.0-beta.14</a></li> + <li><a href=" https://github.com/zakarumych/allocator-api2 ">allocator-api2 0.2.21</a></li> + <li><a href=" https://github.com/nical/android_system_properties ">android_system_properties 0.1.5</a></li> + <li><a href=" https://github.com/zrzka/anes-rs ">anes 0.1.6</a></li> + <li><a href=" https://github.com/dtolnay/anyhow ">anyhow 1.0.102</a></li> + <li><a href=" https://github.com/dtolnay/async-trait ">async-trait 0.1.89</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-config 1.8.14</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-credential-types 1.2.13</a></li> + <li><a href=" https://github.com/aws/aws-lc-rs ">aws-lc-sys 0.41.0</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-runtime 1.7.1</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-sigv4 1.4.1</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-async 1.2.13</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-checksums 0.64.5</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-eventstream 0.60.19</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-http-client 1.1.11</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-http 0.63.5</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-json 0.62.4</a></li> + <li><a href=" https://github.com/awslabs/smithy-rs ">aws-smithy-observability 0.2.5</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-query 0.60.14</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-runtime-api 1.11.5</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-runtime 1.10.2</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-types 1.4.5</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-smithy-xml 0.60.14</a></li> + <li><a href=" https://github.com/smithy-lang/smithy-rs ">aws-types 1.3.13</a></li> + <li><a href=" https://github.com/BLAKE3-team/BLAKE3 ">blake3 1.8.5</a></li> + <li><a href=" https://github.com/cesarb/constant_time_eq ">constant_time_eq 0.4.2</a></li> + <li><a href=" https://github.com/zowens/crc32c ">crc32c 0.6.8</a></li> + <li><a href=" https://github.com/dirs-dev/dirs-sys-rs ">dirs-sys 0.5.0</a></li> + <li><a href=" https://github.com/soc/dirs-rs ">dirs 6.0.0</a></li> + <li><a href=" https://github.com/sgodwincs/dlv-list-rs ">dlv-list 0.5.2</a></li> + <li><a href=" https://gitlab.com/kornelski/dunce ">dunce 1.0.5</a></li> + <li><a href=" https://github.com/dtolnay/dyn-clone ">dyn-clone 1.0.20</a></li> + <li><a href=" https://github.com/nlordell/ethnum-rs ">ethnum 1.5.3</a></li> + <li><a href=" https://github.com/google/flatbuffers ">flatbuffers 25.12.19</a></li> + <li><a href=" https://github.com/georust/geo ">geo-traits 0.3.0</a></li> + <li><a href=" https://github.com/georust/geo ">geo-types 0.7.19</a></li> + <li><a href=" https://github.com/georust/geo ">geo 0.31.0</a></li> + <li><a href=" https://github.com/geoarrow/geoarrow-rs ">geoarrow-array 0.8.0</a></li> + <li><a href=" https://github.com/geoarrow/geoarrow-rs ">geoarrow-expr-geo 0.8.0</a></li> + <li><a href=" https://github.com/geoarrow/geoarrow-rs ">geoarrow-schema 0.8.0</a></li> + <li><a href=" https://github.com/datafusion-contrib/geodatafusion ">geodatafusion 0.4.0</a></li> + <li><a href=" https://github.com/rustwasm/gloo/tree/master/crates/timers ">gloo-timers 0.3.0</a></li> + <li><a href=" https://github.com/VoidStarKat/half-rs ">half 2.7.1</a></li> + <li><a href=" https://github.com/ethereal-sheep/heapify ">heapify 0.2.0</a></li> + <li><a href=" https://github.com/huggingface/xet-core ">hf-xet 1.5.2</a></li> + <li><a href=" https://github.com/TedDriggs/ident_case ">ident_case 1.0.1</a></li> + <li><a href=" https://github.com/dtolnay/itoa ">itoa 1.0.18</a></li> + <li><a href=" https://github.com/jni-rs/jni-rs ">jni-macros 0.22.4</a></li> + <li><a href=" https://github.com/jni-rs/jni-sys ">jni-sys-macros 0.4.1</a></li> + <li><a href=" https://github.com/jni-rs/jni-rs ">jni 0.22.4</a></li> + <li><a href=" https://crates.io/crates/lance-namespace-reqwest-client ">lance-namespace-reqwest-client 0.7.6</a></li> + <li><a href=" https://github.com/rust-lang/libc ">libc 0.2.186</a></li> + <li><a href=" https://github.com/fast/mea ">mea 0.6.3</a></li> + <li><a href=" https://github.com/Frommi/miniz_oxide/tree/master/miniz_oxide ">miniz_oxide 0.8.9</a></li> + <li><a href=" https://github.com/dtolnay/monostate ">monostate-impl 0.1.18</a></li> + <li><a href=" https://github.com/dtolnay/monostate ">monostate 0.1.18</a></li> + <li><a href=" https://github.com/jhpratt/num-conv ">num-conv 0.2.2</a></li> + <li><a href=" https://github.com/illicitonion/num_enum ">num_enum 0.7.6</a></li> + <li><a href=" https://github.com/illicitonion/num_enum ">num_enum_derive 0.7.6</a></li> + <li><a href=" https://github.com/madsmtm/objc2 ">objc2-core-foundation 0.3.2</a></li> + <li><a href=" https://github.com/madsmtm/objc2 ">objc2-io-kit 0.3.2</a></li> + <li><a href=" https://github.com/madsmtm/objc2 ">objc2-system-configuration 0.3.2</a></li> + <li><a href=" https://github.com/apache/opendal ">object_store_opendal 0.56.0</a></li> + <li><a href=" https://github.com/faern/oneshot ">oneshot 0.1.13</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-core 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-layer-concurrent-limit 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-layer-logging 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-layer-retry 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-layer-timeout 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-azblob 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-azdls 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-azure-common 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-cos 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-gcs 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-hf 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-oss 0.56.0</a></li> + <li><a href=" https://github.com/apache/opendal ">opendal-service-s3 0.56.0</a></li> + <li><a href=" https://github.com/dylni/os_str_bytes ">os_str_bytes 6.6.1</a></li> + <li><a href=" https://github.com/dtolnay/paste ">paste 1.0.15</a></li> + <li><a href=" https://github.com/vitiral/path_abs ">path_abs 0.5.1</a></li> + <li><a href=" https://github.com/taiki-e/pin-project ">pin-project-internal 1.1.13</a></li> + <li><a href=" https://github.com/taiki-e/pin-project-lite ">pin-project-lite 0.2.17</a></li> + <li><a href=" https://github.com/taiki-e/pin-project ">pin-project 1.1.13</a></li> + <li><a href=" https://github.com/taiki-e/portable-atomic-util ">portable-atomic-util 0.2.7</a></li> + <li><a href=" https://github.com/taiki-e/portable-atomic ">portable-atomic 1.13.1</a></li> + <li><a href=" https://github.com/dtolnay/prettyplease ">prettyplease 0.2.37</a></li> + <li><a href=" https://github.com/dtolnay/proc-macro2 ">proc-macro2 1.0.106</a></li> + <li><a href=" https://github.com/dtolnay/quote ">quote 1.0.45</a></li> + <li><a href=" https://github.com/r-efi/r-efi ">r-efi 5.3.0</a></li> + <li><a href=" https://github.com/r-efi/r-efi ">r-efi 6.0.0</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand 0.10.1</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand 0.8.6</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand 0.9.4</a></li> + <li><a href=" https://github.com/rust-random/rand ">rand_chacha 0.9.0</a></li> + <li><a href=" https://github.com/rust-random/rngs ">rand_xorshift 0.4.0</a></li> + <li><a href=" https://github.com/rust-random/rngs ">rand_xoshiro 0.7.0</a></li> + <li><a href=" https://github.com/udoprog/relative-path ">relative-path 1.9.3</a></li> + <li><a href=" https://github.com/TrueLayer/reqwest-middleware ">reqwest-middleware 0.5.1</a></li> + <li><a href=" https://github.com/georust/rstar ">rstar 0.12.2</a></li> + <li><a href=" https://github.com/rustls/rustls-platform-verifier ">rustls-platform-verifier-android 0.1.1</a></li> + <li><a href=" https://github.com/dtolnay/rustversion ">rustversion 1.0.22</a></li> + <li><a href=" https://github.com/dtolnay/ryu ">ryu 1.0.23</a></li> + <li><a href=" https://github.com/dtolnay/semver ">semver 1.0.28</a></li> + <li><a href=" https://github.com/dtolnay/seq-macro ">seq-macro 0.3.6</a></li> + <li><a href=" https://github.com/serde-rs/serde ">serde 1.0.228</a></li> + <li><a href=" https://github.com/serde-rs/serde ">serde_core 1.0.228</a></li> + <li><a href=" https://github.com/serde-rs/serde ">serde_derive 1.0.228</a></li> + <li><a href=" https://github.com/serde-rs/serde ">serde_derive_internals 0.29.1</a></li> + <li><a href=" https://github.com/serde-rs/json ">serde_json 1.0.149</a></li> + <li><a href=" https://github.com/dtolnay/serde-repr ">serde_repr 0.1.20</a></li> + <li><a href=" https://github.com/nox/serde_urlencoded ">serde_urlencoded 0.7.1</a></li> + <li><a href=" https://github.com/dtolnay/serde-yaml ">serde_yaml 0.9.34+deprecated</a></li> + <li><a href=" https://github.com/comex/rust-shlex ">shlex 1.3.0</a></li> + <li><a href=" https://github.com/rusticstuff/simdutf8 ">simdutf8 0.1.5</a></li> + <li><a href=" https://github.com/jedisct1/rust-siphash ">siphasher 1.0.3</a></li> + <li><a href=" https://github.com/vitiral/stfu8 ">stfu8 0.2.7</a></li> + <li><a href=" https://github.com/substrait-io/substrait-rs ">substrait 0.62.2</a></li> + <li><a href=" https://github.com/dtolnay/syn ">syn 2.0.117</a></li> + <li><a href=" https://github.com/Actyx/sync_wrapper ">sync_wrapper 1.0.2</a></li> + <li><a href=" https://github.com/oliver-giersch/tagptr.git ">tagptr 0.2.0</a></li> + <li><a href=" https://github.com/dtolnay/thiserror ">thiserror-impl 1.0.69</a></li> + <li><a href=" https://github.com/dtolnay/thiserror ">thiserror-impl 2.0.18</a></li> + <li><a href=" https://github.com/dtolnay/thiserror ">thiserror 1.0.69</a></li> + <li><a href=" https://github.com/dtolnay/thiserror ">thiserror 2.0.18</a></li> + <li><a href=" https://github.com/apache/thrift/tree/master/lib/rs ">thrift 0.17.0</a></li> + <li><a href=" https://github.com/time-rs/time ">time-core 0.1.8</a></li> + <li><a href=" https://github.com/time-rs/time ">time-macros 0.2.27</a></li> + <li><a href=" https://github.com/time-rs/time ">time 0.3.47</a></li> + <li><a href=" https://github.com/oxidecomputer/typify ">typify-impl 0.5.0</a></li> + <li><a href=" https://github.com/oxidecomputer/typify ">typify-macro 0.5.0</a></li> + <li><a href=" https://github.com/oxidecomputer/typify ">typify 0.5.0</a></li> + <li><a href=" https://github.com/dtolnay/unicode-ident ">unicode-ident 1.0.24</a></li> + <li><a href=" https://github.com/alacritty/vte ">utf8parse 0.2.2</a></li> + <li><a href=" https://github.com/bytecodealliance/wasi-rs ">wasip3 0.4.0+wasi-0.3.0-rc-2026-01-06</a></li> + <li><a href=" https://github.com/ardaku/wasite ">wasite 1.0.2</a></li> + <li><a href=" https://github.com/MattiasBuelens/wasm-streams/ ">wasm-streams 0.4.2</a></li> + <li><a href=" https://github.com/MattiasBuelens/wasm-streams/ ">wasm-streams 0.5.0</a></li> + <li><a href=" https://github.com/ardaku/whoami ">whoami 2.1.2</a></li> + <li><a href=" https://github.com/retep998/winapi-rs ">winapi-i686-pc-windows-gnu 0.4.0</a></li> + <li><a href=" https://github.com/retep998/winapi-rs ">winapi-x86_64-pc-windows-gnu 0.4.0</a></li> + <li><a href=" https://github.com/huggingface/xet-core ">xet-client 1.5.2</a></li> + <li><a href=" https://github.com/huggingface/xet-core ">xet-core-structures 1.5.2</a></li> + <li><a href=" https://github.com/huggingface/xet-core ">xet-data 1.5.2</a></li> + <li><a href=" https://github.com/huggingface/xet-core ">xet-runtime 1.5.2</a></li> + <li><a href=" https://github.com/gyscos/zstd-rs ">zstd-safe 7.2.4</a></li> + <li><a href=" https://github.com/gyscos/zstd-rs ">zstd-sys 2.0.16+zstd.1.5.7</a></li> + </ul> + <pre class="license-text">Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. + +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. + +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. + +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. + +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. + +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. + +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). + +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. + +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." + +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: + + (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. + + You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/chronotope/chrono-tz ">chrono-tz 0.10.4</a></li> + </ul> + <pre class="license-text">Chrono-TZ is dual-licensed under the MIT License and Apache 2.0 Licence. +The licenses do not apply to files in the tzdb folder which are in the +public domain. parse-zoneinfo was forked from zoneinfo-parse, which +was originally created by Benjamin Sago under the MIT license. + +Copyright (c) 2016-2024 Benjamin Sago & the chronotope maintainers + +The MIT License + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2016 Djzin + +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. +</pre> + </li> + <li class="license"> + <h3 id="Apache-2.0">Apache License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/chronotope/chrono ">chrono 0.4.44</a></li> + </ul> + <pre class="license-text">Rust-chrono is dual-licensed under The MIT License [1] and +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and +contributors. + +Nota Bene: This is same as the Rust Project's own license. + + +[1]: <http://opensource.org/licenses/MIT>, which is reproduced below: + +~~~~ +The MIT License (MIT) + +Copyright (c) 2014, Kang Seonghoon. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +~~~~ + + +[2]: <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below: + +~~~~ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +~~~~ + +</pre> + </li> + <li class="license"> + <h3 id="BSD-2-Clause">BSD 2-Clause "Simplified" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/droundy/arrayref ">arrayref 0.3.9</a></li> + </ul> + <pre class="license-text">Copyright (c) 2015 David Roundy <roundyd@physics.oregonstate.edu> +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. + +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 +HOLDER 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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-2-Clause">BSD 2-Clause "Simplified" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/fusion-engineering/rust-git-version ">git-version-macro 0.3.9</a></li> + <li><a href=" https://github.com/fusion-engineering/rust-git-version ">git-version 0.3.9</a></li> + </ul> + <pre class="license-text">Copyright (c) 2017-2020 +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. + +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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/CurrySoftware/rust-stemmers ">rust-stemmers 1.2.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2001, Dr Martin Porter +Copyright (c) 2004,2005, Richard Boulton +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.</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dropbox/rust-alloc-no-stdlib ">alloc-no-stdlib 2.0.4</a></li> + <li><a href=" https://github.com/dropbox/rust-brotli-decompressor ">brotli-decompressor 5.0.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2016 Dropbox, Inc. +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 copyright holder 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 HOLDER 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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dalek-cryptography/subtle ">subtle 2.6.1</a></li> + </ul> + <pre class="license-text">Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved. +Copyright (c) 2016-2024 Isis Agora Lovecruft. 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 copyright holder 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 +HOLDER 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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dropbox/rust-alloc-no-stdlib ">alloc-stdlib 0.2.2</a></li> + <li><a href=" https://github.com/aws/aws-lc-rs ">aws-lc-sys 0.41.0</a></li> + <li><a href=" https://github.com/dropbox/rust-brotli ">brotli 8.0.2</a></li> + </ul> + <pre class="license-text">Copyright (c) <year> <owner>. + +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 copyright holder 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 HOLDER 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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/BurntSushi/rust-snappy ">snap 1.1.1</a></li> + </ul> + <pre class="license-text">Copyright 2011, The Snappy-Rust Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * 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. + * Neither the name of the copyright holder 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. +</pre> + </li> + <li class="license"> + <h3 id="BSD-3-Clause">BSD 3-Clause "New" or "Revised" License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hsivonen/encoding_rs ">encoding_rs 0.8.35</a></li> + </ul> + <pre class="license-text">Copyright © WHATWG (Apple, Google, Mozilla, Microsoft). + +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 copyright holder 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 HOLDER 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. +</pre> + </li> + <li class="license"> + <h3 id="BSL-1.0">Boost Software License 1.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/DoumanAsh/xxhash-rust ">xxhash-rust 0.8.15</a></li> + </ul> + <pre class="license-text">Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="CC0-1.0">Creative Commons Zero v1.0 Universal</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://crates.io/crates/tiny-keccak ">tiny-keccak 2.0.2</a></li> + </ul> + <pre class="license-text">Creative Commons Legal Code + +CC0 1.0 Universal + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE + LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN + ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS + INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES + REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS + PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM + THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED + HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer +exclusive Copyright and Related Rights (defined below) upon the creator +and subsequent owner(s) (each and all, an "owner") of an original work of +authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for +the purpose of contributing to a commons of creative, cultural and +scientific works ("Commons") that the public can reliably and without fear +of later claims of infringement build upon, modify, incorporate in other +works, reuse and redistribute as freely as possible in any form whatsoever +and for any purposes, including without limitation commercial purposes. +These owners may contribute to the Commons to promote the ideal of a free +culture and the further production of creative, cultural and scientific +works, or to gain reputation or greater distribution for their Work in +part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any +expectation of additional consideration or compensation, the person +associating CC0 with a Work (the "Affirmer"), to the extent that he or she +is an owner of Copyright and Related Rights in the Work, voluntarily +elects to apply CC0 to the Work and publicly distribute the Work under its +terms, with knowledge of his or her Copyright and Related Rights in the +Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be +protected by copyright and related or neighboring rights ("Copyright and +Related Rights"). Copyright and Related Rights include, but are not +limited to, the following: + + i. the right to reproduce, adapt, distribute, perform, display, + communicate, and translate a Work; + ii. moral rights retained by the original author(s) and/or performer(s); +iii. publicity and privacy rights pertaining to a person's image or + likeness depicted in a Work; + iv. rights protecting against unfair competition in regards to a Work, + subject to the limitations in paragraph 4(a), below; + v. rights protecting the extraction, dissemination, use and reuse of data + in a Work; + vi. database rights (such as those arising under Directive 96/9/EC of the + European Parliament and of the Council of 11 March 1996 on the legal + protection of databases, and under any national implementation + thereof, including any amended or successor version of such + directive); and +vii. other similar, equivalent or corresponding rights throughout the + world based on applicable law or treaty, and any national + implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention +of, applicable law, Affirmer hereby overtly, fully, permanently, +irrevocably and unconditionally waives, abandons, and surrenders all of +Affirmer's Copyright and Related Rights and associated claims and causes +of action, whether now known or unknown (including existing as well as +future claims and causes of action), in the Work (i) in all territories +worldwide, (ii) for the maximum duration provided by applicable law or +treaty (including future time extensions), (iii) in any current or future +medium and for any number of copies, and (iv) for any purpose whatsoever, +including without limitation commercial, advertising or promotional +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each +member of the public at large and to the detriment of Affirmer's heirs and +successors, fully intending that such Waiver shall not be subject to +revocation, rescission, cancellation, termination, or any other legal or +equitable action to disrupt the quiet enjoyment of the Work by the public +as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason +be judged legally invalid or ineffective under applicable law, then the +Waiver shall be preserved to the maximum extent permitted taking into +account Affirmer's express Statement of Purpose. In addition, to the +extent the Waiver is so judged Affirmer hereby grants to each affected +person a royalty-free, non transferable, non sublicensable, non exclusive, +irrevocable and unconditional license to exercise Affirmer's Copyright and +Related Rights in the Work (i) in all territories worldwide, (ii) for the +maximum duration provided by applicable law or treaty (including future +time extensions), (iii) in any current or future medium and for any number +of copies, and (iv) for any purpose whatsoever, including without +limitation commercial, advertising or promotional purposes (the +"License"). The License shall be deemed effective as of the date CC0 was +applied by Affirmer to the Work. Should any part of the License for any +reason be judged legally invalid or ineffective under applicable law, such +partial invalidity or ineffectiveness shall not invalidate the remainder +of the License, and in such case Affirmer hereby affirms that he or she +will not (i) exercise any of his or her remaining Copyright and Related +Rights in the Work or (ii) assert any associated claims and causes of +action with respect to the Work, in either case contrary to Affirmer's +express Statement of Purpose. + +4. Limitations and Disclaimers. + + a. No trademark or patent rights held by Affirmer are waived, abandoned, + surrendered, licensed or otherwise affected by this document. + b. Affirmer offers the Work as-is and makes no representations or + warranties of any kind concerning the Work, express, implied, + statutory or otherwise, including without limitation warranties of + title, merchantability, fitness for a particular purpose, non + infringement, or the absence of latent or other defects, accuracy, or + the present or absence of errors, whether or not discoverable, all to + the greatest extent permissible under applicable law. + c. Affirmer disclaims responsibility for clearing rights of other persons + that may apply to the Work or any use thereof, including without + limitation any person's Copyright and Related Rights in the Work. + Further, Affirmer disclaims responsibility for obtaining any necessary + consents, permissions or other rights required for any use of the + Work. + d. Affirmer understands and acknowledges that Creative Commons is not a + party to this document and has no duty or obligation with respect to + this CC0 or use of the Work. +</pre> + </li> + <li class="license"> + <h3 id="CDDL-1.0">Common Development and Distribution License 1.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jonhoo/inferno.git ">inferno 0.11.21</a></li> + </ul> + <pre class="license-text">Unless otherwise noted, all files in this distribution are released +under the Common Development and Distribution License (CDDL). +Exceptions are noted within the associated source files. + +-------------------------------------------------------------------- + + +COMMON DEVELOPMENT AND DISTRIBUTION LICENSE Version 1.0 + +1. Definitions. + + 1.1. "Contributor" means each individual or entity that creates + or contributes to the creation of Modifications. + + 1.2. "Contributor Version" means the combination of the Original + Software, prior Modifications used by a Contributor (if any), + and the Modifications made by that particular Contributor. + + 1.3. "Covered Software" means (a) the Original Software, or (b) + Modifications, or (c) the combination of files containing + Original Software with files containing Modifications, in + each case including portions thereof. + + 1.4. "Executable" means the Covered Software in any form other + than Source Code. + + 1.5. "Initial Developer" means the individual or entity that first + makes Original Software available under this License. + + 1.6. "Larger Work" means a work which combines Covered Software or + portions thereof with code not governed by the terms of this + License. + + 1.7. "License" means this document. + + 1.8. "Licensable" means having the right to grant, to the maximum + extent possible, whether at the time of the initial grant or + subsequently acquired, any and all of the rights conveyed + herein. + + 1.9. "Modifications" means the Source Code and Executable form of + any of the following: + + A. Any file that results from an addition to, deletion from or + modification of the contents of a file containing Original + Software or previous Modifications; + + B. Any new file that contains any part of the Original + Software or previous Modifications; or + + C. Any new file that is contributed or otherwise made + available under the terms of this License. + + 1.10. "Original Software" means the Source Code and Executable + form of computer software code that is originally released + under this License. + + 1.11. "Patent Claims" means any patent claim(s), now owned or + hereafter acquired, including without limitation, method, + process, and apparatus claims, in any patent Licensable by + grantor. + + 1.12. "Source Code" means (a) the common form of computer software + code in which modifications are made and (b) associated + documentation included in or with such code. + + 1.13. "You" (or "Your") means an individual or a legal entity + exercising rights under, and complying with all of the terms + of, this License. For legal entities, "You" includes any + entity which controls, is controlled by, or is under common + control with You. For purposes of this definition, + "control" means (a) the power, direct or indirect, to cause + the direction or management of such entity, whether by + contract or otherwise, or (b) ownership of more than fifty + percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants. + + 2.1. The Initial Developer Grant. + + Conditioned upon Your compliance with Section 3.1 below and + subject to third party intellectual property claims, the Initial + Developer hereby grants You a world-wide, royalty-free, + non-exclusive license: + + (a) under intellectual property rights (other than patent or + trademark) Licensable by Initial Developer, to use, + reproduce, modify, display, perform, sublicense and + distribute the Original Software (or portions thereof), + with or without Modifications, and/or as part of a Larger + Work; and + + (b) under Patent Claims infringed by the making, using or + selling of Original Software, to make, have made, use, + practice, sell, and offer for sale, and/or otherwise + dispose of the Original Software (or portions thereof). + + (c) The licenses granted in Sections 2.1(a) and (b) are + effective on the date Initial Developer first distributes + or otherwise makes the Original Software available to a + third party under the terms of this License. + + (d) Notwithstanding Section 2.1(b) above, no patent license is + granted: (1) for code that You delete from the Original + Software, or (2) for infringements caused by: (i) the + modification of the Original Software, or (ii) the + combination of the Original Software with other software + or devices. + + 2.2. Contributor Grant. + + Conditioned upon Your compliance with Section 3.1 below and + subject to third party intellectual property claims, each + Contributor hereby grants You a world-wide, royalty-free, + non-exclusive license: + + (a) under intellectual property rights (other than patent or + trademark) Licensable by Contributor to use, reproduce, + modify, display, perform, sublicense and distribute the + Modifications created by such Contributor (or portions + thereof), either on an unmodified basis, with other + Modifications, as Covered Software and/or as part of a + Larger Work; and + + (b) under Patent Claims infringed by the making, using, or + selling of Modifications made by that Contributor either + alone and/or in combination with its Contributor Version + (or portions of such combination), to make, use, sell, + offer for sale, have made, and/or otherwise dispose of: + (1) Modifications made by that Contributor (or portions + thereof); and (2) the combination of Modifications made by + that Contributor with its Contributor Version (or portions + of such combination). + + (c) The licenses granted in Sections 2.2(a) and 2.2(b) are + effective on the date Contributor first distributes or + otherwise makes the Modifications available to a third + party. + + (d) Notwithstanding Section 2.2(b) above, no patent license is + granted: (1) for any code that Contributor has deleted + from the Contributor Version; (2) for infringements caused + by: (i) third party modifications of Contributor Version, + or (ii) the combination of Modifications made by that + Contributor with other software (except as part of the + Contributor Version) or other devices; or (3) under Patent + Claims infringed by Covered Software in the absence of + Modifications made by that Contributor. + +3. Distribution Obligations. + + 3.1. Availability of Source Code. + + Any Covered Software that You distribute or otherwise make + available in Executable form must also be made available in Source + Code form and that Source Code form must be distributed only under + the terms of this License. You must include a copy of this + License with every copy of the Source Code form of the Covered + Software You distribute or otherwise make available. You must + inform recipients of any such Covered Software in Executable form + as to how they can obtain such Covered Software in Source Code + form in a reasonable manner on or through a medium customarily + used for software exchange. + + 3.2. Modifications. + + The Modifications that You create or to which You contribute are + governed by the terms of this License. You represent that You + believe Your Modifications are Your original creation(s) and/or + You have sufficient rights to grant the rights conveyed by this + License. + + 3.3. Required Notices. + + You must include a notice in each of Your Modifications that + identifies You as the Contributor of the Modification. You may + not remove or alter any copyright, patent or trademark notices + contained within the Covered Software, or any notices of licensing + or any descriptive text giving attribution to any Contributor or + the Initial Developer. + + 3.4. Application of Additional Terms. + + You may not offer or impose any terms on any Covered Software in + Source Code form that alters or restricts the applicable version + of this License or the recipients' rights hereunder. You may + choose to offer, and to charge a fee for, warranty, support, + indemnity or liability obligations to one or more recipients of + Covered Software. However, you may do so only on Your own behalf, + and not on behalf of the Initial Developer or any Contributor. + You must make it absolutely clear that any such warranty, support, + indemnity or liability obligation is offered by You alone, and You + hereby agree to indemnify the Initial Developer and every + Contributor for any liability incurred by the Initial Developer or + such Contributor as a result of warranty, support, indemnity or + liability terms You offer. + + 3.5. Distribution of Executable Versions. + + You may distribute the Executable form of the Covered Software + under the terms of this License or under the terms of a license of + Your choice, which may contain terms different from this License, + provided that You are in compliance with the terms of this License + and that the license for the Executable form does not attempt to + limit or alter the recipient's rights in the Source Code form from + the rights set forth in this License. If You distribute the + Covered Software in Executable form under a different license, You + must make it absolutely clear that any terms which differ from + this License are offered by You alone, not by the Initial + Developer or Contributor. You hereby agree to indemnify the + Initial Developer and every Contributor for any liability incurred + by the Initial Developer or such Contributor as a result of any + such terms You offer. + + 3.6. Larger Works. + + You may create a Larger Work by combining Covered Software with + other code not governed by the terms of this License and + distribute the Larger Work as a single product. In such a case, + You must make sure the requirements of this License are fulfilled + for the Covered Software. + +4. Versions of the License. + + 4.1. New Versions. + + Sun Microsystems, Inc. is the initial license steward and may + publish revised and/or new versions of this License from time to + time. Each version will be given a distinguishing version number. + Except as provided in Section 4.3, no one other than the license + steward has the right to modify this License. + + 4.2. Effect of New Versions. + + You may always continue to use, distribute or otherwise make the + Covered Software available under the terms of the version of the + License under which You originally received the Covered Software. + If the Initial Developer includes a notice in the Original + Software prohibiting it from being distributed or otherwise made + available under any subsequent version of the License, You must + distribute and make the Covered Software available under the terms + of the version of the License under which You originally received + the Covered Software. Otherwise, You may also choose to use, + distribute or otherwise make the Covered Software available under + the terms of any subsequent version of the License published by + the license steward. + + 4.3. Modified Versions. + + When You are an Initial Developer and You want to create a new + license for Your Original Software, You may create and use a + modified version of this License if You: (a) rename the license + and remove any references to the name of the license steward + (except to note that the license differs from this License); and + (b) otherwise make it clear that the license contains terms which + differ from this License. + +5. DISCLAIMER OF WARRANTY. + + COVERED SOFTWARE IS PROVIDED UNDER THIS LICENSE ON AN "AS IS" + BASIS, WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, + INCLUDING, WITHOUT LIMITATION, WARRANTIES THAT THE COVERED + SOFTWARE IS FREE OF DEFECTS, MERCHANTABLE, FIT FOR A PARTICULAR + PURPOSE OR NON-INFRINGING. THE ENTIRE RISK AS TO THE QUALITY AND + PERFORMANCE OF THE COVERED SOFTWARE IS WITH YOU. SHOULD ANY + COVERED SOFTWARE PROVE DEFECTIVE IN ANY RESPECT, YOU (NOT THE + INITIAL DEVELOPER OR ANY OTHER CONTRIBUTOR) ASSUME THE COST OF ANY + NECESSARY SERVICING, REPAIR OR CORRECTION. THIS DISCLAIMER OF + WARRANTY CONSTITUTES AN ESSENTIAL PART OF THIS LICENSE. NO USE OF + ANY COVERED SOFTWARE IS AUTHORIZED HEREUNDER EXCEPT UNDER THIS + DISCLAIMER. + +6. TERMINATION. + + 6.1. This License and the rights granted hereunder will terminate + automatically if You fail to comply with terms herein and fail to + cure such breach within 30 days of becoming aware of the breach. + Provisions which, by their nature, must remain in effect beyond + the termination of this License shall survive. + + 6.2. If You assert a patent infringement claim (excluding + declaratory judgment actions) against Initial Developer or a + Contributor (the Initial Developer or Contributor against whom You + assert such claim is referred to as "Participant") alleging that + the Participant Software (meaning the Contributor Version where + the Participant is a Contributor or the Original Software where + the Participant is the Initial Developer) directly or indirectly + infringes any patent, then any and all rights granted directly or + indirectly to You by such Participant, the Initial Developer (if + the Initial Developer is not the Participant) and all Contributors + under Sections 2.1 and/or 2.2 of this License shall, upon 60 days + notice from Participant terminate prospectively and automatically + at the expiration of such 60 day notice period, unless if within + such 60 day period You withdraw Your claim with respect to the + Participant Software against such Participant either unilaterally + or pursuant to a written agreement with Participant. + + 6.3. In the event of termination under Sections 6.1 or 6.2 above, + all end user licenses that have been validly granted by You or any + distributor hereunder prior to termination (excluding licenses + granted to You by any distributor) shall survive termination. + +7. LIMITATION OF LIABILITY. + + UNDER NO CIRCUMSTANCES AND UNDER NO LEGAL THEORY, WHETHER TORT + (INCLUDING NEGLIGENCE), CONTRACT, OR OTHERWISE, SHALL YOU, THE + INITIAL DEVELOPER, ANY OTHER CONTRIBUTOR, OR ANY DISTRIBUTOR OF + COVERED SOFTWARE, OR ANY SUPPLIER OF ANY OF SUCH PARTIES, BE + LIABLE TO ANY PERSON FOR ANY INDIRECT, SPECIAL, INCIDENTAL, OR + CONSEQUENTIAL DAMAGES OF ANY CHARACTER INCLUDING, WITHOUT + LIMITATION, DAMAGES FOR LOST PROFITS, LOSS OF GOODWILL, WORK + STOPPAGE, COMPUTER FAILURE OR MALFUNCTION, OR ANY AND ALL OTHER + COMMERCIAL DAMAGES OR LOSSES, EVEN IF SUCH PARTY SHALL HAVE BEEN + INFORMED OF THE POSSIBILITY OF SUCH DAMAGES. THIS LIMITATION OF + LIABILITY SHALL NOT APPLY TO LIABILITY FOR DEATH OR PERSONAL + INJURY RESULTING FROM SUCH PARTY'S NEGLIGENCE TO THE EXTENT + APPLICABLE LAW PROHIBITS SUCH LIMITATION. SOME JURISDICTIONS DO + NOT ALLOW THE EXCLUSION OR LIMITATION OF INCIDENTAL OR + CONSEQUENTIAL DAMAGES, SO THIS EXCLUSION AND LIMITATION MAY NOT + APPLY TO YOU. + +8. U.S. GOVERNMENT END USERS. + + The Covered Software is a "commercial item," as that term is + defined in 48 C.F.R. 2.101 (Oct. 1995), consisting of "commercial + computer software" (as that term is defined at 48 + C.F.R. 252.227-7014(a)(1)) and "commercial computer software + documentation" as such terms are used in 48 C.F.R. 12.212 + (Sept. 1995). Consistent with 48 C.F.R. 12.212 and 48 + C.F.R. 227.7202-1 through 227.7202-4 (June 1995), all + U.S. Government End Users acquire Covered Software with only those + rights set forth herein. This U.S. Government Rights clause is in + lieu of, and supersedes, any other FAR, DFAR, or other clause or + provision that addresses Government rights in computer software + under this License. + +9. MISCELLANEOUS. + + This License represents the complete agreement concerning subject + matter hereof. If any provision of this License is held to be + unenforceable, such provision shall be reformed only to the extent + necessary to make it enforceable. This License shall be governed + by the law of the jurisdiction specified in a notice contained + within the Original Software (except to the extent applicable law, + if any, provides otherwise), excluding such jurisdiction's + conflict-of-law provisions. Any litigation relating to this + License shall be subject to the jurisdiction of the courts located + in the jurisdiction and venue specified in a notice contained + within the Original Software, with the losing party responsible + for costs, including, without limitation, court costs and + reasonable attorneys' fees and expenses. The application of the + United Nations Convention on Contracts for the International Sale + of Goods is expressly excluded. Any law or regulation which + provides that the language of a contract shall be construed + against the drafter shall not apply to this License. You agree + that You alone are responsible for compliance with the United + States export administration regulations (and the export control + laws and regulation of any other countries) when You use, + distribute or otherwise make available any Covered Software. + +10. RESPONSIBILITY FOR CLAIMS. + + As between Initial Developer and the Contributors, each party is + responsible for claims and damages arising, directly or + indirectly, out of its utilization of rights under this License + and You agree to work with Initial Developer and Contributors to + distribute such responsibility on an equitable basis. Nothing + herein is intended or shall be deemed to constitute any admission + of liability. + +-------------------------------------------------------------------- + +NOTICE PURSUANT TO SECTION 9 OF THE COMMON DEVELOPMENT AND +DISTRIBUTION LICENSE (CDDL) + +For Covered Software in this distribution, this License shall +be governed by the laws of the State of California (excluding +conflict-of-law provisions). + +Any litigation relating to this License shall be subject to the +jurisdiction of the Federal Courts of the Northern District of +California and the state courts of the State of California, with +venue lying in Santa Clara County, California. +</pre> + </li> + <li class="license"> + <h3 id="CDLA-Permissive-2.0">Community Data License Agreement Permissive 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rustls/webpki-roots ">webpki-root-certs 1.0.7</a></li> + <li><a href=" https://github.com/rustls/webpki-roots ">webpki-roots 0.26.11</a></li> + <li><a href=" https://github.com/rustls/webpki-roots ">webpki-roots 1.0.7</a></li> + </ul> + <pre class="license-text"># Community Data License Agreement - Permissive - Version 2.0 + +This is the Community Data License Agreement - Permissive, Version +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree +as follows: + +## 1. Provision of the Data + +1.1. A Data Recipient may use, modify, and share the Data made +available by Data Provider(s) under this agreement if that Data +Recipient follows the terms of this agreement. + +1.2. This agreement does not impose any restriction on a Data +Recipient's use, modification, or sharing of any portions of the +Data that are in the public domain or that may be used, modified, +or shared under any other legal exception or limitation. + +## 2. Conditions for Sharing Data + +2.1. A Data Recipient may share Data, with or without modifications, so +long as the Data Recipient makes available the text of this agreement +with the shared Data. + +## 3. No Restrictions on Results + +3.1. This agreement does not impose any restriction or obligations +with respect to the use, modification, or sharing of Results. + +## 4. No Warranty; Limitation of Liability + +4.1. All Data Recipients receive the Data subject to the following +terms: + +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS, +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE, +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING +WITHOUT LIMITATION LOST PROFITS), 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 DATA OR RESULTS, +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. + +## 5. Definitions + +5.1. "Data" means the material received by a Data Recipient under +this agreement. + +5.2. "Data Provider" means any person who is the source of Data +provided under this agreement and in reliance on a Data Recipient's +agreement to its terms. + +5.3. "Data Recipient" means any person who receives Data directly +or indirectly from a Data Provider and agrees to the terms of this +agreement. + +5.4. "Results" means any outcome obtained by computational analysis +of Data, including for example machine learning models and models' +insights. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/briansmith/untrusted ">untrusted 0.7.1</a></li> + <li><a href=" https://github.com/briansmith/untrusted ">untrusted 0.9.0</a></li> + </ul> + <pre class="license-text">// Copyright 2015-2016 Brian Smith. +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/acw/simple_asn1 ">simple_asn1 0.6.4</a></li> + </ul> + <pre class="license-text">Copyright (c) 2017 Adam Wick + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/briansmith/ring ">ring 0.17.14</a></li> + </ul> + <pre class="license-text">Copyright 2015-2025 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rustls/webpki ">rustls-webpki 0.103.13</a></li> + </ul> + <pre class="license-text">Except as otherwise noted, this project is licensed under the following +(ISC-style) terms: + +Copyright 2015 Brian Smith. + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +The files under third-party/chromium are licensed as described in +third-party/chromium/LICENSE. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/frewsxcv/earcutr/ ">earcutr 0.4.3</a></li> + </ul> + <pre class="license-text">ISC License + +Copyright (c) 2016, Mapbox +Copyright (c) 2018, Tree Cricket + +Permission to use, copy, modify, and/or distribute this software for any purpose +with or without fee is hereby granted, provided that the above copyright notice +and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF +THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="ISC">ISC License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/aws/aws-lc-rs ">aws-lc-rs 1.17.0</a></li> + <li><a href=" https://github.com/aws/aws-lc-rs ">aws-lc-sys 0.41.0</a></li> + </ul> + <pre class="license-text">ISC License: + +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC") +Copyright (c) 1995-2003 by Internet Software Consortium + +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-onig/rust-onig ">onig_sys 69.9.3</a></li> + </ul> + <pre class="license-text"># Rust-Onig is Open Source! + +All source code in this repository is distributed under the terms of +the *MIT License* unless otherwise stated. The Oniguruma source code +remains the property of the original authors and is re-distributed +under the original license, see [COPYING](oniguruma/COPYING) for more +information. + +> The MIT License (MIT) +> +> Copyright (c) 2015 Will Speak <will@willspeak.me>, Ivan Ivashchenko +> <defuz@me.com>, and contributors. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/iwillspeak/rust-onig ">onig 6.5.3</a></li> + </ul> + <pre class="license-text"># Rust-Onig is Open Source! + +All source code in this repository is distributed under the terms of +the *MIT License* unless otherwise stated. The Oniguruma source code +remains the property of the original authors and is re-distributed +under the original license. + +> The MIT License (MIT) +> +> Copyright (c) 2015 Will Speak <will@willspeak.me>, Ivan Ivashchenko +> <defuz@me.com>, and contributors. +> +> Permission is hereby granted, free of charge, to any person obtaining a copy +> of this software and associated documentation files (the "Software"), to deal +> in the Software without restriction, including without limitation the rights +> to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +> copies of the Software, and to permit persons to whom the Software is +> furnished to do so, subject to the following conditions: +> +> The above copyright notice and this permission notice shall be included in all +> copies or substantial portions of the Software. +> +> THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +> IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +> AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +> LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +> OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +> SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dropbox/rust-brotli ">brotli 8.0.2</a></li> + </ul> + <pre class="license-text">Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-openssl/rust-openssl ">openssl-sys 0.9.116</a></li> + </ul> + <pre class="license-text">Copyright (c) 2014 Alex Crichton + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/mio ">mio 1.2.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2014 Carl Lerche and other MIO contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Geal/nom ">nom 7.1.3</a></li> + <li><a href=" https://github.com/rust-bakery/nom ">nom 8.0.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2014-2019 Geoffroy Couprie + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/hyper ">hyper 1.9.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2014-2026 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/reem/rust-ordered-float ">ordered-float 2.10.1</a></li> + <li><a href=" https://github.com/reem/rust-ordered-float ">ordered-float 5.3.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2015 Jonathan Reem + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/steffengy/schannel-rs ">schannel 0.1.29</a></li> + </ul> + <pre class="license-text">Copyright (c) 2015 steffengy + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/quickwit-oss/bitpacking ">bitpacking 0.9.3</a></li> + </ul> + <pre class="license-text">Copyright (c) 2016 Paul Masurel + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://gitlab.redox-os.org/redox-os/syscall ">redox_syscall 0.5.18</a></li> + </ul> + <pre class="license-text">Copyright (c) 2017 Redox OS Developers + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/h2 ">h2 0.4.14</a></li> + </ul> + <pre class="license-text">Copyright (c) 2017 h2 authors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/bytes ">bytes 1.11.1</a></li> + </ul> + <pre class="license-text">Copyright (c) 2018 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/palfrey/serial_test/ ">serial_test 3.4.0</a></li> + <li><a href=" https://github.com/palfrey/serial_test/ ">serial_test_derive 3.4.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2018 Tom Parker-Shemilt + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/seanmonstar/want ">want 0.3.1</a></li> + </ul> + <pre class="license-text">Copyright (c) 2018-2019 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/seanmonstar/try-lock ">try-lock 0.2.5</a></li> + </ul> + <pre class="license-text">Copyright (c) 2018-2023 Sean McArthur +Copyright (c) 2016 Alex Crichton + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/loom ">loom 0.7.2</a></li> + <li><a href=" https://github.com/tokio-rs/slab ">slab 0.4.12</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Carl Lerche + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/davidpdrsn/assert-json-diff.git ">assert-json-diff 2.0.2</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 David Pedersen + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hawkw/sharded-slab ">sharded-slab 0.1.7</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hawkw/matchers ">matchers 0.2.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Eliza Weisman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/http-body ">http-body 0.4.6</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/tls ">tokio-native-tls 0.3.1</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-appender 0.2.5</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-attributes 0.1.31</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-core 0.1.36</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-log 0.2.0</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-mock 0.1.0-beta.3</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-serde 0.2.0</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing-subscriber 0.3.23</a></li> + <li><a href=" https://github.com/tokio-rs/tracing ">tracing 0.1.44</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Tokio Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tower-rs/tower ">tower-layer 0.3.3</a></li> + <li><a href=" https://github.com/tower-rs/tower ">tower-service 0.3.3</a></li> + <li><a href=" https://github.com/tower-rs/tower ">tower 0.5.3</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019 Tower Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tower-rs/tower-http ">tower-http 0.6.11</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019-2021 Tower Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/http-body ">http-body 1.0.1</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/http-body ">http-body-util 0.1.3</a></li> + </ul> + <pre class="license-text">Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/RustCrypto/asm-hashes ">sha2-asm 0.6.4</a></li> + </ul> + <pre class="license-text">Copyright (c) 2020 RustCrypto Developers +Copyright (c) 2017 Project Nayuki, Artyom Pavlov + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/hyperium/hyper-util ">hyper-util 0.1.20</a></li> + </ul> + <pre class="license-text">Copyright (c) 2023-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-cli/termtree ">termtree 0.5.1</a></li> + </ul> + <pre class="license-text">Copyright (c) Individual contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mystor/synstructure ">synstructure 0.13.2</a></li> + </ul> + <pre class="license-text">Copyright 2016 Nika Layzell + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jeromefroe/lru-rs.git ">lru 0.16.4</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2016 Jerome Froelich + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/statrs-dev/statrs ">statrs 0.18.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2016 Michael Ma + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/pacman82/atoi-rs ">atoi 2.0.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/webdesus/fs_extra ">fs_extra 1.3.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 Denis Kurilenko + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/myrrlyn/tap ">tap 1.0.1</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 Elliot Linder <darfink@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/vitiral/std_prelude ">std_prelude 0.2.12</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 Garrett Berg <vitiral@gmail.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/djc/tokio-retry ">tokio-retry 0.3.1</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 Sam Rijs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/TedDriggs/darling ">darling 0.14.4</a></li> + <li><a href=" https://github.com/TedDriggs/darling ">darling 0.23.0</a></li> + <li><a href=" https://github.com/TedDriggs/darling ">darling_core 0.14.4</a></li> + <li><a href=" https://github.com/TedDriggs/darling ">darling_core 0.23.0</a></li> + <li><a href=" https://github.com/TedDriggs/darling ">darling_macro 0.14.4</a></li> + <li><a href=" https://github.com/TedDriggs/darling ">darling_macro 0.23.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2017 Ted Driggs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/bitvecto-rs/bitvec ">bitvec 1.0.1</a></li> + <li><a href=" https://github.com/myrrlyn/wyz ">wyz 0.5.1</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2018 myrrlyn (Alexander Payne) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/sgodwincs/ordered-multimap-rs ">ordered-multimap 0.7.3</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2018 sgodwincs + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/georust/geographiclib-rs ">geographiclib-rs 0.2.7</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/xacrimon/dashmap ">dashmap 6.2.1</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Acrimon + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Aeledfyr/deepsize/ ">deepsize 0.2.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Aeledfyr + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/nukesor/comfy-table ">comfy-table 7.2.2</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Arne Beer + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/GREsau/schemars ">schemars 0.8.22</a></li> + <li><a href=" https://github.com/GREsau/schemars ">schemars_derive 0.8.22</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Graham Esau + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/kornelski/rust-rgb ">rgb 0.8.53</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Kornel + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Peternator7/strum ">strum 0.26.3</a></li> + <li><a href=" https://github.com/Peternator7/strum ">strum_macros 0.26.4</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Peter Glotfelty + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/tokio ">tokio-macros 2.7.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 Yoshua Wuyts +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/bitvecto-rs/radium ">radium 0.7.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2019 kneecaw (Nika Layzell) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tabac/hyperloglog.rs ">hyperloglogplus 0.4.1</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020 Anastasios Bakogiannis + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://gitlab.com/bronsonbdevost/next_afterf ">float_next_after 1.0.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020 Scripta Qumranica Electronica + +Created by Bronson Brown-deVost + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/thoren-d/tracing-chrome ">tracing-chrome 0.7.2</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020 Thoren Paulson + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/myrrlyn/funty ">funty 2.0.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020 myrrlyn (Alexander Payne) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/apache/arrow-rs ">arrow-array 58.3.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020-2022 Oliver Margetts + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Nugine/const-str ">const-str 1.1.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2020-2026 Nugine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://gitlab.com/samsartor/async_cell ">async_cell 0.2.3</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2021 Sam Sartor + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Nugine/outref ">outref 0.5.2</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2022 Nugine + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/sarah-ek/aligned-vec/ ">aligned-vec 0.6.4</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2022 sarah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://gitlab.redox-os.org/redox-os/libredox.git ">libredox 0.1.16</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2023 4lDO2 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/iShape-Rust/iFloat ">i_float 1.15.0</a></li> + <li><a href=" https://github.com/iShape-Rust/iShape ">i_shape 1.14.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2023 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/sarah-ek/equator/ ">equator-macro 0.4.2</a></li> + <li><a href=" https://github.com/sarah-ek/equator/ ">equator 0.4.2</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2023 sarah + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/spire-rs/countio ">countio 0.3.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2024 Spire Team + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/iShape-Rust/iKeySort ">i_key_sort 0.6.0</a></li> + <li><a href=" https://github.com/iShape-Rust/iTree ">i_tree 0.16.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) 2024 iShape-Rust + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Nugine/simd ">base64-simd 0.8.0</a></li> + <li><a href=" https://github.com/Aeledfyr/deepsize/ ">deepsize_derive 0.1.2</a></li> + <li><a href=" https://github.com/iShape-Rust/iOverlay ">i_overlay 4.0.7</a></li> + <li><a href=" https://github.com/rust-lang/compiler-builtins ">libm 0.2.16</a></li> + <li><a href=" https://github.com/ogham/rust-number-prefix ">number_prefix 0.4.0</a></li> + <li><a href=" https://github.com/influxdata/pbjson ">pbjson-build 0.8.0</a></li> + <li><a href=" https://github.com/influxdata/pbjson ">pbjson-types 0.8.0</a></li> + <li><a href=" https://github.com/influxdata/pbjson ">pbjson 0.8.0</a></li> + <li><a href=" https://github.com/plotters-rs/plotters ">plotters-backend 0.3.7</a></li> + <li><a href=" https://github.com/plotters-rs/plotters.git ">plotters-svg 0.3.7</a></li> + <li><a href=" https://github.com/plotters-rs/plotters ">plotters 0.3.7</a></li> + <li><a href=" https://github.com/MitchellRhysHall/random_word ">random_word 0.5.2</a></li> + <li><a href=" https://github.com/getsentry/symbolic ">symbolic-common 12.18.3</a></li> + <li><a href=" https://github.com/getsentry/symbolic ">symbolic-demangle 12.18.3</a></li> + <li><a href=" https://github.com/Nugine/simd ">vsimd 0.8.0</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) <year> <copyright holders> + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and +associated documentation files (the "Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the +following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE +USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tokio-rs/tokio ">tokio-stream 0.1.18</a></li> + <li><a href=" https://github.com/tokio-rs/tokio ">tokio-util 0.7.18</a></li> + <li><a href=" https://github.com/tokio-rs/tokio ">tokio 1.52.3</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) Tokio Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mcountryman/simd-adler32 ">simd-adler32 0.3.9</a></li> + </ul> + <pre class="license-text">MIT License + +Copyright (c) [2021] [Marvin Countryman] + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/fkoep/downcast-rs ">downcast 0.11.0</a></li> + </ul> + <pre class="license-text">MIT License (MIT) + +Copyright (c) 2017 Felix Köpge + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/sunfishcode/is-terminal ">is-terminal 0.4.17</a></li> + <li><a href=" https://github.com/dtolnay/unsafe-libyaml ">unsafe-libyaml 0.2.11</a></li> + <li><a href=" https://github.com/dtolnay/zmij ">zmij 1.0.21</a></li> + </ul> + <pre class="license-text">Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/winnow-rs/winnow ">winnow 1.0.3</a></li> + </ul> + <pre class="license-text">Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mvdnes/spin-rs.git ">spin 0.10.0</a></li> + <li><a href=" https://github.com/mvdnes/spin-rs.git ">spin 0.9.8</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2014 Mathijs van de Nes + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/zonyitoo/rust-ini ">rust-ini 0.21.3</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2014 Y. T. CHUNG + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rust-phf/rust-phf ">phf 0.12.1</a></li> + <li><a href=" https://github.com/rust-phf/rust-phf ">phf_shared 0.12.1</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/BurntSushi/aho-corasick ">aho-corasick 1.1.4</a></li> + <li><a href=" https://github.com/BurntSushi/byteorder ">byteorder 1.5.0</a></li> + <li><a href=" https://github.com/BurntSushi/rust-csv ">csv-core 0.1.13</a></li> + <li><a href=" https://github.com/BurntSushi/rust-csv ">csv 1.4.0</a></li> + <li><a href=" https://github.com/BurntSushi/fst ">fst 0.4.7</a></li> + <li><a href=" https://github.com/BurntSushi/jiff ">jiff-tzdb-platform 0.1.3</a></li> + <li><a href=" https://github.com/BurntSushi/jiff ">jiff-tzdb 0.1.6</a></li> + <li><a href=" https://github.com/BurntSushi/jiff ">jiff 0.2.24</a></li> + <li><a href=" https://github.com/BurntSushi/memchr ">memchr 2.8.0</a></li> + <li><a href=" https://github.com/BurntSushi/utf8-ranges ">utf8-ranges 1.0.5</a></li> + <li><a href=" https://github.com/BurntSushi/walkdir ">walkdir 2.5.0</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/10xGenomics/lz4-rs ">lz4-sys 1.11.1+lz4-1.10.0</a></li> + <li><a href=" https://github.com/10xGenomics/lz4-rs ">lz4 1.28.1</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Artem V. Navrotskiy + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/nix-rust/nix ">nix 0.26.4</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Carl Lerche + nix-rust Authors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dguo/strsim-rs ">strsim 0.10.0</a></li> + <li><a href=" https://github.com/rapidfuzz/strsim-rs ">strsim 0.11.1</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Danny Guo +Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com> +Copyright (c) 2018 Akash Kurdekar + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/GuillaumeGomez/sysinfo ">sysinfo 0.38.4</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Guillaume Gomez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/shepmaster/twox-hash ">twox-hash 2.1.2</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Jake Goulding + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Marwes/combine ">combine 4.6.7</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Markus Westerlind + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/Keats/jsonwebtoken ">jsonwebtoken 10.4.0</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Vincent Prouillet + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/aws/aws-lc-rs ">aws-lc-sys 0.41.0</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015-2020 the fiat-crypto authors (see +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS). + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dermesser/integer-encoding-rs ">integer-encoding 3.0.4</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official +Google product or in any way related! +Copyright (c) 2018-2020 Lewin Bormann (lbo@spheniscida.de) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to +deal in the Software without restriction, including without limitation the +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or +sell copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS +IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/jcreekmore/pem-rs.git ">pem 3.0.6</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2016 Jonathan Creekmore + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/nabijaczleweli/safe-transmute-rs ">safe-transmute 0.11.3</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2016 nabijaczleweli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/BurntSushi/same-file ">same-file 1.0.6</a></li> + <li><a href=" https://github.com/BurntSushi/winapi-util ">winapi-util 0.1.11</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2017 Andrew Gallant + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/console-rs/console ">console 0.15.11</a></li> + <li><a href=" https://github.com/console-rs/indicatif ">indicatif 0.17.11</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2017 Armin Ronacher <armin.ronacher@active-4.com> + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://gitlab.redox-os.org/redox-os/users ">redox_users 0.5.2</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2017 Jose Narvaez + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://hg.sr.ht/~icefox/oorandom ">oorandom 11.1.5</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2019 Simon Heath + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/pseitz/lz4_flex ">lz4_flex 0.13.1</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2020 Pascal Seitz + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/eira-fransham/crunchy ">crunchy 0.2.4</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright 2017-2023 Eira Fransham. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/gyscos/zstd-rs ">zstd 0.13.3</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) +Copyright (c) 2016 Alexandre Bury + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/nushell/nu-ansi-term ">nu-ansi-term 0.50.3</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2014 Benjamin Sago +Copyright (c) 2021-2022 The Nushell Project Developers + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/abonander/mime_guess ">mime_guess 2.0.5</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Austin Bonander + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/fizyk20/generic-array.git ">generic-array 0.14.7</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2015 Bartłomiej Kamiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE.</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tafia/quick-xml ">quick-xml 0.26.0</a></li> + <li><a href=" https://github.com/tafia/quick-xml ">quick-xml 0.38.4</a></li> + <li><a href=" https://github.com/tafia/quick-xml ">quick-xml 0.39.4</a></li> + </ul> + <pre class="license-text">The MIT License (MIT) + +Copyright (c) 2016 Johann Tuffe + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MIT">MIT License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/kornelski/rust_urlencoding ">urlencoding 2.1.3</a></li> + </ul> + <pre class="license-text">© 2016 Bertram Truong +© 2021 Kornel Lesiński + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +</pre> + </li> + <li class="license"> + <h3 id="MPL-2.0">Mozilla Public License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/tobz/tracking-allocator ">tracking-allocator 0.4.0</a></li> + </ul> + <pre class="license-text">Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0.</pre> + </li> + <li class="license"> + <h3 id="MPL-2.0">Mozilla Public License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/mackwic/colored ">colored 3.1.1</a></li> + </ul> + <pre class="license-text">Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at http://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. +</pre> + </li> + <li class="license"> + <h3 id="MPL-2.0">Mozilla Public License 2.0</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/soc/option-ext.git ">option-ext 0.2.0</a></li> + </ul> + <pre class="license-text">Mozilla Public License Version 2.0 +================================== + +1. Definitions +-------------- + +1.1. "Contributor" + means each individual or legal entity that creates, contributes to + the creation of, or owns Covered Software. + +1.2. "Contributor Version" + means the combination of the Contributions of others (if any) used + by a Contributor and that particular Contributor's Contribution. + +1.3. "Contribution" + means Covered Software of a particular Contributor. + +1.4. "Covered Software" + means Source Code Form to which the initial Contributor has attached + the notice in Exhibit A, the Executable Form of such Source Code + Form, and Modifications of such Source Code Form, in each case + including portions thereof. + +1.5. "Incompatible With Secondary Licenses" + means + + (a) that the initial Contributor has attached the notice described + in Exhibit B to the Covered Software; or + + (b) that the Covered Software was made available under the terms of + version 1.1 or earlier of the License, but not also under the + terms of a Secondary License. + +1.6. "Executable Form" + means any form of the work other than Source Code Form. + +1.7. "Larger Work" + means a work that combines Covered Software with other material, in + a separate file or files, that is not Covered Software. + +1.8. "License" + means this document. + +1.9. "Licensable" + means having the right to grant, to the maximum extent possible, + whether at the time of the initial grant or subsequently, any and + all of the rights conveyed by this License. + +1.10. "Modifications" + means any of the following: + + (a) any file in Source Code Form that results from an addition to, + deletion from, or modification of the contents of Covered + Software; or + + (b) any new file in Source Code Form that contains any Covered + Software. + +1.11. "Patent Claims" of a Contributor + means any patent claim(s), including without limitation, method, + process, and apparatus claims, in any patent Licensable by such + Contributor that would be infringed, but for the grant of the + License, by the making, using, selling, offering for sale, having + made, import, or transfer of either its Contributions or its + Contributor Version. + +1.12. "Secondary License" + means either the GNU General Public License, Version 2.0, the GNU + Lesser General Public License, Version 2.1, the GNU Affero General + Public License, Version 3.0, or any later versions of those + licenses. + +1.13. "Source Code Form" + means the form of the work preferred for making modifications. + +1.14. "You" (or "Your") + means an individual or a legal entity exercising rights under this + License. For legal entities, "You" includes any entity that + controls, is controlled by, or is under common control with You. For + purposes of this definition, "control" means (a) the power, direct + or indirect, to cause the direction or management of such entity, + whether by contract or otherwise, or (b) ownership of more than + fifty percent (50%) of the outstanding shares or beneficial + ownership of such entity. + +2. License Grants and Conditions +-------------------------------- + +2.1. Grants + +Each Contributor hereby grants You a world-wide, royalty-free, +non-exclusive license: + +(a) under intellectual property rights (other than patent or trademark) + Licensable by such Contributor to use, reproduce, make available, + modify, display, perform, distribute, and otherwise exploit its + Contributions, either on an unmodified basis, with Modifications, or + as part of a Larger Work; and + +(b) under Patent Claims of such Contributor to make, use, sell, offer + for sale, have made, import, and otherwise transfer either its + Contributions or its Contributor Version. + +2.2. Effective Date + +The licenses granted in Section 2.1 with respect to any Contribution +become effective for each Contribution on the date the Contributor first +distributes such Contribution. + +2.3. Limitations on Grant Scope + +The licenses granted in this Section 2 are the only rights granted under +this License. No additional rights or licenses will be implied from the +distribution or licensing of Covered Software under this License. +Notwithstanding Section 2.1(b) above, no patent license is granted by a +Contributor: + +(a) for any code that a Contributor has removed from Covered Software; + or + +(b) for infringements caused by: (i) Your and any other third party's + modifications of Covered Software, or (ii) the combination of its + Contributions with other software (except as part of its Contributor + Version); or + +(c) under Patent Claims infringed by Covered Software in the absence of + its Contributions. + +This License does not grant any rights in the trademarks, service marks, +or logos of any Contributor (except as may be necessary to comply with +the notice requirements in Section 3.4). + +2.4. Subsequent Licenses + +No Contributor makes additional grants as a result of Your choice to +distribute the Covered Software under a subsequent version of this +License (see Section 10.2) or under the terms of a Secondary License (if +permitted under the terms of Section 3.3). + +2.5. Representation + +Each Contributor represents that the Contributor believes its +Contributions are its original creation(s) or it has sufficient rights +to grant the rights to its Contributions conveyed by this License. + +2.6. Fair Use + +This License is not intended to limit any rights You have under +applicable copyright doctrines of fair use, fair dealing, or other +equivalents. + +2.7. Conditions + +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted +in Section 2.1. + +3. Responsibilities +------------------- + +3.1. Distribution of Source Form + +All distribution of Covered Software in Source Code Form, including any +Modifications that You create or to which You contribute, must be under +the terms of this License. You must inform recipients that the Source +Code Form of the Covered Software is governed by the terms of this +License, and how they can obtain a copy of this License. You may not +attempt to alter or restrict the recipients' rights in the Source Code +Form. + +3.2. Distribution of Executable Form + +If You distribute Covered Software in Executable Form then: + +(a) such Covered Software must also be made available in Source Code + Form, as described in Section 3.1, and You must inform recipients of + the Executable Form how they can obtain a copy of such Source Code + Form by reasonable means in a timely manner, at a charge no more + than the cost of distribution to the recipient; and + +(b) You may distribute such Executable Form under the terms of this + License, or sublicense it under different terms, provided that the + license for the Executable Form does not attempt to limit or alter + the recipients' rights in the Source Code Form under this License. + +3.3. Distribution of a Larger Work + +You may create and distribute a Larger Work under terms of Your choice, +provided that You also comply with the requirements of this License for +the Covered Software. If the Larger Work is a combination of Covered +Software with a work governed by one or more Secondary Licenses, and the +Covered Software is not Incompatible With Secondary Licenses, this +License permits You to additionally distribute such Covered Software +under the terms of such Secondary License(s), so that the recipient of +the Larger Work may, at their option, further distribute the Covered +Software under the terms of either this License or such Secondary +License(s). + +3.4. Notices + +You may not remove or alter the substance of any license notices +(including copyright notices, patent notices, disclaimers of warranty, +or limitations of liability) contained within the Source Code Form of +the Covered Software, except that You may alter any license notices to +the extent required to remedy known factual inaccuracies. + +3.5. Application of Additional Terms + +You may choose to offer, and to charge a fee for, warranty, support, +indemnity or liability obligations to one or more recipients of Covered +Software. However, You may do so only on Your own behalf, and not on +behalf of any Contributor. You must make it absolutely clear that any +such warranty, support, indemnity, or liability obligation is offered by +You alone, and You hereby agree to indemnify every Contributor for any +liability incurred by such Contributor as a result of warranty, support, +indemnity or liability terms You offer. You may include additional +disclaimers of warranty and limitations of liability specific to any +jurisdiction. + +4. Inability to Comply Due to Statute or Regulation +--------------------------------------------------- + +If it is impossible for You to comply with any of the terms of this +License with respect to some or all of the Covered Software due to +statute, judicial order, or regulation then You must: (a) comply with +the terms of this License to the maximum extent possible; and (b) +describe the limitations and the code they affect. Such description must +be placed in a text file included with all distributions of the Covered +Software under this License. Except to the extent prohibited by statute +or regulation, such description must be sufficiently detailed for a +recipient of ordinary skill to be able to understand it. + +5. Termination +-------------- + +5.1. The rights granted under this License will terminate automatically +if You fail to comply with any of its terms. However, if You become +compliant, then the rights granted under this License from a particular +Contributor are reinstated (a) provisionally, unless and until such +Contributor explicitly and finally terminates Your grants, and (b) on an +ongoing basis, if such Contributor fails to notify You of the +non-compliance by some reasonable means prior to 60 days after You have +come back into compliance. Moreover, Your grants from a particular +Contributor are reinstated on an ongoing basis if such Contributor +notifies You of the non-compliance by some reasonable means, this is the +first time You have received notice of non-compliance with this License +from such Contributor, and You become compliant prior to 30 days after +Your receipt of the notice. + +5.2. If You initiate litigation against any entity by asserting a patent +infringement claim (excluding declaratory judgment actions, +counter-claims, and cross-claims) alleging that a Contributor Version +directly or indirectly infringes any patent, then the rights granted to +You by any and all Contributors for the Covered Software under Section +2.1 of this License shall terminate. + +5.3. In the event of termination under Sections 5.1 or 5.2 above, all +end user license agreements (excluding distributors and resellers) which +have been validly granted by You or Your distributors under this License +prior to termination shall survive termination. + +************************************************************************ +* * +* 6. Disclaimer of Warranty * +* ------------------------- * +* * +* Covered Software is provided under this License on an "as is" * +* basis, without warranty of any kind, either expressed, implied, or * +* statutory, including, without limitation, warranties that the * +* Covered Software is free of defects, merchantable, fit for a * +* particular purpose or non-infringing. The entire risk as to the * +* quality and performance of the Covered Software is with You. * +* Should any Covered Software prove defective in any respect, You * +* (not any Contributor) assume the cost of any necessary servicing, * +* repair, or correction. This disclaimer of warranty constitutes an * +* essential part of this License. No use of any Covered Software is * +* authorized under this License except under this disclaimer. * +* * +************************************************************************ + +************************************************************************ +* * +* 7. Limitation of Liability * +* -------------------------- * +* * +* Under no circumstances and under no legal theory, whether tort * +* (including negligence), contract, or otherwise, shall any * +* Contributor, or anyone who distributes Covered Software as * +* permitted above, be liable to You for any direct, indirect, * +* special, incidental, or consequential damages of any character * +* including, without limitation, damages for lost profits, loss of * +* goodwill, work stoppage, computer failure or malfunction, or any * +* and all other commercial damages or losses, even if such party * +* shall have been informed of the possibility of such damages. This * +* limitation of liability shall not apply to liability for death or * +* personal injury resulting from such party's negligence to the * +* extent applicable law prohibits such limitation. Some * +* jurisdictions do not allow the exclusion or limitation of * +* incidental or consequential damages, so this exclusion and * +* limitation may not apply to You. * +* * +************************************************************************ + +8. Litigation +------------- + +Any litigation relating to this License may be brought only in the +courts of a jurisdiction where the defendant maintains its principal +place of business and such litigation shall be governed by laws of that +jurisdiction, without reference to its conflict-of-law provisions. +Nothing in this Section shall prevent a party's ability to bring +cross-claims or counter-claims. + +9. Miscellaneous +---------------- + +This License represents the complete agreement concerning the subject +matter hereof. If any provision of this License is held to be +unenforceable, such provision shall be reformed only to the extent +necessary to make it enforceable. Any law or regulation which provides +that the language of a contract shall be construed against the drafter +shall not be used to construe this License against a Contributor. + +10. Versions of the License +--------------------------- + +10.1. New Versions + +Mozilla Foundation is the license steward. Except as provided in Section +10.3, no one other than the license steward has the right to modify or +publish new versions of this License. Each version will be given a +distinguishing version number. + +10.2. Effect of New Versions + +You may distribute the Covered Software under the terms of the version +of the License under which You originally received the Covered Software, +or under the terms of any subsequent version published by the license +steward. + +10.3. Modified Versions + +If you create software not governed by this License, and you want to +create a new license for such software, you may create and use a +modified version of this License if you rename the license and remove +any references to the name of the license steward (except to note that +such modified license differs from this License). + +10.4. Distributing Source Code Form that is Incompatible With Secondary +Licenses + +If You choose to distribute Source Code Form that is Incompatible With +Secondary Licenses under the terms of this version of the License, the +notice described in Exhibit B of this License must be attached. + +Exhibit A - Source Code Form License Notice +------------------------------------------- + + This Source Code Form is subject to the terms of the Mozilla Public + License, v. 2.0. If a copy of the MPL was not distributed with this + file, You can obtain one at https://mozilla.org/MPL/2.0/. + +If it is not possible or desirable to put the notice in a particular +file, then You may include the notice in a location (such as a LICENSE +file in a relevant directory) where a recipient would be likely to look +for such a notice. + +You may add additional accurate notices of copyright ownership. + +Exhibit B - "Incompatible With Secondary Licenses" Notice +--------------------------------------------------------- + + This Source Code Form is "Incompatible With Secondary Licenses", as + defined by the Mozilla Public License, v. 2.0. +</pre> + </li> + <li class="license"> + <h3 id="Unicode-3.0">Unicode License v3</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/dtolnay/unicode-ident ">unicode-ident 1.0.24</a></li> + </ul> + <pre class="license-text">UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 1991-2023 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. +</pre> + </li> + <li class="license"> + <h3 id="Unicode-3.0">Unicode License v3</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_collections 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_locale 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_locale_core 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_locale_data 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_normalizer 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_normalizer_data 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_properties 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_properties_data 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_provider 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_segmenter 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">icu_segmenter_data 2.2.0</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">litemap 0.8.2</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">potential_utf 0.1.5</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">tinystr 0.8.3</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">writeable 0.6.3</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">yoke-derive 0.8.2</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">yoke 0.8.2</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">zerofrom-derive 0.1.7</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">zerofrom 0.1.8</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">zerotrie 0.2.4</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">zerovec-derive 0.11.3</a></li> + <li><a href=" https://github.com/unicode-org/icu4x ">zerovec 0.11.6</a></li> + </ul> + <pre class="license-text">UNICODE LICENSE V3 + +COPYRIGHT AND PERMISSION NOTICE + +Copyright © 2020-2024 Unicode, Inc. + +NOTICE TO USER: Carefully read the following legal agreement. BY +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE. + +Permission is hereby granted, free of charge, to any person obtaining a +copy of data files and any associated documentation (the "Data Files") or +software and any associated documentation (the "Software") to deal in the +Data Files or Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, and/or sell +copies of the Data Files or Software, and to permit persons to whom the +Data Files or Software are furnished to do so, provided that either (a) +this copyright and permission notice appear with all copies of the Data +Files or Software, or (b) this copyright and permission notice appear in +associated Documentation. + +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF +THIRD PARTY RIGHTS. + +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES, +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA +FILES OR SOFTWARE. + +Except as contained in this notice, the name of a copyright holder shall +not be used in advertising or otherwise to promote the sale, use or other +dealings in these Data Files or Software without prior written +authorization of the copyright holder. + +SPDX-License-Identifier: Unicode-3.0 + +— + +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J. +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others. +</pre> + </li> + <li class="license"> + <h3 id="Zlib">zlib License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/trifectatechfoundation/zlib-rs ">zlib-rs 0.6.3</a></li> + </ul> + <pre class="license-text">(C) 2024 Trifecta Tech Foundation + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution. +</pre> + </li> + <li class="license"> + <h3 id="Zlib">zlib License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rodrimati1992/const_panic/ ">const_panic 0.2.15</a></li> + <li><a href=" https://github.com/rodrimati1992/konst/ ">konst 0.4.3</a></li> + <li><a href=" https://github.com/rodrimati1992/konst/ ">konst_proc_macros 0.4.1</a></li> + </ul> + <pre class="license-text">Copyright (c) 2021 Matias Rodriguez. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution.</pre> + </li> + <li class="license"> + <h3 id="Zlib">zlib License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/rodrimati1992/typewit/ ">typewit 1.15.2</a></li> + </ul> + <pre class="license-text">Copyright (c) 2023 Matias Rodriguez. + +This software is provided 'as-is', without any express or implied +warranty. In no event will the authors be held liable for any damages +arising from the use of this software. + +Permission is granted to anyone to use this software for any purpose, +including commercial applications, and to alter it and redistribute it +freely, subject to the following restrictions: + +1. The origin of this software must not be misrepresented; you must not + claim that you wrote the original software. If you use this software + in a product, an acknowledgment in the product documentation would be + appreciated but is not required. +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. +3. This notice may not be removed or altered from any source distribution.</pre> + </li> + <li class="license"> + <h3 id="Zlib">zlib License</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + <li><a href=" https://github.com/orlp/foldhash ">foldhash 0.1.5</a></li> + <li><a href=" https://github.com/orlp/foldhash ">foldhash 0.2.0</a></li> + </ul> + <pre class="license-text">Copyright (c) 2024 Orson Peters + +This software is provided 'as-is', without any express or implied warranty. In +no event will the authors be held liable for any damages arising from the use of +this software. + +Permission is granted to anyone to use this software for any purpose, including +commercial applications, and to alter it and redistribute it freely, subject to +the following restrictions: + +1. The origin of this software must not be misrepresented; you must not claim + that you wrote the original software. If you use this software in a product, + an acknowledgment in the product documentation would be appreciated but is + not required. + +2. Altered source versions must be plainly marked as such, and must not be + misrepresented as being the original software. + +3. This notice may not be removed or altered from any source distribution.</pre> + </li> + </ul> + </main> +</body> + +</html> diff --git a/about.hbs b/about.hbs new file mode 100644 index 00000000000..699b3b04edf --- /dev/null +++ b/about.hbs @@ -0,0 +1,70 @@ +<html> + +<head> + <style> + @media (prefers-color-scheme: dark) { + body { + background: #333; + color: white; + } + a { + color: skyblue; + } + } + .container { + font-family: sans-serif; + max-width: 800px; + margin: 0 auto; + } + .intro { + text-align: center; + } + .licenses-list { + list-style-type: none; + margin: 0; + padding: 0; + } + .license-used-by { + margin-top: -10px; + } + .license-text { + max-height: 200px; + overflow-y: scroll; + white-space: pre-wrap; + } + </style> +</head> + +<body> + <main class="container"> + <div class="intro"> + <h1>Third Party Licenses</h1> + <p>This page lists the licenses of the projects used in cargo-about.</p> + </div> + + <h2>Overview of licenses:</h2> + <ul class="licenses-overview"> + {{#each overview}} + <li><a href="#{{id}}">{{name}}</a> ({{count}})</li> + {{/each}} + </ul> + + <h2>All license text:</h2> + <ul class="licenses-list"> + {{#each licenses}} + <li class="license"> + <h3 id="{{id}}">{{name}}</h3> + <h4>Used by:</h4> + <ul class="license-used-by"> + {{#each used_by}} + <li><a href="{{#if crate.repository}} {{crate.repository}} {{else}} https://crates.io/crates/{{crate.name}} {{/if}}">{{crate.name}} {{crate.version}}</a></li> + {{/each}} + </ul> + <pre class="license-text">{{text}}</pre> + </li> + {{/each}} + </ul> + </main> +</body> + +</html> diff --git a/about.toml b/about.toml new file mode 100644 index 00000000000..d4ddcef2855 --- /dev/null +++ b/about.toml @@ -0,0 +1,17 @@ +accepted = [ + "0BSD", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "BSL-1.0", + "bzip2-1.0.6", + "CC0-1.0", + "CDDL-1.0", + "CDLA-Permissive-2.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Zlib", +] diff --git a/benchmarks/auto-ivf-dot/PROTOCOL.md b/benchmarks/auto-ivf-dot/PROTOCOL.md new file mode 100644 index 00000000000..1281ac1f2b0 --- /dev/null +++ b/benchmarks/auto-ivf-dot/PROTOCOL.md @@ -0,0 +1,54 @@ +# Dot auto-probing calibration (OSS-2249) + +All execution takes place on AWS. No local benchmark or test runs. + +- Baseline: Lance main `00a8230ba21341966cb3edac17b475b7b9219302`. +- Data: complete `lance-format/wiki-cohere-35m` (35,000,000 rows, 5,000 queries) + and `lance-format/dpr-wikipedia-single-nq` (21,015,300 rows, 3,610 queries). + Pin HF revisions and verify every published SHA256 before use. +- Preserve original float32 vectors and dot semantics. Never normalize the + corpus or queries. Ground-truth IDs are source row positions. +- Build one IVF_FLAT index per corpus from the baseline, using + `ceil(rows / 4096)` partitions and the default training/assignment settings. + Freeze its centroids, partition membership, source, native binary and manifest. + Baseline and candidate queries use these identical indices. +- Split each official query set with NumPy RNG seed 2249: first half calibration, + second half evaluation. Freeze IDs before calibration. Tune only on calibration. +- Primary objective: minimize mean scanned partitions subject to recall >= 0.95 + separately for each corpus and k in {1, 10, 100}. Report per-corpus optima and + the best common profile. Best means best in the documented search grid; + no universal optimum or per-query recall guarantee is claimed. + The common-profile objective weights the two corpora equally. Select one + score-gap family using the sum of its mean costs across the three k groups; + tune floor, cap and margin independently within each k group. +- First collect actual ground-truth partition ranks and centroid scores. Use + these to evaluate policies cheaply, then verify shortlisted policies using + actual index queries. Routing coverage alone is not a latency measurement. +- Diagnose query/vector norms, score offsets, relative centroid gaps and + query-projected residuals. Distinguish observed correlation from causation. +- Compare current main Auto, calibrated Auto, and nearby power-of-two fixed + budgets. Select the fixed budgets on calibration queries, not evaluation. + Also evaluate the independently frozen per-corpus profile within the selected + score-gap family as `tuned`; report any held-out recall misses without retuning. +- Execute serial queries, balanced interleaved policy order, warmed index cache, + identical thread/CPU settings. Record source and binary identity for every run. + Keep compilation, index construction and other benchmarks out of timing runs. +- Freeze the first 512 evaluation queries for matched latency distributions. + Measure candidate Auto and all nearby fixed budgets selected on calibration + queries on every evaluation query for recall. Measure main's legacy policy + on the first 32 evaluation queries as a diagnostic, since it scans all 35M + rows for Wiki k=10/100. Report its smaller sample count and do not use its + latency percentiles as the primary performance baseline. +- Report raw per-query recall, elapsed ms, scanned rows and scanned partitions; + summarize mean/p90/p95/p99/max with NumPy linear percentiles. Capture I/O + counters and validate actual scan metrics against the policy predictions. +- After each serial timing phase, run the remaining correctness queries with + eight workers. Tag those records `phase=recall` and exclude their elapsed times + from performance summaries. Join all workers before the next timing phase. + Run per-corpus override profiles in a separate recall phase with constant + process-wide environment settings to prevent cross-query configuration races. +- Independently evaluate the frozen common profile. If a dataset misses 95%, + report the miss; do not tune on those evaluation queries and relabel it held out. +- Preserve fixed/min/max semantics, unsupported-index/metric behavior, late + probing for filtered queries, and existing L2/cosine profiles. Add regression + coverage and run the prescribed Rust/Python checks on AWS before delivery. diff --git a/benchmarks/auto-ivf-dot/README.md b/benchmarks/auto-ivf-dot/README.md new file mode 100644 index 00000000000..2907669974b --- /dev/null +++ b/benchmarks/auto-ivf-dot/README.md @@ -0,0 +1,70 @@ +# Dot-product Auto IVF probing + +This experiment calibrates the initial IVF_FLAT probe budget on the complete +[Wiki-Cohere 35M](https://huggingface.co/datasets/lance-format/wiki-cohere-35m) +and [DPR Wikipedia](https://huggingface.co/datasets/lance-format/dpr-wikipedia-single-nq) +datasets. See [PROTOCOL.md](PROTOCOL.md) for the frozen evaluation contract. +The completed AWS measurements and parameter tradeoffs are in [RESULTS.md](RESULTS.md). + +Run all Python commands from `python/` after the repository's `make install` +setup, on a benchmark VM with enough memory to cache one complete IVF_FLAT +index (at least 256 GiB for these datasets). Build the native extension with the +repository's `release-with-debug` profile. Keep raw corpus and query norms. + +Prepare a working copy of each HF snapshot under +`$STUDY/data/<dataset-name>/`, pin its revision, verify its `SHA256SUMS`, and +create a `VERIFIED` marker only after every checksum passes. Copies must preserve +row order. Immutable data files can be hard-linked; manifests and version hints +must be independent copies because index creation writes dataset metadata. + +```bash +export STUDY=/path/to/study +export LANCE_CPU_THREADS=32 RAYON_NUM_THREADS=32 +export OPENBLAS_NUM_THREADS=16 OMP_NUM_THREADS=16 +export LANCE_USE_HNSW_SPEEDUP_INDEXING=auto +uv run python ../benchmarks/auto-ivf-dot/prepare.py "$STUDY" wiki-cohere-35m +uv run python ../benchmarks/auto-ivf-dot/prepare.py "$STUDY" dpr-wikipedia-single-nq +uv run python ../benchmarks/auto-ivf-dot/calibrate.py "$STUDY" +``` + +`prepare.py` builds a frozen IVF_FLAT index, maps every indexed row to its actual +partition, and saves centroid scores, ground-truth partition ranks, partition +sizes, and diagnostic projections. `calibrate.py` reads only the calibration +half of the official queries, selects floors/caps/margins, and freezes the +chosen score normalization before independent evaluation. + +Run `measure.py` first with the baseline binary and `--limit 8` to verify that +explicitly bounded Auto with a full-partition maximum produces identical results +and scan counts to main's unbounded Auto. The `legacy` timing arm uses that +existing bounded policy on the candidate binary so it can be interleaved with +candidate Auto and fixed budgets under the same cache and process conditions. +The `tuned` arm uses the per-corpus profile frozen during calibration, within +the selected gap family, through the three Auto environment overrides. + +After installing a candidate implementing the **frozen** profile, run: + +```bash +export LANCE_CPU_THREADS=16 RAYON_NUM_THREADS=16 +export OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 +uv run python ../benchmarks/auto-ivf-dot/measure.py "$STUDY" wiki-cohere-35m --native +uv run python ../benchmarks/auto-ivf-dot/measure.py "$STUDY" dpr-wikipedia-single-nq --native +uv run python ../benchmarks/auto-ivf-dot/audit.py "$STUDY" +``` + +Timing runs must be serial with no other compilation or benchmark workload. +Use the same CPU affinity for every run. By default Auto and all selected fixed +budgets evaluate every held-out query. Their matched latency distributions use +the first 512 held-out queries (`--timing-queries`). +The legacy diagnostic uses 32 queries (`--legacy-queries`) because its full scans +are expensive; its percentiles are descriptive only. `--limit` +is for smoke checks or a clearly labeled subset. CSVs retain +neighbor IDs, timings, recall, scan counts, I/O, and routing predictions for each +query. JSON summaries include percentiles, prediction mismatches, and binary +identity. Inspect mismatches and source-score ties before drawing conclusions. +After each serial timing phase, remaining recall-only queries use eight workers +(`--recall-workers`); their elapsed times are excluded from performance summaries. +Every worker finishes before the next serial timing phase. + +The tuned 96% calibration target leaves headroom for an independent 95% recall +target. Neither the profile nor the grid search guarantees recall on arbitrary +indices, partition counts, query distributions, or other datasets. diff --git a/benchmarks/auto-ivf-dot/RESULTS.md b/benchmarks/auto-ivf-dot/RESULTS.md new file mode 100644 index 00000000000..a42fab096b1 --- /dev/null +++ b/benchmarks/auto-ivf-dot/RESULTS.md @@ -0,0 +1,335 @@ +# OSS-2249: dot Auto IVF probing + +The frozen common profiles achieved at least 95% mean native recall in all six +held-out Wiki/DPR cases. These are empirical parameters for Float32 IVF_FLAT, +selected within the documented grid; they are not a universal optimum or recall +guarantee. No MS MARCO data was used. All execution ran on AWS. + +## Common defaults + +For sorted centroid distances `d_i = 1 - dot(q, c_i)`, take the prefix satisfying +`d_i - d_0 <= margin * abs(1 - d_0)`, then apply the learned floor and initial cap. +Caller minimums take precedence over the learned cap. Later probing remains +available when filtering or deletions exhaust the initial budget. + +| k | margin | floor | initial cap | Wiki recall | DPR recall | +| ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | 0.14 | 56 partitions | 112 partitions | 95.320% | 96.565% | +| 2–10 | 0.055 | 144 partitions | 432 partitions | 95.428% | 96.454% | +| 11–100 | 0.0625 | 200 partitions | 768 partitions | 95.809% | 96.218% | + +Recall was measured at k=1, 10, and 100; the buckets between those endpoints +were not separately benchmarked. The selection objective minimizes mean +partitions with a 96% calibration target on both corpora, weighting each corpus +equally. One gap family is chosen by aggregate cost across the three k groups; +parameters are independent per group. The relative-score family won that +aggregate objective. Standard-deviation normalization had a lower k=1 cost but +a higher aggregate cost. The exact search grid is in `calibrate.py` and the +archived `calibration.json`. Profiles were frozen before evaluation. + +## Common Auto versus fixed budgets + +The fixed budgets below are the neighboring tested powers of two that reached +95% held-out recall. All three neighboring budgets were selected on calibration +data and measured; the complete matrix, including misses, follows below. +This is a **policy comparison on the same candidate binary and frozen index**, +not an equal-recall or before/after branch speedup. Recall differs between arms. +Lower latency is better; the ratio is fixed mean / Auto mean. + +| Scenario / metric | Baseline fixed policy | This PR common Auto | Benefit | +| --- | ---: | ---: | ---: | +| Wiki, k=1, mean latency | 128.26 ms (128 probes; 95.720% recall) | 113.26 ms (95.320% recall) | 1.13x mean speedup | +| Wiki, k=10, mean latency | 502.50 ms (512 probes; 97.272% recall) | 263.09 ms (95.428% recall) | 1.91x mean speedup | +| Wiki, k=100, mean latency | 518.94 ms (512 probes; 95.762% recall) | 453.49 ms (95.809% recall) | 1.14x mean speedup | +| DPR, k=1, mean latency | 120.45 ms (128 probes; 97.230% recall) | 91.38 ms (96.565% recall) | 1.32x mean speedup | +| DPR, k=10, mean latency | 120.75 ms (128 probes; 95.950% recall) | 135.99 ms (96.454% recall) | 1.13x slower | +| DPR, k=100, mean latency | 237.39 ms (256 probes; 97.232% recall) | 186.59 ms (96.218% recall) | 1.27x mean speedup | + +- DPR k=10: common Auto is slower than fixed128, while both exceed 95% recall. +- Wiki k=100: Auto mean is lower, but p99 is 827.30 ms versus fixed512's + 555.05 ms (1.49x slower). There is no blanket tail-latency improvement. +- Wiki k=10: fixed256 was the calibration-selected reference but reaches only + 94.636% held-out recall. The preregistered neighboring fixed512 reaches 97.272%, + versus Auto's 95.428%; their 1.91x mean-latency ratio is not an equal-quality gain. +- Mean recall is an empirical point estimate. In particular Wiki k=1's + approximate 95% confidence interval includes values below 95%; this study does + not establish a population-level 95% lower bound. Query-wise intervals are + retained in `audited-results.json`. + +## Per-corpus calibrated profiles + +These minimize calibration cost within the selected relative-score family. +They were frozen independently of evaluation and were not adjusted after misses. +They can be supplied through `LANCE_AUTO_PROBE_MARGIN`, +`LANCE_AUTO_MIN_INITIAL_NPROBES`, and `LANCE_AUTO_MAX_INITIAL_NPROBES`, respectively. +Overrides are process-wide: do not change them concurrently with queries. +Choose a profile according to the recall and tail-latency requirements below. + +| Corpus | k | margin / floor / cap (partitions) | native recall | mean ms | p99 ms | assessment | +| --- | ---: | --- | ---: | ---: | ---: | --- | +| Wiki | 1 | 0.033 / 72 / 336 | 94.920% | 91.90 | 313.89 | misses 95%; use common profile | +| Wiki | 10 | 0.055 / 112 / 464 | 95.416% | 260.78 | 488.12 | passes; inspect tail tradeoff | +| Wiki | 100 | 0.0605 / 248 / 768 | 95.750% | 444.52 | 820.19 | passes; inspect tail tradeoff | +| DPR | 1 | 0.11 / 32 / 256 | 96.620% | 80.55 | 242.53 | passes; inspect tail tradeoff | +| DPR | 10 | 0.125 / 64 / 240 | 96.709% | 115.81 | 232.49 | passes; inspect tail tradeoff | +| DPR | 100 | 0.135 / 96 / 496 | 96.122% | 162.31 | 465.11 | passes; inspect tail tradeoff | + +## Setup and provenance + +- AWS r8i.8xlarge, 32 vCPU / 256 GiB, Ubuntu 24.04.4; 2 TiB gp3 EBS, + 10,000 IOPS and 1,000 MiB/s provisioned throughput. +- Baseline main: `00a8230ba21341966cb3edac17b475b7b9219302`. +- [Wiki-Cohere 35M](https://huggingface.co/datasets/lance-format/wiki-cohere-35m/tree/cc3840e4e0c9091f26cdd0385fbb7a6ad81a7285): + 35,000,000 × 768 float32 vectors, 5,000 official queries; 8,545 IVF partitions. +- [DPR Wikipedia](https://huggingface.co/datasets/lance-format/dpr-wikipedia-single-nq/tree/d95c3715333a608fa5eb8ca6fe8e6bdb724f32bd): + 21,015,300 × 768 float32 vectors, 3,610 official queries; 5,131 IVF partitions. + DPR retains its CC BY-NC 4.0 terms. +- Every published file's SHA256 was verified. Raw vector/query norms and row + positions are preserved. Fresh baseline IVF_FLAT indices use default training + and automatic HNSW assignment, with `ceil(rows / 4096)` partitions. +- Seed 2249 splits each official query set in half: 2,500 Wiki and 1,805 DPR + evaluation queries. Calibration/evaluation IDs are disjoint, with no identical + query-vector hashes crossing the split. Full-corpus GT supplies the top100 IDs. +- Every primary arm measures recall on every held-out query. Latency uses 512 + matched serial queries per arm, rotating policy order; affinity 0–15, + Lance/Rayon threads 16, BLAS/OMP threads 1, query parallelism 1, prewarmed + 128 GiB index cache. All measured queries recorded zero storage bytes read. +- Remaining recall-only queries use eight workers; those elapsed times are + excluded from latency summaries. The tuned phase uses constant global overrides. +- One timing observation per query/arm; NumPy linear percentiles. Latency + variability across reruns or machines has not been measured. Scanned-row and + partition distributions below use every held-out query, not just timed queries. +- Main's Auto and candidate's explicit full-bounded legacy path return identical + IDs and scan counts on eight queries per corpus/k. Legacy uses only 32 queries + per corpus/k for diagnostics; it is excluded from the primary comparison. + +Native binary SHA256 (both report version `13.0.0-beta.9`): + +```text +baseline 8f3ee435e88c2b88d782115bc114925d5c88e453f224856819a833c9aecd4f2d +candidate c04c3a7839194ed7493f014e59135096640b52493d326a2e192404b162a84a10 +``` + +The archive contains the exact measured source patch, binaries, index identities, +centroids, membership/routing maps, query splits, every returned ID and raw +metric, calibration grid/results, audit results, and build/test/lint logs: +`s3://mmlb-us-east-1/rq-benchmark/results/oss2249-dot-auto-20260922/` (private). +`evidence.tar.gz` contains the data and logs; `binaries.tar.gz` contains the +baseline/candidate native extensions and routing diagnostic binary. +The full source datasets and frozen IVF indices remain on the stopped benchmark +VM; the archive records their file hashes rather than copying the full corpus. +The canceled 128-query baseline audit is labeled partial and excluded. + +## Why the old policy fails + +Fresh main indices have unit-norm dot centroids while raw query norms vary. +Median nearest-centroid scores were 0.721 for Wiki and 6.308 for DPR, producing +opposite signs in `1 - dot`. The legacy signed-distance multiplier searches one +partition for Wiki k=1, every partition for Wiki k=10/100, and one partition for +DPR k=10/100. Native baseline queries confirmed the behavior after prewarming. +The new nonnegative gap uses the magnitude of the best inner product and is +invariant to positive query scaling apart from floating-point rounding. + +## Validation and independent audit + +- The unmodified baseline failed both new Python regression cases (16 and 1 + partitions instead of the requested floor/cap of 2); the candidate passes. +- Rust KNN: 187 tests passed. Python dot tests: 4 passed on both development + and optimized extensions, including multiple fragments and late filtered probing. +- `cargo fmt --all`, workspace `cargo clippy --all --tests --benches -- -D warnings`, + and `uv run make lint` passed on AWS. +- `audit.py` independently recomputes recall from every returned ID, checks unique + query coverage, result cardinality, zero I/O, binary identity and frozen + calibration identity. All six common profiles pass the 95% mean-recall target. +- 215 NumPy/native prediction discrepancies were inspected using native + `IvfModel::find_partitions`, including its partial-sort behavior. Every actual + partition count and scanned-row count agrees with native routing. Residual + strict-ID recall differences are near ties under float64 rescoring; they remain + counted as misses in the reported recall. Details and numerical tolerances are + in `routing-diagnosis.json`, not silently removed from the measurements. + +The implementation applies to finite Float32 IVF_FLAT queries with k <= 100. +Fixed budgets, explicit Auto maximums, unsupported index/query types, refinement +factors greater than one, legacy readers, and k > 100 keep their prior policy. +L2/cosine behavior is unchanged. No public signature or index format changes. +Other corpora, partition counts, quantizers, cold I/O, and filtered-workload +performance have not been calibrated here. + +## Complete native matrix + +`auto` is the common profile; `tuned` is the per-corpus profile. Latency units are +milliseconds. Primary recall sample sizes are Wiki 2,500 and DPR 1,805; primary +timing sample size is 512. Legacy has only 32 observations for both. + +### Wiki + +<details> +<summary>Latency and recall</summary> + +All distribution values are in ms. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 95.320% | 113.26 | 120.25 | 121.89 | 125.94 | 130.76 | +| 1 | fixed128 | 95.720% | 128.26 | 135.52 | 137.78 | 141.46 | 146.01 | +| 1 | fixed256 | 97.760% | 252.38 | 264.08 | 267.65 | 275.19 | 280.59 | +| 1 | fixed64 | 93.400% | 66.46 | 71.09 | 72.52 | 74.89 | 76.03 | +| 1 | legacy | 43.750% | 5.42 | 5.82 | 5.91 | 6.13 | 6.18 | +| 1 | tuned | 94.920% | 91.90 | 142.47 | 191.02 | 313.89 | 346.55 | +| 10 | auto | 95.428% | 263.09 | 435.91 | 442.77 | 454.95 | 463.49 | +| 10 | fixed128 | 90.908% | 128.69 | 136.24 | 138.40 | 142.62 | 146.16 | +| 10 | fixed256 | 94.636% | 252.64 | 264.75 | 267.40 | 276.11 | 282.65 | +| 10 | fixed512 | 97.272% | 502.50 | 522.35 | 528.59 | 535.72 | 542.35 | +| 10 | legacy | 100.000% | 7,876.54 | 7,895.01 | 7,898.01 | 7,907.54 | 7,910.71 | +| 10 | tuned | 95.416% | 260.78 | 467.74 | 475.86 | 488.12 | 495.69 | +| 100 | auto | 95.809% | 453.49 | 793.87 | 809.57 | 827.30 | 840.32 | +| 100 | fixed1024 | 97.905% | 1,038.29 | 1,072.57 | 1,078.50 | 1,089.57 | 1,130.21 | +| 100 | fixed256 | 92.424% | 261.95 | 274.11 | 277.33 | 287.55 | 301.96 | +| 100 | fixed512 | 95.762% | 518.94 | 539.65 | 546.11 | 555.05 | 580.98 | +| 100 | legacy | 100.000% | 8,239.30 | 8,406.48 | 8,413.79 | 8,416.41 | 8,417.19 | +| 100 | tuned | 95.750% | 444.52 | 790.46 | 806.97 | 820.19 | 861.43 | + +</details> + +<details> +<summary>Partitions scanned</summary> + +All distribution values are in partitions. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 95.320% | 111.98 | 112.00 | 112.00 | 112.00 | 112.00 | +| 1 | fixed128 | 95.720% | 128.00 | 128.00 | 128.00 | 128.00 | 128.00 | +| 1 | fixed256 | 97.760% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 1 | fixed64 | 93.400% | 64.00 | 64.00 | 64.00 | 64.00 | 64.00 | +| 1 | legacy | 43.750% | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | +| 1 | tuned | 94.920% | 90.40 | 139.10 | 188.00 | 331.03 | 336.00 | +| 10 | auto | 95.428% | 268.51 | 432.00 | 432.00 | 432.00 | 432.00 | +| 10 | fixed128 | 90.908% | 128.00 | 128.00 | 128.00 | 128.00 | 128.00 | +| 10 | fixed256 | 94.636% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 10 | fixed512 | 97.272% | 512.00 | 512.00 | 512.00 | 512.00 | 512.00 | +| 10 | legacy | 100.000% | 8,545.00 | 8,545.00 | 8,545.00 | 8,545.00 | 8,545.00 | +| 10 | tuned | 95.416% | 266.92 | 464.00 | 464.00 | 464.00 | 464.00 | +| 100 | auto | 95.809% | 451.98 | 768.00 | 768.00 | 768.00 | 768.00 | +| 100 | fixed1024 | 97.905% | 1,024.00 | 1,024.00 | 1,024.00 | 1,024.00 | 1,024.00 | +| 100 | fixed256 | 92.424% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 100 | fixed512 | 95.762% | 512.00 | 512.00 | 512.00 | 512.00 | 512.00 | +| 100 | legacy | 100.000% | 8,545.00 | 8,545.00 | 8,545.00 | 8,545.00 | 8,545.00 | +| 100 | tuned | 95.750% | 444.00 | 768.00 | 768.00 | 768.00 | 768.00 | + +</details> + +<details> +<summary>Rows scanned</summary> + +All distribution values are in rows. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 95.320% | 487,846.67 | 518,601.40 | 527,357.30 | 546,888.91 | 576,565.00 | +| 1 | fixed128 | 95.720% | 557,320.73 | 590,569.30 | 599,961.75 | 619,670.59 | 651,684.00 | +| 1 | fixed256 | 97.760% | 1,112,215.75 | 1,165,693.20 | 1,179,587.50 | 1,204,510.01 | 1,239,295.00 | +| 1 | fixed64 | 93.400% | 279,741.70 | 301,192.70 | 307,661.25 | 318,967.76 | 343,753.00 | +| 1 | legacy | 43.750% | 4,766.78 | 6,136.40 | 6,413.75 | 6,855.04 | 7,015.00 | +| 1 | tuned | 94.920% | 395,659.33 | 619,157.70 | 844,534.70 | 1,436,441.19 | 1,553,201.00 | +| 10 | auto | 95.428% | 1,172,510.13 | 1,930,456.90 | 1,959,398.80 | 2,002,112.22 | 2,049,345.00 | +| 10 | fixed128 | 90.908% | 557,320.73 | 590,569.30 | 599,961.75 | 619,670.59 | 651,684.00 | +| 10 | fixed256 | 94.636% | 1,112,215.75 | 1,165,693.20 | 1,179,587.50 | 1,204,510.01 | 1,239,295.00 | +| 10 | fixed512 | 97.272% | 2,227,195.43 | 2,313,214.70 | 2,338,086.25 | 2,373,320.98 | 2,421,818.00 | +| 10 | legacy | 100.000% | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | +| 10 | tuned | 95.416% | 1,166,710.63 | 2,070,476.90 | 2,100,250.05 | 2,146,024.57 | 2,186,004.00 | +| 100 | auto | 95.809% | 1,978,009.75 | 3,425,574.00 | 3,470,017.90 | 3,530,335.76 | 3,567,863.00 | +| 100 | fixed1024 | 97.905% | 4,474,592.19 | 4,603,210.20 | 4,640,992.40 | 4,704,198.88 | 4,752,187.00 | +| 100 | fixed256 | 92.424% | 1,112,215.75 | 1,165,693.20 | 1,179,587.50 | 1,204,510.01 | 1,239,295.00 | +| 100 | fixed512 | 95.762% | 2,227,195.43 | 2,313,214.70 | 2,338,086.25 | 2,373,320.98 | 2,421,818.00 | +| 100 | legacy | 100.000% | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | 35,000,000.00 | +| 100 | tuned | 95.750% | 1,941,612.04 | 3,417,389.70 | 3,463,375.55 | 3,526,866.85 | 3,567,863.00 | + +</details> + +### DPR + +<details> +<summary>Latency and recall</summary> + +All distribution values are in ms. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 96.565% | 91.38 | 111.37 | 112.37 | 114.96 | 118.21 | +| 1 | fixed128 | 97.230% | 120.45 | 126.29 | 127.58 | 129.81 | 131.15 | +| 1 | fixed256 | 99.169% | 237.71 | 247.19 | 249.62 | 253.17 | 253.84 | +| 1 | fixed64 | 94.238% | 62.26 | 65.75 | 66.82 | 67.97 | 71.33 | +| 1 | legacy | 100.000% | 3,484.27 | 4,255.68 | 4,431.53 | 4,508.53 | 4,540.36 | +| 1 | tuned | 96.620% | 80.55 | 157.94 | 196.57 | 242.53 | 246.46 | +| 10 | auto | 96.454% | 135.99 | 142.41 | 143.95 | 146.15 | 147.18 | +| 10 | fixed128 | 95.950% | 120.75 | 126.84 | 128.07 | 129.97 | 131.49 | +| 10 | fixed256 | 98.366% | 238.10 | 248.07 | 250.62 | 254.04 | 257.04 | +| 10 | fixed64 | 91.861% | 62.47 | 66.12 | 66.71 | 67.66 | 70.35 | +| 10 | legacy | 32.812% | 5.72 | 6.32 | 6.44 | 6.70 | 6.79 | +| 10 | tuned | 96.709% | 115.81 | 218.46 | 227.52 | 232.49 | 238.03 | +| 100 | auto | 96.218% | 186.59 | 194.63 | 196.58 | 199.73 | 205.71 | +| 100 | fixed128 | 93.888% | 120.77 | 126.46 | 127.94 | 130.50 | 137.92 | +| 100 | fixed256 | 97.232% | 237.39 | 247.50 | 249.96 | 254.57 | 269.03 | +| 100 | fixed512 | 98.968% | 472.73 | 487.17 | 490.38 | 499.89 | 504.92 | +| 100 | legacy | 26.062% | 5.83 | 6.29 | 6.38 | 6.96 | 7.21 | +| 100 | tuned | 96.122% | 162.31 | 301.79 | 378.26 | 465.11 | 478.44 | + +</details> + +<details> +<summary>Partitions scanned</summary> + +All distribution values are in partitions. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 96.565% | 95.15 | 112.00 | 112.00 | 112.00 | 112.00 | +| 1 | fixed128 | 97.230% | 128.00 | 128.00 | 128.00 | 128.00 | 128.00 | +| 1 | fixed256 | 99.169% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 1 | fixed64 | 94.238% | 64.00 | 64.00 | 64.00 | 64.00 | 64.00 | +| 1 | legacy | 100.000% | 3,701.38 | 4,511.30 | 4,682.50 | 4,752.86 | 4,782.00 | +| 1 | tuned | 96.620% | 80.68 | 165.00 | 200.00 | 256.00 | 256.00 | +| 10 | auto | 96.454% | 144.00 | 144.00 | 144.00 | 144.00 | 144.00 | +| 10 | fixed128 | 95.950% | 128.00 | 128.00 | 128.00 | 128.00 | 128.00 | +| 10 | fixed256 | 98.366% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 10 | fixed64 | 91.861% | 64.00 | 64.00 | 64.00 | 64.00 | 64.00 | +| 10 | legacy | 32.812% | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | +| 10 | tuned | 96.709% | 119.07 | 240.00 | 240.00 | 240.00 | 240.00 | +| 100 | auto | 96.218% | 200.00 | 200.00 | 200.00 | 200.00 | 200.00 | +| 100 | fixed128 | 93.888% | 128.00 | 128.00 | 128.00 | 128.00 | 128.00 | +| 100 | fixed256 | 97.232% | 256.00 | 256.00 | 256.00 | 256.00 | 256.00 | +| 100 | fixed512 | 98.968% | 512.00 | 512.00 | 512.00 | 512.00 | 512.00 | +| 100 | legacy | 26.062% | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | +| 100 | tuned | 96.122% | 167.71 | 304.60 | 387.60 | 496.00 | 496.00 | + +</details> + +<details> +<summary>Rows scanned</summary> + +All distribution values are in rows. + +| k | policy | recall | mean | p90 | p95 | p99 | max | +| ---: | --- | ---: | ---: | ---: | ---: | ---: | ---: | +| 1 | auto | 96.565% | 373,306.53 | 458,631.00 | 464,569.80 | 474,240.76 | 483,995.00 | +| 1 | fixed128 | 97.230% | 501,553.33 | 526,285.40 | 531,654.60 | 539,997.52 | 557,562.00 | +| 1 | fixed256 | 99.169% | 1,006,389.42 | 1,047,725.20 | 1,056,201.60 | 1,072,031.64 | 1,087,576.00 | +| 1 | fixed64 | 94.238% | 250,010.18 | 264,539.80 | 267,534.20 | 273,331.48 | 280,373.00 | +| 1 | legacy | 100.000% | 15,069,122.81 | 18,440,124.50 | 19,174,839.45 | 19,482,897.12 | 19,614,786.00 | +| 1 | tuned | 96.620% | 318,219.34 | 658,136.80 | 793,762.00 | 1,023,053.80 | 1,061,549.00 | +| 10 | auto | 96.454% | 564,498.75 | 591,235.20 | 597,280.60 | 606,902.36 | 618,749.00 | +| 10 | fixed128 | 95.950% | 501,553.33 | 526,285.40 | 531,654.60 | 539,997.52 | 557,562.00 | +| 10 | fixed256 | 98.366% | 1,006,389.42 | 1,047,725.20 | 1,056,201.60 | 1,072,031.64 | 1,087,576.00 | +| 10 | fixed64 | 91.861% | 250,010.18 | 264,539.80 | 267,534.20 | 273,331.48 | 280,373.00 | +| 10 | legacy | 32.812% | 3,889.16 | 5,109.10 | 5,396.45 | 5,808.20 | 5,988.00 | +| 10 | tuned | 96.709% | 468,805.20 | 914,753.20 | 952,196.80 | 984,725.72 | 1,002,354.00 | +| 100 | auto | 96.218% | 785,402.82 | 819,545.00 | 828,141.80 | 839,150.64 | 855,206.00 | +| 100 | fixed128 | 93.888% | 501,553.33 | 526,285.40 | 531,654.60 | 539,997.52 | 557,562.00 | +| 100 | fixed256 | 97.232% | 1,006,389.42 | 1,047,725.20 | 1,056,201.60 | 1,072,031.64 | 1,087,576.00 | +| 100 | fixed512 | 98.968% | 2,023,956.78 | 2,084,049.00 | 2,096,435.40 | 2,114,013.88 | 2,133,549.00 | +| 100 | legacy | 26.062% | 3,889.16 | 5,109.10 | 5,396.45 | 5,808.20 | 5,988.00 | +| 100 | tuned | 96.122% | 660,744.19 | 1,217,256.00 | 1,516,333.60 | 1,959,414.36 | 2,016,202.00 | + +</details> + diff --git a/benchmarks/auto-ivf-dot/audit.py b/benchmarks/auto-ivf-dot/audit.py new file mode 100644 index 00000000000..e442535f557 --- /dev/null +++ b/benchmarks/auto-ivf-dot/audit.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +"""Independently audit native per-query output against the published ground truth.""" + +import argparse +import csv +import hashlib +import json +from pathlib import Path + +import lance +import numpy as np +from calibrate import NAMES, summary +from prepare import matrix, save + + +def audit(root): + calibration = json.loads((root / "calibration.json").read_text()) + report = { + "profiles": calibration["selected"], + "datasets": {}, + "query_integrity": {}, + } + discrepancies = [] + for name in NAMES: + directory = root / name + identity = json.loads((directory / "identity-native.json").read_text()) + assert ( + identity["binary_sha256"] + == (root / "candidate-binary.sha256").read_text().split()[0] + ) + assert ( + identity["calibration_sha256"] + == hashlib.sha256((root / "calibration.json").read_bytes()).hexdigest() + ) + split = np.load(directory / "split.npz") + ids = split["evaluation"] + assert not set(ids) & set(split["calibration"]) + queries = matrix( + lance.dataset(root / "data" / name / "queries.lance").to_table()["vector"] + ) + hashes = [hashlib.sha256(row.tobytes()).digest() for row in queries] + report["query_integrity"][name] = { + "disjoint_query_ids": True, + "shared_vector_hashes": len( + {hashes[i] for i in ids} & {hashes[i] for i in split["calibration"]} + ), + } + truth = matrix( + lance.dataset(root / "data" / name / "ground_truth.lance").to_table()[ + "neighbor_ids" + ] + ) + build = json.loads((directory / "build.json").read_text()) + assert truth.shape[1] == 100 + assert int(truth.max()) < build["rows"] + assert all(len(set(row.tolist())) == 100 for row in truth) + with (directory / "measure-native.csv").open() as stream: + records = list(csv.DictReader(stream)) + report["datasets"][name] = {} + for k in [1, 10, 100]: + result = {} + groups = sorted({row["policy"] for row in records if int(row["k"]) == k}) + fixed = calibration["fixed"][name][str(k)] + meeting = next(i for i, row in enumerate(fixed) if row["recall"] >= 0.95) + expected_policies = {"auto", "tuned", "legacy"} | { + f"fixed{row['nprobes']}" + for row in fixed[max(0, meeting - 1) : meeting + 2] + } + assert set(groups) == expected_policies + for policy in groups: + rows = [ + row + for row in records + if int(row["k"]) == k and row["policy"] == policy + ] + query_ids = [int(row["query_id"]) for row in rows] + expected_ids = ( + ids[: identity["legacy_queries"]] if policy == "legacy" else ids + ) + assert len(query_ids) == len(set(query_ids)) == len(expected_ids) + assert set(query_ids) == set(expected_ids) + recalls = [] + for row, query_id in zip(rows, query_ids): + neighbors = json.loads(row["neighbor_ids"]) + assert len(neighbors) == len(set(neighbors)) == k + recall = len(set(neighbors) & set(truth[query_id, :k].tolist())) / k + assert abs(recall - float(row["recall"])) < 1e-12 + assert int(row["bytes_read"]) == 0, ( + name, + k, + policy, + query_id, + row["bytes_read"], + ) + recalls.append(recall) + if ( + int(row["partitions"]) != int(row["predicted_partitions"]) + or int(row["comparisons"]) != int(row["predicted_rows"]) + or abs(recall - float(row["route_recall"])) > 1e-12 + ): + discrepancies.append({"dataset": name, **row}) + timed = [row for row in rows if row["phase"] == "timing"] + expected_timed = min(identity["timing_queries"], len(expected_ids)) + assert len(timed) == expected_timed + mean = float(np.mean(recalls)) + error = 1.96 * float(np.std(recalls, ddof=1)) / np.sqrt(len(recalls)) + result[policy] = { + "queries": len(rows), + "timing_queries": len(timed), + "recall": mean, + "recall_ci95_normal": [ + max(0.0, mean - error), + min(1.0, mean + error), + ], + "meets_recall95": mean >= 0.95, + "latency_ms": summary([float(row["latency_ms"]) for row in timed]), + "partitions": summary([int(row["partitions"]) for row in rows]), + "scanned_rows": summary([int(row["comparisons"]) for row in rows]), + "bytes_read": 0, + } + report["datasets"][name][str(k)] = result + report["prediction_discrepancies"] = len(discrepancies) + report["common_profile_passed_all_recall_targets"] = all( + result["auto"]["meets_recall95"] + for dataset in report["datasets"].values() + for result in dataset.values() + ) + save(root / "prediction-discrepancies.json", discrepancies) + save(root / "audited-results.json", report) + print(json.dumps(report, indent=2)) + assert report["common_profile_passed_all_recall_targets"] + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + args = parser.parse_args() + audit(args.root) diff --git a/benchmarks/auto-ivf-dot/calibrate.py b/benchmarks/auto-ivf-dot/calibrate.py new file mode 100644 index 00000000000..7b0eba4cc16 --- /dev/null +++ b/benchmarks/auto-ivf-dot/calibrate.py @@ -0,0 +1,222 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +"""Select dot probing parameters using calibration queries only.""" + +import argparse +from pathlib import Path + +import numpy as np +from prepare import emit, save + +NAMES = ["wiki-cohere-35m", "dpr-wikipedia-single-nq"] +FLOORS = sorted(set([1, 2, 4, 6, 12, 384, 512, *range(8, 257, 8)])) +CAPS = [ + 8, + 12, + 16, + 24, + 32, + 48, + 64, + 96, + 128, + 192, + 256, + 384, + 512, + 768, + 1024, + 1536, + 2048, + 3072, + 4096, + 6144, + 8192, + 16384, +] +CAPS = sorted(set(CAPS + list(range(16, 769, 16)))) +MARGINS = { + "relative_score": np.r_[ + np.arange(0, 0.1005, 0.0005), + np.arange(0.105, 1.005, 0.005), + np.arange(1.05, 4.05, 0.05), + ], + "standard_deviation": np.arange(0, 8.025, 0.025), +} + + +def load(root, name): + routes = np.load(root / name / "routes.npz") + ids = np.load(root / name / "split.npz")["calibration"] + scores = routes["scores"][ids].astype(np.float64) + ranks = routes["ranks"][ids] + rows = routes["scanned_rows"][ids] + # The denominator is homogeneous in the query, unlike abs(1 - dot). + scales = { + "relative_score": np.abs(scores[:, :1]), + "standard_deviation": np.std(scores, axis=1, keepdims=True), + } + gaps = {} + for mode, scale in scales.items(): + gaps[mode] = np.divide( + scores[:, :1] - scores, + scale, + out=np.full_like(scores, np.inf), + where=scale != 0, + ) + gaps[mode][scores == scores[:, :1]] = 0 + coverage = {} + for k in [1, 10, 100]: + counts = np.zeros((len(ids), scores.shape[1] + 1), dtype=np.int16) + np.add.at(counts, (np.arange(len(ids))[:, None], ranks[:, :k]), 1) + coverage[k] = np.cumsum(counts, axis=1, dtype=np.int16) + return { + "scores": scores, + "gaps": gaps, + "coverage": coverage, + "rows": rows, + "ids": ids, + } + + +def summary(values): + return { + "mean": float(np.mean(values)), + **{f"p{p}": float(np.percentile(values, p)) for p in [90, 95, 99]}, + "max": float(np.max(values)), + } + + +def calibration(root): + data = {name: load(root, name) for name in NAMES} + result = { + "target_recall": 0.96, + "evaluation_used": False, + "grid": { + "floors": FLOORS, + "caps": CAPS, + "margins": {key: value.tolist() for key, value in MARGINS.items()}, + }, + "fixed": {}, + "profiles": {}, + } + for name, values in data.items(): + result["fixed"][name] = {} + for k in [1, 10, 100]: + budgets = [ + p + for p in [1, 2, 4, 8, 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192] + if p < values["scores"].shape[1] + ] + [values["scores"].shape[1]] + rows = [ + { + "nprobes": budget, + "recall": float(values["coverage"][k][:, budget].mean() / k), + "scanned_rows": summary(values["rows"][:, budget - 1]), + } + for budget in budgets + ] + result["fixed"][name][str(k)] = rows + # Keep separate optima for each corpus and a common profile satisfying both. + # 96% calibration provides headroom for the independent 95% target. + for k in [1, 10, 100]: + best = {} + for mode, margins in MARGINS.items(): + best[mode] = {key: None for key in NAMES + ["common"]} + for margin in margins: + selected = { + name: np.array( + [ + np.searchsorted(row, margin, side="right") + for row in values["gaps"][mode] + ] + ) + for name, values in data.items() + } + for floor in FLOORS: + caps = np.asarray([cap for cap in CAPS if cap >= floor]) + metrics = {} + for name, values in data.items(): + budgets = np.minimum( + np.maximum(selected[name][:, None], floor), caps[None, :] + ) + budgets = np.minimum(budgets, values["scores"].shape[1]) + recall = ( + values["coverage"][k][ + np.arange(len(budgets))[:, None], budgets + ].mean(axis=0) + / k + ) + cost = budgets.mean(axis=0) + metrics[name] = (recall, cost) + valid = np.flatnonzero(recall >= result["target_recall"]) + if len(valid): + index = valid[np.argmin(cost[valid])] + candidate = { + "margin": float(margin), + "floor": floor, + "cap": int(caps[index]), + "recall": float(recall[index]), + "mean_partitions": float(cost[index]), + } + old = best[mode][name] + if ( + old is None + or candidate["mean_partitions"] < old["mean_partitions"] + ): + best[mode][name] = candidate + valid = np.flatnonzero( + np.logical_and.reduce( + [ + metrics[name][0] >= result["target_recall"] + for name in NAMES + ] + ) + ) + cost = np.mean([metrics[name][1] for name in NAMES], axis=0) + if len(valid): + index = valid[np.argmin(cost[valid])] + candidate = { + "mode": mode, + "margin": float(margin), + "floor": floor, + "cap": int(caps[index]), + "mean_partitions": float(cost[index]), + "datasets": { + name: { + "recall": float(metrics[name][0][index]), + "mean_partitions": float(metrics[name][1][index]), + } + for name in NAMES + }, + } + old = best[mode]["common"] + if ( + old is None + or candidate["mean_partitions"] < old["mean_partitions"] + ): + best[mode]["common"] = candidate + emit("calibrated", k=k, mode=mode, best=best[mode]) + result["profiles"][str(k)] = best + save(root / "calibration.json", result) + # Freeze both common families before ever examining evaluation outcomes. + result["selected_mode"] = min( + MARGINS, + key=lambda mode: sum( + result["profiles"][str(k)][mode]["common"]["mean_partitions"] + for k in [1, 10, 100] + ), + ) + result["selected"] = { + str(k): result["profiles"][str(k)][result["selected_mode"]]["common"] + for k in [1, 10, 100] + } + save(root / "calibration.json", result) + emit("frozen", mode=result["selected_mode"], profiles=result["selected"]) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + args = parser.parse_args() + calibration(args.root) diff --git a/benchmarks/auto-ivf-dot/measure.py b/benchmarks/auto-ivf-dot/measure.py new file mode 100644 index 00000000000..8fbc600408c --- /dev/null +++ b/benchmarks/auto-ivf-dot/measure.py @@ -0,0 +1,342 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +"""Measure frozen probing policies with a warm cache and serial paired queries.""" + +import argparse +import csv +import hashlib +import json +import os +import time +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import lance +import numpy as np +from calibrate import summary +from prepare import emit, matrix, positions, save + + +def predict(scores, profile): + scores = scores.astype(np.float64) + scale = ( + np.abs(scores[:, 0]) + if profile["mode"] == "relative_score" + else scores.std(axis=1) + ) + counts = ((scores[:, :1] - scores) <= profile["margin"] * scale[:, None]).sum( + axis=1 + ) + return np.clip(counts, profile["floor"], min(profile["cap"], scores.shape[1])) + + +def configure_override(profile): + for variable, field in [ + ("LANCE_AUTO_PROBE_MARGIN", "margin"), + ("LANCE_AUTO_MIN_INITIAL_NPROBES", "floor"), + ("LANCE_AUTO_MAX_INITIAL_NPROBES", "cap"), + ]: + if profile is None: + os.environ.pop(variable, None) + else: + os.environ[variable] = str(profile[field]) + + +def run_query(dataset, vector, k, policy, partitions, fragment_offsets): + nearest = { + "column": "vector", + "q": vector, + "k": k, + "metric": "dot", + "query_parallelism": 1, + } + if policy.startswith("fixed"): + nearest["nprobes"] = int(policy.removeprefix("fixed")) + elif policy == "legacy": + # Explicit upper bound keeps the exact main legacy policy on the candidate + # binary. The full partition count leaves its actual budget unchanged. + nearest["maximum_nprobes"] = partitions + captured = [] + start = time.perf_counter_ns() + table = dataset.scanner( + columns=["_distance"], + with_row_id=True, + nearest=nearest, + scan_stats_callback=captured.append, + ).to_table() + elapsed = (time.perf_counter_ns() - start) / 1e6 + assert len(table) == k + assert len(captured) == 1 + stats = captured[0] + ids = positions(table["_rowid"].to_numpy(), fragment_offsets) + assert len(np.unique(ids)) == k + return ids, elapsed, stats + + +def measure(root, name, limit, native, timing_queries, legacy_queries, recall_workers): + directory = root / "data" / name + out = root / name + calibration = json.loads((root / "calibration.json").read_text()) + build = json.loads((out / "build.json").read_text()) + dataset = lance.dataset( + directory / "base.lance", + version=build["version"], + index_cache_size_bytes=128 * 1024**3, + ) + queries = matrix(lance.dataset(directory / "queries.lance").to_table()["vector"]) + truth = matrix( + lance.dataset(directory / "ground_truth.lance").to_table()["neighbor_ids"] + ) + ids = np.load(out / "split.npz")["evaluation"] + if limit: + ids = ids[:limit] + timing_ids = set(ids[:timing_queries].tolist()) + routes = np.load(out / "routes.npz") + scores = routes["scores"] + scanned_rows = routes["scanned_rows"] + ranks = routes["ranks"] + fragment_offsets = np.load(out / "fragment-offsets.npy") + started = time.monotonic() + dataset.prewarm_index("dot_flat") + emit("prewarmed", dataset=name, seconds=time.monotonic() - started) + records = [] + results = {} + suffix = "native" if native else "baseline-audit" + output = out / f"measure-{suffix}.csv" + with output.open("w") as stream: + fields = [ + "query_id", + "k", + "policy", + "latency_ms", + "recall", + "partitions", + "comparisons", + "bytes_read", + "predicted_partitions", + "predicted_rows", + "route_recall", + "neighbor_ids", + "phase", + ] + writer = csv.DictWriter(stream, fieldnames=fields) + writer.writeheader() + for k in [1, 10, 100]: + profile = calibration["selected"][str(k)] + tuned = calibration["profiles"][str(k)][calibration["selected_mode"]][name] + configure_override(None) + fixed = calibration["fixed"][name][str(k)] + meeting = next( + index for index, row in enumerate(fixed) if row["recall"] >= 0.95 + ) + budgets = [ + row["nprobes"] for row in fixed[max(0, meeting - 1) : meeting + 2] + ] + policies = ( + ["auto", "tuned", "legacy", *[f"fixed{value}" for value in budgets]] + if native + else ["auto", "legacy"] + ) + predicted = predict(scores, profile) + tuned_predicted = predict(scores, {**tuned, "mode": profile["mode"]}) + legacy_distances = np.float32(1) - scores + legacy = np.maximum( + 1, + ( + legacy_distances + <= legacy_distances[:, :1] * {1: 0.6, 10: 7.0, 100: 81.0}[k] + ).sum(axis=1), + ) + for policy in policies: + configure_override(tuned if policy == "tuned" else None) + run_query( + dataset, + queries[ids[0]], + k, + policy, + build["partitions"], + fragment_offsets, + ) + + def query_records(item, tuned_only=False): + ordinal, query_id = item + # Rotate order to balance systematic drift between policies. + active = ( + policies + if ordinal < legacy_queries or not native + else [p for p in policies if p != "legacy"] + ) + if tuned_only: + active = ["tuned"] + elif ordinal >= timing_queries: + active = [p for p in active if p != "tuned"] + shift = ordinal % len(active) + baseline_pair = [] + query_rows = [] + for policy in active[shift:] + active[:shift]: + if ordinal < timing_queries: + configure_override(tuned if policy == "tuned" else None) + actual_ids, elapsed, stats = run_query( + dataset, + queries[query_id], + k, + policy, + build["partitions"], + fragment_offsets, + ) + expected = ( + int(policy.removeprefix("fixed")) + if policy.startswith("fixed") + else int( + tuned_predicted[query_id] + if policy == "tuned" + else predicted[query_id] + if native and policy == "auto" + else legacy[query_id] + ) + ) + recall = len(np.intersect1d(actual_ids, truth[query_id, :k])) / k + scanned = stats.all_counts["partitions_searched"] + if not native: + baseline_pair.append( + (actual_ids.tolist(), scanned, stats.index_comparisons) + ) + if ordinal == 0: + save( + out / f"metrics-{suffix}-{k}-{policy}.json", + stats.all_counts, + ) + row = { + "query_id": int(query_id), + "k": k, + "policy": policy, + "latency_ms": elapsed, + "recall": recall, + "partitions": scanned, + "comparisons": stats.index_comparisons, + "bytes_read": stats.bytes_read, + "predicted_partitions": expected, + "predicted_rows": int(scanned_rows[query_id, expected - 1]), + "route_recall": float(np.mean(ranks[query_id, :k] <= expected)), + "neighbor_ids": json.dumps(actual_ids.tolist()), + "phase": "timing" if query_id in timing_ids else "recall", + } + query_rows.append(row) + if not native: + assert baseline_pair[0] == baseline_pair[1], baseline_pair + return query_rows + + def record(ordinal, query_rows): + writer.writerows(query_rows) + records.extend(query_rows) + if ordinal % 100 == 0: + stream.flush() + emit( + "measuring", + dataset=name, + k=k, + completed=ordinal, + total=len(ids), + ) + + serial_count = min(timing_queries, len(ids)) + for ordinal in range(serial_count): + record(ordinal, query_records((ordinal, ids[ordinal]))) + # Only correctness is collected concurrently. These latencies are + # excluded from every timing summary, and all futures finish before + # the next serial timing phase starts. + configure_override(None) + with ThreadPoolExecutor(max_workers=recall_workers) as executor: + remaining = enumerate(ids[serial_count:], start=serial_count) + for ordinal, query_rows in enumerate( + executor.map(query_records, remaining), start=serial_count + ): + record(ordinal, query_rows) + if native: + # Environment overrides are process-wide. Run the tuned recall + # phase separately with constant settings, never mutate them + # while concurrent queries are in flight. + configure_override(tuned) + with ThreadPoolExecutor(max_workers=recall_workers) as executor: + remaining = enumerate(ids[serial_count:], start=serial_count) + for ordinal, query_rows in enumerate( + executor.map(lambda item: query_records(item, True), remaining), + start=serial_count, + ): + record(ordinal, query_rows) + configure_override(None) + results[str(k)] = {} + for policy in policies: + rows = [ + row for row in records if row["k"] == k and row["policy"] == policy + ] + results[str(k)][policy] = { + "queries": len(rows), + "recall": float(np.mean([r["recall"] for r in rows])), + "timing_queries": sum(r["query_id"] in timing_ids for r in rows), + **{ + field: summary( + [r[field] for r in rows if r["query_id"] in timing_ids] + ) + for field in ["latency_ms", "partitions", "comparisons"] + }, + "bytes_read": sum(r["bytes_read"] for r in rows), + "partition_prediction_mismatches": sum( + r["partitions"] != r["predicted_partitions"] for r in rows + ), + "recall_prediction_max_error": max( + abs(r["recall"] - r["route_recall"]) for r in rows + ), + "row_prediction_mismatches": sum( + r["comparisons"] != r["predicted_rows"] for r in rows + ), + } + save(out / f"measure-{suffix}.json", results) + emit("measured", dataset=name, k=k, result=results[str(k)]) + library = Path(lance.__file__).parent / "lance.abi3.so" + save( + out / f"identity-{suffix}.json", + { + "binary_sha256": hashlib.sha256(library.read_bytes()).hexdigest(), + "calibration_sha256": hashlib.sha256( + (root / "calibration.json").read_bytes() + ).hexdigest(), + "cpu_affinity": sorted(os.sched_getaffinity(0)), + "threads": { + key: os.environ.get(key) + for key in [ + "LANCE_CPU_THREADS", + "RAYON_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "OMP_NUM_THREADS", + ] + }, + "lance_version": lance.__version__, + "query_count": len(ids), + "timing_queries": min(timing_queries, len(ids)), + "legacy_queries": min(legacy_queries, len(ids)), + "recall_workers": recall_workers, + "native": native, + }, + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument("dataset") + parser.add_argument("--limit", type=int, default=0) + parser.add_argument("--native", action="store_true") + parser.add_argument("--timing-queries", type=int, default=512) + parser.add_argument("--legacy-queries", type=int, default=32) + parser.add_argument("--recall-workers", type=int, default=8) + args = parser.parse_args() + measure( + args.root, + args.dataset, + args.limit, + args.native, + args.timing_queries, + args.legacy_queries, + args.recall_workers, + ) diff --git a/benchmarks/auto-ivf-dot/prepare.py b/benchmarks/auto-ivf-dot/prepare.py new file mode 100644 index 00000000000..2321ca9a104 --- /dev/null +++ b/benchmarks/auto-ivf-dot/prepare.py @@ -0,0 +1,227 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +"""Build frozen IVF_FLAT indices and collect actual partition routing evidence. + +Run from the repository Python environment with ``uv run``. The root directory +must contain data/<HF dataset name>/{base,queries,ground_truth}.lance and VERIFIED. +""" + +import argparse +import dataclasses +import hashlib +import json +import math +import time +from pathlib import Path + +import lance +import numpy as np +from lance.dataset import VectorIndexReader + + +def save(path, value): + temporary = path.with_suffix(".tmp") + temporary.write_text(json.dumps(value, indent=2, default=str) + "\n") + temporary.replace(path) + + +def emit(event, **values): + print(json.dumps({"time": time.time(), "event": event, **values}), flush=True) + + +def matrix(column): + array = column.combine_chunks() if hasattr(column, "combine_chunks") else column + return array.values.to_numpy().reshape(len(array), array.type.list_size) + + +def offsets(dataset): + fragments = dataset.get_fragments() + result = np.zeros(max(f.fragment_id for f in fragments) + 1, dtype=np.int64) + start = 0 + for fragment in fragments: + result[fragment.fragment_id] = start + start += fragment.count_rows() + return result + + +def positions(row_ids, fragment_offsets): + return fragment_offsets[(row_ids >> np.uint64(32)).astype(np.int64)] + ( + row_ids & np.uint64(0xFFFFFFFF) + ).astype(np.int64) + + +def prepare(root, name): + directory = root / "data" / name + assert (directory / "VERIFIED").exists() + out = root / name + out.mkdir(exist_ok=True) + queries_table = lance.dataset(directory / "queries.lance").to_table() + truth_table = lance.dataset(directory / "ground_truth.lance").to_table() + assert np.array_equal(queries_table["query_id"], truth_table["query_id"]) + queries = matrix(queries_table["vector"]) + truth = matrix(truth_table["neighbor_ids"]) + split = np.random.default_rng(2249).permutation(len(queries)) + np.savez( + out / "split.npz", + calibration=split[: len(split) // 2], + evaluation=split[len(split) // 2 :], + ) + dataset = lance.dataset(directory / "base.lance") + if not (out / "build.json").exists(): + assert dataset.list_indices() == [] + count = dataset.count_rows() + partitions = math.ceil(count / 4096) + started = time.monotonic() + last = [0.0] + + def progress(event): + now = time.monotonic() + if event.event != "progress" or now - last[0] >= 30: + emit("build_progress", dataset=name, progress=dataclasses.asdict(event)) + last[0] = now + + dataset = dataset.create_index( + "vector", + "IVF_FLAT", + name="dot_flat", + metric="dot", + num_partitions=partitions, + progress_callback=progress, + ) + stats = dataset.stats.index_stats("dot_flat") + assert stats["num_indexed_rows"] == count + assert stats["num_unindexed_rows"] == 0 + save( + out / "build.json", + { + "rows": count, + "partitions": partitions, + "version": dataset.version, + "seconds": time.monotonic() - started, + "indices": dataset.list_indices(), + "lance_version": lance.__version__, + }, + ) + emit("build_complete", dataset=name, seconds=time.monotonic() - started) + build = json.loads((out / "build.json").read_text()) + dataset = lance.dataset( + directory / "base.lance", + version=build["version"], + index_cache_size_bytes=128 * 1024**3, + ) + centroids = matrix(dataset.centroids(index_name="dot_flat")) + np.save(out / "centroids.npy", centroids) + fragment_offsets = offsets(dataset) + np.save(out / "fragment-offsets.npy", fragment_offsets) + if not (out / "mapping.npz").exists(): + membership = np.full(build["rows"], -1, dtype=np.int32) + sizes = np.empty(len(centroids), dtype=np.int64) + rng = np.random.default_rng(2249) + sample_partitions = set(rng.choice(len(centroids), 16, replace=False).tolist()) + sample_positions = [] + sample_membership = [] + reader = VectorIndexReader(dataset, "dot_flat") + for pid in range(reader.num_partitions()): + row_ids = reader.read_partition(pid)["_rowid"].to_numpy() + row_positions = positions(row_ids, fragment_offsets) + assert np.all(membership[row_positions] == -1) + membership[row_positions] = pid + sizes[pid] = len(row_ids) + if pid in sample_partitions and len(row_positions): + sampled = rng.choice( + row_positions, min(64, len(row_positions)), replace=False + ) + sample_positions.extend(sampled.tolist()) + sample_membership.extend([pid] * len(sampled)) + if pid % 512 == 0: + emit("mapping", dataset=name, partition=pid) + assert np.all(membership >= 0) + assert sizes.sum() == build["rows"] + np.savez_compressed( + out / "mapping.npz", + truth_partitions=membership[truth], + sizes=sizes, + sample_positions=sample_positions, + sample_partitions=sample_membership, + ) + del membership, reader + mapping = np.load(out / "mapping.npz") + scores = queries @ centroids.T + order = np.argsort(-scores, axis=1, kind="stable") + ranks = np.empty(order.shape, dtype=np.int32) + np.put_along_axis(ranks, order, np.arange(1, len(centroids) + 1)[None, :], axis=1) + truth_ranks = np.take_along_axis(ranks, mapping["truth_partitions"], axis=1) + sorted_scores = np.take_along_axis(scores, order, axis=1) + scanned_rows = np.cumsum(mapping["sizes"][order], axis=1) + np.savez_compressed( + out / "routes.npz", + scores=sorted_scores, + ranks=truth_ranks, + scanned_rows=scanned_rows, + ) + # A fixed calibration-only subset diagnoses residuals without reading the + # evaluation queries during model selection. Truth vectors retain their norms. + diagnostic_ids = split[: min(256, len(split) // 2)] + ids, inverse = np.unique(truth[diagnostic_ids], return_inverse=True) + vectors = matrix(dataset.take(ids.tolist(), columns=["vector"])["vector"]) + selected_vectors = vectors[inverse].reshape( + len(diagnostic_ids), truth.shape[1], queries.shape[1] + ) + projected = np.einsum("qd,qkd->qk", queries[diagnostic_ids], selected_vectors) + projected_centroids = np.take_along_axis( + scores[diagnostic_ids], mapping["truth_partitions"][diagnostic_ids], axis=1 + ) + residuals = projected - projected_centroids + sample_vectors = matrix( + dataset.take(mapping["sample_positions"].tolist(), columns=["vector"])["vector"] + ) + sample_residuals = ( + queries[diagnostic_ids] + @ (sample_vectors - centroids[mapping["sample_partitions"]]).T + ) + percentiles = [0, 10, 50, 90, 95, 99, 100] + save( + out / "diagnostics.json", + { + "query_ids": diagnostic_ids.tolist(), + "percentiles": percentiles, + "query_norm": np.percentile( + np.linalg.norm(queries[diagnostic_ids], axis=1), percentiles + ).tolist(), + "neighbor_norm": np.percentile( + np.linalg.norm(selected_vectors, axis=2), percentiles + ).tolist(), + "centroid_norm": np.percentile( + np.linalg.norm(centroids, axis=1), percentiles + ).tolist(), + "nearest_centroid_score": np.percentile( + sorted_scores[diagnostic_ids, 0], percentiles + ).tolist(), + "neighbor_score": np.percentile(projected, percentiles).tolist(), + "projected_residual": np.percentile(residuals, percentiles).tolist(), + "random_partition_projected_residual": np.percentile( + sample_residuals, percentiles + ).tolist(), + "random_partition_vector_norm": np.percentile( + np.linalg.norm(sample_vectors, axis=1), percentiles + ).tolist(), + "centroid_sha256": hashlib.sha256(centroids.tobytes()).hexdigest(), + }, + ) + emit( + "prepared", + dataset=name, + rows=build["rows"], + queries=len(queries), + partitions=len(centroids), + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("root", type=Path) + parser.add_argument( + "dataset", choices=["wiki-cohere-35m", "dpr-wikipedia-single-nq"] + ) + args = parser.parse_args() + prepare(args.root, args.dataset) diff --git a/benchmarks/tpch/README.md b/benchmarks/tpch/README.md index 7894ba4fd27..4999094217d 100644 --- a/benchmarks/tpch/README.md +++ b/benchmarks/tpch/README.md @@ -1,10 +1,42 @@ -***Compare lance vs parquet for TPCH Q1 and Q6 using SF1 dataset*** +# Compare Lance vs Parquet for TPCH Q1 and Q6 (SF1) -**Steps to run the benchmark:** +## Prerequisites -1. `cd lance/benchmarks/tpch` -2. `mkdir dataset && cd dataset` -3. download parquet file lineitem from : "https://github.com/cwida/duckdb-data/releases/download/v1.0/lineitemsf1.snappy.parquet"; then rename it to "lineitem_sf1.parquet" -4. generate lance file from the parquet file in the same directory -5. `cd ..` -6. `python3 benchmark.py q1` +Install Python dependencies: + +```bash +python3 -m pip install duckdb pyarrow pylance +``` + +## Prepare Dataset (generated locally with DuckDB) + +Run from this directory (`lance/benchmarks/tpch`): + +```bash +mkdir -p dataset +python3 - <<'PY' +import duckdb +import pyarrow.parquet as pq +import lance + +con = duckdb.connect(database=":memory:") +con.execute("INSTALL tpch; LOAD tpch") +con.execute("CALL dbgen(sf=1)") + +lineitem = con.query("SELECT * FROM lineitem").to_arrow_table() +pq.write_table(lineitem, "dataset/lineitem_sf1.parquet") +lance.write_dataset(lineitem, "dataset/lineitem.lance", mode="overwrite") +PY +``` + +This creates: + +- `dataset/lineitem_sf1.parquet` +- `dataset/lineitem.lance` + +## Run Benchmark + +```bash +python3 benchmark.py q1 +python3 benchmark.py q6 +``` diff --git a/ci/check_dependency_age.py b/ci/check_dependency_age.py new file mode 100644 index 00000000000..d29f518ccc5 --- /dev/null +++ b/ci/check_dependency_age.py @@ -0,0 +1,281 @@ +#!/usr/bin/env python3 +"""Fail if any crates.io dependency in a Cargo.lock is younger than a minimum age. + +A freshly published version is the window in which a compromised crate is most +likely to still be live, so we refuse to ship one until it has had time to be +noticed and yanked. Cargo's own `registry.global-min-publish-age` only constrains +resolution: versions already written into Cargo.lock are grandfathered in and are +never re-checked, so it cannot answer "is what we committed too new?". + +Publish timestamps come from the `pubtime` field of the sparse index. Cargo keeps +the index lines it has downloaded in `$CARGO_HOME/registry/index/*/.cache`, which +lets a full scan run without any network calls; anything not found there is +fetched from index.crates.io, and anything the index has not published yet is +looked up through the crates.io API, which is authoritative and never lags. + +The same floor is enforced on developer machines by Aikido endpoint protection, +which filters the index it serves them. A security fix that has to land sooner +therefore needs an Aikido allowlist entry as well as one in +ci/dependency-age-allowlist.toml; the entry here only unblocks CI. +""" + +import argparse +import json +import os +import sys +import time +import tomllib +import urllib.error +import urllib.request +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timedelta, timezone +from pathlib import Path + +DEFAULT_LOCKFILES = ("Cargo.lock", "python/Cargo.lock", "java/lance-jni/Cargo.lock") +DEFAULT_ALLOWLIST = "ci/dependency-age-allowlist.toml" +DEFAULT_MIN_AGE_HOURS = 48 +CRATES_IO_SOURCE = "registry+https://github.com/rust-lang/crates.io-index" +INDEX_URL = "https://index.crates.io" +API_URL = "https://crates.io/api/v1/crates" +USER_AGENT = "lance-ci-dependency-age (https://github.com/lance-format/lance)" +FETCH_THREADS = 16 +# The API is rate limited and is only ever asked about the few versions the +# index CDN has not caught up with, so it gets a much smaller pool. +API_THREADS = 4 +FETCH_TIMEOUT_SECONDS = 30 +FETCH_ATTEMPTS = 4 + +# The cache file is a cargo internal: a format byte, an index-format u32, an +# `etag: ...` line, then a NUL-separated run of alternating version and index +# JSON. Only the JSON matters here, so rather than validating the framing we +# pick out the chunks that parse, and let the caller fall back to HTTP if a +# cargo release ever changes the layout underneath us. +CACHE_JSON_PREFIX = b"{" + + +def index_path(name): + """Return the sparse-index path prefix cargo uses for a crate name.""" + name = name.lower() + if len(name) <= 2: + return f"{len(name)}/{name}" + if len(name) == 3: + return f"3/{name[0]}/{name}" + return f"{name[:2]}/{name[2:4]}/{name}" + + +def parse_index_entries(chunks): + """Map version -> pubtime (or None) from an iterable of index JSON chunks.""" + entries = {} + for chunk in chunks: + chunk = chunk.strip() + if not chunk.startswith(CACHE_JSON_PREFIX): + continue + entry = json.loads(chunk) + entries[entry["vers"]] = entry.get("pubtime") + return entries + + +def parse_cache_file(data): + try: + return parse_index_entries(data.split(b"\x00")) + except (json.JSONDecodeError, KeyError, UnicodeDecodeError): + return {} + + +def parse_index_response(data): + return parse_index_entries(data.splitlines()) + + +def parse_api_response(data): + """Map version -> publish time from a crates.io `/versions` response.""" + versions = json.loads(data)["versions"] + return {version["num"]: version.get("created_at") for version in versions} + + +def crates_io_packages(lock_text): + """Return (name, version) for every crates.io package in a lockfile.""" + lock = tomllib.loads(lock_text) + return [ + (package["name"], package["version"]) + for package in lock.get("package", []) + if package.get("source") == CRATES_IO_SOURCE + ] + + +def load_allowlist(path): + """Map (crate, version) -> reason for the exemptions in an allowlist file.""" + if not Path(path).is_file(): + return {} + entries = tomllib.loads(Path(path).read_text()).get("allow", []) + allowed = {} + for entry in entries: + missing = {"crate", "version", "reason"} - entry.keys() + if missing: + raise SystemExit( + f"{path}: allow entry {entry} is missing {sorted(missing)}" + ) + allowed[(entry["crate"], entry["version"])] = entry["reason"] + return allowed + + +def cache_dirs(cargo_home): + return sorted(Path(cargo_home).glob("registry/index/*/.cache")) + + +def pubtimes_from_cache(names, cargo_home): + dirs = cache_dirs(cargo_home) + found = {} + for name in names: + for directory in dirs: + path = directory / index_path(name) + if not path.is_file(): + continue + entries = parse_cache_file(path.read_bytes()) + if entries: + found[name] = entries + break + return found + + +def http_get(url): + """Return the body, or None for a 404. Retries transient failures.""" + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + for attempt in range(FETCH_ATTEMPTS): + try: + with urllib.request.urlopen( + request, timeout=FETCH_TIMEOUT_SECONDS + ) as response: + return response.read() + except urllib.error.HTTPError as error: + if error.code == 404: + return None + if attempt == FETCH_ATTEMPTS - 1: + raise + except OSError: + if attempt == FETCH_ATTEMPTS - 1: + raise + time.sleep(2**attempt) + + +def fetch_index_pubtimes(name): + body = http_get(f"{INDEX_URL}/{index_path(name)}") + return name, {} if body is None else parse_index_response(body) + + +def fetch_api_pubtimes(name): + body = http_get(f"{API_URL}/{name}/versions") + return name, {} if body is None else parse_api_response(body) + + +def pubtimes_from_index(names): + return fetch_all(fetch_index_pubtimes, names, FETCH_THREADS) + + +def pubtimes_from_api(names): + return fetch_all(fetch_api_pubtimes, names, API_THREADS) + + +def fetch_all(fetch, names, threads): + if not names: + return {} + with ThreadPoolExecutor(threads) as pool: + return dict(pool.map(fetch, names)) + + +def resolve_pubtimes(packages, cargo_home): + """Sort packages into dated, undated, and not-in-the-index. + + Returns ({(name, version): pubtime}, [undated], [absent]). + """ + known = pubtimes_from_cache({name for name, _ in packages}, cargo_home) + for lookup in (pubtimes_from_index, pubtimes_from_api): + stale = sorted( + {name for name, version in packages if version not in known.get(name, {})} + ) + for name, entries in lookup(stale).items(): + known[name] = {**known.get(name, {}), **entries} + + dated, undated, absent = {}, [], [] + for name, version in packages: + entries = known.get(name, {}) + if version not in entries: + absent.append((name, version)) + elif entries[version] is None: + undated.append((name, version)) + else: + dated[(name, version)] = entries[version] + return dated, undated, absent + + +def too_new(dated, cutoff): + """Return (name, version, published) for versions published after the cutoff.""" + violations = [] + for (name, version), pubtime in dated.items(): + published = datetime.fromisoformat(pubtime.replace("Z", "+00:00")) + if published > cutoff: + violations.append((name, version, published)) + return violations + + +def check_lockfile(path, cutoff, cargo_home, allowed): + """Print this lockfile's findings and return whether it passed.""" + packages = crates_io_packages(Path(path).read_text()) + dated, undated, absent = resolve_pubtimes(packages, cargo_home) + + if undated: + # The index carries no pubtime for a few dozen versions predating the + # field. Those are years old, so treating them as passing is safe. + print(f"{path}: {len(undated)} of {len(packages)} versions predate `pubtime`") + + violations = [ + violation + for violation in too_new(dated, cutoff) + if (violation[0], violation[1]) not in allowed + ] + absent = [package for package in absent if package not in allowed] + + for name, version, published in sorted(violations): + short_by = (published - cutoff).total_seconds() / 3600 + print( + f"{path}: {name} {version} is too new — published {published.isoformat()}, " + f"{short_by:.1f}h short of the minimum age" + ) + for name, version in sorted(absent): + print(f"{path}: {name} {version} is not in the index, so it cannot be dated") + + return not violations and not absent + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("lockfiles", nargs="*", default=list(DEFAULT_LOCKFILES)) + parser.add_argument("--min-age-hours", type=int, default=DEFAULT_MIN_AGE_HOURS) + parser.add_argument("--allowlist", default=DEFAULT_ALLOWLIST) + parser.add_argument( + "--cargo-home", default=os.environ.get("CARGO_HOME", Path.home() / ".cargo") + ) + args = parser.parse_args(argv) + + cutoff = datetime.now(timezone.utc) - timedelta(hours=args.min_age_hours) + allowed = load_allowlist(args.allowlist) + for (name, version), reason in sorted(allowed.items()): + print(f"{args.allowlist} exempts {name} {version}: {reason}") + + passed = [ + check_lockfile(path, cutoff, args.cargo_home, allowed) + for path in args.lockfiles + ] + if all(passed): + return 0 + + print( + f"\nEvery crates.io dependency must be at least {args.min_age_hours}h old. " + "Wait for these versions to age, or pin the previous version. Dependabot's " + "cooldown should normally keep this from firing.", + file=sys.stderr, + ) + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/check_proto_comments.py b/ci/check_proto_comments.py new file mode 100644 index 00000000000..efeb5efd262 --- /dev/null +++ b/ci/check_proto_comments.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Require `/* ... */` for multi-line comments in .proto files. + +Multi-line `//` runs cannot be folded by editors, which makes the long design +notes in these schemas hard to skim. The `/* text` + ` * text` layout keeps every +content column exactly where the `//` form had it, so protoc extracts identical +leading_comments and the generated Rust, Python and Java doc comments do not +change. + +The SPDX header is exempt; license-header-check requires it verbatim. +""" + +import argparse +import re +import sys +from pathlib import Path + +DEFAULT_ROOTS = (".",) +SKIP_DIRS = {".git", ".venv", "node_modules", "target"} + +COMMENT_LINE = re.compile(r"^(\s*)(///?)(.*)$") +SPDX_LINE = re.compile(r"^//\s*SPDX-") + + +def violations(lines): + """Yield (start, indent, contents) for each multi-line `//` comment run.""" + i = 0 + while i < len(lines) and SPDX_LINE.match(lines[i]): + i += 1 + while i < len(lines): + match = COMMENT_LINE.match(lines[i]) + if match is None: + i += 1 + continue + indent = match.group(1) + contents = [] + j = i + while j < len(lines): + line = COMMENT_LINE.match(lines[j]) + if line is None or line.group(1) != indent: + break + contents.append(line.group(3)) + j += 1 + if len(contents) > 1: + yield i, indent, contents + i = j + + +def rewrite(text): + lines = text.split("\n") + out = [] + i = 0 + for start, indent, contents in violations(lines): + out.extend(lines[i:start]) + out.append((indent + "/*" + contents[0]).rstrip()) + out.extend((indent + " *" + content).rstrip() for content in contents[1:]) + out.append(indent + " */") + i = start + len(contents) + out.extend(lines[i:]) + return "\n".join(out) + + +def collect(paths): + files = [] + for path in paths: + path = Path(path) + if not path.is_dir(): + files.append(path) + continue + found = path.rglob("*.proto") + files.extend(sorted(p for p in found if SKIP_DIRS.isdisjoint(p.parts))) + return files + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "paths", nargs="*", default=DEFAULT_ROOTS, help="files or directories to check" + ) + parser.add_argument( + "--fix", action="store_true", help="rewrite offending comments in place" + ) + args = parser.parse_args(argv) + + failed = False + for file in collect(args.paths): + original = file.read_text() + offenders = list(violations(original.split("\n"))) + if not offenders: + continue + failed = True + if args.fix: + file.write_text(rewrite(original)) + print(f"{file}: rewrote {len(offenders)} multi-line comment(s)") + else: + for start, _, contents in offenders: + print( + f"{file}:{start + 1}: multi-line comment must use /* ... */ ({len(contents)} lines)" + ) + + if failed and not args.fix: + print( + "\nRun `python ci/check_proto_comments.py --fix` to convert them.", + file=sys.stderr, + ) + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/ci/create_rc.sh b/ci/create_rc.sh index 6dcc53ae3cf..f6f8b0039a6 100644 --- a/ci/create_rc.sh +++ b/ci/create_rc.sh @@ -78,8 +78,21 @@ else echo "Warning: Previous tag not found" fi +# Determine release type based on version components +# - major: X.0.0 releases +# - minor: X.Y.0 releases where Y > 0 +# - patch: X.Y.Z releases where Z > 0 +if [ "${PATCH}" -gt 0 ]; then + RELEASE_TYPE="patch" +elif [ "${MINOR}" -eq 0 ]; then + RELEASE_TYPE="major" +else + RELEASE_TYPE="minor" +fi +echo "Release type: ${RELEASE_TYPE}" + echo "Successfully created RC tag: ${RC_TAG}" echo "RC_TAG=${RC_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true echo "RC_VERSION=${RC_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "RELEASE_TYPE=patch" >> $GITHUB_OUTPUT 2>/dev/null || true +echo "RELEASE_TYPE=${RELEASE_TYPE}" >> $GITHUB_OUTPUT 2>/dev/null || true 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/ci/create_release_branch.sh b/ci/create_release_branch.sh index 594709bcb15..db88f5b6b24 100755 --- a/ci/create_release_branch.sh +++ b/ci/create_release_branch.sh @@ -2,180 +2,307 @@ set -e # Script to create a release branch with initial RC for major/minor release -# Always creates RC from the tip of main branch -# Checks for breaking changes and bumps major version if needed -# The version is automatically determined from main branch HEAD -# Usage: create_release_branch.sh -# Example: create_release_branch.sh - -TAG_PREFIX=${1:-"v"} +# Can create from main branch or from an existing release branch +# +# Usage: create_release_branch.sh [source_release_branch] [tag_prefix] +# +# Examples: +# create_release_branch.sh # Create from main branch +# create_release_branch.sh release/v1.3 # Create minor release from release/v1.3 +# create_release_branch.sh "" v # Create from main with custom prefix + +SOURCE_RELEASE_BRANCH=${1:-""} +TAG_PREFIX=${2:-"v"} readonly SELF_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) -git checkout main -MAIN_VERSION=$(grep '^version = ' Cargo.toml | head -n1 | cut -d'"' -f2) -echo "Main branch current version: ${MAIN_VERSION}" +# Source common release functions +source "${SELF_DIR}/release_common.sh" + +# Determine if we're creating from main or from a release branch +if [ -n "${SOURCE_RELEASE_BRANCH}" ]; then + echo "Creating minor release from release branch: ${SOURCE_RELEASE_BRANCH}" + CREATE_FROM_RELEASE_BRANCH="true" +else + echo "Creating release from main branch" + CREATE_FROM_RELEASE_BRANCH="false" +fi + +# Always check main version first (for validation when creating from release branch) +git fetch origin main +MAIN_VERSION=$(git show origin/main:Cargo.toml | grep '^version = ' | head -n1 | cut -d'"' -f2) +echo "Main branch version: ${MAIN_VERSION}" -# Extract the base version from main (remove beta suffix if present) +# Parse main version if [[ "${MAIN_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then - CURR_MAJOR="${BASH_REMATCH[1]}" - CURR_MINOR="${BASH_REMATCH[2]}" - CURR_PATCH="${BASH_REMATCH[3]}" - BASE_VERSION="${CURR_MAJOR}.${CURR_MINOR}.${CURR_PATCH}" + MAIN_MAJOR="${BASH_REMATCH[1]}" + MAIN_MINOR="${BASH_REMATCH[2]}" + MAIN_PATCH="${BASH_REMATCH[3]}" else echo "ERROR: Cannot parse version from main branch: ${MAIN_VERSION}" exit 1 fi -echo "Current base version on main: ${BASE_VERSION}" +if [ "${CREATE_FROM_RELEASE_BRANCH}" = "true" ]; then + # + # ============= CREATE FROM RELEASE BRANCH ============= + # + # Validate main is at a major version (X.0.0-beta.N) + if [ "${MAIN_MINOR}" != "0" ] || [ "${MAIN_PATCH}" != "0" ]; then + echo "ERROR: Cannot create minor release from release branch when main is not at a major version" + echo "Main is at ${MAIN_VERSION}, expected X.0.0-beta.N format" + echo "Minor releases from release branches are only allowed when main is targeting a major release" + exit 1 + fi -# Check for existing release-root tag to find comparison base -CURR_RELEASE_ROOT_TAG="release-root/${BASE_VERSION}-beta.N" + echo "Main is at major version ${MAIN_MAJOR}.0.0 - OK to create minor release from release branch" -if git rev-parse "${CURR_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then - echo "Found release root tag: ${CURR_RELEASE_ROOT_TAG}" - COMPARE_TAG="${CURR_RELEASE_ROOT_TAG}" - COMPARE_COMMIT=$(git rev-parse "${CURR_RELEASE_ROOT_TAG}") - echo "Will compare against: ${COMPARE_TAG} (commit: ${COMPARE_COMMIT})" -else - echo "No release root tag found for current version series" - COMPARE_TAG="" -fi + # Checkout the source release branch + git checkout "${SOURCE_RELEASE_BRANCH}" + SOURCE_VERSION=$(get_version_from_cargo) + echo "Source release branch version: ${SOURCE_VERSION}" -# Check for breaking changes -BREAKING_CHANGES="false" -if [ -n "${COMPARE_TAG}" ]; then - if python3 "${SELF_DIR}/check_breaking_changes.py" --detect-only "${COMPARE_TAG}" "HEAD"; then - echo "No breaking changes detected" - BREAKING_CHANGES="false" + # Parse source version + if [[ "${SOURCE_VERSION}" =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)(-beta\.([0-9]+))?$ ]]; then + SOURCE_MAJOR="${BASH_REMATCH[1]}" + SOURCE_MINOR="${BASH_REMATCH[2]}" + SOURCE_PATCH="${BASH_REMATCH[3]}" else - echo "Breaking changes detected" - BREAKING_CHANGES="true" + echo "ERROR: Cannot parse version from source branch: ${SOURCE_VERSION}" + exit 1 fi -fi -# Determine RC version based on breaking changes -if [ "${BREAKING_CHANGES}" = "true" ]; then - # Extract base RC version from release-root tag message - TAG_MESSAGE=$(git tag -l --format='%(contents)' "${CURR_RELEASE_ROOT_TAG}") - BASE_RC_VERSION=$(echo "${TAG_MESSAGE}" | head -n1 | sed 's/Base: //') - BASE_RC_MAJOR=$(echo "${BASE_RC_VERSION}" | cut -d. -f1 | sed 's/^v//') + # Validate source branch is in the same major version series (or one less than main) + if [ "${SOURCE_MAJOR}" -ge "${MAIN_MAJOR}" ]; then + echo "ERROR: Source branch major version (${SOURCE_MAJOR}) must be less than main major version (${MAIN_MAJOR})" + exit 1 + fi - echo "Base RC version: ${BASE_RC_VERSION} (major: ${BASE_RC_MAJOR})" + # Determine next minor version + RC_MAJOR="${SOURCE_MAJOR}" + RC_MINOR=$((SOURCE_MINOR + 1)) + RC_VERSION="${RC_MAJOR}.${RC_MINOR}.0-rc.1" - if [ "${CURR_MAJOR}" -gt "${BASE_RC_MAJOR}" ]; then - echo "Major version already bumped from ${BASE_RC_MAJOR} to ${CURR_MAJOR}" - RC_VERSION="${BASE_VERSION}-rc.1" + echo "Creating RC version: ${RC_VERSION}" + + # Release type is always minor when creating from release branch + RELEASE_TYPE="minor" + echo "Release type: ${RELEASE_TYPE}" + + # Create new release branch from source branch + RELEASE_BRANCH="release/v${RC_MAJOR}.${RC_MINOR}" + echo "Creating release branch ${RELEASE_BRANCH} from ${SOURCE_RELEASE_BRANCH}" + git checkout -b "${RELEASE_BRANCH}" + + # Set version to RC version + echo "Setting version to ${RC_VERSION}" + bump_and_commit_version "${RC_VERSION}" "chore: release candidate ${RC_VERSION} + +Created from ${SOURCE_RELEASE_BRANCH}" + + # Create the RC tag + RC_TAG="${TAG_PREFIX}${RC_VERSION}" + echo "Creating tag ${RC_TAG}" + git tag -a "${RC_TAG}" -m "Release candidate ${RC_VERSION} + +Created from ${SOURCE_RELEASE_BRANCH}" + + echo "Successfully created RC tag: ${RC_TAG} on branch ${RELEASE_BRANCH}" + + # Find latest stable tag on source branch for release notes comparison + # Look for tags matching vX.Y.* where X.Y matches source branch + LATEST_STABLE_TAG=$(git tag -l "${TAG_PREFIX}${SOURCE_MAJOR}.${SOURCE_MINOR}.*" | grep -v -E '(beta|rc)' | sort -V | tail -n1) + + if [ -n "${LATEST_STABLE_TAG}" ]; then + PREVIOUS_TAG="${LATEST_STABLE_TAG}" + echo "Release notes will compare against latest stable: ${PREVIOUS_TAG}" + + # Create minor-release-root tag to mark this as a minor release from a release branch + # This tag stores the source stable tag for use by determine_previous_tag + MINOR_RELEASE_ROOT_TAG="minor-release-root/${RC_MAJOR}.${RC_MINOR}.0" + echo "Creating minor release root tag: ${MINOR_RELEASE_ROOT_TAG}" + git tag -a "${MINOR_RELEASE_ROOT_TAG}" -m "${PREVIOUS_TAG}" else - echo "Breaking changes require major version bump" - RC_MAJOR=$((CURR_MAJOR + 1)) - RC_VERSION="${RC_MAJOR}.0.0-rc.1" + echo "Warning: No stable tag found for ${SOURCE_MAJOR}.${SOURCE_MINOR}.* series" + PREVIOUS_TAG="" fi -else - # No breaking changes, use current base version - RC_VERSION="${BASE_VERSION}-rc.1" -fi -echo "Creating RC version: ${RC_VERSION}" + # Output for GitHub Actions (no main version or release root tag when creating from release branch) + echo "RC_TAG=${RC_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RC_VERSION=${RC_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RELEASE_TYPE=${RELEASE_TYPE}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "SOURCE_RELEASE_BRANCH=${SOURCE_RELEASE_BRANCH}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "MINOR_RELEASE_ROOT_TAG=${MINOR_RELEASE_ROOT_TAG:-}" >> $GITHUB_OUTPUT 2>/dev/null || true + + echo "Successfully created minor RC from release branch!" + echo " RC Tag: ${RC_TAG}" + echo " Release Branch: ${RELEASE_BRANCH}" + echo " Source Branch: ${SOURCE_RELEASE_BRANCH}" + echo " Release Notes Base: ${PREVIOUS_TAG}" + echo " Minor Release Root Tag: ${MINOR_RELEASE_ROOT_TAG:-none}" -# Determine release type (major if X.0.0, otherwise minor) -RC_MINOR=$(echo "${RC_VERSION}" | cut -d. -f2 | cut -d- -f1) -if [ "${RC_MINOR}" = "0" ]; then - RELEASE_TYPE="major" else - RELEASE_TYPE="minor" -fi -echo "Release type: ${RELEASE_TYPE}" + # + # ============= CREATE FROM MAIN BRANCH ============= + # + git checkout main + BASE_VERSION="${MAIN_MAJOR}.${MAIN_MINOR}.${MAIN_PATCH}" + CURR_MAJOR="${MAIN_MAJOR}" + CURR_MINOR="${MAIN_MINOR}" + CURR_PATCH="${MAIN_PATCH}" + + echo "Current base version on main: ${BASE_VERSION}" + + # Check for existing release-root tag to find comparison base + CURR_RELEASE_ROOT_TAG="release-root/${BASE_VERSION}-beta.N" + + if git rev-parse "${CURR_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then + echo "Found release root tag: ${CURR_RELEASE_ROOT_TAG}" + COMPARE_TAG="${CURR_RELEASE_ROOT_TAG}" + COMPARE_COMMIT=$(git rev-parse "${CURR_RELEASE_ROOT_TAG}") + echo "Will compare against: ${COMPARE_TAG} (commit: ${COMPARE_COMMIT})" + else + echo "No release root tag found for current version series" + COMPARE_TAG="" + fi -# Parse RC version for release branch -RC_MAJOR=$(echo "${RC_VERSION}" | cut -d. -f1) -RC_MINOR=$(echo "${RC_VERSION}" | cut -d. -f2) -RELEASE_BRANCH="release/v${RC_MAJOR}.${RC_MINOR}" + # Check for breaking changes + BREAKING_CHANGES="false" + if [ -n "${COMPARE_TAG}" ]; then + if python3 "${SELF_DIR}/check_breaking_changes.py" --detect-only "${COMPARE_TAG}" "HEAD"; then + echo "No breaking changes detected" + BREAKING_CHANGES="false" + else + echo "Breaking changes detected" + BREAKING_CHANGES="true" + fi + fi -echo "Will create release branch: ${RELEASE_BRANCH}" + # Determine RC version based on breaking changes + if [ "${BREAKING_CHANGES}" = "true" ]; then + # Extract base RC version from release-root tag message + TAG_MESSAGE=$(git tag -l --format='%(contents)' "${CURR_RELEASE_ROOT_TAG}") + BASE_RC_VERSION=$(echo "${TAG_MESSAGE}" | head -n1 | sed 's/Base: //') + BASE_RC_MAJOR=$(echo "${BASE_RC_VERSION}" | cut -d. -f1 | sed 's/^v//') + + echo "Base RC version: ${BASE_RC_VERSION} (major: ${BASE_RC_MAJOR})" + + if [ "${CURR_MAJOR}" -gt "${BASE_RC_MAJOR}" ]; then + echo "Major version already bumped from ${BASE_RC_MAJOR} to ${CURR_MAJOR}" + RC_VERSION="${BASE_VERSION}-rc.1" + else + echo "Breaking changes require major version bump" + RC_MAJOR=$((CURR_MAJOR + 1)) + RC_VERSION="${RC_MAJOR}.0.0-rc.1" + fi + else + # No breaking changes, use current base version + RC_VERSION="${BASE_VERSION}-rc.1" + fi -# Create release branch from main HEAD -echo "Creating release branch ${RELEASE_BRANCH} from main HEAD" -git checkout -b "${RELEASE_BRANCH}" + echo "Creating RC version: ${RC_VERSION}" -# Set version to RC version -echo "Setting version to ${RC_VERSION}" -bump-my-version bump -vv --new-version "${RC_VERSION}" --no-tag patch + # Determine release type (major if X.0.0, otherwise minor) + RC_MINOR=$(echo "${RC_VERSION}" | cut -d. -f2 | cut -d- -f1) + if [ "${RC_MINOR}" = "0" ]; then + RELEASE_TYPE="major" + else + RELEASE_TYPE="minor" + fi + echo "Release type: ${RELEASE_TYPE}" + + # Parse RC version for release branch + RC_MAJOR=$(echo "${RC_VERSION}" | cut -d. -f1) + RC_MINOR=$(echo "${RC_VERSION}" | cut -d. -f2) + RELEASE_BRANCH="release/v${RC_MAJOR}.${RC_MINOR}" + + echo "Will create release branch: ${RELEASE_BRANCH}" + + # Create release branch from main HEAD + echo "Creating release branch ${RELEASE_BRANCH} from main HEAD" + git checkout -b "${RELEASE_BRANCH}" + + # Set version to RC version + echo "Setting version to ${RC_VERSION}" + bump-my-version bump -vv --new-version "${RC_VERSION}" --no-tag patch -# Update Cargo.lock files after version bump -cargo update -(cd python && cargo update) -(cd java/lance-jni && cargo update) + # Update Cargo.lock files after version bump + cargo update --workspace + (cd python && cargo update --workspace) + (cd java/lance-jni && cargo update --workspace) -# Commit the RC version -git add -A -git commit -m "chore: release candidate ${RC_VERSION}" + # Commit the RC version + git add -A + git commit -m "chore: release candidate ${RC_VERSION}" -# Create the RC tag -RC_TAG="${TAG_PREFIX}${RC_VERSION}" -echo "Creating tag ${RC_TAG}" -git tag -a "${RC_TAG}" -m "Release candidate ${RC_VERSION}" + # Create the RC tag + RC_TAG="${TAG_PREFIX}${RC_VERSION}" + echo "Creating tag ${RC_TAG}" + git tag -a "${RC_TAG}" -m "Release candidate ${RC_VERSION}" -echo "Successfully created RC tag: ${RC_TAG} on branch ${RELEASE_BRANCH}" + echo "Successfully created RC tag: ${RC_TAG} on branch ${RELEASE_BRANCH}" -# Now bump main to next unreleased version (beta.0) -echo "Bumping main to next version beta.0" -git checkout main + # Now bump main to next unreleased version (beta.0) + echo "Bumping main to next version beta.0" + git checkout main -# Determine next version for main based on RC version -# Always bump minor from the RC version -NEXT_MAJOR="${RC_MAJOR}" -NEXT_MINOR=$((RC_MINOR + 1)) -NEXT_VERSION="${NEXT_MAJOR}.${NEXT_MINOR}.0-beta.0" + # Determine next version for main based on RC version + # Always bump minor from the RC version + NEXT_MAJOR="${RC_MAJOR}" + NEXT_MINOR=$((RC_MINOR + 1)) + NEXT_VERSION="${NEXT_MAJOR}.${NEXT_MINOR}.0-beta.0" -echo "Bumping main to ${NEXT_VERSION} (unreleased)" + echo "Bumping main to ${NEXT_VERSION} (unreleased)" -bump-my-version bump -vv --new-version "${NEXT_VERSION}" --no-tag patch + bump-my-version bump -vv --new-version "${NEXT_VERSION}" --no-tag patch -# Update Cargo.lock files after version bump -cargo update -(cd python && cargo update) -(cd java/lance-jni && cargo update) + # Update Cargo.lock files after version bump + cargo update --workspace + (cd python && cargo update --workspace) + (cd java/lance-jni && cargo update --workspace) -git add -A -git commit -m "chore: bump main to ${NEXT_VERSION} + git add -A + git commit -m "chore: bump main to ${NEXT_VERSION} Unreleased version after creating ${RC_TAG}" -echo "Main branch bumped to ${NEXT_VERSION}" + echo "Main branch bumped to ${NEXT_VERSION}" -# Create release-root tag for the new beta series on main (points to commit before RC branch) -# Strip the prerelease suffix from NEXT_VERSION for the tag name -NEXT_BASE_VERSION="${NEXT_MAJOR}.${NEXT_MINOR}.0" -RELEASE_ROOT_TAG="release-root/${NEXT_BASE_VERSION}-beta.N" -echo "Creating release root tag ${RELEASE_ROOT_TAG} pointing to RC ${RC_VERSION}" -git tag -a "${RELEASE_ROOT_TAG}" "${RC_TAG}^" -m "Base: ${RC_VERSION} + # Create release-root tag for the new beta series on main (points to commit before RC branch) + # Strip the prerelease suffix from NEXT_VERSION for the tag name + NEXT_BASE_VERSION="${NEXT_MAJOR}.${NEXT_MINOR}.0" + RELEASE_ROOT_TAG="release-root/${NEXT_BASE_VERSION}-beta.N" + echo "Creating release root tag ${RELEASE_ROOT_TAG} pointing to RC ${RC_VERSION}" + git tag -a "${RELEASE_ROOT_TAG}" "${RC_TAG}^" -m "Base: ${RC_VERSION} Release root for ${NEXT_BASE_VERSION}-beta.N series" -# Determine comparison base for RC release notes -# For major/minor RC, we want to compare against the OLD release-root tag (the one for the main version before bump) -# which points to the previous RC base -OLD_RELEASE_ROOT_TAG="release-root/${BASE_VERSION}-beta.N" + # Determine comparison base for RC release notes + # For major/minor RC, we want to compare against the OLD release-root tag (the one for the main version before bump) + # which points to the previous RC base + OLD_RELEASE_ROOT_TAG="release-root/${BASE_VERSION}-beta.N" -if git rev-parse "${OLD_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then - PREVIOUS_TAG="${OLD_RELEASE_ROOT_TAG}" - echo "Release notes will compare against previous release-root: ${PREVIOUS_TAG}" -else - echo "Warning: Release root tag ${OLD_RELEASE_ROOT_TAG} not found" - PREVIOUS_TAG="" -fi + if git rev-parse "${OLD_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then + PREVIOUS_TAG="${OLD_RELEASE_ROOT_TAG}" + echo "Release notes will compare against previous release-root: ${PREVIOUS_TAG}" + else + echo "Warning: Release root tag ${OLD_RELEASE_ROOT_TAG} not found" + PREVIOUS_TAG="" + fi -# Output for GitHub Actions -echo "RC_TAG=${RC_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "RC_VERSION=${RC_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "MAIN_VERSION=${NEXT_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "RELEASE_ROOT_TAG=${RELEASE_ROOT_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true -echo "RELEASE_TYPE=${RELEASE_TYPE}" >> $GITHUB_OUTPUT 2>/dev/null || true - -echo "Successfully created major/minor RC!" -echo " RC Tag: ${RC_TAG}" -echo " Release Branch: ${RELEASE_BRANCH}" -echo " Main Version: ${NEXT_VERSION}" -echo " Release Root Tag: ${RELEASE_ROOT_TAG}" + # Output for GitHub Actions + echo "RC_TAG=${RC_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RC_VERSION=${RC_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RELEASE_BRANCH=${RELEASE_BRANCH}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "MAIN_VERSION=${NEXT_VERSION}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RELEASE_ROOT_TAG=${RELEASE_ROOT_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "PREVIOUS_TAG=${PREVIOUS_TAG}" >> $GITHUB_OUTPUT 2>/dev/null || true + echo "RELEASE_TYPE=${RELEASE_TYPE}" >> $GITHUB_OUTPUT 2>/dev/null || true + + echo "Successfully created major/minor RC!" + echo " RC Tag: ${RC_TAG}" + echo " Release Branch: ${RELEASE_BRANCH}" + echo " Main Version: ${NEXT_VERSION}" + echo " Release Root Tag: ${RELEASE_ROOT_TAG}" +fi diff --git a/ci/dependency-age-allowlist.toml b/ci/dependency-age-allowlist.toml new file mode 100644 index 00000000000..60866509a4e --- /dev/null +++ b/ci/dependency-age-allowlist.toml @@ -0,0 +1,15 @@ +# Versions exempt from the minimum publish age enforced by +# ci/check_dependency_age.py. +# +# Only add an entry once the same version has been allowlisted in Aikido +# endpoint protection. Aikido filters the crates.io index it serves to +# developer machines, so an entry here on its own unblocks CI while leaving +# every developer unable to build. +# +# Entries become inert once the version passes the minimum age, so pruning +# them is housekeeping rather than urgent. + +# [[allow]] +# crate = "some-crate" +# version = "1.2.3" +# reason = "RUSTSEC-2026-0000, allowlisted in Aikido 2026-09-16" diff --git a/ci/format_vote_gate.py b/ci/format_vote_gate.py new file mode 100644 index 00000000000..b80f20c0b35 --- /dev/null +++ b/ci/format_vote_gate.py @@ -0,0 +1,419 @@ +"""Format-specification vote gate (see `.github/workflows/format-vote-gate.yml`). + +Structurally enforces the PMC vote required for Lance format-specification +changes (https://lance.org/community/voting/). The `format-change` label is +applied by the path labeler (`.github/labeler-area.yml`); this script reads it +and publishes the `format-spec-vote` commit status, which blocks merging until: + + * 3 PMC members have approved the PR (excluding the author), counted on any + commit so a rebase or a typo fix doesn't send everyone back to re-vote; + * at least one of those approvals is on the head commit. That member is + vouching that nothing substantive changed since the earlier approvals; + * no PMC member has an outstanding "Request changes" review (a veto); and + * the 72-hour voting period has elapsed. The clock starts once the PR is both + labeled and out of draft, and pauses over weekends. + +A PMC member can waive a trivial edit by applying the `format-waived` label. +Non-format PRs get a passing status immediately and are otherwise left alone. +Drafts get a blocking status but no comment: the vote has not opened yet. + +The vote-counting and deadline rules are pure functions (`tally_reviews`, +`decide_verdict`, `vote_opened_at`, `weekday_deadline`) unit tested in +`test_format_vote_gate.py`; `main` wires them to the GitHub API. +""" + +import json +import os +from collections import namedtuple +from datetime import datetime, timedelta, timezone +from zoneinfo import ZoneInfo + +STATUS_CONTEXT = "format-spec-vote" +FORMAT_LABEL = "format-change" +WAIVED_LABEL = "format-waived" +COMMENT_MARKER = "<!-- format-spec-vote-status -->" +REQUIRED_APPROVALS = 3 +PERIOD_HOURS = 72 +VOTING_URL = "https://lance.org/community/voting/" +WORKFLOW_FILE = "format-vote-gate.yml" +# Keep in sync with the `cron` in the workflow; only used in the comment text. +SWEEP_MINUTES = 15 + +# The weekend boundary is fixed in UTC rather than a local zone: it has no DST +# transitions to reason about, and no PMC member's timezone gets to define when +# everyone else's clock pauses. Deadlines are *displayed* in UTC and Pacific. +WEEKEND_TZ = timezone.utc +DISPLAY_TZ = ZoneInfo("America/Los_Angeles") + +# datetime.weekday() numbers Monday 0 .. Sunday 6, so the weekend is >= 5. +_SATURDAY = 5 + +# Review states that express a stance; COMMENTED/PENDING are ignored. +_STANCE_STATES = ("APPROVED", "CHANGES_REQUESTED", "DISMISSED") + +TimelineFacts = namedtuple("TimelineFacts", "labeled_at waived ready_at") + + +def tally_reviews(reviews, head_sha, author, is_pmc): + """Tally PMC votes from a PR's reviews. + + `reviews` is an ordered list of dicts with `login`, `state`, `commit_id`. + A member's stance is their most recent stance review. Approvals count + whatever commit they were cast on; the ones on the head commit are returned + separately, because the gate requires at least one of those. A "changes + requested" review is a veto regardless of commit. The PR author never + counts. + """ + latest = {} + for review in reviews: + login = review["login"] + if not login or not is_pmc(login) or login == author: + continue + if review["state"] not in _STANCE_STATES: + continue + latest[login.lower()] = review + + approvals, head_approvals, vetoes = [], [], [] + for review in latest.values(): + if review["state"] == "APPROVED": + approvals.append(review["login"]) + if review["commit_id"] == head_sha: + head_approvals.append(review["login"]) + elif review["state"] == "CHANGES_REQUESTED": + vetoes.append(review["login"]) + return approvals, head_approvals, vetoes + + +def decide_verdict( + veto_count, approval_count, head_approval_count, period_elapsed, required +): + """Return the blocking condition (if any), in priority order.""" + if veto_count > 0: + return "veto" + if approval_count < required: + return "insufficient" + if head_approval_count == 0: + return "unconfirmed" + if not period_elapsed: + return "waiting_period" + return "pass" + + +def vote_opened_at(labeled_at, ready_at): + """When the voting period starts, or None if it hasn't. + + A vote opens only once the proposal is both identified as a format change + and offered for review, so the clock starts at the later of the two. A draft + is still being drafted; time spent there shouldn't count toward the period. + """ + if labeled_at is None or ready_at is None: + return None + return max(labeled_at, ready_at) + + +def _start_of_day(dt): + return dt.replace(hour=0, minute=0, second=0, microsecond=0) + + +# Advance to Monday 00:00 if `dt` lands on a weekend; otherwise leave it alone. +def _skip_weekend(dt): + while dt.weekday() >= _SATURDAY: + dt = _start_of_day(dt) + timedelta(days=1) + return dt + + +# Saturday 00:00 following `dt`, which must already be a weekday. +def _next_weekend(dt): + return _start_of_day(dt) + timedelta(days=_SATURDAY - dt.weekday()) + + +def weekday_deadline(start, hours): + """When `hours` of non-weekend time have elapsed after `start`. + + Weekends don't count toward the voting period, so a proposal opened on a + Friday afternoon doesn't burn most of its period while nobody is reading it. + Both `start` and the result are aware datetimes; the arithmetic happens in + `WEEKEND_TZ`, which decides where each weekend begins and ends. + """ + cursor = _skip_weekend(start.astimezone(WEEKEND_TZ)) + remaining = timedelta(hours=hours) + while True: + until_weekend = _next_weekend(cursor) - cursor + if remaining <= until_weekend: + return cursor + remaining + remaining -= until_weekend + cursor = _skip_weekend(_next_weekend(cursor)) + + +def _fmt_list(logins): + return ", ".join(f"@{login}" for login in logins) if logins else "none" + + +# Renders as `Wed 2026-08-05 17:00 UTC (10:00 PDT)` — the PMC spans both zones. +# The Pacific weekday is spelled out only when the deadline falls on a different +# day there, which is the case that actually trips people up. +def _fmt_deadline(dt): + local = dt.astimezone(DISPLAY_TZ) + local_day = "" if local.date() == dt.date() else f"{local:%a }" + return f"{dt:%a %Y-%m-%d %H:%M} UTC ({local_day}{local:%H:%M %Z})" + + +def _as_utc(dt): + return dt.replace(tzinfo=timezone.utc) if dt.tzinfo is None else dt + + +def _build_comment( + headline, approval_cell, head_approval_cell, vetoes, period_cell, rerun_url +): + return "\n".join( + [ + COMMENT_MARKER, + "> [!IMPORTANT]", + "> ## Format specification vote", + "", + "This PR modifies the Lance format specification, so it requires " + f"**{REQUIRED_APPROVALS} binding +1 votes from PMC members** " + "(excluding the proposer), **at least one of them on the latest " + f"commit**, and a minimum **{PERIOD_HOURS}-hour** voting period, " + "weekends excluded, before it can merge. " + "Vote by approving this PR (+1) or requesting changes (−1, a veto). " + f"See the [voting process]({VOTING_URL}).", + "", + "Approvals carry over across pushes, so a rebase or a typo fix does " + "not send everyone back to re-vote. Whoever approves the latest " + "commit is vouching that nothing substantive has changed since the " + "earlier approvals; if something has, ask for fresh votes.", + "", + f"**Status: {headline}**", + "", + "| | |", + "|---|---|", + f"| Approvals | {approval_cell} |", + f"| Latest commit approved by | {head_approval_cell} |", + f"| Vetoes | {_fmt_list(vetoes)} |", + f"| Voting period | {period_cell} |", + "", + "<sub>Updated automatically by the format-spec vote gate, which " + f"re-checks every {SWEEP_MINUTES} minutes — just voted? " + f"[Re-check now]({rerun_url}) (press Run workflow; leave the input " + "blank to re-check every open format PR). A PMC member may apply " + f"the `{WAIVED_LABEL}` label to waive the vote for a trivial edit " + "(typo, wording, formatting).</sub>", + ] + ) + + +def _load_pmc(workspace): + import yaml + + roster_path = os.path.join(workspace, "docs", "src", "community", "pmc.yaml") + with open(roster_path) as handle: + roster = yaml.safe_load(handle) + return {member["handle"].lower() for member in roster["members"]} + + +class Gate: + def __init__(self, repo, pmc, run_url, rerun_url): + self.repo = repo + self.pmc = pmc + self.run_url = run_url + self.rerun_url = rerun_url + + def is_pmc(self, login): + return login is not None and login.lower() in self.pmc + + def set_status(self, sha, state, description): + self.repo.get_commit(sha).create_status( + state=state, + context=STATUS_CONTEXT, + description=description[:140], + target_url=self.run_url, + ) + + def upsert_comment(self, issue, body): + for comment in issue.get_comments(): + if COMMENT_MARKER in (comment.body or ""): + if comment.body != body: + comment.edit(body) + return + issue.create_comment(body) + + def timeline_facts(self, issue): + """Read the vote-clock inputs off the PR timeline in one pass.""" + labeled_at = None + waived_by_pmc = False + ready_at = None + for event in issue.get_events(): + actor = event.actor.login if event.actor else None + # A PR converted back to draft and re-opened for review restarts the + # clock, so the *last* ready_for_review wins. + if event.event == "ready_for_review": + ready_at = _as_utc(event.created_at) + continue + if event.event != "labeled" or event.label is None: + continue + if event.label.name == FORMAT_LABEL and labeled_at is None: + labeled_at = _as_utc(event.created_at) + elif event.label.name == WAIVED_LABEL and self.is_pmc(actor): + waived_by_pmc = True + return TimelineFacts(labeled_at, waived_by_pmc, ready_at) + + def evaluate(self, number): + pr = self.repo.get_pull(number) + if pr.state != "open": + print(f"PR #{number} is {pr.state}; skipping.") + return + head_sha = pr.head.sha + labels = {label.name for label in pr.labels} + + # Non-format PRs get a passing status and are otherwise left alone. + if FORMAT_LABEL not in labels: + self.set_status( + head_sha, "success", "No format-spec change; vote not required." + ) + print(f"PR #{number}: not a format change.") + return + + issue = self.repo.get_issue(number) + facts = self.timeline_facts(issue) + + if WAIVED_LABEL in labels and facts.waived: + self.set_status( + head_sha, "success", "Format-spec vote waived by a PMC member." + ) + print(f"PR #{number}: vote waived.") + return + + # Stay quiet on drafts: the proposal isn't up for a vote yet, so there is + # nothing for the PMC to act on and no deadline to announce. + if pr.draft: + self.set_status( + head_sha, + "failure", + "Draft; voting period starts when marked ready for review.", + ) + print(f"PR #{number}: draft, vote not open.") + return + + reviews = [ + { + "login": review.user.login if review.user else None, + "state": review.state, + "commit_id": review.commit_id, + } + for review in pr.get_reviews() + ] + approvals, head_approvals, vetoes = tally_reviews( + reviews, head_sha, pr.user.login, self.is_pmc + ) + + now = datetime.now(timezone.utc) + # `pr.created_at` covers a PR opened ready for review, which never emits a + # ready_for_review event. + opened_at = vote_opened_at( + facts.labeled_at, facts.ready_at or _as_utc(pr.created_at) + ) + period_ends = weekday_deadline(opened_at or now, PERIOD_HOURS) + period_elapsed = now >= period_ends + verdict = decide_verdict( + len(vetoes), + len(approvals), + len(head_approvals), + period_elapsed, + REQUIRED_APPROVALS, + ) + + deadline = _fmt_deadline(period_ends) + if verdict == "veto": + state, summary = "failure", f"Vetoed by {len(vetoes)} PMC member(s)." + headline = f"� Blocked — vetoed by {_fmt_list(vetoes)}" + elif verdict == "insufficient": + state = "failure" + summary = f"{len(approvals)}/{REQUIRED_APPROVALS} PMC approvals." + headline = f"� Blocked — {len(approvals)} of {REQUIRED_APPROVALS} required approvals" + elif verdict == "unconfirmed": + state = "failure" + summary = f"{len(approvals)} PMC approvals, but none on the latest commit." + headline = ( + f"� Blocked — {len(approvals)} approvals, but none on the latest " + "commit; one PMC member must approve it" + ) + elif verdict == "waiting_period": + state, summary = "failure", f"Approved; voting period ends {deadline}." + headline = ( + f"� Approvals met ({len(approvals)}/{REQUIRED_APPROVALS}); " + f"voting period ends {deadline}" + ) + else: + state = "success" + summary = f"Passed — {len(approvals)} PMC approvals, period elapsed." + headline = f"✅ Vote passed — {len(approvals)} PMC approvals, voting period elapsed" + + period_cell = ( + f"elapsed — ended {deadline}" if period_elapsed else f"ends {deadline}" + ) + approval_cell = ( + f"{_fmt_list(approvals)} ({len(approvals)}/{REQUIRED_APPROVALS})" + ) + head_approval_cell = ( + _fmt_list(head_approvals) + if head_approvals + else "none — one PMC member must approve the latest commit" + ) + + self.set_status(head_sha, state, summary) + self.upsert_comment( + issue, + _build_comment( + headline, + approval_cell, + head_approval_cell, + vetoes, + period_cell, + self.rerun_url, + ), + ) + print(f"PR #{number}: {summary}") + + +def main(): + from github import Github + + workspace = os.environ["GITHUB_WORKSPACE"] + token = os.environ["GITHUB_TOKEN"] + repo_name = os.environ["GITHUB_REPOSITORY"] + event_name = os.environ["GITHUB_EVENT_NAME"] + actions_url = f"{os.environ['GITHUB_SERVER_URL']}/{repo_name}/actions" + run_url = f"{actions_url}/runs/{os.environ['GITHUB_RUN_ID']}" + rerun_url = f"{actions_url}/workflows/{WORKFLOW_FILE}" + + repo = Github(token).get_repo(repo_name) + gate = Gate(repo, _load_pmc(workspace), run_url, rerun_url) + print(f"Re-check on demand: {rerun_url}") + + if event_name in ("schedule", "workflow_dispatch"): + # Neither trigger carries PR context. A manual run may name one PR; + # otherwise sweep every open format-change PR. + requested = os.environ.get("GATE_PR", "").strip() + if requested: + gate.evaluate(int(requested)) + return + pulls = [ + pr + for pr in repo.get_pulls(state="open") + if any(label.name == FORMAT_LABEL for label in pr.labels) + ] + print(f"Sweep: {len(pulls)} open {FORMAT_LABEL} PR(s).") + for pr in pulls: + try: + gate.evaluate(pr.number) + except Exception as err: # noqa: BLE001 - keep sweeping other PRs + print(f"PR #{pr.number}: {err}") + else: + with open(os.environ["GITHUB_EVENT_PATH"]) as handle: + event = json.load(handle) + gate.evaluate(event["pull_request"]["number"]) + + +if __name__ == "__main__": + main() diff --git a/ci/new_contributors.py b/ci/new_contributors.py new file mode 100644 index 00000000000..50c6382bf9e --- /dev/null +++ b/ci/new_contributors.py @@ -0,0 +1,280 @@ +#!/usr/bin/env python3 +""" +Usage: python ci/new_contributors.py + +Counts commits by authors in the last year, including co-authors. +Only shows contributors without write permission to the repository. +""" + +import json +import re +import subprocess +import sys +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path + +REPO = "lance-format/lance" +CACHE_DIR = Path.home() / ".cache" / "lance" +CACHE_FILE = CACHE_DIR / "contributor_stats_cache.json" + +# Emails to exclude from results (bots, automated accounts, etc.) +EXCLUDED_EMAILS = { + "noreply@anthropic.com", + "lance-dev@lancedb.com", + "dev+gha@lance.org", +} + + +def load_username_cache() -> dict[str, str]: + """Load cached email -> username mappings.""" + if CACHE_FILE.exists(): + try: + with open(CACHE_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def save_username_cache(cache: dict[str, str]) -> None: + """Save email -> username mappings to cache.""" + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(cache, f, indent=2) + except OSError as e: + print(f"Warning: Could not save cache: {e}", file=sys.stderr) + + +def get_commits_last_year() -> list[dict]: + """Get all commits from the last year with author and body.""" + one_year_ago = (datetime.now() - timedelta(days=365)).strftime("%Y-%m-%d") + + result = subprocess.run( + [ + "git", + "log", + f"--since={one_year_ago}", + "--format=%H%x00%an%x00%ae%x00%b%x00%x01", + ], + capture_output=True, + text=True, + check=True, + ) + + commits = [] + for entry in result.stdout.split("\x01"): + entry = entry.strip() + if not entry: + continue + parts = entry.split("\x00") + if len(parts) >= 4: + commits.append( + { + "hash": parts[0], + "author_name": parts[1], + "author_email": parts[2], + "body": parts[3], + } + ) + return commits + + +def extract_co_authors(body: str) -> list[tuple[str, str]]: + """Extract co-authors from commit body. + + Returns list of (name, email) tuples. + """ + co_authors = [] + pattern = r"Co-authored-by:\s*(.+?)\s*<([^>]+)>" + for match in re.finditer(pattern, body, re.IGNORECASE): + co_authors.append((match.group(1).strip(), match.group(2).strip())) + return co_authors + + +def get_github_username_from_email(email: str) -> str | None: + """Try to get GitHub username from email pattern.""" + # Handle GitHub noreply emails + match = re.match(r"(\d+\+)?([^@]+)@users\.noreply\.github\.com", email) + if match: + return match.group(2) + return None + + +def resolve_usernames_via_api( + email_to_sample_commit: dict[str, str], + cache: dict[str, str], +) -> dict[str, str]: + """Get GitHub usernames by querying one sample commit per email. + + Uses cache for previously resolved emails. + Returns mapping of email -> GitHub username. + """ + email_to_username: dict[str, str] = {} + + # Check cache first + uncached_emails = {} + for email, sha in email_to_sample_commit.items(): + if email in cache: + email_to_username[email] = cache[email] + else: + uncached_emails[email] = sha + + if uncached_emails: + items = list(uncached_emails.items()) + total = len(items) + cached_count = len(email_to_sample_commit) - total + if cached_count > 0: + print( + f" Found {cached_count} cached, resolving {total} via API...", + file=sys.stderr, + ) + + for i, (email, sha) in enumerate(items): + result = subprocess.run( + [ + "gh", + "api", + f"repos/{REPO}/commits/{sha}", + "--jq", + ".author.login // empty", + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + username = result.stdout.strip() + if username: + email_to_username[email] = username + cache[email] = username + + if (i + 1) % 20 == 0: + print(f" Resolved {i + 1}/{total} authors...", file=sys.stderr) + + return email_to_username + + +def get_collaborators_with_write() -> set[str]: + """Get set of GitHub usernames with write permission.""" + collaborators = set() + + # Get collaborators with push/admin/maintain permission + result = subprocess.run( + [ + "gh", + "api", + f"repos/{REPO}/collaborators", + "--paginate", + "--jq", + ".[] | select(.permissions.push == true or .permissions.admin == true or .permissions.maintain == true) | .login", + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for line in result.stdout.strip().split("\n"): + if line: + collaborators.add(line.lower()) + + return collaborators + + +def main(): + print("Fetching commits from the last year...", file=sys.stderr) + commits = get_commits_last_year() + print(f"Found {len(commits)} commits", file=sys.stderr) + + # Load username cache + username_cache = load_username_cache() + + # Count commits per author (by email, since that's more reliable) + # Also track one sample commit hash per email for API resolution + author_commits: dict[str, int] = defaultdict(int) + email_to_name: dict[str, str] = {} + email_to_sample_commit: dict[str, str] = {} + + for commit in commits: + # Add main author + email = commit["author_email"].lower() + author_commits[email] += 1 + email_to_name[email] = commit["author_name"] + if email not in email_to_sample_commit: + email_to_sample_commit[email] = commit["hash"] + + # Add co-authors (they don't have commit hashes, so we can't resolve via API) + for name, co_email in extract_co_authors(commit["body"]): + co_email = co_email.lower() + author_commits[co_email] += 1 + if co_email not in email_to_name: + email_to_name[co_email] = name + + print(f"Found {len(author_commits)} unique authors", file=sys.stderr) + + # First pass: get usernames from email patterns (noreply emails) + email_to_username: dict[str, str | None] = {} + for email in author_commits: + email_to_username[email] = get_github_username_from_email(email) + + # Second pass: resolve remaining emails via GitHub API (only for main authors) + emails_to_resolve = { + email: sha + for email, sha in email_to_sample_commit.items() + if email_to_username.get(email) is None + } + print( + f"Resolving {len(emails_to_resolve)} usernames via GitHub API...", + file=sys.stderr, + ) + api_mappings = resolve_usernames_via_api(emails_to_resolve, username_cache) + for email, username in api_mappings.items(): + email_to_username[email] = username + + # Save updated cache + save_username_cache(username_cache) + + # Get collaborators with write permission + print("Fetching repository collaborators...", file=sys.stderr) + write_collaborators = get_collaborators_with_write() + print( + f"Found {len(write_collaborators)} collaborators with write access", + file=sys.stderr, + ) + + # Filter to only non-write contributors + non_write_contributors = [] + for email, count in author_commits.items(): + # Skip excluded emails + if email in EXCLUDED_EMAILS: + continue + username = email_to_username.get(email) + if username and username.lower() in write_collaborators: + continue + # Include if we couldn't determine username or if they don't have write access + non_write_contributors.append( + { + "email": email, + "name": email_to_name[email], + "username": username, + "commits": count, + } + ) + + # Sort by commit count descending + non_write_contributors.sort(key=lambda x: x["commits"], reverse=True) + + # Print results + print("\nContributors without write permission (sorted by commit count):\n") + print(f"{'Commits':<10} {'Username':<25} {'Name':<30} {'Email'}") + print("-" * 100) + for contributor in non_write_contributors: + username = contributor["username"] or "(unknown)" + print( + f"{contributor['commits']:<10} {username:<25} {contributor['name']:<30} {contributor['email']}" + ) + + print(f"\nTotal: {len(non_write_contributors)} contributors without write access") + + +if __name__ == "__main__": + main() diff --git a/ci/publish_beta.sh b/ci/publish_beta.sh index 43747f32cd8..06fa5c16a91 100644 --- a/ci/publish_beta.sh +++ b/ci/publish_beta.sh @@ -93,9 +93,9 @@ if [[ "${BRANCH}" == "main" ]] && [[ "${CURRENT_VERSION}" =~ -beta\.[0-9]+$ ]]; bump-my-version bump -vv --new-version "${NEXT_VERSION}" --no-tag patch # Update Cargo.lock files after version bump - cargo update - (cd python && cargo update) - (cd java/lance-jni && cargo update) + cargo update --workspace + (cd python && cargo update --workspace) + (cd java/lance-jni && cargo update --workspace) git add -A git commit -m "chore: bump to ${NEXT_VERSION} based on breaking change detection" @@ -133,9 +133,9 @@ echo "Bumping beta version" bump-my-version bump -vv prerelease_num # Update Cargo.lock files after version bump -cargo update -(cd python && cargo update) -(cd java/lance-jni && cargo update) +cargo update --workspace +(cd python && cargo update --workspace) +(cd java/lance-jni && cargo update --workspace) # Get new version NEW_VERSION=$(grep '^version = ' Cargo.toml | head -n1 | cut -d'"' -f2) @@ -169,17 +169,32 @@ fi BETA_MAJOR=$(echo "${NEW_VERSION}" | cut -d. -f1) BETA_MINOR=$(echo "${NEW_VERSION}" | cut -d. -f2) BETA_PATCH=$(echo "${NEW_VERSION}" | cut -d. -f3 | cut -d- -f1) +BETA_NUM=$(echo "${NEW_VERSION}" | sed 's/.*-beta\.//') if [[ "${BRANCH}" == "main" ]]; then - # For main branch: compare against release-root tag - BETA_RELEASE_ROOT_TAG="release-root/${BETA_MAJOR}.${BETA_MINOR}.${BETA_PATCH}-beta.N" - - if git rev-parse "${BETA_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then - echo "Release notes will compare from ${BETA_RELEASE_ROOT_TAG} to ${BETA_TAG}" - RELEASE_NOTES_FROM="${BETA_RELEASE_ROOT_TAG}" + # For main branch: + # - First beta (beta.1): compare against release-root tag (all changes since last RC) + # - Subsequent betas (beta.2+): compare against previous beta tag (incremental changes) + if [ "${BETA_NUM}" -eq 1 ]; then + BETA_RELEASE_ROOT_TAG="release-root/${BETA_MAJOR}.${BETA_MINOR}.${BETA_PATCH}-beta.N" + if git rev-parse "${BETA_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then + echo "First beta: release notes will compare from ${BETA_RELEASE_ROOT_TAG} to ${BETA_TAG}" + RELEASE_NOTES_FROM="${BETA_RELEASE_ROOT_TAG}" + else + echo "Warning: Release root tag ${BETA_RELEASE_ROOT_TAG} not found" + RELEASE_NOTES_FROM="" + fi else - echo "Warning: Release root tag ${BETA_RELEASE_ROOT_TAG} not found" - RELEASE_NOTES_FROM="" + # For beta.2+, compare against previous beta + PREV_BETA_NUM=$((BETA_NUM - 1)) + PREV_BETA_TAG="${TAG_PREFIX}${BETA_MAJOR}.${BETA_MINOR}.${BETA_PATCH}-beta.${PREV_BETA_NUM}" + if git rev-parse "${PREV_BETA_TAG}" >/dev/null 2>&1; then + echo "Subsequent beta: release notes will compare from ${PREV_BETA_TAG} to ${BETA_TAG}" + RELEASE_NOTES_FROM="${PREV_BETA_TAG}" + else + echo "Warning: Previous beta tag ${PREV_BETA_TAG} not found" + RELEASE_NOTES_FROM="" + fi fi elif [[ "${BRANCH}" =~ ^release/ ]]; then # For release branch: compare against last stable tag diff --git a/ci/release_common.sh b/ci/release_common.sh index bea245919fa..573202d1689 100644 --- a/ci/release_common.sh +++ b/ci/release_common.sh @@ -29,9 +29,9 @@ bump_and_commit_version() { bump-my-version bump -vv --new-version "${NEW_VERSION}" --no-tag patch # Update Cargo.lock files after version bump - cargo update - (cd python && cargo update) - (cd java/lance-jni && cargo update) + cargo update --workspace + (cd python && cargo update --workspace) + (cd java/lance-jni && cargo update --workspace) git add -A git commit -m "${COMMIT_MESSAGE}" @@ -40,6 +40,12 @@ bump_and_commit_version() { # Determines the previous tag for release notes comparison # Args: MAJOR MINOR PATCH [TAG_PREFIX] # Returns: previous tag name or empty string +# +# For major/minor releases (PATCH=0): +# - Checks for minor-release-root tag (minor release from release branch) +# - Otherwise uses release-root tag (standard flow from main) +# For patch releases (PATCH>0): +# - Compares against previous patch stable tag determine_previous_tag() { local MAJOR=$1 local MINOR=$2 @@ -47,7 +53,19 @@ determine_previous_tag() { local TAG_PREFIX=${4:-"v"} if [ "${PATCH}" = "0" ]; then - # Major/Minor release: compare against release-root tag + # Major/Minor release: check for minor-release-root tag first + # This tag is created when a minor release is cut from a release branch + local MINOR_RELEASE_ROOT_TAG="minor-release-root/${MAJOR}.${MINOR}.0" + if git rev-parse "${MINOR_RELEASE_ROOT_TAG}" >/dev/null 2>&1; then + # Read the source tag from the tag message + local SOURCE_TAG=$(git tag -l --format='%(contents:subject)' "${MINOR_RELEASE_ROOT_TAG}") + if [ -n "${SOURCE_TAG}" ]; then + echo "${SOURCE_TAG}" + return + fi + fi + + # Standard flow: use release-root tag local RELEASE_ROOT_TAG="release-root/${MAJOR}.${MINOR}.${PATCH}-beta.N" if git rev-parse "${RELEASE_ROOT_TAG}" >/dev/null 2>&1; then echo "${RELEASE_ROOT_TAG}" diff --git a/ci/review_stats.py b/ci/review_stats.py new file mode 100644 index 00000000000..7e0dc796ac1 --- /dev/null +++ b/ci/review_stats.py @@ -0,0 +1,212 @@ +#!/usr/bin/env python3 +""" +Usage: python ci/review_stats.py + +Counts code reviews by contributors in the last 30 days. +Users without write permission are marked with an asterisk (*). +""" + +import json +import subprocess +import sys +from collections import defaultdict +from datetime import datetime, timedelta +from pathlib import Path + +REPO = "lance-format/lance" +CACHE_DIR = Path.home() / ".cache" / "lance" +CACHE_FILE = CACHE_DIR / "review_stats_cache.json" + + +def load_cache() -> dict: + """Load cached data.""" + if CACHE_FILE.exists(): + try: + with open(CACHE_FILE) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + return {} + + +def save_cache(cache: dict) -> None: + """Save data to cache.""" + try: + CACHE_DIR.mkdir(parents=True, exist_ok=True) + with open(CACHE_FILE, "w") as f: + json.dump(cache, f, indent=2) + except OSError as e: + print(f"Warning: Could not save cache: {e}", file=sys.stderr) + + +def get_prs_last_30_days() -> list[int]: + """Get all merged PR numbers from the last 30 days using search API.""" + cutoff_date = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d") + + pr_numbers = [] + page = 1 + per_page = 100 + + print("Fetching PRs from the last 30 days...", file=sys.stderr) + while True: + # Use search API for better date filtering + query = f"repo:{REPO} is:pr is:merged merged:>={cutoff_date}" + result = subprocess.run( + [ + "gh", + "api", + "search/issues", + "-X", + "GET", + "-f", + f"q={query}", + "-f", + f"per_page={per_page}", + "-f", + f"page={page}", + "--jq", + ".items[].number", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0 or not result.stdout.strip(): + break + + numbers = [int(n) for n in result.stdout.strip().split("\n") if n] + if not numbers: + break + + pr_numbers.extend(numbers) + print( + f" Fetched page {page} ({len(pr_numbers)} PRs so far)...", file=sys.stderr + ) + + if len(numbers) < per_page: + break + page += 1 + + return pr_numbers + + +def get_reviews_for_pr(pr_number: int) -> list[str]: + """Get list of reviewer usernames for a PR.""" + result = subprocess.run( + [ + "gh", + "api", + f"repos/{REPO}/pulls/{pr_number}/reviews", + "--jq", + ".[].user.login", + ], + capture_output=True, + text=True, + ) + if result.returncode != 0: + return [] + + return [u for u in result.stdout.strip().split("\n") if u] + + +def get_collaborators_with_write() -> set[str]: + """Get set of GitHub usernames with write permission.""" + collaborators = set() + + result = subprocess.run( + [ + "gh", + "api", + f"repos/{REPO}/collaborators", + "--paginate", + "--jq", + ".[] | select(.permissions.push == true or .permissions.admin == true or .permissions.maintain == true) | .login", + ], + capture_output=True, + text=True, + ) + if result.returncode == 0: + for line in result.stdout.strip().split("\n"): + if line: + collaborators.add(line.lower()) + + return collaborators + + +def main(): + cache = load_cache() + + # Get PRs from the last 30 days + pr_numbers = get_prs_last_30_days() + print(f"Found {len(pr_numbers)} merged PRs", file=sys.stderr) + + # Get reviews for each PR + print("Fetching reviews...", file=sys.stderr) + review_counts: dict[str, int] = defaultdict(int) + cached_prs = cache.get("pr_reviews", {}) + uncached_count = 0 + + for i, pr_num in enumerate(pr_numbers): + pr_key = str(pr_num) + if pr_key in cached_prs: + reviewers = cached_prs[pr_key] + else: + reviewers = get_reviews_for_pr(pr_num) + cached_prs[pr_key] = reviewers + uncached_count += 1 + + for reviewer in reviewers: + review_counts[reviewer.lower()] += 1 + + if (i + 1) % 50 == 0: + print(f" Processed {i + 1}/{len(pr_numbers)} PRs...", file=sys.stderr) + + cache["pr_reviews"] = cached_prs + save_cache(cache) + + if uncached_count > 0: + print( + f" Fetched {uncached_count} PRs via API, {len(pr_numbers) - uncached_count} from cache", + file=sys.stderr, + ) + + # Get collaborators with write permission + print("Fetching repository collaborators...", file=sys.stderr) + write_collaborators = get_collaborators_with_write() + print( + f"Found {len(write_collaborators)} collaborators with write access", + file=sys.stderr, + ) + + # Build results list + reviewers = [] + for username, count in review_counts.items(): + has_write = username in write_collaborators + reviewers.append( + { + "username": username, + "reviews": count, + "has_write": has_write, + } + ) + + # Sort by review count descending + reviewers.sort(key=lambda x: x["reviews"], reverse=True) + + # Print results + print("\nCode reviews by contributor (sorted by review count):\n") + print("* = no write permission\n") + print(f"{'Reviews':<10} {'Username':<30}") + print("-" * 40) + for reviewer in reviewers: + marker = "" if reviewer["has_write"] else "*" + print(f"{reviewer['reviews']:<10} {reviewer['username']:<30} {marker}") + + total_with_write = sum(1 for r in reviewers if r["has_write"]) + total_without_write = len(reviewers) - total_with_write + print( + f"\nTotal: {len(reviewers)} reviewers ({total_with_write} with write access, {total_without_write} without)" + ) + + +if __name__ == "__main__": + main() diff --git a/ci/test_check_dependency_age.py b/ci/test_check_dependency_age.py new file mode 100644 index 00000000000..d9b29e6b460 --- /dev/null +++ b/ci/test_check_dependency_age.py @@ -0,0 +1,208 @@ +"""Unit tests for the crates.io dependency age check. + +Run with: pytest ci/test_check_dependency_age.py +""" + +import json +from datetime import datetime, timedelta, timezone + +import pytest + +from check_dependency_age import ( + check_lockfile, + crates_io_packages, + index_path, + load_allowlist, + parse_api_response, + parse_cache_file, + parse_index_response, + too_new, +) + +NOW = datetime(2026, 9, 16, 12, 0, tzinfo=timezone.utc) +CUTOFF = NOW - timedelta(hours=48) + + +def cache_blob(entries): + """Build a cargo index cache file the way cargo lays one out.""" + out = b"\x03\x02\x00\x00\x00" + b'etag: "abc"' + b"\x00" + for version, pubtime in entries.items(): + entry = {"name": "demo", "vers": version, "deps": [], "yanked": False} + if pubtime is not None: + entry["pubtime"] = pubtime + out += version.encode() + b"\x00" + json.dumps(entry).encode() + b"\x00" + return out + + +def lockfile(packages): + blocks = ["version = 4\n"] + for name, version, source in packages: + block = f'[[package]]\nname = "{name}"\nversion = "{version}"\n' + if source is not None: + block += f'source = "{source}"\n' + blocks.append(block) + return "\n".join(blocks) + + +CRATES_IO = "registry+https://github.com/rust-lang/crates.io-index" + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + pytest.param("a", "1/a", id="one_char"), + pytest.param("ab", "2/ab", id="two_chars"), + pytest.param("abc", "3/a/abc", id="three_chars"), + pytest.param("serde", "se/rd/serde", id="long"), + pytest.param("Inflector", "in/fl/inflector", id="uppercase_is_folded"), + ], +) +def test_index_path(name, expected): + assert index_path(name) == expected + + +def test_parse_cache_file_reads_pubtimes(): + blob = cache_blob({"1.0.0": "2020-01-01T00:00:00Z", "1.1.0": None}) + assert parse_cache_file(blob) == { + "1.0.0": "2020-01-01T00:00:00Z", + "1.1.0": None, + } + + +def test_parse_cache_file_tolerates_an_unknown_layout(): + """A cargo change to the cache format must degrade to the HTTP fallback.""" + assert parse_cache_file(b"\x09\x99{not json at all}\x00") == {} + + +def test_parse_index_response_reads_newline_delimited_json(): + body = b'{"vers":"1.0.0","pubtime":"2020-01-01T00:00:00Z"}\n{"vers":"2.0.0"}\n' + assert parse_index_response(body) == { + "1.0.0": "2020-01-01T00:00:00Z", + "2.0.0": None, + } + + +def test_parse_api_response_reads_created_at(): + body = b'{"versions":[{"num":"1.0.0","created_at":"2020-01-01T00:00:00.123456Z"}]}' + assert parse_api_response(body) == {"1.0.0": "2020-01-01T00:00:00.123456Z"} + + +def test_crates_io_packages_ignores_path_and_git_packages(): + text = lockfile( + [ + ("serde", "1.0.0", CRATES_IO), + ("lance", "0.1.0", None), + ("forked", "0.2.0", "git+https://example.com/forked#abc123"), + ] + ) + assert crates_io_packages(text) == [("serde", "1.0.0")] + + +@pytest.mark.parametrize( + ("pubtime", "expected"), + [ + pytest.param("2026-09-10T00:00:00Z", [], id="old"), + pytest.param("2026-09-14T11:59:00Z", [], id="just_past_cutoff"), + pytest.param("2026-09-14T12:01:00Z", [("serde", "1.0.0")], id="just_too_new"), + pytest.param("2026-09-16T11:00:00Z", [("serde", "1.0.0")], id="an_hour_old"), + ], +) +def test_too_new_uses_the_cutoff_as_the_boundary(pubtime, expected): + violations = too_new({("serde", "1.0.0"): pubtime}, CUTOFF) + assert [(name, version) for name, version, _ in violations] == expected + + +def cargo_home_with(tmp_path, crates): + home = tmp_path / "cargo" + for name, entries in crates.items(): + path = ( + home / "registry/index/index.crates.io-1949cf8c/.cache" / index_path(name) + ) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(cache_blob(entries)) + return home + + +def test_load_allowlist_is_empty_when_the_file_is_absent(tmp_path): + assert load_allowlist(tmp_path / "nope.toml") == {} + + +def test_load_allowlist_reads_entries(tmp_path): + path = tmp_path / "allow.toml" + path.write_text( + '[[allow]]\ncrate = "serde"\nversion = "1.0.0"\nreason = "RUSTSEC-0"\n' + ) + assert load_allowlist(path) == {("serde", "1.0.0"): "RUSTSEC-0"} + + +def test_load_allowlist_rejects_an_entry_without_a_reason(tmp_path): + """An exemption with no rationale is not reviewable, so refuse to run.""" + path = tmp_path / "allow.toml" + path.write_text('[[allow]]\ncrate = "serde"\nversion = "1.0.0"\n') + with pytest.raises(SystemExit, match="reason"): + load_allowlist(path) + + +def test_check_lockfile_passes_when_every_version_is_old_enough(tmp_path, capsys): + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "1.0.0", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": "2026-09-01T00:00:00Z"}}) + + assert check_lockfile(lock, CUTOFF, home, {}) is True + assert capsys.readouterr().out == "" + + +def test_check_lockfile_reports_a_too_new_version(tmp_path, capsys): + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "1.0.0", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": "2026-09-16T00:00:00Z"}}) + + assert check_lockfile(lock, CUTOFF, home, {}) is False + assert "serde 1.0.0 is too new" in capsys.readouterr().out + + +def test_check_lockfile_accepts_versions_predating_pubtime(tmp_path, capsys): + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "1.0.0", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": None}}) + + assert check_lockfile(lock, CUTOFF, home, {}) is True + assert "1 of 1 versions predate `pubtime`" in capsys.readouterr().out + + +def test_check_lockfile_fails_when_the_index_does_not_list_the_version( + tmp_path, capsys, monkeypatch +): + """An undatable version is a hole in the check, so it must not pass silently.""" + monkeypatch.setattr("check_dependency_age.pubtimes_from_index", lambda names: {}) + monkeypatch.setattr("check_dependency_age.pubtimes_from_api", lambda names: {}) + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "9.9.9", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": "2026-09-01T00:00:00Z"}}) + + assert check_lockfile(lock, CUTOFF, home, {}) is False + assert "serde 9.9.9 is not in the index" in capsys.readouterr().out + + +def test_check_lockfile_honours_an_allowlisted_version(tmp_path, capsys): + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "1.0.0", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": "2026-09-16T00:00:00Z"}}) + allowed = {("serde", "1.0.0"): "RUSTSEC-0"} + + assert check_lockfile(lock, CUTOFF, home, allowed) is True + assert "too new" not in capsys.readouterr().out + + +def test_check_lockfile_allowlist_also_covers_an_undatable_version( + tmp_path, capsys, monkeypatch +): + """Aikido hides allowlisted-but-young versions from the index it serves.""" + monkeypatch.setattr("check_dependency_age.pubtimes_from_index", lambda names: {}) + monkeypatch.setattr("check_dependency_age.pubtimes_from_api", lambda names: {}) + lock = tmp_path / "Cargo.lock" + lock.write_text(lockfile([("serde", "9.9.9", CRATES_IO)])) + home = cargo_home_with(tmp_path, {"serde": {"1.0.0": "2026-09-01T00:00:00Z"}}) + + assert check_lockfile(lock, CUTOFF, home, {("serde", "9.9.9"): "RUSTSEC-0"}) is True + assert capsys.readouterr().out == "" diff --git a/ci/test_check_proto_comments.py b/ci/test_check_proto_comments.py new file mode 100644 index 00000000000..61fa7bf192e --- /dev/null +++ b/ci/test_check_proto_comments.py @@ -0,0 +1,82 @@ +"""Unit tests for the .proto multi-line comment style check. + +Run with: pytest ci/test_check_proto_comments.py +""" + +import pytest + +from check_proto_comments import rewrite + +HEADER = "// SPDX-License-Identifier: Apache-2.0\n// SPDX-FileCopyrightText: Copyright The Lance Authors\n" + + +@pytest.mark.parametrize( + "source", + [ + pytest.param(HEADER, id="license_header"), + pytest.param("// A single line comment.\nmessage M {}\n", id="single_line"), + pytest.param("/// A single doc comment.\nmessage M {}\n", id="single_doc_line"), + pytest.param( + "message M {\n int32 a = 1; // Trailing.\n int32 b = 2; // Also trailing.\n}\n", + id="trailing", + ), + pytest.param( + "/* Already a block\n * comment.\n */\nmessage M {}\n", id="already_block" + ), + ], +) +def test_leaves_compliant_comments_alone(source): + assert rewrite(source) == source + + +def test_rewrites_multi_line_run_as_block(): + source = "// First line.\n// Second line.\nmessage M {}\n" + assert rewrite(source) == "/* First line.\n * Second line.\n */\nmessage M {}\n" + + +def test_preserves_content_columns_and_indent(): + source = "message M {\n // Offsets are laid out as:\n // i == 0: 0\n // i > 0: prev\n int32 a = 1;\n}\n" + assert rewrite(source) == ( + "message M {\n /* Offsets are laid out as:\n * i == 0: 0\n * i > 0: prev\n */\n int32 a = 1;\n}\n" + ) + + +def test_blank_comment_lines_carry_no_trailing_whitespace(): + source = "// First paragraph.\n//\n// Second paragraph.\nmessage M {}\n" + assert ( + rewrite(source) + == "/* First paragraph.\n *\n * Second paragraph.\n */\nmessage M {}\n" + ) + + +def test_rewrites_doc_comment_run(): + source = "/// First line.\n/// Second line.\nmessage M {}\n" + assert rewrite(source) == "/* First line.\n * Second line.\n */\nmessage M {}\n" + + +def test_splits_runs_at_differing_indent(): + source = "// Outer.\n// Outer continued.\nmessage M {\n // Inner.\n // Inner continued.\n int32 a = 1;\n}\n" + assert rewrite(source) == ( + "/* Outer.\n * Outer continued.\n */\nmessage M {\n /* Inner.\n * Inner continued.\n */\n int32 a = 1;\n}\n" + ) + + +def test_keeps_license_header_when_file_starts_with_it(): + source = HEADER + "\n// Doc line one.\n// Doc line two.\nmessage M {}\n" + assert ( + rewrite(source) + == HEADER + "\n/* Doc line one.\n * Doc line two.\n */\nmessage M {}\n" + ) + + +def test_is_idempotent(): + once = rewrite("// First line.\n// Second line.\nmessage M {}\n") + assert rewrite(once) == once + + +def test_converts_a_comment_run_that_abuts_the_license_header(): + source = HEADER + "// Doc line one.\n// Doc line two.\nmessage M {}\n" + assert ( + rewrite(source) + == HEADER + "/* Doc line one.\n * Doc line two.\n */\nmessage M {}\n" + ) diff --git a/ci/test_format_vote_gate.py b/ci/test_format_vote_gate.py new file mode 100644 index 00000000000..944b5053d93 --- /dev/null +++ b/ci/test_format_vote_gate.py @@ -0,0 +1,176 @@ +"""Unit tests for the format-spec vote gate logic. + +Run with: pytest ci/test_format_vote_gate.py +""" + +from datetime import datetime, timedelta, timezone + +import pytest + +from format_vote_gate import ( + PERIOD_HOURS, + decide_verdict, + tally_reviews, + vote_opened_at, + weekday_deadline, +) + +HEAD = "sha_head" +PMC = {"alice", "bob", "carol", "dave"} + + +def is_pmc(login): + return login is not None and login.lower() in PMC + + +def review(login, state, commit_id=HEAD): + return {"login": login, "state": state, "commit_id": commit_id} + + +def test_counts_distinct_pmc_approvals_on_head_commit(): + approvals, head_approvals, vetoes = tally_reviews( + [ + review("alice", "APPROVED"), + review("bob", "APPROVED"), + review("carol", "APPROVED"), + ], + HEAD, + "author", + is_pmc, + ) + assert sorted(approvals) == ["alice", "bob", "carol"] + assert sorted(head_approvals) == ["alice", "bob", "carol"] + assert vetoes == [] + + +def test_only_latest_review_per_member_counts(): + # Alice approved, then later requested changes -> she is a veto, not approval. + approvals, _, vetoes = tally_reviews( + [review("alice", "APPROVED"), review("alice", "CHANGES_REQUESTED")], + HEAD, + "author", + is_pmc, + ) + assert approvals == [] + assert vetoes == ["alice"] + + +def test_approvals_on_earlier_commits_still_count(): + # A push doesn't send earlier voters back to re-vote; only the head-commit + # approval is tracked separately, because the gate requires one of those. + approvals, head_approvals, _ = tally_reviews( + [review("alice", "APPROVED", "old_sha"), review("bob", "APPROVED")], + HEAD, + "author", + is_pmc, + ) + assert sorted(approvals) == ["alice", "bob"] + assert head_approvals == ["bob"] + + +def test_no_head_approval_when_every_vote_predates_the_push(): + approvals, head_approvals, _ = tally_reviews( + [review("alice", "APPROVED", "old_sha"), review("bob", "APPROVED", "older")], + HEAD, + "author", + is_pmc, + ) + assert sorted(approvals) == ["alice", "bob"] + assert head_approvals == [] + + +def test_ignores_author_non_pmc_and_dismissed(): + approvals, _, vetoes = tally_reviews( + [ + review("author", "APPROVED"), # PR author, even if PMC, never counts + review("eve", "APPROVED"), # not on the PMC + review("dave", "DISMISSED"), # withdrawn + review("carol", "COMMENTED"), # a comment is not a vote + ], + HEAD, + "author", + is_pmc, + ) + assert approvals == [] + assert vetoes == [] + + +@pytest.mark.parametrize( + ("veto_count", "approvals", "head_approvals", "period_elapsed", "expected"), + [ + # A veto wins even with enough approvals, one on head, and time elapsed. + (1, 5, 1, True, "veto"), + (0, 2, 1, True, "insufficient"), + # Enough votes carried over, but nobody has approved the latest commit. + (0, 3, 0, True, "unconfirmed"), + (0, 3, 1, False, "waiting_period"), + (0, 3, 1, True, "pass"), + # The head-commit approval counts toward the three; it is not a fourth. + (0, 3, 3, True, "pass"), + ], +) +def test_decide_verdict_priority( + veto_count, approvals, head_approvals, period_elapsed, expected +): + assert ( + decide_verdict(veto_count, approvals, head_approvals, period_elapsed, 3) + == expected + ) + + +def utc(text): + return datetime.fromisoformat(text).replace(tzinfo=timezone.utc) + + +# 2026-08-03 is a Monday, so this week runs Mon 03 .. Sun 09 August. +@pytest.mark.parametrize( + ("opened", "expected"), + [ + # Fully inside a work week: a plain 72-hour offset. + ("2026-08-03T09:00", "2026-08-06T09:00"), + # Opened Friday afternoon: 7h accrue before Saturday, the remaining 65h + # resume Monday 00:00 and land Wednesday afternoon. + ("2026-08-07T17:00", "2026-08-12T17:00"), + # Opened during a weekend: the clock only starts on Monday. + ("2026-08-08T12:00", "2026-08-13T00:00"), + # Opened the instant a weekend ends. + ("2026-08-10T00:00", "2026-08-13T00:00"), + # The deadline itself lands exactly on the weekend boundary. + ("2026-08-05T00:00", "2026-08-08T00:00"), + ], +) +def test_weekday_deadline_excludes_weekends(opened, expected): + assert weekday_deadline(utc(opened), PERIOD_HOURS) == utc(expected) + + +def test_weekday_deadline_spans_multiple_weekends(): + # A period longer than one work week has to skip more than one weekend. + assert weekday_deadline(utc("2026-08-03T00:00"), 24 * 6) == utc("2026-08-11T00:00") + + +def test_weekday_deadline_converts_to_weekend_tz(): + # Late Friday in a UTC+X zone is already Saturday in UTC, so the clock waits. + friday_evening_tokyo = utc("2026-08-08T01:00").astimezone( + timezone(timedelta(hours=9)) + ) + assert weekday_deadline(friday_evening_tokyo, PERIOD_HOURS) == utc( + "2026-08-13T00:00" + ) + + +def test_vote_opens_at_the_later_of_label_and_ready(): + labeled, ready = utc("2026-08-03T09:00"), utc("2026-08-04T09:00") + assert vote_opened_at(labeled, ready) == ready + assert vote_opened_at(ready, labeled) == ready + + +@pytest.mark.parametrize( + ("labeled", "ready"), + [ + (None, utc("2026-08-03T09:00")), # not a format change (yet) + (utc("2026-08-03T09:00"), None), # still a draft + (None, None), + ], +) +def test_vote_does_not_open_until_both_conditions_hold(labeled, ready): + assert vote_opened_at(labeled, ready) is None diff --git a/ci/test_labeler_area.py b/ci/test_labeler_area.py new file mode 100644 index 00000000000..ae4b74bd806 --- /dev/null +++ b/ci/test_labeler_area.py @@ -0,0 +1,34 @@ +"""Regression tests for format-spec path classification.""" + +from pathlib import Path + +import yaml + + +ROOT = Path(__file__).parent.parent +LABELER_CONFIG = ROOT / ".github" / "labeler-area.yml" +EXECUTION_PROTO_PATHS = { + "protos/ann.proto", + "protos/filtered_read.proto", + "protos/table_identifier.proto", +} + + +def paths_for(label): + config = yaml.safe_load(LABELER_CONFIG.read_text()) + return set(config[label][0]["changed-files"][0]["any-glob-to-any-file"]) + + +def test_format_labels_use_the_same_paths(): + assert paths_for("A-format") == paths_for("format-change") + + +def test_only_persisted_protos_are_format_changes(): + detected_proto_paths = { + path for path in paths_for("format-change") if path.startswith("protos/") + } + all_proto_paths = { + path.relative_to(ROOT).as_posix() for path in (ROOT / "protos").glob("*.proto") + } + + assert detected_proto_paths == all_proto_paths - EXECUTION_PROTO_PATHS diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 00000000000..63310f898d5 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,4 @@ +disallowed-macros = [ + { path = "location", reason = "Use #[track_caller] + #[snafu(implicit)] instead of manual location construction.", allow-invalid = true }, + { path = "snafu::location", reason = "Use #[track_caller] + #[snafu(implicit)] instead of manual location construction." }, +] diff --git a/codecov.yml b/codecov.yml deleted file mode 100644 index 301f0663d39..00000000000 --- a/codecov.yml +++ /dev/null @@ -1,13 +0,0 @@ -# make all status informational -- pass no matter what -coverage: - status: - project: - default: - target: 50% - threshold: 10% - informational: true - patch: - default: - target: 50% - threshold: 10% - informational: true diff --git a/deny.toml b/deny.toml index e799d67c437..17d4b546f2d 100644 --- a/deny.toml +++ b/deny.toml @@ -80,13 +80,12 @@ ignore = [ #{ id = "RUSTSEC-0000-0000", reason = "you can specify a reason the advisory is ignored" }, #"a-crate-that-is-yanked@0.1.1", # you can also ignore yanked crate versions if you wish #{ crate = "a-crate-that-is-yanked@0.1.1", reason = "you can specify why you are ignoring the yanked crate" }, - { id = "RUSTSEC-2021-0153", reason = "`encoding` is used by lindera" }, - { id = "RUSTSEC-2024-0370", reason = "`proc-macro-error` is used by jieba-rs via include-flate" }, { id = "RUSTSEC-2024-0436", reason = "`paste` is used by datafusion" }, { id = "RUSTSEC-2023-0071", reason = "`rsa` is used by opendal via reqsign" }, { id = "RUSTSEC-2025-0119", reason = "`number_prefix` used by hf-hub in examples" }, - { id = "RUSTSEC-2025-0134", reason = "`rustls-pemfile` unmaintained; awaiting upstream object_store/hyper-rustls migration to rustls-pki-types" }, - { id = "RUSTSEC-2025-0141", reason = "`bincode` is unmaintained and used by tantivy"}, + { id = "RUSTSEC-2026-0194", reason = "`quick-xml` <0.41 pulled transitively via object_store (datafusion) and reqsign-aws-v4 (opendal); upstream must upgrade first" }, + { id = "RUSTSEC-2026-0195", reason = "`quick-xml` <0.41 pulled transitively via object_store (datafusion) and reqsign-aws-v4 (opendal); upstream must upgrade first" }, + { id = "RUSTSEC-2026-0002", reason = "`lru` 0.12.x pulled transitively via goosefs-sdk; fixed in >=0.16.3, goosefs-sdk must upgrade first" }, ] # If this is true, then cargo deny will use the git executable to fetch advisory database. # If this is false, then it uses a built-in git library. @@ -129,6 +128,7 @@ exceptions = [ # Each entry is the crate and version constraint, and its specific allow # list #{ allow = ["Zlib"], crate = "adler32" }, + { allow = ["CDDL-1.0"], crate = "inferno" }, ] # Some crates don't have (easily) machine readable licensing information, @@ -166,6 +166,10 @@ registries = [ # More documentation about the 'bans' section can be found here: # https://embarkstudios.github.io/cargo-deny/checks/bans/cfg.html [bans] +# Lint every dependency declared by a workspace member against the shared +# `[workspace.dependencies]` table: any crate used by more than one member must +# go through `workspace = true`, and entries nothing uses are an error. +workspace-dependencies = { duplicates = "deny", unused = "deny" } # Lint level for when multiple versions of the same crate are detected multiple-versions = "warn" # Lint level for when a crate version requirement is `*` diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index ec95d95e47f..bd822264494 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: @@ -18,7 +18,7 @@ uv run mkdocs serve ### Python Generated Doc -Python code documentation is built using Sphinx in [lance-python-doc](https://github.com/lancedb/lance-python-doc), +Python code documentation is built using Sphinx in [lance-python-doc](https://github.com/lance-format/lance-python-doc), and published through [Github Pages](https://lance-format.github.io/lance-python-doc/) in ReadTheDocs style. ### Rust Generated Doc diff --git a/docs/Makefile b/docs/Makefile index 9f44b4de2e8..08f5108b770 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,9 +1,13 @@ -.PHONY: help serve build clean install dev-install check-links sync +.PHONY: help serve build clean check-links sync make-full-website clean-full-website help: @echo "Available commands:" @echo " make serve - Serve documentation locally (auto-installs deps)" @echo " make build - Build documentation (auto-installs deps)" + @echo " make make-full-website - Assemble the full website from local repo checkouts" + @echo " make clean-full-website - Remove generated website content" + @echo " Run 'make make-full-website' before 'make build' or 'make serve' for the full multi-repo site" + @echo " Override any repo with env vars like LANCE_NAMESPACE_REPO=..., LANCE_SPARK_REPO=..., LANCE_RAY_REPO=..." @echo " make clean - Clean build artifacts" @echo " make check-links - Check for broken links" @echo " make sync - Sync dependencies with pyproject.toml" @@ -11,12 +15,18 @@ help: sync: uv sync --all-extras -serve: +serve: make-full-website uv run mkdocs serve -build: +build: make-full-website uv run mkdocs build +make-full-website: + ./make-full-website.sh + +clean-full-website: + ./clean-full-website.sh + clean: rm -rf site/ rm -rf .cache/ diff --git a/docs/README.md b/docs/README.md index 80092b157c7..8cb33f86387 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,7 @@ # Lance Documentation -This directory contains the documentation for Lance, built with MkDocs and Material theme. +This directory contains the documentation for Lance, built with MkDocs and a +custom theme (`theme/`) implementing the Lance Docs design. ## Getting Started with uv @@ -62,5 +63,18 @@ uv sync --upgrade ## Project Structure - `src/` - Source markdown files for documentation +- `theme/` - Custom MkDocs theme implementing the Lance Docs design + (templates in `*.html`, styles/behaviour in `theme/assets/`) - `mkdocs.yml` - MkDocs configuration - `pyproject.toml` - Python project configuration (uv compatible) + +## Theme Notes + +- Light/dark mode follows `prefers-color-scheme`, is toggleable from the + header, and persists in `localStorage` (`ld-theme`). +- The GitHub star count is fetched from the public GitHub API and cached in + `localStorage` for an hour; the button degrades to a plain link offline. +- Search is a lightweight overlay (`/` or `Cmd/Ctrl+K`) over the standard + `search` plugin index — no external search dependencies. +- Mermaid diagrams render client-side; the library is loaded from a CDN only + on pages that contain a diagram. diff --git a/docs/clean-full-website.sh b/docs/clean-full-website.sh new file mode 100755 index 00000000000..3ffa8ffe15b --- /dev/null +++ b/docs/clean-full-website.sh @@ -0,0 +1,181 @@ +#!/usr/bin/env bash +set -euo pipefail + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "$script_dir/.." && pwd) +docs_src="$script_dir/src" + +rm -rf "$docs_src/format/catalog" +rm -rf "$docs_src/format/namespace" +rm -f "$docs_src/format/layout.png" +rm -f "$docs_src/format/overview.png" +rm -f "$docs_src/format/java-sdk-example.png" +rm -rf "$docs_src/integrations/huggingface" +rm -rf "$docs_src/integrations/duckdb" +rm -rf "$docs_src/integrations/spark" +rm -rf "$docs_src/integrations/ray" +rm -rf "$docs_src/integrations/trino" +rm -rf "$docs_src/integrations/context" +rm -f "$docs_src/community/project-specific/.pages" +rm -rf "$docs_src/community/project-specific/lance" +rm -f "$docs_src/community/project-specific/namespace.md" +rm -f "$docs_src/community/project-specific/namespace-impls.md" +rm -f "$docs_src/community/project-specific/ray.md" +rm -f "$docs_src/community/project-specific/spark.md" +rm -f "$docs_src/community/project-specific/trino.md" +rm -f "$docs_src/community/project-specific/context.md" + +cat > "$docs_src/format/.pages" <<'EOF' +nav: + - Overview: index.md + - File Format: file + - Table Format: table + - Index Formats: index + - Catalog Specs: catalog + - Namespace Client Spec: namespace +EOF + +cat > "$docs_src/integrations/.pages" <<'EOF' +nav: + - Overview: index.md + - Apache DataFusion: datafusion.md + - PostgreSQL: https://github.com/lance-format/pglance + - PyTorch: pytorch.md + - TensorFlow: tensorflow.md +EOF + +mkdir -p "$docs_src/format/catalog/dir" +mkdir -p "$docs_src/format/catalog/rest" +mkdir -p "$docs_src/format/namespace/operations/models" +mkdir -p "$docs_src/format/namespace/supported-catalogs" + +cat > "$docs_src/format/catalog/.pages" <<'EOF' +title: Catalog Specs +nav: + - Overview: index.md + - Directory Catalog: dir + - REST Catalog: rest +EOF + +cat > "$docs_src/format/catalog/index.md" <<'EOF' +# Catalog Specs + +This section describes how Lance catalogs organize, discover, and coordinate Lance tables. + +When a local `lance-namespace` checkout with split catalog docs is available, `docs/make-full-website.sh` replaces these placeholders with the latest source content. + +See also: + +- [Directory Catalog](dir/index.md) +- [REST Catalog](rest/index.md) +- [Namespace Client Spec](../namespace/index.md) +EOF + +cat > "$docs_src/format/catalog/dir/index.md" <<'EOF' +# Directory Catalog + +The Directory Catalog is the storage-native catalog format for Lance. + +This placeholder page keeps the local website buildable when external catalog docs are not available. +Run `docs/make-full-website.sh` with `LANCE_NAMESPACE_REPO` pointing at a split `lance-namespace` checkout to populate the full specification. +EOF + +cat > "$docs_src/format/catalog/rest/index.md" <<'EOF' +# REST Catalog + +The REST Catalog is the service-oriented catalog specification for Lance. + +This placeholder page keeps the local website buildable when external catalog docs are not available. +Run `docs/make-full-website.sh` with `LANCE_NAMESPACE_REPO` pointing at a split `lance-namespace` checkout to populate the full specification. +EOF + +cat > "$docs_src/format/namespace/.pages" <<'EOF' +title: Namespace Client Spec +nav: + - Overview: index.md + - Objects & Relationships: object-relationship.md + - Operations: operations + - Supported Catalogs: supported-catalogs +EOF + +cat > "$docs_src/format/namespace/index.md" <<'EOF' +# Namespace Client Spec + +The Lance Namespace Client Spec defines the interface that engines and tools use to discover tables, resolve locations, and coordinate table operations through catalogs. + +When a local `lance-namespace` checkout with split namespace docs is available, `docs/make-full-website.sh` replaces these placeholders with the latest source content. + +See also: + +- [Objects & Relationships](object-relationship.md) +- [Operations](operations/index.md) +- [Supported Catalogs](supported-catalogs/index.md) +EOF + +cat > "$docs_src/format/namespace/object-relationship.md" <<'EOF' +# Objects & Relationships + +This placeholder page keeps the local website buildable when external namespace docs are not available. + +Run `docs/make-full-website.sh` with `LANCE_NAMESPACE_REPO` pointing at a split `lance-namespace` checkout to populate the full object model description. +EOF + +cat > "$docs_src/format/namespace/operations/.pages" <<'EOF' +title: Operations +nav: + - Overview: index.md + - Models: models +EOF + +cat > "$docs_src/format/namespace/operations/index.md" <<'EOF' +# Operations + +This placeholder page keeps the local website buildable when external namespace docs are not available. + +Run `docs/make-full-website.sh` with `LANCE_NAMESPACE_REPO` pointing at a split `lance-namespace` checkout to populate the operation reference. +EOF + +cat > "$docs_src/format/namespace/operations/models/.pages" <<'EOF' +title: Models +EOF + +cat > "$docs_src/format/namespace/operations/models/index.md" <<'EOF' +# Operation Models + +This placeholder page keeps the local website buildable when external namespace docs are not available. +EOF + +cat > "$docs_src/format/namespace/supported-catalogs/.pages" <<'EOF' +title: Supported Catalogs +nav: + - Overview: index.md + - Lance Directory Catalog: lance-dir.md + - Lance REST Catalog: lance-rest.md + - Template: template.md +EOF + +cat > "$docs_src/format/namespace/supported-catalogs/index.md" <<'EOF' +# Supported Catalogs + +This placeholder page keeps the local website buildable when external namespace docs are not available. + +Run `docs/make-full-website.sh` with `LANCE_NAMESPACE_REPO` and `LANCE_NAMESPACE_IMPLS_REPO` set to local checkouts to populate the full integration catalog list. +EOF + +cat > "$docs_src/format/namespace/supported-catalogs/lance-dir.md" <<'EOF' +# Lance Directory Catalog + +This placeholder page keeps the local website buildable when external namespace docs are not available. +EOF + +cat > "$docs_src/format/namespace/supported-catalogs/lance-rest.md" <<'EOF' +# Lance REST Catalog + +This placeholder page keeps the local website buildable when external namespace docs are not available. +EOF + +cat > "$docs_src/format/namespace/supported-catalogs/template.md" <<'EOF' +# Template + +This placeholder page keeps the local website buildable when external namespace docs are not available. +EOF diff --git a/docs/hooks/pmc_roster.py b/docs/hooks/pmc_roster.py new file mode 100644 index 00000000000..3a56daa2b5d --- /dev/null +++ b/docs/hooks/pmc_roster.py @@ -0,0 +1,46 @@ +"""MkDocs hook: render the PMC roster table from `pmc.yaml` at build time. + +`docs/src/community/pmc.yaml` is the source of truth for the PMC roster (it also +drives the format-spec vote gate). The roster page contains the placeholder +`<!-- PMC_ROSTER_TABLE -->`; this hook expands it into a Markdown table when the +docs are built, so the table never has to be maintained by hand. + +Registered via `hooks:` in `mkdocs.yml`. +""" + +import pathlib + +import yaml + +PLACEHOLDER = "<!-- PMC_ROSTER_TABLE -->" + +COLUMNS = [ + ("Name", "name"), + ("GitHub Handle", "handle"), + ("Affiliation", "affiliation"), + ("Ecosystem Roles", "ecosystem_roles"), +] + + +def _render_table(members): + headers = [title for title, _ in COLUMNS] + rows = [[str(m.get(key, "") or "") for _, key in COLUMNS] for m in members] + widths = [ + max([len(headers[i])] + [len(row[i]) for row in rows]) + for i in range(len(COLUMNS)) + ] + + def row(cells): + return "| " + " | ".join(c.ljust(widths[i]) for i, c in enumerate(cells)) + " |" + + lines = [row(headers), "|" + "|".join("-" * (w + 2) for w in widths) + "|"] + lines.extend(row(r) for r in rows) + return "\n".join(lines) + + +def on_page_markdown(markdown, page, config, files): + if PLACEHOLDER not in markdown: + return markdown + roster_path = pathlib.Path(config["docs_dir"]) / "community" / "pmc.yaml" + roster = yaml.safe_load(roster_path.read_text()) + return markdown.replace(PLACEHOLDER, _render_table(roster["members"])) diff --git a/docs/make-full-website.sh b/docs/make-full-website.sh new file mode 100755 index 00000000000..414b3831fb0 --- /dev/null +++ b/docs/make-full-website.sh @@ -0,0 +1,336 @@ +#!/usr/bin/env bash +set -euo pipefail + +usage() { + cat >&2 <<'EOF' +Usage: make-full-website.sh + +Override any repo path with the matching environment variable: + LANCE_NAMESPACE_REPO + LANCE_NAMESPACE_IMPLS_REPO + LANCE_SPARK_REPO + LANCE_RAY_REPO + LANCE_TRINO_REPO + LANCE_DUCKDB_REPO + LANCE_HUGGINGFACE_REPO + LANCE_CONTEXT_REPO +Defaults: + LANCE_NAMESPACE_REPO=$HOME/oss/lance-namespace + LANCE_NAMESPACE_IMPLS_REPO=$HOME/oss/lance-namespace-impls + LANCE_SPARK_REPO=$HOME/oss/lance-spark + LANCE_RAY_REPO=$HOME/oss/lance-ray + LANCE_TRINO_REPO=$HOME/oss/lance-trino + LANCE_DUCKDB_REPO=$HOME/oss/lance-duckdb + LANCE_HUGGINGFACE_REPO=$HOME/oss/lance-huggingface + LANCE_CONTEXT_REPO=$HOME/oss/lance-context +EOF +} + +normalize_path() { + local path="$1" + + case "$path" in + \~) + printf '%s\n' "$HOME" + ;; + \~/*) + printf '%s\n' "$HOME/${path:2}" + ;; + *) + printf '%s\n' "$path" + ;; + esac +} + +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +repo_root=$(cd -- "$script_dir/.." && pwd) +docs_src="$script_dir/src" + +if [ "$#" -gt 0 ]; then + if [ "$#" -eq 1 ] && [ "$1" = "--help" ]; then + usage + exit 0 + fi + usage + exit 1 +fi + +namespace_repo_input=${LANCE_NAMESPACE_REPO:-$HOME/oss/lance-namespace} +namespace_impls_repo_input=${LANCE_NAMESPACE_IMPLS_REPO:-$HOME/oss/lance-namespace-impls} +spark_repo_input=${LANCE_SPARK_REPO:-$HOME/oss/lance-spark} +ray_repo_input=${LANCE_RAY_REPO:-$HOME/oss/lance-ray} +trino_repo_input=${LANCE_TRINO_REPO:-$HOME/oss/lance-trino} +duckdb_repo_input=${LANCE_DUCKDB_REPO:-$HOME/oss/lance-duckdb} +huggingface_repo_input=${LANCE_HUGGINGFACE_REPO:-$HOME/oss/lance-huggingface} +context_repo_input=${LANCE_CONTEXT_REPO:-$HOME/oss/lance-context} + +copy_docs_dir() { + local source_dir="$1" + local target_dir="$2" + + if [ -d "$source_dir" ]; then + mkdir -p "$(dirname "$target_dir")" + rm -rf "$target_dir" + cp -R "$source_dir" "$target_dir" + return 0 + fi + + return 1 +} + +copy_file_if_exists() { + local source_file="$1" + local target_file="$2" + + if [ -f "$source_file" ]; then + mkdir -p "$(dirname "$target_file")" + cp "$source_file" "$target_file" + return 0 + fi + + return 1 +} + +copy_duckdb_docs() { + local source_dir="$1" + local target_dir="$2" + + if [ ! -d "$source_dir" ]; then + return 1 + fi + + rm -rf "$target_dir" + mkdir -p "$target_dir" + cp "$source_dir/.pages" "$target_dir/.pages" + cp "$source_dir/index.md" "$target_dir/index.md" + cp "$source_dir/sql.md" "$target_dir/sql.md" + cp "$source_dir/rest.md" "$target_dir/rest.md" + cp "$source_dir/cloud.md" "$target_dir/cloud.md" + + return 0 +} + +resolve_repo_dir() { + local repo_path="$1" + + repo_path=$(normalize_path "$repo_path") + + if [ -d "$repo_path" ]; then + (cd -- "$repo_path" && pwd) + return + fi + + printf '%s\n' "$repo_path" +} + +warn_missing_repo() { + local repo_label="$1" + local repo_path="$2" + + echo "Warning: $repo_label repo not found at '$repo_path'; keeping placeholder docs." >&2 +} + +namespace_repo=$(resolve_repo_dir "$namespace_repo_input") +namespace_impls_repo=$(resolve_repo_dir "$namespace_impls_repo_input") +spark_repo=$(resolve_repo_dir "$spark_repo_input") +ray_repo=$(resolve_repo_dir "$ray_repo_input") +trino_repo=$(resolve_repo_dir "$trino_repo_input") +duckdb_repo=$(resolve_repo_dir "$duckdb_repo_input") +huggingface_repo=$(resolve_repo_dir "$huggingface_repo_input") +context_repo=$(resolve_repo_dir "$context_repo_input") + +"$script_dir/clean-full-website.sh" + +if copy_docs_dir "$namespace_repo/docs/src/catalog" "$docs_src/format/catalog"; then + : +else + warn_missing_repo "Lance Namespace catalog docs" "$namespace_repo/docs/src/catalog" +fi + +if copy_docs_dir "$namespace_repo/docs/src/namespace" "$docs_src/format/namespace"; then + copy_file_if_exists "$namespace_repo/docs/src/overview.png" "$docs_src/format/overview.png" + copy_file_if_exists "$namespace_repo/docs/src/java-sdk-example.png" "$docs_src/format/java-sdk-example.png" + : +else + warn_missing_repo "Lance Namespace namespace docs" "$namespace_repo/docs/src/namespace" +fi + +if [ -f "$docs_src/format/namespace/operations/index.md" ]; then +python3 - <<'PY' "$docs_src/format/namespace/operations/index.md" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +text = path.read_text() +path.write_text(text.replace('[Models](models/)', '[Models](models/index.md)')) +PY +fi + +mkdir -p "$docs_src/format/namespace/operations/models" +cat > "$docs_src/format/namespace/operations/models/index.md" <<'EOF' +# Operation Models + +This section contains the generated request and response models for the Lance Namespace operations. + +These pages define the JSON schemas referenced by the [operations overview](../index.md) and the [REST Catalog API specification](../../../catalog/rest/index.md). +EOF + +supported_catalogs_dir="$docs_src/format/namespace/supported-catalogs" +if [ -d "$namespace_impls_repo/docs/src" ]; then +while IFS= read -r -d '' source_file; do + target_file="$supported_catalogs_dir/$(basename "$source_file")" + if [ -e "$target_file" ]; then + echo "Refusing to overwrite existing supported catalog file: $target_file" >&2 + exit 1 + fi + cp "$source_file" "$target_file" +done < <(find "$namespace_impls_repo/docs/src" -maxdepth 1 -type f -name '*.md' ! -name 'index.md' -print0 | sort -z) + +python3 - <<'PY' "$namespace_impls_repo/docs/src/.pages" "$supported_catalogs_dir/.pages" +from pathlib import Path +import sys + +impl_pages = Path(sys.argv[1]).read_text().splitlines() +target = Path(sys.argv[2]) +existing = target.read_text().splitlines() + +impl_entries = [] +for line in impl_pages: + stripped = line.strip() + if not stripped.startswith('- '): + continue + entry = stripped[2:] + if entry == 'Introduction: index.md': + continue + impl_entries.append(f' - {entry}') + +output = [] +inserted = False +for line in existing: + # Keep namespace-owned entries first and insert impl-owned entries immediately + # before the template item when it exists. + if line.strip() == '- Template: template.md' and not inserted: + output.extend(impl_entries) + inserted = True + output.append(line) + +if not inserted: + output.extend(impl_entries) + +target.write_text('\n'.join(output) + '\n') +PY +else + warn_missing_repo "Lance Namespace Impls docs" "$namespace_impls_repo/docs/src" +fi + +integration_entries=( +) + +while IFS= read -r line; do + integration_entries+=("$line") +done < <(sed -n '2,$p' "$docs_src/integrations/.pages") + +if copy_duckdb_docs "$duckdb_repo/docs" "$docs_src/integrations/duckdb"; then + integration_entries+=(" - DuckDB: duckdb") +else + warn_missing_repo "Lance DuckDB docs" "$duckdb_repo/docs" +fi + +if copy_docs_dir "$huggingface_repo/docs/src" "$docs_src/integrations/huggingface"; then + integration_entries+=(" - Huggingface: huggingface") +else + warn_missing_repo "Lance HuggingFace docs" "$huggingface_repo/docs/src" +fi + +if copy_docs_dir "$spark_repo/docs/src" "$docs_src/integrations/spark"; then + python3 - <<'PY' "$docs_src/integrations/spark/operations/ddl/create-index.md" +from pathlib import Path +import sys + +path = Path(sys.argv[1]) +if path.exists(): + text = path.read_text() + path.write_text(text.replace('https://lance.org/format/table/index/scalar/fts/#tokenizers', 'https://lance.org/format/index/scalar/fts/#tokenizers')) +PY + integration_entries+=(" - Apache Spark: spark") +else + warn_missing_repo "Lance Spark docs" "$spark_repo/docs/src" +fi + +if copy_docs_dir "$ray_repo/docs/src" "$docs_src/integrations/ray"; then + integration_entries+=(" - Ray: ray") +else + warn_missing_repo "Lance Ray docs" "$ray_repo/docs/src" +fi + +if copy_docs_dir "$trino_repo/docs/src" "$docs_src/integrations/trino"; then + integration_entries+=(" - Trino: trino") +else + warn_missing_repo "Lance Trino docs" "$trino_repo/docs/src" +fi + +if copy_docs_dir "$context_repo/docs/src" "$docs_src/integrations/context"; then + integration_entries+=(" - Lance Context: context") +else + warn_missing_repo "Lance Context docs" "$context_repo/docs/src" +fi + +{ + echo "nav:" + for entry in "${integration_entries[@]}"; do + [ -n "$entry" ] || continue + echo "$entry" + done +} > "$docs_src/integrations/.pages" + +mkdir -p "$docs_src/community/project-specific/lance" +copy_file_if_exists "$repo_root/CONTRIBUTING.md" "$docs_src/community/project-specific/lance/general.md" +copy_file_if_exists "$repo_root/release_process.md" "$docs_src/community/project-specific/lance/release.md" +copy_file_if_exists "$repo_root/rust/CONTRIBUTING.md" "$docs_src/community/project-specific/lance/rust.md" +copy_file_if_exists "$repo_root/python/CONTRIBUTING.md" "$docs_src/community/project-specific/lance/python.md" +copy_file_if_exists "$repo_root/docs/CONTRIBUTING.md" "$docs_src/community/project-specific/lance/docs.md" + +project_entries=( + " - index.md" + " - Lance: lance" +) + +if copy_file_if_exists "$namespace_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/namespace.md"; then + project_entries+=(" - Lance Namespace: namespace.md") +fi + +if copy_file_if_exists "$namespace_impls_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/namespace-impls.md"; then + project_entries+=(" - Lance Namespace Impls: namespace-impls.md") +fi + +if copy_file_if_exists "$ray_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/ray.md"; then + project_entries+=(" - Lance Ray: ray.md") +fi + +if copy_file_if_exists "$spark_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/spark.md"; then + project_entries+=(" - Lance Spark: spark.md") +fi + +if copy_file_if_exists "$trino_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/trino.md"; then + project_entries+=(" - Lance Trino: trino.md") +fi + +if copy_file_if_exists "$context_repo/CONTRIBUTING.md" "$docs_src/community/project-specific/context.md"; then + project_entries+=(" - Lance Context: context.md") +fi + +{ + echo "nav:" + for entry in "${project_entries[@]}"; do + echo "$entry" + done +} > "$docs_src/community/project-specific/.pages" + +cat > "$docs_src/community/project-specific/lance/.pages" <<'EOF' +nav: + - General: general.md + - Release: release.md + - Rust: rust.md + - Python: python.md + - Docs: docs.md +EOF diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 2a84cf12213..d872d749ff2 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -1,42 +1,18 @@ 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 repo_name: lance-format/lance repo_url: https://github.com/lance-format/lance +# Custom theme implementing the "Lance Docs" design (see theme/). theme: - name: material - custom_dir: overrides - logo: logo/white.png - favicon: logo/logo.png - palette: - - scheme: default - primary: custom - accent: custom - toggle: - icon: material/brightness-7 - name: Switch to dark mode - - scheme: slate - primary: custom - accent: custom - toggle: - icon: material/brightness-4 - name: Switch to light mode - features: - - navigation.tabs - - navigation.sections - - navigation.instant - - navigation.indexes - - navigation.tracking - - navigation.top - - search.highlight - - search.share - - content.code.copy - - content.code.annotate - icon: - repo: fontawesome/brands/github + name: null + custom_dir: theme + locale: en + static_templates: + - 404.html markdown_extensions: - admonition @@ -51,9 +27,16 @@ markdown_extensions: line_spans: __span pygments_lang_class: true - pymdownx.inlinehilite - - pymdownx.snippets + - pymdownx.snippets: + # Allow snippets to pull in files from the repo root (e.g. shared docs + # kept alongside Rust source). Paths are relative to the docs/ dir. + base_path: + - . + - .. - pymdownx.tabbed: alternate_style: true + # Autolink bare URLs (e.g. repository tables on the community pages). + - pymdownx.magiclink - attr_list - md_in_html - tables @@ -66,20 +49,7 @@ plugins: - mkdocs_protobuf: proto_dir: ../protos -extra: - generator: false - social: - - icon: fontawesome/brands/github - link: https://github.com/lance-format/lance - - icon: fontawesome/brands/discord - link: https://discord.gg/lance +hooks: + - hooks/pmc_roster.py 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/overrides/home.html b/docs/overrides/home.html deleted file mode 100644 index 3322267f25e..00000000000 --- a/docs/overrides/home.html +++ /dev/null @@ -1,295 +0,0 @@ -{% extends "main.html" %} - -{% block tabs %} - {{ super() }} - - <style> - /* Prevent horizontal overflow */ - body { - overflow-x: hidden; - } - - /* Hide main content for home page */ - .md-content { - display: none; - } - - /* Hide table of contents */ - @media screen and (min-width: 60em) { - .md-sidebar--secondary { - display: none; - } - } - - /* Hide navigation */ - @media screen and (min-width: 76.25em) { - .md-sidebar--primary { - display: none; - } - } - - /* Make header static */ - .md-header { - position: initial; - } - - .md-main__inner { - margin: 0; - } - - /* Style Learn More and View Integrations links */ - .lance-feature-section .md-button, - .lance-intro-section .md-button:not(.md-button--primary) { - background-color: transparent; - color: inherit; - text-decoration: none; - border: none; - box-shadow: none; - } - - .lance-feature-section .md-button:hover, - .lance-intro-section .md-button:not(.md-button--primary):hover { - background-color: transparent; - color: #625EFF; - text-decoration: none; - } - </style> - - <script> - // Fix palette toggle jumping to bottom of page - (function() { - // Store scroll position - let savedScrollY = 0; - let isTogglingPalette = false; - - // Prevent hash-based scrolling for palette changes - if ('scrollRestoration' in history) { - history.scrollRestoration = 'manual'; - } - - // Intercept scroll attempts during palette toggle - const preventScroll = function() { - if (isTogglingPalette) { - window.scrollTo(0, savedScrollY); - } - }; - - // Save scroll position and intercept clicks on palette toggle - document.addEventListener('click', function(e) { - const paletteLabel = e.target.closest('[for^="__palette"]'); - if (paletteLabel) { - savedScrollY = window.scrollY; - isTogglingPalette = true; - - // Prevent any scrolling for a brief moment - window.addEventListener('scroll', preventScroll, { passive: false }); - - setTimeout(function() { - isTogglingPalette = false; - window.removeEventListener('scroll', preventScroll); - }, 100); - } - }, true); - - // Listen for hash changes from palette toggle - window.addEventListener('hashchange', function(e) { - if (window.location.hash.startsWith('#__palette')) { - e.preventDefault(); - // Immediately restore scroll position - window.scrollTo(0, savedScrollY); - // Clean up hash without triggering another hashchange - history.replaceState(null, null, window.location.pathname); - } - }, false); - - // Also handle palette input changes directly - document.addEventListener('DOMContentLoaded', function() { - const paletteInputs = document.querySelectorAll('input[name="__palette"]'); - paletteInputs.forEach(function(input) { - input.addEventListener('change', function() { - window.scrollTo(0, savedScrollY); - }); - }); - }); - })(); - </script> - - <!-- Hero Section --> - <section class="mdx-container"> - <div class="container"> - <div class="intro-message"> - <div class="hero-logo"> - <img src="logo/white.png" alt="Lance Logo"> - <h1>Lance<sup>™</sup></h1> - </div> - <h3>The Open Lakehouse Format for Multimodal AI</h3> - <hr class="intro-divider" /> - <ul class="list-inline"> - <li> - <a href="quickstart" class="md-button md-button--primary">Get Started</a> - </li> - <li> - <a href="format" class="md-button">Read the Spec</a> - </li> - <li> - <a href="examples/python/llm_training" class="md-button">Train an LLM</a> - </li> - <li> - <a href="https://discord.gg/lance" class="md-button" target="_blank" rel="noopener">Join Discord</a> - </li> - </ul> - </div> - </div> - </section> - - <!-- What is Lance Section --> - <section class="lance-intro-section"> - <div class="container"> - <div class="lance-intro-content"> - <h2>What is Lance?</h2> - <p> - Lance is a modern, open source lakehouse format for multimodal AI. It contains a file format, table format, and catalog spec, - allowing you to build a complete open lakehouse on top of object storage to power your AI workflows. - Lance brings high-performance vector search, full-text search, random access, and feature - engineering capabilities to the lakehouse, while you can still get all the existing lakehouse benefits - like SQL analytics, ACID transactions, time travel, and integrations with open engines (Apache Spark, Ray, PyTorch, Trino, DuckDB, etc.) - and open catalogs (Apache Polaris, Unity Catalog, Apache Gravitino, Hive Metastore, etc.) - </p> - <p> - Learn more about Lance's technical details by reading our - <a href="https://arxiv.org/abs/2504.15247" class="lance-paper-link" target="_blank" rel="noopener">research paper</a> - published at <em>VLDB 2025</em>. - </p> - <a href="quickstart" class="md-button md-button--primary">Read the Docs</a> - </div> - </div> - </section> - - <!-- Feature 1: Expressive Hybrid Search --> - <section class="lance-feature-section"> - <div class="container"> - <div class="lance-feature-content"> - <div class="lance-feature-text"> - <h2>Expressive Hybrid Search</h2> - <p> - Lance enables powerful hybrid search combining vector similarity, full-text search, - and SQL analytics on the same dataset. All query types are accelerated by corresponding - secondary indexes as part of the Lance specification. - </p> - <p> - Run semantic search on embeddings, BM25 search on keywords, and apply complex SQL predicates - - all using a single table with a unified interface. - </p> - <a href="quickstart/vector-search" class="md-button">Learn More</a> - </div> - <div class="lance-feature-demo"> - <img src="assets/images/hybrid-search.png" alt="Hybrid Search Example" style="max-width: 500px; width: 100%; height: auto; border-radius: 8px;"> - </div> - </div> - </div> - </section> - - <!-- Feature 2: Lightning-fast Random Access --> - <section class="lance-feature-section reverse"> - <div class="container"> - <div class="lance-feature-content"> - <div class="lance-feature-text"> - <h2>Lightning-fast Random Access</h2> - <p> - Lance delivers 100x faster random access compared to Parquet or Iceberg. - Unlike traditional formats, Lance maintains high performance even when - randomly accessing scattered rows across your entire dataset. - </p> - <p> - With a highly optimized file format plus efficient row-addressing and secondary indexes at table level, - you can access individual records across multiple files instantly, - making it perfect for real-time ML serving, random sampling, and interactive applications. - </p> - <a href="guide/read_and_write#random-access" class="md-button">Learn More</a> - </div> - <div class="lance-feature-demo"> - <img src="assets/images/random-access.png" alt="Random Access Example" style="max-width: 500px; width: 100%; height: auto; border-radius: 8px;"> - </div> - </div> - </div> - </section> - - <!-- Feature 3: Native Multimodal Data Support --> - <section class="lance-feature-section"> - <div class="container"> - <div class="lance-feature-content"> - <div class="lance-feature-text"> - <h2>Native Multimodal Data Support</h2> - <p> - Store images, videos, audio, text, and embeddings alongside your traditional tabular data in a single unified format. - Lance's blob encoding efficiently handles large binary objects with lazy loading, - while optimized vector storage accelerates similarity search. - </p> - <p> - Perfect for AI/ML workloads where you need to store raw data, ML features, generated captions and embeddings - all together for multimodal retrieval and genAI workflows. - </p> - <a href="guide/blob" class="md-button">Learn More</a> - </div> - <div class="lance-feature-demo"> - <img src="assets/images/multimodal-data.png" alt="Multimodal Data Example" style="max-width: 500px; width: 100%; height: auto; border-radius: 8px;"> - </div> - </div> - </div> - </section> - - <!-- Feature 4: Data Evolution --> - <section class="lance-feature-section reverse"> - <div class="container"> - <div class="lance-feature-content"> - <div class="lance-feature-text"> - <h2>Data Evolution > Schema Evolution</h2> - <p> - Schema evolution in most open table formats are metadata only and fast. - But when trying to backfill column values in existing rows, a full table rewrite is typically required. - Lance supports data evolution (efficient schema evolution with backfill), making it perfect for ML - feature engineering, embedding and media content management. - </p> - <p> - Adding a new column with data is as simple as writing new Lance files to the Lance table - - no need to rewrite your entire dataset. - </p> - <a href="guide/data_evolution" class="md-button">Learn More</a> - </div> - <div class="lance-feature-demo"> - <img src="assets/images/data-evolution.png" alt="Data Evolution Example" style="max-width: 500px; width: 100%; height: auto; border-radius: 8px;"> - </div> - </div> - </div> - </section> - - <!-- Feature 5: Rich Ecosystem Integration --> - <section class="lance-feature-section"> - <div class="container"> - <div class="lance-feature-content"> - <div class="lance-feature-text"> - <h2>Rich Ecosystem Integrations</h2> - <p> - As an open format, Lance integrates seamlessly with the Python data ecosystem and modern data platforms. - Work with your favorite tools including Pandas, Polars, Ray and PyTorch for data processing and machine learning. - </p> - <p> - Connect with leading query engines like Apache DataFusion, DuckDB, Apache Spark, Trino, and Apache Flink/Fluss - to run SQL analytics and distributed processing on your Lance datasets. - </p> - <a href="integrations/datafusion" class="md-button">View Integrations</a> - </div> - <div class="lance-feature-demo"> - <img src="assets/images/ecosystem-integrations.png" alt="Lance Ecosystem Integrations" style="max-width: 500px; width: 100%; height: auto; border-radius: 8px;"> - </div> - </div> - </div> - </section> - - -{% endblock %} - -{% block content %}{% endblock %} -{% block footer %} - {{ super() }} -{% endblock %} diff --git a/docs/pyproject.toml b/docs/pyproject.toml index 7ef4e59666c..3aa6847e4f3 100644 --- a/docs/pyproject.toml +++ b/docs/pyproject.toml @@ -3,13 +3,15 @@ name = "lance-docs" version = "0.1.0" description = "Documentation for Lance - Modern columnar data format for ML and LLMs" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10,<3.11" dependencies = [ "mkdocs>=1.5.0", - "mkdocs-material>=9.4.0", + "pymdown-extensions>=10.0", + "pygments>=2.16", "mkdocs-protobuf>=0.1.0", "mkdocs-linkcheck>=1.0.0", - "mkdocs-awesome-pages-plugin>=2.10.1" + "mkdocs-awesome-pages-plugin>=2.10.1", + "requests>=2.31.0" # mkdocs-linkcheck imports it but doesn't declare it ] [tool.uv] 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 <ines@ines.io> - * @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 = `<span ${this._attributes(line)}>${line.value || ''}</span>`; - - 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 deleted file mode 100644 index 086b6b43be6..00000000000 --- a/docs/src/assets/stylesheets/home.css +++ /dev/null @@ -1,329 +0,0 @@ -/* Lance Homepage Styles */ - -/* Override with custom color #625EFF site-wide */ -:root > * { - --md-primary-fg-color: #625EFF; - --md-primary-fg-color--light: #8481FF; - --md-primary-fg-color--dark: #4A46CC; - --md-accent-fg-color: #625EFF; - --md-accent-fg-color--transparent: rgba(98, 94, 255, 0.1); -} - -* { - box-sizing: border-box; -} - -.container { - width: 100%; - max-width: 1140px; - margin-right: auto; - margin-left: auto; - padding-right: 15px; - padding-left: 15px; -} - -/* Hero Section - Fullscreen with Background Image */ -.mdx-container { - text-align: center; - color: #f8f8f8; - background: url("../images/lance-mj.png") no-repeat center center; - background-size: cover; - min-height: 100vh; - height: 100vh; - display: flex; - align-items: center; - justify-content: center; -} - -.intro-message { - position: relative; - padding: 40px 20px; - font-family: "Lato", -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; - max-width: 1000px; - margin: 0 auto; -} - -.hero-logo { - display: inline-flex; - align-items: center; - margin-bottom: 16px; -} - -.hero-logo img { - height: 120px; - width: auto; - margin-right: 24px; - margin-top: 12px; - filter: drop-shadow(3px 3px 8px rgba(0, 0, 0, 0.9)); -} - -.intro-message h1 { - font-weight: 400; - margin: 0; - display: inline-block; - text-shadow: 3px 3px 8px rgba(0, 0, 0, 0.9), 1px 1px 3px rgba(0, 0, 0, 1); - font-size: 8em; - line-height: 1.2; - color: #ffffff; - vertical-align: middle; -} - -.intro-message h1 sup { - font-size: 2rem; - text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.9); -} - -.intro-message h3 { - font-size: 1.1rem; - text-shadow: 2px 2px 6px rgba(0, 0, 0, 0.9), 1px 1px 3px rgba(0, 0, 0, 1); - font-weight: 600; - margin-bottom: 32px; - color: #ffffff; -} - -.intro-divider { - width: 400px; - max-width: 80%; - border-top: 1px solid rgba(255, 255, 255, 0.8); - border-bottom: 1px solid rgba(0, 0, 0, 0.2); - margin: 24px auto; -} - -.list-inline { - padding-left: 0; - margin-left: -5px; - list-style: none; - margin-bottom: 0; -} - -.list-inline li { - display: inline-block; - padding-right: 5px; - padding-left: 5px; -} - -.intro-message .md-button { - margin: 8px; - padding: 14px 36px; - font-size: 1.1rem; - font-weight: 600; - text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.8); - transition: all 0.3s ease; -} - -.intro-message .md-button:hover { - transform: translateY(-2px); - box-shadow: 0 8px 16px rgba(0, 0, 0, 0.3); -} - -.intro-message .md-button--primary:hover { - box-shadow: 0 8px 20px rgba(98, 94, 255, 0.5); -} - -.intro-message .md-button:not(.md-button--primary):hover { - box-shadow: 0 8px 20px rgba(255, 255, 255, 0.4); -} - -/* What is Lance Section */ -.lance-intro-section { - padding: 80px 0; - background-color: rgba(128, 128, 128, 0.03); - border-bottom: 1px solid rgba(128, 128, 128, 0.1); -} - -.lance-intro-content { - max-width: 900px; - margin: 0 auto; - text-align: center; -} - -.lance-intro-content h2 { - font-size: 36px; - font-weight: 500; - margin-bottom: 32px; - color: var(--md-primary-fg-color); -} - -.lance-intro-content p { - font-size: 16px; - line-height: 1.8; - margin-bottom: 32px; - opacity: 0.9; - text-align: left; -} - -.lance-paper-link { - color: var(--md-primary-fg-color); - text-decoration: none; -} - -.lance-paper-link:hover { - color: var(--md-primary-fg-color); - text-decoration: none; -} - -.lance-intro-content a:hover { - color: #757575; - text-decoration: none; -} - -.lance-intro-content .md-button { - margin-top: 16px; - padding: 10px 28px; - font-size: 14px; - border: 2px solid currentColor; - background-color: transparent; - transition: all 0.3s ease; -} - -.lance-intro-content .md-button:hover { - color: var(--md-primary-fg-color); - background-color: transparent; -} - -/* Feature Sections */ -.lance-feature-section { - padding: 80px 0; - border-bottom: 1px solid rgba(128, 128, 128, 0.1); -} - -.lance-feature-section:last-child { - border-bottom: none; -} - -.lance-feature-content { - display: flex; - flex-wrap: wrap; - align-items: center; - gap: 60px; -} - -.lance-feature-text { - flex: 1; - min-width: 300px; -} - -.lance-feature-text h2 { - font-size: 30px; - font-weight: 500; - margin-bottom: 16px; - color: var(--md-primary-fg-color); -} - -.lance-feature-text p { - font-size: 15px; - line-height: 1.6; - opacity: 0.85; - margin-bottom: 16px; -} - -.lance-feature-text .md-button { - font-size: 0.6rem; - padding: 0; - transition: all 0.3s ease; -} - -.lance-feature-text .md-button:hover { - transform: translateX(4px); - color: var(--md-primary-fg-color); -} - -.lance-feature-demo { - flex: 1; - min-width: 400px; - display: flex; - justify-content: center; - overflow: hidden; -} - -/* Alternating layout */ -.lance-feature-section.reverse .lance-feature-content { - 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 <ines@ines.io> - * @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/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/community/contributing.md b/docs/src/community/contributing.md index 7e2e681b4d2..d3626be6461 100644 --- a/docs/src/community/contributing.md +++ b/docs/src/community/contributing.md @@ -21,13 +21,24 @@ Major technical changes are discussed organically through the following approach - **Iterate on Design**: Engage with the community to refine the approach based on their input and expertise - **Draft PRs for Details**: Once the general direction is acceptable to the community, publish draft PRs to help hash out implementation details. Draft PRs are encouraged as they facilitate concrete discussions - **Break Down Changes**: Split large draft PRs into smaller, incremental PRs for easier review and to demonstrate progress -- **Formal Voting**: Maintainers with write access can approve code modifications related to the design. If the design requires Lance format spec changes, a separate vote will be conducted on GitHub Discussions following the [voting requirements](./voting.md#voting-requirements) +- **Formal Voting**: Maintainers with write access can approve code modifications related to the design. If the design requires Lance format spec changes, those changes go in their own PR and the PMC votes on that PR following the [voting requirements](./voting.md#voting-requirements) + +## Format Specification Changes + +Changes to the Lance format specification — the protobuf definitions and the docs +under `docs/src/format/` — are proposed as a pull request and voted on there by the +PMC. The PR is the proposal; there is no separate discussion thread to open first. + +Scope such a PR to the specification itself, plus the minimum library changes needed +to keep the build green, and put the implementation in follow-up PRs. See +[Lance Format Specification Changes](./voting.md#lance-format-specification-changes) +for what the vote requires and how it is counted. ## AI Tooling Integrations We encourage contributors to continuously improve integrations with AI tools, including: -- Enhancing coding agent guidelines such as `AGENTS.md` and `CLAUDE.md` +- Enhancing coding agent guidelines such as `AGENTS.md` - Providing feedback to AI code reviewers - Developing and improving AI-driven GitHub actions diff --git a/docs/src/community/index.md b/docs/src/community/index.md index a52b33d277c..5b9c9bdcbe4 100644 --- a/docs/src/community/index.md +++ b/docs/src/community/index.md @@ -43,52 +43,78 @@ Maintainer and PMC rosters information follow these guidelines: This section details the projects maintained in the Lance community. -### Core Projects +### Core Project -Core projects are the foundational repositories maintained by the Lance community with strict quality and release standards. -[Contributing Guidelines](./contributing.md), [Community Voting Process](./voting.md) and [Release Guidelines](./release.md) -are all applicable to these projects. +[lance](https://github.com/lance-format/lance) is the core project of the lance-format GitHub Organization, +which hosts most of the development on the table and file format, Rust SDK, Python and Java binding SDKs, documentation and discussions. -Here is the list of current core projects: - -| Project Name | Repository | Contents | -|-------------------|------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------| -| lance | https://github.com/lance-format/lance | Lance file and table format specification, Rust SDK (including Namespace Integration SDK), Python SDK, Java SDK, Website | -| lance-namespace | https://github.com/lance-format/lance-namespace | Lance namespace format specification, Rust/Python/Java Codegen SDKs, Java/Python Integration SDK | -| lance-python-docs | https://github.com/lance-format/lance-python-docs | Lance Python SDK generated docs and integration hook with readthedocs | -| lance-ray | https://github.com/lance-format/lance-ray | Ray integration for Lance | -| lance-spark | https://github.com/lance-format/lance-spark | Apache Spark connector for Lance | +The core project is maintained by the Lance community with strict quality and release standards. +[Contributing Guidelines](./contributing.md), [Community Voting Process](./voting.md) and [Release Guidelines](./release.md) +are all applicable to the core project. ### Subprojects Subprojects are initiatives or repositories that extend Lance's functionality. They must align with Lance's overall mission and technical direction. -New subprojects can be created with PMC approval. +New subprojects are created by graduating from incubating subprojects through a PMC vote. + +Subprojects have relaxed requirements compared to core projects: -Subprojects have relaxed requirements for contribution, -where contributors may receive write access even if not maintainers. +- Contributors may receive write access even if not maintainers +- Merges may be allowed without review at maintainer discretion +- Release processes may be simplified compared to core projects Here is the list of current subprojects: -| Project Name | Repository | Contents | -|-------------------|------------------------------------------------|-----------------------------------------------------| +| Project Name | Repository | Contents | +|-------------------|---------------------------------------------------|------------------------------------------------------------------------| +| lance-duckdb | https://github.com/lance-format/lance-duckdb | DuckDB extension for Lance | +| lance-huggingface | https://github.com/lance-format/lance-huggingface | Hugging Face integration for Lance | +| lance-namespace | https://github.com/lance-format/lance-namespace | Lance namespace format specification, Rust/Python/Java Codegen SDKs | +| lance-namespace-impls | https://github.com/lance-format/lance-namespace-impls | Lance Namespace Implementations - Apache Hive, Apache Polaris, Apache Gravitino, Unity Catalog, AWS Glue and more | +| lance-python-doc | https://github.com/lance-format/lance-python-doc | Lance Python SDK generated docs and integration hook with readthedocs | +| lance-ray | https://github.com/lance-format/lance-ray | Ray integration for Lance | +| lance-spark | https://github.com/lance-format/lance-spark | Apache Spark connector for Lance | + +### Incubating Subprojects + +Incubating subprojects are experimental or early-stage repositories in the Lance ecosystem. +Any PMC member can create an incubating subproject without a formal vote. +These projects provide a space for new ideas to develop before committing to full subproject standards. + +Incubating subprojects have the most relaxed requirements: + +- Anyone can be added as a committer by the project creator or existing PMC members +- Merges without review are allowed +- No formal release process is required + +**Important**: All incubating subprojects must include a prominent notice in their README with the following exact notice: + +> ⚠� **Incubating Subproject**: This project is in incubation and is not yet an official Lance subproject. +> APIs and functionality may change without notice. Use it in production at your own risk. + +Here is the list of current incubating subprojects: + +| Project Name | Repository | Contents | +|-------------------|---------------------------------------------------|-----------------------------------------------------| +| lance-context | https://github.com/lance-format/lance-context | Manage Multimodal Agentic Context Lifecycle with Lance | | lance-data-viewer | https://github.com/lance-format/lance-data-viewer | Read-only web interface for browsing Lance datasets | -| lance-duckdb | https://github.com/lance-format/lance-duckdb | DuckDB extension for Lance | | lance-flink | https://github.com/lance-format/lance-flink | Apache Flink connector for Lance | | lance-graph | https://github.com/lance-format/lance-graph | Cypher-capable graph query engine on top of Lance | | lance-trino | https://github.com/lance-format/lance-trino | Trino connector for Lance | | pglance | https://github.com/lance-format/pglance | PostgreSQL extension for Lance | -### Graduating a Subproject +### Graduating from Incubating to Subproject -The PMC can vote to promote a subproject to a core project once the subproject has demonstrated aspects including: +The PMC can vote to promote an incubating subproject to a subproject once the project has demonstrated: -- Proper repository setup including CI, issue tracking, contributing guide, etc. -- Proper code standard enforcement including lint, testing, etc. -- Automated release mechanism -- Established production use cases +- Proper repository setup including CI, issue tracking, and contributing guide +- Proper code standard enforcement including lint and testing +- Established use cases - Community adoption outside the primary contributor -- Consistent contributions from the community to add new features and fix bugs +- At least one Lance maintainer actively maintaining the project + +Contributors with write access will retain their access after graduation to subproject. ### Project License diff --git a/docs/src/community/maintainers.md b/docs/src/community/maintainers.md index 4ebcbd8da01..a772f4739bd 100644 --- a/docs/src/community/maintainers.md +++ b/docs/src/community/maintainers.md @@ -40,30 +40,38 @@ Maintainers with GitHub write access are additionally encouraged to: ## Roster -| Name | GitHub Handle | Affiliation | GitHub Write Access | Ecosystem Roles | -|------------------------|----------------------|-------------------|---------------------|-------------------------------------------------| -| Matt Basta | mattbasta | Runway AI | | | -| Giuseppe Battista | giusedroid | AWS | | | -| Timothy Carambat | timothycarambat | Anything LLM | | | -| Ayush Chaurasia | AyushExel | LanceDB | | -| Chongchen Chen | chenkovsky | MiraclePlus | | | -| Akela Drissner-Schmid | akelad | dltHub | | | -| Ty Dunn | TyDunn | Continue | | | -| Enwei Jiao | jiaoew1991 | Luma.ai | ✓ | Milvus Maintainer | -| Bryan Keller | bryanck | Netflix | | Apache Iceberg Committer | -| Aman Kishore | AmanKishore | Harvey.ai | | | -| Sangwu Lee | RE-N-Y | Krea.ai | | | -| Jeremy Leibs | jleibs | Rerun.io | | | -| Haocheng Liu | HaochengLIU | Seven Research | ✓ | | -| Nathan Ma | majin1102 | ByteDance | ✓ | Apache Amoro (incubating) PPMC Member | -| ChanChan Mao | ccmao1130 | LanceDB | | | -| Lu Qiu | LuQQiu | LanceDB | ✓ | Alluxio PMC Member | -| Rong Rong | walterddr | Google DeepMind | | Apache Pinot PMC Member, Apache Flink Committer | -| Nat Roth | nrothGIT | Meta AI | | | -| Kevin Shaffer-Morrison | kevinshaffermorrison | AWS | | | -| Noah Shpak | noahshpak | Thinking Machines | | | -| Ankit Vij | ankitvij-db | Databricks | | | -| Jiacheng Yang | jiachengdb | Google AI | | | +| Name | GitHub Handle | Affiliation | GitHub Write Access | Ecosystem Roles | +|------------------------|----------------------|-------------------|---------------------|----------------------------------------------------| +| Wyatt Alt | wkalt | LanceDB | ✓ | | +| Matt Basta | mattbasta | Runway AI | | | +| Giuseppe Battista | giusedroid | AWS | | | +| Timothy Carambat | timothycarambat | Anything LLM | | | +| Ayush Chaurasia | AyushExel | LanceDB | | | +| Chongchen Chen | chenkovsky | MiraclePlus | | | +| Akela Drissner-Schmid | akelad | dltHub | | | +| Ty Dunn | TyDunn | Continue | | | +| Enwei Jiao | jiaoew1991 | Luma.ai | ✓ | Milvus Maintainer | +| Bryan Keller | bryanck | Netflix | | Apache Iceberg Committer | +| Aman Kishore | AmanKishore | Harvey.ai | | | +| Sangwu Lee | RE-N-Y | Krea.ai | | | +| Jeremy Leibs | jleibs | Genesis AI | | | +| Haocheng Liu | HaochengLIU | Seven Research | ✓ | | +| Nathan Ma | majin1102 | ByteDance | ✓ | Apache Amoro (incubating) PPMC Member | +| ChanChan Mao | ccmao1130 | LanceDB | | | +| Lu Qiu | LuQQiu | LanceDB | ✓ | Alluxio PMC Member | +| Dan Rammer | hamersaw | LanceDB | ✓ | | +| Rong Rong | walterddr | Google DeepMind | | Apache Pinot PMC Member, Apache Flink Committer | +| Nat Roth | nrothGIT | Meta AI | | | +| Kevin Shaffer-Morrison | kevinshaffermorrison | AWS | | | +| Noah Shpak | noahshpak | Thinking Machines | | | +| Chunxu Tang | ChunxuTang | Google | | PrestoDB Committer | +| Ankit Vij | ankitvij-db | Databricks | | | +| Beinan Wang | beinan | Microsoft AI | | Alluxio PMC Member, Presto TSC Member | +| Jiacheng Yang | jiachengdb | Google AI | | | +| Yang Jie | LuciferYang | Baidu Inc. | | Apache Spark PMC Member, Apache Uniffle PMC Member | +| Jianjian Xie | jja725 | Uber | | | +| Zhang Yue | zhangyue19921010 | ByteDance | | Apache Hudi PMC, Apache Druid Committer, Kafka Contributor | +| Jinglun | wojiaodoubao | ByteDance | | Apache Hadoop Committer | ## Becoming a Maintainer @@ -99,4 +107,4 @@ To be granted GitHub write access, the maintainer should: - Have a history of high-quality contributions - Have earned trust from the community for code reviews - Get nominated by a PMC member and approval through a passing vote -- Sign the Contributor License Agreement (CLA) \ No newline at end of file +- Sign the Contributor License Agreement (CLA) diff --git a/docs/src/community/pmc.md b/docs/src/community/pmc.md index 3d9daeaebff..5088e45d6ae 100644 --- a/docs/src/community/pmc.md +++ b/docs/src/community/pmc.md @@ -27,27 +27,9 @@ In addition to the [activities of maintainers](./maintainers.md#activities), PMC ## Roster -| Name | GitHub Handle | Affiliation | Ecosystem Roles | -|-----------------|-----------------|--------------|---------------------------------------------------------------------------------------------------------------| -| Yang Cen | BubbleCal | LanceDB | Milvus Contributor | -| Pablo Delgado | pablete | Netflix | | -| Hao Ding | Xuanwo | LanceDB | Apache OpenDAL PMC Chair, Apache Iceberg Committer, Apache Member and [more](https://xuanwo.io/about/) | -| Zhaowei Huang | SaintBacchus | Alibaba | Apache Doris Committer | -| Will Jones | wjones127 | LanceDB | Apache Arrow PMC Member, Apache DataFusion PMC Member, Delta Lake Maintainer | -| Matt Kafonek | kafonek | Runway AI | | -| Denny Lee | dennyglee | Databricks | Unity Catalog Maintainer, Delta Lake Maintainer, Apache Spark Contributor, MLflow Contributor | -| Rob Meng | chebbyChefNEQ | Jump Trading | | -| Dao Mi | dowjones226 | Netflix | | -| Weston Pace | westonpace | LanceDB | Apache Arrow PMC Member, Substrait SMC Member | -| Calvin Qi | calvinqi | Harvey.ai | | -| Prashanth Rao | prrao87 | LanceDB | | -| Ethan Rosenthal | EthanRosenthal | Runway AI | | | -| Tim Saucer | timsaucer | Rerun.io | Apache DataFusion PMC Member | -| Chang She | changhiskhan | LanceDB | Pandas Co-Author | -| Jasmine Wang | onigiriisabunny | LanceDB | Alluxio PMC Community Manager | -| Lei Xu | eddyxu | LanceDB | Apache Hadoop PMC Member | -| Vino Yang | yanghua | Bytedance | Apache Hudi PMC Member, Apache Kyuubi PMC Member, Apache Kylin Committer, Apache Incubation Program Committer | -| Jack Ye | jackye1995 | LanceDB | Apache Iceberg PMC Member, Apache Polaris (incubating) PPMC Member, Apache Incubation Program Committer | +<!-- The roster table is generated at build time from `pmc.yaml` by the + `hooks/pmc_roster.py` MkDocs hook. Edit `pmc.yaml`, not this placeholder. --> +<!-- PMC_ROSTER_TABLE --> ## Becoming a PMC Member diff --git a/docs/src/community/pmc.yaml b/docs/src/community/pmc.yaml new file mode 100644 index 00000000000..34819652c00 --- /dev/null +++ b/docs/src/community/pmc.yaml @@ -0,0 +1,90 @@ +# Source of truth for the Project Management Committee (PMC) roster. +# +# This file drives two things, so keep it accurate: +# 1. The roster table in `pmc.md`, rendered at docs build time by the +# `docs/hooks/pmc_roster.py` MkDocs hook. +# 2. The format-specification vote gate, which only counts PR approvals from +# the `handle`s listed here (see `.github/workflows/format-vote-gate.yml`). +# +# Adding or removing a member is itself a PMC vote (roster change). After the +# vote passes, edit this file; the docs table updates automatically. +# +# `handle` must match the member's GitHub login exactly (case-insensitive when +# matched). `ecosystem_roles` is free-form markdown and may be empty. +members: + - name: Yang Cen + handle: BubbleCal + affiliation: LanceDB + ecosystem_roles: Milvus Contributor + - name: Pablo Delgado + handle: pablete + affiliation: Netflix + ecosystem_roles: "" + - name: Hao Ding + handle: Xuanwo + affiliation: LanceDB + ecosystem_roles: Apache OpenDAL PMC Chair, Apache Iceberg Committer, Apache Member and [more](https://xuanwo.io/about/) + - name: Zhaowei Huang + handle: SaintBacchus + affiliation: Alibaba + ecosystem_roles: Apache Doris Committer + - name: Will Jones + handle: wjones127 + affiliation: LanceDB + ecosystem_roles: Apache Arrow PMC Member, Apache DataFusion PMC Member, Delta Lake Maintainer + - name: Matt Kafonek + handle: kafonek + affiliation: Runway AI + ecosystem_roles: "" + - name: Denny Lee + handle: dennyglee + affiliation: Databricks + ecosystem_roles: Unity Catalog Maintainer, Delta Lake Maintainer, Apache Spark Contributor, MLflow Contributor + - name: Rob Meng + handle: chebbyChefNEQ + affiliation: Jump Trading + ecosystem_roles: "" + - name: Dao Mi + handle: dowjones226 + affiliation: Netflix + ecosystem_roles: "" + - name: Weston Pace + handle: westonpace + affiliation: LanceDB + ecosystem_roles: Apache Arrow PMC Member, Substrait SMC Member + - name: Calvin Qi + handle: calvinqi + affiliation: Harvey.ai + ecosystem_roles: "" + - name: Prashanth Rao + handle: prrao87 + affiliation: LanceDB + ecosystem_roles: "" + - name: Ethan Rosenthal + handle: EthanRosenthal + affiliation: Runway AI + ecosystem_roles: "" + - name: Tim Saucer + handle: timsaucer + affiliation: Rerun.io + ecosystem_roles: Apache DataFusion PMC Member + - name: Chang She + handle: changhiskhan + affiliation: LanceDB + ecosystem_roles: Pandas Co-Author + - name: Jasmine Wang + handle: onigiriisabunny + affiliation: LanceDB + ecosystem_roles: Alluxio PMC Community Manager + - name: Lei Xu + handle: eddyxu + affiliation: LanceDB + ecosystem_roles: Apache Hadoop PMC Member + - name: Vino Yang + handle: yanghua + affiliation: Bytedance + ecosystem_roles: Apache Hudi PMC Member, Apache Kyuubi PMC Member, Apache Kylin Committer, Apache Incubation Program Committer + - name: Jack Ye + handle: jackye1995 + affiliation: LanceDB + ecosystem_roles: Apache Iceberg PMC Member, Apache Polaris (incubating) PPMC Member, Apache Incubation Program Committer diff --git a/docs/src/community/voting.md b/docs/src/community/voting.md index 577642dd01a..ccb6f5a80bd 100644 --- a/docs/src/community/voting.md +++ b/docs/src/community/voting.md @@ -22,6 +22,10 @@ each vote should be cast as an independent comment instead of as a reply within This ensures that people can discuss the vote as replies to that specific comment if needed (e.g., to discuss **-1** vetoes or address concerns). +For votes conducted on a pull request, cast **+1** by approving the PR and **-1** by +requesting changes. These votes are counted automatically, so a **+1** written only as +a comment does not count. + ## Binding Votes Only votes from the binding voters are counted for each decision, @@ -42,12 +46,112 @@ A **-1** binding vote is considered a veto for all decision types. Vetoes: |-------------------------------------------------------------------------------|----------------------------------------------|--------------------------------|---------------------------------------|----------------| | Governance process and structure modifications | 3 | PMC | Private Mailing List | 1 week | | Changes in maintainers and PMC rosters | 3 (excluding the people proposed for change) | PMC | Private Mailing List | 1 week | -| Subproject creation and management | 3 | PMC | GitHub Discussions | 3 days | -| Subproject graduation to core project | 3 | PMC | GitHub Discussions | 1 week | -| Release a new stable major version of core projects | 3 | PMC | GitHub Discussions | 1 week | -| Release a new stable minor version of core projects | 3 | PMC | GitHub Discussions | 3 days | -| Release a new stable patch version of core projects | 3 | PMC | GitHub Discussions | N/A | -| Lance Format Specification modifications | 3 (excluding proposer) | PMC | GitHub Discussions (with a GitHub PR) | 1 week | -| Code modifications in core projects (except changes to format specifications) | 1 (excluding proposer) | Maintainers with write access | GitHub PR | N/A | +| 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 | 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 PR (see [below](#lance-format-specification-changes)) | 72 hours, excluding weekends | +| Experimental Lance Format Specification feature (stabilization vote) | 3 (excluding proposer) | PMC | GitHub Discussions (with a GitHub PR) | 1 week | +| Code modifications in the core project (except changes to format specifications) | 1 (excluding proposer) | Maintainers with write access | GitHub PR | N/A | | Release a new stable version of subprojects | 1 | PMC | GitHub Discussions | N/A | | Code modifications in subprojects | 1 (excluding proposer) | Contributors with write access | GitHub PR | N/A | + +## Lance Format Specification Changes + +The pull request *is* the proposal. Open a PR with the specification change, +and the PMC votes on it there — there is no separate design document or +discussion thread to write first, and the requirement is enforced structurally +in CI rather than by convention. + +### Proposing a Change + +Keep a format-specification PR to the specification itself: the protobuf +definitions and the spec documentation, plus the minimum library changes needed +to keep the build green (for example, matching a renamed generated field). +Implement the behavior behind the change in follow-up PRs. + +This is not just a tidiness preference. The vote is on the format — a durable +compatibility contract that outlives any one implementation — and PMC members +should be able to read the whole of what they are voting on. A PR that also +carries the reader, writer, and test changes buries the contract in +implementation detail, and it drags an ordinary code review through a 72-hour +voting period it does not need. + +Discussion happens as review comments on the PR, so reviewers can respond to +specific lines of the specification. Open the PR as a draft while it is still +taking shape; the voting period starts when you mark it ready for review. + +### How the Vote is Counted + +A PR counts as a format-specification change when it modifies the protobuf +definitions (`protos/**/*.proto`) or the spec documentation (`docs/src/format/**`); +such PRs are labeled `format-change` automatically. The +[format spec vote gate](https://github.com/lance-format/lance/blob/main/.github/workflows/format-vote-gate.yml) +blocks merging a `format-change` PR until all of the following hold: + +- **Three binding +1 votes.** Three PMC members have approved the PR, excluding + the proposer. Cast +1 by approving the PR. An approval counts no matter what commit + it was cast on, so a rebase or a typo fix does not send everyone back to + re-vote. +- **One +1 on the latest commit.** At least one of those approvals — from a PMC + member who is not the proposer — must be on the latest commit. That member is + vouching that nothing substantive has changed since the earlier approvals; if + something has, they should ask the other voters for fresh votes rather than + approving. This approval counts toward the three; it is not a fourth vote. +- **No veto.** No PMC member has an outstanding "Request changes" review. A `-1` + binding vote (cast by requesting changes) is a veto and blocks the merge until + withdrawn. +- **Minimum voting period.** At least 72 hours have elapsed since the vote + opened. Weekends do not count toward the 72 hours, so a proposal opened on a + Friday afternoon still gets three working days of attention. The voting period + opens once the PR is both labeled `format-change` and marked ready for + review, whichever comes last. Weekends are delimited in UTC; the gate comments + on the PR with the exact closing time in both UTC and Pacific Time. + +The gate is the `format-spec-vote` required status check on protected branches. +The PMC roster used to count votes is read from +[`docs/src/community/pmc.yaml`](./pmc.md). It re-evaluates on a 15-minute +schedule, so the tally comment and the status check trail a review by a few +minutes; the comment links to a "Run workflow" page for anyone who would rather +re-check immediately. + +For a trivial edit that does not change the format — a typo, wording, or +formatting fix — a PMC member may apply the `format-waived` label to waive the +vote. + +## Experimental Specification Features + +Certain format specification changes may be merged as **experimental** before their stabilization vote closes. +This allows iteration on new features without blocking on a completed vote, +while preserving the integrity of the stable format and the community's ability to reject or modify the feature. + +### Prerequisites + +A feature may only be merged as experimental if it satisfies **all** of the following criteria: + +1. The feature is clearly marked as experimental in both the protobuf definitions and the documentation. +2. The feature is **forward compatible**: writers that use the feature do not affect readers that are unaware of it. +3. The feature is **backward compatible**: writers that do not use the feature do not affect readers that use it. +4. Dropping the feature will not require a rewrite of existing data. + +### Required Commitments + +Before merging an experimental feature, the following commitments must be in place: + +1. A Github discussion on the feature has been started. For features that will span multiple PRs this discussion +should include a design document providing an overview of the entire planned feature. +2. **Authors** accept that if the stabilization vote is rejected or expires without passing, the feature will be removed. +3. **Users** accept that breaking changes may be made to experimental features at any time without a separate vote. +4. **Authors and users** accept that the PMC may request backwards-incompatible changes to the feature during the stabilization process. +5. The file format has an additional concept of "stable versions". A stable version may not contain any experimental features. Before a version can be stabilized, all its features must be stabilized or moved out to the next version. + +### Stabilization Workflow + +1. Open a PR implementing the new format feature. +2. Open a discussion of the feature on GitHub Discussions. This is not a voting discussion. It is a place for maintainers +to provide early feedback. +3. Merge the PR with the feature clearly marked as experimental. +4. When ready, open a PR to remove the experimental markers. This is the PR that will carry the vote. Merging this PR +stabilized the feature. +5. If the stabilization PR **fails or expires**, remove the feature from the codebase and specification. diff --git a/docs/src/examples/python/llm_dataset_creation.md b/docs/src/examples/python/llm_dataset_creation.md index e1fd24f9513..2a70e37bcb4 100644 --- a/docs/src/examples/python/llm_dataset_creation.md +++ b/docs/src/examples/python/llm_dataset_creation.md @@ -24,7 +24,7 @@ from tqdm.auto import tqdm # optional for progress tracking tokenizer = AutoTokenizer.from_pretrained('gpt2') -dataset = load_dataset('wikitext', 'wikitext-103-raw-v1', streaming=True)['train'] +dataset = load_dataset('Salesforce/wikitext', 'wikitext-103-raw-v1', streaming=True)['train'] dataset = dataset.shuffle(seed=1337) ``` @@ -105,4 +105,4 @@ lance.write_dataset( If you want to apply some other pre-processing to the tokens before saving it to the disk (like masking, etc), you may add it in the `process_samples` function. -And that's it! Your dataset has been tokenized and saved to the disk! \ No newline at end of file +And that's it! Your dataset has been tokenized and saved to the disk! diff --git a/docs/src/format/.pages b/docs/src/format/.pages index 6914d14e2c1..1d9306c9384 100644 --- a/docs/src/format/.pages +++ b/docs/src/format/.pages @@ -2,4 +2,6 @@ nav: - Overview: index.md - File Format: file - Table Format: table - + - Index Formats: index + - Catalog Specs: catalog + - Namespace Client Spec: namespace diff --git a/docs/src/format/AGENTS.md b/docs/src/format/AGENTS.md new file mode 100644 index 00000000000..faa482f687c --- /dev/null +++ b/docs/src/format/AGENTS.md @@ -0,0 +1,20 @@ +# Format Documentation Guidelines + +Also see [root AGENTS.md](../../../AGENTS.md) for cross-language standards. + +## Change Process + +- Changes here require a PMC vote on the pull request, enforced by the `format-spec-vote` CI gate. See [Lance Format Specification Changes](../community/voting.md#lance-format-specification-changes). +- Keep a spec change in its own PR, together with the matching `protos/` change and only the library edits needed to compile. Put the implementation in a follow-up PR — voters need to read the contract, not its implementation. + +## Style + +- Keep format docs as concise, text-only reference — no code examples (put those in user guide sections). +- Express file schemas as `pyarrow` schema definitions, not markdown tables or informal text — pyarrow schemas are unambiguous and executable. +- Use language-agnostic definitions (JSON Schema, protobuf) — not language-specific code like Rust structs. + +## Content + +- Explain schema/data evolution with concrete mechanics (field IDs, tombstones, data rewrites) — don't just name operations or defer to external specs. +- Describe all algorithms with full detail: parameters, precision, ordering, normalization bounds, and implementation steps — never reference an algorithm by name alone. +- Index docs must include explicit file schemas and describe reader navigation (page type distinction, root/entry point location) — follow the pattern in `index/scalar/bitmap.md`. diff --git a/docs/src/format/file/encoding.md b/docs/src/format/file/encoding.md index f3da5cd60df..1cb83d581e9 100644 --- a/docs/src/format/file/encoding.md +++ b/docs/src/format/file/encoding.md @@ -126,7 +126,7 @@ We can convert these into repetition levels as follows: | 3 | 2 | Start of new middle list | | ? | 2 | Start of new inner-most list (empty list) | | ? | 3 | Start of new outer-most list (empty list) | -| 4 | 0 | Start of new outer-most list | +| 4 | 3 | Start of new outer-most list | ### Mini Block Page Layout @@ -184,9 +184,9 @@ must be loaded at initialization time and placed in the search cache. | 12 | Number of 8-byte words in block N | | 4 | Log2 of number of values in block N | -The last 4 bits are special and we just store 0 today. This is because the protobuf contains the number of -values (not required to be a power of 2) in the entire disk page. We can subtract the values in the other blocks -to get the number of values in the last block. +For all chunks except the last, the lower 4 bits store `log2(num_values)` and `num_values` must be a power of two. +For the last chunk, these bits are set to `0`. The protobuf stores the total number of values in the page, so readers +can derive the final chunk size by subtracting the values from earlier chunks. #### Buffer 2 (Dictionary, optional) @@ -198,9 +198,8 @@ dictionary in the buffer at index 2. We require the dictionary to be full loaded This means we don't have to load the dictionary during random access but it does require the dictionary be placed in the search cache. -Dictionary encoding is one of the few spots today where we have no rules on how it is encoded and compressed. We -treat the entire dictionary as a single opaque buffer. As a result we rely on the block compression trait to handle -dictionary compression. +Dictionary values are stored as a single buffer and compressed through the block compression path. The compression +scheme for dictionary values can be configured separately (see `lance-encoding:dict-values-compression` below). #### Buffer 2 (or 3) (Repetition Index, optional) @@ -328,6 +327,148 @@ The protobuf for the full zip layout describes the compression of the data buffe size of the control words and how many bits we have per value (for fixed-width data) or how many bits we have per offset (for variable-width data). +### Sparse Page Layout + +Sparse pages require Lance 2.3. They represent flat or nested Arrow structure directly as slot-domain mappings instead +of dense repetition and definition events. Writers emit this layout only in files declared as 2.3 or above. The layout is +identified only by `PageLayout`; field metadata does not identify the layout of an existing page. + +A domain is a layer-local integer coordinate space `[0, num_slots)`, and a slot is one element in that space. The +outer-most domain contains the page's top-level rows. Each layer maps its parent domain to the next layer's parent +domain, and the terminal child domain contains the leaf value slots stored in value chunks. + +Structural layers are ordered from outer-most to inner-most: + +- validity maps a nullable item or struct slot to valid or null +- list maps non-empty parent slots to variable-size child ranges +- fixed-size-list maps each parent slot to a child range of a fixed dimension + +The layer list may be empty for a flat, non-nullable leaf page. In that case the scheduling domain and +`num_visible_items` must be equal. The explicit writer currently emits its normalized all-valid layer even when a flat +page could use this shorter wire representation. + +A list slot that is valid and absent from `non_empty_positions` is an empty list. Maps use the same structural contract +as lists. The terminal child-domain size equals `SparseLayout.num_visible_items`. + +`SparseLayout.num_visible_items` is the number of leaf value slots encoded in value chunks. Null leaf slots count +because they still occupy positions in Arrow's leaf value buffer; a nullable primitive with 100 slots, including 30 +nulls, has 100 visible items. `SparseLayout.num_items` is the number of entries in the equivalent dense repetition and +definition stream. It equals `num_visible_items` plus one structural placeholder for every list slot without children. +The first layer's `num_slots` is the logical top-level row count used for projection. + +Position sets have four semantic representations: `empty`, `all`, one non-empty `range`, or an `explicit` +delta-compressed `u64` buffer. Count sets are `empty`, one positive `constant` value, or an `explicit` compressed `u64` +buffer. Every layer has a `SparseValiditySet` whose meaning is explicit: + +- `SPARSE_VALIDITY_NULL_POSITIONS`: stored positions are null and all other positions are valid +- `SPARSE_VALIDITY_VALID_POSITIONS`: stored positions are valid and all other positions are null + +The unspecified validity meaning is invalid. Both polarities are part of the wire contract and have identical Arrow +semantics after normalization. + +#### Writer Selection + +Writers may emit this layout only for Lance 2.3+ fields. A field can request it explicitly with +`lance-encoding:structural-encoding=sparse`; the same request is an input error for earlier file versions. Without an +explicit structural encoding, the Lance 2.3 writer selects sparse only when the dense mini-block repetition/definition +budget would split the page or one top-level row exceeds that budget, and only when the value path is supported by the +sparse writer. Explicit `miniblock`, `fullzip`, and `sparse` requests are not changed by this automatic policy. Lance +2.2 and earlier writers never select sparse. + +Unsupported sparse value paths, including dictionary values and variable-width packed structs, retain their dense +behavior. Writers normalize Arrow validity and list structure once. Within-budget dense pages do not build sparse +position/count plans. All-valid layers use null positions plus `empty`; all-null layers use valid positions plus +`empty`. Other layers choose the validity polarity with the lower semantic encoded cost, with ties using null +positions. Field metadata controls writer selection only: readers always use `PageLayout` to determine the layout of +an encoded page and must not use field metadata for that decision. + +Pages without a value payload keep the existing canonical `ConstantLayout`: structural-only types such as an empty +struct, and leaf pages whose visible values are all null, do not emit `SparseLayout`. An explicitly sparse page with +at least one non-null visible value does emit `SparseLayout`, even when all non-null values are equal. This boundary +avoids introducing a second structural-only representation without evidence that it improves the existing constant +encoding. + +#### Buffers and Selective Reads + +A sparse page contains the following physical buffers: + +| Buffer | Contents | +| ------ | -------- | +| 0 | Value chunk metadata, one 8-byte entry per chunk | +| 1 | Mini-block compressed value chunks without repetition or definition levels | +| 2+ | One buffer for each explicit position or count set, in structural-layer field order | + +Each value chunk metadata entry stores `(chunk_size / 8) - 1` as little-endian `u32`, followed by its visible value +count as little-endian `u32`. Chunk sizes must be positive multiples of 8 and fit this representation. The sum of +chunk sizes must equal buffer 1 exactly and the sum of chunk value counts must equal `num_visible_items`. A value chunk +contains at most 32,768 visible values. `num_buffers` describes the number of value buffers inside every chunk and +excludes the structural buffers. + +General-compressed sparse buffers use the existing length-prefixed LZ4 or Zstd representation and must not contain +another general-compression wrapper. SparseLayout does not impose additional size or descriptor-complexity limits on +otherwise representable buffers. + +Readers normalize structural metadata once, project requested top-level ranges through each layer, and read only value +chunks that intersect the resulting leaf ranges. When no leaf range remains, readers rebuild offsets and validity from +the structural plan without reading buffer 1. + +#### Caching and Point Reads + +Reader initialization loads buffer 0 and every explicit structural buffer, then validates and normalizes them into a +cached page plan. The cached state contains parsed value-chunk descriptors and prefix offsets, decoded semantic +position/count sets, validity, and the ordered structural layers. It does not contain value payload bytes from buffer +1. The plan is cached per field and page and reused by later scans, range reads, and takes. + +After that plan is cached, reading one primitive leaf value reads only the value chunk that contains it. A cold read +first loads the structural metadata and then the intersecting value chunk. Reading one top-level list or +fixed-size-list value may intersect multiple leaf chunks and reads each intersecting chunk. A selection whose projected +structure contains no leaf slots reads no value chunk. + +#### Validation + +Readers must reject malformed sparse metadata instead of inferring or repairing it. Required checks include: + +- physical buffer count, chunk-count bounds, and every checked offset/size range +- first-layer row domain, adjacent parent/child domain chaining, and terminal visible-value domain +- semantic set cardinality, explicit position ordering and bounds, and validity meaning +- exact `num_items` +- list non-empty positions being valid, count cardinality, positive counts, and child-count sum +- fixed-size-list dimension and checked child-domain multiplication +- value chunk byte/value sums, size representation and alignment, general-compression headers, descriptor buffer + count, and complete chunk consumption + +```protobuf +%%% proto.message.SparseLayout %%% +``` + +```protobuf +%%% proto.message.SparseStructuralLayer %%% +``` + +```protobuf +%%% proto.message.SparseValidityLayer %%% +``` + +```protobuf +%%% proto.message.SparseListLayer %%% +``` + +```protobuf +%%% proto.message.SparseFixedSizeListLayer %%% +``` + +```protobuf +%%% proto.message.SparseValiditySet %%% +``` + +```protobuf +%%% proto.message.SparsePositionSet %%% +``` + +```protobuf +%%% proto.message.SparseCountSet %%% +``` + ### Constant Page Layout This layout is used when all (visible) values in the page are the same scalar value. @@ -402,7 +543,8 @@ are always accessed together. Packed struct is always opt-in (see section on configuration below). -Currently packed struct is limited to fixed-width data. +In Lance 2.1, packed struct is limited to fixed-width children (`PackedStruct`). +Starting with Lance 2.2, variable-width children are also supported via `VariablePackedStruct`. ### Fixed Size List @@ -445,9 +587,9 @@ on a per-value basis. We use ☑� to mark a technique that is applied on a per | Constant | ✅ (2.1) | � | � | | Bitpacking | ✅ (2.1) | � | ✅ (2.1) | | Fsst | � | ✅ (2.1) | ✅ (2.1) | -| Rle | � | � | ✅ (2.1) | +| Rle | ✅ (2.2) | � | ✅ (2.1) | | ByteStreamSplit | � | � | ✅ (2.1) | -| General | � | ☑� (2.1) | ✅ (2.1) | +| General | ✅ (2.2) | ☑� (2.1) | ✅ (2.1) | In the following sections we will describe each technique in a bit more detail and explain how it is utilized in various contexts. @@ -544,9 +686,12 @@ options. However, they can also be set in the field metadata in the schema. | `lance-encoding:rle-threshold` | `0.0-1.0` | `0.5` | See below | | `lance-encoding:bss` | `off`, `on`, `auto` | `auto` | See below | | `lance-encoding:dict-divisor` | Integers greater than 1 | `2` | See below | +| `lance-encoding:dict-size-ratio` | `0.0-1.0` | `0.8` | See below | +| `lance-encoding:dict-values-compression` | `lz4`, `zstd`, `none` | `lz4` | Select general compression scheme for dictionary values | +| `lance-encoding:dict-values-compression-level` | Integers (scheme dependent) | Varies by scheme | Compression level for dictionary values general compression | | `lance-encoding:general` | `off`, `on` | `off` | Whether to apply general compression. | | `lance-encoding:packed` | Any string | Not set | Whether to apply packed struct encoding (see above). | -| `lance-encoding:structural-encoding` | `miniblock`, `fullzip` | Not set | Force a particular structural encoding to be applied (only useful for testing purposes) | +| `lance-encoding:structural-encoding` | `miniblock`, `fullzip`, `sparse` | Not set | Force a structural encoding; `sparse` requires Lance 2.3. | ### Configuration Details @@ -619,18 +764,46 @@ BSS is particularly effective for: - Time-series data with consistent precision - Scientific data with correlated mantissa patterns -#### Dictionary Divisor +#### Dictionary Encoding Controls -Currently this is used to determine whether or not we apply dictionary encoding. First, we use HLL to estimate -the number of unique values in the column. Then we divide the number of total values by the divisor to get a -threshold. If the number of unique values is less than the threshold then we apply dictionary encoding. The -configuration variable defines the divisor that we apply and it defaults to 2 which means we apply dictionary -encoding if we estimate that less than half the values are unique. +Dictionary encoding is gated by a few heuristics. +The decision is made on the leaf value page, so nested types can still benefit. +For example, `List<u32>` can use dictionary encoding for its `u32` values. -Dictionary encoding is effective for columns with low cardinality where the same values repeat many times. -The dictionary is stored once per page and indices are stored in place of the actual values. +Two field-level metadata keys control when dictionary encoding is attempted: -This is likely to change in future versions. +- `lance-encoding:dict-divisor` (default `2`): the encoder computes a unique-value budget as `num_values / divisor` +- `lance-encoding:dict-size-ratio` (default `0.8`): the estimated dictionary-encoded representation must stay below this ratio of the raw page size + +There are additional global guards available as environment variables: + +- `LANCE_ENCODING_DICT_TOO_SMALL` (minimum page size before trying dictionary encoding, default `100` values) +- `LANCE_ENCODING_DICT_DIVISOR` (fallback divisor when field metadata is not set, default `2`) +- `LANCE_ENCODING_DICT_MAX_CARDINALITY` (upper cap for dictionary entries, default `100000`) +- `LANCE_ENCODING_DICT_SIZE_RATIO` (fallback ratio when field metadata is not set, default `0.8`) + +Dictionary encoding is effective when values repeat frequently and the number of distinct values stays low. + +#### Dictionary Values Compression + +Dictionary values are compressed through the block-compression path and have their own configuration: + +- `lance-encoding:dict-values-compression`: `lz4`, `zstd`, `none` +- `lance-encoding:dict-values-compression-level`: optional scheme-specific level + +Environment-variable fallbacks: + +- `LANCE_ENCODING_DICT_VALUES_COMPRESSION` +- `LANCE_ENCODING_DICT_VALUES_COMPRESSION_LEVEL` + +Priority order is: + +1. Field metadata (`dict-values-*`) +2. Environment variables (`LANCE_ENCODING_DICT_VALUES_*`) +3. Default (`lz4`) + +`none` disables general (opaque) compression for dictionary values. For fixed-width dictionary values, structural +encodings such as RLE or bitpacking may still be selected when beneficial. #### Packed Struct Encoding @@ -639,3 +812,23 @@ 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 tuned via an environment variable: + +- `LANCE_MINIBLOCK_MAX_VALUES` (default `4096`, maximum `32768`): 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. Increasing it can reduce metadata overhead and +improve throughput for highly compressible data, but it may increase random-read amplification. diff --git a/docs/src/format/file/index.md b/docs/src/format/file/index.md index a008de7a843..83f0b9a644d 100644 --- a/docs/src/format/file/index.md +++ b/docs/src/format/file/index.md @@ -1,5 +1,21 @@ # Lance File Format +The Lance file format is a columnar container optimized for cloud object stores, random access, and Arrow-native processing. It deliberately focuses on page layout and encoding mechanics, while leaving table semantics and search structures to higher layers. + +## Design Goals + +### No Row Groups + +Lance does not use Parquet-style row groups. Each column may have its own number of pages, which keeps column data in large storage-friendly chunks regardless of schema width and avoids coupling scanner partitioning to physical file layout. + +### Random-Access-Friendly Encoding + +Pages are designed so readers can fetch contiguous row ranges with a small and predictable number of I/O operations. This is important for selective filters, point lookups, vector-search follow-up reads, and ML training workloads that sample rows non-sequentially. + +### Functional Decomposition + +The file layer does not bundle table-level statistics or query-side indices into the base file structure. Those capabilities are defined as separate index formats so they can evolve independently of the core file container. + ## File Structure A Lance file is a container for tabular data. The data is stored in "disk pages". Each disk page contains some rows diff --git a/docs/src/format/file/versioning.md b/docs/src/format/file/versioning.md index 13fbdd89724..a43be294070 100644 --- a/docs/src/format/file/versioning.md +++ b/docs/src/format/file/versioning.md @@ -5,18 +5,52 @@ major number is changed when the file format itself is modified while the minor strategy is modified. Newer versions will typically have better performance and compression but may not be readable by older versions of Lance. -In addition, the latest version of the file format (next) is unstable and should not be used for production use cases. -Breaking changes could be made to unstable encodings and that would mean that files written with these encodings are -no longer readable by any newer versions of Lance. The `next` version should only be used for experimentation and -benchmarking upcoming features. +Any version explicitly labeled unstable, including the current 2.3 format and the `next` alias, should not be used for +production use cases. Unstable formats have no compatibility guarantee: breaking encoding changes may make files +written by one Lance build unreadable by later builds. They should only be used for experimentation and benchmarking +upcoming features. + +The `stable` and `next` aliases are resolved by the specific Lance release you are using. During a format rollout +(for example, 2.3), prefer explicit version pinning for deterministic behavior across environments. The following values are supported: -| Version | Minimal Lance Version | Maximum Lance Version | Description | -| -------------- | --------------------- | --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -| 0.1 | Any | 0.34 (write) | This is the initial Lance format. It is no longer writable. | -| 2.0 | 0.16.0 | Any | Rework of the Lance file format that removed row groups and introduced null support for lists, fixed size lists, and primitives | -| 2.1 (unstable) | None | Any | Enhances integer and string compression, adds support for nulls in struct fields, and improves random access performance with nested fields. | -| legacy | N/A | N/A | Alias for 0.1 | -| stable | N/A | N/A | Alias for the latest stable version (currently 2.0) | -| next | N/A | N/A | Alias for the latest unstable version (currently 2.1) | +| Version | Minimal Lance Version | Maximum Lance Version | Description | +| -------------- | --------------------- | --------------------- | ----------- | +| 0.1 | Any | 0.34 (write) | This is the initial Lance format. It is no longer writable. | +| 2.0 | 0.16.0 | Any | Rework of the Lance file format that removed row groups and introduced null support for lists, fixed size lists, and primitives | +| 2.1 | 0.38.1 | Any | Enhances integer and string compression, adds support for nulls in struct fields, and improves random access performance with nested fields. | +| 2.2 | None | Any | Adds support for newer nested type/encoding capabilities (including map support) and 2.2-era storage features. | +| 2.3 (unstable) | None | Unspecified | Adds sparse structural pages and other experimental encodings. | +| legacy | N/A | N/A | Alias for 0.1 | +| stable | N/A | N/A | Alias for the default version for new datasets in the Lance release you are running. | +| next | N/A | N/A | Alias for the latest unstable version in the Lance release you are running.| + +## Compatibility Caveats + +Stable formats carry a compatibility guarantee, but certain data patterns exposed encoder bugs +that required encoding changes to fix. Files containing those patterns written by the fixed +encoder are not readable by readers predating the fix. The affected scenarios are listed here +so operators running mixed-version deployments know the minimum reader version required. + +### FixedSizeList with all-null inner values (Lance 11.1.0) + +**Affected format**: 2.1 and later. + +**Scenario**: A `FixedSizeList` column where every inner value (not the outer list item itself) +is null — for example, `FixedSizeList<nullable Float32, dim=4>` where all eight Float32 values +across two outer rows are null. + +**Buggy writer (Lance < 11.1.0)**: The encoder wrote `bits_per_value=0` into the FullZip page +layout. Readers of any version rejected these pages with an error, so the data was unreadable +regardless of reader version. + +**Fixed writer (Lance ≥ 11.1.0)**: The encoder stores per-row validity bytes for the null inner +values, producing `bits_per_value > 0`. The fixed reader (Lance ≥ 11.1.0) can also decode the +old buggy pages, so old files written before 11.1.0 become readable after upgrading. + +**Forward compatibility**: Files containing this pattern written by Lance ≥ 11.1.0 are **not +readable by Lance < 11.1.0**. Old readers encounter the `Compression::Constant` inner encoding +in the FSL descriptor and panic rather than returning an error. + +**Minimum reader version for new files**: Lance 11.1.0. diff --git a/docs/src/format/index.md b/docs/src/format/index.md index 7bc346cdc1e..055c9129c5f 100644 --- a/docs/src/format/index.md +++ b/docs/src/format/index.md @@ -1,82 +1,51 @@ -# Lance Format Specification +# Lance Lakehouse Format Specifications -Lance is a **Lakehouse Format** that spans three specification layers: file format, table format, and catalog spec. +Lance is a lakehouse format defined as a stack of interoperating specifications, rather than as a single file format or metadata layout. The storage-facing layers cover files, tables, indices, and catalogs. A unified namespace interface sits above those layers and gives engines a consistent way to work with Lance tables across catalog implementations. -## Understanding the Lakehouse Stack +## Architecture Overview -To understand where Lance fits in the data ecosystem, let's first map out the complete lakehouse technology stack. -The modern lakehouse architecture consists of six distinct layers: +Modern lakehouses are built from complementary layers. Lance keeps those layers intentionally decoupled so that the file format, table metadata, indices, and catalogs can evolve independently without forcing lock-in across the stack. ![Lakehouse Stack](../images/lakehouse_stack.png) -### 1. Object Store +At a high level: -At the foundation lies the **object store**—storage systems characterized by their object-based simple hierarchy, -typically providing highly durable guarantees with HTTP-based communication protocols for data transfer. -This includes systems like S3, GCS, and Azure Blob Storage. +- The **file format** stores column data in large pages optimized for random access and avoids row groups. +- The **table format** manages fragments, manifests, deletions, schema evolution, and ACID commits. +- The **index formats** define redundant search structures such as scalar, vector, full-text, and system indices. +- The **catalog specs** define how tables are discovered, registered, and coordinated across engines and services. +- The **namespace client spec** provides a unified interface for engines to interact with any catalog implementations. -### 2. File Format +The layers are designed so that only table readers, table writers, and index readers or writers need to understand the on-disk Lance file layout. -Above the storage layer, the **file format** describes how a single file should be stored on disk. -This is where formats like Apache Parquet operate, defining the internal structure, encoding, and compression of individual data files. +## Design Themes -### 3. Table Format +### File Format -The **table format** layer describes how multiple files work together to form a logical table. -The key feature that modern table formats enable is transactional commits and read isolation to allow multiple writers and readers to safely operate against the same table. -All major open source table formats including Iceberg and Lance implement these features through MVCC (Multi-Version Concurrency Control), -where each commit atomically produces a new table version, and all table versions form a serializable history for the specific table. -This also unlocks features like time travel and makes features like schema evolution easy to develop. +The Lance file format is optimized for cloud object storage and highly selective reads. It avoids Parquet-style row groups, uses structural encodings for efficient random access, and keeps statistics and search structures out of the file format so those concerns can evolve independently as indices. -### 4. Catalog Spec +### Table Format -The **catalog spec** defines how any system can discover and manage a collection of tables within storage. -This is where the lower storage and format stack meets the upper service and compute stack. +The Lance table format organizes data in two dimensions: rows are grouped into fragments, and each fragment can contain multiple data files, each contributing a subset of columns. This makes column additions and backfills primarily metadata operations instead of data rewrites, which is especially useful for feature engineering and embedding workflows. -Table formats require at least a way to list all available tables and to describe, add and drop tables in the list. -This is necessary for actually building the so-called **connectors** in compute engines so they can discover and start working on the table according to the format. -Historically, Hive has defined the Hive MetaStore spec that is sufficient for most table formats including Delta Lake, Hudi, Paimon, and also Lance. -Iceberg offers its unique Iceberg REST Catalog spec. +### Index Formats -From the top down, projects like Apache Polaris, Unity Catalog, and Apache Gravitino usually offer additional specification for operating against table derivatives -(e.g. views, materialized views, user-defined table functions) and objects used in table operations (e.g. user-defined functions, policies). +Indices are first-class table objects. Lance tables define how indices are discovered, versioned, and coordinated transactionally. The index formats themselves remain decoupled from both the file encoding and the table manifest structure. -This intersection between top and bottom stack is also why typically a catalog service would provide both the catalog specifications offered by the format side for easy connectivity to compute engines, -as well as providing their own APIs for extended management features. +### Catalog Specs -Another key differentiation of a catalog spec versus a catalog service is that there can be multiple different vendors implementing the same spec. -For example, for Polaris REST spec we have open source Apache Polaris server, Snowflake Horizon Catalog, and Polaris-compatible services in AWS Glue, Azure OneLake, etc. +Lance provides both storage-native and service-oriented catalog options. The [Directory Catalog](catalog/dir/index.md) supports zero-infrastructure deployments directly on object stores, while the [REST Catalog](catalog/rest/index.md) standardizes enterprise-facing APIs and can act as an external manifest store. -### 5. Catalog Service +### Namespace Client Spec -A **catalog service** implements one or more catalog specifications to provide both table metadata and optionally continuous background maintenance (compaction, optimization, index updates) that table formats require to stay performant. -Catalog services typically implement multiple specifications to support different table formats. -For example, Polaris, Unity and Gravitino all support the Iceberg REST catalog specification for Iceberg tables, and have their own generic table API for other table formats. +The [Namespace Client Spec](namespace/index.md) provides a language-agnostic interface for engines to interact with any catalog implementation, including Lance-native catalogs and third-party catalog systems. This abstraction allows applications to switch between directory-based, REST-based, and third-party catalogs without changing their code. -Since table formats are static specifications, catalog services supply the active operational work needed for production deployments. -This is often where open source transitions to commercial offerings, as open source projects typically provide metadata functionality, while commercial solutions offer the full operational experience including automated maintenance. -There are also open source solutions like Apache Amoro emerging to fill this gap with complete open source catalog service implementations that offer both table metadata access and continuous optimization. +## Specifications -### 6. Compute Engine +The main specification entry points are: -Finally, **compute engines** are the workhorses that visit catalog services and leverage their knowledge of file formats, table formats, and catalog specifications to perform complex data workflows, including SQL queries, analytics processing, vector search, full-text search, and machine learning training. -All sorts of applications can be built on top of compute engines to serve more concrete analytics, ML and AI use cases. - -### The Overall Lakehouse Architecture - -In the lakehouse architecture, compute power resides in the object store, catalog services, and compute engines. -The middle three layers (file format, table format, catalog spec) are specifications without compute. -This separation enables portability and interoperability. - -## Understanding Lance as a Lakehouse Format - -Lance spans all three specification layers: - -1. **File Format**: The Lance columnar file format, [read specification →](file/index.md) -2. **Table Format**: The Lance table format, [read specification →](table/index.md) -3. **Catalog Spec**: The Lance Namespace specification, [read specification →](namespace/index.md) - -For comparison: - -- **Apache Iceberg** operates at the table format and catalog spec layers, using Apache Parquet, Apache Avro and Apache ORC as the file format -- **Delta Lake** and **Apache Hudi** operate at only the table format layer, using Apache Parquet as the file format \ No newline at end of file +1. **File Format**: [Lance file format](file/index.md) +2. **Table Format**: [Lance table format](table/index.md) +3. **Index Formats**: [Scalar, vector, and system index formats](index/index.md) +4. **Catalog Specs**: [Directory and REST catalog specs](catalog/index.md) +5. **Namespace Client Spec**: [Lance namespace interface](namespace/index.md) diff --git a/docs/src/format/table/index/.pages b/docs/src/format/index/.pages similarity index 66% rename from docs/src/format/table/index/.pages rename to docs/src/format/index/.pages index 74a6e37ce75..1880de92cf6 100644 --- a/docs/src/format/table/index/.pages +++ b/docs/src/format/index/.pages @@ -1,6 +1,6 @@ -title: Index +title: Index Formats nav: - - index.md + - Overview: index.md - Scalar Indices: scalar - Vector Indices: vector - System Indices: system diff --git a/docs/src/format/index/index.md b/docs/src/format/index/index.md new file mode 100644 index 00000000000..9aff9361707 --- /dev/null +++ b/docs/src/format/index/index.md @@ -0,0 +1,331 @@ +# Indices in Lance + +Lance treats indices as independent, redundant data structures layered on top of table row identifiers. This keeps the file format free of built-in search structures and lets index formats evolve independently from the table layout. + +Lance supports three main categories of indices to accelerate data access: scalar +indices, vector indices, and system indices. + +**Scalar indices** accelerate queries on scalar data types such as integers, timestamps, +and strings. This includes primary skipping structures such as [zone maps](scalar/zonemap.md) +as well as secondary structures such as [B-trees](scalar/btree.md), [bitmap indices](scalar/bitmap.md), +and [full-text search indices](scalar/fts.md). They typically accept predicates such as equality, +range, set-membership, or token matches and return matching row identifiers. + +<figure markdown="span"> + ![](./scalar_index.drawio.svg) +</figure> + +**[Vector indices](./vector/index.md)** are specialized for approximate nearest neighbor search on +high-dimensional embeddings. Examples include IVF-based layouts and HNSW graphs. Instead of scalar +predicates, vector indices receive a query vector and return row identifiers plus distance scores. + +**System indices** are auxiliary structures that support internal table maintenance and row-identifier +resolution. They are not queried directly by end users. Examples include the [Fragment Reuse Index](system/frag_reuse.md), +which supports efficient remapping after compaction. + +## Design + +Lance indices are designed with the following design choices in mind: + +1. **Indices are loaded on demand**: A dataset can be loaded and read without loading any indices. + Indices are only loaded when a query can benefit from them. + This design minimizes memory usage and speeds up dataset opening time. +2. **Indices can be loaded progressively**: indices are designed so that only the necessary parts + are loaded into memory during query execution. For example, when querying a B-tree index, + it loads a small page table to figure out which pages of the index to load for the given query, + and then only loads those pages to perform the indexed search. This amortizes the cost of + cold index queries, since each query only needs to load a small portion of the index. +3. **Indices can be coalesced to larger units than fragments.** Indices are much smaller than + data files, so it is efficient to coalesce index segments to cover multiple fragments. + This reduces the number of index files that need to be opened during query execution and + then number of unique index data structures that need to be queried. +4. **Index files are immutable once written, similar to data files.** They can be modified only + by creating new files. This means they can be safely cached in memory or on disk without + worrying about consistency issues. + +## Basic Concepts + +An index in Lance is defined over a specific column (or multiple columns) of a dataset. +It is identified by its name. + +An index is made up of multiple **index segments**, identified by their unique UUIDs. +Each segment is an independent, self-contained index covering a subset of the data. + +Each index segment covers a disjoint subset of fragments in the dataset. The segments must cover +all rows in the fragments they cover, with one exception: if a fragment has delete markers at the time +of index creation, the index segment is allowed to not contain the deleted rows. The fragments an index +covers are those recorded in the `fragment_bitmap` field. + +Index segments together **do not** need to cover all fragments. This means an index isn't required to +be fully up-to-date. When this happens, engines can split their queries into indexed and unindexed +subplans and merge the results. + +<figure markdown="span"> + ![](./starter-example.drawio.svg) + <figcaption>Abstract layout of a typical dataset, with three fragments and two indices. + </figcaption> +</figure> + +Consider the example dataset in the figure above: + +- The dataset contains three fragments with ids 0, 1, 2. Fragment 1 has 10 deleted rows, indicated + by the deletion file. +- There is an index called "id_idx", which has two segments: one covering fragments 0 and another covering + fragment 1. Fragment 2 is not covered by the index. Queries using this index will need to query both + segments and then scan fragment 2 directly. Additionally, when querying the segment covering fragment 1, + the engine will need to filter out the 10 deleted rows. +- There is another index called "vec_idx", which has a single segment covering all three fragments. + Because it covers all fragments, queries using this index do not need to scan any fragments directly. + They do, however, need to filter out the 10 deleted rows from fragment 1. + +## Index Storage + +The content of each index is stored at the `_indices/{UUID}` directory under the [base path](../table/layout.md#base-path-system). +We call this location the **index directory**. +The actual content stored in the index directory depends on the index type. These can be +arbitrary files defined by the index implementation. However, often they are made up of +Lance files containing the index data structures. This allows reuse of the existing Lance +file format code for reading and writing index data. + +## Creating and Updating Index Segments + +Index segments are created and updated through a transactional process: + +1. **Build the index data**: Read the relevant column data from the fragments to be indexed + and construct the index data structures. Write these to files in a new `_indices/{UUID}` + directory, where `{UUID}` is a newly generated unique identifier. + +2. **Prepare the metadata**: Create an `IndexMetadata` message with: + - `uuid`: The newly generated UUID + - `name`: The index name (must match existing segments if adding to an existing index) + - `fields`: The columns the index is keyed on, in key order. + - `covering_fields`: An independent declaration of the columns whose values the index carries, + letting a query that only projects those columns be answered without a fragment take. A field + may appear in both lists when the index carries a value it is also keyed on. Empty for an index + that carries no extra columns. Declaring a column here does not by itself make it servable -- + see [Serving carried columns](#serving-carried-columns). + - `fragment_bitmap`: The set of fragment IDs covered by this segment + - `index_details`: Index-specific configuration and parameters + - `version`: The format version of this index type + - See the full protobuf definition in [table.proto](https://github.com/lance-format/lance/blob/main/protos/table.proto). + +3. **Commit the transaction**: Write a new manifest that includes the new index segment + in its `IndexSection`. This is done atomically using the same transaction mechanism + as data writes. + +When updating a column in place (without deleting the row), the engine must remove the +affected fragment IDs from the `fragment_bitmap` field of any index segment whose `fields` +or `covering_fields` include that column. This marks those fragments as needing re-indexing +without invalidating the entire segment and prevents invalid data from being read from the +index. + +## Index Compatibility + +Before using an index segment, engines must verify they support it: + +1. **Check the index type**: The `index_details` field contains a protobuf `Any` message + whose type URL identifies the index type (e.g., B-tree, IVF, HNSW). If the engine + does not recognize the type, it should skip this index segment. + +2. **Check the version**: The `version` field in `IndexMetadata` indicates the format + version of the index segment. If the engine does not support this version, it should + skip this index segment. This allows index formats to evolve over time while + maintaining backwards compatibility. + +When an engine cannot use an index segment, it should fall back to scanning the +fragments that would have been covered by that segment. + +### Covering-field contracts + +A manifest that uses independent key and covering declarations must set +`FLAG_COVERED_INDEX_METADATA` and `FLAG_INDEPENDENT_COVERING_FIELDS` in both its reader +and writer feature flags. In this contract, `fields` contains only the key fields and +`covering_fields` separately contains the carried fields. The lists have no positional +relationship and may overlap. The index dependency set is their union. + +For backwards compatibility, a manifest with `FLAG_COVERED_INDEX_METADATA` but without +`FLAG_INDEPENDENT_COVERING_FIELDS` uses the legacy contract: the carried columns form the +trailing suffix of `fields` in emission order, so `covering_fields` equals +`fields[len(fields) - len(covering_fields)..]`. A column the index is both keyed on and +carries appears twice, once as a key and once in the carried suffix: `fields = [1, 1]` with +`covering_fields = [1]` is keyed on `1` and carries `1`. Readers must select the contract +from the manifest flags before interpreting any `IndexMetadata`. + +### Activating independent declarations + +Setting `FLAG_INDEPENDENT_COVERING_FIELDS` reinterprets every `IndexMetadata` in the +manifest's index section, so a writer must not set it while any entry remains in legacy +form. A legacy `fields = [vector, payload]` with `covering_fields = [payload]` reads +under the new contract as keyed on both columns, and a query on `payload` would then be +planned against an index that is really keyed on `vector`. + +Activation is therefore a single atomic manifest change. A writer that sets the flag must +normalize every entry of the index section in that same manifest to the key prefix: +replace `fields` with `fields[..len(fields) - len(covering_fields)]` and leave +`covering_fields` unchanged. For example, `fields = [1, 1]` with `covering_fields = [1]` +becomes `fields = [1]` with `covering_fields = [1]`. A +writer that cannot normalize an entry must not set the flag; it may instead set the flag +only on a manifest in which no entry declares covering fields at all. No committed +manifest may set the flag while holding a legacy-form entry. + +A commit that loses a conflict and retries must normalize the index section of the +manifest it is retrying against rather than the one it first read, so an entry another +writer committed in between is never carried forward unnormalized. + +Derived manifests retain the flag. Every manifest built from one that sets it keeps the +bit set, unless a writer converts every entry back to the legacy contract in one atomic +change of its own. Because `FLAG_COVERED_INDEX_METADATA` is recomputed from covering +declarations on every commit, a writer that sets `FLAG_INDEPENDENT_COVERING_FIELDS` must +also keep `FLAG_COVERED_INDEX_METADATA` set in both feature words whenever the independent +bit is set, including manifests with no covering entries and manifests that drop the last +covering index. The two bits are retained together until an atomic reverse conversion +clears them together. + +### Serving carried columns + +`IndexMetadata.covering_fields` records the columns an index segment *declares* it +carries. It does not establish that the segment's storage holds their values. + +**The segment's storage schema is authoritative.** Before answering a query from a +carried column, an engine must confirm that column is present and bound to the declared +logical field in the storage it opened, and fall back to a take against the base table +when it cannot. A segment whose metadata identifies a column its storage does not hold is +a legal state, not corruption: a maintenance operation that cannot carry the payload +through a rebuild is permitted to withdraw it and leave the `covering_fields` listed in +the metadata. + +!!! note "Capability varies by segment" + + Whether a segment's storage holds a declared column depends on the index type, on the + writer that produced the segment, and on what later maintenance did to it, so one + logical index may hold values for some of its segments and not others. An engine + therefore verifies each selected segment rather than inferring capability from the + index type, the writer version, or the `covering_fields` metadata alone, and serves + from the base table every column it cannot verify. + +## Loading an index + +When loading an index: + +1. Get the offset to the index section from the `index_section` field in the [manifest](../table/index.md#manifest). +2. Read the index section from the manifest file. This is a protobuf message of type `IndexSection`, which + contains a list of `IndexMetadata` messages, each describing an index segment. +3. Read the index files from the `_indices/{UUID}` directory under the dataset directory, + where `{UUID}` is the UUID of the index segment. + +!!! tip "Optimizing manifest loading" + + When the manifest file is small, you can read and cache the index section eagerly. This avoids + an extra file read when loading indices. + +The `IndexMetadata` message contains important information about the index segment: + +- `uuid`: the unique identifier of the index segment. +- `fields`: the columns the index is keyed on, in key order. +- `covering_fields`: the independently declared columns whose values the index carries alongside + its own data. A field may also occur in `fields`. Empty for an index that carries no extra + columns. Every id in `covering_fields` names a top-level field. Covering a struct column + carries the whole struct, its children included, as one column. This declaration is not + authoritative for what the segment can serve -- see + [Serving carried columns](#serving-carried-columns) and the legacy interpretation under + [Covering-field contracts](#covering-field-contracts). +- `fragment_bitmap`: the set of fragment IDs covered by this index segment. +- `index_details`: a protobuf `Any` message that contains index-specific details, such as index type, + parameters, and storage format. This allows different index types to store their own metadata. + +<details> + <summary>Full protobuf definitions</summary> + +There are both part of the `table.proto` file in the Lance source code. + +```protobuf +%%% proto.message.IndexSection %%% + +%%% proto.message.IndexMetadata %%% +``` + +</details> + +## Handling deleted and invalidated rows + +Since index segments are immutable, they may contain references to rows that have been deleted +or updated. These should be filtered out during query execution. + +<figure markdown="span"> + ![](./indices-fragment handling.drawio.svg) + <figcaption>Representation of index segment covering fragments that have deleted rows, + completely deleted fragments, and updated fragments. + </figcaption> +</figure> + +There are four situations to consider: + +1. **A fragment has some deleted rows.** A few of the rows in the fragment have been marked + as deleted, but some of the rows are still present. The row addresses from the deletion + file should be used to filter out results from the index. +2. **A fragment has been completely deleted.** This can be detected by checking if a + fragment ID present in the fragment bitmap is missing from the dataset. + Any row addresses from this fragment should be filtered out. +3. **A fragment has had one of the index's columns updated in place.** This cannot be detected + just by examining metadata. To prevent reading invalid data, the engine should filter out any + row addresses that are not in the index's current `fragment_bitmap`. + The column need not be one the index is keyed on: every column in the union of `fields` and + `covering_fields` counts. A carried column can be updated while the keyed column is untouched, + and a segment left covering that fragment would answer from an obsolete carried value. +4. **A fragment has an updated value in an [overlay file](../table/data_overlay_file.md).** + This can be detected by checking if any of the fragments in the index's `fragment_bitmap` + have overlay files. For each overlay whose `committed_version` is greater than the index + segment's `dataset_version`, the overlay carries updated values not reflected in the index, + so its covered rows must be excluded from index results. Excluded rows are re-evaluated + against their current (overlaid) values on the flat path — dropping them without + re-evaluation would silently lose rows that match under the new value. Exclusion is + field-aware: only overlays covering a column in the union of the index's `fields` and + `covering_fields` matter. Restricting this to the keyed columns would leave a fragment + covered after an overlay updated a carried one, and the index would then serve a stale + carried value. + You may exclude just the affected rows or the whole fragment; the latter is simpler and + safer but re-evaluates more rows than necessary. + See [Data Overlay Files](../table/data_overlay_file.md#index-integration) + for the exclusion set, re-evaluation, and correctness invariant. + +## Compaction and remapping + +When fragments are compacted, the row addresses of the rows in the fragments change. +This means that any index segments referencing those fragments will no longer point +to existing row addresses. There are three ways to handle this: + +<figure markdown="span"> +![](./indices-compaction.drawio.svg) +</figure> + +1. Do nothing and let the index segment not cover those fragments anymore. This approach is + simple and valid, but it means compaction can immediately make an index out-of-date. This + is the worst options for query performance. + +2. Immediately rewrite the index segments with the row addresses remapped. This approach + ensures the index is kept up-to-date, but it incurs significant write amplification + during compaction. + +3. Create a [Fragment Reuse Index](system/frag_reuse.md) that maps old row addresses to new + row addresses. This allows readers to remap the row addresses in memory upon reading + the index segments. This approach adds some IO and computation overhead during query + execution, but avoids write amplification during compaction. + +## Stable Row ID for Index + +Indices can optionally use stable row IDs instead of row addresses. A stable row ID is a +logical identifier that remains constant even when rows are moved during compaction. + +**Benefits:** + +- No remapping needed after compaction +- Updates only invalidate the index if data in the union of its `fields` and + `covering_fields` changes + +**Tradeoffs:** + +- Requires an additional lookup to translate stable row IDs to physical row addresses + at query time + +This feature is currently experimental. Performance evaluation is ongoing to determine +when the tradeoff is worthwhile. diff --git a/docs/src/format/table/index/indices-compaction.drawio.svg b/docs/src/format/index/indices-compaction.drawio.svg similarity index 100% rename from docs/src/format/table/index/indices-compaction.drawio.svg rename to docs/src/format/index/indices-compaction.drawio.svg diff --git a/docs/src/format/table/index/indices-fragment handling.drawio.svg b/docs/src/format/index/indices-fragment handling.drawio.svg similarity index 100% rename from docs/src/format/table/index/indices-fragment handling.drawio.svg rename to docs/src/format/index/indices-fragment handling.drawio.svg diff --git a/docs/src/format/table/index/scalar/.pages b/docs/src/format/index/scalar/.pages similarity index 76% rename from docs/src/format/table/index/scalar/.pages rename to docs/src/format/index/scalar/.pages index ba297222b07..b166362f8f3 100644 --- a/docs/src/format/table/index/scalar/.pages +++ b/docs/src/format/index/scalar/.pages @@ -7,4 +7,6 @@ nav: - Bloom Filter: bloom_filter.md - Full Text Search: fts.md - N-gram: ngram.md + - MinHash LSH (experimental): minhash_lsh.md + - FM-Index: fmindex.md - RTree: rtree.md diff --git a/docs/src/format/table/index/scalar/bitmap.md b/docs/src/format/index/scalar/bitmap.md similarity index 100% rename from docs/src/format/table/index/scalar/bitmap.md rename to docs/src/format/index/scalar/bitmap.md diff --git a/docs/src/format/table/index/scalar/bloom_filter.md b/docs/src/format/index/scalar/bloom_filter.md similarity index 88% rename from docs/src/format/table/index/scalar/bloom_filter.md rename to docs/src/format/index/scalar/bloom_filter.md index 5a0e08e4228..6d924f8705d 100644 --- a/docs/src/format/table/index/scalar/bloom_filter.md +++ b/docs/src/format/index/scalar/bloom_filter.md @@ -4,6 +4,9 @@ Bloom filters are probabilistic data structures that allow for fast membership t They are space-efficient and can test whether an element is a member of a set. It's an inexact filter - they may include false positives but never false negatives. +In addition, since finding NULLs is a common query pattern, the index also maintains a +bitmap of null rows which allows it to return exact results for IS NULL queries. + ## Index Details ```protobuf @@ -32,6 +35,13 @@ The bloom filter index stores zone-based bloom filters in a single file: |---------------------------|--------|-------------------------------------------------------------| | `bloomfilter_item` | String | Expected number of items per zone (default: "8192") | | `bloomfilter_probability` | String | False positive probability (default: "0.00057", ~1 in 1754) | +| `null_bitmap` | UInt32 | Index of null bitmap global buffer | + +### Global Buffers + +| Metadata Key | Description | +|---------------------|------------------------------------------------------------| +| `null_bitmap` | A serialized RowAddrTreeMap specifying which rows are null | ## Bloom Filter Spec @@ -122,10 +132,11 @@ Offset 60-63: Block 1, Word 7 (32-bit LE) ## Accelerated Queries -The bloom filter index provides inexact results for the following query types: +The bloom filter index provides inexact results for the following query types (nullability queries +return exact results): | Query Type | Description | Operation | Result Type | |------------|---------------------------|-------------------------------------------|-------------| | **Equals** | `column = value` | Tests if value exists in bloom filter | AtMost | | **IsIn** | `column IN (v1, v2, ...)` | Tests if any value exists in bloom filter | AtMost | -| **IsNull** | `column IS NULL` | Returns zones where has_null is true | AtMost | \ No newline at end of file +| **IsNull** | `column IS NULL` | Returns zones where has_null is true | Exact | diff --git a/docs/src/format/table/index/scalar/btree.md b/docs/src/format/index/scalar/btree.md similarity index 100% rename from docs/src/format/table/index/scalar/btree.md rename to docs/src/format/index/scalar/btree.md diff --git a/docs/src/format/index/scalar/fmindex.md b/docs/src/format/index/scalar/fmindex.md new file mode 100644 index 00000000000..1138024896f --- /dev/null +++ b/docs/src/format/index/scalar/fmindex.md @@ -0,0 +1,64 @@ +# FM-Index (Full-text / Substring / Regex Search) + +The FM-Index (Ferragina-Manzini Index) is a compressed substring index based on the Burrows-Wheeler Transform (BWT). Unlike traditional inverted indexes (Full-Text Search) which index distinct words, the FM-Index enables efficient **arbitrary substring search**, **prefix match**, and **suffix/regular-expression search** directly on raw bytes. + +In Lance, the FM-Index is designed to scale dynamically across millions of documents or large-scale datasets, and is partitioned using Lance's **Segmented Index** architecture to support incremental appends, disjoint fragment tracking, and segment merging. + +## High-Level Architecture + +The FM-Index indexes raw text by treating columns of strings or binary payloads as raw byte arrays. + +``` + +----------------------------------------+ + | Lance Dataset | + | (Disjoint groups of Fragments 0..N) | + +----------------------------------------+ + | + Divide fragments into num_segments + | + v + +----------------------------------------+ + | Segmented Index | + | +-----------+ +-----------+ +-------+ | + | | Segment 1 | | Segment 2 | | ... | | + | | (FM-Idx) | | (FM-Idx) | | | | + | +-----------+ +-----------+ +-------+ | + +----------------------------------------+ +``` + +Each segment contains its own self-contained physical FM-Index mapping byte sub-sequences to Lance global row IDs. + +## Data Normalization & Sanitization + +The FM-Index is **normalization-independent by design** because it operates entirely on raw bytes. + +### Byte Sanitization vs. Text Normalization + +1. **Byte Sanitization (Core Index Layer)**: + The physical FM-Index uses specific sentinel bytes internally to mark boundaries: + - `\x00` is reserved as the global Burrows-Wheeler Transform (BWT) terminator character. + - `\xFF` is reserved as the document/row separator character. + + To avoid breaking the indexing structures, any incoming occurrences of `\x00` or `\xFF` are sanitized by remapping them to space (`\x20`) characters at index-build time. No other bytes are changed in this layer. + +2. **Text Normalization (User/Application Layer)**: + Because the index faithfully maps raw bytes, any semantic normalization (such as case folding `Hello` -> `hello`, Unicode NFKC normalization, stemming, or whitespace collapsing) is fully decoupled from the core index engine: + - To build a case-insensitive search index, users apply a lowercase transform to the column *prior* to indexing. + - When querying, the user's query text must undergo the exact same normalization pipeline. + +## Configurable Segment Partitioning + +Merging or appending to BWT-based indexes cannot be done via simple concatenation; the BWT suffix array must be reconstructed by re-reading the text and rebuilding. To balance build cost and search performance, Lance allows configuring how fragments map to index segments. + +- **`num_segments` parameter**: Configured at index-creation time. If `num_segments` is specified (e.g. `num_segments = 4`), Lance splits the target dataset fragments into disjoint subsets and builds independent FM-Index segments over each chunk. +- **Unindexed Appends**: When new fragments are appended to the dataset, a subsequent `create_index` execution with unindexed fragment coverage will construct a new separate segment representing only those new fragments, keeping existing segments fully intact. +- **Segment Merging**: Multiple existing index segments can be merged into a single segment under Lance's `merge_segments` protocol. Lance unions the fragment coverage bitmaps of the selected segments, re-reads the raw text from those covered fragments, and constructs a fresh unified FM-Index. + +## Query Evaluation + +When a substring query is submitted (e.g., `CONTAINS(column, "query_string")`): +1. The search string is sanitized (remapping any `\x00` or `\xFF` to spaces) and optionally normalized if the target index is normalized. +2. The query is dispatched across all active segments in the logical index in parallel. +3. Each segment performs a BWT backward-search to locate occurrences of the pattern. +4. Matching offsets are mapped back to absolute dataset Row IDs. +5. Results from all segments are unioned to produce the final selection. diff --git a/docs/src/format/table/index/scalar/fts.md b/docs/src/format/index/scalar/fts.md similarity index 56% rename from docs/src/format/table/index/scalar/fts.md rename to docs/src/format/index/scalar/fts.md index 5af36d294b8..563d7ebf994 100644 --- a/docs/src/format/table/index/scalar/fts.md +++ b/docs/src/format/index/scalar/fts.md @@ -18,6 +18,8 @@ The FTS index consists of multiple files storing the token dictionary, document 3. `invert.lance` - Compressed posting lists for each token 4. `metadata.lance` - Index metadata and configuration +An FTS index may contain multiple partitions. Each partition has its own set of token, document, and posting list files, prefixed with the partition ID (e.g. `part_0_tokens.lance`, `part_0_docs.lance`, `part_0_invert.lance`). The `metadata.lance` file lists all partition IDs in the index. At query time, every partition must be searched and the results combined to produce the final ranked output. Fewer partitions generally means better query performance, since each partition requires its own token dictionary lookup and posting list scan. The number of partitions is controlled by the training configuration -- specifically `LANCE_FTS_TARGET_SIZE` determines how large each merged partition can grow (see [Training Process](#training-process) for details). + ### Token Dictionary File Schema | Column | Type | Nullable | Description | @@ -32,6 +34,15 @@ The FTS index consists of multiple files storing the token dictionary, document | `_rowid` | UInt64 | false | Document row ID | | `_num_tokens` | UInt32 | false | Number of tokens in the document | +Partitioned `docs.lance` files may include the optional schema metadata key +`total_tokens`. Its decimal `UInt64` value is the sum of `_num_tokens` in that +file. `_num_tokens` remains the canonical per-document data. Readers use the +metadata value to construct exact corpus statistics without scanning the column; +when the key is absent, they compute the sum from `_num_tokens`. Writers produce +the key from the same document table in the same file commit. A present value +that cannot be parsed, or that differs when `_num_tokens` is subsequently +loaded, is file corruption. + ### FTS List File Schema | Column | Type | Nullable | Description | @@ -41,6 +52,8 @@ The FTS index consists of multiple files storing the token dictionary, document | `_length` | UInt32 | false | Number of documents containing the token | | `_compressed_position` | List<List<LargeBinary>> | true | Optional compressed position lists for phrase queries | +The posting-list file schema metadata includes `posting_block_size`, the number of documents encoded per compressed posting block. Older indexes that do not have this metadata use the legacy block size `128`. + ### Metadata File Schema The metadata file contains JSON-serialized configuration and partition information: @@ -65,6 +78,7 @@ The metadata file contains JSON-serialized configuration and partition informati | `min_gram` | UInt32 | 2 | Minimum n-gram length (only for ngram tokenizer) | | `max_gram` | UInt32 | 15 | Maximum n-gram length (only for ngram tokenizer) | | `prefix_only` | Boolean | false | Generate only prefix n-grams (only for ngram tokenizer) | +| `block_size` | UInt32 | 128 | Documents per compressed posting block. Must be 128 or 256. Missing values from older indexes read as 128. `256` is experimental and may introduce breaking changes. | ## Tokenizers @@ -78,9 +92,24 @@ The full text search index supports multiple tokenizer types for different text | **whitespace** | Splits only on whitespace characters | Preserve punctuation | | **raw** | No tokenization, treats entire text as single token | Exact matching | | **ngram** | Breaks text into overlapping character sequences | Substring/fuzzy search | +| **icu** | ICU dictionary-based Unicode word segmentation | Mixed-language text | +| **icu/split** | ICU segmentation with simple-style delimiter splitting | Mixed-language identifiers | | **jieba/*** | Chinese text tokenizer with word segmentation | Chinese text | | **lindera/*** | Japanese text tokenizer with morphological analysis | Japanese text | +#### ICU Tokenizer (Mixed-language text) + +The ICU tokenizer uses Unicode word boundary rules and dictionary-based segmentation for complex scripts. It is useful for mixed-language text where the default `simple` tokenizer would keep an unspaced CJK span as one large token. + +By default, Lance preserves ICU word segments as returned by ICU. Use `base_tokenizer: "icu/split"` to split ICU word segments again on non-alphanumeric delimiters such as underscores and punctuation. For example, `hello_world �ん���世界` is tokenized as `hello`, `world`, `�ん���`, and `世界`. + +- **Models**: Uses compiled ICU4X segmenter data bundled with Lance +- **Usage**: Specify as `icu`, or `icu/split` to split punctuation-delimited identifiers +- **Features**: + - Unicode-aware word boundary detection + - Dictionary-based segmentation for Chinese, Japanese, Khmer, Lao, Myanmar, and Thai + - No external language model download required + #### Jieba Tokenizer (Chinese) Jieba is a popular Chinese text segmentation library that uses a dictionary-based approach with statistical methods for word segmentation. @@ -189,6 +218,58 @@ address.city:San address.city:Francisco ``` +## Training Process + +Building an FTS index is a multi-phase pipeline: the source column is scanned, documents are tokenized in parallel, intermediate results are spilled to part files on disk, and the part files are merged into final output partitions. + +### Phase 1: Tokenization + +The input column is read as a stream of record batches and dispatched to a pool of tokenizer worker tasks. Each worker tokenizes documents independently, accumulating tokens, posting lists, and document metadata in memory. + +When a worker's accumulated data reaches the partition size limit or the document count hits `u32::MAX`, it flushes the data to disk as a set of part files (`part_<id>_tokens.lance`, `part_<id>_invert.lance`, `part_<id>_docs.lance`). A single worker may produce multiple part files if it processes enough data. + +### Phase 2: Merge + +After all workers finish, the part files are merged into output partitions. Part files are streamed with bounded buffering so that not all data needs to be loaded into memory at once. For each part file, the token dictionaries are unified, document sets are concatenated, and posting lists are rewritten with adjusted IDs. + +When a merged partition reaches the target size, it is written to the destination store and a new one is started. After all part files are consumed the final partition is flushed, and a `metadata.lance` file is written listing the partition IDs and index parameters. + +### Configuration + +| Environment Variable | Default | Description | +|----------------------------|----------------------------------|-----------------------------------------------------------------------------------------------------------------------| +| `LANCE_FTS_NUM_SHARDS` | Number of compute-intensive CPUs | Number of parallel tokenizer worker tasks. Higher values increase indexing throughput but use more memory. | +| `LANCE_FTS_PARTITION_SIZE` | 256 (MiB) | Maximum uncompressed size of a worker's in-memory buffer before it is spilled to a part file. | +| `LANCE_FTS_TARGET_SIZE` | 4096 (MiB) | Target uncompressed size for merged output partitions. Fewer, larger partitions improve query performance. | + +### Memory and Performance Considerations + +Memory usage is primarily determined by two factors: + +- **`LANCE_FTS_NUM_SHARDS`** -- Each worker holds an independent in-memory buffer. Peak memory is roughly `NUM_SHARDS * PARTITION_SIZE` plus the overhead of token dictionaries and posting list structures. +- **`LANCE_FTS_PARTITION_SIZE`** -- Larger values reduce the number of part files and make the merge phase cheaper. Smaller values reduce per-worker memory at the cost of more part files. + +Merge phase memory is bounded by the streaming approach: part files are loaded one at a time with a small concurrency buffer. The merged partition's in-memory size is bounded by `LANCE_FTS_TARGET_SIZE`. + +Building an FTS index requires temporary disk space to store the part files generated during tokenization. The amount of temporary space depends heavily on whether position information is enabled. An index with `with_position: true` stores the position of every token occurrence in every document, which can easily require 10x the size of the original column or more in temporary disk space. An index without positions tends to be smaller than the original column and will typically need less than 2x the size of the column in total disk space. + +Performance tips: + +- Larger `LANCE_FTS_TARGET_SIZE` produces fewer output partitions, which is beneficial for query performance because queries must scan every partition's token dictionary. When memory allows, prefer fewer, larger partitions. +- `with_position: true` significantly increases index size because term positions are stored for every occurrence. Only enable it when phrase queries are needed. +- The ngram tokenizer generates many more tokens per document than word-level tokenizers, so expect larger index sizes and higher memory usage. + +### Distributed Training + +The FTS index supports distributed training where different worker nodes each index a subset of the data and the results are assembled afterward. + +1. Each distributed worker is assigned a **fragment mask** (`(fragment_id as u64) << 32`) that is OR'd into the partition IDs it generates, ensuring globally unique IDs across workers. +2. Workers set `skip_merge: true` so they write their part files directly without running the merge phase. +3. Instead of a single `metadata.lance`, each worker writes per-partition metadata files named `part_<id>_metadata.lance`. +4. After all workers finish, a coordinator merges the metadata files: it collects all partition IDs, remaps them to a sequential range starting from 0 (renaming the corresponding data files), and writes the final unified `metadata.lance`. + +This allows each worker to operate independently during the tokenization phase. Only the final metadata merge requires a single-node step, and it is lightweight since it only renames files and writes a small metadata file. + ## Accelerated Queries Lance SDKs provide dedicated full text search APIs to leverage the FTS index capabilities. @@ -203,4 +284,4 @@ Here are the query types enabled by the FTS index: | **phrase** | Exact phrase matching with position information (requires `with_position: true`) | `{"phrase": {"query": "exact phrase"}}` | AtMost | | **boolean** | Complex boolean queries with must/should/must_not clauses for sophisticated search logic | `{"boolean": {"must": [...], "should": [...]}}` | AtMost | | **multi_match** | Search across multiple fields simultaneously with unified scoring | `{"multi_match": [{"field1": "query"}, ...]}` | AtMost | -| **boost** | Boost relevance scores for specific terms or queries by a configurable factor | `{"boost": {"query": {...}, "factor": 2.0}}` | AtMost | \ No newline at end of file +| **boost** | Boost relevance scores for specific terms or queries by a configurable factor | `{"boost": {"query": {...}, "factor": 2.0}}` | AtMost | diff --git a/docs/src/format/table/index/scalar/label_list.md b/docs/src/format/index/scalar/label_list.md similarity index 100% rename from docs/src/format/table/index/scalar/label_list.md rename to docs/src/format/index/scalar/label_list.md diff --git a/docs/src/format/index/scalar/minhash_lsh.md b/docs/src/format/index/scalar/minhash_lsh.md new file mode 100644 index 00000000000..a96697be624 --- /dev/null +++ b/docs/src/format/index/scalar/minhash_lsh.md @@ -0,0 +1,421 @@ +# MinHash LSH Index (Near-Duplicate Search) + +!!! warning "Experimental" + + This index is an experimental format feature. Its details message and + its files may change incompatibly without a separate vote; after such a + change the index must be rebuilt, the data itself is never rewritten. + The feature is removed if its stabilization vote does not pass. Design + discussion: [lance#8820](https://github.com/lance-format/lance/discussions/8820). + +The MinHash LSH index finds near duplicates of a text: rows whose content is +essentially the same as the query with a few edits, such as reposts with a +different footer, mirror pages, or copy-pasted records with small changes. It +scores rows by the **estimated Jaccard similarity** of their token shingle +sets, a literal measure of overlap that needs no model. Unlike the Full-Text +Search index, which ranks rows by term relevance, and unlike a vector index, +which finds rows that mean the same thing, this index answers "which rows are +copies of this one". + +The index is approximate: candidates are found with locality sensitive hashing +(LSH) over MinHash signatures and ranked by comparing signatures, so the +reported similarity is an estimate, and rows whose similarity is below the +index's threshold are usually not found. + +## High-Level Architecture + +Every indexed text is turned into a fixed-length **signature** of `num_hashes` +values, and the signature is split into `num_bands` **bands** whose hashes are +the lookup keys. Two rows become candidates for each other when they share at +least one band key; candidates are then ranked by the fraction of equal +signature values. + +``` + text + | tokenize, shingle, hash + v + +-----------------------------------------+ + | signature: num_hashes 16-bit values | 1 - (fraction of equal values) + +-----------------------------------------+ = Jaccard distance + | split into num_bands bands, hash each + v + band keys ------------> bands table (candidate lookup: rows sharing a key) + signature ------------> signature table (ranking the candidates) +``` + +The index uses Lance's **Segmented Index** architecture: each segment covers a +disjoint group of fragments and holds its own bands table and signature table. +Scores depend only on the two signatures being compared, never on corpus +statistics, so results from different segments merge exactly. + +``` + +----------------------------------------+ + | Lance Dataset | + | (Disjoint groups of Fragments 0..N) | + +----------------------------------------+ + | + v + +----------------------------------------+ + | Segmented Index | + | +-----------+ +-----------+ +-------+ | + | | Segment 1 | | Segment 2 | | ... | | + | | bands + | | bands + | | | | + | | signatures| | signatures| | | | + | +-----------+ +-----------+ +-------+ | + +----------------------------------------+ +``` + +## Index Details + +```protobuf +%%% proto.message.MinHashLshIndexDetails %%% +``` + +| Parameter | Default | Effect | +|:--------------------|:-----------------------------------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `num_hashes` | 128 | Signature length, 1 to 4096 and a multiple of `num_bands`. More hashes make the similarity estimate more precise (error about `1 / sqrt(num_hashes)`) and the signature table larger (`8 + 2 * num_hashes` bytes per row). | +| `num_bands` | 16 | Number of bands, 1 to 256. With `num_hashes` it sets the similarity threshold `(1 / num_bands) ^ (num_bands / num_hashes)`. The bands table costs `12 * num_bands` bytes per row. | +| `shingle_size` | 3 | Tokens per shingle. Shorter shingles tolerate more edits but let unrelated texts that share common phrases look alike; 5 suits long documents, 2 very short texts. | +| `tokenizer` | full text search default, without stemming and stop-word removal | The tokenizer, recorded as the Full-Text Search index records it. Stemming and stop-word removal are off by default because merging different words inflates similarity. | +| `signature_version` | 0 | Version of the signature procedure, including its hash seed. Managed by Lance, not a user parameter. | + +Reference points: the probability that a row with true Jaccard similarity `J` +becomes a candidate is `1 - (1 - J^r)^b` with `b = num_bands` and +`r = num_hashes / num_bands`. + +| `num_hashes` / `num_bands` | Threshold | Found at J = 0.9 | Found at J = 0.7 | Bytes per row | +|:---------------------------|:----------|:-----------------|:-----------------|:--------------| +| 64 / 8 | 0.77 | 99% | 38% | 232 | +| 128 / 16 (default) | 0.71 | 99.99% | 61% | 456 | +| 96 / 16 | 0.63 | 100% | 87% | 392 | + +Bands of fewer than six values admit a noticeable share of unrelated rows into +the candidate set on tables of billions of rows and are better left to offline +use. + +A query is tokenized from the details alone, so the details must identify the +tokenizer completely: settings the Full-Text Search details cannot record +(custom stop words, document-level text extraction) and tokenizers that load +dictionaries from the deployment (`jieba/*`, `lindera/*`) are rejected. For +CJK text, `ngram` or `icu` gives deterministic shingles. + +## Signature Generation + +The build side and the query side run the same procedure, so a query is +comparable with every stored row. The exact procedure, its constants and +known-answer vectors are in [Version 0 Reference](#version-0-reference): + +1. Tokenize the text with the configured tokenizer. +2. Form shingles of `shingle_size` consecutive tokens. A text with fewer + tokens than `shingle_size` forms one shingle of all its tokens; a text with + no tokens (NULL, empty, whitespace only) has no signature, is not indexed, + and never appears in results. +3. Hash every shingle to 64 bits with a fixed seed. +4. Apply `num_hashes` fixed permutations and keep the minimum of each over + all shingles of the text. +5. Store each minimum as a 16-bit value. A minimum shrinks as the text grows, + so its raw high bits would agree between any two long texts; the stored + value is a multiply-shift hash of the minimum, which keeps the chance that + two different minima collide negligible against the `1 / num_hashes` + resolution of the estimate. + +Band keys are hashes of the `num_hashes / num_bands` consecutive values of a +band, prefixed with the band number so that the keys of one band are +contiguous. + +The estimated Jaccard similarity of two signatures is the fraction of +positions whose values are equal; the reported `_distance` is +`1 - estimated similarity`. Every constant of the procedure is fixed by +`signature_version`, and every segment of an index must carry identical +details, so signatures written by different segments or at query time are +always comparable. Changing a parameter requires rebuilding the index. + +## Storage Layout + +Each segment consists of two Lance files: + +- `signatures.lance` — one row per indexed document: its `_rowid` and its + signature as a fixed-size list of `num_hashes` 16-bit values. The row number + is the segment-local document id, so a segment holds at most `2^32` + documents; larger tables use several segments. Rows are fixed width and + stored without compression, so one document's signature is a single ranged + read at a known offset. +- `bands.lance` — one row per (band key, document id) pair, sorted by band key + and then document id. A bucket is the run of rows sharing a key. The file is + divided into logical pages of a fixed number of rows recorded in the file + (4096 as written), and a page table holding the largest key of every page + is stored in a global buffer of the file and kept in memory when the index + is open, so the pages of any bucket are known without reading the file. + +Both files repeat the serialized index details and the file format version as +schema metadata; a segment is opened only when both files match the details of +the index. The file schemas, metadata keys and the page table encoding are in +[Version 0 Reference](#version-0-reference). + +## Segments, Appends and Merging + +- **Building**: `create_index` reads the text column, signs every row and + writes one segment. Distributed builds create one uncommitted segment per + worker over disjoint fragment sets and commit them together; the commit + rejects segments whose details differ. +- **Unindexed appends**: fragments appended after the build are not covered + by any segment. `optimize_indices` adds a segment over them. Until then a + query still searches those rows, signing them on the fly with the index + parameters, unless the scan asks for indexed rows only (`fast_search`). +- **Segment merging**: merging segments rebuilds one segment from the stored + signatures without tokenizing the text again — the signatures of the + surviving rows are concatenated, band keys are recomputed and the bands + table is rewritten. A merge whose result would exceed `2^32` documents is + rejected and the segments stay separate. +- **Deletes and compaction**: deleted rows are filtered at query time; after a + compaction the stored row ids are remapped like those of every other scalar + index. + +## Query Evaluation + +When a search is submitted (`nearest = MinHashQuery(text, column)`): + +1. The query text is signed with the parameters recorded in the index, and its + band keys are computed. +2. In every segment, each band key is looked up through the page table, which + yields the pages of the key's bucket; the pages of all buckets are fetched + through the page cache, the missing ones with bounded scattered reads. The + document ids in the buckets form the candidate set. +3. The candidates' signatures are read — from memory when resident, otherwise + with scattered reads, or with a sequential scan when the candidates cover a + large share of the segment — and each candidate's Jaccard distance to the + query is computed. Rows removed by filters or deletions are skipped. +4. Each segment keeps its `limit` closest rows; rows not covered by any + segment are scored the same way by the query engine. +5. The per-segment results are merged by distance, and the `limit` closest + rows are returned with their `_distance`. + +## Compatibility + +- A reader that does not know this index opens a dataset that has one + normally: the index is listed under a name derived from the type URL of + its details and is not used by queries. Creating the index sets no reader + or writer feature flag. +- A writer that does not create the index changes nothing for readers that + know it. +- Dropping the feature is dropping the index: its files live only under the + index's own `_indices/{uuid}/` directory, and no data file, manifest field + or other index is rewritten. + +## Version 0 Reference + +This section is normative for `signature_version = 0` and +`minhash_lsh_index_version = 0`: an independent implementation must reproduce +the signatures, band keys and files defined here exactly. All arithmetic is on +unsigned 64-bit integers modulo `2^64`, and every multi-byte value is +little-endian. + +### Signature Procedure + +1. **Tokenize** the text with the configured tokenizer. A token is the UTF-8 + bytes of the token text the tokenizer emits, in emission order. +2. **Shingle**: a shingle is `shingle_size` consecutive tokens joined with the + single byte `0x1F`; a text of `n` tokens has `n - shingle_size + 1` + shingles, one starting at every token position. A text with fewer tokens + than `shingle_size` but at least one token forms one shingle of all its + tokens. A text with no tokens (NULL, empty, whitespace only, or every token + discarded by the tokenizer) has no signature. +3. **Hash** every shingle to 64 bits: `x = XXH64(shingle bytes, seed = 42)`. +4. **Coefficients** come from a SplitMix64 generator whose state `s` starts at + 42. One draw is: + + ``` + s = s + 0x9E3779B97F4A7C15 + z = s + z = (z ^ (z >> 30)) * 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) * 0x94D049BB133111EB + draw = z ^ (z >> 31) + ``` + + The draws are consumed in this order: for `i = 0 .. num_hashes - 1`, + `a_i = draw() | 1` and then `b_i = draw()`; after all pairs, for + `i = 0 .. num_hashes - 1`, `c_i = draw() | 1`. The coefficients depend on + `num_hashes` only. +5. **Permute and take the minima**: `m_i = min over all shingles x of + (a_i * x + b_i)`, comparing the full 64-bit values. +6. **Compress** each minimum to 16 bits: `signature[i] = (c_i * m_i) >> 48`. +7. **Band keys**: the signature is split into `num_bands` bands of + `r = num_hashes / num_bands` consecutive values; band `j` holds + `signature[j * r .. (j + 1) * r)` and its key is + + ``` + band_key_j = (j << 56) | (XXH64(band bytes, seed = 42) & 0x00FFFFFFFFFFFFFF) + ``` + + where the band bytes are the band's `r` values, each written as 2 + little-endian bytes, in signature order. + +The estimated Jaccard similarity of two signatures is the number of positions +`i` with equal `signature[i]` divided by `num_hashes`; `_distance` is +`1 - estimated similarity`, and rows at equal distance are ordered by row id. + +### Known-Answer Vectors + +An implementation must reproduce these values, computed with +`num_hashes = 4`, `num_bands = 2`, `shingle_size = 2`, the default tokenizer +and `signature_version = 0`. + +Coefficients: + +| `i` | `a_i` | `b_i` | `c_i` | +|:----|:---------------------|:---------------------|:---------------------| +| 0 | `0xbdd732262feb6e95` | `0x28efe333b266f103` | `0x5705b8770b3d7dd5` | +| 1 | `0x47526757130f9f53` | `0x581ce1ff0e4ae394` | `0x9e54d738297f77af` | +| 2 | `0x09bc585a244823f3` | `0xde4431fa3c80db06` | `0x3474724a775b19bf` | +| 3 | `0x37e9671c45376d5d` | `0xccf635ee9e9e2fa4` | `0x7e348a0e451650bf` | + +Text `"The quick brown fox"` tokenizes to `the`, `quick`, `brown`, `fox`: + +| Shingle bytes | `XXH64` | +|:-----------------------|:---------------------| +| `the` `0x1F` `quick` | `0x4c0b0d36203ce55f` | +| `quick` `0x1F` `brown` | `0x09c7ae895d82b184` | +| `brown` `0x1F` `fox` | `0x68e7f4b1be7b4c1f` | + +| `i` | `m_i` | `signature[i]` | +|:----|:---------------------|:---------------| +| 0 | `0x0959767d77eafad7` | `0xb00f` | +| 1 | `0x464e4457275cd2a1` | `0x59d6` | +| 2 | `0x50f41804abaa5973` | `0x511a` | +| 3 | `0x8944bd01067b09e7` | `0xcd2c` | + +Band keys: `0x00f30f3501d458ea` (band 0), `0x01aec5df28d04915` (band 1). + +Text `"Fox"` has one token, fewer than `shingle_size`, so its only shingle is +`fox` with `XXH64` `0x07c0130ab04388d5`; the minima are `0x2bb8ade505081afc`, +`0x0a273c6ff9a78ba3`, `0x802f20573838dc35`, `0x1ef46a8d372c9605`, the +signature is `0x2970 0xd6db 0x65f8 0xadfc`, and the band keys are +`0x003cf6e803312eca` and `0x017c5504fbc83f36`. + +The texts `""` and `" \n"` have no tokens and therefore no signature. + +### Signature File Schema + +`signatures.lance` holds one row per indexed document. Row `i` is document +`i` of the segment; rows are written in document id order and never +reordered. + +```python +pa.schema( + [ + pa.field("_rowid", pa.uint64(), nullable=False), + pa.field( + "signature", + pa.list_(pa.field("item", pa.uint16(), nullable=False), num_hashes), + nullable=False, + metadata={ + "lance-encoding:structural-encoding": "fullzip", + "lance-encoding:compression": "none", + }, + ), + ], + metadata={ + "minhash_lsh_details": "<hexadecimal serialized MinHashLshIndexDetails>", + "minhash_lsh_index_version": "0", + }, +) +``` + +`_rowid` is the row id of the indexed row as the dataset hands it to the +index: the row address, or the stable row id when the dataset has stable row +ids enabled, like every scalar index. `signature` is the signature of the +[Signature Procedure](#signature-procedure); its values are never null (the +Lance schema does not record the nullability of a fixed-size list item, so a +reader treats a null value as corruption of the file). The field metadata of +`signature` selects the full-zip structural encoding without compression, so +every row occupies the same `2 * num_hashes` bytes and a reader fetches the +signature of document `i` by row number as one ranged read. + +### Bands File Schema + +`bands.lance` holds one row per (band key, document id) pair, sorted by +`band_key` and then `doc_id`, ascending. + +```python +pa.schema( + [ + pa.field( + "band_key", + pa.uint64(), + nullable=False, + metadata={"lance-encoding:compression": "none"}, + ), + pa.field( + "doc_id", + pa.uint32(), + nullable=False, + metadata={"lance-encoding:compression": "none"}, + ), + ], + metadata={ + "minhash_lsh_details": "<hexadecimal serialized MinHashLshIndexDetails>", + "minhash_lsh_index_version": "0", + "minhash_lsh_num_docs": "<decimal document count>", + "minhash_lsh_page_rows": "4096", + "minhash_lsh_page_table_buffer": "<decimal global buffer index>", + }, +) +``` + +`band_key` is the key of step 7 of the [Signature Procedure](#signature-procedure); +`doc_id` is the segment-local document id, the row number in +`signatures.lance`. Both columns are stored without compression, so any run +of rows is one ranged read per column. The file is divided into logical pages +of `minhash_lsh_page_rows` rows: page `p` holds rows +`[p * page_rows, (p + 1) * page_rows)`, and the last page may be shorter. A +bucket may span any number of pages. + +### Schema Metadata + +| Key | File | Value | +|:--------------------------------|:--------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `minhash_lsh_details` | both | Lower-case hexadecimal encoding of the serialized `MinHashLshIndexDetails` the segment was built from. A reader decodes it and requires it to describe, field by field, the same details as the index metadata. | +| `minhash_lsh_index_version` | both | Decimal file layout version; `0` for the layout described here. Independent of `signature_version`. | +| `minhash_lsh_num_docs` | `bands` | Decimal number of documents of the segment; equals the row count of `signatures.lance`. | +| `minhash_lsh_page_rows` | `bands` | Decimal number of rows per logical page; positive. Writers use `4096`. | +| `minhash_lsh_page_table_buffer` | `bands` | Decimal index of the global buffer of `bands.lance` that holds the page table. | + +### Page Table + +The page table is a global buffer of `bands.lance` holding +`ceil(num_rows / page_rows)` little-endian UInt64 values; entry `p` is the +largest `band_key` of page `p`, that is the key of its last row. It is read +when the segment is opened and kept in memory while the index is open. The +bucket of key `K` lies in the pages from the first page whose entry is `>= K` +through the first page whose entry is `> K` (or the last page when there is +none); the pages strictly between hold nothing but that bucket. Both +positions are binary searches over the table, so a bucket's page range is +known without reading the file. + +### Opening a Segment + +A reader opens a segment in this order and treats a failed check as +corruption of the named file, except where noted: + +1. Parse the index details and check the ranges of + [Index Details](#index-details). +2. Open both files. In each, the schema must match its definition above + exactly in field names, types, nullability and list size, which must equal + `num_hashes`; `minhash_lsh_details` must be present, decode, validate and + equal the index details field by field; and `minhash_lsh_index_version` + must be present. A version greater than the one the reader implements is + rejected as unsupported, not as corruption. +3. In `bands.lance`, `minhash_lsh_page_rows` must be positive and + `minhash_lsh_num_docs` must equal the row count of `signatures.lance`. +4. Read the page table buffer; its length must be a multiple of 8 and its + entry count must be `ceil(num_rows / page_rows)` for the row count of + `bands.lance`. + +While answering queries, a page that does not decode to non-null `UInt64` +and `UInt32` columns, and a `doc_id` that is not below +`minhash_lsh_num_docs`, are corruption of `bands.lance`; a signature batch +with a null value is corruption of `signatures.lance`. + +The fragments a segment covers are recorded in the index metadata of the +dataset, never derived from the stored `_rowid` values (which do not identify +fragments once stable row ids are enabled). diff --git a/docs/src/format/table/index/scalar/ngram.md b/docs/src/format/index/scalar/ngram.md similarity index 66% rename from docs/src/format/table/index/scalar/ngram.md rename to docs/src/format/index/scalar/ngram.md index bdf78474d50..d437363d264 100644 --- a/docs/src/format/table/index/scalar/ngram.md +++ b/docs/src/format/index/scalar/ngram.md @@ -29,4 +29,10 @@ The N-gram index provides inexact results for the following query types: | Query Type | Description | Operation | Result Type | |----------------|--------------------------|-------------------------------------------------------|-------------| -| **contains** | Substring search in text | Finds all trigrams in query, intersects posting lists | AtMost | \ No newline at end of file +| **contains** | Substring search in text | Finds all trigrams in query, intersects posting lists | AtMost | +| **regexp_like** / **regexp_match** | Regular-expression match | Derives a necessary trigram condition from the pattern (AND of intersections, OR of unions), then rechecks the true regex | AtMost | +| **LIKE** (infix) | Wildcard match such as `%foo%bar%` | Uses the literal segments of the pattern as a trigram condition, then rechecks the LIKE | AtMost | + +Patterns from which no trigram can be derived - for example `a.b`, `.*`, +case-insensitive matches, or literal runs shorter than three characters - fall +back to rechecking every row. This is always correct, just not accelerated. diff --git a/docs/src/format/table/index/scalar/rtree.md b/docs/src/format/index/scalar/rtree.md similarity index 100% rename from docs/src/format/table/index/scalar/rtree.md rename to docs/src/format/index/scalar/rtree.md diff --git a/docs/src/format/table/index/scalar/zonemap.md b/docs/src/format/index/scalar/zonemap.md similarity index 71% rename from docs/src/format/table/index/scalar/zonemap.md rename to docs/src/format/index/scalar/zonemap.md index 256edc2671d..552d476cc86 100644 --- a/docs/src/format/table/index/scalar/zonemap.md +++ b/docs/src/format/index/scalar/zonemap.md @@ -8,6 +8,9 @@ zones that cannot contain matching values. Zone maps are "inexact" filters - they can definitively exclude zones but may include false positives that require rechecking. +In addition, since finding NULLs is a common query pattern, the index also maintains a +bitmap of null rows which allows it to return exact results for IS NULL queries. + ## Index Details ```protobuf @@ -34,17 +37,25 @@ The zone map index stores zone statistics in a single file: ### Schema Metadata -| Key | Type | Description | -|-----------------|--------|-------------------------------------------| -| `rows_per_zone` | String | Number of rows per zone (default: "8192") | +| Key | Type | Description | +|---------------------|--------|-------------------------------------------| +| `rows_per_zone` | String | Number of rows per zone (default: "8192") | +| `null_bitmap` | UInt32 | Index of null bitmap global buffer | + +### Global Buffers + +| Metadata Key | Description | +|---------------------|------------------------------------------------------------| +| `null_bitmap` | A serialized RowAddrTreeMap specifying which rows are null | ## Accelerated Queries -The zone map index provides inexact results for the following query types: +The zone map index provides inexact results for the following query types (nullability queries +return exact results): | Query Type | Description | Operation | Result Type | |------------|---------------------------|---------------------------------------------|-------------| | **Equals** | `column = value` | Includes zones where min ≤ value ≤ max | AtMost | | **Range** | `column BETWEEN a AND b` | Includes zones where ranges overlap | AtMost | | **IsIn** | `column IN (v1, v2, ...)` | Includes zones that could contain any value | AtMost | -| **IsNull** | `column IS NULL` | Includes zones where null_count > 0 | AtMost | \ No newline at end of file +| **IsNull** | `column IS NULL` | Includes zones where null_count > 0 | Exact | diff --git a/docs/src/format/table/index/scalar_index.drawio.svg b/docs/src/format/index/scalar_index.drawio.svg similarity index 100% rename from docs/src/format/table/index/scalar_index.drawio.svg rename to docs/src/format/index/scalar_index.drawio.svg diff --git a/docs/src/format/table/index/starter-example.drawio.svg b/docs/src/format/index/starter-example.drawio.svg similarity index 100% rename from docs/src/format/table/index/starter-example.drawio.svg rename to docs/src/format/index/starter-example.drawio.svg diff --git a/docs/src/format/table/index/system/.pages b/docs/src/format/index/system/.pages similarity index 100% rename from docs/src/format/table/index/system/.pages rename to docs/src/format/index/system/.pages diff --git a/docs/src/format/index/system/frag_reuse.md b/docs/src/format/index/system/frag_reuse.md new file mode 100644 index 00000000000..1179234f13a --- /dev/null +++ b/docs/src/format/index/system/frag_reuse.md @@ -0,0 +1,241 @@ +# Fragment Reuse Index + +The Fragment Reuse Index (FRI) is an internal index that keeps existing indices +usable while fragments are compacted or reclustered. It records how old physical +row addresses map to new addresses, without changing surviving row values. + +## Use Case 1: Compact Fragments + +When data modifications happen against a Lance table, they can trigger compaction +and index optimization at the same time to improve data layout and index coverage. +By default, compaction remaps all indices to prevent read regression. +This means both compaction and index optimization can modify the same index and +cause one process to fail. Typically, compaction fails because it has to modify +all indices and takes longer, resulting in table layout degrading over time. + +Fragment Reuse Index allows compaction to defer the index remap process. +Suppose a compaction removes fragments A and B and produces C. At query runtime, +the existing indices are reused by translating their addresses in A and B to +addresses in C. Because indices are typically cached in memory after initial +load, the translated index can be reused by subsequent queries. + +## Use Case 2: Recluster Fragments + +Fragments are often organized by data arrival order. Queries filtering by fields +such as day or user UUID may therefore need to search many fragments or index +segments. An external engine, such as Spark, can reorganize rows into destination +fragments grouped by those fields. + +A **stable partition** assigns source rows to destination fragments while +preserving their relative source order within each destination. This lets FRI +reuse existing indices after reclustering. Sorting or arbitrarily shuffling rows +within a destination requires a different mapping; it is not represented by the +stable-partition format defined here. + +## Index Details + +FRI uses one system-index entry named `__lance_frag_reuse`. Its +`IndexMetadata.index_details` contains `FragmentReuseIndexDetails`: + +```protobuf +%%% proto.message.FragmentReuseIndexDetails %%% +``` + +The outer `InlineContent` / `ExternalFile` choice applies to the entire history. +Small histories are stored inline; larger histories store the serialized +`InlineContent` in `_indices/<FRI UUID>/details.binpb`. Updating the history +replaces the FRI metadata. Stable-partition row maps are separate immutable files +and are not rewritten with that history. + +### FRI Index Versions + +`IndexMetadata.index_version` identifies the format required to interpret FRI; +it is not a dataset version or the sequence number of a rewrite. + +- **Version 0** retains the existing compaction format and read/write behavior. + `InlineContent.legacy_versions` keeps the original field number, 1, and wire + representation of `versions`. +- **Version 1** adds `InlineContent.transitions` at field 2. It supports both + ordered compaction and stable partition. Each legacy group can be interpreted + as an ordered-compaction transition, forming one history with the new records. + +Adding a transition or a new mapping type does not itself require increasing +`index_version`. Readers may skip unsupported mappings and fall back to scanning +where a complete translation path is unavailable. Writers must reject operations +that require interpreting or maintaining unsupported mappings; operations that +carry the existing history unchanged need not interpret them. Changes to the +shared metadata contract that require reader upgrades must increase `index_version`. + +### Shared Transition Metadata + +Each transition records ordered `sources` and `destinations`, plus exactly one +mapping. A fragment digest contains its ID, physical row count, and deleted row +count. Source counts describe the rewrite input; destination counts describe the +newly written fragments, with zero deletions. + +Sources define scan order; destinations define output order for compaction and +label order for stable partition. The surviving source row count must equal the +total destination row count. Each fragment has at most one producer and one +consumer in the retained history, and the graph must be acyclic. A fragment's +physical row count stays constant across transitions; its deletion count may +increase. Translation follows these dependencies, not the serialized list order. + +## Mappings + +### Ordered Compaction + +Ordered compaction concatenates surviving rows in source-fragment order, with +ascending physical row offsets within each source, then splits that stream into +the ordered destination fragments. + +`changed_row_addrs` stores a serialized RoaringTreemap of surviving source +addresses. A physical address uses the upper 32 bits for the fragment ID and the +lower 32 bits for the row offset. A valid source row absent from the bitmap was +deleted. + +To translate a surviving row, count the surviving rows in preceding source +fragments and the surviving rows before its offset in its own fragment. Their +sum is its zero-based position in the output stream. Find the destination whose +cumulative physical-row range contains that position and subtract the start of +that range to obtain the destination offset. + +The legacy `Group` and the new `OrderedCompaction` mapping use this same ordering +and bitmap representation. No per-row destination labels are required. + +### Stable Partition + +A stable partition processes source fragments sequentially in their recorded +order and assigns each surviving row a label: the zero-based position of its +destination in `destinations`. Rows with the same label retain their relative +source order. Deleted source rows carry a null label. + +The mapping payload is one immutable Lance file, +`_fri/<map_id>/stable_partition.lance`. `map_id` is a UUID independent of the FRI +index UUID, so the file survives FRI metadata rewrites. `map_size_bytes` records +its exact size. An unset `base_id` selects the dataset base; otherwise it selects +the corresponding `Manifest.base_paths` entry. Relocating a dataset requires +copying these files or updating their references. + +#### Row Map File Schema + +The row map is a Lance file with one nullable `uint16` label per physical source +row. Rows follow source-fragment order, then physical row offset within each +fragment, including deleted rows. + +```python +import pyarrow as pa + +row_map_schema = pa.schema([pa.field("label", pa.uint16(), nullable=True)]) +``` + +A label tells us which destination receives the row: `0` means +`destinations[0]`, `1` means `destinations[1]`, and `null` means the row was +deleted at rewrite time. It stores the destination's position in the list, not +its fragment ID or row offset. The number of destinations must be between 1 +and 65,536, and each non-null label must be smaller than that number. + +#### Counts Matrix + +Labels tell us the destination fragment. To find the row offset inside that +fragment, we count earlier source rows with the same label. + +To avoid reading all earlier labels, source rows are divided into blocks. +For each block and destination, the counts matrix stores the cumulative number +of rows sent to that destination through the end of the block. Null labels are +not counted. `block_rows` must be positive; the current writer uses 65,536. +Only the final block may be shorter. + +The matrix is stored in a Lance global buffer. The schema metadata key +`lance:stable_partition:counts_buffer_index` contains its buffer index as a +decimal string. Readers locate the buffer through the normal Lance file metadata. + +The buffer starts with a 28-byte header, in this order: + +- Magic: four bytes, `LSPC`. +- Version: `u32`, value 1. +- Representation: `u32`, value 0 for the dense grid. +- Number of destinations: `u32`. +- Rows per block: `u32`. +- Total physical source rows: `u64`. + +All integers are unsigned and little-endian. The header is followed by +`ceil(total_rows / block_rows) * num_destinations` cumulative `u32` counts, +ordered by block, then destination. No extra bytes are allowed. Unsupported +versions or representations must be rejected. + +Counts must never decrease, and a block cannot contribute more live rows than +its length. Final destination counts must match the destination digests. The +label-file row count and header total must match the sum of source physical row +counts. An empty file has no grid rows and zero destination totals. + +#### Address Translation + +For source fragment `s` and row offset `o`: + +1. Find its label-file position: the physical row counts of all preceding source + fragments, plus `o`. +2. Read the block containing that position. A null label means the row was deleted; + otherwise label `d` selects `destinations[d]`. +3. The destination row offset is the count for `d` before this block, plus the + number of occurrences of `d` strictly before this row within the block. + The count before the first block is zero. + +For batch translation, read each requested block once. Initialize destination +counters from the preceding counts row, then scan the block in order. Each +non-null label takes the current counter as its offset and increments it. +Opening FRI history does not require reading labels; translation only needs the +counts matrix and the requested label blocks. + +## Expected Use Pattern + +When indexing or index remapping cannot keep up with compaction or reclustering, +FRI allows fragment rewrites to proceed while retaining existing indices. +Each rewrite that defers index remapping records its mapping: a reuse version +in FRI index version 0, or transitions in index version 1. A rewrite publishes +its mapping atomically, replacing the FRI entry in the same commit. A +transition must reference only fragments committed no later than itself. +Fragments not covered by any index are served by scanning, so correctness +never depends on a mapping being present. + +Once all dependent indices have caught up, the corresponding history can be +trimmed. Cleanup must retain intermediate transitions still needed to translate +old addresses. External mapping files can be deleted only when no retained +dataset version references them. + +## Stable Row IDs + +Older readers and writers did not expect both stable row IDs and a Fragment Reuse Index but are +not prevented from opening tables that have both. These older systems could silently corrupt such +tables. A table version that has both sets `FLAG_FRAG_REUSE_WITH_STABLE_ROW_IDS` in the reader and +writer [feature flags](../../table/versioning.md#current-feature-flags) to guard against this. + +## Impacts + +### Conflict Resolution + +Deferring index remapping avoids replacing existing indices during a fragment +rewrite, reducing conflicts with concurrent index building or optimization. +FRI does not remove conflicts between overlapping rewrites. See +[conflict resolution](../../table/transaction.md#conflict-resolution). + +### Index Load Cost + +Loading affected indices requires translating their stored row addresses. +Ordered compaction uses its bitmap and fragment layouts; stable partition also +reads the required row-map blocks. Translated indices can be cached and reused. +Longer mapping chains add translation work; trimming unused history reduces it. + +### Reader and Writer Compatibility + +The first commit publishing FRI index version 1 sets +`FLAG_FRAGMENT_REUSE_INDEX` (1024) in both manifest flag fields. Subsequent +manifests retain both bits. FRI index version 0 does not require this flag. + +Tables using stable row IDs do not support tagged histories; writers must not +publish `index_version >= 1` on them. + +The reader flag prevents older clients from partially interpreting the new +history. The writer flag prevents them from dropping mappings when rewriting +FRI metadata. Clients that do not support the corresponding flag must reject +the read or write and require an upgrade. See +[feature flags](../../table/versioning.md). diff --git a/docs/src/format/index/system/mem_wal.md b/docs/src/format/index/system/mem_wal.md new file mode 100644 index 00000000000..2f0a5cb1fa5 --- /dev/null +++ b/docs/src/format/index/system/mem_wal.md @@ -0,0 +1,14 @@ +# MemWAL Index + +The MemWAL Index is a system index that serves as the centralized structure for all MemWAL metadata. +It stores configuration (shard specs, indexes to maintain), SSTable compaction progress, and shard state snapshots. + +A table has at most one MemWAL index. +The table may be a primary-key table or an append-only table without primary-key metadata. +Primary-key-dependent lookup and deduplication semantics only apply when a primary key is defined. + +For the complete specification, see: + +- [MemWAL Index Overview](../../table/mem_wal.md#memwal-index) - Purpose and high-level description +- [MemWAL Index Details](../../table/mem_wal.md#memwal-index-details) - Storage format, schemas, and staleness handling +- [MemWAL Implementation](../../table/mem_wal.md#implementation-expectation) - Implementation details and expectations diff --git a/docs/src/format/table/index/vector/.pages b/docs/src/format/index/vector/.pages similarity index 100% rename from docs/src/format/table/index/vector/.pages rename to docs/src/format/index/vector/.pages diff --git a/docs/src/format/table/index/vector/index.md b/docs/src/format/index/vector/index.md similarity index 64% rename from docs/src/format/table/index/vector/index.md rename to docs/src/format/index/vector/index.md index f987c6a675e..c7f385accf4 100644 --- a/docs/src/format/table/index/vector/index.md +++ b/docs/src/format/index/vector/index.md @@ -1,6 +1,6 @@ # Vector Indices -Lance provides a powerful and extensible secondary index system for efficient vector similarity search. +Lance provides a powerful and extensible secondary index system for efficient vector similarity search. All vector indices are stored as regular Lance files, making them portable and easy to manage. It is designed for efficient similarity search across large-scale vector datasets. @@ -12,7 +12,7 @@ Lance splits each vector index into 3 parts - clustering, sub-index and quantiza Clustering divides all the vectors into different disjoint clusters (a.k.a. partitions). Lance currently supports using Inverted File (IVF) as the primary clustering mechanism. -IVF partitions the vectors into clusters using the k-means clustering algorithm. +IVF partitions the vectors into clusters using the k-means clustering algorithm. Each cluster contains vectors that are similar to the cluster centroid. During search, only the most relevant clusters are examined, dramatically reducing search time. IVF can be combined with any sub-index type and quantization method. @@ -51,7 +51,7 @@ Here are the commonly used combinations: The Lance vector index format has gone through 3 versions so far. This document currently only records version 3 which is the latest version. -The specific version of the vector index is recorded in the `index_version` field of the generic [index metadata](../index.md#index-metadata). +The specific version of the vector index is recorded in the `index_version` field of the generic [index metadata](../index.md#loading-an-index). ## Storage Layout (V3) @@ -84,9 +84,9 @@ HNSW (Hierarchical Navigable Small World) indices provide fast approximate searc | Column | Type | Nullable | Description | | ------------- | ------------- | -------- | ---------------------- | -| `__vector_id` | uint64 | false | Vector identifier | -| `__neighbors` | list<uint32> | false | Neighbor node IDs | -| `_distance` | list<float32> | false | Distances to neighbors | +| `__vector_id` | uint32 | true | Vector identifier | +| `__neighbors` | list<uint32> | true | Neighbor node IDs | +| `_distance` | list<float32> | true | Distances to neighbors | !!! note HNSW consists of multiple levels, and all levels must be written in order starting from level 0. @@ -111,7 +111,7 @@ References the IVF metadata stored in the Lance file global buffer. This value records the global buffer index, currently this is always "1". !!! note - Global buffer indices in Lance files are 1-based, + Global buffer indices in Lance files are 1-based, so you need to subtract 1 when accessing them through code. ##### "lance:flat" @@ -161,23 +161,36 @@ the Arrow schema of the Lance file varies depending on the quantization method u !!! note All partitions are stored in the same file, and partitions must be written in order. +Every quantization format below lists only its internal columns. When a V3 IVF +writer materializes carried values, it appends one trailing column per carried +field after them, named and typed exactly as in the dataset schema. This physical +payload may be a subset of the manifest's `covering_fields` declaration (see +[Index Metadata](../index.md)). A reader returns only columns whose physical +schema and dataset field ids it verifies across every selected segment; all other +projected columns come from a base-table take. + +A reader discovers carried columns by exclusion, not by position: any column in the +auxiliary file's schema that is not one of the quantizer's internal columns is a +carried column. Writers append them in trailing order, but a reader must not depend +on that ordering to identify them. + ##### FLAT No quantization applied - stores original vectors in their full precision: | Column | Type | Nullable | Description | | -------- | ------------------------ | -------- | ----------------------------------------------------- | -| `_rowid` | uint64 | false | Row identifier | -| `flat` | list<float32>[dimension] | false | Original vector values (list_size = vector dimension) | +| `_rowid` | uint64 | true | Row identifier | +| `flat` | list<float32>[dimension] | true | Original vector values (list_size = vector dimension) | ##### PQ Compresses vectors using product quantization for significant memory savings: -| Column | Type | Nullable | Description | -| ----------- | -------------- | -------- | ------------------------------------------- | -| `_rowid` | uint64 | false | Row identifier | -| `__pq_code` | list<uint8>[m] | false | PQ codes (list_size = number of subvectors) | +| Column | Type | Nullable | Description | +| ----------- | ----------------------------------------------- | -------- | --------------------------------------------- | +| `_rowid` | uint64 | true | Row identifier | +| `__pq_code` | list<uint8>[num_sub_vectors * num_bits / 8] | true | PQ codes, packed to `num_bits` per subvector | ##### SQ @@ -185,19 +198,28 @@ Compresses vectors using scalar quantization for moderate memory savings: | Column | Type | Nullable | Description | | ----------- | ---------------------- | -------- | --------------------------------------- | -| `_rowid` | uint64 | false | Row identifier | -| `__sq_code` | list<uint8>[dimension] | false | SQ codes (list_size = vector dimension) | +| `_rowid` | uint64 | true | Row identifier | +| `__sq_code` | list<uint8>[dimension] | true | SQ codes (list_size = vector dimension) | ##### RQ Compresses vectors using RabitQ with random rotation and binary quantization for extreme compression: -| Column | Type | Nullable | Description | -| ----------------- | -------------------------- | -------- | --------------------------------------------------------------- | -| `_rowid` | uint64 | false | Row identifier | -| `_rabit_codes` | list<uint8>[dimension / 8] | false | Binary quantized codes (1 bit per dimension, packed into bytes) | -| `__add_factors` | float32 | false | Additive correction factors for distance computation | -| `__scale_factors` | float32 | false | Scale correction factors for distance computation | +| Column | Type | Nullable | Present when | Description | +| -------------------- | ------------------------------------------------ | -------- | --------------------------- | --------------------------------------------------------------- | +| `_rowid` | uint64 | true | always | Row identifier | +| `_rabit_codes` | list<uint8>[ceil(code_dim / 8)] | true | always | Binary quantized codes (1 bit per dimension, packed into bytes) | +| `__add_factors` | float32 | true | always | Additive correction factors for distance computation | +| `__scale_factors` | float32 | true | always | Scale correction factors for distance computation | +| `__error_factors` | float32 | true | `raw_query` estimator | Error factors for raw-query lower-bound pruning | +| `__blocked_ex_codes` | list<uint8>[next_multiple_of(code_dim, 64) * (num_bits - 1) / 8] | true | `num_bits > 1` | Extra RabitQ code bits for multi-bit RQ, in the blocked layout | +| `__add_factors_ex` | float32 | true | `num_bits > 1` | Additive correction factors for ex-code distance computation | +| `__scale_factors_ex` | float32 | true | `num_bits > 1` | Scale correction factors for ex-code distance computation | + +!!! note + Indexes written before the blocked ex-code layout store the same bits in + `__ex_codes`, sized `ceil(dimension * (num_bits - 1) / 8)`. Readers still + accept that column and repack it at load time; writers no longer emit it. #### Arrow Schema Metadata @@ -205,11 +227,12 @@ The auxiliary file also contains metadata in its Arrow schema metadata for vecto Here are the metadata keys and their corresponding values: ##### "distance_type" + The distance metric used to compute similarity between vectors (e.g., "l2", "cosine", "dot"). ##### "lance:ivf" -Similar to the index file's "lance:ivf" but focused on vector storage layout. +Similar to the index file's "lance:ivf" but focused on vector storage layout. This doesn't contain the partitions' centroids. It's only used for tracking each partition's offset and length in the auxiliary file. @@ -219,6 +242,17 @@ Contains RabitQ-specific metadata in JSON format (only present for RQ quantizati This includes the rotation matrix position, number of bits, and packing information. See the RQ metadata specification in the "storage_metadata" section below. +##### "covering_field_ids" + +The dataset field ids of the storage file's physical carried columns, +comma separated in physical schema order (only present when the storage carries +values). Arrow fields carry no Lance field id, so names and types alone cannot +prove which logical column a payload came from. Readers use these ids to bind +physical values to the segment's `covering_fields` declaration, and treat missing, +malformed, ambiguous, or mismatched metadata as no servable carried capability. +A merge must not combine shards whose carried columns disagree on these ids, even +when those columns match by name and type. + ##### "storage_metadata" Contains quantizer-specific metadata as a list of JSON strings. @@ -247,14 +281,16 @@ For **RabitQ (RQ)**: | JSON Key | Type | Description | | --------------------- | ---- | ---------------------------------------------------- | | `rotate_mat_position` | u32 | Position of the rotation matrix in the global buffer | -| `num_bits` | u8 | Number of bits per dimension (currently always 1) | +| `num_bits` | u8 | Number of bits per dimension, in the range 1..=9 | +| `code_dim` | u32 | Rotated vector dimension for the 1-bit binary code | | `packed` | bool | Whether codes are packed for optimized computation | +| `query_estimator` | string | Distance estimator layout: `residual_query` or `raw_query`. Missing values are read as `residual_query` for compatibility with released 1-bit IVF_RQ indexes. | #### Lance File Global Buffer ##### Quantization Codebook -For product quantization, the codebook is stored in `Tensor` format +For product quantization, the codebook is stored in `Tensor` format in the auxiliary file's global buffer for efficient access: ```protobuf @@ -264,14 +300,19 @@ in the auxiliary file's global buffer for efficient access: ##### Rotation Matrix For RabitQ, the rotation matrix is stored in `Tensor` format -in the auxiliary file's global buffer. The rotation matrix is an orthogonal matrix used +in the auxiliary file's global buffer. The rotation matrix is an orthogonal matrix used to rotate vectors before binary quantization: ```protobuf %%% proto.message.Tensor %%% ``` -The rotation matrix has shape `[code_dim, code_dim]` where `code_dim = dimension * num_bits`. +The rotation matrix has shape `[code_dim, code_dim]` where `code_dim` is the rotated vector +dimension. IVF_RQ always stores the 1-bit binary sign code in `_rabit_codes`; for `num_bits > 1`, +the remaining `num_bits - 1` ex-code bits are stored in `__blocked_ex_codes` instead of widening the +binary code path. New IVF_RQ indexes store raw-query estimator factors. `num_bits=1` indexes only +store the binary-code factor columns; multi-bit indexes also store separate ex-code additive and +scale factors. ## Appendices @@ -283,69 +324,71 @@ PQ uses 16 num_sub_vectors (m=16) with 8 num_bits per subvector, and distance ty #### Index File - Arrow Schema Metadata: - - `"lance:index"` → `{ "type": "IVF_PQ", "distance_type": "l2" }` - - `"lance:ivf"` → "1" (references IVF metadata in the global buffer) - - `"lance:flat"` → `["", "", ...]` (one empty string per partition; IVF_PQ uses a FLAT sub-index inside each partition) + - `"lance:index"` → `{ "type": "IVF_PQ", "distance_type": "l2" }` + - `"lance:ivf"` → "1" (references IVF metadata in the global buffer) + - `"lance:flat"` → `["", "", ...]` (one empty string per partition; IVF_PQ uses a FLAT sub-index inside each partition) - Lance File Global buffer (Protobuf): - - `Ivf` message containing: - - `centroids_tensor`: shape `[num_partitions, 128]` (float32) - - `offsets`: start offset (row) of each partition in `auxiliary.idx` - - `lengths`: number of vectors in each partition - - `loss`: k-means loss (optional) + - `Ivf` message containing: + - `centroids_tensor`: shape `[num_partitions, 128]` (float32) + - `offsets`: start offset (row) of each partition in `auxiliary.idx` + - `lengths`: number of vectors in each partition + - `loss`: k-means loss (optional) #### Auxiliary File - Arrow Schema Metadata: - - `"distance_type"` → `"l2"` - - `"lance:ivf"` → tracks per-partition `offsets` and `lengths` (no centroids here) - - `"storage_metadata"` → `[ "{"pq":{"num_sub_vectors":16,"nbits":8,"dimension":128,"transposed":true}}" ]` + - `"distance_type"` → `"l2"` + - `"lance:ivf"` → tracks per-partition `offsets` and `lengths` (no centroids here) + - `"storage_metadata"` → `[ "{"pq":{"num_sub_vectors":16,"nbits":8,"dimension":128,"transposed":true}}" ]` - Lance File Global buffer: - - `Tensor` codebook with shape `[256, num_sub_vectors, dim/num_sub_vectors]` = `[256, 16, 8]` (float32) -- Rows with Arrow schema: + - `Tensor` codebook with shape `[256, num_sub_vectors, dim/num_sub_vectors]` = `[256, 16, 8]` (float32) +- Rows with Arrow schema: ```python pa.schema([ pa.field("_rowid", pa.uint64()), - pa.field("__pq_code", pa.list(pa.uint8(), list_size=16)), # m subvector codes + pa.field("__pq_code", pa.list_(pa.uint8(), list_size=16)), # num_sub_vectors * num_bits / 8 = 16 * 8 / 8 ]) ``` ### Appendix 2: Example IVF_RQ Format This example shows how an `IVF_RQ` index is physically laid out. Assume vectors have dimension 128, -RQ uses 1 bit per dimension (num_bits=1), and distance type is "l2". +RQ uses 1 bit per dimension (`num_bits=1`), and distance type is "l2". For `num_bits > 1`, the +auxiliary schema also includes `__blocked_ex_codes`, `__add_factors_ex`, and `__scale_factors_ex`. #### Index File - Arrow Schema Metadata: - - `"lance:index"` → `{ "type": "IVF_RQ", "distance_type": "l2" }` - - `"lance:ivf"` → "1" (references IVF metadata in the global buffer) - - `"lance:flat"` → `["", "", ...]` (one empty string per partition; IVF_RQ uses a FLAT sub-index inside each partition) + - `"lance:index"` → `{ "type": "IVF_RQ", "distance_type": "l2" }` + - `"lance:ivf"` → "1" (references IVF metadata in the global buffer) + - `"lance:flat"` → `["", "", ...]` (one empty string per partition; IVF_RQ uses a FLAT sub-index inside each partition) - Lance File Global buffer (Protobuf): - - `Ivf` message containing: - - `centroids_tensor`: shape `[num_partitions, 128]` (float32) - - `offsets`: start offset (row) of each partition in `auxiliary.idx` - - `lengths`: number of vectors in each partition - - `loss`: k-means loss (optional) + - `Ivf` message containing: + - `centroids_tensor`: shape `[num_partitions, 128]` (float32) + - `offsets`: start offset (row) of each partition in `auxiliary.idx` + - `lengths`: number of vectors in each partition + - `loss`: k-means loss (optional) #### Auxiliary File - Arrow Schema Metadata: - - `"distance_type"` → `"l2"` - - `"lance:ivf"` → tracks per-partition `offsets` and `lengths` (no centroids here) - - `"lance:rabit"` → `"{"rotate_mat_position":1,"num_bits":1,"packed":true}"` + - `"distance_type"` → `"l2"` + - `"lance:ivf"` → tracks per-partition `offsets` and `lengths` (no centroids here) + - `"lance:rabit"` → `"{"rotate_mat_position":1,"num_bits":1,"packed":true,"query_estimator":"raw_query"}"` - Lance File Global buffer: - - `Tensor` rotation matrix with shape `[code_dim, code_dim]` = `[128, 128]` (float32) -- Rows with Arrow schema: + - `Tensor` rotation matrix with shape `[code_dim, code_dim]` = `[128, 128]` (float32) +- Rows with Arrow schema: ```python pa.schema([ pa.field("_rowid", pa.uint64()), - pa.field("_rabit_codes", pa.list(pa.uint8(), list_size=16)), # dimension/8 = 128/8 = 16 bytes + pa.field("_rabit_codes", pa.list_(pa.uint8(), list_size=16)), # ceil(code_dim / 8) = ceil(128 / 8) pa.field("__add_factors", pa.float32()), pa.field("__scale_factors", pa.float32()), + pa.field("__error_factors", pa.float32()), ]) ``` diff --git a/docs/src/format/java-sdk-example.png b/docs/src/format/java-sdk-example.png new file mode 100644 index 00000000000..e019d04752c Binary files /dev/null and b/docs/src/format/java-sdk-example.png differ diff --git a/docs/src/format/overview.png b/docs/src/format/overview.png new file mode 100644 index 00000000000..5c77330112c Binary files /dev/null and b/docs/src/format/overview.png differ diff --git a/docs/src/format/table/.pages b/docs/src/format/table/.pages index 69209d956cc..064c080e5f4 100644 --- a/docs/src/format/table/.pages +++ b/docs/src/format/table/.pages @@ -1,9 +1,11 @@ nav: - index.md + - Schema: schema.md - Versioning: versioning.md - Transactions: transaction.md - Layout: layout.md - Branch & Tag: branch_tag.md - Row ID & Lineage: row_id_lineage.md + - Data Overlay Files: data_overlay_file.md + - Fragment Metadata Tree: fragment_metadata.md - MemTable & WAL: mem_wal.md - - index diff --git a/docs/src/format/table/branch_tag.md b/docs/src/format/table/branch_tag.md index e3bd328d5d2..b34149bb3f9 100644 --- a/docs/src/format/table/branch_tag.md +++ b/docs/src/format/table/branch_tag.md @@ -17,7 +17,9 @@ Branch names must follow these validation rules: 4. Cannot contain `..` or `\` 5. Segments must contain only alphanumeric characters, `.`, `-`, `_` 6. Cannot end with `.lock` -7. Cannot be named `main` (reserved for main branch) +7. Cannot be named `main` (reserved for the default branch) + +Branch names are case-sensitive, matching Git/GitHub ref semantics. The exact name `main` is a virtual name for the default branch. It may appear in API reference contexts as an alias for the default branch, but no branch metadata file named `main.json` is created. ### Branch Metadata Path @@ -38,10 +40,11 @@ Each branch metadata file is a JSON file with the following fields: | JSON Key | Type | Optional | Description | |------------------|--------|----------|--------------------------------------------------------------------------------| -| `parent_branch` | string | Yes | Name of the branch this was created from. `null` indicates branched from main. | -| `parent_version` | number | | Version number of the parent branch at the time this branch was created. | -| `create_at` | number | | Unix timestamp (seconds since epoch) when the branch was created. | -| `manifest_size` | number | | Size of the initial manifest file in bytes. | +| `parentBranch` | string | Yes | Name of the branch this was created from. `null` indicates branched from the default branch. | +| `parentVersion` | number | | Version number of the parent branch at the time this branch was created. | +| `createAt` | number | | Unix timestamp (seconds since epoch) when the branch was created. | +| `manifestSize` | number | | Size of the initial manifest file in bytes. | +| `metadata` | object | Yes | String key/value metadata map. If absent, it is treated as an empty object. | ### Branch Dataset Layout @@ -116,6 +119,9 @@ Each tag file is a JSON file with the following fields: | JSON Key | Type | Optional | Description | |-----------------|--------|----------|--------------------------------------------------------------------------| -| `branch` | string | Yes | Branch name being tagged. `null` or absent indicates main branch. | +| `branch` | string | Yes | Branch name being tagged. `null` or absent indicates the default branch. | | `version` | number | | Version number being tagged within that branch. | -| `manifest_size` | number | | Size of the manifest file in bytes. Used for efficient manifest loading. | +| `createdAt` | string | Yes | RFC 3339 timestamp for when the tag was first created. | +| `updatedAt` | string | Yes | RFC 3339 timestamp for the latest tag reference update. | +| `manifestSize` | number | | Size of the manifest file in bytes. Used for efficient manifest loading. | +| `metadata` | object | Yes | String key/value metadata map. If absent, it is treated as an empty object. | diff --git a/docs/src/format/table/data_overlay_file.md b/docs/src/format/table/data_overlay_file.md new file mode 100644 index 00000000000..2c6bfb01b58 --- /dev/null +++ b/docs/src/format/table/data_overlay_file.md @@ -0,0 +1,393 @@ +# Data Overlay Files + +!!! warning "Experimental" + + This feature is currently experimental and not yet supported in any library. + +<!-- TODO: When overlay file support is implemented, update this note to state + the released version that first supports the feature (and drop the + "experimental" framing once it is stable). --> + +!!! note "Overlay files require feature flag 64 (data overlay files)" + + A reader or writer that does not understand overlay files must refuse a + dataset that uses them. Silently ignoring an overlay would return stale base + values, which is a correctness bug rather than a degraded experience. + +Overlay files supply new values for a subset of `(row offset, field)` cells +within a fragment **without rewriting the fragment's base data files**. They make +updates cheap when only a small fraction of rows and/or columns change: instead +of rewriting whole columns or moving rows to a new fragment, a writer appends a +small file carrying just the changed cells. + +This is Lance's third mechanism for changing data in place, alongside +[deletion files](index.md#deletion-files) (which remove rows) and +[data evolution](index.md#data-evolution) (which adds or rewrites whole columns). +An overlay changes individual cells. + +## Concepts + +### Coverage and resolution + +Each overlay declares which cells it provides through a **coverage** bitmap (or, +for sparse overlays, one bitmap per field). The bitmaps index **physical row +offsets**. They include deleted rows and are stable even as deletion vectors change. + +To resolve a cell `(offset, field)` on read, walk the fragment's overlays from +**newest to oldest**. The first overlay that covers `(offset, field)` wins; its +value is used. If no overlay covers the cell, the value falls through to the base +data file (or is `NULL` if no base data file holds that field). + +Precedence among overlays is determined by: + +1. `committed_version` — higher wins (see [Versioning](#versioning-and-ordering)). +2. Position in `DataFragment.overlays` as a tiebreaker — a later entry is newer. + +A covered offset whose value is `NULL` overrides the cell **to** `NULL`. This is +distinct from an offset that is simply absent from the bitmap, which falls +through to the base. Coverage, not value-nullness, decides whether an overlay +applies. + +### Interaction with deletions + +Deletions take precedence over overlays. If a row offset is marked deleted in the +fragment's deletion file, any overlay value for that offset is dead and is +ignored, regardless of commit order. + +### Physical layout + +An overlay's data file stores **one value column per field**, in the order of +`data_file.fields`. It does **not** store a row-offset key column. The position of +a covered offset's value within its column is the **rank** of that offset in the +field's coverage bitmap — the number of set bits below it. Resolving a cell is a +rank lookup plus one value fetch, with no separate offset column to store or +search. + +Because different fields may cover different offset sets, the value columns of a +single sparse overlay may have **different lengths**. The Lance file format +permits columns of differing item counts within one file, so a sparse overlay is +representable as a single file. (See [Writer support](#writer-support) for the +current implementation status.) + +### Dense vs. sparse overlays + +A single overlay is one of two shapes: + +- **Dense (rectangular).** One `shared_offset_bitmap` applies to every field. Every + covered offset has a value for every field. This is the common case for a plain + `UPDATE`, where one `SET` list is applied to one set of rows. +- **Sparse.** A `FieldCoverage` carries one bitmap per field, used when different + fields cover different offset sets — for example a `MERGE` with multiple + `WHEN MATCHED` branches, where different rows update different columns. A dense + overlay would have to widen to the bounding rectangle and fill the untouched + cells with their current values (post-images), which for wide columns such as + embeddings means re-storing data that did not change. A sparse overlay stores + exactly the changed cells. + +## Protobuf + +<details> +<summary>DataOverlayFile protobuf message</summary> + +```protobuf +%%% proto.message.DataOverlayFile %%% +``` + +</details> + +<details> +<summary>FieldCoverage protobuf message</summary> + +```protobuf +%%% proto.message.FieldCoverage %%% +``` + +</details> + +## Versioning and ordering + +Overlays reuse the dataset version as their ordering clock rather than +introducing a separate generation counter. + +`committed_version` is the dataset version at which an overlay **became +effective** — the version of the commit that introduced it, **not** the version +it was read from. It is stamped at commit time and re-stamped if the commit is +retried, in the same way as the created-at / last-updated-at version sequences. + +This single value drives every ordering decision: + +- **Overlay vs. overlay** (read precedence): higher `committed_version` wins. +- **Overlay vs. index** (query correctness): an index records the + `dataset_version` it was built from. An index whose `dataset_version >= + committed_version` already incorporates the overlay. An overlay whose + `committed_version > index.dataset_version` is newer than the index and its + cells must be excluded from index results and re-evaluated. +- **Scheduler signal**: the gap between an overlay's `committed_version` and an + index's `dataset_version`, or between an overlay and the base, is a staleness + measure the compaction scheduler can use. + +!!! note "Why effective version, not read version" + + Suppose an overlay reads version 5 and commits at version 6, while an index + is built reading version 5 (before the overlay) and commits at version 7 with + `dataset_version = 5`. If the overlay stored its *read* version (5), the test + `5 > 5` is false, the row would not be excluded, and the index — which never + saw the overlay — would return a stale result. Storing the *effective* + version (6) makes `6 > 5` true, the cell is excluded and re-evaluated, and the + result is correct. + +## Index integration + +Building an index over a fragment that has overlays does **not** require dropping +the fragment from the index's coverage. The fragment stays indexed, and the query +path reconciles overlays at query time using an **exclusion set**. + +The exclusion set for an index on field `F` is the union of the coverage bitmaps, +restricted to field `F`, of every overlay whose `committed_version > +index.dataset_version`. The exclusion is **field-aware**: an overlay that touches +only unrelated columns does not exclude anything from the index on `F`. + +`F` here ranges over the union of the index's `fields` and `covering_fields` +(see [Serving carried columns](../index/index.md#serving-carried-columns)). +An overlay updating a merely-carried column leaves the keyed value correct while +making the carried value stale, so it must exclude those rows just the same. + +The query then proceeds as: + +1. Run the index search as usual, producing candidate rows. +2. Remove any candidate in the exclusion set. (Its indexed value may be stale.) +3. **Re-evaluate** the excluded rows against their current values — the same flat + path already used for the unindexed tail of fragments. For a scalar predicate + this re-applies the filter; for a vector query it re-scores the row's current + vector. Rows that still match are added back to the result. + +Step 3 is what makes exclusion correct rather than merely safe: removing a row +from index candidates without re-evaluating it would silently drop a row that +should match under its new value. + +Exclusion is always *sufficient* because a write changes a cell only by adding an +overlay, and that overlay's `committed_version` — the version of the commit that +adds it — necessarily exceeds the `dataset_version` of any pre-existing index. So +every cell a write changes is guaranteed to fall in that index's exclusion set. +Compaction may remove an overlay only if no index still relies on it for exclusion +(see [Compaction](#compaction)). + +## Compaction + +Overlays accumulate read cost — every overlay is a bitmap to test, a possible +file to open, and additional work to interleave values. Compaction bounds that cost in two modes: + +- **Overlay → overlay.** Merge several overlays into fewer, computing the + post-image per `(offset, field)` by walking the merged overlays newest-first. + The merged overlay takes the **maximum** `committed_version` of its inputs, so + the exclusion semantics are preserved. The merged overlays must be **contiguous + in `committed_version`** — with overlays at v10, v30, and v50 you cannot merge + just v10 and v50, because stamping the result v50 would incorrectly promote + v10's values above the intervening v30 for any cell v30 also covers. Indexes can + still be re-used, but they may now need to exclude more rows. This is cheap to + write and does not touch the base. +- **Overlay → base.** Fold overlays into a fresh base data file, computing the + post-image for every covered cell, then clear the overlays. The base is + complete, so every post-image is well defined. Overlay offsets are physical, so + they cannot survive a rewrite that reorders rows; folding therefore materializes + values rather than carrying overlays forward. + +!!! warning "Folding an indexed field must update its index" + + An overlay→base fold removes the overlay, which removes the exclusion signal + that kept an index correct. Folding an overlay that covers an indexed field + `F` is therefore equivalent to a column rewrite of `F` and must, in the same + commit, either rebuild the index to a `dataset_version` at least the folded + overlay's `committed_version`, or remove the fragment from the index's + coverage so the rows fall to the flat path. Otherwise the index would serve + stale values with no overlay to exclude them. This is the same rule that + already governs rewriting a column that an index is built on. + +When a fragment with overlays is compacted by a row-rewriting operation +(`RewriteRows`, which produces new fragments with new row addresses), the +overlays are folded into the new base as part of the rewrite, and existing +[fragment-reuse remapping](row_id_lineage.md) handles the row-address changes as +it does today. + +## Row lineage + +An overlay write updates the `last_updated_at_version` of every covered row, so +change-data-feed and time-travel queries observe the update. Because overlays are +addressed by physical offset, they do **not** require stable row IDs to be +enabled; lineage updates apply only when those features are on. + +## Worked example + +The following example illustrates how overlays function across their lifecycle, to make the rules above concrete. + +A table `users` with stable row IDs enabled and these fields: + +| field id | name | type | +|----------|-----------|-------------------------| +| 1 | id | `int32` (primary key) | +| 2 | name | `utf8` | +| 3 | age | `int32` | +| 4 | embedding | `fixed_size_list<f32,4>`| + +Created at version 1 as a single fragment `0` with one base data file +`data/file0.lance` holding all four columns. `physical_rows = 4`: + +| offset | id | name | age | embedding | +|--------|----|-------|-----|------------------| +| 0 | 1 | Alice | 30 | … | +| 1 | 2 | Bob | 25 | … | +| 2 | 3 | Carol | 40 | … | +| 3 | 4 | Dave | 22 | … | + +A BTree scalar index on `age` is built at version 1, covering fragment `0` +(`dataset_version = 1`). + +### Step 1 — write an overlay + +```sql +UPDATE users SET age = age + 1 WHERE id IN (2, 4); -- Bob (offset 1), Dave (offset 3) +``` + +This touches one field (`age`) for two rows, so the writer emits a dense overlay — +one shared bitmap covering both offsets — and commits it as version 2. Fragment +`0` gains: + +```text +DataOverlayFile { + data_file: { path: "data/overlay-<uuid>.lance", fields: [3], column_indices: [0] } + coverage: shared_offset_bitmap = {1, 3} + committed_version: 2 +} +``` + +The overlay file stores a single `age` column with two values, `[26, 23]`, at +ranks `{1,3}.rank(1) = 0` and `{1,3}.rank(3) = 1`. `last_updated_at_version` is +set to 2 for offsets 1 and 3. + +### Step 2 — read + +`SELECT id, age FROM users` reads base ages `[30, 25, 40, 22]`. For `age` +(field 3), the overlay covers offsets 1 and 3, so `age[1]` is replaced with the +overlay value at rank `{1,3}.rank(1) = 0` → `26`, and `age[3]` with the value at +rank `{1,3}.rank(3) = 1` → `23`. Result ages: `[30, 26, 40, 23]`. + +### Step 3 — index query + +```sql +SELECT * FROM users WHERE age = 26; +``` + +The `age` index was built at `dataset_version = 1`; the overlay's +`committed_version` is 2. Since `2 > 1`, the overlay's coverage for `age`, `{1, 3}`, +is the exclusion set for this query. + +- The index (built at v1) holds Bob's *old* `age = 25`, so a lookup for `26` + returns nothing from the index. +- The whole exclusion set is re-evaluated on the flat path, not just the rows the + index returned. Offset 1's current `age` (26, via the overlay) matches, so Bob + is returned; offset 3's current `age` (23) does not match and is dropped. + +The mirror case `WHERE age = 25` shows exclusion preventing a stale hit: the index +returns offset 1 (stale `25`), but offset 1 is excluded, re-evaluated to `26`, and +correctly dropped. + +### Step 4 — a second, non-rectangular write + +```sql +MERGE INTO users USING staged ON users.id = staged.id +WHEN MATCHED AND staged.kind = 'rename' THEN UPDATE SET name = staged.name -- Carol(2), Dave(3) +WHEN MATCHED AND staged.kind = 'embed' THEN UPDATE SET embedding = staged.embedding -- Bob(1) +``` + +`name` is updated for offsets `{2, 3}` and `embedding` for offset `{1}` — different +fields over different rows. This is a sparse overlay, committed as version 3: + +```text +DataOverlayFile { + data_file: { path: "data/overlay-<uuid2>.lance", fields: [2, 4], column_indices: [0, 1] } + coverage: field_coverage { offset_bitmaps: [ {2,3}, {1} ] } + // name (field 2) ^ ^ embedding (field 4) + committed_version: 3 +} +``` + +The file's `name` column has **two** values (`["Caroline", "David"]`, at +ranks 0 and 1 of `{2,3}`) and its `embedding` column has **one** value (at rank 0 +of `{1}`) — columns of different lengths in one file. + +### Step 5 — read after the second write + +`SELECT name, age, embedding FROM users` resolves each field independently, +newest overlay first: + +- `name`: the v3 overlay covers `{2,3}` → `["Alice", "Bob", "Caroline", "David"]`. +- `age`: the v3 overlay does not cover `age`; the v2 overlay still applies at + offsets 1 and 3 → `[30, 26, 40, 23]`. +- `embedding`: the v3 overlay covers `{1}` → Bob's vector is the new one, others + from base. + +Overlays from different versions coexist and apply per field. + +### Step 6 — compaction (overlay → base) + +The scheduler folds both overlays into fragment `0` at version 4, computing +post-images for `age`, `name`, and `embedding`, and writing a new base data file +`data/file1.lance` with those columns. In the old file, fields 2, 3, and 4 are +marked with a tombstone (`-2`); field 1 (`id`) remains. The fragment's `overlays` list is +cleared. Row addresses are preserved (a column rewrite, not a row rewrite), so +stable row IDs and the deletion vector are untouched. + +Because the fold removed the overlay that was excluding offsets 1 and 3 from the +`age` index, the commit must drop fragment `0` from its coverage so `age` queries +fall to the flat path. + +## Guidance + +!!! note "This section is a stub." + + The following are implementation considerations, not part of the on-disk + specification. + +### When to overlay vs. rewrite a column vs. move rows + +<!-- TODO: Replace the rough heuristic below with concrete thresholds once we + have benchmarked the crossover points between overlays, column rewrites, + and row moves. --> + +*(To be expanded.)* The choice between appending an overlay, rewriting a full +column (data evolution), and moving updated rows to a new fragment depends on the +fraction of rows changed, the fraction of columns changed, column width, the +presence of indexes on the changed columns, and the accumulated overlay read +cost. Roughly: few rows changed favors overlays; most rows in a few columns +favors a column rewrite; most columns changed favors moving rows to a new +fragment. + +### Writer support + +<!-- TODO: Fill in as writer implementation progresses, including the status of + single-file sparse overlays (independent-length columns). --> + +*(To be expanded.)* Dense (rectangular) overlays write with the existing +equal-length file writer today. Sparse overlays stored as a **single** file +require the writer to emit columns of independent lengths, which the current v2 +writer does not yet do (it advances all columns from one global row counter). +Until that support lands, a writer can express a sparse update as multiple dense +overlays in one transaction. + +### Scheduling compaction + +<!-- TODO: Fill in with a concrete cost/benefit policy once compaction is + implemented and benchmarked. --> + +*(To be expanded.)* The overlay→overlay and overlay→base modes have very +different costs; a cost/benefit scheduler decides when each is worthwhile, using +the version gap as a staleness signal. + +## Related specifications + +- [Table format overview](index.md) +- [Transactions: DataOverlay operation](transaction.md#dataoverlay) — write path + and conflict semantics +- [Row ID & Lineage](row_id_lineage.md) +- [Index Formats: handling overlay rows](../index/index.md#handling-deleted-and-invalidated-rows) +- [Format Versioning](versioning.md) diff --git a/docs/src/format/table/fragment_metadata.md b/docs/src/format/table/fragment_metadata.md new file mode 100644 index 00000000000..5a75473c37c --- /dev/null +++ b/docs/src/format/table/fragment_metadata.md @@ -0,0 +1,461 @@ +# Fragment Metadata Tree + +!!! warning "Experimental" + + This layout is unstable. Readers and writers may change it without + keeping compatibility with earlier unstable revisions. Flat manifests + remain the default. + +**This page specifies a proposed on-disk format for storing fragment state. +Support for creating or reading tree tables is not yet available in released +Lance versions.** + +!!! note "Tree tables require feature flag 4096, `FLAG_FRAGMENT_TREE`" + + A reader or writer that does not understand this layout must refuse the + dataset. The flat `fragments` list is empty on a tree table, so a reader + that ignored the flag would see an empty table. + +In the flat format, each Version Manifest carries the complete fragment list. +Committing a change and opening a dataset both process that whole list, even +when the change touches only a few fragments. + +The fragment metadata tree (fragment tree) stores those records in immutable +Lance leaves keyed by fragment ID. Validated changes may remain above the leaves as +mutations until enough work accumulates to rewrite them. + +The Version Manifest defines table-version state. Committing it makes that +version visible. + +![Fragment metadata tree](fragment_metadata_tree.svg) + +*A shallow tree is shown. Larger trees may insert interior routing nodes between +the root and leaves.* + +### At a glance + +| | | +|---|---| +| Read | Read the leaf record, if any, then apply newer mutations for that fragment. | +| Write | Native Lance validates the change. The tree stores the resulting mutation. | +| Publish | Write immutable tree objects first, then commit the Version Manifest. | + +Protobuf messages are in `protos/fragment_metadata.proto`. +`Manifest.fragment_tree` is declared in `protos/table.proto`. Operation +semantics, conflict rules, and native validation stay with +[transactions](transaction.md). A storage action is the result of a successful +native transaction. + +Readers must treat a violation of any requirement on this page as a corrupt +snapshot. + +## Snapshot + +A tree table must set `Manifest.fragment_tree`, leave `Manifest.fragments` +empty, and set flag 4096 in both `reader_feature_flags` and +`writer_feature_flags`. These fields must agree. When `FLAG_FRAGMENT_TREE` +is set, `FragmentTreeMetadata.layout` must select exactly one recognized layout. + +Each version stores fragment records either in the tree or in +`Manifest.fragments`, never both. A writer may convert a flat table by +publishing a new tree-backed version. Whether and when to convert is +writer policy. + +A `FragmentTree` must contain exactly one of an inline root or a +`root_uuid`. + +An inline root carries the complete `FragmentTreeRoot` in the Version +Manifest. `mutations_since_root` must be empty. + +An external root named by `root_uuid` belongs to the current dataset. +`root_uuid` must contain exactly 16 bytes, in the order of the UUID's hexadecimal +digits. Its path is `_bt/root/{uuid}.root`, with lowercase hexadecimal digits and +hyphens after digits 8, 12, 16, and 20. Readers must reject any other byte length. +`mutations_since_root` contains every mutation in this version that is not +represented by that root. Readers must not follow a mutation chain. + +``` +Version Manifest N +├─ root_uuid ──────────► Root R +└─ mutations_since_root + +fragment state N = Root R + mutations_since_root +``` + +Opening a version requires its Version Manifest and at most one external root. +Version history is not consulted. + +`buffer` on a root or interior holds mutations already in the tree that have +not been pushed to that node's children. + +## Tree objects + +Tree objects are immutable and stored under `_bt/` relative to a dataset root. +Tree object paths must begin with `_bt/`. Readers must reject absolute paths +and paths outside `_bt/`. + +``` +{dataset_root}/ + _bt/ + root/{uuid}.root FragmentTreeRoot protobuf + node/{uuid}.node FragmentTreeNode protobuf + leaf/{uuid}.lance Lance file of complete fragment records +``` + +A `.root` file is a raw `FragmentTreeRoot` protobuf. A `.node` file is a +raw `FragmentTreeNode` protobuf. Neither has an extra header or footer. + +`FragmentTreeRoot` holds its children, buffer, and `next_action_sequence`. + +`FragmentTreeNode` holds the child list and a mutation buffer for that +subtree. + +Child `path` values must be non-empty. Resolved child locations within a child +list must be unique. Equal paths in different datasets name different objects; +different `base_id` values resolving to the same object do not make it unique. + +A leaf is a Lance file of complete fragment records for one fragment-ID range. + +## Object references + +The Version Manifest root belongs to the current dataset. It is either an +inline `FragmentTreeRoot` or a `root_uuid` in the current dataset. + +A `FragmentTreeChild` contains a `path` relative to its resolved dataset +and an optional `base_id`. + +When `base_id` is set, it must identify a `BasePath` in the Version Manifest +with `is_dataset_root` set to true. When `base_id` is absent, the reference +inherits the resolved dataset of the containing tree object. +Look up entries by `BasePath.id`, not their position in `base_paths`. Zero is a +valid ID, distinct from an absent `base_id`. IDs must be unique within the +manifest, and readers must reject an unknown ID. + +A child object resolves to `{resolved_dataset}/{path}`. Tree object paths must +begin with `_bt/` and must not be resolved under `data/`. + +A reference with `base_id` set may name an immutable tree object in another +dataset. Descendant references with no `base_id` inherit that object's resolved +dataset. + +New tree objects must belong to the current dataset. Tree objects owned by +another dataset must not be modified. + +A shallow clone's root must belong to the clone dataset. Unchanged source +subtrees may be referenced through `base_id`. The clone's `base_paths` must +contain a `BasePath` for the source dataset root and preserve every `BasePath` +entry referenced by a shared tree object at the same id. New base paths must +use previously unused ids. +If an ID already names a different base, the writer must copy the affected +objects and remap their references, or reject the operation. It must not change +the meaning of an ID used by a shared immutable object. + +When a tree object is copied into another dataset, references that inherited +the source dataset must be rewritten with a `base_id` naming that dataset. +Existing explicit `base_id` values must continue to resolve to the same +`BasePath` entries. + +An `ExternalFile` embedded in `DataFragment` has no `base_id`. Its path is +relative to the resolved dataset of the tree object or Version Manifest that +contains the fragment state. + +When fragment state is written to a different dataset, each referenced +`ExternalFile` must be copied to that dataset and its path rewritten before +publication. The write must fail if any such reference cannot be preserved. + +## Leaf format + +A leaf is a Lance file with one row per fragment and the following schema. +Any Lance file version may be used. + +```python +import pyarrow as pa + +mapping = pa.dictionary(pa.int32(), pa.binary()) +data_file = pa.struct([ + pa.field("path", pa.utf8(), nullable=False), + pa.field("field_ids", mapping, nullable=False), + pa.field("column_indices", mapping, nullable=False), + pa.field("major_version", pa.uint32(), nullable=False), + pa.field("minor_version", pa.uint32(), nullable=False), + pa.field("file_size_bytes", pa.uint64(), nullable=False), + pa.field("base_id", pa.uint32(), nullable=True), +]) +overlay = pa.struct([ + pa.field("data_file", data_file, nullable=False), + pa.field("overlay_meta", pa.binary(), nullable=False), +]) +leaf_schema = pa.schema([ + pa.field("id", pa.uint64(), nullable=False), + pa.field("fragment_meta", pa.binary(), nullable=False), + pa.field("files", pa.list_(pa.field("item", data_file, nullable=False)), nullable=False), + pa.field("overlays", pa.list_(pa.field("item", overlay, nullable=False)), nullable=False), +]) +``` + +The schema must match exactly, including field order, types, and nullability. +Only `base_id` may be null; dictionary values must also be non-null. Empty lists +represent no files or overlays. + +Rows must be ordered by strictly increasing `id`. Valid fragment IDs fit in +`u32`. A fragment must not be split across leaves. Preserve file and overlay +list order, including duplicate file paths and the overlay precedence defined +in [Data Overlay Files](data_overlay_file.md). + +`fragment_meta` contains the `DataFragment` protobuf with `id` set to 0 and +`files` and `overlays` cleared. Readers must reject a nonzero protobuf `id` or +nonempty `files` or `overlays`, then restore these fields from the row. +All other fragment fields keep their protobuf representation and semantics. + +Each file struct represents a `DataFile`: `field_ids` maps to `fields`, and +`major_version` and `minor_version` map to `file_major_version` and +`file_minor_version`. Other fields use their protobuf names. + +`overlay_meta` contains the `DataOverlayFile` protobuf with `data_file` absent. +Readers must reject a populated `data_file`, then restore it from the sibling +struct. Coverage and committed version retain their protobuf semantics. + +Each binary dictionary value stores a mapping vector as consecutive little-endian +signed 32-bit integers, without a length prefix. Empty bytes mean an empty vector. +Readers must reject lengths not divisible by 4 and out-of-bounds dictionary keys. +Decoded vectors preserve element order and signed values, and must satisfy the +`DataFile` mapping rules for that file version. + +Lance stores the dictionaries. Keys are local to their dictionary array; compare +decoded vectors, not keys, across arrays, columns, batches, or leaves. + +`file_size_bytes` of 0 means unknown. A null `base_id` inherits the leaf +object's resolved dataset. A set `base_id` matches `BasePath.id` in this Version +Manifest's `base_paths`. The same inheritance rule applies to deletion files, overlays, +and other fragment-owned files. A null `base_id` in a buffered mutation +inherits the resolved dataset of the structure containing that mutation. + +Counts in a leaf are known. `physical_rows` of 0 is zero rows. A deletion file +must carry `num_deleted_rows`, which must not exceed `physical_rows`. + +After decoding a leaf, readers must verify the following fields against its +parent `FragmentTreeChild`: + +- `num_keys` equals the number of rows +- `total_rows` and `visible_rows` equal the counts recomputed from the + fragment records +- `height` is 0 +- `num_children` is 0 + +`min_key` is an inherited routing bound. `object_size` is the stored byte +length of the fetched object. `materialized_through_action_sequence` is stored +on the child reference. Readers must validate those fields by the rules in +Routing and Sequence numbers, not by recomputing them from the leaf file. + +## Routing + +A root may have zero or more children. With no children, mutations may remain +in the root `buffer` or `FragmentTree.mutations_since_root`. Fragment +resolution must not descend to a leaf. Once children exist, they route +fragment IDs as follows. + +Interior nodes must have at least two children. The root may have zero, one, +or more. + +Children are ordered by `min_key`. `min_key` is the inclusive lower bound of +this child's fragment-ID range. It need not equal an ID currently stored in +the child. The next child's `min_key` is the exclusive upper bound. The +first child of the root must have `min_key` 0. An interior node's first +child must begin at the lower bound assigned by its parent. Together, the +children must partition the parent range. Routing a fragment ID selects the +rightmost child whose `min_key` is at most that fragment ID. + +Ranges must not overlap. Each mutation buffered by an interior must belong to +exactly one child range. The same rule applies to a root once it has children. + +`height` is the number of edges from the referenced object to its leaves. +Leaves have height 0. Every child of an interior must have height one less +than that interior, so all leaves in a subtree occur at the same depth. + +A leaf must contain at least one fragment record and have `num_children` 0. +Writers must not retain an empty child. + +`num_children` is the number of direct children in the referenced object. It +is 0 for leaves and at least 2 for interiors. After an interior is decoded, +readers must verify that this value equals `children.len()`. + +`num_keys` is the number of fragment records in the referenced subtree, +including that object's own buffer. Mutations held by ancestors are excluded. +After a leaf is decoded, it must equal the number of rows. After an +interior is decoded, it must equal the sum of the children's `num_keys` plus +the buffer `fragment_count_delta`s. + +`total_rows` is the physical-row count in that subtree, including deleted +rows. `visible_rows` is that count minus deleted rows. Both include that +object's own buffer and exclude ancestor mutations. `visible_rows` must not +exceed `total_rows`. After a leaf is decoded, both must equal the counts +recomputed from fragment records. After an interior is decoded, both must +equal the sum of the children's values plus the corresponding buffer deltas. + +`object_size` is the stored byte length of the child object. It must be +nonzero. It is known before the child is fetched and must equal the fetched +object's byte length. + +A child's inherited range is `min_key` inclusive to the next sibling's +`min_key` exclusive. The last child of the root uses `2^32` as that exclusive +end. The last child of an interior uses the exclusive end assigned by its +parent. `min_key` must be at most `2^32 - 1`. + +Every fragment ID decoded from a leaf must lie in that leaf's inherited +range. Every mutation target decoded from a root or interior buffer must lie +in that node's inherited range. Every descendant child range must be contained +in its parent's range. Readers apply these checks as objects are decoded. The +checks do not require opening the rest of the tree. + +## Mutations + +`FragmentTreeMutation` wraps one `FragmentAction` with a sequence number +and three count deltas. Every mutation must contain an action. The action must +select exactly one recognized variant, and any message payload required by +that variant must be present. + +`fragment_count_delta` is 1 when the action creates a record, minus 1 when it +removes one, and 0 otherwise. `total_rows_delta` and `visible_rows_delta` are +the record after the action minus the record before it. An absent record +counts as zero. + +When the prior fragment is available, readers must recompute the mutation +deltas and verify that they match the stored values. Ancestor summaries are +maintained from these deltas. After materialization, readers must recompute +leaf summaries from records. Writers must not publish a materialization whose +recomputed summaries disagree with the derived version totals. + +File and deletion-file actions must leave all other `DataFragment` fields +unchanged. Use `upsert_fragment` when no other action can represent the change. +This includes changes to overlay files, version sequences, and row-ID state. +It replaces the complete fragment record. + +Removal actions are idempotent. Removing an absent fragment, file, or deletion +file is a no-op where specified below. Actions that modify an existing +fragment require that fragment to exist. + +| Action | Precondition | Effect | +|---|---|---| +| `upsert_fragment` | None | Install the complete record, replacing anything at that id | +| `remove_fragment` | None | Remove the record at that id. An absent id is a no-op | +| `add_data_file` | Record present, else reject | Append the file to the end of the ordered file list | +| `remove_data_file` | Record present, else reject | Remove every file whose path matches, keeping survivor order. No match is a no-op | +| `add_deletion_file` | Record present, else reject | Set the deletion file, replacing any existing one | +| `clear_deletion_file` | Record present, else reject | Clear the deletion file. No deletion file is a no-op | +| `replace_data_file` | Record present and a file whose path equals `expected_path`, else reject | On the first such file set `path`, `file_size_bytes`, and `base_id`. Keep its field ids, column indices, and file version | + +`Fragment.files` is an ordered list and paths may repeat. `replace_data_file` +edits a slot, not a path. Writers must not fold two replacements that chain +through a renamed path into one. Starting from files `[A, B]`, renaming B to +A and then replacing the first A with C yields `[C, A]`. Folding them into +one replacement of B with C yields `[A, C]`. + +To resolve a fragment, collect every mutation for its id from +`mutations_since_root`, the root buffer, and each interior buffer on its +routing path. Sort by sequence and apply in order to the leaf record, or to +nothing if there is no leaf or the leaf has no record. + +Version fragment count, physical-row count, and visible-row count are derived +from the root child summaries plus the root buffer deltas, then plus the +`mutations_since_root` deltas. They are not stored on `FragmentTree`. +Derived `visible_rows` must not exceed derived `total_rows`. + +## Sequence numbers + +Mutation sequences must be nonzero and unique. +Each tree has one sequence namespace. Writers must not reuse a sequence number +anywhere in the tree. + +- `FragmentTreeRoot.next_action_sequence` is at least 1. Every sequence in + the root buffer, in every interior buffer below it, and every leaf watermark + below it must be less than this value. +- `FragmentTree.next_action_sequence` is at least the root's value. + Every `mutations_since_root` sequence must be at or above the root's value + and below `FragmentTree.next_action_sequence`. +- A leaf watermark of 0 means the leaf has applied nothing, so every mutation + routed to it replays. + +Readers must verify uniqueness across every mutation source decoded for the +operation. Uniqueness across unread subtrees is a writer invariant. + +Mutations may be serialized in any order. Aggregate counts must not depend on +serialization order; readers may apply checked deltas in action sequence order. + +`materialized_through_action_sequence` records how far a leaf has been +materialized. It is not part of the leaf contents. The same leaf may +therefore be referenced with different watermarks. It must be 0 on an +interior child. + +A leaf watermark `N` means every mutation for that leaf's range with a +sequence at or below `N` has been incorporated into the leaf's records, with +no holes, and no buffer above the leaf holds a mutation for its range with a +sequence at or below `N`. Readers must reject a collected mutation whose +sequence is at or below the owning leaf's +`materialized_through_action_sequence`. + +## Fragment IDs + +`Manifest.max_fragment_id` is authoritative for fragment ID allocation. The +tree has no separate allocator. + +`max_fragment_id` is absent only when no fragment ID has ever been allocated or +reserved in the lineage. It must not decrease. Deletion does not make fragment +IDs reusable, and restore must not lower the value. + +The first allocated fragment ID is 0. Otherwise, allocation uses +`max_fragment_id + 1`. `ReserveFragments` advances the value. A value of +`2^32 - 1` exhausts the fragment-ID space. + +Every fragment ID stored or targeted by the tree must be at most +`Manifest.max_fragment_id`. If `max_fragment_id` is absent, the tree must +contain no fragment records or fragment-targeting mutations. + +## Validation + +Readers must validate every structure they decode before using it. They must +not treat a corrupt snapshot as empty. + +| Object | Additional checks | +|---|---| +| Manifest | Feature flags set, `fragments` empty, `fragment_tree` present, layout selected | +| `FragmentTree` | Exactly one root representation, sequence range valid, tree object references valid, no target above `max_fragment_id` | +| Root | Children and buffer valid, derived visible rows at most derived total rows | +| Node / leaf | Fetched byte length equals parent `object_size`. Shape and aggregate fields that can be recomputed from the object agree with the parent child reference. Routing bounds and the leaf watermark follow Routing and Sequence numbers. | + +## Publication and cleanup + +Writers compete for the next dataset version, even when changing different +fragments. A writer that loses the version race must check intervening +transactions and rebase compatible changes before retrying. Incompatible +changes fail under the normal transaction rules. + +A retry may read the new manifest, transaction history, and affected tree +objects, then write a transaction record, tree objects, and a new manifest. +If changes fit in `mutations_since_root`, the external root can be reused without +rewriting a leaf. Validation, flushing, splitting, or eager loading can require +additional I/O. + +Every tree object reachable from a Version Manifest must be durable before that +manifest is committed. The manifest commit is the visibility boundary. + +A failed attempt leaves unreachable objects that are eligible for cleanup. + +Whether to inline the root or publish a new external root is writer policy and +may change between versions. + +Unchanged immutable subtrees may be reused. New tree objects must belong to +the current dataset. Reused references must preserve their resolved dataset. + +Cleanup computes reachability from the fragment state of every +retained manifest, applying buffers and `mutations_since_root`. The reachable +set includes the external root named by `root_uuid`, if any, every `.node` and +`.lance` reachable from that root after resolving child references, and every +data file, deletion file, and related object that state names. +A file named only by a pending mutation is reachable through that resolved +state. A file named only by a mutation that a later mutation in the same +snapshot supersedes is not. Objects under this dataset's `_bt/` outside the +set follow the same age and in-progress rules as data files. + +An object reachable from any retained manifest of this dataset must not be +removed. Tree object lifetime across datasets follows the same contract as +data files named through `base_paths`. diff --git a/docs/src/format/table/fragment_metadata_tree.svg b/docs/src/format/table/fragment_metadata_tree.svg new file mode 100644 index 00000000000..3bbe92d3ff5 --- /dev/null +++ b/docs/src/format/table/fragment_metadata_tree.svg @@ -0,0 +1,67 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 420" role="img" aria-labelledby="title desc"> + <title id="title">Fragment metadata tree + A Version Manifest holds mutations_since_root and points at an external root. The root holds a buffer and routes fragment-id ranges to Lance leaves. Interior nodes may sit between the root and leaves. + + + + + + + + VERSION + MANIFEST + + Version Manifest + snapshot authority, schema, version + + mutations_since_root + not in the referenced root yet + + + root_uuid + + EXTERNAL + ROOT + + Root R + child ranges + + buffer + in the tree, not in children or leaves yet + + + + fragment-id ranges + + LEAVES + + Lance leaf + fragments 0..999 + + Lance leaf + fragments 1000..1999 + diff --git a/docs/src/format/table/index.md b/docs/src/format/table/index.md index a1105592ae6..a20d4c564c2 100644 --- a/docs/src/format/table/index.md +++ b/docs/src/format/table/index.md @@ -2,9 +2,23 @@ ## Overview -The Lance table format organizes datasets as versioned collections of fragments and indices. -Each version is described by an immutable manifest file that references data files, deletion files, transaction file and indices. -The format supports ACID transactions, schema evolution, and efficient incremental updates through Multi-Version Concurrency Control (MVCC). +The Lance table format organizes datasets as versioned collections of fragments, data files, deletion files, and indices. Each version is described by an immutable manifest that references the physical data for that snapshot. + +The format is designed for machine learning and highly selective workloads where column additions, index maintenance, and partial rewrites must be cheap. It supports ACID transactions, schema evolution, time travel, and efficient incremental updates through Multi-Version Concurrency Control (MVCC). + +## Design Goals + +### Two-Dimensional Storage + +Rows are partitioned into fragments, and each fragment can contain multiple data files that each provide one or more columns. This lets writers add or backfill columns by attaching new data files to existing fragments instead of rewriting the full table. + +### First-Class Indices + +Indices are part of the table format lifecycle. The table metadata describes index discovery and transactional coordination, while the detailed search structures remain separate index formats. This gives engines a uniform way to create, drop, update, and query indices without coupling the table format to any single indexing algorithm. + +### External Manifest Store + +Lance can commit directly to object storage, but deployments may also coordinate commits through an external manifest store. In that model, the external system helps serialize commits and apply governance checks, while the canonical table state is still persisted in the Lance table format. ## Manifest @@ -33,6 +47,9 @@ Afterwards, field IDs are assigned incrementally for newly added fields. Column encoding configurations are specified through field metadata using the `lance-encoding:` prefix. See [File Format Encoding Specification](../file/encoding.md) for details on available encodings, compression schemes, and configuration options. +For complete schema specification details including supported data types, field ID assignment, and metadata handling, +see the [Schema Format Specification](schema.md). +
Field protobuf message @@ -102,6 +119,17 @@ Field ids might be replaced with `-2`, a tombstone value. In this case that column should be ignored. This used, for example, when rewriting a column: The old data file replaces the field id with `-2` to ignore the old data, and a new data file is appended to the fragment. +Every negative field id is reserved for system use and never names a field of the +dataset schema. A reader MUST skip any negative id when it projects the dataset schema +onto a data file, rather than treat it as a schema field or reject the file. Besides +`-1` (not yet assigned; only ever exists in memory and must not be written) and the +`-2` tombstone above, `-3`, `-4` and `-5` are the hidden `_rowid`, +`_row_created_at_version` and `_row_last_updated_at_version` columns that hold a +fragment's row lineage sequences when they are not stored in the manifest; see +[Row ID and Lineage](row_id_lineage.md). Such a column always lives in one of the +fragment's `files`, next to user columns or in a file holding nothing else, and at +most one file of a fragment may carry each of these ids. + ## Data Files Data files store column data for a fragment using the Lance file format. @@ -118,6 +146,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. @@ -140,6 +179,29 @@ However, this invalidates row addresses and requires rebuilding indices, which c +## Data Overlay Files + +!!! warning "Experimental" + + This feature is currently experimental and not yet supported in any library. + + + +!!! note "Overlay files require feature flag 64 (data overlay files)" + +Overlay files supply new values for a subset of cells within +a fragment without rewriting the base data files. They make updates cheap when only +a small percentage of rows and/or columns change: a writer appends a small file +carrying just the changed cells instead of rewriting whole columns or moving rows +to a new fragment. + +For the full specification — coverage and resolution rules, dense vs. sparse layout, +versioning, index integration, compaction, and a worked example — see the +[Data Overlay Files Specification](data_overlay_file.md). + + + ## Related Specifications ### Storage Layout @@ -164,7 +226,7 @@ See [Row ID & Lineage Specification](row_id_lineage.md) Vector indices, scalar indices, full-text search, and index management. -See [Index Specification](index/index.md) +See [Index Formats](../index/index.md) ### Versioning diff --git a/docs/src/format/table/index/index.md b/docs/src/format/table/index/index.md deleted file mode 100644 index 54827c71ba5..00000000000 --- a/docs/src/format/table/index/index.md +++ /dev/null @@ -1,235 +0,0 @@ - -# Indices in Lance - -Lance supports three main categories of indices to accelerate data access: scalar -indices, vector indices, and system indices. - -**Scalar indices** are traditional indices that speed up queries on scalar data types, such as -integers and strings. Examples include [B-trees](scalar/btree.md) and -[full-text search indices](scalar/full_text.md). Typically, scalar indices receive a -query predicate, such as equality or range conditions, and output a set of row addresses that -satisfy the predicate. - -
- ![](./scalar_index.drawio.svg) -
- -**[Vector indices](./vector/index.md)** are specialized for approximate nearest neighbor (ANN) search on high-dimensional -vector data, such as embeddings from machine learning models. Examples includes IVF (Inverted File) -indices and HNSW (Hierarchical Navigable Small World) indices. These are separate from scalar indices -because they use meaningfully different query patterns. Instead of sargable predicates, vector indices -receive a query vector and return the nearest neighbor row addresses based on some distance metric, -such as Euclidean distance or cosine similarity. They return row addresses and the corresponding distances. - -**System indices** are auxiliary indices that help accelerate internal system operations. They are -different from user-facing scalar and vector indices, as they are not directly used in user queries. -Examples include the [Fragment Reuse Index](system/frag_reuse.md), which supports efficient row address -remapping after compaction. - -## Design - -Lance indices are designed with the following design choices in mind: - -1. **Indexes are loaded on demand**: A dataset and be loaded and read without loading any indices. - Indices are only loaded when a query can benefit from them. - This design minimizes memory usage and speeds up dataset opening time. -2. **Indexes can be loaded progressively**: indexes are designed so that only the necessary parts - are loaded into memory during query execution. For example, when querying a B-tree index, - it loads a small page table to figure out which pages of the index to load for the given query, - and then only loads those pages to perform the indexed search. This amortizes the cost of - cold index queries, since each query only needs to load a small portion of the index. -3. **Indexes can be coalesced to larger units than fragments.** Indexes are much smaller than - data files, so it is efficient to coalesce index segments to cover multiple fragments. - This reduces the number of index files that need to be opened during query execution and - then number of unique index data structures that need to be queried. -4. **Index files are immutable once written, similar to data files.** They can be modified only - by creating new files. This means they can be safely cached in memory or on disk without - worrying about consistency issues. - -## Basic Concepts - -An index in Lance is defined over a specific column (or multiple columns) of a dataset. -It is identified by its name. - -An index is made up of multiple **index segments**, identified by their unique UUIDs. -Each segment is an independent, self-contained index covering a subset of the data. - -Each index segment covers a disjoint subset of fragments in the dataset. The segments must cover -all rows in the fragments they cover, with one exception: if a fragment has delete markers at the time -of index creation, the index segment is allowed to not contain the deleted rows. The fragments an index -covers are those recorded in the `fragment_bitmap` field. - -Index segments together **do not** need to cover all fragments. This means an index isn't required to -be fully up-to-date. When this happens, engines can split their queries into indexed and unindexed -subplans and merge the results. - -
- ![](./starter-example.drawio.svg) -
Abstract layout of a typical dataset, with three fragments and two indices. -
-
- -Consider the example dataset in the figure above: - -* The dataset contains three fragments with ids 0, 1, 2. Fragment 1 has 10 deleted rows, indicated - by the deletion file. -* There is an index called "id_idx", which has two segments: one covering fragments 0 and another covering - fragment 1. Fragment 2 is not covered by the index. Queries using this index will need to query both - segments and then scan fragment 2 directly. Additionally, when querying the segment covering fragment 1, - the engine will need to filter out the 10 deleted rows. -* There is another index called "vec_idx", which has a single segment covering all three fragments. - Because it covers all fragments, queries using this index do not need to scan any fragments directly. - They do, however, need to filter out the 10 deleted rows from fragment 1. - -## Index Storage - -The content of each index is stored at the `_indices/{UUID}` directory under the [base path](../layout.md#base-path-system). -We call this location the **index directory**. -The actual content stored in the index directory depends on the index type. These can be -arbitrary files defined by the index implementation. However, often they are made up of -Lance files containing the index data structures. This allows reuse of the existing Lance -file format code for reading and writing index data. - -## Creating and Updating Index Segments - -Index segments are created and updated through a transactional process: - -1. **Build the index data**: Read the relevant column data from the fragments to be indexed - and construct the index data structures. Write these to files in a new `_indices/{UUID}` - directory, where `{UUID}` is a newly generated unique identifier. - -2. **Prepare the metadata**: Create an `IndexMetadata` message with: - - `uuid`: The newly generated UUID - - `name`: The index name (must match existing segments if adding to an existing index) - - `fields`: The column(s) being indexed - - `fragment_bitmap`: The set of fragment IDs covered by this segment - - `index_details`: Index-specific configuration and parameters - - `version`: The format version of this index type - - See the full protobuf definition in [table.proto](https://github.com/lance-format/lance/blob/main/protos/table.proto). - -3. **Commit the transaction**: Write a new manifest that includes the new index segment - in its `IndexSection`. This is done atomically using the same transaction mechanism - as data writes. - -When updating an indexed column in place (without deleting the row), the engine must -remove the affected fragment IDs from the `fragment_bitmap` field of any index segments -that cover those fragments. This marks those fragments as needing re-indexing without -invalidating the entire segment and prevents invalid data from being read from the index. - -## Index Compatibility - -Before using an index segment, engines must verify they support it: - -1. **Check the index type**: The `index_details` field contains a protobuf `Any` message - whose type URL identifies the index type (e.g., B-tree, IVF, HNSW). If the engine - does not recognize the type, it should skip this index segment. - -2. **Check the version**: The `version` field in `IndexMetadata` indicates the format - version of the index segment. If the engine does not support this version, it should - skip this index segment. This allows index formats to evolve over time while - maintaining backwards compatibility. - -When an engine cannot use an index segment, it should fall back to scanning the -fragments that would have been covered by that segment. - -## Loading an index - -When loading an index: - -1. Get the offset to the index section from the `index_section` field in the [manifest](../index.md#manifest). -2. Read the index section from the manifest file. This is a protobuf message of type `IndexSection`, which - contains a list of `IndexMetadata` messages, each describing an index segment. -3. Read the index files from the `_indices/{UUID}` directory under the dataset directory, - where `{UUID}` is the UUID of the index segment. - -!!! tip "Optimizing manifest loading" - - When the manifest file is small, you can read and cache the index section eagerly. This avoids - an extra file read when loading indices. - -The `IndexMetadata` message contains important information about the index segment: - -* `uuid`: the unique identifier of the index segment. -* `fields`: the column(s) the index is built on. -* `fragment_bitmap`: the set of fragment IDs covered by this index segment. -* `index_details`: a protobuf `Any` message that contains index-specific details, such as index type, - parameters, and storage format. This allows different index types to store their own metadata. - - -
- Full protobuf definitions - -There are both part of the `table.proto` file in the Lance source code. - -```protobuf -%%% proto.message.IndexSection %%% - -%%% proto.message.IndexMetadata %%% -``` - -
- -## Handling deleted and invalidated rows - -Since index segments are immutable, they may contain references to rows that have been deleted -or updated. These should be filtered out during query execution. - -
- ![](./indices-fragment handling.drawio.svg) -
Representation of index segment covering fragments that have deleted rows, - completely deleted fragments, and updated fragments. -
-
- -There are three situations to consider: - -1. **A fragment has some deleted rows.** A few of the rows in the fragment have been marked - as deleted, but some of the rows are still present. The row addresses from the deletion - file should be used to filter out results from the index. -2. **A fragment has been completely deleted.** This can be detected by checking if a - fragment ID present in the fragment bitmap is missing from the dataset. - Any row addresses from this fragment should be filtered out. -3. **A fragment has had the indexed column updated in place.** This cannot be detected just - by examining metadata. To prevent reading invalid data, the engine should filter out any - row addresses that are not in the index's current `fragment_bitmap`. - -## Compaction and remapping - -When fragments are compacted, the row addresses of the rows in the fragments change. -This means that any index segments referencing those fragments will no longer point -to existing row addresses. There are three ways to handle this: - -
-![](./indices-compaction.drawio.svg) -
- -1. Do nothing and let the index segment not cover those fragments anymore. This approach is - simple and valid, but it means compaction can immediately make an index out-of-date. This - is the worst options for query performance. - -2. Immediately rewrite the index segments with the row addresses remapped. This approach - ensures the index is kept up-to-date, but it incurs significant write amplification - during compaction. - -3. Create a [Fragment Reuse Index](system/frag_reuse.md) that maps old row addresses to new - row addresses. This allows readers to remap the row addresses in memory upon reading - the index segments. This approach adds some IO and computation overhead during query - execution, but avoids write amplification during compaction. - -## Stable Row ID for Index - -Indices can optionally use stable row IDs instead of row addresses. A stable row ID is a -logical identifier that remains constant even when rows are moved during compaction. - -**Benefits:** - -- No remapping needed after compaction -- Updates only invalidate the index if the indexed column data changes - -**Tradeoffs:** - -- Requires an additional lookup to translate stable row IDs to physical row addresses - at query time - -This feature is currently experimental. Performance evaluation is ongoing to determine -when the tradeoff is worthwhile. diff --git a/docs/src/format/table/index/system/frag_reuse.md b/docs/src/format/table/index/system/frag_reuse.md deleted file mode 100644 index f0ba6acab2f..00000000000 --- a/docs/src/format/table/index/system/frag_reuse.md +++ /dev/null @@ -1,35 +0,0 @@ -# Fragment Reuse Index - -The Fragment Reuse Index is an internal index used to optimize fragment operations -during compaction and dataset updates. - -When data modifications happen against a Lance table, -it could trigger compaction and index optimization at the same time to improve data layout and index coverage. -By default, compaction will remap all indices at the same time to prevent read regression. -This means both compaction and index optimization could modify the same index and cause one process to fail. -Typically, the compaction would fail because it has to modify all indices and takes longer, -resulting in table layout degrading over time. - -Fragment Reuse Index allows a compaction to defer the index remap process. -Suppose a compaction removes fragments A and B and produces C. -At query runtime, it reuses the old fragments A and B by -updating the row addresses related to A and B in the index to the latest ones in C. -Because indices are typically cached in memory after initial load, -the in-memory index is up to date after the fragment reuse application process. - -## Index Details - -```protobuf -%%% proto.message.FragmentReuseIndexDetails %%% -``` - -## Expected Use Pattern - -Fragment Reuse Index should be created if the user defers index remap in compaction. -The index accumulates a new **reuse version** every time a compaction is executed. - -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 diff --git a/docs/src/format/table/index/system/mem_wal.md b/docs/src/format/table/index/system/mem_wal.md deleted file mode 100644 index 73693cd6704..00000000000 --- a/docs/src/format/table/index/system/mem_wal.md +++ /dev/null @@ -1,12 +0,0 @@ -# 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. - -A table has at most one MemWAL index. - -For the complete specification, see: - -- [MemWAL Index Overview](../../mem_wal.md#memwal-index) - Purpose and high-level description -- [MemWAL Index Details](../../mem_wal.md#memwal-index-details) - Storage format, schemas, and staleness handling -- [MemWAL Index Builder](../../mem_wal.md#memwal-index-builder) - Background process and configuration updates diff --git a/docs/src/format/table/layout.md b/docs/src/format/table/layout.md index 46efa56a908..15af3505714 100644 --- a/docs/src/format/table/layout.md +++ b/docs/src/format/table/layout.md @@ -20,7 +20,8 @@ A Lance dataset in its basic form stores all files within the dataset root direc data/ *.lance -- Data files containing column data _versions/ - *.manifest -- Manifest files (one per version) + *.manifest -- Manifest files (one per version) + latest_version_hint.json -- Optional hint of the latest version (see below) _transactions/ *.txn -- Transaction files for commit coordination _deletions/ @@ -34,6 +35,8 @@ A Lance dataset in its basic form stores all files within the dataset root direc *.json -- Tag metadata branches/ *.json -- Branch metadata + _bt/ + ... -- Fragment tree objects (see Fragment Metadata Tree) tree/ {branch_name}/ ... -- Branch dataset @@ -71,6 +74,8 @@ Each of these file types includes an optional `base_id` field in their metadata When a file's `base_id` is absent, the file is located relative to the dataset root. When a file's `base_id` is present, readers must look up the corresponding base path entry in the manifest's `base_paths` array to determine where the file is stored. +Fragment tree children and fragment files stored in the tree also carry optional `base_id`. Resolution is specified in [Fragment Metadata Tree](fragment_metadata.md#object-references). + At read time, path resolution follows a two-step process. First, the reader determines the base path: if `base_id` is absent, the base path is the dataset root; otherwise, the reader looks up the base path entry using the `base_id` to obtain the path and its `is_dataset_root` flag. Second, the reader constructs the full file path based on whether the base path represents a dataset root. @@ -201,3 +206,14 @@ Manifest files are stored in the `_versions/` directory with naming schemes that See [Manifest Naming Schemes](transaction.md#manifest-naming-schemes) for details on the V1 and V2 patterns and their implications for version discovery. +### Version Hint + +The optional file `_versions/latest_version_hint.json` records the latest committed version as JSON: + +```json +{"version": 42} +``` + +It exists to accelerate latest-version discovery on stores where listing `_versions/` is expensive: a reader can read the hint and probe higher versions with HEAD requests instead of listing the whole directory, falling back to a full listing if the hint is missing or stale. + +The hint is purely an optimization. It is always safe to delete, never affects correctness, and can be ignored by readers that don't understand it. Writers may choose not to write it. diff --git a/docs/src/format/table/mem_wal.md b/docs/src/format/table/mem_wal.md index 3d308a6c9be..ce40500ea0b 100644 --- a/docs/src/format/table/mem_wal.md +++ b/docs/src/format/table/mem_wal.md @@ -7,654 +7,717 @@ scan, point lookup, vector search and full-text search. ![MemWAL Overview](../../images/mem_wal_overview.png) -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. +A Lance table is called the **base table** in this document. +The base table may have an [unenforced primary key](index.md#unenforced-primary-key) in its schema. +Primary keys are required for primary-key lookups and last-write-wins upsert semantics. +Append-only MemWAL tables may omit a primary key. -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. +MemWAL adds a set of shards on top of the base table. +Writers append to shards. +Each shard keeps recent data in an in-memory MemTable, persists writes to a per-shard WAL, flushes MemTables as small Lance datasets, and later compacts those SSTables into the base table. -### MemWAL Region +The base table manifest contains one MemWAL system index entry named `__lance_mem_wal`. +This index stores MemWAL configuration and global progress metadata inline in `IndexMetadata.index_details`. +Each shard's own manifest remains authoritative for shard-local mutable state. -A **MemWAL Region** is the main unit to horizontally scale out writes. +### MemWAL Shard -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. +A **MemWAL shard** is the unit of horizontal write scaling. +Each shard has exactly one active writer epoch at a time. +Writers claim a shard, append WAL entries, update the in-memory MemTable, and publish SSTable generations by updating the shard manifest. -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: +For primary-key tables, all rows for the same primary key must map to the same shard. +If one primary key can appear in multiple shards, asynchronous compaction order between shards can make an older row overwrite a newer row. +Append-only tables without a primary key do not rely on last-write-wins conflict resolution and may use any deterministic shard assignment suitable for the workload. -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) +### MemWAL Index -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. +The MemWAL index is a system index entry on the base table. +It has `name = "__lance_mem_wal"`, no indexed fields, and no index files. +`IndexMetadata.files` is `None`. +All MemWAL index data is stored in the `MemWalIndexDetails` protobuf message in `IndexMetadata.index_details`. -See [MemWAL Region Architecture](#memwal-region-architecture) for the complete region architecture. +The index stores: -### MemWAL Index +- **Configuration**: `sharding_specs`, `maintained_indexes`, `maintain_all_indexes`, and `writer_config_defaults`. +- **Compaction progress**: `compacted_sstables`, the last SSTable compacted into the base table for each shard. +- **Index catchup progress**: `index_catchup`, the compacted SSTable generation covered by each base-table index. +- **Shard snapshots**: optional point-in-time snapshot fields for read optimization. -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: +Shard snapshots are not authoritative. +Readers that need the latest shard set list `_mem_wal/` and read each shard's latest manifest. -- **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 -- **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 Architecture -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. +![Shard Architecture](../../images/mem_wal_shard.png) -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. +Within a shard, writes first enter an in-memory **MemTable** and are durably appended to the shard **write-ahead log (WAL)**. +The MemTable is periodically **flushed** to storage as a Lance dataset. +SSTables are asynchronously **compacted** into the base table. -See [MemWAL Index Details](#memwal-index-details) for the complete structure. +### MemTable -## Region Architecture +A MemTable holds rows inserted into a shard before those rows are flushed to storage. +It serves two purposes: -![Region Architecture](../../images/mem_wal_regional.png) +1. It buffers data and per-MemTable indexes before an SSTable is written. +2. It lets readers access data that has not been flushed yet when strong consistency is required. -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. -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. +The storage format does not prescribe the in-memory MemTable layout. +Conceptually, a MemTable is an append log of Arrow record batches. +Later appends have larger in-memory row positions. +For primary-key tables, in-memory reads use the largest visible row position as the newest row for a key. -### MemTable +### SSTable Generation -A MemTable holds rows inserted into the region before flushing to storage. -It serves 2 purposes: +Within each shard, SSTables have monotonically increasing generation numbers starting from 1. +When a MemTable is flushed, the resulting SSTable is assigned the shard manifest's `current_generation`, and `current_generation` advances to the next SSTable generation. +A MemTable does not have a generation. -1. build up data and related indexes to be flushed to storage as a flushed MemTable -2. allow a reader to potentially access data that is not flushed to storage yet +SSTable generation numbers order persisted data freshness within one shard: -#### MemTable Format +- Base table data is modeled as generation 0. +- Higher SSTable generations are newer. +- The active MemTable is newer than every published SSTable. +- Within the active MemTable, higher row positions are newer. +- Within an SSTable, flush-time deletion vectors hide older duplicate primary-key rows, so readers see at most the newest row for each primary key. -The complete in-memory format of a MemTable is implementation-specific and out of the scope of this spec. -The Lance core Rust SDK maintains one default implementation and is available through all its language binding SDKs, -but integrations are free to build their own MemTable format depending on the specific use cases, -as long as it follows the MemWAL storage layout, reader and writer requirements when flushing MemTable. +## WAL -Conceptually, because Lance uses [Arrow as its in-memory data exchange format](https://arrow.apache.org/docs/format/index.html), -for the ease of explanation in this spec, we will treat MemTable as a list of Arrow record batches, -and each write into the MemTable is a new Arrow record batch. +The WAL is the durable append log for a shard. +Every durable WAL append creates one **WAL entry**. -#### MemTable Generation +### WAL Entry Positions -Based on conditions like memory limit and durability requirements, -a MemTable needs to be **flushed** to storage and discarded. -When that happens, new writes go to a new MemTable and the cycle repeats. -Each MemTable is assigned a monotonically increasing generation number starting from 1. -When MemTable of generation `N` is discarded, the next MemTable gets assigned generation `N+1`. +WAL entry positions are 1-based. +The first data entry is position 1. +Position 0 is reserved as the sentinel value meaning no WAL entry has been covered. -### WAL +Writers append WAL entries in increasing position order. +If entry `N` is not fully written, entry `N + 1` must not exist. +Recovery replays from `replay_after_wal_entry_position + 1`. -WAL serves as the durable storage of all MemTables in a region. -It consists of data in MemTables ordered by generation. -Every time we write to the WAL, we call it a **WAL Flush**. +### WAL Entry Format -#### WAL Durability +Each WAL entry is an Apache Arrow IPC stream file. +The Arrow schema metadata includes: -When a write is flushed to WAL, the specific write becomes durable. -Otherwise, if the MemTable is lost, data is also lost. +- `writer_epoch`: decimal string containing the writer epoch that created the entry. +- `generation`: decimal string containing the memtable generation the entry's batches belong to. A flush drains one memtable, so an entry never spans two generations. A reader rebuilds generation boundaries from this value. Absent in WAL-only mode, which holds no memtable. +- `fence_sentinel`: optional marker for a data-less fence sentinel entry. -Multiple writes can be batched together in a single WAL flush to reduce WAL flush frequency and improve throughput. -The more writes a single WAL flush batches, the longer it takes for a write to be durable. +A normal WAL entry contains one or more record batches. +A fence sentinel entry contains no batches and is skipped during replay. +Sentinels are used so an older writer collides on the next WAL position and discovers that it has been fenced. -The whole LSM tree's durability is determined by the durability of the WAL. -For example, if WAL is stored in Amazon S3, it has 99.999999999% durability. -If it is stored in local disk, the data will be lost if the local disk is damaged. +### WAL Storage Layout -#### WAL Entry +WAL entries live under `_mem_wal/{shard_id}/wal/`. +Filenames use bit-reversed 64-bit binary names with the `.arrow` suffix: -Each time a WAL flush happens, it adds a new **WAL Entry** to the WAL. -In other words, a WAL consists of an ordered list of WAL entries starting from position 0. -Writer must flush WAL entries in sequential order from lower to higher position. -If WAL entry `N` is not flushed fully, WAL entry `N+1` must not exist in storage. +```text +_mem_wal/{shard_id}/wal/{bit_reversed_position}.arrow +``` -### WAL Replay +The bit-reversal spreads sequential positions across object-store keyspace. +For example, position 5 is encoded as: -**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. +```text +1010000000000000000000000000000000000000000000000000000000000000.arrow +``` -See [Writer Fencing](#writer-fencing) for the full fencing mechanism. +## SSTable -#### WAL Entry Format +An SSTable is the immutable result of flushing a MemTable. +It is stored as a Lance dataset under its shard directory. -Each WAL entry is a file in storage following the [Apache Arrow IPC stream format](https://arrow.apache.org/docs/format/Columnar.html#ipc-streaming-format) to store the batch of writes in the MemTable. -The writer epoch is stored in the stream's Arrow schema metadata with key `writer_epoch` for fencing validation during replay. +!!! note + Unlike a classic LSM sorted string table, a MemWAL SSTable is not sorted by key; random access is instead served by its BTree primary-key sidecar. It is called an SSTable because it is an immutable, persisted, indexed run. -#### WAL Storage Layout +### SSTable Storage Layout -Each WAL entry is stored within the WAL directory of the region located at `_mem_wal/{region_id}/wal`. +An SSTable with generation `i` is written to: -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. -The filename is the bit-reversed binary representation of the entry ID with suffix `.lance`. -For example, entry ID 5 (binary `000...101`) becomes `1010000000000000000000000000000000000000000000000000000000000000.arrow`. +```text +_mem_wal/{shard_id}/{random8}_gen_{i}/ +``` -### Flushed MemTable +`{random8}` is an 8-character random hex value generated for each flush attempt. +If a flush attempt fails, a retry writes a different directory instead of reusing a partially written one. +The shard manifest records the successful directory name in the SSTable's `path`. + +### SSTable Accounting + +Alongside `generation` and `path`, a shard manifest entry may record what the +SSTable holds, as the writer's MemTable accounted for it at flush: + +- `in_memory_bytes`: payload size of the rows, summed over the MemTable's + buffers as the window each one holds. +- `physical_rows`: rows held, counting the older duplicates of a primary key + that the generation's deletion vector masks. A scan applying the deletion + vector yields fewer. +- `primary_key_bytes`: total payload size of the primary-key columns over every + row in `physical_rows`. Not a per-row size, which varies for a + variable-length key. + +All three are estimates of payload, not bounds on what reading the SSTable +costs: they exclude the per-array structure a reader materializes, and +`primary_key_bytes` is not the size of any encoded form of the key. A consumer +budgeting memory from them adds its own headroom. + +All three are optional. An entry written before they existed records none of +them, and a reader must not treat an absent value as zero; `primary_key_bytes` +is also absent on a table with no primary key. + +The SSTable directory is a standard Lance dataset written with the base table's data storage version. +Each SSTable is written as one fragment. +Additional MemWAL sidecars may be present: + +```text +{random8}_gen_{i}/ +├── _versions/ +│ └── {version}.manifest +├── _deletions/ # Present when within-SSTable dedup deletes rows +├── _indices/ # Present when maintained user indexes are built +│ └── {index_uuid}/ +├── _pk_index/ # Primary-key sidecar BTree, not a manifest index +└── bloom_filter.bin # Primary-key bloom filter +``` -A flushed MemTable is created by flushing the MemTable to storage. -In Lance MemWAL spec, a flushed MemTable must be a Lance table following the Lance table format spec. +The exact Lance dataset internals follow the [Lance table storage layout](layout.md). -!!!note - This is called Sorted String Table (SSTable) or Sorted Run in many LSM-tree literatures and implementations. - However, since our MemTable is not sorted, we just use the term flushed MemTable to avoid confusion. +### SSTable Row Order -#### Flushed MemTable Storage Layout +SSTable rows are written in forward insert order. +Physical row offsets increase with write time. +For a duplicate primary key within one SSTable, the newest row has the largest physical offset. -The MemTable of generation `i` is flushed to `_mem_wal/{region_uuid}/{random_hex}_gen_{i}/` directory, -where `{random_hex}` is a random 8-character hex value generated at flush time. -The random hex value is necessary to ensure if one MemTable flush attempt fails, -The retry can use another directory. -The content within the generation directory follows the [Lance table storage layout](layout.md). +Primary-key SSTables use a deletion vector to expose last-write-wins semantics. +During flush, the writer scans rows in forward order, keeps the last occurrence of each primary key, and marks all earlier duplicate offsets deleted. +The deletion vector is attached to fragment 0 in the SSTable's Lance manifest. -#### Merging MemTable to Base Table +Append-only SSTables without a primary key do not perform primary-key deduplication and retain every row. -Generation numbers determine merge order of flushed MemTable into base table: -lower numbers represent older data and must be merged to the base table first to preserve correct upsert semantics. +### Tombstone Rows -### Region Manifest +Delete operations are represented as rows with the internal `_tombstone` column. +Tombstone rows follow the same forward row ordering and deletion-vector rules as ordinary rows. +If the newest row for a primary key is a tombstone, the deletion vector keeps that tombstone row and hides older rows for the key. +Read planning then filters `_tombstone = false`, so the key is absent from query results. -Each region has a manifest file. This is the source of truth for the state of a region. +### SSTable Primary-Key Sidecars -#### Region Manifest Contents +Primary-key MemTables maintain an implicit BTree for primary-key deduplication, independent of `maintained_indexes`. +When a primary-key MemTable is flushed, the SSTable writes two primary-key sidecars: -The manifest contains: +- `bloom_filter.bin` stores the SSTable's primary-key bloom filter and lets point lookups skip SSTables that cannot contain the queried key. +- `_pk_index/` stores a standalone BTree over primary-key values to forward row ids. -- **Fencing state**: `writer_epoch` as the latest writer fencing token, see [Writer Fencing](#writer-fencing) for more details. -- **WAL pointers**: `replay_after_wal_entry_position` (last entry position flushed to MemTable, 0-based), `wal_entry_position_last_seen` (last entry position seen at manifest update, 0-based) -- **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`) +The `_pk_index/` sidecar is not a maintained user index, is not registered in the SSTable's Lance manifest, and has no manifest UUID. +Its identity is its immutable SSTable path. +Readers open it directly from `{sstable_path}/_pk_index`. -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. -The manifest itself is atomically written, but recovery must try to get newer WAL files to find the actual state beyond this hint. +The `_pk_index/` directory is a Lance scalar BTree index store: -The manifest is serialized as a protobuf binary file using the `RegionManifest` message. +```text +_pk_index/ +├── page_data.lance +└── page_lookup.lance +``` -
-RegionManifest protobuf message +Readers load this directory as a BTree index using `BTreeIndexDetails` with default parameters. +The primary-key index type is the Arrow type of the primary-key column for a single-column primary key, or `Binary` for a composite primary key. -```protobuf -%%% mem_wal.message.RegionManifest %%% -``` +The `page_lookup.lance` file has the following schema: -
+| Column | Type | Nullable | Description | +|--------------|-------------------------|----------|------------------------------------------------| +| `min` | {PrimaryKeyIndexType} | true | Minimum primary-key index value in the page | +| `max` | {PrimaryKeyIndexType} | true | Maximum primary-key index value in the page | +| `null_count` | UInt32 | false | Number of null values in the page | +| `page_idx` | UInt32 | false | Page number pointing into `page_data.lance` | -#### Region Manifest Versioning +The `page_data.lance` file has the following schema: -Manifests are versioned starting from 1 and immutable. -Each update creates a new manifest file at the next version number. -Updates use put-if-not-exists or file rename to ensure atomicity depending on the storage system. -If two processes compete, one wins and the other retries. +| Column | Type | Nullable | Description | +|----------|-----------------------|----------|-------------------------------------------------------------------| +| `values` | {PrimaryKeyIndexType} | true | Sorted primary-key index values | +| `ids` | UInt64 | false | Forward row ids corresponding to each primary-key index value | -To commit a manifest version: +For a single-column primary key, the indexed value stores the primary-key scalar directly. +For a composite primary key, the indexed value stores an order-preserving binary tuple encoding of all primary-key columns in primary-key column order. +Each tuple column is encoded as: -1. Compute the next version number -2. Write the manifest to `{bit_reversed_version}.binpb` using put-if-not-exists -3. In parallel best-effort write to `version_hint.json` with `{"version": }` (failure is acceptable) +- `0x00` for null. +- `0x01` followed by the non-null value encoding otherwise. -To read the latest manifest version: +Supported non-null value encodings are: -1. Read `version_hint.json` to get the latest version hint. If not found, start from version 1 -2. Check existence for subsequent versions from the starting version -3. Continue until a version is not found -4. The latest version is the last found version +- Signed integers and date values: sign-flipped 8-byte big-endian integer bytes. +- Unsigned integers: 8-byte big-endian unsigned integer bytes. +- Boolean: one byte, `0x00` for false and `0x01` for true. +- UTF-8 and binary values: raw bytes, with each `0x00` byte escaped as `0x00 0xff`, followed by a `0x00 0x00` terminator. -!!!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 encoding is injective and preserves primary-key tuple ordering under lexicographic byte comparison. +Composite primary-key columns must use one of the supported encodings above. -#### Region Manifest Storage Layout +The sidecar row ids are in the same forward row-position space as the data files, deletion vector, and maintained user indexes. +The sidecar is used for cross-generation membership and block-list checks. +It is not used to choose the newest row inside the same SSTable; the deletion vector has already hidden older same-generation duplicates. -All region manifest versions are stored in `_mem_wal/{region_id}/manifest` directory. +### Maintained User Indexes -Each region manifest version file uses bit-reversed 64-bit binary naming, the same scheme as WAL files. -For example, version 5 becomes `1010000000000000000000000000000000000000000000000000000000000000.binpb`. +When the MemWAL index names indexes to maintain -- in `maintained_indexes`, or every index the table has when `maintain_all_indexes` is set -- flush may build matching indexes inside the SSTable. +These index files live in the SSTable's `_indices/{index_uuid}/` directory and are recorded in the SSTable's Lance manifest. +The implicit primary-key BTree sidecar is not included in `maintained_indexes` and does not live under `_indices/`. -## MemWAL Index Details +These indexes use the same row-position space as the forward-written data files. +If the SSTable has a primary key, its deletion vector masks stale duplicate rows for indexed reads as well. + +### SSTable Compaction -The MemWAL Index uses the [standard index storage](index/index.md#index-storage) at `_indices/{UUID}/`. +SSTables are compacted into the base table in ascending generation order within each shard. +Lower generation numbers are older and must be compacted before higher generation numbers. +SSTable compaction uses merge-insert semantics so newer rows overwrite older rows for the same primary key. -The index stores its data in two parts: +## Shard Manifest -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 +Each shard has a versioned manifest. +The latest shard manifest is the source of truth for shard-local state. -### Index Details +### Shard Manifest Contents + +The manifest contains: -The `index_details` field in `IndexMetadata` contains a `MemWalIndexDetails` protobuf message with the following key fields: +- **Identity**: `shard_id`, `shard_spec_id`, and `shard_field_entries`. +- **Fencing state**: `writer_epoch`. +- **WAL pointers**: `replay_after_wal_entry_position` and `wal_entry_position_last_seen`. +- **SSTable generation state**: `current_generation` and `sstables`. +- **Lifecycle state**: `status`, either `ACTIVE` or `SEALED`. -- **Configuration fields** (`region_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. -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. -- **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_field_entries` stores computed shard field values as raw Arrow scalar bytes keyed by `ShardingField.field_id`. +The matching `ShardingField.result_type` determines how to decode each value. +For example, `int32` values are four little-endian bytes and `utf8` values are raw UTF-8 bytes. + +`replay_after_wal_entry_position` is the most recent 1-based WAL position covered by an SSTable. +The default value 0 means no WAL entry has been covered and recovery starts at position 1. + +`wal_entry_position_last_seen` is a best-effort hint for the most recent WAL position observed at manifest update time. +It is not authoritative because it is not updated on every WAL write. +Recovery must still probe or list WAL files to find the actual tail. + +`current_generation` is the generation number to assign to the next SSTable created by flushing the MemTable. +Each entry in `sstables` records a published SSTable's `generation` and `path`. + +`status = SEALED` marks a reversible in-flight drop-table operation. +Sealed shards refuse new writer claims. + +The manifest is serialized as the `ShardManifest` protobuf message.
-MemWalIndexDetails protobuf message +ShardManifest protobuf message ```protobuf -%%% mem_wal.message.MemWalIndexDetails %%% +%%% mem_wal.message.ShardManifest %%% ```
-### Region Identifier +### Shard Manifest Versioning -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. +Manifest versions start at 1. +Each update writes a new immutable protobuf file: -### Region Spec +```text +_mem_wal/{shard_id}/manifest/{bit_reversed_version}.binpb +``` -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. +Writers use put-if-not-exists or atomic rename, depending on storage support. +If two processes race to write the same next version, one wins and the other reloads and retries. -Each region spec has: +After a successful version write, the writer best-effort updates: -- **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. +```json +{"version": } +``` -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. +in: -A region spec's field array consists of **region field** definitions. -Each region field has the following properties: +```text +_mem_wal/{shard_id}/manifest/version_hint.json +``` + +Readers use `version_hint.json` as a starting point and then probe subsequent versions until a version is missing. +The latest manifest is the last existing version. + +## MemWAL Index Details -| Property | Description | -|----------|-------------| -| `field_id` | Unique string identifier for this region field | -| `source_ids` | Array of field IDs referencing source columns in the schema | -| `transform` | A well-known region 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 | +The MemWAL index is stored inline in the base table's `IndexMetadata`. +It is a system index with no file directory. +The `index_details` field contains a `MemWalIndexDetails` protobuf message. -#### Region Expression +Important fields: -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). -Source columns are referenced as `col0`, `col1`, etc., corresponding to the order of field IDs in `source_ids`. +- `sharding_specs`: sharding configuration used by writers and shard pruning. +- `maintained_indexes`: names of base-table indexes to maintain in MemTables and SSTables. Ignored when `maintain_all_indexes` is set. +- `maintain_all_indexes`: maintain every index the table has rather than the names above. The set is resolved from the table's indexes each time a MemTable is built, so an index created after the MemWAL was installed is picked up and a dropped one falls out. An index the writer cannot mirror is skipped, so introducing one does not make the table unwritable; a name in `maintained_indexes` that it cannot mirror is an error instead, because the caller asked for that index. The field carries presence: absent and empty `maintained_indexes` mean maintain nothing, while set and empty means maintain everything. +- `writer_config_defaults`: string map of default writer configuration values persisted for all writers. +- `compacted_sstables`: per-shard compaction progress, updated atomically with base-table compaction commits. +- `index_catchup`: per-index coverage progress after data has been compacted into the base table. +- `snapshot_ts_millis`, `num_shards`, and `inline_snapshots`: optional shard snapshot fields for read optimization. -Region expressions must satisfy the following requirements: +A shard absent from `index_catchup` for an index means that index is *not* known +to have caught up, so the shard's SSTables must be retained until some commit +records that it has. -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). -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. +Catch-up is derived at commit time, not reported by the writer. An index whose segments together span every fragment live at the transaction's read version holds every row compaction had copied into the base table by then, so the commit records it as caught up to that version's `compacted_sstables`. That is the only proof available — nothing maps a compaction generation to the fragments its rows landed in — so covering the table as the transaction read it is how an index shows it covered those rows. Fragments appended since that read are a later catch-up gap and are not required. -#### Region Transform +Two rules bound what a commit may record. It never credits more than its own `compacted_sstables`, and it clamps to that value, so a position can only describe generations the base table has actually taken in. Otherwise it never lowers a position an index already held, provided that index is unchanged by this commit. "Unchanged" compares each segment's UUID together with its fragment bitmap, not the UUID alone, because an operation can prune the bitmap in place while keeping the UUID; the remaining metadata does not affect which rows the index answers for. An index this commit changes keeps no position it cannot re-earn. -A **Region Transform** is a well-known region expression with a predefined name. -When a transform is specified, the expression is derived automatically. +Because the position is derived rather than transmitted, it cannot go stale between inspection and commit, and it survives rebase — `read_version` is fixed for a transaction's life, so what a commit can prove does not move, though a rebased attempt may record a different result because the head it commits against has changed. Any commit can earn a position, so an ordinary index build that happens to cover the table records catch-up as a side effect. A dedicated repair is still needed where no such commit occurs, or where an index does not yet span the table. -| Transform | Parameters | Region Expression | Result Type | -|-----------|------------|-------------------|-------------| -| `identity` | (none) | `col0` | same as source | -| `year` | (none) | `date_part('year', col0)` | `int32` | -| `month` | (none) | `date_part('month', col0)` | `int32` | -| `day` | (none) | `date_part('day', col0)` | `int32` | -| `hour` | (none) | `date_part('hour', col0)` | `int32` | -| `bucket` | `num_buckets` | `abs(murmur3(col0)) % N` | `int32` | -| `multi_bucket` | `num_buckets` | `abs(murmur3_multi(col0, col1, ...)) % N` | `int32` | -| `truncate` | `width` | `left(col0, W)` (string) or `col0 - (col0 % W)` (numeric) | same as source | +A read version with no fragments proves nothing, even though an index trivially covers an empty table. An empty fragment list is also what a manifest written before the `UpdateMemWalState` fragment fix looks like, where the SSTables are the last copy of those rows; crediting coverage there would retire them. The cost is that a table whose rows have all been deleted keeps its SSTables. -The `bucket` and `multi_bucket` transforms use Murmur3 hash functions: +Shard snapshots, when present, use the following Lance file schema: -- **`murmur3(col)`**: Computes the 32-bit Murmur3 hash (x86 variant, seed 0) of a single column. Returns a signed 32-bit integer. Returns NULL if input is NULL. -- **`murmur3_multi(col0, col1, ...)`**: Computes the Murmur3 hash across multiple columns. Returns a signed 32-bit integer. NULL fields are ignored during hashing; returns NULL only if all inputs are NULL. +| Column | Type | Nullable | Description | +|----------------------------|------------------------------|----------|--------------------------------------------------------| +| `shard_id` | Utf8 | false | Shard UUID string | +| `shard_spec_id` | UInt32 | false | Sharding spec that produced the shard | +| `shard_field_{field_id}` | `ShardingField.result_type` | false | Computed shard field value for the given sharding field | + +The MemWAL index data is stored inline. +Readers discover the latest shard set by listing `_mem_wal/` shard directories and reading shard manifests. + +
+MemWalIndexDetails protobuf message -The hash result is wrapped with `abs()` and modulo `N` to produce a non-negative bucket number in the range `[0, N)`. +```protobuf +%%% mem_wal.message.MemWalIndexDetails %%% +``` -### Region Snapshot Storage +
-Region snapshots are stored using one of two strategies based on the number of regions: +## Sharding -| Region Count | Storage Strategy | Location | -|--------------|------------------|----------| -| <= 100 (threshold) | Inline | `inline_snapshots` field in index details | -| > 100 | External Lance file | `_indices/{UUID}/index.lance` | +A **ShardingSpec** defines how rows map to shards. +Each spec has a positive `spec_id` and one or more `ShardingField` entries. +Each shard manifest records the `shard_spec_id` and the computed shard field values for that shard. +`spec_id = 0` means the shard was manually created and is not governed by a sharding spec. -The threshold (100 regions) is implementation-defined and may vary. +Each `ShardingField` contains: -**Inline storage**: For small region 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. +- `field_id`: stable identifier for the computed shard field. +- `source_ids`: field IDs of source columns in the Lance schema. +- `transform`: well-known transform name, when using built-in transform evaluation. +- `expression`: reserved custom expression text, mutually exclusive with `transform`. +- `result_type`: Arrow type name for the computed value. +- `parameters`: transform-specific string parameters. -**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. +The supported built-in transforms are: -### Region Snapshot Arrow Schema +- `unsharded`: takes no source columns, always returns `int32` value 0, and creates one shard. +- `bucket`: takes one source column and `num_buckets`, hashes the value, and returns an `int32` bucket id in `[0, num_buckets)`. +- `identity`: takes one source column and returns the raw scalar value as the shard value. -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: +`bucket` computes a deterministic 32-bit hash with seed 0 and then computes: -| 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) | -| `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) | +```text +(hash & i32::MAX) % num_buckets +``` -For example, with a region spec containing a field `user_bucket` of type `int32`: +`num_buckets` must be in `[1, 1024]`. +Null bucket values hash to 0 and therefore map to bucket 0. +See [Appendix 3: Bucket Hashing](#appendix-3-bucket-hashing) for the exact hash algorithm and test vectors. -| Column | Type | Description | -|--------|------|-------------| -| ... | ... | (base columns above) | -| `region_field_user_bucket` | `int32` | Bucket value for this region | +The `bucket` transform supports scalar boolean, integer, floating-point, date32, time, timestamp, utf8, and large_utf8 source types. +The `identity` transform supports scalar boolean, integer, utf8, and large_utf8 source types. -This schema directly corresponds to the fields in the `RegionManifest` protobuf message plus the computed region field values. +The `year`, `month`, `day`, `hour`, `multi_bucket`, and `truncate` transform names are not supported MemWAL sharding transforms and must not be used in `ShardingSpec.transform`. ## Storage Layout -Here is a recap of the storage layout with all the files and concepts defined so far: +The MemWAL storage layout is: -``` +```text {table_path}/ +├── _versions/ +│ └── ... # Base table manifests, including __lance_mem_wal index metadata ├── _indices/ -│ └── {index_uuid}/ # MemWAL Index (uses standard index storage) -│ └── index.lance # Serialized region snapshots (Lance file) -│ +│ └── ... # Ordinary base table index files; MemWAL index has no files └── _mem_wal/ - └── {region_uuid}/ # Region directory (UUID v4) + └── {shard_id}/ ├── manifest/ - │ ├── {bit_reversed_version}.binpb # Serialized region manifest (bit-reversed naming) - │ └── version_hint.json # Version hint file + │ ├── {bit_reversed_version}.binpb + │ └── version_hint.json ├── wal/ - │ ├── {bit_reversed_entry_id}.lance # WAL data files (bit-reversed naming) + │ ├── {bit_reversed_position}.arrow │ └── ... - └── {random_hash}_gen_{i}/ # Flushed MemTable (generation i, random prefix) + └── {random8}_gen_{generation}/ ├── _versions/ - │ └── {version}.manifest # Table manifest (V2 naming scheme) - ├── _indices/ # Indexes - │ ├── {vector_index}/ - │ └── {scalar_index}/ - └── bloom_filter.bin # Primary key bloom filter + │ └── {version}.manifest + ├── _deletions/ + ├── _indices/ + │ └── {index_uuid}/ + ├── _pk_index/ + └── bloom_filter.bin ``` -## Implementation Expectation - -This specification describes the storage layout for the LSM tree architecture. Implementations are free to use any approach to fulfill the storage layout requirements. Once data is written to the expected storage layout, the reader and writer expectations apply. - -The specification defines: +Some SSTable subdirectories are conditional. +For example, `_deletions/` is present only when the SSTable's Lance manifest references a deletion vector, `_indices/` is present only when maintained user indexes are built, and `_pk_index/` plus `bloom_filter.bin` are meaningful for primary-key tables. -- **Storage layout**: The directory structure, file formats, and naming conventions for WAL entries, flushed MemTables, region 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 +## Implementation Expectation -Implementations may choose different approaches for: +This document specifies the storage layout and observable reader and writer invariants. +Implementations may choose different in-memory structures, buffering policies, background scheduling, and query execution plans. -- In-memory data structures and indexing -- Buffering strategies before WAL flush -- Background task scheduling and concurrency -- Query execution strategies +An implementation is compatible when it: -As long as the storage layout is correct and the documented invariants are maintained, implementations can optimize for their specific use cases. +1. Writes WAL entries, shard manifests, SSTables, and MemWAL index metadata using the documented layout. +2. Preserves WAL position, writer fencing, and manifest versioning invariants. +3. Exposes last-write-wins semantics for primary-key tables. +4. Preserves append-only semantics for tables without primary keys. +5. Maintains generation ordering when compacting SSTables into the base table. ## Writer Expectations -A writer operates on a single region and is responsible for: +A writer operates on one shard and is responsible for: -1. Claiming the region 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 +1. Claiming the shard with epoch-based fencing. +2. Appending WAL entries in sequential 1-based positions. +3. Maintaining in-memory MemTable state. +4. Flushing MemTables to SSTable Lance datasets. +5. Updating the shard manifest after an SSTable is durably written. ### Writer Fencing -Writers use epoch-based fencing to ensure single-writer semantics per region. +Writers use `writer_epoch` to enforce single-writer semantics per shard. -To claim a region: +To claim a shard: -1. Load the latest region 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 +1. Load the latest shard manifest. +2. Verify the shard is `ACTIVE`. +3. Increment `writer_epoch`. +4. Atomically write a new manifest version. +5. If the manifest write loses a race, reload and retry. -Before any manifest update, a writer must verify its `writer_epoch` remains valid: +Before a manifest update, a writer verifies its local epoch is still current: -- If `local_writer_epoch == stored_writer_epoch`: The writer is still active and may proceed -- If `local_writer_epoch < stored_writer_epoch`: The writer has been fenced and must abort +- If `local_writer_epoch == stored_writer_epoch`, the writer may proceed. +- If `local_writer_epoch < stored_writer_epoch`, the writer has been fenced and must abort. -For a concrete example, see [Appendix 1: Writer Fencing Example](#appendix-1-writer-fencing-example). +WAL append conflicts also detect fencing. +If an older writer collides with a newer writer's WAL entry at the same position, it reloads the manifest and observes the higher epoch. +Fence sentinel entries make this collision path explicit without storing data batches. ## Background Job Expectations -Background jobs handle merging flushed MemTables to the base table and garbage collection. - -### MemTable Merger +Background jobs compact SSTables into the base table and remove obsolete shard data. -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. +### SSTable Compactor -The merge uses Lance's merge-insert operation with atomic transaction semantics: +SSTables must be compacted into the base table in ascending generation order within each shard. +The compaction uses Lance merge-insert semantics and updates `compacted_sstables[shard_id]` atomically with the base-table commit. -- `merged_generations[region_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 +On commit conflict, a compactor reloads the conflicting base-table version: -For a concrete example, see [Appendix 2: Concurrent Merger Example](#appendix-2-concurrent-merger-example). +- If the committed `compacted_sstables[shard_id]` is already greater than or equal to the generation being compacted, the compactor skips that generation. +- Otherwise, the compactor retries from the latest base-table version. ### 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 may remove obsolete SSTables after: + +1. The SSTable has been compacted into the base table. +2. Every index a query may rely on has caught up to cover the SSTable's generation, or the SSTable is no longer needed for indexed reads. An index absent from `index_catchup` has *not* caught up, so this condition is not met for it. +3. No retained base-table version needs the SSTable for time travel or consistency. -1. The generation has been merged to the base table (`generation <= merged_generations[region_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 +!!! warning + Deleting WAL files can weaken writer fencing. + + Fencing detects a stalled writer when its put-if-not-exists for the next WAL entry collides with a newer writer's entry at the same position. + If garbage collection has removed that WAL file, the stalled writer may write into empty space with an old `writer_epoch`. + Implementations that garbage collect WAL files must compensate by re-checking fence state after WAL writes, partitioning WAL positions by epoch, or otherwise preventing stale writers from landing at positions that have been garbage collected. ## Reader Expectations ### LSM Tree Merging Read -Readers **MUST** merge results from multiple data sources (base table, flushed MemTables, in-memory MemTables) by primary key to ensure correctness. +For primary-key tables, readers merge rows from the base table, SSTables, and optionally in-memory MemTables by primary key. +The newest row wins. -When the same primary key exists in multiple sources, the reader must keep only the newest version based on: +Freshness ordering within one shard is: -1. **Generation number** (`_gen`): Higher generation wins. The base table has generation -1, MemTables have positive integers starting from 1. -2. **Row address** (`_rowaddr`): Within the same generation, higher row address wins (later writes within a batch overwrite earlier ones). +1. The active MemTable wins over every SSTable and the base table. +2. Among SSTables, higher generation wins. +3. Any uncompacted SSTable wins over the base table. +4. Within the active MemTable, higher row position wins. +5. Within an SSTable, its deletion vector has already hidden older duplicate primary-key rows. -The ordering for "newest" is: highest `_gen` first, then highest `_rowaddr`. +For freshness comparisons, the base table uses the sentinel generation 0. +SSTable generations are positive. +This ordering applies only to sources selected for the same read plan. +Readers must not include an SSTable that is already covered by the base table according to `compacted_sstables[shard_id]`, because otherwise the positive SSTable generation would incorrectly outrank base-table rows during deduplication. +Rows from different shards do not need primary-key deduplication if the sharding spec guarantees that each primary key maps to exactly one shard. -This deduplication is essential because: +Append-only tables without a primary key do not perform primary-key deduplication. +Rows from all selected sources are distinct appended rows. -- A row updated in a MemTable also exists (with older data) in the base table -- A flushed MemTable that has been merged to the base table may not yet be garbage collected, causing the same row to appear in both -- A single write batch may contain multiple updates to the same primary key +### Tombstones -Without proper merging, queries would return duplicate or stale rows. +Readers must treat `_tombstone = true` rows as delete markers. +In SSTables, deletion vectors first resolve same-generation duplicate primary keys. +Then query planning filters tombstone rows from user-visible results. +In active in-memory MemTables, the newest visible row position for a primary key wins; if that row is a tombstone, the key is absent. ### Reader Consistency -Reader consistency depends on two factors: +Reader consistency depends on: -1. access to in-memory MemTables -2. the source of region metadata (either through MemWAL index or region manifests) +1. Whether the reader can access active in-memory MemTables. +2. Whether shard metadata comes from latest shard manifests or from an older MemWAL index snapshot. -Strong consistency requires access to in-memory MemTables for all regions involved in the query and reading region manifests directly. -Otherwise, the query is eventually consistent due to missing unflushed data or stale MemWAL Index snapshots. +Strong consistency requires active in-memory MemTable access for relevant shards and direct reads of latest shard manifests. +Otherwise, reads are eventually consistent because unflushed data or newly-created shards may be absent from the read plan. -!!!note - Reading a stale MemWAL Index does not impact correctness, only freshness: - - - **Merged MemTable still in index**: If a flushed MemTable has been merged to the base table but still shows in the MemWAL index, readers query both. This results in some inefficiency for querying the same data twice, but [LSM-tree merging](#lsm-tree-merging-read) ensures correct results since both contain the same data. The inefficiency is also compensated by the fact that the data is covered by index and we rarely end up scanning both data. - - **Garbage collected MemTable still in index**: If a flushed MemTable has been garbage collected, but is still in the MemWAL index, readers would fail to open it and skip it. This is also safe because if it is garbage collected, the data must already exist in the base table. - - **Newly flushed MemTable not in index**: If a newly flushed MemTable is added after the snapshot was built, it is not queried. The result is eventually consistent but correct for the snapshot's point in time. +Reading a stale MemWAL index snapshot does not corrupt last-write-wins ordering, but it can reduce freshness: -### Query Planning +- If a compacted SSTable is still listed, readers must skip it when `generation <= compacted_sstables[shard_id]`. + For primary-key tables, including it would let an older SSTable row outrank newer base-table contents because SSTable generations are positive and the base table is modeled as generation 0. + For append-only tables, including it would return the same append twice. +- If a garbage-collected SSTable is still listed, readers may skip it after failing to open it because its data must already be in the base table or be filtered out by `compacted_sstables`. +- If a new SSTable is not listed, the read is consistent with the older snapshot but may miss fresher data. -#### MemTable Collection +Readers that require latest shard membership should list `_mem_wal/` and read shard manifests instead of relying only on snapshots. -The query planner collects datasets from multiple sources and assembles them for unified query execution. -Datasets come from: - -1. base table (representing already-merged data) -2. flushed MemTables (persisted but not yet merged) -3. optionally in-memory MemTables (if accessible). - -Each dataset is tagged with a generation number: -1 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. - -The planner also collects bloom filters from each generation for staleness detection during search queries. +### Query Planning -#### Region Pruning +A query planner collects sources from: -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. +1. The base table. +2. SSTables that are not yet safely replaceable by base-table indexed reads. +3. Active in-memory MemTables, when available and required by the requested consistency level. -For each filter predicate: +Each source is tagged with its shard and freshness tier. +SSTable sources are also tagged with their generation. +For primary-key reads, the planner applies LSM deduplication across selected sources. +For append-only reads, the planner concatenates selected sources without primary-key deduplication. -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 +Bloom filters and `_pk_index/` sidecars help prune SSTables during point lookups and cross-generation deduplication. -For example, with a region spec using `bucket(user_id, 10)` and a filter `user_id = 123`: +### Shard Pruning -1. Compute `bucket(123, 10) = 3` -2. Only scan regions with bucket value 3 -3. Skip all other regions +When sharding specs are available, the planner evaluates query predicates against shard fields and skips shards whose computed shard values cannot match. -Region pruning applies to both scan queries and prefilters in search queries. +For example, with `bucket(user_id, 10)` and predicate `user_id = 123`: -#### Indexed Read Plan +1. Compute the bucket id for `123`. +2. Scan only shards whose manifest has the same computed bucket value. +3. Skip all other bucket shards. -When data is merged from a flushed MemTable to the base table, the base table's indexes are rebuilt asynchronously by the [base table index builders](#base-table-index-builder). -During this window, the merged data exists in the base table but is not yet covered by the base table's indexes. +### Indexed Read Plan -Without special handling, indexed queries would fall back to expensive full scans for the unindexed part of the base table. -To maintain indexed read performance, the query planner should use `index_catchup` progress to determine the optimal data source for each query. +When data is compacted from an SSTable into the base table, base-table indexes may lag behind the data commit. +`index_catchup` records which compacted generation each base-table index covers. -The key insight is that flushed MemTables serve as a bridge between the base table's index catchup and the current merged state. -For a query that requires a specific index for acceleration, when `index_gen < merged_gen`, -the generations in the gap `(index_gen, merged_gen]` have data already merged in the base table but are not covered by the base table's index. -Since flushed MemTables contain pre-built indexes (created during [MemTable flush](#memtable-flush)), queries can use these indexes instead of scanning unindexed data in the base table. -This ensures all reads remain indexed regardless of how far behind the async index builder is. +If an indexed query needs index `I` and `I` has only caught up to generation `G` while `compacted_sstables[shard_id]` is higher, the planner should read the gap from SSTable indexes instead of scanning unindexed base-table rows. +Once index `I` catches up, the planner can use the base-table index for those compacted rows. ## Appendices ### Appendix 1: Writer Fencing Example -This example demonstrates how epoch-based fencing prevents data corruption when two writers compete for the same region. +Initial shard manifest: -#### Initial State - -``` -Region manifest (version 1): - writer_epoch: 5 - replay_after_wal_entry_position: 10 - wal_entry_position_last_seen: 12 +```text +version: 1 +writer_epoch: 5 +replay_after_wal_entry_position: 10 +wal_entry_position_last_seen: 12 +status: ACTIVE ``` -#### Scenario - -| Step | Writer A | Writer B | Manifest State | -|------|----------|----------|----------------| -| 1 | Loads manifest, sees epoch=5 | | epoch=5, version=1 | -| 2 | Increments to epoch=6, writes manifest v2 | | epoch=6, version=2 | -| 3 | Starts writing WAL entries 13, 14, 15 | | | -| 4 | | Loads manifest v2, sees epoch=6 | epoch=6, version=2 | -| 5 | | Increments to epoch=7, writes manifest v3 | epoch=7, version=3 | -| 6 | | Starts writing WAL entries 16, 17 | | -| 7 | Tries to flush MemTable, loads manifest | | | -| 8 | Sees epoch=7, but local epoch=6 | | | -| 9 | **Writer A is fenced!** Aborts all operations | | | -| 10 | | Continues writing normally | epoch=7, version=3 | - -#### What Happens to Writer A's WAL Entries? - -Writer A wrote WAL entries 13, 14, 15 with `writer_epoch=6` in their schema metadata. - -When Writer B performs crash recovery or MemTable flush: - -1. Reads WAL entries sequentially starting from `replay_after_wal_entry_position + 1` (entry 11, since positions are 0-based) -2. For each entry, checks existence using HEAD request on the bit-reversed filename -3. Continues until an entry is not found (e.g., entry 18 doesn't exist) -4. Finds entries 13, 14, 15, 16, 17 -5. Reads each file's `writer_epoch` from schema metadata -6. Entries 13, 14, 15 have `writer_epoch=6` which is <= current epoch (7) -> **valid, will be replayed** -7. Entries 16, 17 have `writer_epoch=7` -> **valid, will be replayed** - -#### Key Points - -1. **No data loss**: Writer A's entries are not discarded. They were written with a valid epoch at the time and will be included in recovery. +Writer A loads version 1, claims epoch 6, and writes manifest version 2. +It appends WAL entries 13, 14, and 15 with `writer_epoch = 6`. -2. **Consistency preserved**: Writer A is prevented from making further writes that could conflict with Writer B. +Writer B then loads version 2, claims epoch 7, and writes manifest version 3. +It appends WAL entries 16 and 17 with `writer_epoch = 7`. -3. **Orphaned files are safe**: WAL files from fenced writers remain on storage and are replayed by the new writer. They are only garbage collected after being included in a flushed MemTable that has been merged. +When Writer A later tries to flush or update the shard manifest, it reloads the manifest and sees stored epoch 7 while its local epoch is 6. +Writer A is fenced and must abort. -4. **Epoch validation timing**: Writers check their epoch before manifest updates (MemTable flush), not on every WAL write. This keeps the hot path fast while ensuring consistency at commit boundaries. +Recovery starts from `replay_after_wal_entry_position + 1`, which is entry 11. +Entries 13, 14, 15, 16, and 17 are valid replay inputs because they were written by epochs that were valid at write time and are not greater than the current shard epoch. -### Appendix 2: Concurrent Merger Example +### Appendix 2: Concurrent Compactor Example -This example demonstrates how MemWAL Index and conflict resolution handle concurrent mergers safely. +Initial state: -#### Initial State +```text +MemWAL index: + compacted_sstables: {shard: 5} -``` -MemWAL Index: - merged_generations: {region: 5} - -Region manifest (version 1): +Shard manifest: current_generation: 8 - flushed_generations: [(6, "abc123_gen_6"), (7, "def456_gen_7")] + sstables: + - generation: 6, path: "abc12345_gen_6" + - generation: 7, path: "def67890_gen_7" ``` -#### Scenario 1: Racing on the Same Generation - -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 | | | -| 3 | Starts merging gen 6 | | | -| 4 | | Reads index: merged_gen=5 | merged_gen=5 | -| 5 | | Reads region manifest | | -| 6 | | Starts merging gen 6 | | -| 7 | Commits (merged_gen=6) | | **merged_gen=6** | -| 8 | | Tries to commit | | -| 9 | | **Conflict**: reads new index | | -| 10 | | Sees merged_gen=6 >= 6, aborts | | -| 11 | | Reloads, continues to gen 7 | | +Two compactors both try to compact the SSTable at generation 6. +Compactor A commits first and updates `compacted_sstables[shard]` to 6 in the same base-table commit as the data. +Compactor B then hits a commit conflict, reloads the latest MemWAL index, sees `compacted_sstables[shard] >= 6`, skips generation 6, and continues with generation 7. -Merger B's conflict resolution detected that generation 6 was already merged by checking the MemWAL Index in the conflicting commit. +The MemWAL index is the authoritative compaction-progress record because it is committed atomically with the base-table data changes. -#### Scenario 2: Crash After Table Commit +### Appendix 3: Bucket Hashing -Merger A crashes after committing to the table. +The bucket transform hash uses 32-bit wrapping arithmetic with these mixing functions. +Right shifts in `fmix` are logical shifts of the `u32` bit pattern. -| Step | Merger A | Merger B | MemWAL Index | -|------|----------|----------|--------------| -| 1 | Reads index: merged_gen=5 | | merged_gen=5 | -| 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 | | -| 6 | | **Skips gen 6** (already merged) | | -| 7 | | Merges gen 7, commits | **merged_gen=7** | - -The MemWAL Index is the single source of truth. Merger B correctly used it to determine that generation 6 was already merged. - -#### Key Points +```text +mix_k1(k) = rotl32(k * 0xcc9e2d51, 15) * 0x1b873593 +mix_h1(h, k) = rotl32(h ^ k, 13) * 5 + 0xe6546b64 +fmix(h, len) = + h = h ^ len + h = (h ^ (h >> 16)) * 0x85ebca6b + h = (h ^ (h >> 13)) * 0xc2b2ae35 + h ^ (h >> 16) +``` -1. **Single source of truth**: `merged_generations` is the authoritative source for merge progress, updated atomically with data. +Signed and unsigned casts use two's-complement wrapping. +Values are normalized and hashed as follows: + +- `bool`: `false` as `0`, `true` as `1`, then `hash_i32`. +- `int8`, `int16`, `int32`, `uint8`, `uint16`, `uint32`, `date32`, `time32`: cast to `i32`, then `hash_i32`. +- `int64`, `uint64`, `timestamp`, `time64`: cast to `i64`, then `hash_i64`. +- `float32`: `-0.0` and `+0.0` normalize to bits `0`; all NaNs normalize to `0x7fc00000`; other values use IEEE 754 bits cast to `i32`, then `hash_i32`. +- `float64`: `-0.0` and `+0.0` normalize to bits `0`; all NaNs normalize to `0x7ff8000000000000`; other values use IEEE 754 bits cast to `i64`, then `hash_i64`. +- `utf8` and `large_utf8`: hash the UTF-8 bytes with `hash_bytes`. + +The helper hashes are: + +```text +hash_i32(v) = fmix(mix_h1(0, mix_k1(v)), 4) + +hash_i64(v) = + low = low 32 bits of v as i32 + high = high 32 bits of v as i32 + fmix(mix_h1(mix_h1(0, mix_k1(low)), mix_k1(high)), 8) + +hash_bytes(bytes) = + h = 0 + for each complete 4-byte little-endian chunk: + h = mix_h1(h, mix_k1(chunk_as_i32)) + for each remaining byte: + h = mix_h1(h, mix_k1(sign_extend_i8(byte))) + fmix(h, byte_length) +``` -2. **Conflict resolution uses MemWAL Index**: When a commit conflicts, the merger checks the conflicting commit's MemWAL Index. +Test vectors for `num_buckets = 8`: -3. **No progress regression**: Because MemWAL Index is updated atomically with data, concurrent mergers cannot regress the merge progress. +- `int32` or `date32`: `1 -> 2`, `2 -> 7`, `null -> 0`, `3 -> 1`. +- `utf8`: `"a" -> 1`, `"b" -> 5`, `null -> 0`. +- `bool`: `true -> 2`. +- `float32`: `1.25 -> 0`. +- `float64`: `1.25 -> 0`. diff --git a/docs/src/format/table/row_id_lineage.md b/docs/src/format/table/row_id_lineage.md index 12f684aa48d..2756d7757f9 100644 --- a/docs/src/format/table/row_id_lineage.md +++ b/docs/src/format/table/row_id_lineage.md @@ -72,8 +72,12 @@ This protocol mirrors fragment ID assignment and ensures row IDs are unique acro Stable row IDs are a dataset-level feature recorded in the table manifest. -- Stable row IDs **must be enabled when the dataset is first created**. -- Currently, they **cannot be turned on later** for an existing dataset. Attempts to write with `enable_stable_row_ids = true` against a dataset that was created without stable row IDs will not change the dataset's configuration. +- Stable row IDs may be enabled when a dataset is created or by migrating an existing dataset. +- An ordinary write with `enable_stable_row_ids = true` does not migrate an existing dataset. Use the stable row ID migration operation instead; the Rust API exposes it as `Dataset::migrate_to_stable_row_ids`. +- Before migrating a dataset whose current manifest has no writer version, use the current Lance writer to commit an ordinary no-op deletion with predicate `false`, then reopen the latest version. This metadata-upgrade commit recomputes the authoritative physical row count for every fragment. Do not invoke stable row ID migration directly on such a legacy manifest: affected releases may have recorded stale counts, which would produce incomplete row ID sequences. +- Before migration, stop all index builds and index commits, drop every secondary index so no index entry remains in the dataset metadata, and keep index creation quiesced until migration completes. An in-flight index commit from a pre-migration snapshot can otherwise attach stale physical row addresses after activation. Recreate indices after migration. +- Quiesce data-modifying writers during migration. The migration uses a single atomic merge commit and does not retry when a concurrent write causes a conflict; the caller must retry the migration. +- Migration assigns an ID to every physical row position, including deleted positions, and atomically enables the feature and advances `next_row_id`. Migrating a dataset that already uses stable row IDs is a no-op. - When stable row IDs are disabled, the `_rowid` column (if requested) is not stable and should not be used as a persistent identifier. Row-level version tracking (`_row_created_at_version`, `_row_last_updated_at_version`) and the row ID index described below are only available when stable row IDs are enabled. @@ -181,11 +185,81 @@ The implementation selects the most compact encoding based on the value range, c -#### Inline vs External Storage +#### Inline and Spilled Storage + +`DataFragment` defines inline and column alternatives for row ID sequences and row +version sequences. This allows small sequences to stay in the manifest (fewer +IOPS) while larger sequences (resulting from frequent updates) move outside the +manifest. + +Sequences small enough (~200KB encoded and under) are stored inline in the fragment +metadata to avoid additional I/O. An inline sequence is rewritten into every manifest +version. + +A larger sequence is **spilled to a hidden column of one of the fragment's data +files**. The column arm of the oneof (`column_row_ids`, `column_created_at_versions` +or `column_last_updated_at_versions`) is an empty `RowLineageColumn` marker: it carries +no file reference, because the file is one of the fragment's `files` and is found by a +reserved negative field id in that entry's `fields`. Exactly one file of the fragment +carries each reserved field; zero or more than one is corruption. The `column_indices` +entry paired with the field id locates the column the way it does for a user column, so +a lineage column +may share a file with the user columns or with the other lineage columns, or sit in a +file that holds nothing else. Reading it uses the ordinary data file reader and its +encodings. + +The marker is valid only in a fragment whose data files are Lance v2 files. A legacy +v1 data file has no `column_indices` to locate the column by, and a fragment cannot +mix v1 and v2 files, so a writer on a dataset that stores v1 files MUST leave every +sequence inline; a marker in a fragment with a v1 data file is corruption. + +The columns have this schema, with the field ids `-3`, `-4` and `-5` respectively. +The three names are reserved: a writer MUST reject a user column with any of them +(every Lance write path does), so a hidden column never collides with a field of the +dataset schema. + +```python +import pyarrow as pa + +row_lineage_columns = pa.schema([ + pa.field("_rowid", pa.uint64(), nullable=False), + pa.field("_row_created_at_version", pa.uint64(), nullable=False), + pa.field("_row_last_updated_at_version", pa.uint64(), nullable=False), +]) +``` + +Each column present holds exactly `physical_rows` values, one per physical row in +physical row order, deleted rows included; the value at offset `i` is the row id or +version of the row at offset `i`, the same thing the inline encoding's `i`-th entry +would be. A null value, or a column whose length differs from `physical_rows`, is +corruption and MUST be rejected rather than read as a default. A file may carry any +subset of the three columns; a sequence whose arm is not the column marker is not read +from any file, whatever the file holds. + +Which sequences may leave the manifest follows from when their values are known. +A value the commit assigns -- an appended fragment's row ids, an inserted row's +created-at version, every row's last-updated-at version -- can change when a commit +conflict is retried, so it stays inline where the retry can rewrite it; those +sequences are single runs and cost a few bytes. A value carried over from existing +rows -- the row ids and created-at versions that compaction or a row rewrite +preserves -- is fixed before the commit and may be written to a data file. +A writer spills only on a table that opts in through the `lance.row_lineage.spill` +config key; a table that never sets it is unchanged. + +A writer that emits any column arm MUST set the spilled row lineage feature flag +(bit 11, value 2048) in both the reader and writer flag words. A reader without that +bit sees an unset oneof and would take the fragment to have no row IDs at all, on a +table whose manifest says every fragment has them. + +Field numbers 6, 8 and 10 of `DataFragment` (`external_row_ids`, +`external_last_updated_at_versions`, `external_created_at_versions`) once named an +opaque byte range in a file holding the same encoding as the inline arm. No Lance +writer ever emitted them; the column arms replace that design, and the numbers and +names are reserved. -Row ID sequences are stored either inline in the fragment metadata or in external files. -Sequences smaller than ~200KB are stored inline to avoid additional I/O, while larger sequences are written to external files referenced by path and offset. -This threshold balances manifest size against the overhead of separate file reads. +!!! note + Spilled row lineage sequences are not yet a released feature. A released build + treats bit 11 as an unknown feature flag and refuses the dataset.
DataFragment row_id_sequence field @@ -194,7 +268,7 @@ This threshold balances manifest size against the overhead of separate file read message DataFragment { oneof row_id_sequence { bytes inline_row_ids = 5; - ExternalFile external_row_ids = 6; + RowLineageColumn column_row_ids = 12; } } ``` @@ -281,7 +355,7 @@ RowDatasetVersionSequence { message DataFragment { oneof created_at_version_sequence { bytes inline_created_at_versions = 9; - ExternalFile external_created_at_versions = 10; + RowLineageColumn column_created_at_versions = 14; } } ``` @@ -324,7 +398,7 @@ New physical row (current): message DataFragment { oneof last_updated_at_version_sequence { bytes inline_last_updated_at_versions = 7; - ExternalFile external_last_updated_at_versions = 8; + RowLineageColumn column_last_updated_at_versions = 13; } } ``` @@ -360,4 +434,3 @@ WHERE _row_created_at_version <= {begin_version} ``` This query excludes newly inserted rows by requiring `_row_created_at_version <= {begin_version}`, ensuring only pre-existing rows that were subsequently updated are returned. - diff --git a/docs/src/format/table/schema.md b/docs/src/format/table/schema.md new file mode 100644 index 00000000000..15e8946c708 --- /dev/null +++ b/docs/src/format/table/schema.md @@ -0,0 +1,442 @@ +# Schema Format Specification + +## Overview + +The schema describes the structure of a Lance table, including all fields, their data types, and metadata. +Schemas use a logical type system where data types are represented as strings that map to Apache Arrow data types. +Each field in the schema has a unique identifier (field ID) that enables robust schema evolution and version tracking. + +!!! note + + Logical types are currently being simplified through discussion [#5864](https://github.com/lance-format/lance/discussions/5864). + Proposed changes include consolidating encoding-specific variants (e.g., `large_string` and `string`, `large_binary` and `binary`) + into single logical types with runtime optimization. Additionally, [#5817](https://github.com/lance-format/lance/discussions/5817) proposes adding + `string_view` and `binary_view` types. This document describes the current implementation. + +## Data Types + +Lance supports a comprehensive set of data types that map to Apache Arrow types. +Data types are represented as strings in the schema and can be grouped into several categories. + +### Primitive Types + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `null` | `Null` | Null type (no values) | +| `bool` | `Boolean` | Boolean (true/false) | +| `int8` | `Int8` | Signed 8-bit integer | +| `uint8` | `UInt8` | Unsigned 8-bit integer | +| `int16` | `Int16` | Signed 16-bit integer | +| `uint16` | `UInt16` | Unsigned 16-bit integer | +| `int32` | `Int32` | Signed 32-bit integer | +| `uint32` | `UInt32` | Unsigned 32-bit integer | +| `int64` | `Int64` | Signed 64-bit integer | +| `uint64` | `UInt64` | Unsigned 64-bit integer | + +### Floating Point Types + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `halffloat` | `Float16` | IEEE 754 half-precision floating point (16-bit) | +| `float` | `Float32` | IEEE 754 single-precision floating point (32-bit) | +| `double` | `Float64` | IEEE 754 double-precision floating point (64-bit) | + +### String and Binary Types + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `string` | `Utf8` | Variable-length UTF-8 encoded string | +| `binary` | `Binary` | Variable-length binary data | +| `large_string` | `LargeUtf8` | Variable-length UTF-8 string (supports large offsets) | +| `large_binary` | `LargeBinary` | Variable-length binary data (supports large offsets) | + +### Decimal Types + +Decimal types support arbitrary-precision numeric values. The format is: `decimal:::` + +| Logical Type | Arrow Type | Precision | Example | +|---|---|---|---| +| `decimal:128:P:S` | `Decimal128` | Up to 38 digits | `decimal:128:10:2` (10 total digits, 2 after decimal) | +| `decimal:256:P:S` | `Decimal256` | Up to 76 digits | `decimal:256:20:5` | + +- **Precision (P)**: Total number of digits (1-38 for Decimal128, up to 76 for Decimal256) +- **Scale (S)**: Number of digits after the decimal point (0 ≤ S ≤ P) + +### Date and Time Types + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `date32:day` | `Date32` | Date (days since epoch) | +| `date64:ms` | `Date64` | Date (milliseconds since epoch) | +| `time32:s` | `Time32` | Time (seconds since midnight) | +| `time32:ms` | `Time32` | Time (milliseconds since midnight) | +| `time64:us` | `Time64` | Time (microseconds since midnight) | +| `time64:ns` | `Time64` | Time (nanoseconds since midnight) | +| `duration:s` | `Duration` | Duration (seconds) | +| `duration:ms` | `Duration` | Duration (milliseconds) | +| `duration:us` | `Duration` | Duration (microseconds) | +| `duration:ns` | `Duration` | Duration (nanoseconds) | + +### Timestamp Types + +Timestamp types represent a point in time and may include timezone information. +Format: `timestamp::` + +- **Unit**: `s` (seconds), `ms` (milliseconds), `us` (microseconds), `ns` (nanoseconds) +- **Timezone**: IANA timezone string (e.g., `UTC`, `America/New_York`) or `-` for no timezone + +Examples: +- `timestamp:us:UTC` - Microsecond precision timestamp in UTC +- `timestamp:ms:America/New_York` - Millisecond precision timestamp in America/New_York timezone +- `timestamp:ns:-` - Nanosecond precision timestamp with no timezone + +### Complex Types + +#### Struct Type + +A struct is a container for named fields with heterogeneous types. + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `struct` | `Struct` | Composite type containing multiple named fields | + +Struct fields are represented as child fields in the schema. + +Example schema with a struct: +```protobuf +Field { + name: "address" + type: "struct" + children: [ + Field { name: "street", type: "string" }, + Field { name: "city", type: "string" }, + Field { name: "zip", type: "int32" } + ] +} +``` + +#### List Types + +Lists represent variable-length arrays of a single type. + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `list` | `List` | Variable-length list of values | +| `list.struct` | `List(Struct)` | Variable-length list of struct values | +| `large_list` | `LargeList` | Variable-length list (supports large offsets) | +| `large_list.struct` | `LargeList(Struct)` | Variable-length list of struct values (large offsets) | + +The element type is specified as a child field. + +#### Fixed-Size List Types + +Fixed-size lists have a predetermined size known at schema definition time. +Format: `fixed_size_list::` + +| Logical Type | Description | Example | +|---|---|---| +| `fixed_size_list:float:128` | Fixed-size list of 128 floats | Vector embeddings (128-dimensional) | +| `fixed_size_list:int32:10` | Fixed-size list of 10 integers | | + +Special extension types: +- `fixed_size_list:lance.bfloat16:256` - Fixed-size list of bfloat16 values + +#### Fixed-Size Binary Type + +Fixed-size binary data with a predetermined size in bytes. +Format: `fixed_size_binary:` + +| Logical Type | Description | Example | +|---|---|---| +| `fixed_size_binary:16` | Fixed-size binary of 16 bytes | MD5 hash | +| `fixed_size_binary:32` | Fixed-size binary of 32 bytes | SHA-256 hash | + +#### Dictionary Type + +Dictionary-encoded data with separate keys and values. +Format: `dict:::` + +- **Value type**: The type of dictionary values +- **Key type**: The type used for dictionary indices (typically int8, int16, or int32) +- **Ordered**: Boolean indicating if dictionary values are sorted (currently not fully supported) + +Example: `dict:string:int16:false` - Dictionary-encoded strings with int16 keys + +#### Map Type + +Key-value pairs stored in a structured format. + +| Logical Type | Arrow Type | Description | +|---|---|---| +| `map` | `Map` | Key-value pairs (currently supports unordered keys only) | + +Maps have key and value types specified as child fields. + +### Extension Types + +Lance supports custom extension types that provide semantic meaning on top of Arrow types. + +#### Blob Type + +Represents large binary data stored externally. + +| Logical Type | Description | +|---|---| +| `blob` | Large binary data with external storage reference | +| `json` | JSON-encoded data stored as binary | + +Blob types are stored as large binary data with metadata describing storage location. + +#### BFloat16 Type + +Brain float (bfloat16) is a 16-bit floating point format optimized for ML. +Used within fixed-size lists: `fixed_size_list:lance.bfloat16:SIZE` + +## Field IDs + +Field IDs are unique integer identifiers assigned to each field in a schema. +They are essential for robust schema evolution, as they allow fields to be renamed or reordered without breaking references. + +### Field ID Assignment + +**Initial assignment (depth-first order):** +When a table is created, field IDs are assigned to all fields in depth-first order, starting from 0. + +Nested fields are linked via the `parent_id` field in the protobuf message. For example, if field "c" (id: 2) is a struct containing fields "x", "y", "z", those child fields will have `parent_id: 2`. Top-level fields have `parent_id: -1`. + +Example with nested structure: +``` +Field order: a, b, c.x, c.y, c.z, d + +Assigned IDs with parent relationships: +- a: 0 (parent_id: -1) +- b: 1 (parent_id: -1) +- c: 2 (parent_id: -1, struct type) +- c.x: 3 (parent_id: 2) +- c.y: 4 (parent_id: 2) +- c.z: 5 (parent_id: 2) +- d: 6 (parent_id: -1) +``` + +Note: A `parent_id` of -1 indicates a top-level field. For nested fields, `parent_id` references the ID of the parent field. Child fields reference their parent via `parent_id` rather than being stored as separate "children" arrays in the protobuf message (though the Rust in-memory representation maintains a children vector for convenience). + +**New field assignment (incremental):** +When fields are added later (e.g., through schema evolution), they receive the next available ID +incrementally. This preserves the history of field additions. + +### Field ID Properties + +- **Immutable**: Once assigned, a field's ID never changes +- **Unique**: Each field within a table has a unique ID +- **Stable**: IDs are preserved across schema evolution operations +- **Sparse**: Field IDs may not form a contiguous sequence after schema evolution + +### Using Field IDs + +When referencing fields internally within the format, use the field ids rather than field names or positions. + +## Field Metadata + +Fields can carry additional metadata as key-value pairs to configure encoding, primary key behavior, and other properties. + +### Primary Key Metadata + +Primary key configuration is handled by two protobuf fields in the Field message: +- **unenforced_primary_key** (bool): Whether this field is part of the primary key +- **unenforced_primary_key_position** (uint32): Position in primary key ordering (1-based for ordered, 0 for unordered) + +For detailed discussion on primary key configuration, see [Unenforced Primary Key](index.md#unenforced-primary-key) in the table format overview. + +### Clustering Key Metadata + +Clustering key configuration uses a single protobuf field in the Field message: +- **unenforced_clustering_key_position** (uint32): 1-based position in clustering key ordering. 0 means not a clustering key field. + +Clustering keys hint at the physical ordering of data within a table. Unlike primary keys, +clustering key fields may be nullable. This metadata enables query engines to perform +optimizations such as storage-partitioned joins. + +### Encoding Metadata + +Column encoding configurations are specified with the `lance-encoding:` prefix. +See [File Format Encoding Specification](../file/encoding.md) for complete details on available encodings. + +### Arrow Extension Type Metadata + +Custom Arrow extension types may have metadata under the `ARROW:extension:` namespace +(e.g., `ARROW:extension:name`). + +## Schema Protobuf Definition + +The schema is serialized using protobuf messages. Key messages include: + +### Field Message + +```protobuf +%%% proto.message.lance.file.Field %%% +``` + +The Field message contains: +- **id**: Unique field identifier (int32) +- **name**: Field name (string) +- **type**: Field type enum (PARENT, REPEATED, or LEAF) +- **logical_type**: Logical type string representation (string) - e.g., "int64", "struct", "list" +- **nullable**: Whether the field can be null (bool) +- **parent_id**: Parent field ID for nested fields; -1 for top-level fields (int32) +- **metadata**: Key-value pairs for additional configuration (map) +- **unenforced_primary_key**: Whether this field is part of the primary key (bool) +- **unenforced_primary_key_position**: Position in primary key ordering (uint32, 0 = unordered) + +### Schema Message + +The complete schema is represented as a collection of top-level fields plus metadata. + +## Schema Evolution + +Field IDs enable efficient schema evolution: + +- **Add Column**: Assign a new field ID and add to schema +- **Drop Column**: Remove field from schema; its ID may be reused in some systems +- **Rename Column**: Change field name; ID remains the same +- **Reorder Columns**: Change field order in schema; IDs remain the same +- **Type Evolution**: Data type can be changed. This might require rewriting the column in the data, depending on how the type was changed. + +The use of field IDs ensures that data files can be correctly interpreted even as the schema changes over time. + +## Example Schemas + +The examples below use a simplified representation of the field structure. In the actual protobuf format, `type` refers to the field type enum (PARENT/REPEATED/LEAF) and `logical_type` contains the data type string representation. + +### Simple Table + +``` +Field { + id: 0 + name: "id" + logical_type: "int64" + nullable: false + parent_id: -1 +} +Field { + id: 1 + name: "name" + logical_type: "string" + nullable: true + parent_id: -1 +} +Field { + id: 2 + name: "created_at" + logical_type: "timestamp:us:UTC" + nullable: true + parent_id: -1 +} +``` + +### Nested Structure + +``` +Field { + id: 0 + name: "id" + logical_type: "int64" + nullable: false + parent_id: -1 // Top-level field +} +Field { + id: 1 + name: "user" + logical_type: "struct" + nullable: true + parent_id: -1 // Top-level field +} +Field { + id: 2 + name: "name" + logical_type: "string" + nullable: true + parent_id: 1 // Nested under "user" struct (id: 1) +} +Field { + id: 3 + name: "email" + logical_type: "string" + nullable: true + parent_id: 1 // Nested under "user" struct (id: 1) +} +Field { + id: 4 + name: "tags" + logical_type: "list" + nullable: true + parent_id: -1 // Top-level field +} +Field { + id: 5 + name: "item" + logical_type: "string" + nullable: true + parent_id: 4 // Nested under "tags" list (id: 4) +} +``` + +### With Vector Embeddings + +``` +Field { + id: 0 + name: "id" + logical_type: "int64" + nullable: false + parent_id: -1 // Top-level field + unenforced_primary_key: true + unenforced_primary_key_position: 1 // Ordered position in primary key +} +Field { + id: 1 + name: "text" + logical_type: "string" + nullable: true + parent_id: -1 // Top-level field +} +Field { + id: 2 + name: "embedding" + logical_type: "fixed_size_list:lance.bfloat16:384" + nullable: true + parent_id: -1 // Top-level field +} +``` + +## Type Conversion Reference + +When converting between logical types and Arrow types, Lance uses the following mappings: + +| Arrow Type | Logical Type Format | +|---|---| +| `Arrow::Null` | `null` | +| `Arrow::Boolean` | `bool` | +| `Arrow::Int8` to `Int64` | `int8`, `int16`, `int32`, `int64` | +| `Arrow::UInt8` to `UInt64` | `uint8`, `uint16`, `uint32`, `uint64` | +| `Arrow::Float16` | `halffloat` | +| `Arrow::Float32` | `float` | +| `Arrow::Float64` | `double` | +| `Arrow::Utf8` | `string` | +| `Arrow::LargeUtf8` | `large_string` | +| `Arrow::Binary` | `binary` | +| `Arrow::LargeBinary` | `large_binary` | +| `Arrow::Decimal128(p, s)` | `decimal:128:p:s` | +| `Arrow::Decimal256(p, s)` | `decimal:256:p:s` | +| `Arrow::Date32` | `date32:day` | +| `Arrow::Date64` | `date64:ms` | +| `Arrow::Time32(TimeUnit)` | `time32:s`, `time32:ms` | +| `Arrow::Time64(TimeUnit)` | `time64:us`, `time64:ns` | +| `Arrow::Timestamp(unit, tz)` | `timestamp:unit:tz` | +| `Arrow::Duration(unit)` | `duration:s`, `duration:ms`, `duration:us`, `duration:ns` | +| `Arrow::Struct` | `struct` | +| `Arrow::List(Element)` | `list` or `list.struct` if element is Struct | +| `Arrow::LargeList(Element)` | `large_list` or `large_list.struct` | +| `Arrow::FixedSizeList(Element, Size)` | `fixed_size_list:type:size` | +| `Arrow::FixedSizeBinary(Size)` | `fixed_size_binary:size` | +| `Arrow::Dictionary(KeyType, ValueType)` | `dict:value_type:key_type:false` | +| `Arrow::Map` | `map` | diff --git a/docs/src/format/table/transaction.md b/docs/src/format/table/transaction.md index 56b867a4683..a38f8e48a63 100644 --- a/docs/src/format/table/transaction.md +++ b/docs/src/format/table/transaction.md @@ -38,6 +38,14 @@ These files serve two purposes: 1. Enable manifest reconstruction during commit retries when concurrent transactions have been committed 2. Support conflict detection by describing the operation performed +### Inline Transaction Sections + +Small transactions may also be embedded in the manifest file. The manifest stores the byte position of the length-prefixed Transaction message in `transaction_section` (field 23). Readers that do not recognize this manifest field ignore it, so adding a Transaction operation cannot prevent those readers from opening the dataset. + +Decoding an inline transaction while opening a manifest is an optional cache optimization. An undecodable or unsupported Transaction message must not make the manifest unreadable. A Transaction whose `operation` holds a field number in the reserved range 100-199 that the reader does not recognize is an operation from a newer writer. Readers must treat it as an unknown operation that conflicts with every concurrent transaction, and must not re-encode or commit it. + +`transaction_section_deprecated` (field 21) is the legacy inline position. New writers must leave it absent. Readers may use it to retrieve a transaction from an old manifest when field 23 is absent. + ### Commit Algorithm The commit process attempts to atomically write a new manifest file using the storage primitives described above. @@ -52,6 +60,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 +80,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 +105,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 +142,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 +167,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. @@ -114,6 +208,10 @@ This includes operations such as compaction, defragmentation, and re-ordering. Rewrite operations change row addresses, requiring index updates. New fragment IDs must be reserved via `ReserveFragments` before executing a `Rewrite` transaction. +A rewrite that defers index remapping publishes its address mapping in the +same commit by replacing the [Fragment Reuse Index](../index/system/frag_reuse.md) +entry in the manifest's index section. +
Rewrite protobuf message @@ -123,6 +221,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 +267,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 +306,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 +336,14 @@ 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 +- UpdateConfig (only if it updates schema or field metadata, which a restore rewinds) + ### ReserveFragments Pre-allocates fragment IDs for use in future `Rewrite` operations. @@ -178,6 +358,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 +381,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 +400,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 +434,15 @@ 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 +- Restore (only if the UpdateConfig updates schema or field metadata) +- UpdateConfig (only if the two operations modify the same config) + ### DataReplacement Replaces data in specific column regions with new data files. @@ -233,6 +456,88 @@ 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. It rewrites a column file positionally against the fragments it targets, so a concurrent operation only +conflicts when it removes one of those fragments or invalidates the rows the column file covers. Here are the operations that +conflict with DataReplacement (non-retryable): + +- Overwrite +- Restore +- UpdateMemWalState +- Delete (only if it removes a target fragment outright) +- Update (only if it removes a target fragment outright) + +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 it rewrites rows out of a target fragment, or rewrites one of the replaced fields in place) +- Merge (always) + +A concurrent Delete or Update that only adds a deletion vector to a target fragment (without removing it) is compatible: the +positional column file stays aligned and the rebase preserves the deletion vector. + +### DataOverlay + +Attaches [overlay files](data_overlay_file.md) to fragments, supplying new values +for a subset of `(row offset, field)` cells without rewriting the fragments' base +data files. The overlays are appended to each fragment's existing `overlays` list, +so overlays written by concurrent commits are preserved. Each overlay's +`committed_version` is stamped to the new dataset version at commit time (and +re-stamped on retry), like the created-at / last-updated-at version sequences. + +
+DataOverlay protobuf message + +```protobuf +%%% proto.message.DataOverlay %%% + +%%% proto.message.DataOverlayGroup %%% +``` + +
+ +#### DataOverlay Compatibility + +A DataOverlay operation only changes cells within existing fragments and preserves +physical row addresses, so — like DataReplacement — it is intentionally permissive. +Because overlays stack and the higher `committed_version` wins each covered cell, +independent backfills never conflict, and a concurrent Delete simply makes the +overlay value for a deleted offset inert. Here are the operations that conflict +with DataOverlay: + +- Overwrite +- Restore +- UpdateMemWalState + +The following operations are retryable conflicts with DataOverlay: + +- Rewrite (only if overlapping fragments) — row-rewriting compaction or an + overlay→base fold changes physical row addresses or consumes the overlays, so + the overlay's offsets are no longer valid; the writer must re-read the new + fragment, recompute, and retry. +- Merge (always). +- A row-moving Update that touches an overlaid fragment — a delete-and-reinsert + update (any update that is not a `REWRITE_COLUMNS` column rewrite) relocates the + updated rows into new fragments, so the overlay's physical offsets no longer + address them; the writer must re-read and retry. + +DataOverlay is compatible with another DataOverlay (any fields), Append, Delete, a +`REWRITE_COLUMNS` column rewrite, and DataReplacement, because all of these +preserve physical row addresses: overlay offsets stay valid, the overlay is newer +and wins its covered cells, and the version gate excludes those cells from any +rebuilt index. + +When a DataReplacement or a `REWRITE_COLUMNS` update writes new base values for a +field, it supersedes any older overlay on that field: the writer tombstones the +overlay's entry for the rewritten field — replacing the field id with the obsolete +sentinel, as with obsolete base columns — so the fresh base values are not silently +shadowed. Overlay entries for other fields are preserved, and an overlay left with +no live fields is dropped. + ### UpdateMemWalState Updates the state of MemWal indices (write-ahead log based indices). @@ -259,6 +564,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,13 +694,14 @@ 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. An external manifest store is a key-value store that supports put-if-not-exists operations. -The external manifest store supplements but does not replace the manifests in object storage. +It is the concurrency coordinator and fast version index: its conditional write selects one +immutable staging manifest for each version. The canonical manifest bytes in object storage +remain authoritative, so the external store supplements but does not replace them. A reader unaware of the external manifest store can still read the table, but may observe a version up to one commit behind the true latest version. ### Commit Process with External Store @@ -399,27 +711,44 @@ 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 +2. **Reserve version in external store**: `PUT_EXTERNAL_STORE base_uri, version, {dataset}/_versions/{version}.manifest-{uuid}` + - Atomically reserve the version for this staged manifest using put-if-not-exists + - The reservation selects one immutable staging object; it is not yet the canonical commit + - If this operation fails due to conflict, another writer reserved 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 + - Successful materialization at this deterministic path is the commit point + - 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 + - After copying, read the canonical object's current metadata. Return its ETag to the caller as + an opaque physical-generation observation so runtime caches do not collapse a newly committed + Dataset into an older cached Dataset at the same URI and version + - Do not persist that ETag in the external store. Concurrent finalizers can copy the same selected + immutable bytes into different physical generations, and COPY plus external-store publication + is not atomic. Every helper therefore publishes the same stable path-and-size tuple + - Completes the synchronization between external store and object storage **Fault Tolerance:** -If the writer fails after step 2 but before step 4, the external store and object store are temporarily out of sync. -Readers detect this condition and attempt to complete the synchronization. -If synchronization fails, the reader refuses to load to ensure dataset portability. +If the writer fails after step 2 but before step 3, the external store contains a pending +reservation. Readers that use the external store detect this state and retry materialization. +If step 3 succeeds but step 4 fails, the canonical object remains committed; readers use it and +may repair the external index. Staging deletion is garbage collection and does not affect the +commit outcome. + +**Rolling Upgrade:** + +Roll this behavior out normally across the fleet. New readers ignore legacy stored +ETags, and legacy readers already accept finalized rows without an ETag, so mixed-version rows +remain compatible. While both legacy finalizers and legacy readers remain, the pre-existing race +can still republish a stale ETag that a legacy reader rejects. Full protection takes effect when +the rolling upgrade converges; no row migration or quiesced cutover is required. ### Reader Process with External Store @@ -428,20 +757,23 @@ 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, validate the canonical object's size. Ignore any legacy + stored ETag because it is neither content identity nor dataset-incarnation identity; the + validation HEAD still returns the current canonical ETag to the caller + - 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 + - Best-effort record the finalized path and size without an ETag while returning the observed + destination ETag to the current caller + - If this index repair fails, retain staging so a future reader can retry it 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 + - Return once canonical materialization succeeds, even if index repair or staging cleanup fails + - If canonical materialization cannot be established, or an observed size differs, return an error 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/docs/src/format/table/versioning.md b/docs/src/format/table/versioning.md index 745dd1ccd87..e4280cfc215 100644 --- a/docs/src/format/table/versioning.md +++ b/docs/src/format/table/versioning.md @@ -21,14 +21,26 @@ they should return an "unsupported" error on any read or write operation.
-| Flag Bit | Flag Name | Reader Required | Writer Required | Description | +| Bit Mask | Flag Name | Reader Required | Writer Required | Description | |----------|---------------------------------|-----------------|-----------------|-------------------------------------------------------------------------------------------------------------| -| 1 | `FLAG_DELETION_FILES` | Yes | Yes | Fragments may contain deletion files, which record the tombstones of soft-deleted rows. | -| 2 | `FLAG_STABLE_ROW_IDS` | Yes | Yes | Row IDs are stable for both moves and updates. Fragments contain an index mapping row IDs to row addresses. | -| 4 | `FLAG_USE_V2_FORMAT_DEPRECATED` | No | No | Files are written with the new v2 format. This flag is deprecated and no longer used. | -| 8 | `FLAG_TABLE_CONFIG` | No | Yes | Table config is present in the manifest. | -| 16 | `FLAG_BASE_PATHS` | Yes | Yes | Dataset uses multiple base paths (for shallow clones or multi-base datasets). | +| `2^0` | `FLAG_DELETION_FILES` | Yes | Yes | Fragments may contain deletion files, which record the tombstones of soft-deleted rows. | +| `2^1` | `FLAG_STABLE_ROW_IDS` | Yes | Yes | Row IDs are stable for both moves and updates. Fragments contain an index mapping row IDs to row addresses. | +| `2^2` | `FLAG_USE_V2_FORMAT_DEPRECATED` | No | No | Files are written with the new v2 format. This flag is deprecated and no longer used. | +| `2^3` | `FLAG_TABLE_CONFIG` | No | Yes | Table config is present in the manifest. | +| `2^4` | `FLAG_BASE_PATHS` | Yes | Yes | Dataset uses multiple base paths (for shallow clones or multi-base datasets). | +| `2^5` | `FLAG_DISABLE_TRANSACTION_FILE` | No | Yes | Transactions are recorded in the manifest rather than in a separate transaction file. | +| `2^6` | `FLAG_UNSTABLE_DATA_OVERLAY_FILES` | Yes | Yes | Fragments may carry data overlay files. Unstable: release builds reject it unless explicitly opted in. | +| `2^7` | `FLAG_COVERED_INDEX_METADATA` | Yes | Yes | Some index declares covering columns (`IndexMetadata.covering_fields`). Without `FLAG_INDEPENDENT_COVERING_FIELDS`, the carried columns form the trailing suffix of `fields`. An implementation without this flag may select or maintain an index using the wrong fields. | +| `2^8` | `FLAG_MIXED_DATA_FILE_VERSIONS` | Yes | Yes | The snapshot may reference recognized V2 data files with different exact versions. Both bits must be set and remain set on later versions. | +| `2^9` | `FLAG_FRAG_REUSE_WITH_STABLE_ROW_IDS` | Yes | Yes | The table uses stable row IDs and carries a [Fragment Reuse Index](../index/system/frag_reuse.md). | +| `2^10` | `FLAG_FRAGMENT_REUSE_INDEX` | Yes | Yes | The fragment reuse index records tagged transitions (`IndexMetadata.index_version >= 1`). Readers must translate row addresses through them; writers must preserve them. An implementation without this flag would decode the details as the legacy format and silently drop the transitions when it next rewrites the fragment reuse index. See [FRI index versions](../index/system/frag_reuse.md#fri-index-versions). | +| `2^11` | `FLAG_UNSTABLE_SPILLED_ROW_LINEAGE` | Yes | Yes | Some fragment stores its row ids or row version sequences as hidden columns of a data file rather than inline. A reader without this flag would see the fragment as having no row ids. Unstable: release builds reject it unless explicitly opted in. | +| `2^12` | `FLAG_FRAGMENT_TREE` | Yes | Yes | Fragment records live in a [fragment tree](fragment_metadata.md). `Manifest.fragments` is empty. | +| `2^13` | `FLAG_INDEPENDENT_COVERING_FIELDS` | Yes | Yes | Requires `FLAG_COVERED_INDEX_METADATA` and is retained together with it. `IndexMetadata.fields` contains only key fields, while `covering_fields` independently declares carried fields and may overlap `fields`. Implementations that only support the legacy suffix contract must reject the dataset. | +| `2^14` | `FLAG_MANAGED_BLOBS` | Yes | Yes | Blob descriptors independently address owned objects. Readers resolve the descriptor's base and path; cleanup preserves objects referenced by protected snapshots, independently of the original data file. Both bits remain set on later versions, including restores. |
-Flags with bit values 32 and above are unknown and will cause implementations to reject the dataset with an "unsupported" error. +Bits at positions 15 and above (`2^15`, `2^16`, and so on) are unknown; unknown flags cause implementations to reject the dataset with an "unsupported" error. The paired mixed-version and Managed Blob reader and writer bits must each either both be set or both be clear; a half-set manifest is invalid. + +Publishing a new data file containing a Blob v2 field activates the Managed Blob capability, including files containing only inline values. Metadata-only updates and deletion vectors on existing files do not activate it. Once activated, removing Blob fields or restoring an older snapshot does not clear it. Before activation, all maintenance clients must support Managed Blobs: the flag cannot revoke old handles or prevent older clients from running cleanup through an unflagged historical snapshot. See [client compatibility](../../guide/blob.md#managed-objects-and-client-compatibility). diff --git a/docs/src/guide/.pages b/docs/src/guide/.pages index e92b9a1182a..7a0fd817b1c 100644 --- a/docs/src/guide/.pages +++ b/docs/src/guide/.pages @@ -4,10 +4,12 @@ nav: - Data Evolution: data_evolution.md - Blob API: blob.md - JSON Support: json.md - - Tags: tags.md + - Tags and Branches: tags_and_branches.md - Object Store Configuration: object_store.md + - Observability: observability.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/arrays.md b/docs/src/guide/arrays.md index f824dd641c1..5765ca7675b 100644 --- a/docs/src/guide/arrays.md +++ b/docs/src/guide/arrays.md @@ -119,11 +119,11 @@ calling `lance.arrow.ImageTensorArray.to_encoded`. A `lance.arrow.EncodedImageArray.to_tensor` method is provided to decode encoded images and return them as `lance.arrow.FixedShapeImageTensorArray`, from -which they can be converted to numpy arrays or TensorFlow tensors. +which they can be converted to numpy arrays. For decoding images, it will first attempt to use a decoder provided via the optional function parameter. If decoder is not provided it will attempt to use -[Pillow](https://pillow.readthedocs.io/en/stable/) and [tensorflow](https://www.tensorflow.org/api_docs/python/tf/io/encode_png) in that -order. If neither library or custom decoder is available an exception will be raised. +[Pillow](https://pillow.readthedocs.io/en/stable/). If neither Pillow nor a custom +decoder is available an exception will be raised. ```python from lance.arrow import ImageURIArray @@ -132,13 +132,16 @@ uris = [os.path.join(os.path.dirname(__file__), "images/1.png")] encoded_images = ImageURIArray.from_uris(uris).read_uris() print(encoded_images.to_tensor()) -def tensorflow_decoder(images): - import tensorflow as tf +def pillow_decoder(images): + import io import numpy as np + from PIL import Image - return np.stack(tf.io.decode_png(img.as_py(), channels=3) for img in images.storage) + return np.stack( + np.asarray(Image.open(io.BytesIO(img.as_py()))) for img in images.storage + ) -print(encoded_images.to_tensor(tensorflow_decoder)) +print(encoded_images.to_tensor(pillow_decoder)) # # [[42, 42, 42, 255]] # @@ -164,8 +167,8 @@ created by calling `lance.arrow.ImageArray.from_array` and passing in a It can be encoded into to `lance.arrow.EncodedImageArray` by calling `lance.arrow.FixedShapeImageTensorArray.to_encoded` and passing custom encoder If encoder is not provided it will attempt to use -[tensorflow](https://www.tensorflow.org/api_docs/python/tf/io/encode_png) and [Pillow](https://pillow.readthedocs.io/en/stable/) in that order. Default encoders will -encode to PNG. If neither library is available it will raise an exception. +[Pillow](https://pillow.readthedocs.io/en/stable/). The default encoder will encode +to PNG. If neither Pillow nor a custom encoder is available it will raise an exception. ```python from lance.arrow import ImageURIArray @@ -176,4 +179,4 @@ tensor_images.to_encoded() # # [... # b'\x89PNG\r\n\x1a...' -``` \ No newline at end of file +``` diff --git a/docs/src/guide/blob.md b/docs/src/guide/blob.md index 6450230e09b..a9d28b3d73b 100644 --- a/docs/src/guide/blob.md +++ b/docs/src/guide/blob.md @@ -1,86 +1,512 @@ -# Blob As Files +# Blob Columns -Unlike other data formats, large multimodal data is a first-class citizen in the Lance columnar format. -Lance provides a high-level API to store and retrieve large binary objects (blobs) in Lance datasets. +Lance can store large binary objects (images, videos, audio, model artifacts) in blob columns, where they are treated like any other column payload in the dataset. +Blob columns support both planned full-payload reads and lazy file-like access. -![Blob](../images/blob.png) +!!! tip "Choosing between `read_blobs` and `take_blobs`" + - For data loaders and batch processing that need complete byte payloads, use `read_blobs`. + - Use `take_blobs` when you need a `BlobFile` handle for streaming, seeking, or partial reads. -Lance serves large binary data using `lance.BlobFile`, which -is a file-like object that lazily reads large binary objects. -To create a Lance dataset with large blob data, you can mark a large binary column as a blob column by -adding the metadata `lance-encoding:blob` to `true`. +![Blob v2 overview](../images/blob-v2-overview-light.png#only-light) +![Blob v2 overview](../images/blob-v2-overview-dark.png#only-dark) + +If you're unsure about whether you need a blob column in the first place (and why it's useful), read the "[when to use blob column vs. inline binary](#when-to-use-a-blob-column-vs-inline-binary)" section below. + +## Quick Start: Blob v2 + +This page focuses on blob workflows in Python and uses Lance file format terminology. + +- `data_storage_version` means the Lance **file format version** of a dataset. +- A dataset has a default file format version. Append and compaction can select + another exact `2.x` version without rewriting existing files or changing that default. +- File format `0.1` cannot be mixed with the `2.x` family. ```python +import lance import pyarrow as pa +from lance import blob_array, blob_field -schema = pa.schema( - [ - pa.field("id", pa.int64()), - pa.field("video", - pa.large_binary(), - metadata={"lance-encoding:blob": "true"} - ), - ] +schema = pa.schema([ + pa.field("id", pa.int64()), + blob_field("blob"), +]) + +table = pa.table( + { + "id": [1], + "blob": blob_array([b"hello blob v2"]), + }, + schema=schema, ) + +ds = lance.write_dataset(table, "./blobs_v22.lance", data_storage_version="2.2") + +_row_address, payload = ds.read_blobs("blob", indices=[0])[0] +assert payload == b"hello blob v2" ``` -To write blob data to a Lance dataset, create a PyArrow table with the blob schema and use `lance.write_dataset`: +## Version Compatibility + +Blob support is tied to the dataset's file format version. Earlier file format versions +(`< 2.2`) stored blobs using the `lance-encoding:blob` metadata field, while Blob +v2 introduces a new storage layout that requires file format `>= 2.2`. + +The target file version selects the physical Blob encoding. For `>= 2.2`, +dataset writers also accept legacy blob-marked binary inputs and convert them +to Blob v2 before encoding. Older files keep their original encoding. + +| Dataset `data_storage_version` | Legacy blob metadata (`lance-encoding:blob`) | Blob v2 (`lance.blob.v2`) | +|---|---|---| +| `0.1`, `2.0`, `2.1` | Supported for write/read | Not supported | +| `2.2+` | Accepted as input; written as Blob v2 | Supported for write/read (recommended) | + +### Mixing legacy Blob and Blob v2 files + +Append with `data_storage_version="2.2"` to start writing Blob v2 into an +existing `2.0` or `2.1` dataset. Both legacy blob-marked binary inputs and +`blob_field` / `blob_array` inputs are accepted. No separate upgrade operation +or historical-payload rewrite is required. + +When the first Blob v2 file for a legacy column is committed, the snapshot uses +the Blob v2 logical schema for that column. Descriptor scans return the v2 +`kind, position, size, blob_id, blob_uri` struct across all fragments, including +selections containing only old files. Old payloads appear as Inline descriptors +pointing into their original data files. `take_blobs`, `read_blobs`, and +`blob_handling="all_binary"` continue to expose the same payloads, nulls, and +empty values. Historical snapshots retain their previous schema. + +Descriptor consumers that access fields by position or compare exact Arrow +schemas must account for the extended structure. Interpret `position` together +with `kind` and the object reference. See [the schema-transition discussion](https://github.com/lance-format/lance/issues/9458). + +The dataset's default file version remains unchanged. Select `2.2` or newer +explicitly when writing Blob v2 structs or compacting the mixed column into +Blob v2 files. Compaction rewrites the selected fragments; it is not required +to enable new Blob v2 writes. + +### Managed objects and client compatibility + +Writers store out-of-line Blob v2 payloads in independently named +`_blobs/.blob` objects and publish the Managed Blob reader and writer +capability on the table. This works with file formats 2.2 and 2.3; it does not +require choosing 2.3. Clients that do not understand the capability must refuse +to open a flagged snapshot. Updating an existing table with Blob data files can +activate it, even if that batch contains only inline values. The capability +remains set across later writes and restores. + +Compaction preserves Managed payload objects and can adopt existing Packed or +Dedicated sidecars in place. For example, compaction can replace `data/A.lance` +with `data/B.lance` while B's Managed descriptor records the base and path of +the existing `data/A/0001.blob`. Cleanup can then delete `data/A.lance` without +copying or deleting the blob that B still references. The blob's path may retain +A's name, but resolving and retaining the blob no longer requires A's data file. +Cleanup retains objects referenced by protected snapshots; a partially live +packed object is retained as a whole. Blob reads continue to return the same +bytes. Raw descriptor scans report `kind = 4` for a Managed object relative to +the data file's table base, or `kind = 5` with an explicit registered base ID. +The default table base is implicit: moving the complete dataset moves its local +Managed objects without retaining a URI to the old location. Explicit base IDs +must already be registered before writing references to them. A shallow clone +keeps the source table base through its data-file metadata; compaction preserves +that base when it moves descriptors into new files. A deep clone copies local +objects at the same relative paths without rewriting their descriptors. + +Before activating Managed Blobs on an existing table, upgrade all clients that +run table maintenance to a version that supports Managed Blobs, or stop their +maintenance tasks. After activation, all maintenance must use a supporting +client. + +The table flag prevents older clients from opening a flagged snapshot. It does +not revoke a handle opened before activation or prevent opening an unflagged +historical snapshot. Older clients, including v11.0.0, can run cleanup through +these handles without checking the feature flags of the other manifests they +inspect. Such cleanup can delete adopted sidecars that newer snapshots still +reference, because it treats the original data file as their owner. The table +flag therefore does not protect against these old maintenance paths. + +## Blob v2: Write Patterns + +Use `blob_field` and `blob_array` to build blob v2 columns. + +### Logical Arrow schema + +A blob v2 field is tagged with `ARROW:extension:name = "lance.blob.v2"`. Writers +accept these logical struct shapes: + +| Shape | Children | Use | +|---|---|---| +| Minimal | `data: LargeBinary?`, `uri: Utf8?` | Inline bytes or a complete external object | +| Complete | Minimal fields plus `position: UInt64?`, `size: UInt64?` | An optional byte range within an external object | + +Every non-null row must set exactly one of `data` and `uri`. For the complete +shape, `position` and `size` must either both be set or both be null, a range +requires `uri`, and an explicit range must have `size > 0`. Use inline `b""` for +an empty blob; a URI without range fields still represents the complete external +object, including an empty object. Python's `blob_field` and `BlobType` use the +complete shape. Lance preserves an accepted logical shape, including child +fields, nullability, and metadata, across create, append, and merge-insert +writes; descriptor scans still return the compact stored descriptor shape. ```python import lance +import pyarrow as pa +from lance import Blob, blob_array, blob_field + +schema = pa.schema([ + pa.field("id", pa.int64()), + blob_field("blob", nullable=True), +]) + +# A single column can mix: +# - inline bytes +# - external URI +# - external URI slice (position + size) +# - null +rows = pa.table( + { + "id": [1, 2, 3, 4], + "blob": blob_array([ + b"inline-bytes", + "s3://bucket/path/video.mp4", + Blob.from_uri("s3://bucket/archive.tar", position=4096, size=8192), + None, + ]), + }, + schema=schema, +) -# First, download a sample video file for testing -# wget https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4 -import urllib.request -urllib.request.urlretrieve( - "https://www.learningcontainer.com/wp-content/uploads/2020/05/sample-mp4-file.mp4", - "sample_video.mp4" +ds = lance.write_dataset( + rows, + "./blobs_v22.lance", + data_storage_version="2.2", ) +``` -# Then read the video file content -with open("sample_video.mp4", 'rb') as f: - video_data = f.read() +Note: -# Create table with blob data -table = pa.table({ - "id": [1], - "video": [video_data], -}, schema=schema) +- By default, external blob URIs must map to a registered non-dataset-root base path. +- If you need to reference external objects outside those bases, set + `allow_external_blob_outside_bases=True` when writing. +- Blob v2 storage layout thresholds can be configured per column with + `blob_field(..., inline_size_threshold=..., dedicated_size_threshold=...)`. + The inline threshold controls when values move from the data file to packed + `.blob` sidecar storage. The dedicated threshold controls when values move + from packed sidecar storage to a dedicated `.blob` file. The dedicated + threshold is checked first. For existing columns, these thresholds are stored + in the dataset schema; appends that explicitly provide different threshold + metadata for the same column are rejected. +- `blob_pack_file_size_threshold` is a write option for rolling packed `.blob` + sidecar files. It does not control inline-vs-packed placement. +- Blob v2 fields can be nested inside structs and variable-length lists. Blob-aware + scans preserve the surrounding nested layout; use `blob_handling="all_binary"` + to materialize nested blob payloads as bytes. + +### Example: packed external blobs (single container file) + +```python +import io +import tarfile +from pathlib import Path +import lance +import pyarrow as pa +from lance import Blob, blob_array, blob_field + +# Build a tar file with three payloads +payloads = { + "a.bin": b"alpha", + "b.bin": b"bravo", + "c.bin": b"charlie", +} + +with tarfile.open("container.tar", "w") as tf: + for name, data in payloads.items(): + info = tarfile.TarInfo(name) + info.size = len(data) + tf.addfile(info, io.BytesIO(data)) + +# Capture offset/size for each member +blob_values = [] +with tarfile.open("container.tar", "r") as tf: + container_uri = Path("container.tar").resolve().as_uri() + for name in payloads: + m = tf.getmember(name) + blob_values.append(Blob.from_uri(container_uri, position=m.offset_data, size=m.size)) + +schema = pa.schema([ + pa.field("name", pa.utf8()), + blob_field("blob"), +]) + +rows = pa.table( + { + "name": list(payloads.keys()), + "blob": blob_array(blob_values), + }, + schema=schema, +) -# Write to Lance dataset ds = lance.write_dataset( - table, - "./youtube.lance", - schema=schema + rows, + "./packed_blobs_v22.lance", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, +) +``` + +## Blob v2: Read Patterns + +Choose the read API based on the payload shape you want: + +| API | Returns | Use When | +|---|---|---| +| `read_blobs` | `List[Tuple[int, Optional[bytes]]]` | You need complete blob payloads in memory, such as training loaders or batch preprocessing. | +| `read_blob_ranges` | `List[Tuple[int, int, Optional[bytes]]]` | You need selected byte ranges from multiple rows without materializing complete blobs. | +| `take_blobs` | `List[Optional[BlobFile]]` | You need file-like objects for streaming, seeking, or partial reads. | +| `scanner(..., blob_handling="all_binary")` | Arrow binary columns | You want blob columns in a scan result or `pyarrow.Table`. | + +Do not wrap `take_blobs` in your own thread pool just to call `read()` or +`readall()` on every blob. Use `read_blobs` instead; it plans and executes +batched blob reads through Lance's scheduler. + +Exactly one selector must be provided to `read_blobs` or `take_blobs`: `ids`, +`indices`, or `addresses`. `read_blob_ranges` accepts the same selector kinds +through its required `selector` argument. + +| Selector | Typical Use | Stability | +|---|---|---| +| `indices` | Positional reads within one dataset snapshot | Stable within that snapshot | +| `ids` | Logical row-id based reads | Stable logical identity (when row ids are available) | +| `addresses` | Low-level physical reads and debugging | Unstable physical location | + +### Read complete payloads by row indices + +```python +import lance + +ds = lance.dataset("./blobs_v22.lance") +rows = ds.read_blobs("blob", indices=[0, 1]) +payloads = [payload for _row_address, payload in rows] +``` + +### Read complete payloads by row ids + +```python +import lance + +ds = lance.dataset("./blobs_v22.lance") +row_ids = ds.to_table(columns=[], with_row_id=True).column("_rowid").to_pylist() + +rows = ds.read_blobs("blob", ids=row_ids[:2]) +``` + +### Read complete payloads by row addresses + +```python +import lance + +ds = lance.dataset("./blobs_v22.lance") +row_addrs = ds.to_table(columns=[], with_row_address=True).column("_rowaddr").to_pylist() + +rows = ds.read_blobs("blob", addresses=row_addrs[:2]) +``` + +Blob selection APIs preserve logical result cardinality. `read_blobs()` and +`take_blobs()` return one element per selected row, and `read_blob_ranges()` +returns one element per request. A null blob is returned as `None`; a valid +empty blob remains a non-null empty payload or zero-length `BlobFile`. + +### Read row-specific byte ranges + +Use `read_blob_ranges` to read multiple blob-local ranges with one planned API +call. Each request is a `(row, offset, length)` tuple, and `selector` determines +whether every `row` is interpreted as a row ID, row address, or dataset index. + +```python +import lance + +ds = lance.dataset("./blobs_v22.lance") +results = ds.read_blob_ranges( + "blob", + requests=[ + (7, 0, 1024), + (7, 4096, 1024), + (12, 0, 0), + ], + selector="indices", ) + +for request_index, row_address, data in results: + if data is None: + # The selected blob is null. + continue + print(request_index, row_address, len(data)) ``` -To fetch blobs from a Lance dataset, you can use `lance.dataset.LanceDataset.take_blobs`. +Each result contains the zero-based `request_index`, the resolved physical row +address, and the requested bytes. `request_index` identifies the original +request when the same row appears more than once. -For example, it's easy to use `BlobFile` to extract frames from a video file without -loading the entire video into memory. +A request on a null blob returns `None`, including when its range is empty. An +empty range on a non-null blob returns `b""` without payload I/O. For every +request, `offset + length` must fit in an unsigned 64-bit integer. A range on a +non-null blob must not extend beyond its logical size; blob-local bounds are not +evaluated for null blobs because they have no logical payload length. + +### Read blob columns as Arrow binary ```python -import av # pip install av import lance -ds = lance.dataset("./youtube.lance") -start_time, end_time = 500, 1000 -# Get blob data from the first row (id=0) -blobs = ds.take_blobs("video", ids=[0]) -with av.open(blobs[0]) as container: +ds = lance.dataset("./blobs_v22.lance") +table = ds.scanner(columns=["blob"], blob_handling="all_binary").to_table() +payloads = table.column("blob").to_pylist() +``` + +### Open file-like blob handles lazily + +Sequential reads are buffered, 512 KiB by default. Pass `buffer_size=0` +for unbuffered reads. `read_range` and `read_ranges` do not use this +buffer. + +```python +import lance + +ds = lance.dataset("./blobs_v22.lance") +blobs = ds.take_blobs("blob", indices=[0, 1]) + +blob = blobs[0] +if blob is not None: + with blob as f: + header = f.read(1024) +``` + +### Example: decode video frames lazily + +```python +import av +import lance + +ds = lance.dataset("./videos_v22.lance") +blob = ds.take_blobs("video", indices=[0])[0] +if blob is None: + raise ValueError("video blob is null") + +start_ms, end_ms = 500, 1000 + +with av.open(blob) as container: stream = container.streams.video[0] stream.codec_context.skip_frame = "NONKEY" - start_time = start_time / stream.time_base - start_time = start_time.as_integer_ratio()[0] - end_time = end_time / stream.time_base - container.seek(start_time, stream=stream) + start = (start_ms / 1000) / stream.time_base + end = (end_ms / 1000) / stream.time_base + container.seek(int(start), stream=stream) for frame in container.decode(stream): - if frame.time > end_time: + if frame.time is not None and frame.time > end_ms / 1000: break - display(frame.to_image()) - clear_output(wait=True) -``` \ No newline at end of file + # process frame + pass +``` + +## Legacy Compatibility (`data_storage_version` <= `2.1`) + +If you need to keep writing legacy blob columns, use file format `0.1`, `2.0`, or `2.1` +and mark `LargeBinary` fields with a metadata kwarg `"lance-encoding:blob": true`. + +```python +import lance +import pyarrow as pa + +schema = pa.schema([ + pa.field("id", pa.int64()), + pa.field( + "video", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ), +]) + +table = pa.table( + { + "id": [1, 2], + "video": [b"foo", b"bar"], + }, + schema=schema, +) + +ds = lance.write_dataset( + table, + "./legacy_blob_dataset", + data_storage_version="2.1", +) +``` + +With `data_storage_version >= 2.2`, dataset writers convert this input to Blob v2. +For new datasets, prefer `blob_field` / `blob_array` with file format 2.2. + +## Rewrite to a New Blob v2 Dataset + +To replace all historical legacy Blob encodings at once, you can rewrite them +into a new dataset with `data_storage_version="2.2"`. This is optional for +`2.0` / `2.1` tables, which can instead append Blob v2 files as described above. +File format `0.1` tables still require a rewrite to move into the `2.x` family. + +```python +import lance +import pyarrow as pa +from lance import blob_array, blob_field + +legacy = lance.dataset("./legacy_blob_dataset") +raw = legacy.scanner(columns=["id", "video"], blob_handling="all_binary").to_table() + +new_schema = pa.schema([ + pa.field("id", pa.int64()), + blob_field("video"), +]) + +rewritten = pa.table( + { + "id": raw.column("id"), + "video": blob_array(raw.column("video").to_pylist()), + }, + schema=new_schema, +) + +lance.write_dataset( + rewritten, + "./blob_v22_dataset", + data_storage_version="2.2", +) +``` + +!!! warning + - The example above materializes binary payloads in memory (`blob_handling="all_binary"` and `to_pylist()`). + - For large datasets, prefer chunked/batched rewrite pipelines. + +## When to Use a Blob Column vs. Inline Binary + +Not every binary column needs to be a blob column. Plain Arrow `binary`/`large_binary` stores bytes *inline*, interleaved with your other columns, which is simplest and fastest for really small blobs (e.g., thumbnail images). Using a blob column to store the binary payload makes sense when either of these holds: + +- **You need partial or streaming reads.** Inline binary is always read in full; there is no way to fetch a byte range without materializing the entire value. Blob columns expose `read_blob_ranges` for planned row-specific range reads and `take_blobs` → `BlobFile` handles for caller-driven seeks, so you pay only for the bytes you touch. +- **Your values are large (roughly 1 MB or more on average).** Operations that rewrite entire rows, such as compaction or some updates, must copy the large inline payloads forward into the new version — even when those bytes never changed. The bigger the payload, the more bytes you rewrite per logical change (write amplification). A blob column keeps large payloads in separate `.blob` files that are referenced rather than re-copied, so these operations don't rewrite the heavy bytes. + +!!! tip + As a rule of thumb, if average payload size is below a few tens of KB and you only ever read whole values, plain inline binary is fine. Above ~1 MB, or any time you want file-like access, prefer a blob column. Blob v2 also tunes this automatically: by default it keeps payloads up to 64 KiB inline, packs payloads over 64 KiB and up to 4 MiB into shared `.blob` sidecars, and gives payloads over 4 MiB their own dedicated `.blob` file. These thresholds are configurable per column. + +## Troubleshooting + +This section contains commonly noticed issues or errors, and explains how to address them. + +### Blob v2 requires file version >= 2.2 +**Cause**: You are writing blob v2 values into a dataset/file format below `2.2`. +**Fix**: Write to a dataset created with `data_storage_version="2.2"` (or newer). + +### Legacy blob columns ... are not supported for file version >= 2.2 +**Cause**: A lower-level file writer received legacy blob metadata instead of prepared Blob v2 input. +**Fix**: Use the dataset writer, which adapts legacy byte inputs, or prepare Blob v2 columns with the file-level Blob APIs. + + +### Exactly one of ids, indices, or addresses must be specified +**Cause**: `read_blobs` or `take_blobs` received none or multiple selectors. +**Fix**: Provide exactly one of `ids`, `indices`, or `addresses`. diff --git a/docs/src/guide/data_evolution.md b/docs/src/guide/data_evolution.md index d6c7b23f674..40a68156552 100644 --- a/docs/src/guide/data_evolution.md +++ b/docs/src/guide/data_evolution.md @@ -48,6 +48,11 @@ assert dataset.schema == pa.schema([ This operation is very fast, as it only updates the metadata of the dataset. +For Lance file format `<= 2.1`, adding sub-columns under an existing `struct` is not supported. +Starting with Lance file format `2.2`, schema-only add can also extend nested `struct` fields +(including `struct` fields nested inside list types), for example by adding +`people.item.location` under `list>`. + ### With data backfill New columns can be added and populated within a single operation using the @@ -153,11 +158,26 @@ print(dataset.schema) # id: int64 ``` +Starting with Lance file format `2.2`, nested sub-column removal is supported for +nested types (for example `people.item.city` on `list>`), instead of +being limited to `struct` only. + To actually remove the data from disk, the files must be rewritten to remove the columns and then the old files must be deleted. This can be done using `lance.dataset.DatasetOptimizer.compact_files()` followed by `lance.LanceDataset.cleanup_old_versions()`. +!!! warning + + `drop_columns` is metadata-only and remains reversible as long as old versions are retained. + After `compact_files()` rewrites data files and `cleanup_old_versions()` removes old manifests/files, + removed data may become permanently unrecoverable. + + For production workflows, use a rollback window: + - create a tag (or snapshot/backup) before nested column drops + - delay cleanup until the rollback window has passed + - only run aggressive cleanup after rollback validation + ## Renaming columns Columns can be renamed using the `lance.LanceDataset.alter_columns` method. diff --git a/docs/src/guide/data_types.md b/docs/src/guide/data_types.md index c7853695f36..c3f222d36ba 100644 --- a/docs/src/guide/data_types.md +++ b/docs/src/guide/data_types.md @@ -32,9 +32,42 @@ Lance supports the full Apache Arrow type system. When writing data through Pyth ### Blob Type for Large Binary Objects -Lance provides a specialized **Blob** type for efficiently storing and retrieving very large binary objects such as videos, images, audio files, or other multimedia content. Unlike regular binary columns, blobs are stored out-of-line and support lazy loading, which means you can read portions of the data without loading everything into memory. +Lance provides a specialized **Blob** type for efficiently storing and retrieving very large binary objects such as videos, images, audio files, or other multimedia content. Blob columns support planned full-payload reads as well as lazy file-like access for streaming, seeking, and partial reads. -To create a blob column, add the `lance-encoding:blob` metadata to a `LargeBinary` field: +For new datasets, use blob v2 (`lance.blob.v2`) via `blob_field` and `blob_array`. + +Blob versioning follows dataset file format rules: + +- `data_storage_version` is the Lance file format version of a dataset. +- A dataset's `data_storage_version` is fixed once created. +- For `data_storage_version >= 2.2`, legacy blob metadata (`lance-encoding:blob`) is rejected on write. +- Legacy metadata-based blob write remains available for `0.1`, `2.0`, and `2.1`. + +```python +import lance +import pyarrow as pa +from lance import blob_array, blob_field + +schema = pa.schema([ + pa.field("id", pa.int64()), + blob_field("video"), +]) + +table = pa.table( + { + "id": [1], + "video": blob_array([b"sample-video-bytes"]), + }, + schema=schema, +) + +ds = lance.write_dataset(table, "./videos_v22.lance", data_storage_version="2.2") +_row_address, payload = ds.read_blobs("video", indices=[0])[0] +``` + +For legacy compatibility (`data_storage_version <= 2.1`), you can still write blob columns using `LargeBinary` with `lance-encoding:blob=true`. + +To create a blob column with the legacy path, add the `lance-encoding:blob` metadata to a `LargeBinary` field: ```python import pyarrow as pa @@ -58,14 +91,26 @@ table = pa.table({ "video": [video_data], }, schema=schema) -ds = lance.write_dataset(table, "./videos.lance", schema=schema) +ds = lance.write_dataset( + table, + "./videos_legacy.lance", + schema=schema, + data_storage_version="2.1", +) +``` + +To read complete blob payloads into memory, use `read_blobs()`: + +```python +rows = ds.read_blobs("video", indices=[0]) +_row_address, payload = rows[0] ``` -To read blob data, use `take_blobs()` which returns file-like objects for lazy reading: +Use `take_blobs()` only when you need file-like objects for lazy, partial, or seek-based reading: ```python # Retrieve blob as a file-like object (lazy loading) -blobs = ds.take_blobs("video", ids=[0]) +blobs = ds.take_blobs("video", indices=[0]) # Use with libraries that accept file-like objects import av # pip install av @@ -283,6 +328,7 @@ ds = lance.write_dataset(table, "./with_metadata.lance") ### Map Types Store key-value pairs with dynamic keys: +Map writes require Lance file format version 2.2 or later. ```python import lance @@ -301,7 +347,7 @@ table = pa.Table.from_pydict({ ], }, schema=schema) -ds = lance.write_dataset(table, "./with_maps.lance") +ds = lance.write_dataset(table, "./with_maps.lance", data_storage_version="2.2") ``` ## Data Type Mapping for Integrations @@ -326,7 +372,7 @@ When integrating Lance with other systems (like Apache Flink, Spark, or Presto), | `TIMESTAMP WITH LOCAL TIMEZONE` | `Timestamp` | With timezone info | | `BINARY` / `VARBINARY` | `Binary` | | | `BYTES` | `Binary` | | -| `BLOB` | `LargeBinary` with `lance-encoding:blob` | Large binary objects with lazy loading | +| `BLOB` | Blob v2 extension type (`lance.blob.v2`) | Use `blob_field` / `blob_array` for new datasets; legacy metadata path applies to `data_storage_version <= 2.1` | | `ARRAY` | `List(T)` | Variable-length array | | `ARRAY(n)` | `FixedSizeList(T, n)` | Fixed-length array (vectors) | | `ROW` / `STRUCT` | `Struct` | Nested structure | @@ -362,7 +408,7 @@ This maps to Lance's `FixedSizeList(Float32, 384)` type, which is optimized for: 3. **Align dimensions for SIMD**: Vector dimensions divisible by 8 enable optimal SIMD acceleration. Common dimensions: 128, 256, 384, 512, 768, 1024, 1536. -4. **Create indexes for large datasets**: For datasets with more than ~10,000 vectors, create an ANN index for fast search: +4. **Create indices for large datasets**: For datasets with more than ~10,000 vectors, create an ANN index for fast search: ```python # IVF_PQ is recommended for most use cases diff --git a/docs/src/guide/distributed_indexing.md b/docs/src/guide/distributed_indexing.md new file mode 100644 index 00000000000..0a6ab26030a --- /dev/null +++ b/docs/src/guide/distributed_indexing.md @@ -0,0 +1,216 @@ +# 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 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 and segment-native scalar 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) + +## Terminology + +This guide uses the following terms consistently: + +- **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 physical segments + +For example, a distributed vector build may create a layout like: + +```text +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// +├── index.idx +└── auxiliary.idx + +indices// +├── index.idx +└── auxiliary.idx +``` + +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 can be committed directly. + +## Roles + +There are two parties involved in distributed indexing: + +- **Workers** build segments +- **The caller** launches workers, chooses how those segments should be turned + into final segments, optionally merges caller-defined groups, 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 indexing has two layers of parallelism. + +### Worker Build + +First, multiple workers build segments in parallel: + +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//` + +### Worker Shuffle Memory + +Vector workers using the two-file IVF shuffler can set +`LANCE_SHUFFLE_MAX_PRELOADED_OFFSETS_BYTES` to control the maximum allocated bytes +retained for the shuffle offsets table. The default is `268435456` (256 MiB). +Set it before starting the shuffle; reopened shuffle readers also use the setting. +Values must be non-negative integers in bytes. `0` disables offset preloading; +invalid values return an error. + +For example, allow up to 512 MiB per shuffle: + +```shell +export LANCE_SHUFFLE_MAX_PRELOADED_OFFSETS_BYTES=536870912 +``` + +When offsets fit, the reader can combine adjacent partitions into read windows. +If the limit is exceeded or allocation fails, the reader uses offsets from disk +and reads partitions individually. The limit applies to each shuffle, so memory +use adds up across concurrent builds. It does not include decoded data, index +construction, or other buffers, and it does not limit total process memory. + +### Segment Merge + +Then the caller decides whether those existing segments should be committed as-is +or merged into larger 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. + +`merge_existing_index_segments(...)` currently supports vector, inverted, +bitmap, BTree, and zone map segments. Other scalar index families can still +commit multiple compatible segments directly when their build path supports +fragment-scoped segments, but cannot be merged into a larger physical segment +until they add a merge implementation. + +### Vector Model Scope + +Distributed vector builds support two model scopes. + +**Shared model artifacts**: the caller trains or provides IVF centroids once and +passes the same artifacts to every worker. For IVF-PQ segments that should be +physically mergeable, workers should also use the same PQ codebook. This makes +partition ids and quantizer state have the same meaning across segments. + +**Independent segment models**: each worker trains the IVF/PQ model for its own +`fragment_ids`. The resulting segments can be committed together as one logical +index without sharing centroids or codebooks. + +At query time, Lance searches each physical segment independently: + +1. Lance opens each segment by index UUID +2. each segment ranks IVF partitions using its own centroids +3. each segment searches the selected partitions using its own quantizer storage +4. Lance merges the candidate rows from all segments by `_distance` + +Because partition ids are interpreted only within a segment during this fanout +query path, independently trained committed segments can return valid results. +For L2 and cosine IVF-PQ, each segment computes residuals against its own IVF +centroid during both build and query, so distances remain estimates of the +original query-to-vector metric. + +Physical merge is a separate operation. It rewrites several segment artifacts +into one artifact with one model metadata scope. Use shared compatible model +artifacts for segments you plan to merge physically, or keep independently +trained segments as separate physical segments. + +## Internal Finalize Model + +Internally, Lance models distributed segment build as: + +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 merge step is driven directly by the `IndexMetadata` returned from +`execute_uncommitted()`. + +This is intentionally a storage-level model: + +- 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 + +The caller chooses the final segment grouping: + +- 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 +physical segments. + +## Responsibility Boundaries + +The caller is expected to know: + +- which distributed build is ready for segment build +- the segment metadata returned by worker builds +- how the resulting physical segments should be published + +Lance is responsible for: + +- writing segment artifacts +- planning physical segments from the supplied segment set +- 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/docs/src/guide/distributed_write.md b/docs/src/guide/distributed_write.md index 4fbc43a1058..13137b5de8e 100644 --- a/docs/src/guide/distributed_write.md +++ b/docs/src/guide/distributed_write.md @@ -263,3 +263,114 @@ Output: 6 7 Gracie 88 7 8 Henry 82 ``` + +### Handling stable row id + +On a dataset created with `enable_stable_row_ids=True`, each row keeps the same +`_rowid` for its lifetime, even when an update rewrites it into a different +fragment. Lance cannot infer which new row replaces which old one, so when you +assemble the transaction yourself, carrying those ids across is your job: read +the rows you are rewriting with `with_row_id=True` and attach their ids to the +new fragment with `lance.fragment.RowIdSequence`. + +Rows you leave without an id are treated as newly inserted. That is not an error, +so a fragment written without `row_id_meta` commits successfully while silently +giving every rewritten row a fresh identity, breaking `_rowid` for anything +downstream that relies on it. + +You do **not** need to supply `created_at_version_meta` or +`last_updated_at_version_meta`. Leave them as `None`. Lance derives both while +building the manifest: `last_updated_at_version_meta` becomes the version being +committed, and `created_at_version_meta` is copied from whichever existing row +carries the same stable row id, so a rewritten row keeps the version it first +appeared in. + +#### Mixing updated and new rows + +A single fragment may hold both rewritten rows and brand new ones. Order it so +that **the rewritten rows come first and the new rows last**, then pass only the +row ids of the rewritten rows. The row ids bind to the leading rows in fragment +order, and the commit generates new ids for the remaining rows. + +Do not generate ids for the new rows yourself. Row ids are handed out from a counter +in the manifest, and a commit that loses a race is retried against the version +that won, which may have consumed the very ids you picked. Only the commit knows +which values are free, so it assigns them after conflict resolution has settled. +Supplying more row ids than the fragment has rows is rejected. + +```python +import lance +import pyarrow as pa +import pyarrow.compute as pc +from lance.fragment import RowIdSequence, write_fragments + +schema = pa.schema([("id", pa.int64()), ("score", pa.int64())]) +dataset_uri = "./stable_row_ids.lance" +dataset = lance.write_dataset( + pa.table({"id": [1, 2, 3, 4], "score": [85, 90, 75, 80]}, schema=schema), + dataset_uri, + enable_stable_row_ids=True, +) + +# On a worker: read the rows to rewrite, keeping their stable row ids. +rows = dataset.to_table(columns=["id", "score"], with_row_id=True) +rewritten = rows.filter(pc.field("id").isin([2, 3])) + +# Rewritten rows first, then the row that did not exist before. +new_data = pa.table( + { + "id": rewritten["id"].to_pylist() + [5], + "score": [95, 70, 60], + }, + schema=schema, +) +fragments = write_fragments(new_data, dataset_uri, schema=schema) +assert len(fragments) == 1 + +# Only the rewritten rows have ids. The trailing row gets one at commit time. +fragments[0].row_id_meta = RowIdSequence(rewritten["_rowid"]).to_inline_metadata() + +# On the committing worker: tombstone the old copies of the rewritten rows. +updated_fragment = dataset.get_fragments()[0].delete("id in (2, 3)") + +op = lance.LanceOperation.Update( + updated_fragments=[updated_fragment], + new_fragments=fragments, +) +dataset = lance.LanceDataset.commit(dataset_uri, op, read_version=dataset.version) + +print(dataset.to_table(with_row_id=True).to_pandas()) +``` + +Output: +``` + id score _rowid +0 1 85 0 +1 4 80 3 +2 2 95 1 +3 3 70 2 +4 5 60 4 +``` + +Row ids 1 and 2 followed their rows into the new fragment, and the inserted row +received the next unused id. Reading the lineage columns shows that the rewritten +rows kept their original creation version while the inserted row is stamped with +the version that added it: + +```python +print( + dataset.to_table( + columns=["id", "_row_created_at_version", "_row_last_updated_at_version"] + ).to_pandas() +) +``` + +Output: +``` + id _row_created_at_version _row_last_updated_at_version +0 1 1 1 +1 4 1 1 +2 2 1 2 +3 3 1 2 +4 5 2 2 +``` 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. diff --git a/docs/src/guide/migration.md b/docs/src/guide/migration.md index 9b7471ed07c..56d7737941f 100644 --- a/docs/src/guide/migration.md +++ b/docs/src/guide/migration.md @@ -6,6 +6,76 @@ 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. +## 9.0.0 + +* Unless overridden, newly created FTS indexes use format v2. The code analyzer + and `block_size=256` require format v3, so readers must support v3 before an + index using either option is created. `document_granularity="list_element"` + also requires v3 reader capability, independently of the posting format. + +* To keep new indexes readable by nodes that support at most format v1 or v2, + set `format_version` in the index creation parameters, or set + `LANCE_FTS_FORMAT_VERSION` for a rollout-wide override. Formats v1 and v2 + require the text analyzer and `block_size=128`. + +* Operations that maintain an existing FTS index, including append, incremental + indexing, optimize, and mem-wal maintained-index flush, preserve its format + version. + +## 7.2.0 + +* The `IndexSegmentBuilder` API has been removed from Rust, Python, and Java. + This API was deprecated by the distributed indexing flow based on + `create_index_uncommitted`, `merge_existing_index_segments`, and + `commit_existing_index_segments`, but remained in the codebase as a parallel + way to plan and publish staged index segments. + +* Callers should now publish staged segment outputs directly with + `commit_existing_index_segments(...)`. If multiple staged outputs should be + combined into a larger physical segment first, callers should explicitly group + those outputs and call `merge_existing_index_segments(...)` for each group + before committing the final segment list. + +* The old builder's `target_segment_bytes` automatic size-based grouping has no + direct replacement. Distributed index drivers that used it should choose + segment groups themselves, then pass each group to + `merge_existing_index_segments(...)`. + +## 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. @@ -33,4 +103,4 @@ these. Please open an issue if you were relying on any of these. * The `lance-namespace` Rust crate now splits into `lance-namespace` that contains the main `LanceNamespace` trait and data models, and `lance-namespace-impls` that has different implementations of the namespace. The `DirectoryNamespace` and `RestNamespace` interfaces have been refactored to be more user friendly. -The `DirectoryNamespace` also now uses Lance ObjectStore for IO instead of directly depending on Apache OpenDAL. \ No newline at end of file +The `DirectoryNamespace` also now uses Lance ObjectStore for IO instead of directly depending on Apache OpenDAL. diff --git a/docs/src/guide/object_store.md b/docs/src/guide/object_store.md index 21c1addcc62..451c0b804dd 100644 --- a/docs/src/guide/object_store.md +++ b/docs/src/guide/object_store.md @@ -30,16 +30,70 @@ These options apply to all object stores. | Key | Description | |------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | `allow_http` | Allow non-TLS, i.e. non-HTTPS connections. Default, `False`. | -| `download_retry_count` | Number of times to retry a download. Default, `3`. This limit is applied when the HTTP request succeeds but the response is not fully downloaded, typically due to a violation of `request_timeout`. | +| `block_size` | Gap in bytes below which two reads of one file are merged into a single request. Default, `4096` for local files and `65536` for object stores. Larger values cut the request count of scattered reads (for example a `take` of rows spread across a file) at the cost of reading the bytes in between. | +| `download_retry_count` | Number of times to retry a download. Default, `3`. This limit is applied when the HTTP request succeeds but the response is not fully downloaded, typically due to a violation of `timeout`. | | `allow_invalid_certificates` | Skip certificate validation on https connections. Default, `False`. Warning: This is insecure and should only be used for testing. | | `connect_timeout` | Timeout for only the connect phase of a Client. Default, `5s`. | -| `request_timeout` | Timeout for the entire request, from connection until the response body has finished. Default, `30s`. | +| `timeout` | Timeout for the entire request, from connection until the response body has finished. Default, `30s`. This applies to each individual request, so on a large write it must cover one complete multipart part upload; raise it alongside `LANCE_INITIAL_UPLOAD_SIZE`. | | `user_agent` | User agent string to use in requests. | | `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`. | + +### Bulk copy strategy + +Lance streams bulk index-file movement and dataset deep-clone files through +read and write APIs by default. This avoids requiring a provider-native copy +operation and works across different object stores. + +Set `LANCE_IO_SERVER_SIDE_COPY_ENABLED` to a truthy value (`1`, `true`, `on`, +`yes`, or `y`, case-insensitive) to opt cloud copies whose source and destination +share the same object-store client into the provider-native server-side copy +operation. Cross-client, cross-store, and local copies do not use this setting. +Native copy can reduce client bandwidth and transfer cost, but it requires copy +support from the object-store integration and is subject to the provider +request's timeout and retry behavior. + +Deep clone bounds non-local file movement to four concurrent files by default. +Set `LANCE_DEEP_CLONE_STREAM_CONCURRENCY` to a positive integer to override this +operation-specific limit. The bound also applies when server-side copy is +enabled because S3 and GCS copies above the provider's single-copy size limit +fall back to streaming through Lance. + +## Per-Base Configuration + +A dataset can register additional base paths that store part of its data, and each +base may live in a different bucket, account, or storage provider. A storage option +key of the form `base_.` applies `` only to the base path with that +manifest id. Every base inherits the unscoped options; base-scoped entries add to or +override them for that base only. + +```python +import lance +ds = lance.dataset( + "az://account-a/path", + storage_options={ + # Shared defaults, used by the primary dataset and inherited by bases + "account_name": "account-a", + "account_key": "key-a", + # Overrides for the base path with id 1 + "base_1.account_name": "account-b", + "base_1.account_key": "key-b", + }, +) +``` + +Base ids are assigned when bases are registered (`initial_bases` ids are assigned +sequentially starting at 1, in order) and can be inspected with +`ds.base_paths()`. The returned dictionary maps each base id to its registered +`DatasetBasePath`; its iteration order is unspecified, and it does not include the +primary storage unless that path was explicitly registered as a base. + +Keys that do not match `base_.` exactly (e.g. `base_url`) are treated as +regular storage options. Exact per-base parameter maps (`base_store_params`, keyed +by base path URI) take precedence over base-scoped keys for that base. ## S3 Configuration @@ -81,6 +135,38 @@ The following keys can be used as both environment variables or keys in the | `aws_sse_kms_key_id` | The KMS key ID to use for server-side encryption. If set, `aws_server_side_encryption` must be `"aws:kms"` or `"aws:kms:dsse"`. | | `aws_sse_bucket_key_enabled` | Whether to use bucket keys for server-side encryption. | +### Credential provider selection + +By default, Lance uses the standard AWS credential provider chain (environment +variables, shared config file, web identity tokens, ECS, EC2 instance metadata). + +The `aws_provider_scheme` storage option pins a dataset to a specific credential +provider, which is useful when two datasets in the same process need different +AWS auth (for example, one bucket using IRSA and another using ECS container +credentials). + +| Value | Behavior | +|-------|----------| +| `token` | Use static access-key credentials. Returns an error if `aws_access_key_id` and `aws_secret_access_key` are not set. | +| `ecs` | Use the ECS/Pod Identity container credential endpoint. Reads `AWS_CONTAINER_CREDENTIALS_FULL_URI` or `AWS_CONTAINER_CREDENTIALS_RELATIVE_URI` from the environment. | +| `irsa` | Use IRSA (IAM Roles for Service Accounts) web identity token credentials. Reads `AWS_WEB_IDENTITY_TOKEN_FILE` and `AWS_ROLE_ARN` from the environment. | + +```python +import lance + +# Bucket A — use IRSA (web identity token from the environment) +ds_a = lance.dataset( + "s3://bucket-a/path", + storage_options={"aws_provider_scheme": "irsa"}, +) + +# Bucket B — use ECS container credentials +ds_b = lance.dataset( + "s3://bucket-b/path", + storage_options={"aws_provider_scheme": "ecs"}, +) +``` + ### S3-compatible stores Lance can also connect to S3-compatible stores, such as MinIO. To do so, you must @@ -189,4 +275,354 @@ 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. | + +## Volcengine TOS Configuration + +TOS credentials can be set in the environment variables `TOS_ACCESS_KEY_ID`, +`TOS_SECRET_ACCESS_KEY`, `TOS_ENDPOINT`, `TOS_REGION`, and `TOS_SECURITY_TOKEN`. +Lance also accepts the corresponding `VOLCENGINE_` environment variable prefix. +Alternatively, credentials can be passed as parameters to the `storage_options` +parameter; explicit `storage_options` override environment variables: + +```python +import lance +ds = lance.dataset( + "tos://bucket/path", + storage_options={ + "tos_endpoint": "https://tos-cn-beijing.volces.com", + "tos_region": "cn-beijing", + "tos_access_key_id": "my-access-key", + "tos_secret_access_key": "my-secret-key", + "tos_security_token": "my-session-token", + } +) +``` + +| Key | Description | +|-----|-------------| +| `tos_endpoint` | TOS endpoint. Required (for example, `https://tos-cn-beijing.volces.com`). | +| `tos_region` | TOS signing region (for example, `cn-beijing`). Optional. | +| `tos_access_key_id` | Access key ID used for TOS authentication. Optional if credentials are provided by environment. | +| `tos_secret_access_key` | Secret access key used for TOS authentication. Optional if credentials are provided by environment. | +| `tos_security_token` | Security token for temporary credentials. Optional. | + +## Tencent Cloud COS Configuration + +[COS (Cloud Object Storage)](https://cloud.tencent.com/product/cos) credentials can be set in environment variables prefixed +with `COS_` or `TENCENTCLOUD_` (for example, `COS_ENDPOINT`, `COS_SECRET_ID`, +`COS_SECRET_KEY`, `TENCENTCLOUD_REGION`, `TENCENTCLOUD_SECURITY_TOKEN`). +Alternatively, credentials can be passed as parameters to the `storage_options` +parameter; explicit `storage_options` override environment variables: + +=== "Python" + + ```python + import lance + ds = lance.dataset( + "cos://bucket/path", + storage_options={ + "cos_endpoint": "https://cos.ap-guangzhou.myqcloud.com", + "cos_secret_id": "my-secret-id", + "cos_secret_key": "my-secret-key", + } + ) + ``` + +=== "Rust" + + In this Lance distribution, `tencent` is already part of the **default + features** of the `lance` crate, so simply depending on `lance` is enough: + + ```toml + [dependencies] + lance = "*" + ``` + + You only need to enable the `tencent` feature explicitly in the following + cases: + + - You opted out of default features, e.g. + `lance = { version = "*", default-features = false, features = ["tencent", ...] }`. + - You depend on `lance-io` directly (without `lance`); `tencent` is **not** + a default feature of `lance-io`: + `lance-io = { version = "*", features = ["tencent"] }`. + +| Key | Description | +|-----|-------------| +| `cos_endpoint` | COS endpoint. Required (for example, `https://cos.ap-guangzhou.myqcloud.com`). Can also be set via the `COS_ENDPOINT` environment variable. | +| `cos_secret_id` | Secret ID used for COS authentication. Optional if credentials are provided by environment. | +| `cos_secret_key` | Secret key used for COS authentication. Optional if credentials are provided by environment. | +| `cos_enable_versioning` | Whether to enable object versioning on the bucket. Optional. | + +!!! warning + + Tencent COS does not reliably enforce put-if-not-exists on buckets that have + ever had versioning enabled, even if versioning is now suspended. To prevent + silent manifest overwrites, Lance requires a custom distributed commit lock + for COS writes. Pass the same `commit_lock` implementation to every Python + writer, or provide a custom `CommitHandler` in Rust. Reads do not require a + commit lock. + +!!! note + + The OpenDAL `CosConfig` currently exposes a limited set of options. Additional + settings such as the security token (`TENCENTCLOUD_SECURITY_TOKEN`) and region + (`TENCENTCLOUD_REGION`) must be configured via environment variables. + +## Hugging Face Configuration + +Use `hf://datasets///` to read a Lance dataset hosted on +Hugging Face. Pass these options through `storage_options`: + +| Key | Description | +| --- | --- | +| `hf_token` | Hugging Face access token. Falls back to `HF_TOKEN` or `HUGGINGFACE_TOKEN` when omitted. | +| `hf_revision` | Repository revision, such as a commit ID, branch, or tag. Defaults to `main`. | +| `hf_download_mode` | `http` (default) or `xet`. | +| `hf_enable_resolve_cache` | `"true"` reuses resolved HTTP download URLs and XET file metadata across readers. Defaults to `"false"`. | + +These options also accept names without the `hf_` prefix. The prefixed name +takes precedence when both are supplied. `hf_enable_resolve_cache` accepts only +the strings `"true"` and `"false"`. + +Enable the resolve cache only when existing files will not change. Updates, +including changes behind a moving branch or tag, may remain invisible while +cached results are reused. HTTP download URLs refresh near expiry, and issued +URLs may remain usable until expiry after Hub permissions change. The cache +reduces Hub resolution requests; it does not cache file contents. + +```python +import lance + +ds = lance.dataset( + "hf://datasets/owner/repo/data.lance", + storage_options={"hf_enable_resolve_cache": "true"}, +) +``` + +## GooseFS Configuration + +[GooseFS](https://cloud.tencent.com/product/goosefs) is a distributed caching +filesystem. Lance accesses GooseFS through its Master gRPC service. The URL +format is either: + +- `goosefs://host:port/path`, where `host:port` is the GooseFS Master address + (default port: `9200`, may be omitted, e.g. `goosefs://10.0.0.1/path`) +- `goosefs:///path`, with an empty authority, when the master address comes + from `goosefs_master_addr`, `GOOSEFS_MASTER_ADDR`, or + `goosefs-site.properties` + +`/path` is the filesystem path within GooseFS. + +Manifest commits on `goosefs://` use `ConditionalPutCommitHandler` +(`PutMode::Create` / if-not-exists), backed by GooseFS master's atomic +no-replace rename so concurrent writers cannot clobber each other's +versioned manifests. + +!!! warning "Mixed-version writers are NOT safe" + + The `if-not-exists` guarantee only holds when **every** writer for a + dataset routes through this new handler. A writer running an older + Lance release still selects `UnsafeCommitHandler` for `goosefs://` + and writes the version path unconditionally, which can overwrite a + manifest that an upgraded writer has already won. Safe concurrent + commits therefore require: + + - all writers for the dataset run a Lance release that includes this + routing change, **or** + - writers share an external coordination boundary (e.g. a single-writer + queue, table-level lock, or a gateway that serializes commits) that + prevents the old code path from racing the new one. + + When upgrading in place, quiesce all writers (drain jobs, scale + clients to zero, or route traffic through a writer coordinator) before + rolling out the new Lance version, then bring writers back on the new + version together. + +!!! note "About the dataset path" + + `/path` is just an arbitrary directory inside GooseFS — Lance does **not** + require the path to end with a `.lance` suffix. Any valid GooseFS directory + works, for example: + + - `goosefs://10.0.0.1:9200/data/my-dataset` + - `goosefs://10.0.0.1:9200/data/my-dataset.lance` + - `goosefs://10.0.0.1:9200/lance-test/lance-io` + + The `.lance` suffix used in the examples below is only a naming convention + that makes it easy to recognize a Lance dataset directory at a glance; it + has no special meaning to Lance itself. The only requirement is that the + same path is used consistently for reads and writes of a given dataset. + +=== "Python" + + ```python + import lance + + ds = lance.dataset( + "goosefs://10.0.0.1:9200/data/my-dataset.lance", + storage_options={ + "goosefs_auth_type": "simple", + "goosefs_auth_username": "lance", + }, + ) + ``` + +=== "Rust" + + In this Lance distribution, `goosefs` is already part of the **default + features** of the `lance` crate, so simply depending on `lance` is enough: + + ```toml + [dependencies] + lance = "*" + ``` + + You only need to enable the `goosefs` feature explicitly in the following + cases: + + - You opted out of default features, e.g. + `lance = { version = "*", default-features = false, features = ["goosefs", ...] }`. + - You depend on `lance-io` directly (without `lance`); `goosefs` is **not** + a default feature of `lance-io`: + `lance-io = { version = "*", features = ["goosefs"] }`. + + Open the underlying `lance_io::object_store::ObjectStore` directly (mirrors + the integration test in `rust/lance-io/tests/goosefs_integration.rs`): + + ```rust + use lance_io::object_store::ObjectStore; + + let uri = "goosefs://10.0.0.1:9200/lance-test/lance-io"; + let (store, path) = ObjectStore::from_uri(uri).await?; + + // Read / write through the underlying `object_store::ObjectStore` API + store.inner.put(&path, (&b"hello"[..]).into()).await?; + let result = store.inner.get(&path).await?; + let bytes = result.bytes().await?; + ``` + + Open a Lance dataset with custom storage options: + + ```rust + use std::collections::HashMap; + use lance::dataset::DatasetBuilder; + + let mut storage_options = HashMap::new(); + storage_options.insert("goosefs_master_addr".to_string(), "10.0.0.1:9200".to_string()); + storage_options.insert("goosefs_auth_type".to_string(), "simple".to_string()); + storage_options.insert("goosefs_auth_username".to_string(), "lance".to_string()); + + let dataset = DatasetBuilder::from_uri("goosefs://10.0.0.1:9200/data/my-dataset.lance") + .with_storage_options(storage_options) + .load() + .await?; + ``` + +=== "Java" + + Pass the GooseFS configuration through `ReadOptions.setStorageOptions` + when opening the dataset: + + ```java + import org.lance.Dataset; + import org.lance.ReadOptions; + + import java.util.HashMap; + import java.util.Map; + + Map storageOptions = new HashMap<>(); + storageOptions.put("goosefs_master_addr", "10.0.0.1:9200"); + storageOptions.put("goosefs_auth_type", "simple"); + storageOptions.put("goosefs_auth_username", "lance"); + + ReadOptions options = new ReadOptions.Builder() + .setStorageOptions(storageOptions) + .build(); + + try (Dataset dataset = Dataset.open() + .uri("goosefs://10.0.0.1:9200/data/my-dataset.lance") + .readOptions(options) + .build()) { + // ... use the dataset + } + ``` + + For writes, the same `storageOptions(...)` setter is available on + `WriteDatasetBuilder` and `WriteFragmentBuilder`. + +The Master address is resolved at OpenDAL build time (highest priority first): + +1. The `GOOSEFS_MASTER_ADDR` environment variable. +2. `goosefs.master.rpc.addresses` or `goosefs.master.hostname` in + `goosefs-site.properties` (discovered via `$GOOSEFS_CONFIG_FILE`, + `$GOOSEFS_CONF_DIR`, `$GOOSEFS_HOME/conf`, `~/.goosefs`, and + `/etc/goosefs`). This is the same file the GooseFS SDK already loads; + a Hadoop-style `goosefs:///path` URL relies on it. +3. The `goosefs_master_addr` storage option (supports HA: + `"addr1:port,addr2:port"`). +4. The host and port from the URL authority. + +Lance forwards `goosefs_master_addr` and the URL authority as OpenDAL's +`master_addr`. It does **not** require a host in the URL, so +`goosefs:///path` can rely on `goosefs-site.properties`. OpenDAL fails the +store build only when none of the sources above supply an address. + +A site file that declares masters outranks the URL authority because the file +can carry a full HA master list, which a single URI host cannot express. + +`storage_options` keys **must be lowercase**. Uppercase or mixed-case spellings +such as `GOOSEFS_MASTER_ADDR` are rejected with an explicit error — they are +not ignored, and they are not treated as the matching environment variable. +Environment variables keep the `GOOSEFS_*` form. + +| storage_options key | env var | Description | +|---------------------|---------|-------------| +| `goosefs_master_addr` | `GOOSEFS_MASTER_ADDR` | GooseFS Master address. Supports a single address (`host:port`) or comma-separated HA addresses (`addr1:port,addr2:port`). Optional if the address is provided in the URL, `GOOSEFS_MASTER_ADDR`, or `goosefs-site.properties`. | +| `goosefs_write_type` | `GOOSEFS_WRITE_TYPE` | Write type, e.g. `MUST_CACHE`, `CACHE_THROUGH`, `THROUGH`, `ASYNC_THROUGH`. Optional. | +| `goosefs_block_size` | `GOOSEFS_BLOCK_SIZE` | GooseFS block size (this is the GooseFS-side block size, not Lance's I/O block size). Accepts a raw byte count or GooseFS suffixes such as `64MB` (binary units: `1KB = 1024`). Optional. | +| `goosefs_chunk_size` | `GOOSEFS_CHUNK_SIZE` | Chunk size used when reading or writing files. Accepts a raw byte count or GooseFS suffixes such as `4MB` (binary units: `1KB = 1024`). Optional. | +| `goosefs_auth_type` | `GOOSEFS_AUTH_TYPE` | Authentication type. Either `nosasl` or `simple` (case-insensitive; the value is passed through to OpenDAL). Optional. | +| `goosefs_auth_username` | `GOOSEFS_AUTH_USERNAME` | Username used in `simple` authentication mode. Optional. | + +!!! note "Running the GooseFS integration tests" + + The Rust integration tests for GooseFS live at + `rust/lance-io/tests/goosefs_integration.rs` and are gated behind feature + flags. They require a reachable GooseFS cluster (configured via the + `GOOSEFS_MASTER_ADDR` and `GOOSEFS_AUTH_TYPE` environment variables) and + can be run with: + + ```bash + cargo test -p lance-io --features "goosefs goosefs-test" \ + --test goosefs_integration -- --ignored --nocapture --test-threads=1 + ``` diff --git a/docs/src/guide/observability.md b/docs/src/guide/observability.md new file mode 100644 index 00000000000..9cec3154e82 --- /dev/null +++ b/docs/src/guide/observability.md @@ -0,0 +1,100 @@ +# Observability + +Lance can publish operational metrics to your monitoring stack. The table below +is the authoritative catalogue of the metrics Lance emits, shared verbatim with +the Rust [`lance::metrics`](https://github.com/lance-format/lance/blob/main/rust/lance/src/metrics.md) +module documentation. + +--8<-- "rust/lance/src/metrics.md" + +## Collecting metrics + +Lance emits through the [`metrics`](https://docs.rs/metrics) crate facade, so it +is not tied to a specific backend — you install a recorder/exporter and route +the metrics wherever you like. Metrics are available from the Rust, Python, and +Java APIs. + +### Rust + +Enable the `metrics` feature on the `lance` crate: + +```toml +lance = { version = "...", features = ["metrics"] } +``` + +Then install any `metrics`-compatible recorder once at startup, before opening +datasets. For example, with +[`metrics-exporter-prometheus`](https://docs.rs/metrics-exporter-prometheus): + +```rust +metrics_exporter_prometheus::PrometheusBuilder::new() + .install() + .expect("install Prometheus recorder"); +``` + +Any recorder works — Prometheus, StatsD, an OpenTelemetry bridge, and so on. +When no recorder is installed, emission is a cheap no-op. + +### Python + +Unlike Rust, the Python bindings do not let you plug in an arbitrary recorder: +bridging one across the FFI boundary into the Rust `metrics` facade would be +complicated and inefficient. Instead `pylance` standardizes on OpenTelemetry, +which has good Python support, as its recorder. + +The `pylance` wheels are built with the `metrics` feature enabled. Install the +OpenTelemetry extra and call `instrument_lance_metrics`, which registers Lance's +metrics as observable instruments on your OpenTelemetry `MeterProvider`: + +```bash +pip install "pylance[otel]" +``` + +```python +from lance.otel import instrument_lance_metrics + +# Uses the global MeterProvider; pass meter_provider=... to target a specific one. +instrument_lance_metrics() +``` + +### Java + +The Java SDK includes an OpenTelemetry bridge in `org.lance.otel`. Register it +before opening datasets or performing Lance IO so the process-global Rust +recorder sees every emitted metric: + +```java +import org.lance.otel.LanceMetrics; + +LanceMetrics.instrument(); +``` + +The OpenTelemetry API is a dependency of the Java SDK. The application must +still configure an OpenTelemetry SDK, metric reader, and exporter for collection +and delivery. + +The no-argument method uses OpenTelemetry's global `MeterProvider`. To register +with an explicitly configured provider, pass it directly: + +```java +SdkMeterProvider provider = SdkMeterProvider.builder() + .registerMetricReader(metricReader) + .build(); +LanceMetrics.instrument(provider); +``` + +Repeated calls with the same provider are idempotent. Passing a different +provider unregisters the existing callback before registering the new one. Call +`LanceMetrics.close()` to stop exporting while retaining the process-global Rust +metric state. + +From there the metrics flow through whatever OpenTelemetry pipeline you have +configured (OTLP, Prometheus, console, …). Because OpenTelemetry has no +asynchronous histogram instrument, histograms are exported Prometheus-style as +three observable counters: `_bucket`, `_count`, and `_sum`. +Each `_bucket` sample carries an `le` ("less than or equal") attribute +giving that bucket's inclusive upper bound in the metric's unit; the bucket +count is cumulative, covering every observation at or below `le`. For example, a +`lance_object_store_request_duration_seconds_bucket` sample with `le="0.5"` +counts all requests that completed in 0.5 seconds or less, while `le="+Inf"` is +the total count. diff --git a/docs/src/guide/performance.md b/docs/src/guide/performance.md index a23363b1115..11359cfff6b 100644 --- a/docs/src/guide/performance.md +++ b/docs/src/guide/performance.md @@ -4,10 +4,11 @@ This guide provides tips and tricks for optimizing the performance of your Lance ## Logging -Lance uses the `log` crate to log messages. Displaying these log messages will depend on the client -library you are using. For rust, you will need to configure a logging subscriber. For more details -ses the [log](https://docs.rs/log/latest/log/) docs. The Python and Java clients configure a default -logging subscriber that logs to stderr. +Lance emits log messages through the `log` crate and structured events through the `tracing` crate. +Rust applications configure their own logger and tracing subscriber, including filtering and output +destinations; the Rust library does not install either globally. See the +[log](https://docs.rs/log/latest/log/) and [tracing](https://docs.rs/tracing/latest/tracing/) docs. +The Python and Java clients configure a default logger that logs to stderr. The Python/Java logger can be configured with several environment variables: @@ -21,9 +22,14 @@ The Python/Java logger can be configured with several environment variables: ## Trace Events -Lance uses tracing to log events. If you are running `pylance` then these events will be emitted to +Lance uses tracing to log events. If you are running `pylance` then these events will be emitted as log messages. For Rust connections you can use the `tracing` crate to capture these events. +Rust tracing targets are listed below. In `pylance` logs, trace events are emitted under a +`lance::events::` prefix so they can be filtered separately from normal log records. For example, +`LANCE_LOG="warn,lance::events::object_store::throttle=info"` shows storage throttling events +without enabling other Lance event logs. + ### File Audit File audit events are emitted when significant files are created or deleted. @@ -33,6 +39,31 @@ File audit events are emitted when significant files are created or deleted. | `lance::file_audit` | `mode` | The mode of I/O operation (create, delete, delete_unverified) | | `lance::file_audit` | `type` | The type of file affected (manifest, data file, index file, deletion file) | +### Dataset Events + +Dataset events are emitted when datasets are loaded, written, committed, deleted, compacted, or cleaned. + +| Event | Parameter | Description | +| ----------------------- | ----------- | ------------------------------------------------------------------------- | +| `lance::dataset_events` | `event` | The dataset event type (loading, writing, committed, deleting, and others) | +| `lance::dataset_events` | `uri` | The dataset URI | +| `lance::dataset_events` | `mode` | The write mode | +| `lance::dataset_events` | `operation` | The committed transaction operation | +| `lance::dataset_events` | `predicate` | The delete predicate | +| `lance::dataset_events` | `columns` | The removed columns | + +### Object Store Throttle Events + +Object store throttle events are emitted when Lance observes cloud storage throttle responses and +reduces or retries request rates. + +| Event | Parameter | Description | +| -------------------------------- | --------------- | ---------------------------------------- | +| `lance::object_store::throttle` | `previous_rate` | The request rate before AIMD adjustment | +| `lance::object_store::throttle` | `new_rate` | The request rate after AIMD adjustment | +| `lance::object_store::throttle` | `attempt` | The retry attempt for retry debug events | +| `lance::object_store::throttle` | `error` | The underlying object store throttle error | + ### I/O Events I/O events are emitted when significant I/O operations are performed, particularly @@ -64,7 +95,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/index.md#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 @@ -111,7 +142,7 @@ Keys are often a composite of multiple fields and all keys are scoped to the dat | Deletion Files | Dataset URI, fragment_id, version, id, file_type | The deletion vector for a frag | | Row Id Mask | Dataset URI, version | The row id sequence for the dataset | | Row Id Index | Dataset URI, version | The row id index for the dataset | -| Row Id Sequence | Dataset URI, fragment_id | The row id sequence for a fragment | +| Row Id Sequence | Dataset URI, fragment_id, row_id_meta | The row id sequence for a fragment | | Index Metadata | Dataset URI, version | The index metadata for the dataset | | Index Details¹ | Dataset URI, index uuid | The index details for an index | | File Global Meta | Dataset URI, file path | The global metadata for a file | @@ -159,15 +190,150 @@ working with 1024-dimensional vector embeddings (e.g. 32-bit floats) then 8192 r spread that across 16 CPU threads then you would need 512MB of compute memory per scan. You might find working with 1024 rows per batch is more appropriate. +#### Tuning remote scans + +An ordered dataset scan still overlaps I/O from multiple fragments. `scan_in_order=True` controls the order in +which batches are returned; it does not make fragment reads sequential. This is why a dataset scan can issue +more concurrent requests than scanning one fragment directly. The following controls tune different parts of +the scan: + +* `fragment_readahead` limits how many fragments may have reads scheduled concurrently. Set it to `1` to match + the fragment-level I/O pattern, then increase it if the storage connection has spare bandwidth. +* `LANCE_IO_THREADS` limits concurrent storage requests for the process. Cloud stores default to 64, which is + intended for high-bandwidth, in-region access and can be too aggressive across regions or over the public + internet. +* `io_buffer_size` limits buffered I/O bytes and applies backpressure when decoding falls behind. +* `batch_readahead` limits concurrent batch decoding. It does not control the size of storage range requests. + +For a bandwidth-constrained remote connection, start with conservative settings and tune upward: + +```shell +LANCE_IO_THREADS=8 python scan.py +``` + +```python +scanner = dataset.scanner( + fragment_readahead=1, + batch_readahead=2, + io_buffer_size=64 * 1024 * 1024, +) +for batch in scanner.to_batches(): + process(batch) +``` + +Lance reads encoded pages from storage, so reducing `batch_size` changes the returned and decoded batch sizes +but may not reduce the initial range request. The first batch can require loading one encoded page for each +selected column. + In summary, scans could use up to `(2 * io_buffer_size) + (batch_size * num_compute_threads)` bytes of memory. 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. + +## Fragment Sizing + +A Lance table is a collection of fragments tracked by a manifest. How you size those fragments +trades off two classes of work: + +- **Manifest-level operations** scale with the *number* of fragments. Every dataset mutation + (appends, metadata updates, schema changes, compactions, etc.) rewrites the manifest, so a + larger fragment list makes every write slower. Reads pay a similar cost up front: opening a + dataset, listing fragments, planning a scan, and resolving transaction conflicts at the + dataset level all walk the manifest. +- **Fragment-level operations** scale with the *size* of a fragment. These include scans + against a matching fragment, compaction, updates, deletes, and `merge_insert`. Conflict + detection for these operations is also done at the fragment level. + +Fewer, larger fragments make manifest-level operations cheap but make each fragment-level +operation heavier and increase the chance of conflicts when many writers target the same +fragment. More, smaller fragments do the reverse. + +Practical guidance: + +- The default of 1M rows per fragment works well up to ~1B rows. Past that, bumping toward + ~100M rows per fragment is reasonable, though fragment-count limits are rarely the bottleneck + in practice. +- Tens of thousands of fragments per table is generally fine. +- Keep individual fragments well under object-store object-size limits (S3 caps at 5 TB, and + stores tend to misbehave well before that). 10 GB–100 GB per fragment is a reasonable upper + range; 1 TB is a hard ceiling. +- If you run many concurrent updates, deletes, or `merge_insert` operations, err toward more + fragments — conflict detection is per-fragment, so too few fragments leads to excess + retries. + +## 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) +``` + +Rust callers can open the FRI for the dataset version they have loaded with +`Dataset::frag_reuse_index()`, which returns `None` when that version has no FRI. The returned +index exposes the raw remap: a physical row address is either unmapped, deleted by a recorded +compaction, or mapped to the last address reached through the retained mappings. Neither outcome +is validated against the loaded manifest. Unmapped addresses may still have moved in a compaction +whose history was trimmed, and mapped destinations may since have been removed, so callers that +need a complete translation must verify coverage and destinations themselves. + +For details on the index format and usage patterns, see the +[Fragment Reuse Index specification](../format/index/system/frag_reuse.md). ## Indexes @@ -239,3 +405,136 @@ 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):** New indexes default to 5 bits per dimension. Every bit width stores a 1-bit sign +code plus three 4-byte correction factors. Multi-bit indexes also store the remaining bits in +64-dimension-padded blocks and two additional 4-byte correction factors. Including the 8-byte row +ID, the approximate size per row is: + +- 1-bit: `dimension / 8 + 20` bytes +- Multi-bit: `dimension / 8 + round_up(dimension, 64) * (num_bits - 1) / 8 + 28` bytes + +For example, the default 5-bit index for 100M rows with 768 dimensions requires: + +``` +100M * (768 / 8 + 768 * 4 / 8 + 28) = ~47.3 GiB +``` + +The 5-bit default retains more information for the higher-fidelity distance estimates used by +`Normal` and `Accurate` search modes, at the cost of more quantization work and index I/O during +the build and a larger index. `Fast` search mode uses only the 1-bit sign code even when the index +stores additional bits. Set `num_bits=1` explicitly to minimize index size and build I/O; the same +100M-row example uses about 10.8 GiB, but searches cannot use the multi-bit distance estimate and +may have lower recall. + +#### AMX Acceleration + +On Linux x86_64 with an AMX-FP16 CPU (Intel Granite Rapids / Xeon 6 and newer), a `float16` +vector column indexed with `dot` distance uses the AMX tile instructions, provided the build +machine had clang >= 16 or gcc >= 13 to compile the kernel. There is nothing to enable — +Lance checks the CPU at run time and falls back to the previous implementation everywhere else. + +The accelerated paths are also shape-gated, because below these sizes a tile pass costs more +than it saves and the kernel declines the work: + +| Condition | Why | +|---|---| +| `float16` vectors, `dot` distance | The kernel is fp16-specific; other types and metrics keep their existing paths | +| `dimension >= 32` | One tile pass covers 32 dimensions; a shorter vector would be all scalar cleanup | +| `num_centroids >= 32` | The GEMM steps its centroid loop by 32 and has no partial-tile path | + +Anything outside them behaves exactly as it does today, so a small dataset or a low-dimensional +column simply keeps the previous implementation rather than changing behaviour. + +Index build also changes algorithm where all of the above hold: comparing every vector against +every centroid becomes affordable, so partition assignment is exact instead of approximated with +a graph search over the centroids. Recall improves, and partition assignments differ from what an +older build produced. + +Set `LANCE_DISABLE_AMX=1` to take the AMX paths out of service without rebuilding — for +A/B measurement, or to get the previous behaviour back. Because it also moves partition +assignment back to the approximate path, an index built with it set is not equivalent to one +built without it; compare recall, not just build time. diff --git a/docs/src/guide/read_and_write.md b/docs/src/guide/read_and_write.md index cbfb65a1e4c..40062b6ce33 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}]) @@ -38,6 +40,66 @@ print(ds.count_rows()) # Output: 2 `lance.write_dataset` supports writing `pyarrow.Table`, `pandas.DataFrame`, `pyarrow.dataset.Dataset`, and `Iterator[pyarrow.RecordBatch]`. +## Choosing a data file version + +`data_storage_version` selects the format of newly written data files. For an +existing V2 dataset, an operation can select `"2.0"`, `"2.1"`, `"2.2"`, or `"2.3"` +without rewriting the other files. The dataset's `data_storage_version` property +is the default for writes that omit a target, not a summary of its existing +files. Create and overwrite establish this default; append, update, merge-insert, +and compaction do not change it. V1 and V2 cannot be mixed. + +```python +import lance +import pyarrow as pa + +data = pa.table({"id": [1, 2], "value": [10, 20]}) +ds = lance.write_dataset(data, "./versions.lance", data_storage_version="2.1") +ds.update({"value": "value + 1"}, data_storage_version="2.2") +assert ds.data_storage_version == "2.1" + +ds.merge_insert("id").when_not_matched_insert_all().data_storage_version("2.2").execute( + pa.table({"id": [3], "value": [30]}) +) +ds.optimize.compact_files(data_storage_version="2.2") +``` + +The `"stable"` and `"next"` selectors resolve according to the engine release. +Use exact versions when the output identity must be independent of that release. +V2.3 is currently unstable: files written by one unstable revision may not be +readable by a later revision. Use it only for experimentation. +Compaction plans fix the target before distributing tasks, including when the +target comes from the dataset default. The target survives Python pickle and +Java serialization; workers do not reinterpret it using their own release defaults. + +Java update, merge-insert, and compaction options use +`withDataStorageVersion(DataStorageVersion.V2_2)`. The shared `DataStorageVersion` +enum also provides `STABLE` and `NEXT` selectors. + +Compaction can convert selected fragments to another supported V2 version. +Binary copy requires every selected file to match the output version, as well +as the usual eligibility checks. In particular, overlays need reencoding to +preserve updated values. `try_binary_copy` falls back to reencoding when inputs +are ineligible; `force_binary_copy` rejects them. A version mismatch error includes +the target version, actual version, and file path. A persistent compaction target +can be set through `lance.compaction.data_storage_version` in the table config; +an explicit operation target takes precedence. + +### Upgrading clients before mixed-version writes + +Before writing files that differ from the dataset default, upgrade every reader +and writer to a mixed-version-aware release. Drain, restart, or fence writers +that opened the dataset using an older release. The commit automatically sets +the paired mixed-version reader/writer feature flags when needed; there is no +separate activation API. These flags remain set even if compaction later makes +the files homogeneous again. + +Older clients that do not recognize the flags reject the resulting snapshots. +The flags cannot retroactively fence an older writer that already read a prior +manifest. Historical homogeneous datasets remain readable by clients that +support their file versions; see [table versioning](../format/table/versioning.md) +for the feature flag contract. + ## Adding Rows To insert data into your dataset, you can use either `LanceDataset.insert` @@ -454,3 +516,146 @@ affected files are no longer part of any ANN index if they were before. Because of this, it's recommended to rewrite files before re-building indices. + +### Cleanup old versions + +Lance is an immutable format — every write creates a new version. The new version +only writes the data that changed, so an insert writes the new rows and an update +rewrites the affected columns for the affected rows. Even a delete creates a +small deletion file. However, old versions still reference the previous data +files, so those files are kept on disk until explicitly removed. Over time this +means storage grows with each operation — inserts, updates, and deletes alike. + +Keeping old versions has important benefits: readers that opened an older version +can continue reading it without interference from concurrent writers, providing +snapshot isolation. Old versions also enable time travel queries, letting you +read the dataset as it existed at any prior point in time. + +`cleanup_old_versions` deletes old version metadata and any data files that are +no longer referenced by any version, reclaiming the accumulated storage. + +!!! warning + + Once old versions are cleaned up, time travel queries to those versions are + no longer possible. Choose your retention window (`older_than`) accordingly — + any version removed by cleanup cannot be recovered. + +```python +import lance + +dataset = lance.dataset("./my_dataset.lance") +dataset.cleanup_old_versions() +``` + +By default, versions older than 7 days are removed. You can override this with +the `older_than` parameter (a `timedelta`): + +```python +from datetime import timedelta + +dataset.cleanup_old_versions(older_than=timedelta(days=1)) +``` + +!!! note + + Tagged versions are exempt from cleanup. See [Tags and Branches](tags_and_branches.md) + for details. + +By default, Lance only removes files that it can **verify** are no longer needed. +A file is verified when Lance can see that it was referenced by an older version +and is no longer referenced by any newer version. However, some orphaned files +cannot be verified this way — for example, files left behind by aborted or failed +commits that were never recorded in any version. These files are +indistinguishable from files being written by an in-progress operation. + +Cleanup will never delete the current (active) version. This means passing +`older_than=timedelta(0)` is safe and will delete all versions except the current +one. + +The `delete_unverified` flag enables a more aggressive strategy that will also +delete these unverified files: + +```python +dataset.cleanup_old_versions( + older_than=timedelta(hours=2), + delete_unverified=True, +) +``` + +!!! danger + + Only use `delete_unverified=True` when you are confident that no other + concurrent operation has been in-progress for longer than the `older_than` + duration. Lance uses the file's age to decide whether an unverified file is + safe to remove, so any operation that is still running past the `older_than` + window risks having its files deleted out from under it. + + In particular, combining `delete_unverified=True` with `older_than=timedelta(0)` + is **extremely dangerous** — if any other operation is in-progress at all, + its data files may be deleted, leading to dataset corruption. + +### Automatic cleanup + +Instead of calling `cleanup_old_versions` manually, you can configure Lance to +clean up old versions automatically during writes. When auto cleanup is enabled, +Lance will run cleanup every *N* commits (the **interval**), removing versions +older than a specified duration. + +Auto cleanup can be enabled when creating a new dataset: + +```python +import lance +import pyarrow as pa +from lance.dataset import AutoCleanupConfig + +table = pa.table({"id": range(100)}) +ds = lance.write_dataset( + table, + "./my_dataset.lance", + auto_cleanup_options=AutoCleanupConfig( + interval=20, # run cleanup every 20 commits + older_than_seconds=3600, # remove versions older than 1 hour + ), +) +``` + +Or enabled on an existing dataset: + +```python +ds = lance.dataset("./my_dataset.lance") +ds.optimize.enable_auto_cleanup( + AutoCleanupConfig( + interval=20, + older_than_seconds=3600, + ) +) +``` + +And disabled again: + +```python +ds.optimize.disable_auto_cleanup() +``` + +Auto cleanup parameters can also be set directly via dataset config keys: + +```python +ds.update_config({ + "lance.auto_cleanup.interval": "20", + "lance.auto_cleanup.older_than": "3600s", +}) +``` + +!!! warning + + Auto cleanup runs as part of the commit path. If your writer does not have + delete permissions, or you are doing high-frequency writes where the extra + latency matters, pass `skip_auto_cleanup=True` to `write_dataset` to skip it + on a per-write basis. + +### Other cleanup strategies + +It is common to run cleanup as a periodic background task on a dedicated server +(for example, via a cron job or scheduled workflow). This keeps cleanup off the +write path entirely, avoiding any impact to write latency, but requires setting +up and maintaining additional infrastructure. diff --git a/docs/src/guide/tags.md b/docs/src/guide/tags.md deleted file mode 100644 index 62dec1a15d7..00000000000 --- a/docs/src/guide/tags.md +++ /dev/null @@ -1,51 +0,0 @@ -# Manage Tags - -Lance, much like Git, employs the `LanceDataset.tags` -property to label specific versions within a dataset's history. - -`Tags` are particularly useful for tracking the evolution of datasets, -especially in machine learning workflows where datasets are frequently updated. -For example, you can `create`, `update`, -and `delete` or `list` tags. - -!!! note - - Creating or deleting tags does not generate new dataset versions. - Tags exist as auxiliary metadata stored in a separate directory. - -```python -import lance -ds = lance.dataset("./tags.lance") -print(len(ds.versions())) -# 2 -print(ds.tags.list()) -# {} -ds.tags.create("v1-prod", 1) -print(ds.tags.list()) -# {'v1-prod': {'version': 1, 'manifest_size': ...}} -ds.tags.update("v1-prod", 2) -print(ds.tags.list()) -# {'v1-prod': {'version': 2, 'manifest_size': ...}} -ds.tags.delete("v1-prod") -print(ds.tags.list()) -# {} -print(ds.tags.list_ordered()) -# [] -ds.tags.create("v1-prod", 1) -print(ds.tags.list_ordered()) -# [('v1-prod', {'version': 1, 'manifest_size': ...})] -ds.tags.update("v1-prod", 2) -print(ds.tags.list_ordered()) -# [('v1-prod', {'version': 2, 'manifest_size': ...})] -ds.tags.delete("v1-prod") -print(ds.tags.list_ordered()) -# [] -``` - -!!! note - - Tagged versions are exempted from the `LanceDataset.cleanup_old_versions()` - process. - - To remove a version that has been tagged, you must first `LanceDataset.tags.delete()` - the associated tag. \ No newline at end of file diff --git a/docs/src/guide/tags_and_branches.md b/docs/src/guide/tags_and_branches.md new file mode 100644 index 00000000000..50df8707281 --- /dev/null +++ b/docs/src/guide/tags_and_branches.md @@ -0,0 +1,129 @@ +# Manage Tags and Branches + +Lance provides Git-like tag and branch capabilities through the `LanceDataset.tags` and `LanceDataset.branches` properties. + +## Tags +Tags label specific versions within a branch's history. + +`Tags` are particularly useful for tracking the evolution of datasets, +especially in machine learning workflows where datasets are frequently updated. +For example, you can `create`, `update`, +and `delete` or `list` tags. + +The `reference` parameter (used in `create`, `update`, and `checkout_version`) accepts: + +- An **integer**: version number in the **current branch** (e.g., `1`) +- A **string**: tag name (e.g., `"stable"`) +- A **tuple** `(branch_name, version)`: a specific version in a named branch + - `(None, 2)` means version 2 on the default branch + - `("main", 2)` means version 2 on the default branch (explicit) + - `("experiment", 3)` means version 3 on the experiment branch + - `("branch-name", None)` means the latest version on that branch + +In reference contexts, `"main"` is an alias for the default branch and is equivalent to `None`. + +!!! note + + Creating or deleting tags does not generate new dataset versions. + Tags exist as auxiliary metadata stored in a separate directory. + +```python +import lance +import pyarrow as pa + +ds = lance.dataset("./tags.lance") +print(len(ds.versions())) +# 2 +print(ds.tags.list()) +# {} +ds.tags.create("v1-prod", (None, 1)) +print(ds.tags.list()) +# {'v1-prod': {'version': 1, 'created_at': ..., 'updated_at': ..., 'manifest_size': ...}} +ds.tags.update("v1-prod", (None, 2)) +print(ds.tags.list()) +# {'v1-prod': {'version': 2, 'created_at': ..., 'updated_at': ..., 'manifest_size': ...}} +ds.tags.delete("v1-prod") +print(ds.tags.list()) +# {} +print(ds.tags.list_ordered()) +# [] +ds.tags.create("v1-prod", (None, 1)) +print(ds.tags.list_ordered()) +# [('v1-prod', {'version': 1, 'created_at': ..., 'updated_at': ..., 'manifest_size': ...})] +ds.tags.update("v1-prod", (None, 2)) +print(ds.tags.list_ordered()) +# [('v1-prod', {'version': 2, 'created_at': ..., 'updated_at': ..., 'manifest_size': ...})] +ds.tags.delete("v1-prod") +print(ds.tags.list_ordered()) +# [] +``` + +!!! note + + Tagged versions are exempted from the `LanceDataset.cleanup_old_versions()` + process. + + To remove a version that has been tagged, you must first `LanceDataset.tags.delete()` + the associated tag. + +## Branches + +Branches manage parallel lines of dataset evolution. You can create a branch from an existing version or tag, read and write to it independently, and checkout different branches. You can `create`, `delete`, `list`, and `checkout` branches. + +The `reference` parameter works the same as for Tags (see above). + +!!! note + + Creating or deleting branches does not generate new dataset versions. + New versions are created by writes (append/overwrite/index operations). + + Each branch maintains its own linear version history, so version numbers may overlap across branches. Use `(branch_name, version_number)` tuples as global identifiers for operations like `checkout_version` and `tags.create`. + + `"main"` is reserved for the default branch. Use `"main"` or `None` when referring to the default branch in reference tuples or checkout APIs, but choose a different name when creating, deleting, or updating branches. + +### Create and checkout branches +```python +import lance +import pyarrow as pa + +# Open dataset +ds = lance.dataset("/tmp/test.lance") + +# Create branch from the currently checked-out version +experiment_branch = ds.create_branch("experiment") +experimental_data = pa.Table.from_pydict({"a": [11], "b": [12]}) +lance.write_dataset(experimental_data, experiment_branch, mode="append") + +# Create tag on the latest version of the experimental branch +ds.tags.create("experiment-rc", ("experiment", None)) + +# Checkout by tag name +experiment_rc = ds.checkout_version("experiment-rc") +# Checkout the latest version of the experimental branch by tuple +experiment_latest = ds.checkout_version(("experiment", None)) +# Checkout the latest version of the default branch explicitly +main_latest = ds.checkout_version(("main", None)) + +# Create a new branch from a tag +new_experiment = ds.create_branch("new-experiment", "experiment-rc") +``` + +### List branches +```python +print(ds.branches.list()) +# {'experiment': {...}, 'new-experiment': {...}} +``` + +### Delete a branch +```python +# Ensure the branch is no longer needed before deletion +ds.branches.delete("experiment") +print(ds.branches.list_ordered(order="desc")) +# {'new-experiment': {'parent_branch': 'experiment', 'parent_version': 2, 'create_at': ..., 'manifest_size': ...}, ...} +``` + +!!! note + + Branches hold references to data files. Lance ensures that cleanup does not delete files still referenced by any branch. + + Delete unused branches to allow their referenced files to be cleaned up by `cleanup_old_versions()`. diff --git a/docs/src/guide/tokenizer.md b/docs/src/guide/tokenizer.md index f4f64df3abb..0d3605df30b 100644 --- a/docs/src/guide/tokenizer.md +++ b/docs/src/guide/tokenizer.md @@ -1,6 +1,6 @@ # Tokenizers -Currently, Lance has built-in support for Jieba and Lindera. However, it doesn't come with its own language models. +Currently, Lance has built-in support for ICU, Jieba, and Lindera. ICU uses built-in segmenter data. Jieba and Lindera require external language models. If tokenization is needed, you can download language models by yourself. You can specify the location where the language models are stored by setting the environment variable LANCE_LANGUAGE_MODEL_HOME. If it's not set, the default value is @@ -12,6 +12,53 @@ ${system data directory}/lance/language_models It also supports configuring user dictionaries, which makes it convenient for users to expand their own dictionaries without retraining the language models. +## Inspect Query Tokenization + +Use `lance.tokenize` to inspect the tokens that a full-text query will produce +without creating a dataset or index: + +```python +import lance + +tokens = lance.tokenize("the Cats and Dogs") +[(token.text, token.position) for token in tokens] +# [("cat", 0), ("dog", 2)] +``` + +Positions start at the first retained query token and preserve gaps left by stop +word removal and other filters. This is the same representation used for phrase +matching. The function accepts the tokenizer-related options supported by +`LanceDataset.create_scalar_index`, including custom stop words, n-grams, and the +code analyzer: + +```python +tokens = lance.tokenize( + "getUserName::value42", + analyzer="code", + split_identifiers=True, + index_operators=True, +) +``` + +Options set to `None` use the selected analyzer profile's default. For example, +the code analyzer disables stemming and stop-word removal unless explicitly +overridden. The exception is `max_token_length`: omitting it keeps the default +length limit of 40, while `max_token_length=None` disables the limit. + +## ICU Tokenizer + +ICU uses Unicode word boundary rules and bundled dictionary data for complex scripts. It is useful for mixed-language text and does not require downloading a language model. + +```python +ds.create_scalar_index("text", "INVERTED", base_tokenizer="icu") +``` + +Use `icu/split` when mixed-language text also contains punctuation-delimited identifiers that should be searchable by part. + +```python +ds.create_scalar_index("text", "INVERTED", base_tokenizer="icu/split") +``` + ## Language Models of Jieba ### Downloading the Model @@ -40,7 +87,7 @@ Create a file named config.json in the root directory of the current model. ``` - The "main" field is optional. If not filled, the default is "dict.txt". -- "users" is the path of the user dictionary. For the format of the user dictionary, please refer to https://github.com/messense/jieba-rs/blob/main/src/data/dict.txt. +- "users" is the path of the user dictionary. For the format of the user dictionary, please refer to https://github.com/messense/jieba-rs/blob/main/jieba/src/data/dict.txt. ## Language Models of Lindera @@ -69,11 +116,9 @@ For more detailed configuration methods, see the lindera documentation at https: ```yaml segmenter: mode: "normal" - dictionary: - # Note: in lance, the `kind` field is not supported. You need to specify the model path using the `path` field instead. - path: /path/to/lindera/ipadic/main + dictionary: /path/to/lindera/ipadic/main ``` ## Create your own language model -Put your language model into `LANCE_LANGUAGE_MODEL_HOME`. \ No newline at end of file +Put your language model into `LANCE_LANGUAGE_MODEL_HOME`. diff --git a/docs/src/images/blob-v2-overview-dark.png b/docs/src/images/blob-v2-overview-dark.png new file mode 100644 index 00000000000..04853012405 Binary files /dev/null and b/docs/src/images/blob-v2-overview-dark.png differ diff --git a/docs/src/images/blob-v2-overview-light.png b/docs/src/images/blob-v2-overview-light.png new file mode 100644 index 00000000000..7dae4f70cc3 Binary files /dev/null and b/docs/src/images/blob-v2-overview-light.png differ 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..4d52e2a0c82 --- /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*>/ + + + + + + + + Caller-defined grouping + merge_existing_index_segments(...) + + + + +Vec<IndexMetadata> + + + + + Optional segment merge + + + + + + merge(group[0]) + → IndexMetadata 0 + + + + + merge(group[1]) + → IndexMetadata 1 + + + + + merge(group[N]) + → IndexMetadata N + + + + + + + + + commit_existing_index_segments(...) + + + + + + + + + Logical index + + diff --git a/docs/src/images/lakehouse_stack.png b/docs/src/images/lakehouse_stack.png index ff9546d9639..b3e4db709d6 100644 Binary files a/docs/src/images/lakehouse_stack.png and b/docs/src/images/lakehouse_stack.png differ diff --git a/docs/src/images/mem_wal_overview.png b/docs/src/images/mem_wal_overview.png index 008c84d0724..1c9f64bc647 100644 Binary files a/docs/src/images/mem_wal_overview.png and b/docs/src/images/mem_wal_overview.png differ diff --git a/docs/src/images/mem_wal_regional.png b/docs/src/images/mem_wal_regional.png deleted file mode 100644 index 5681fa27b8b..00000000000 Binary files a/docs/src/images/mem_wal_regional.png and /dev/null differ diff --git a/docs/src/images/mem_wal_shard.png b/docs/src/images/mem_wal_shard.png new file mode 100644 index 00000000000..ad8bed9e4b9 Binary files /dev/null and b/docs/src/images/mem_wal_shard.png differ diff --git a/docs/src/integrations/.pages b/docs/src/integrations/.pages index 05249d14207..ab9c2c0dcee 100644 --- a/docs/src/integrations/.pages +++ b/docs/src/integrations/.pages @@ -1,7 +1,6 @@ nav: + - Overview: index.md - Apache DataFusion: datafusion.md - - DuckDB: duckdb.md - - PostgreSQL: https://github.com/lancedb/pglance + - PostgreSQL: https://github.com/lance-format/pglance - PyTorch: pytorch.md - - Tensorflow: tensorflow.md - - Trino: https://github.com/lancedb/lance-trino + - TensorFlow: tensorflow.md diff --git a/docs/src/integrations/duckdb.md b/docs/src/integrations/duckdb.md deleted file mode 100644 index cac9b048bea..00000000000 --- a/docs/src/integrations/duckdb.md +++ /dev/null @@ -1,397 +0,0 @@ -# DuckDB - -Lance datasets can be queried in SQL with [DuckDB](https://duckdb.org/), -an in-process OLAP relational database. Using DuckDB means you can write complex SQL queries (that may not yet be supported in Lance), without needing to move your data out of Lance. - -!!! note - This integration is done via a DuckDB extension, whose source code and latest documentation (via `README.md`) is available - [here](https://github.com/lance-format/lance-duckdb). - To ensure you see the most up-to-date examples and syntax, check out the repo and the - [DuckDB extension](https://duckdb.org/community_extensions/extensions/lance) - documentation page. - -## Installation - -### Python dependencies - -- To use DuckDB's CLI, install it using the steps shown in [their docs](https://duckdb.org/install/). -- To run the code in Python, install Lance, DuckDB and PyArrow as shown below. - -```bash -pip install pylance duckdb pyarrow -``` - -### Install the Lance extension in DuckDB - -We're now ready to begin querying Lance using DuckDB! First, install the extension. - -=== "SQL" - - ```sql - INSTALL lance FROM community; - LOAD lance; - ``` - -=== "Python" - - ```python - import duckdb - - duckdb.sql( - """ - INSTALL lance FROM community; - LOAD lance; - """ - ) - ``` - -???+ info "Update extensions" - If you already have the extension installed locally, run the following command to update it to the - latest version: - ``` - UPDATE EXTENSIONS; - ``` - -## Examples - -All examples below reuse a small dataset with three rows (duck, horse, dragon) -and a `vector` column with representative values. In the real world, you'd have -a high-dimensional array generated by an embedding model, and a much larger Lance dataset. - -### Write a DuckDB table as a Lance dataset - -Use DuckDB's `COPY ... TO ...` to materialize query results as a Lance dataset. - -=== "SQL" - - ```sql - COPY ( - SELECT * - FROM ( - VALUES - ('duck', 'quack', [0.9, 0.7, 0.1]::FLOAT[]), - ('horse', 'neigh', [0.3, 0.1, 0.5]::FLOAT[]), - ('dragon', 'roar', [0.5, 0.2, 0.7]::FLOAT[]) - ) AS t(animal, noise, vector) - ) TO './lance_duck.lance' (FORMAT lance, mode 'overwrite'); - ``` - -=== "Python" - - ```python - import duckdb - - duckdb.sql( - """ - COPY ( - SELECT * - FROM ( - VALUES - ('duck', 'quack', [0.9, 0.7, 0.1]::FLOAT[]), - ('horse', 'neigh', [0.3, 0.1, 0.5]::FLOAT[]), - ('dragon', 'roar', [0.5, 0.2, 0.7]::FLOAT[]) - ) AS t(animal, noise, vector) - ) TO './lance_duck.lance' (FORMAT lance, mode 'overwrite'); - """ - ) - ``` - -### Query a Lance dataset from DuckDB - -Now that the Lance dataset is written, let's query it using SQL in DuckDB. - -=== "SQL" - - ```sql - SELECT * - FROM './lance_duck.lance' - LIMIT 5; - ``` - -=== "Python" - - ```python - import duckdb - - r1 = duckdb.sql( - """ - SELECT * - FROM './lance_duck.lance' - LIMIT 5; - """ - ) - print(r1) - ``` - - -This returns: - -``` -┌─────────┬─────────┬─────────────────� -│ animal │ noise │ vector │ -│ varchar │ varchar │ float[] │ -├─────────┼─────────┼─────────────────┤ -│ duck │ quack │ [0.9, 0.7, 0.1] │ -│ horse │ neigh │ [0.3, 0.1, 0.5] │ -│ dragon │ roar │ [0.5, 0.2, 0.7] │ -└─────────┴─────────┴─────────────────┘ -``` - -???+ info "Query S3 paths directly" - To access object store URIs (such as `s3://...`), configure a `TYPE LANCE` secret. - - ```sql - CREATE SECRET ( - TYPE LANCE, - PROVIDER credential_chain, - SCOPE 's3://bucket/' - ); - - SELECT * - FROM 's3://bucket/path/to/dataset.lance' - LIMIT 5; - ``` - -### Create a Lance dataset via CREATE TABLE (directory namespace) - -When you `ATTACH` a directory as a Lance namespace, you can create new datasets -using `CREATE TABLE` or `CREATE TABLE AS SELECT`. The dataset is written to -`/.lance`. - -=== "SQL" - - ```sql - ATTACH './lance_ns' AS lance_ns (TYPE LANCE); - - CREATE TABLE lance_ns.main.duck_animals AS - SELECT * - FROM ( - VALUES - ('duck', 'quack', [0.9, 0.7, 0.1]::FLOAT[]), - ('horse', 'neigh', [0.3, 0.1, 0.5]::FLOAT[]), - ('dragon', 'roar', [0.5, 0.2, 0.7]::FLOAT[]) - ) AS t(animal, noise, vector); - ``` - -=== "Python" - - ```python - import duckdb - - duckdb.sql( - """ - ATTACH './lance_ns' AS lance_ns (TYPE LANCE); - - CREATE TABLE lance_ns.main.duck_animals AS - SELECT * - FROM ( - VALUES - ('duck', 'quack', [0.9, 0.7, 0.1]::FLOAT[]), - ('horse', 'neigh', [0.3, 0.1, 0.5]::FLOAT[]), - ('dragon', 'roar', [0.5, 0.2, 0.7]::FLOAT[]) - ) AS t(animal, noise, vector); - """ - ) - ``` - -You can then query the namespace as follows: - -```sql -SELECT count(*) FROM lance_ns.main.duck_animals; -``` - -``` -┌──────────────� -│ count_star() │ -│ int64 │ -├──────────────┤ -│ 3 │ -└──────────────┘ -``` - -### Vector search - -You can perform vector search on a column. This returns the `_distance` -(smaller is closer, so sort in ascending order for nearest neighbors). The example vector here is similar to the query "duck". - -=== "SQL" - - ```sql - SELECT animal, noise, vector, _distance - FROM lance_vector_search( - './lance_duck.lance', - 'vector', - [0.8, 0.7, 0.2]::FLOAT[], - k = 1, - prefilter = true - ) - ORDER BY _distance ASC; - ``` - -=== "Python" - - ```python - import duckdb - - r2 = duckdb.sql( - """ - SELECT animal, noise, vector, _distance - FROM lance_vector_search( - './lance_duck.lance', - 'vector', - [0.8, 0.7, 0.2]::FLOAT[], - k = 1, - prefilter = true - ) - ORDER BY _distance ASC; - """ - ) - print(r2) - ``` - -This returns: -``` -┌─────────┬─────────┬─────────────────� -│ animal │ noise │ vector │ -│ varchar │ varchar │ float[] │ -├─────────┼─────────┼─────────────────┤ -│ duck │ quack │ [0.9, 0.7, 0.1] │ -└─────────┴─────────┴─────────────────┘ -``` - -### Full-text search - -Run keyword-based BM25 search as shown below. This returns a `_score`, which -is sorted in descending order to get the most relevant results. - -=== "SQL" - - ```sql - SELECT animal, noise, vector, _score - FROM lance_fts( - './lance_duck.lance', - 'animal', - 'the brave knight faced the dragon', - k = 1, - prefilter = true - ) - ORDER BY _score DESC; - ``` - -=== "Python" - - ```python - import duckdb - - r3 = duckdb.sql( - """ - SELECT animal, noise, vector, _score - FROM lance_fts( - './lance_duck.lance', - 'animal', - 'the brave knight faced the dragon', - k = 1, - prefilter = true - ) - ORDER BY _score DESC; - """ - ) - print(r3) - ``` - -This returns: - -``` -┌─────────┬─────────┬─────────────────� -│ animal │ noise │ vector │ -│ varchar │ varchar │ float[] │ -├─────────┼─────────┼─────────────────┤ -│ dragon │ roar │ [0.5, 0.2, 0.7] │ -└─────────┴─────────┴─────────────────┘ -``` - -### Hybrid search - -Hybrid search combines vector and FTS scores, returning a `_hybrid_score` in addition -to `_distance` / `_score`. To get the most relevant results, sort in descending order. - -=== "SQL" - - ```sql - SELECT animal, noise, vector, _hybrid_score, _distance, _score - FROM lance_hybrid_search( - './lance_duck.lance', - 'vector', - [0.8, 0.7, 0.2]::FLOAT[], - 'animal', - 'the duck surprised the dragon', - k = 2, - prefilter = false, - alpha = 0.5, - oversample_factor = 4 - ) - ORDER BY _hybrid_score DESC; - ``` - -=== "Python" - - ```python - import duckdb - - r4 = duckdb.sql( - """ - SELECT animal, noise, vector, _hybrid_score, _distance, _score - FROM lance_hybrid_search( - './lance_duck.lance', - 'vector', - [0.8, 0.7, 0.2]::FLOAT[], - 'animal', - 'the duck surprised the dragon', - k = 2, - prefilter = false, - alpha = 0.5, - oversample_factor = 4 - ) - ORDER BY _hybrid_score DESC; - """ - ) - print(r4) - ``` - -This returns: -``` -┌─────────┬─────────┬─────────────────� -│ animal │ noise │ vector │ -│ varchar │ varchar │ float[] │ -├─────────┼─────────┼─────────────────┤ -│ duck │ quack │ [0.9, 0.7, 0.1] │ -│ dragon │ roar │ [0.5, 0.2, 0.7] │ -└─────────┴─────────┴─────────────────┘ -``` - -!!! warning - DuckDB treats `column` as a keyword in some contexts. It's recommended to - use `text_column` / `vector_column` as column names for the Lance extension. - -## Source repo - -Check out the [lance-duckdb](https://github.com/lance-format/lance-duckdb) project -for the latest source code, and go through `README.md` for the latest API docs. -Additional pages are listed below. - -### Full SQL reference - -[sql.md](https://github.com/lance-format/lance-duckdb/blob/main/docs/sql.md) -lists the current SQL surface supported by this extension. It's recommended to refer -to this page for the most up-to-date information. - -### Cloud storage reference - -[cloud.md](https://github.com/lance-format/lance-duckdb/blob/main/docs/cloud.md) lists -the current supported backends that allow you to access data on various cloud providers. - -- S3 / S3-compatible: `s3://...` (also accepts `s3a://...` and `s3n://...`, normalized to `s3://...`) -- Google Cloud Storage: `gs://...` -- Azure Blob Storage: `az://...` -- Alibaba Cloud OSS: `oss://...` -- Hugging Face Hub (OpenDAL): `hf://...` diff --git a/docs/src/integrations/index.md b/docs/src/integrations/index.md new file mode 100644 index 00000000000..5c392275721 --- /dev/null +++ b/docs/src/integrations/index.md @@ -0,0 +1,68 @@ +--- +title: Integrations +description: Connect Lance to query engines, ML frameworks, databases, and catalogs across the data ecosystem. +--- + +# Integrations + +Because Lance is built on Apache Arrow and exposes +a stable Rust core with Python and Java bindings, it can be consumed from query engines, ML frameworks, databases, +and catalog systems without copying or converting data. + +This page lists the integrations maintained by the Lance community. Some live in this repository as first-party +Rust/Python APIs; others are maintained as dedicated subprojects under the [lance-format](https://github.com/lance-format) +GitHub organization. + +## Query Engines and SQL + +| Integration | Description | Source | +|---|---|---| +| [Apache DataFusion](datafusion.md) | Register Lance datasets as DataFusion tables and run SQL with predicate/projection pushdown. Available in both Rust and Python. | Built-in | +| [Apache Spark](spark/index.md) | Read and write Lance datasets from Spark SQL and DataFrames. | [lance-format/lance-spark](https://github.com/lance-format/lance-spark) | +| [Apache Doris](https://doris.apache.org/docs/4.x/lakehouse/catalogs/lance-catalog/) | Query Lance datasets with Doris SQL through filesystem and REST catalogs, with predicate pushdown and vector search. Read-only. | [apache/doris](https://github.com/apache/doris) | +| [DuckDB](duckdb) | Query Lance datasets directly from DuckDB. | [lance-format/lance-duckdb](https://github.com/lance-format/lance-duckdb) | +| [Trino](trino) | Federate Lance into Trino alongside other connectors. | [lance-format/lance-trino](https://github.com/lance-format/lance-trino) | + +## Machine Learning and AI + +| Integration | Description | Source | +|---|---|---| +| [PyTorch](pytorch.md) | Use `lance.torch.data.LanceDataset` as a `torch.utils.data.IterableDataset` for training and inference. | Built-in | +| [TensorFlow](tensorflow.md) | Use `lance_tensorflow.from_lance` to stream Lance data into `tf.data.Dataset` pipelines. | [lance-format/lance-tensorflow](https://github.com/lance-format/lance-tensorflow) | +| [Ray](ray) | Distributed read/write of Lance datasets with Ray Data. | [lance-format/lance-ray](https://github.com/lance-format/lance-ray) | +| [Hugging Face](huggingface) | Convert and load Hugging Face datasets to and from Lance in a single call. | [lance-format/lance-huggingface](https://github.com/lance-format/lance-huggingface) | + +## Databases and Stream Processing + +| Integration | Description | Source | +|---|---|---| +| PostgreSQL | Read Lance datasets from PostgreSQL via the `pglance` extension. | [lance-format/pglance](https://github.com/lance-format/pglance) | +| Apache Flink | Stream data into Lance from Flink jobs. | [lance-format/lance-flink](https://github.com/lance-format/lance-flink) | + +## Catalogs and Namespaces + +| Integration | Description | Source | +|---|---|---| +| [Lance Namespace](../format/namespace) | Specification and codegen SDKs (Rust, Python, Java) for catalog-backed Lance tables. | [lance-format/lance-namespace](https://github.com/lance-format/lance-namespace) | +| [Catalog implementations](../format/namespace/supported-catalogs) | Reference implementations for Apache Hive, Apache Polaris, Apache Gravitino, Unity Catalog, AWS Glue, and others. | [lance-format/lance-namespace-impls](https://github.com/lance-format/lance-namespace-impls) | + +## Other Ecosystem Projects + +| Integration | Description | Source | +|---|---|---| +| Lance Graph | Cypher-capable graph query engine on top of Lance. | [lance-format/lance-graph](https://github.com/lance-format/lance-graph) | +| Lance Data Viewer | Read-only web interface for browsing Lance datasets. | [lance-format/lance-data-viewer](https://github.com/lance-format/lance-data-viewer) | +| Lance Context | Manage multimodal agentic context lifecycle with Lance. | [lance-format/lance-context](https://github.com/lance-format/lance-context) | + +!!! note "Stability" + + Subprojects in the [lance-format](https://github.com/lance-format) organization graduate from incubating + status once they meet the project's quality bar (CI, tests, established use cases, community adoption). + Incubating subprojects may have changing APIs — check the project README for the current status. See + [Community Governance](../community#projects) for details on how integrations are organized. + +## Additional Integrations + +If there's an integration you'd like to see, but isn't listed above, please +[open an issue](https://github.com/lance-format/lance/issues/new) describing the use case. PRs are always +welcome, though it's recommended to alert the maintainers to avoid duplicated work. diff --git a/docs/src/integrations/pytorch.md b/docs/src/integrations/pytorch.md index 543b9978064..ae5c9f6286e 100644 --- a/docs/src/integrations/pytorch.md +++ b/docs/src/integrations/pytorch.md @@ -4,7 +4,7 @@ Machine learning users can use `lance.torch.data.LanceDataset`, a subclass of `torch.utils.data.IterableDataset`, that to use Lance data directly PyTorch training and inference loops. -It starts with creating a ML dataset for training. With the [HuggingFace integration](huggingface.md), +It starts with creating a ML dataset for training. With the [HuggingFace integration](huggingface), it takes just one line of Python to convert a HuggingFace dataset to a Lance dataset. ```python diff --git a/docs/src/integrations/tensorflow.md b/docs/src/integrations/tensorflow.md index 1c5d6b87157..03a5c5c5eca 100644 --- a/docs/src/integrations/tensorflow.md +++ b/docs/src/integrations/tensorflow.md @@ -1,92 +1,71 @@ -# Tensorflow Integration +--- +title: TensorFlow +description: Stream Lance datasets into TensorFlow tf.data pipelines with lance-tensorflow. +--- -Lance can be used as a regular [tf.data.Dataset](https://www.tensorflow.org/api_docs/python/tf/data/Dataset) -in [Tensorflow](https://www.tensorflow.org/). +# TensorFlow Integration -!!! warning +The TensorFlow integration is maintained in the +[lance-format/lance-tensorflow](https://github.com/lance-format/lance-tensorflow) +project. - This feature is experimental and the APIs may change in the future. +The main Lance Python package no longer includes `lance.tf`. Install +`lance-tensorflow` and import `lance_tensorflow` instead. + +```bash +pip install lance-tensorflow +``` ## Reading from Lance -Using `lance.tf.data.from_lance`, you can create an `tf.data.Dataset` easily. +Use `lance_tensorflow.from_lance` to create a `tf.data.Dataset` from a Lance +dataset. ```python -import tensorflow as tf -import lance +from lance_tensorflow import from_lance -# Create tf dataset -ds = lance.tf.data.from_lance("s3://my-bucket/my-dataset") - -# Chain tf dataset with other tf primitives +ds = from_lance( + "s3://my-bucket/my-dataset", + columns=["image", "label"], + filter="split = 'train'", + batch_size=256, +) -for batch in ds.shuffling(32).map(lambda x: tf.io.decode_png(x["image"])): - print(batch) +for batch in ds: + print(batch["label"]) ``` -Backed by the Lance [columnar format](../format/index.md), using `lance.tf.data.from_lance` supports -efficient column selection, filtering, and more. +## Dataset Convenience Methods + +If you want `tf.data.Dataset.from_lance`, register the convenience methods +explicitly after importing `lance_tensorflow`. ```python -ds = lance.tf.data.from_lance( - "s3://my-bucket/my-dataset", - columns=["image", "label"], - filter="split = 'train' AND collected_time > timestamp '2020-01-01'", - batch_size=256) -``` +import tensorflow as tf +import lance_tensorflow -By default, Lance will infer the Tensor spec from the projected columns. You can also specify `tf.TensorSpec` manually. +lance_tensorflow.register_tensorflow_dataset() -```python -batch_size = 256 -ds = lance.tf.data.from_lance( - "s3://my-bucket/my-dataset", - columns=["image", "labels"], - batch_size=batch_size, - output_signature={ - "image": tf.TensorSpec(shape=(), dtype=tf.string), - "labels": tf.RaggedTensorSpec( - dtype=tf.int32, shape=(batch_size, None), ragged_rank=1), - }, +ds = tf.data.Dataset.from_lance("s3://my-bucket/my-dataset") ``` -## Distributed Training and Shuffling +## Migration -Since [a Lance Dataset is a set of Fragments](../format/index.md), we can distribute and shuffle Fragments to different -workers. +Replace old imports: ```python -import tensorflow as tf -from lance.tf.data import from_lance, lance_fragments +import lance.tf.data -world_size = 32 -rank = 10 -seed = 123 # -epoch = 100 +ds = lance.tf.data.from_lance(uri) +``` -dataset_uri = "s3://my-bucket/my-dataset" +with: -# Shuffle fragments distributedly. -fragments = - lance_fragments("s3://my-bucket/my-dataset") - .shuffling(32, seed=seed) - .repeat(epoch) - .enumerate() - .filter(lambda i, _: i % world_size == rank) - .map(lambda _, fid: fid) +```python +from lance_tensorflow import from_lance -ds = from_lance( - uri, - columns=["image", "label"], - fragments=fragments, - batch_size=32 - ) -for batch in ds: - print(batch) +ds = from_lance(uri) ``` -!!! warning - - For multiprocessing you should probably not use fork as lance is - multi-threaded internally and fork and multi-thread do not work well. - Refer to [this discussion](https://discuss.python.org/t/concerns-regarding-deprecation-of-fork-with-alive-threads/33555). \ No newline at end of file +See the [lance-tensorflow README](https://github.com/lance-format/lance-tensorflow) +for the current installation and compatibility details. diff --git a/docs/src/quickstart/.pages b/docs/src/quickstart/.pages index 142d4d91b3d..dc2c58b8f20 100644 --- a/docs/src/quickstart/.pages +++ b/docs/src/quickstart/.pages @@ -1,4 +1,5 @@ nav: - Getting Started with Lance: index.md - Versioning: versioning.md - - Vector Search: vector-search.md \ No newline at end of file + - Vector Search: vector-search.md + - Full-Text Search: full-text-search.md \ No newline at end of file diff --git a/docs/src/quickstart/full-text-search.md b/docs/src/quickstart/full-text-search.md new file mode 100644 index 00000000000..25a7dd3795e --- /dev/null +++ b/docs/src/quickstart/full-text-search.md @@ -0,0 +1,505 @@ +--- +title: Full-Text Search +description: Full-text search (FTS) with inverted BM25 indexes and N-gram search in Lance +--- + +# Full-Text Search in Lance + +Lance provides powerful full-text search (FTS) capabilities using an inverted index. This tutorial guides you through building and using FTS indexes to dramatically speed up text search operations while maintaining high accuracy. + +By the end of this tutorial, you'll be able to build and use an FTS index, understand performance differences between indexed and non-indexed searches, and learn how to tune search parameters for optimal performance. + +## Install the Python SDK + +First, install the required dependencies: + +```bash +pip install pylance pyarrow +``` + +## Set Up Your Environment + +Import the necessary libraries for working with Lance datasets: + +```python +import lance +import pyarrow as pa +``` + +## Prepare Your Text Data + +In this quickstart, we'll create a simple dataset with text documents: + +```python +table = pa.table( + { + "id": [1, 2, 3], + "text": [ + "I left my umbrella on the evening train to Boston", + "This ramen recipe simmers the broth for three hours with dried mushrooms.", + "This train is scheduled to leave for Edinburgh at 9:30 in the morning", + ], + } +) + +# Write to a new Lance dataset +lance.write_dataset(table, "/tmp/fts.lance", mode="overwrite") +``` + +This creates a Lance dataset with three text documents containing overlapping keywords that we'll use to demonstrate different search scenarios. + +## Explore Your Dataset Schema + +Let's examine the structure of our dataset: + +```python +ds = lance.dataset("/tmp/fts.lance") +print(ds.schema) +``` + +This prints the PyArrow schema of the dataset: + +``` +id: int64 +text: large_string +``` + +## Build the Full-Text Search Index + +Full-text search is created with an inverted scalar index on your text column. Choose the `INVERTED` index type when calling `create_scalar_index` on your Lance dataset. Lance uses the BM25 ranking algorithm for relevance scoring. Results are automatically ranked by relevance, with higher scores indicating better matches. + +```python +ds.create_scalar_index( + column="text", + index_type="INVERTED" +) +``` + +The index creation process builds an efficient lookup structure that maps words to the documents containing them. This enables high-performance keyword-based search, even on large datasets. + +!!! warning "Index Creation Time" +Index creation time depends on the size of your text data. For large datasets, this process may take several minutes, but the performance benefits at query time are substantial. + +## Advanced Index Configuration + +You can customize the index creation with various parameters to optimize for your specific use case: + +```python +ds.create_scalar_index( + column="text", + index_type="INVERTED", + name="text_idx", # Optional index name (if omitted, default is "text_idx") + with_position=False, # Set True to enable phrase queries (stores token positions) + base_tokenizer="simple", # Tokenizer: "simple", "icu", "icu/split", "whitespace", "raw", or "ngram" + language="English", # Language used for stemming + stop words (only used if `stem` or `remove_stop_words` is True) + max_token_length=40, # Drop tokens longer than this length + lower_case=True, # Lowercase text before tokenization + stem=True, # Stem tokens (language-dependent) + remove_stop_words=True, # Remove stop words (language-dependent) + custom_stop_words=None, # Optional additional stop words (only used if remove_stop_words=True) + ascii_folding=True, # Fold accents to ASCII when possible (e.g., "é" -> "e") + block_size=128, # Posting block size: 128 or 256; 256 is experimental +) +``` + +### Tokenizer Options + +- **simple**: Splits tokens on whitespace and punctuation +- **whitespace**: Splits tokens only on whitespace +- **raw**: No tokenization (useful for exact matching) + +Lance also supports multilingual tokenization: + +- **icu**: Unicode word segmentation with built-in ICU dictionaries +- **jieba/default**: Chinese text tokenization using Jieba +- **lindera/ipadic**: Japanese text tokenization using Lindera with IPAdic dictionary +- **lindera/ko-dic**: Korean text tokenization using Lindera with Ko-dic dictionary +- **lindera/unidic**: Japanese text tokenization using Lindera with UniDic dictionary + +### Language Processing Features + +- **stemming**: Reduces words to their root form (e.g., "running" → "run") +- **stop words**: Removes common words like "the", "and", "is" +- **ascii folding**: Converts accented characters to ASCII (e.g., "é" → "e") + +## Search With FTS Queries + +Now you can run FTS queries using your inverted index: + +```python +import lance + +# Open dataset +ds = lance.dataset("/tmp/fts.lance") + +# Specify keyword phrases when calling the `to_table` method +query_result = ds.to_table( + full_text_query="umbrella train" +) +print(query_result) +``` + +This query returns documents that contain either "umbrella" or "train" (or both). The search is case-insensitive and uses the inverted index for fast retrieval. + +``` +id: [[1, 3]] +text: [["I left my umbrella on the evening train to Boston", "This train is scheduled to leave for Edinburgh at 9:30 in the morning"]] +_score: [[..., ...]] +``` + +## Combining Full-Text Search with Metadata + +It can be useful to combine FTS with metadata filtering in a single query to find more relevant results. +You can do this by passing a filter expression to the `filter` parameter. + +```python +import lance +import pyarrow as pa + +table = pa.table( + { + "id": [1, 2, 3], + "text": [ + "I left my umbrella on the morning train to Boston", + "This ramen recipe simmers the broth for three hours with dried mushrooms.", + "This train is scheduled to leave for Edinburgh at 9:30 AM", + ], + "category": ["travel", "food", "travel"], + } +) + +# Temp write dataset +lance.write_dataset(table, "./fts_test_with_metadata.lance", mode="overwrite") + +ds = lance.dataset("./fts_test_with_metadata.lance") + +# Create FTS index +ds.create_scalar_index( + column="text", + index_type="INVERTED", +) + +# Run FTS query with metadata filter +query_result = ds.to_table( + full_text_query="three", + filter='category = "food"', +) + +# Returns +# id: [[2]] +# text: [["This ramen recipe simmers the broth for three hours with dried mushrooms."]] +# category: [["food"]] +``` + +## Advanced Search Features + +### Boolean Search Operators + +You can use boolean search operators by constructing a structured query object. + +#### All terms: `AND` + +```python +from lance.query import FullTextOperator, MatchQuery + +# Require the terms 'umbrella AND train AND boston' to be present +and_query = MatchQuery("umbrella train boston", "text", operator=FullTextOperator.AND) +query_result = ds.to_table(full_text_query=and_query) + +# Returns +# text: [["I left my umbrella on the evening train to Boston"]] +``` + +#### Any terms: `OR` + +```python +from lance.query import FullTextOperator, MatchQuery + +# Require the terms 'morning OR evening' to be present +or_query = MatchQuery("morning evening", "text", operator=FullTextOperator.OR) +query_result = ds.to_table(full_text_query=or_query) + +# Returns the Boston document that mentions 'evening', and the Edinburgh document that mentions 'morning' +# text: [["This train is scheduled to leave for Edinburgh at 9:30 in the morning", "I left my umbrella on the evening train to Boston"]] +``` + +#### Mix `AND`/`OR` queries via operators + +You can mix `AND`/`OR` queries using operators in Python: + +```python +from lance.query import FullTextOperator, MatchQuery + +# Combine AND and OR semantics +# Require 'train' AND ('morning' OR 'evening') +q1 = MatchQuery("morning evening", "text", operator=FullTextOperator.OR) +q2 = MatchQuery("train", "text") +query_result = ds.to_table(full_text_query=(q1 & q2)) + +# Returns both the Boston and Edinburgh documents that mention 'train' +# text: [["I left my umbrella on the evening train to Boston", "This train is scheduled to leave for Edinburgh at 9:30 in the morning"]] +``` + +To combine `OR` queries via operators, use the pattern `q1 | q2`. + +Every query combined with `AND` becomes a scoring `MUST` clause: all clauses must match, +and every matching clause contributes to the final `_score`. + +#### Exclude terms: `NOT` + +Queries that exclude specific keywords are explicitly written using `BooleanQuery`/`Occur` +as shown below. + +```python +from lance.query import MatchQuery, BooleanQuery, Occur + +# Require that 'umbrella' be present, but 'train' NOT be present +q = BooleanQuery( + [ + (Occur.MUST, MatchQuery("umbrella", "text")), + (Occur.MUST_NOT, MatchQuery("train", "text")), + ] +) +query_result = ds.to_table(full_text_query=q) + +# Returns empty result, as no document matches this condition +# text: [] +``` + +### Phrase Search + +For exact phrase matching, ensure you enable `with_position=True` during index creation, which is disabled by default. + +```python +# Rebuild the index with positions enabled (required for phrase queries) +ds.create_scalar_index( + "text", + "INVERTED", + with_position=True, + remove_stop_words=False, +) +# Search for the exact phrase "train to boston" +table = ds.to_table(full_text_query="'train to boston'") + +# If stopwords are removed, this phrase query would return an empty result +# text: [["I left my umbrella on the evening train to Boston"]] +``` + +!!! warning "Stop Words Are Removed by Default" +Common words like "to", "the", etc. are categorized as stop words and are removed by default when creating the index. If you want to search exact phrases that include stop words, set `remove_stop_words=False` when creating the index. + +### Substring matches with N-gram indexing + +`NGRAM` is a type of scalar index for **substring / pattern-style** searches over text. It is a good alternative to wildcard-style queries like `term*` / `*term` (which are not parsed by `full_text_query` in Lance). + +The N-gram index creates a bitmap for each N-gram in the string. By default, Lance uses trigrams. This index can be used to speed up queries using the `contains` function in filters. + +```python +import lance + +ds = lance.dataset("/tmp/fts.lance") + +# Build an NGRAM index for substring search (speeds up `contains(...)` filters) +# Give the index a distinct name so it won't replace your FTS index +ds.create_scalar_index(column="text", index_type="NGRAM", name="text_ngram") + +# Substring search +q1 = ds.to_table(filter="contains(text, 'ramen')") + +# Returns the document about ramen +# text: [["This ramen recipe simmers the broth for three hours with dried mushrooms."]] +``` + +You can explain the query plan to confirm the N-gram index's usage as shown below: + +```python +# Inspect the query plan to confirm index usage +print(ds.scanner(filter="contains(text, 'train')").explain_plan()) +``` + +### Fuzzy Search + +Fuzzy search is supported for FTS `MatchQuery` on `INVERTED` indexes. It uses Levenshtein edit distance to match terms with typos or slight variations. + +```python +from lance.query import MatchQuery + +# Explicit edit distance (1) +query_result = ds.to_table( + full_text_query=MatchQuery( + "rammen", # Misspelled 'ramen' + "text", + fuzziness=1, + max_expansions=50, # default: 50 + ) +) +``` + +You can also set `fuzziness=None` to use automatic fuzziness: + +- `0` for term length `<= 2` +- `1` for term length `<= 5` +- `2` for term length `> 5` + +```python +query_result = ds.to_table( + full_text_query=MatchQuery( + "rammen", + "text", + fuzziness=None, + ) +) +``` + +To enforce exact prefixes during fuzzy matching, set `prefix_length`. +This means the first `N` characters must match exactly before fuzzy edits are allowed on the rest of the term. +For example, with `prefix_length=2`, `"rammen"` can match terms starting with `"ra"` (like `"ramen"`), but not terms starting with other prefixes. + +```python +query_result = ds.to_table( + full_text_query=MatchQuery( + "rammen", + "text", + fuzziness=1, + prefix_length=2, # "ra" must match exactly + ) +) +``` + +### Searching Multiple Columns + +When a query should span several text columns (for example `title` and `body`), +Lance offers two strategies. They differ in how they combine the matches from +each column. + +#### `best_fields` with `MultiMatchQuery` + +`MultiMatchQuery` runs the query against each column independently and keeps the +score of the best matching column for each row. Each column is scored against +its own corpus statistics, and an optional per-column `boosts` multiplier is +applied before taking the maximum. + +```python +from lance.query import MultiMatchQuery + +query_result = ds.to_table( + full_text_query=MultiMatchQuery( + "albino elephant", + ["title", "body"], + boosts=[2.0, 1.0], # weight title matches twice as much + ) +) +``` + +This works well when the columns are interchangeable and a strong match in one +column is enough to make a row relevant. + +#### `combined_fields` (BM25F) with `CombinedFieldsQuery` + +`CombinedFieldsQuery` instead treats the target columns as one virtual field and +combines their statistics into a single BM25F score. This makes scores +comparable across columns, and a query can match with terms spread over +different columns. + +```python +from lance.query import CombinedFieldsQuery, FullTextOperator + +query_result = ds.to_table( + full_text_query=CombinedFieldsQuery( + "john smith", + ["first_name", "last_name"], + boosts=[1.0, 1.0], + operator=FullTextOperator.AND, + ) +) +``` + +Prefer `combined_fields` in these cases: + +- A term is rare in one column but common in another. Because document + frequencies are combined, a chance hit in a short column does not outrank a + better match elsewhere. `best_fields` cannot do this. +- The query terms are spread over several columns. With `operator=AND`, + `"john smith"` matches a row whose `first_name` is `"john"` and whose + `last_name` is `"smith"`, even though neither column contains both terms. + `best_fields` would need a single column to contain the whole query. + +Per-column `boosts` set how much each column contributes to the combined term +frequency. Each weight must be at least `1`, and fractional weights are allowed. +For example, a title boost of `2` counts every title term twice. + +!!! note "Shared tokenizer required" + + All columns in a `CombinedFieldsQuery` must use the same tokenizer and index + configuration, because BM25F only makes sense when the columns are tokenized + the same way. Mixing configurations raises an error that lists the columns + involved. + +!!! note "Indexes and unindexed data" + + At least one target column needs an FTS index. Columns without an index, and + rows added since the last index build, are read from the data and scored + together with the indexed rows. Scanning is slower than reading an index, so + optimize the indexes after adding a lot of data. + + BM25F adds up each column's contribution for a whole row, so a column indexed + only with `document_granularity="list_element"` is rejected. Its per-element + documents have no counterpart in the other columns, and the result has no + `_doc_index` column. A column that has both a row index and a list-element + index uses the row index. + +## Performance Tips + +### Index Maintenance + +When you append new rows after creating an `INVERTED` index, Lance still returns those rows in `full_text_query` results. It searches indexed fragments using the FTS index, scans unindexed fragments with flat search, and then merges the results. + +To keep FTS latency low as new data arrives, periodically add unindexed fragments into the existing FTS index by calling `ds.optimize.optimize_indices()`: + +```python +# Append new data +new_rows = pa.table( + { + "id": [4], + "text": ["The next train leaves at noon"], + } +) +ds.insert(new_rows) + +# Incrementally update existing indices (including "text_idx") +ds.optimize.optimize_indices(index_names=["text_idx"]) + +# Optional: monitor index coverage +stats = ds.stats.index_stats("text_idx") +print(stats["num_unindexed_rows"], stats["num_indexed_rows"]) +``` + +!!! info +If you used a custom index name, replace `"text_idx"` with your index name. +If you did not set `name=...` when creating the FTS index on column `"text"`, the default index name is `"text_idx"`. + +If you changed tokenizer settings (such as `with_position`, `base_tokenizer`, stop words, or stemming), rebuild the index with `create_scalar_index(..., replace=True)` so the full dataset is indexed with the new configuration. + +### Index Configuration Best Practices + +- Enable `with_position` when you need phrase queries, because it stores word positions within documents. For simple term searches, disabling this option can save considerable storage space without impacting performance. + +- Keep `lower_case=True` enabled for most applications to ensure case-insensitive search behavior. This provides a better user experience and matches common search expectations, though you can disable it if case sensitivity is important for your use case. + +- Enable stemming (`stem=True`) when you want better recall by matching word variations (e.g., "running" matches "run"). Disable stemming if you need exact term matching or if your domain requires precise terminology. + +- Consider enabling `remove_stop_words=True` for cleaner search results, especially in content-heavy applications. This removes common words like "the", "and", and "is" from the index, reducing noise and improving relevance. Keep stop words if they carry important meaning in your domain. + +### Query Optimization + +Using specific, targeted search terms often yields better performance than broad, generic queries. More specific terms reduce the number of potential matches and allow the index to work more efficiently. Consider analyzing your most common search patterns and optimizing your index configuration accordingly. + +Combining full-text search with metadata filters can significantly reduce the search space and improve performance. Use structured data filters to narrow down results before applying text search, or vice versa. This approach is particularly effective for large datasets where you can eliminate many irrelevant documents early in the query process. + +### Further Reading + +For advanced usage instructions with different tokenizers and more technical details on the index training process, including information about the expected memory and disk usage, visit the [full-text index](../format/index/scalar/fts.md) specification. + +## Next Steps + +Check out the **[User Guide](../guide/read_and_write.md)** and explore the Lance API in more detail. diff --git a/docs/src/quickstart/index.md b/docs/src/quickstart/index.md index 6f2c9a8d147..f10c5749014 100644 --- a/docs/src/quickstart/index.md +++ b/docs/src/quickstart/index.md @@ -19,13 +19,24 @@ pip install pylance For the latest features and bug fixes, you can install the preview version: -```bash -pip install --pre --extra-index-url https://pypi.fury.io/lance-format/pylance -``` +=== "pip" + + ```bash + pip install --pre --extra-index-url https://pypi.fury.io/lance-format pylance + ``` + +=== "uv" + + ```bash + uv venv + uv pip install --prerelease allow --index https://pypi.fury.io/lance-format pylance -> Note: Preview releases receive the same level of testing as regular releases. + # To add to pyproject.toml, just do: + uv add --prerelease allow --index 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 +!!! note + Preview releases receive the same level of testing as regular releases. ## Set Up Your Environment @@ -94,4 +105,4 @@ dataset.to_table().to_pandas() Now that you've mastered the basics of creating Lance datasets, here's what you can explore next: - **[Versioning Your Datasets with Lance](versioning.md)** - Learn how to track changes over time with native versioning -- **[Vector Indexing and Vector Search With Lance](vector-search.md)** - Build high-performance vector search capabilities with ANN indexes +- **[Vector Indexing and Vector Search With Lance](vector-search.md)** - Build high-performance vector search capabilities with ANN indices diff --git a/docs/src/quickstart/vector-search.md b/docs/src/quickstart/vector-search.md index e157c193486..ac77e12b752 100644 --- a/docs/src/quickstart/vector-search.md +++ b/docs/src/quickstart/vector-search.md @@ -1,13 +1,13 @@ --- title: Vector Search -description: High-performance vector search with ANN indexes, including IVF_PQ, IVF_HNSW_PQ, and IVF_HNSW_SQ +description: High-performance vector search with ANN indices, including IVF_PQ, IVF_HNSW_PQ, and IVF_HNSW_SQ --- # Vector Indexing and Vector Search With Lance -Lance provides high-performance vector search capabilities with ANN (Approximate Nearest Neighbor) indexes. +Lance provides high-performance vector search capabilities with ANN (Approximate Nearest Neighbor) indices. -By the end of this tutorial, you'll be able to build and use ANN indexes to dramatically speed up vector search operations while maintaining high accuracy. You'll also learn how to tune search parameters for optimal performance and combine vector search with metadata queries in a single operation. +By the end of this tutorial, you'll be able to build and use ANN indices to dramatically speed up vector search operations while maintaining high accuracy. You'll also learn how to tune search parameters for optimal performance and combine vector search with metadata queries in a single operation. ## Install the Python SDK @@ -210,8 +210,6 @@ The latency vs recall is tunable via: - **refine_factor**: determines how many vectors are retrieved during re-ranking ```python -%%time - sift1m.to_table( nearest={ "column": "vector", @@ -280,4 +278,4 @@ print(result.to_pandas()) ## Next Steps -You should check out **[Versioning Your Datasets with Lance](../quickstart/versioning.md)**. We'll show you how to version your vector datasets and track changes over time. +Check out **[Full-text Search](../quickstart/full-text-search.md)**, where we show how to create and query a BM25 index for keyword-based search in Lance. diff --git a/docs/src/quickstart/versioning.md b/docs/src/quickstart/versioning.md index 57e08c98053..9faa5ab9d02 100644 --- a/docs/src/quickstart/versioning.md +++ b/docs/src/quickstart/versioning.md @@ -1,11 +1,11 @@ --- title: Versioning -description: Learn how to version your Lance datasets with append, overwrite, and tag features +description: Learn how to version your Lance datasets with append, overwrite, tags, and branches --- # Versioning Your Datasets with Lance -Lance supports versioning natively, allowing you to track changes over time. +Lance supports versioning natively, allowing you to track changes over time. In this tutorial, you'll learn how to append new data to existing datasets while preserving historical versions and access specific versions using version numbers or meaningful tags. You'll also understand how to implement proper data governance practices with Lance's native versioning capabilities. @@ -63,6 +63,16 @@ List all versions of a dataset with this request: dataset.versions() ``` +If you only need version numbers, use the lightweight reference API. It lists manifest +locations without reading and deserializing every manifest: + +```python +dataset.version_refs() +``` + +Use `dataset.latest_version` instead when only the latest version of the current branch +is needed. + You can also access any available version: ```python @@ -75,7 +85,7 @@ lance.dataset('/tmp/test.lance', version=2).to_table().to_pandas() ## Tag Your Important Versions -Create named tags for important versions, making it easier to reference specific versions by meaningful names. To create tags for relevant versions, do this: +Create named tags for important versions, making it easier to reference them by meaningful names. ```python dataset.tags.create("stable", 2) @@ -89,8 +99,27 @@ Tags can be checked out like versions: lance.dataset('/tmp/test.lance', version="stable").to_table().to_pandas() ``` +For advanced tag operations (e.g., tagging versions on specific branches), see [Tags and Branches](../guide/tags_and_branches.md). + +## Work with Branches + +Branches manage parallel lines of dataset evolution. You can create branches from existing versions or tags, read and write to them independently, and checkout different branches. + +`main` refers to the default branch in checkout and reference APIs, but it is reserved and cannot be used as the name of a new branch. + +```python +# Create branch from current latest version +experiment_branch = ds.create_branch("experiment") + +# Write to the branch (affects only that branch's history) +tbl = pa.Table.from_pandas(pd.DataFrame({"a": [42]})) +lance.write_dataset(tbl, experiment_branch, mode="append") +``` + +For more details, see [Tags and Branches](../guide/tags_and_branches.md). + ## Next Steps Now that you've mastered dataset versioning with Lance, check out **[Vector Indexing and Vector Search With Lance](vector-search.md)**. You can learn how to build high-performance vector search capabilities on top of your Lance tables. -This will teach you how to build fast, scalable search capabilities for your versioned datasets. \ No newline at end of file +This will teach you how to build fast, scalable search capabilities for your versioned datasets. diff --git a/docs/src/rest.yaml b/docs/src/rest.yaml deleted file mode 100644 index b3af38ba7ef..00000000000 --- a/docs/src/rest.yaml +++ /dev/null @@ -1,3540 +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. - ---- -openapi: 3.1.1 -info: - title: Lance Namespace Specification - license: - name: Apache 2.0 - url: https://www.apache.org/licenses/LICENSE-2.0.html - version: 1.0.0 - description: | - This OpenAPI specification is a part of the Lance namespace specification. It contains 2 parts: - - The `components/schemas`, `components/responses`, `components/examples`, `tags` sections define - the request and response shape for each operation in a Lance Namespace across all implementations. - See https://lance.org/format/namespace/operations for more details. - - The `servers`, `security`, `paths`, `components/parameters` sections are for the - Lance REST Namespace implementation, which defines a complete REST server that can work with Lance datasets. - See https://lance.org/format/namespace/impls/rest for more details. -servers: - - url: "{scheme}://{host}:{port}/{basePath}" - description: Generic server URL with all parts configurable - variables: - scheme: - default: http - host: - default: localhost - port: - default: "2333" - basePath: - default: "" - - url: "{scheme}://{host}/{basePath}" - description: Server URL when the port can be inferred from the scheme - variables: - scheme: - default: http - host: - default: localhost - basePath: - default: "" - -tags: - - name: Namespace - description: | - Operations that are related to a namespace - - name: Table - description: | - Operations that are related to a table - - name: Index - description: | - Operations that are related to an index - - name: Tag - description: | - Operations that are related to tags - - name: Transaction - description: | - Operations that are related to a transaction - - name: Metadata - description: | - Operations that only interact with object metadata and should be computationally lightweight - - name: Data - description: | - Operations that interact with object data and might be computationally intensive - -security: - - OAuth2: [] - - BearerAuth: [] - -paths: - - # Namespace APIs - - /v1/namespace/{id}/create: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Namespace - - Metadata - summary: Create a new namespace - operationId: CreateNamespace - description: | - Create new namespace `id`. - - During the creation process, the implementation may modify user-provided `properties`, - such as adding additional properties like `created_at` to user-provided properties, - omitting any specific property, or performing actions based on any property value. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateNamespaceRequest' - responses: - 200: - $ref: '#/components/responses/CreateNamespaceResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 406: - $ref: '#/components/responses/UnsupportedOperationErrorResponse' - 409: - $ref: '#/components/responses/ConflictErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/namespace/{id}/list: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: 'page_token' - in: query - schema: - $ref: '#/components/schemas/PageToken' - - name: 'limit' - in: query - schema: - $ref: '#/components/schemas/PageLimit' - get: - tags: - - Namespace - - Metadata - summary: List namespaces - operationId: ListNamespaces - description: | - List all child namespace names of the parent namespace `id`. - - REST NAMESPACE ONLY - REST namespace uses GET to perform this operation without a request body. - It passes in the `ListNamespacesRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `page_token`: pass through query parameter of the same name - - `limit`: pass through query parameter of the same name - responses: - 200: - $ref: '#/components/responses/ListNamespacesResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 406: - $ref: '#/components/responses/UnsupportedOperationErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/namespace/{id}/describe: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Namespace - - Metadata - summary: Describe a namespace - operationId: DescribeNamespace - description: | - Describe the detailed information for namespace `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeNamespaceRequest' - responses: - 200: - $ref: '#/components/responses/DescribeNamespaceResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/namespace/{id}/drop: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Namespace - - Metadata - summary: Drop a namespace - operationId: DropNamespace - description: | - Drop namespace `id` from its parent namespace. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DropNamespaceRequest' - responses: - 200: - $ref: '#/components/responses/DropNamespaceResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 409: - $ref: '#/components/responses/ConflictErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/namespace/{id}/exists: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Namespace - - Metadata - summary: Check if a namespace exists - operationId: NamespaceExists - description: | - Check if namespace `id` exists. - - This operation must behave exactly like the DescribeNamespace API, - except it does not contain a response body. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/NamespaceExistsRequest' - responses: - 200: - description: Success, no content - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/namespace/{id}/table/list: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: 'page_token' - in: query - schema: - $ref: '#/components/schemas/PageToken' - - name: 'limit' - in: query - schema: - $ref: '#/components/schemas/PageLimit' - get: - tags: - - Namespace - - Table - - Metadata - summary: List tables in a namespace - operationId: ListTables - description: | - List all child table names of the parent namespace `id`. - - REST NAMESPACE ONLY - REST namespace uses GET to perform this operation without a request body. - It passes in the `ListTablesRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `page_token`: pass through query parameter of the same name - - `limit`: pass through query parameter of the same name - responses: - 200: - $ref: '#/components/responses/ListTablesResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 406: - $ref: '#/components/responses/UnsupportedOperationErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - # Table Metadata APIs - - /v1/table/{id}/register: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Register a table to a namespace - operationId: RegisterTable - description: | - Register an existing table at a given storage location as `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterTableRequest' - responses: - 200: - $ref: '#/components/responses/RegisterTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 406: - $ref: '#/components/responses/UnsupportedOperationErrorResponse' - 409: - $ref: '#/components/responses/ConflictErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/describe: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Describe information of a table - operationId: DescribeTable - description: | - Describe the detailed information for table `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTableRequest' - responses: - 200: - $ref: '#/components/responses/DescribeTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/exists: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Check if a table exists - operationId: TableExists - description: | - Check if table `id` exists. - - This operation should behave exactly like DescribeTable, - except it does not contain a response body. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/TableExistsRequest' - responses: - 200: - description: Success, no content - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/drop: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Drop a table - operationId: DropTable - description: | - Drop table `id` and delete its data. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DropTableRequest' - responses: - 200: - $ref: '#/components/responses/DropTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/deregister: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Deregister a table - operationId: DeregisterTable - description: | - Deregister table `id` from its namespace. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DeregisterTableRequest' - responses: - 200: - $ref: '#/components/responses/DeregisterTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/restore: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Restore table to a specific version - operationId: RestoreTable - description: | - Restore table `id` to a specific version. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/RestoreTableRequest' - responses: - 200: - $ref: '#/components/responses/RestoreTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/version/list: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: List all versions of a table - operationId: ListTableVersions - description: | - List all versions (commits) of table `id` with their metadata. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ListTableVersionsRequest' - responses: - 200: - $ref: '#/components/responses/ListTableVersionsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/alter_columns: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Modify existing columns - operationId: AlterTableAlterColumns - description: | - Modify existing columns in table `id`, such as renaming or changing data types. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableAlterColumnsRequest' - responses: - 200: - $ref: '#/components/responses/AlterTableAlterColumnsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/drop_columns: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Remove columns from table - operationId: AlterTableDropColumns - description: | - Remove specified columns from table `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableDropColumnsRequest' - responses: - 200: - $ref: '#/components/responses/AlterTableDropColumnsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/stats: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Metadata - summary: Get table statistics - operationId: GetTableStats - description: | - Get statistics for table `id`, including row counts, data sizes, and column statistics. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GetTableStatsRequest' - responses: - 200: - $ref: '#/components/responses/GetTableStatsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - # Table Data APIs - - /v1/table/{id}/insert: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: mode - in: query - description: | - How the insert should behave: - - append (default): insert data to the existing table - - overwrite: remove all data in the table and then insert data to it - required: false - schema: - type: string - enum: - - append - - overwrite - default: append - post: - tags: - - Table - - Data - summary: Insert records into a table - operationId: InsertIntoTable - description: | - Insert new records into table `id`. - - REST NAMESPACE ONLY - REST namespace uses Arrow IPC stream as the request body. - It passes in the `InsertIntoTableRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `mode`: pass through query parameter of the same name - requestBody: - description: Arrow IPC stream containing the records to insert - content: - application/vnd.apache.arrow.stream: - schema: - type: string - format: binary - required: true - responses: - 200: - $ref: '#/components/responses/InsertIntoTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/merge_insert: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: "on" - in: query - description: Column name to use for matching rows (required) - required: true - schema: - type: string - - name: "when_matched_delete" - in: query - description: Delete all rows in target table where a match exists in source table - required: false - schema: - type: boolean - default: false - - name: "when_matched_update_all" - in: query - description: Update all columns when rows match - required: false - schema: - type: boolean - default: false - - name: "when_matched_update_all_filt" - in: query - description: The row is updated (similar to UpdateAll) only for rows where the SQL expression evaluates to true - required: false - schema: - type: string - - name: "when_not_matched_insert_all" - in: query - description: Insert all columns when rows don't match - required: false - schema: - type: boolean - default: false - - name: "when_not_matched_by_source_delete" - in: query - description: Delete all rows from target table that don't match a row in the source table - required: false - schema: - type: boolean - default: false - - name: "when_not_matched_by_source_delete_filt" - in: query - description: Delete rows from the target table if there is no match AND the SQL expression evaluates to true - schema: - type: string - post: - tags: - - Table - - Data - summary: Merge insert (upsert) records into a table - operationId: MergeInsertIntoTable - description: | - Performs a merge insert (upsert) operation on table `id`. - This operation updates existing rows - based on a matching column and inserts new rows that don't match. - It returns the number of rows inserted and updated. - - REST NAMESPACE ONLY - REST namespace uses Arrow IPC stream as the request body. - It passes in the `MergeInsertIntoTableRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `on`: pass through query parameter of the same name - - `when_matched_delete`: pass through query parameter of the same name - - `when_matched_update_all`: pass through query parameter of the same name - - `when_matched_update_all_filt`: pass through query parameter of the same name - - `when_not_matched_insert_all`: pass through query parameter of the same name - - `when_not_matched_by_source_delete`: pass through query parameter of the same name - - `when_not_matched_by_source_delete_filt`: pass through query parameter of the same name - requestBody: - description: Arrow IPC stream containing the records to merge - content: - application/vnd.apache.arrow.stream: - schema: - type: string - format: binary - required: true - responses: - 200: - $ref: '#/components/responses/MergeInsertIntoTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/update: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Update rows in a table - operationId: UpdateTable - description: | - Update existing rows in table `id`. - requestBody: - description: Update request - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTableRequest' - required: true - responses: - 200: - $ref: '#/components/responses/UpdateTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/delete: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Delete rows from a table - operationId: DeleteFromTable - description: | - Delete rows from table `id`. - requestBody: - description: Delete request - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteFromTableRequest' - required: true - responses: - 200: - $ref: '#/components/responses/DeleteFromTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/query: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Query a table - operationId: QueryTable - description: | - Query table `id` with vector search, full text search and optional SQL filtering. - Returns results in Arrow IPC file or stream format. - requestBody: - description: Query request - content: - application/json: - schema: - $ref: '#/components/schemas/QueryTableRequest' - required: true - responses: - 200: - $ref: '#/components/responses/QueryTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/count_rows: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Count rows in a table - operationId: CountTableRows - description: | - Count the number of rows in table `id` - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CountTableRowsRequest' - responses: - 200: - $ref: '#/components/responses/CountTableRowsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/create: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: "x-lance-table-location" - in: header - required: false - schema: - type: string - description: URI pointing to root location to create the table at - - name: "x-lance-table-properties" - in: header - required: false - schema: - type: string - description: | - JSON-encoded string map (e.g. { "owner": "jack" }) - post: - tags: - - Table - - Data - summary: Create a table with the given name - operationId: CreateTable - description: | - Create table `id` in the namespace with the given data in Arrow IPC stream. - - The schema of the Arrow IPC stream is used as the table schema. - If the stream is empty, the API creates a new empty table. - - REST NAMESPACE ONLY - REST namespace uses Arrow IPC stream as the request body. - It passes in the `CreateTableRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `location`: pass through header `x-lance-table-location` - - `properties`: pass through header `x-lance-table-properties` - requestBody: - description: Arrow IPC data - content: - application/vnd.apache.arrow.stream: - schema: - type: string - format: binary - required: true - responses: - 200: - $ref: '#/components/responses/CreateTableResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/explain_plan: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Get query execution plan explanation - operationId: ExplainTableQueryPlan - description: | - Get the query execution plan for a query against table `id`. - Returns a human-readable explanation of how the query will be executed. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/ExplainTableQueryPlanRequest' - responses: - 200: - $ref: '#/components/responses/ExplainTableQueryPlanResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/analyze_plan: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Analyze query execution plan - operationId: AnalyzeTableQueryPlan - description: | - Analyze the query execution plan for a query against table `id`. - Returns detailed statistics and analysis of the query execution plan. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AnalyzeTableQueryPlanRequest' - responses: - 200: - $ref: '#/components/responses/AnalyzeTableQueryPlanResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/add_columns: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Data - summary: Add new columns to table schema - operationId: AlterTableAddColumns - description: | - Add new columns to table `id` using SQL expressions or default values. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableAddColumnsRequest' - responses: - 200: - $ref: '#/components/responses/AlterTableAddColumnsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - # Table Index APIs - - /v1/table/{id}/create_index: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Index - - Metadata - summary: Create an index on a table - operationId: CreateTableIndex - description: | - Create an index on a table column for faster search operations. - Supports vector indexes (IVF_FLAT, IVF_HNSW_SQ, IVF_PQ, etc.) and scalar indexes (BTREE, BITMAP, FTS, etc.). - Index creation is handled asynchronously. - Use the `ListTableIndices` and `DescribeTableIndexStats` operations to monitor index creation progress. - requestBody: - description: Index creation request - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTableIndexRequest' - required: true - responses: - 200: - $ref: '#/components/responses/CreateTableIndexResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/index/list: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Index - - Metadata - summary: List indexes on a table - operationId: ListTableIndices - description: | - List all indices created on a table. Returns information about each index - including name, columns, status, and UUID. - requestBody: - description: Index list request - content: - application/json: - schema: - $ref: '#/components/schemas/ListTableIndicesRequest' - required: true - responses: - 200: - $ref: '#/components/responses/ListTableIndicesResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/index/{index_name}/stats: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: index_name - in: path - description: Name of the index to get stats for - required: true - schema: - type: string - post: - tags: - - Table - - Index - - Metadata - summary: Get table index statistics - operationId: DescribeTableIndexStats - description: | - Get statistics for a specific index on a table. Returns information about - the index type, distance type (for vector indices), and row counts. - requestBody: - description: Index stats request - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTableIndexStatsRequest' - required: true - responses: - 200: - $ref: '#/components/responses/DescribeTableIndexStatsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/index/{index_name}/drop: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: index_name - in: path - description: Name of the index to drop - required: true - schema: - type: string - post: - tags: - - Table - - Index - - Metadata - summary: Drop a specific index - operationId: DropTableIndex - description: | - Drop the specified index from table `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DropTableIndexRequest' - responses: - 200: - $ref: '#/components/responses/DropTableIndexResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - # Table Tag APIs - - /v1/table/{id}/tags/list: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - - name: 'page_token' - in: query - schema: - $ref: '#/components/schemas/PageToken' - - name: 'limit' - in: query - schema: - $ref: '#/components/schemas/PageLimit' - get: - tags: - - Table - - Tag - - Metadata - summary: List all tags for a table - operationId: ListTableTags - description: | - List all tags that have been created for table `id`. - Returns a map of tag names to their corresponding version numbers and metadata. - - REST NAMESPACE ONLY - REST namespace uses GET to perform this operation without a request body. - It passes in the `ListTableTagsRequest` information in the following way: - - `id`: pass through path parameter of the same name - - `page_token`: pass through query parameter of the same name - - `limit`: pass through query parameter of the same name - responses: - 200: - $ref: '#/components/responses/ListTableTagsResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/tags/version: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Tag - - Metadata - summary: Get version for a specific tag - operationId: GetTableTagVersion - description: | - Get the version number that a specific tag points to for table `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/GetTableTagVersionRequest' - responses: - 200: - $ref: '#/components/responses/GetTableTagVersionResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/tags/create: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Tag - - Metadata - summary: Create a new tag - operationId: CreateTableTag - description: | - Create a new tag for table `id` that points to a specific version. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTableTagRequest' - responses: - 200: - description: Success, no content - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 409: - $ref: '#/components/responses/ConflictErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/tags/delete: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Tag - - Metadata - summary: Delete a tag - operationId: DeleteTableTag - description: | - Delete an existing tag from table `id`. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteTableTagRequest' - responses: - 200: - description: Success, no content - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/table/{id}/tags/update: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Table - - Tag - - Metadata - summary: Update a tag to point to a different version - operationId: UpdateTableTag - description: | - Update an existing tag for table `id` to point to a different version. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTableTagRequest' - responses: - 200: - description: Success, no content - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - # Transaction API - - /v1/transaction/{id}/describe: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Transaction - - Metadata - summary: Describe information about a transaction - operationId: DescribeTransaction - description: | - Return a detailed information for a given transaction - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTransactionRequest' - responses: - 200: - $ref: '#/components/responses/DescribeTransactionResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - - /v1/transaction/{id}/alter: - parameters: - - $ref: '#/components/parameters/id' - - $ref: '#/components/parameters/delimiter' - post: - tags: - - Transaction - - Metadata - summary: Alter information of a transaction. - operationId: AlterTransaction - description: | - Alter a transaction with a list of actions such as setting status or properties. - The server should either succeed and apply all actions, or fail and apply no action. - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTransactionRequest' - responses: - 200: - $ref: '#/components/responses/AlterTransactionResponse' - 400: - $ref: '#/components/responses/BadRequestErrorResponse' - 401: - $ref: '#/components/responses/UnauthorizedErrorResponse' - 403: - $ref: '#/components/responses/ForbiddenErrorResponse' - 404: - $ref: '#/components/responses/NotFoundErrorResponse' - 409: - $ref: '#/components/responses/ConflictErrorResponse' - 503: - $ref: '#/components/responses/ServiceUnavailableErrorResponse' - 5XX: - $ref: '#/components/responses/ServerErrorResponse' - -components: - parameters: - id: - name: id - description: | - `string identifier` of an object in a namespace, following the Lance Namespace spec. - When the value is equal to the delimiter, it represents the root namespace. - For example, `v1/namespace/./list` performs a `ListNamespace` on the root namespace. - in: path - required: true - schema: - type: string - delimiter: - name: delimiter - description: | - An optional delimiter of the `string identifier`, following the Lance Namespace spec. - When not specified, the `.` delimiter must be used. - in: query - required: false - schema: - $ref: '#/components/schemas/PageToken' - - schemas: - ErrorResponse: - type: object - description: Common JSON error response model - properties: - error: - type: string - description: a brief, human-readable message about the error - example: Incorrect username or password - code: - type: integer - minimum: 400 - maximum: 600 - description: | - HTTP style response code, where 4XX represents client side errors - and 5XX represents server side errors. - - For implementations that uses HTTP (e.g. REST namespace), - this field can be optional in favor of the HTTP response status code. - In case both values exist and do not match, the HTTP response status code should be used. - example: 404 - type: - type: string - description: | - An optional type identifier string for the error. - This allows the implementation to specify their internal error type, - which could be more detailed than the HTTP standard status code. - example: /errors/incorrect-user-pass - detail: - type: string - description: | - an optional human-readable explanation of the error. - This can be used to record information such as stack trace. - example: Authentication failed due to incorrect username or password - instance: - type: string - description: | - a string that identifies the specific occurrence of the error. - This can be a URI, a request or response ID, - or anything that the implementation can recognize to trace specific occurrence of the error. - example: /login/log/abc123 - - CreateNamespaceRequest: - type: object - properties: - id: - type: array - items: - type: string - mode: - type: string - description: | - There are three modes when trying to create a namespace, - to differentiate the behavior when a namespace of the same name already exists: - * CREATE: the operation fails with 409. - * EXIST_OK: the operation succeeds and the existing namespace is kept. - * OVERWRITE: the existing namespace is dropped and a new empty namespace with this name is created. - enum: - - CREATE - - EXIST_OK - - OVERWRITE - properties: - type: object - additionalProperties: - type: string - - CreateNamespaceResponse: - type: object - properties: - properties: - description: | - Properties after the namespace is created. - - If the server does not support namespace properties, it should return null for this field. - If namespace properties are supported, but none are set, it should return an empty object. - type: object - additionalProperties: - type: string - - ListNamespacesRequest: - type: object - properties: - id: - type: array - items: - type: string - page_token: - $ref: "#/components/schemas/PageToken" - limit: - $ref: "#/components/schemas/PageLimit" - - ListNamespacesResponse: - type: object - required: - - namespaces - properties: - namespaces: - type: array - uniqueItems: true - description: | - The list of names of the child namespaces relative to the parent namespace `id` in the request. - items: - type: string - page_token: - $ref: "#/components/schemas/PageToken" - - DescribeNamespaceRequest: - type: object - properties: - id: - type: array - items: - type: string - - DescribeNamespaceResponse: - type: object - properties: - properties: - type: object - description: - Properties stored on the namespace, if supported by the server. - If the server does not support namespace properties, it should return null for this field. - If namespace properties are supported, but none are set, it should return an empty object. - additionalProperties: - type: string - example: { "owner": "Ralph", 'created_at': '1452120468' } - default: { } - nullable: true - - DropNamespaceRequest: - type: object - properties: - id: - type: array - items: - type: string - mode: - type: string - description: | - The mode for dropping a namespace, deciding the server behavior when the namespace to drop is not found. - - FAIL (default): the server must return 400 indicating the namespace to drop does not exist. - - SKIP: the server must return 204 indicating the drop operation has succeeded. - enum: - - SKIP - - FAIL - behavior: - type: string - description: | - The behavior for dropping a namespace. - - RESTRICT (default): the namespace should not contain any table or child namespace when drop is initiated. - If tables are found, the server should return error and not drop the namespace. - - CASCADE: all tables and child namespaces in the namespace are dropped before the namespace is dropped. - enum: - - RESTRICT - - CASCADE - - DropNamespaceResponse: - type: object - properties: - properties: - type: object - additionalProperties: - type: string - transactionId: - description: | - If present, indicating the operation is long running and should be tracked using GetTransaction - type: array - items: - type: string - - NamespaceExistsRequest: - type: object - properties: - id: - type: array - items: - type: string - - PageToken: - description: | - An opaque token that allows pagination for list operations (e.g. ListNamespaces). - - For an initial request of a list operation, - if the implementation cannot return all items in one response, - or if there are more items than the page limit specified in the request, - the implementation must return a page token in the response, - indicating there are more results available. - - After the initial request, - the value of the page token from each response must be used - as the page token value for the next request. - - Caller must interpret either `null`, - missing value or empty string value of the page token from - the implementation's response as the end of the listing results. - type: string - nullable: true - - PageLimit: - description: | - An inclusive upper bound of the - number of results that a caller will receive. - type: integer - nullable: true - - RegisterTableRequest: - type: object - required: - - location - properties: - id: - type: array - items: - type: string - location: - type: string - mode: - type: string - description: | - There are two modes when trying to register a table, - to differentiate the behavior when a table of the same name already exists: - * CREATE (default): the operation fails with 409. - * OVERWRITE: the existing table registration is replaced with the new registration. - enum: - - CREATE - - OVERWRITE - properties: - type: object - additionalProperties: - type: string - - RegisterTableResponse: - type: object - required: - - location - properties: - location: - type: string - properties: - type: object - additionalProperties: - type: string - - ListTablesRequest: - type: object - properties: - id: - type: array - items: - type: string - page_token: - $ref: "#/components/schemas/PageToken" - limit: - $ref: "#/components/schemas/PageLimit" - - ListTablesResponse: - type: object - required: - - tables - properties: - tables: - type: array - uniqueItems: true - description: | - The list of names of the tables relative to the parent namespace `id` in the request. - items: - type: string - page_token: - $ref: "#/components/schemas/PageToken" - - DescribeTableRequest: - type: object - properties: - id: - type: array - items: - type: string - version: - description: | - Version of the table to describe. - If not specified, server should resolve it to the latest version. - type: integer - format: int64 - minimum: 0 - - DescribeTableResponse: - type: object - properties: - version: - type: integer - format: int64 - minimum: 0 - location: - type: string - schema: - $ref: '#/components/schemas/JsonArrowSchema' - properties: - type: object - additionalProperties: - type: string - storage_options: - type: object - description: | - Configuration options to be used to access storage. The available - options depend on the type of storage in use. These will be - passed directly to Lance to initialize storage access. - additionalProperties: - type: string - - CountTableRowsRequest: - type: object - properties: - id: - type: array - items: - type: string - version: - description: | - Version of the table to describe. - If not specified, server should resolve it to the latest version. - type: integer - format: int64 - minimum: 0 - filter: - description: | - SQL filter expression to be applied - type: string - - CountTableRowsResponse: - type: integer - format: int64 - description: | - Response containing the count of rows. - Serializes transparently as just the number for backward compatibility. - minimum: 0 - - InsertIntoTableRequest: - type: object - description: | - Request for inserting records into a table, excluding the Arrow IPC stream. - properties: - id: - type: array - items: - type: string - mode: - type: string - enum: - - append - - overwrite - default: append - - InsertIntoTableResponse: - type: object - description: Response from inserting records into a table - properties: - version: - type: integer - format: int64 - description: The version of the table after the insert - minimum: 0 - - MergeInsertIntoTableRequest: - type: object - description: | - Request for merging or inserting records into a table, excluding the Arrow IPC stream. - properties: - id: - type: array - items: - type: string - "on": - description: Column name to use for matching rows (required) - type: string - when_matched_delete: - description: Delete all rows in target table where a match exists in source table - type: boolean - default: false - when_matched_update_all: - description: Update all columns when rows match - type: boolean - default: false - when_matched_update_all_filt: - description: The row is updated (similar to UpdateAll) only for rows where the SQL expression evaluates to true - type: string - when_not_matched_insert_all: - description: Insert all columns when rows don't match - type: boolean - default: false - when_not_matched_by_source_delete: - description: Delete all rows from target table that don't match a row in the source table - type: boolean - default: false - when_not_matched_by_source_delete_filt: - description: Delete rows from the target table if there is no match AND the SQL expression evaluates to true - type: string - - MergeInsertIntoTableResponse: - type: object - description: Response from merge insert operation - properties: - num_updated_rows: - type: integer - format: int64 - description: Number of rows updated - minimum: 0 - num_inserted_rows: - type: integer - format: int64 - description: Number of rows inserted - minimum: 0 - num_deleted_rows: - type: integer - format: int64 - description: Number of rows deleted (typically 0 for merge insert) - minimum: 0 - version: - type: integer - format: int64 - description: The commit version associated with the operation - minimum: 0 - - UpdateTableRequest: - type: object - description: | - Each update consists of a column name and an SQL expression that will be - evaluated against the current row's value. Optionally, a predicate can be - provided to filter which rows to update. - required: - - updates - properties: - id: - type: array - items: - type: string - predicate: - type: string - nullable: true - description: Optional SQL predicate to filter rows for update - updates: - type: array - items: - type: array - minItems: 2 - maxItems: 2 - items: - type: string - description: List of column updates as [column_name, expression] pairs - - UpdateTableResponse: - type: object - required: - - updated_rows - - version - properties: - updated_rows: - type: integer - format: int64 - description: Number of rows updated - minimum: 0 - version: - type: integer - format: int64 - description: The commit version associated with the operation - minimum: 0 - - DeleteFromTableRequest: - type: object - description: | - Delete data from table based on a SQL predicate. - Returns the number of rows that were deleted. - required: - - predicate - properties: - id: - type: array - items: - type: string - description: The namespace identifier - predicate: - type: string - description: SQL predicate to filter rows for deletion - - DeleteFromTableResponse: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - description: The commit version associated with the operation - minimum: 0 - - QueryTableRequest: - type: object - required: - - vector - - k - properties: - id: - type: array - items: - type: string - bypass_vector_index: - type: boolean - description: Whether to bypass vector index - columns: - type: array - nullable: true - items: - type: string - description: Optional list of columns to return - distance_type: - type: string - description: Distance metric to use - ef: - type: integer - minimum: 0 - description: Search effort parameter for HNSW index - fast_search: - type: boolean - description: Whether to use fast search - filter: - type: string - description: Optional SQL filter expression - full_text_query: - type: object - nullable: true - description: Optional full-text search query. Provide either string_query or structured_query, not both. - properties: - string_query: - $ref: '#/components/schemas/StringFtsQuery' - structured_query: - $ref: '#/components/schemas/StructuredFtsQuery' - k: - type: integer - minimum: 0 - description: Number of results to return - lower_bound: - type: number - format: float - description: Lower bound for search - nprobes: - type: integer - minimum: 0 - description: Number of probes for IVF index - offset: - type: integer - minimum: 0 - description: Number of results to skip - prefilter: - type: boolean - description: Whether to apply filtering before vector search - refine_factor: - type: integer - format: int32 - minimum: 0 - description: Refine factor for search - upper_bound: - type: number - format: float - description: Upper bound for search - vector: - type: object - nullable: true - description: Query vector(s) for similarity search. Provide either single_vector or multi_vector, not both. - properties: - single_vector: - type: array - items: - type: number - format: float - description: Single query vector - multi_vector: - type: array - items: - type: array - items: - type: number - format: float - description: Multiple query vectors for batch search - vector_column: - type: string - description: Name of the vector column to search - version: - type: integer - format: int64 - minimum: 0 - description: Table version to query - with_row_id: - type: boolean - description: If true, return the row id as a column called `_rowid` - - CreateTableIndexRequest: - type: object - required: - - column - - index_type - properties: - id: - type: array - items: - type: string - column: - type: string - description: Name of the column to create index on - index_type: - type: string - enum: - - BTREE - - BITMAP - - LABEL_LIST - - IVF_FLAT - - IVF_PQ - - IVF_HNSW_SQ - - FTS - description: Type of index to create - metric_type: - type: string - enum: - - l2 - - cosine - - dot - nullable: true - description: Distance metric type for vector indexes - with_position: - type: boolean - nullable: true - description: Optional FTS parameter for position tracking - base_tokenizer: - type: string - nullable: true - description: Optional FTS parameter for base tokenizer - language: - type: string - nullable: true - description: Optional FTS parameter for language - max_token_length: - type: integer - nullable: true - minimum: 0 - description: Optional FTS parameter for maximum token length - lower_case: - type: boolean - nullable: true - description: Optional FTS parameter for lowercase conversion - stem: - type: boolean - nullable: true - description: Optional FTS parameter for stemming - remove_stop_words: - type: boolean - nullable: true - description: Optional FTS parameter for stop word removal - ascii_folding: - type: boolean - nullable: true - description: Optional FTS parameter for ASCII folding - - CreateTableIndexResponse: - type: object - required: - - location - properties: - id: - type: array - items: - type: string - location: - type: string - description: Table location (usually empty) - properties: - type: object - additionalProperties: - type: string - description: Additional properties (usually empty) - - ListTableIndicesRequest: - type: object - properties: - id: - type: array - items: - type: string - description: The namespace identifier - version: - type: integer - format: int64 - minimum: 0 - nullable: true - description: Optional table version to list indexes from - page_token: - $ref: '#/components/schemas/PageToken' - limit: - $ref: '#/components/schemas/PageLimit' - - ListTableIndicesResponse: - type: object - required: - - indexes - properties: - indexes: - type: array - items: - $ref: '#/components/schemas/IndexContent' - description: List of indexes on the table - page_token: - $ref: '#/components/schemas/PageToken' - - IndexContent: - type: object - required: - - index_name - - index_uuid - - columns - - status - properties: - index_name: - type: string - description: Name of the index - index_uuid: - type: string - description: Unique identifier for the index - columns: - type: array - items: - type: string - description: Columns covered by this index - status: - type: string - description: Current status of the index - - DescribeTableIndexStatsRequest: - type: object - properties: - id: - type: array - items: - type: string - version: - type: integer - format: int64 - minimum: 0 - nullable: true - description: Optional table version to get stats for - index_name: - type: string - description: Name of the index - - DescribeTableIndexStatsResponse: - type: object - properties: - distance_type: - type: string - nullable: true - description: Distance type for vector indexes - index_type: - type: string - nullable: true - description: Type of the index - num_indexed_rows: - type: integer - format: int64 - minimum: 0 - nullable: true - description: Number of indexed rows - num_unindexed_rows: - type: integer - format: int64 - minimum: 0 - nullable: true - description: Number of unindexed rows - - JsonArrowSchema: - type: object - description: | - JSON representation of a Apache Arrow schema. - required: - - fields - properties: - fields: - type: array - items: - $ref: '#/components/schemas/JsonArrowField' - metadata: - type: object - additionalProperties: - type: string - propertyNames: - type: string - - JsonArrowField: - type: object - description: | - JSON representation of an Apache Arrow field. - required: - - name - - type - - nullable - properties: - metadata: - type: object - additionalProperties: - type: string - propertyNames: - type: string - name: - type: string - nullable: - type: boolean - type: - $ref: '#/components/schemas/JsonArrowDataType' - JsonArrowDataType: - type: object - description: JSON representation of an Apache Arrow DataType - required: - - type - properties: - fields: - type: array - items: - $ref: '#/components/schemas/JsonArrowField' - description: Fields for complex types like Struct, Union, etc. - length: - type: integer - format: int64 - description: Length for fixed-size types - minimum: 0 - type: - type: string - description: The data type name - - Binary: - type: string - format: binary - - CreateTableRequest: - type: object - description: | - Request for creating a table, excluding the Arrow IPC stream. - properties: - id: - type: array - items: - type: string - location: - type: string - schema: - $ref: '#/components/schemas/JsonArrowSchema' - properties: - type: object - additionalProperties: - type: string - - CreateTableResponse: - type: object - properties: - location: - type: string - version: - type: integer - format: int64 - minimum: 0 - schema: - $ref: '#/components/schemas/JsonArrowSchema' - properties: - type: object - additionalProperties: - type: string - storage_options: - type: object - description: | - Configuration options to be used to access storage. The available - options depend on the type of storage in use. These will be - passed directly to Lance to initialize storage access. - additionalProperties: - type: string - - TableExistsRequest: - type: object - properties: - id: - type: array - items: - type: string - version: - description: | - Version of the table to check existence. - If not specified, server should resolve it to the latest version. - type: integer - format: int64 - minimum: 0 - - TransactionStatus: - type: string - enum: - - QUEUED - - RUNNING - - SUCCEEDED - - FAILED - - CANCELED - - DescribeTransactionRequest: - type: object - properties: - id: - type: array - items: - type: string - - DescribeTransactionResponse: - type: object - required: - - status - properties: - status: - $ref: '#/components/schemas/TransactionStatus' - properties: - type: object - additionalProperties: - type: string - - AlterTransactionSetStatus: - type: object - properties: - status: - $ref: '#/components/schemas/TransactionStatus' - - AlterTransactionSetProperty: - type: object - properties: - key: - type: string - value: - type: string - mode: - $ref: '#/components/schemas/SetPropertyMode' - - SetPropertyMode: - type: string - description: | - The behavior if the property key already exists. - - OVERWRITE (default): overwrite the existing value with the provided value - - FAIL: fail the entire operation - - SKIP: keep the existing value and skip setting the provided value - enum: - - OVERWRITE - - FAIL - - SKIP - - AlterTransactionUnsetProperty: - type: object - properties: - key: - type: string - mode: - $ref: '#/components/schemas/UnsetPropertyMode' - - UnsetPropertyMode: - type: string - description: | - The behavior if the property key to unset does not exist. - - SKIP (default): skip the property to unset - - FAIL: fail the entire operation - enum: - - SKIP - - FAIL - - AlterTransactionAction: - type: object - description: | - A single action that could be performed to alter a transaction. - This action holds the model definition for all types of specific actions models, - this is to minimize difference and compatibility issue across codegen in different languages. - When used, only one of the actions should be non-null for each action. - If you would like to perform multiple actions, set a list of actions in the AlterTransactionRequest. - properties: - setStatusAction: - $ref: '#/components/schemas/AlterTransactionSetStatus' - setPropertyAction: - $ref: '#/components/schemas/AlterTransactionSetProperty' - unsetPropertyAction: - $ref: '#/components/schemas/AlterTransactionUnsetProperty' - - AlterTransactionRequest: - type: object - description: | - Alter a transaction with a list of actions. - The server should either succeed and apply all actions, or fail and apply no action. - required: - - actions - properties: - id: - type: array - items: - type: string - actions: - type: array - minItems: 1 - items: - $ref: '#/components/schemas/AlterTransactionAction' - - AlterTransactionResponse: - type: object - required: - - status - properties: - status: - $ref: '#/components/schemas/TransactionStatus' - properties: - type: object - additionalProperties: - type: string - - DropTableRequest: - type: object - description: | - If the table and its data can be immediately deleted, return information of the deleted table. - Otherwise, return a transaction ID that client can use to track deletion progress. - properties: - id: - type: array - items: - type: string - - DropTableResponse: - type: object - properties: - id: - type: array - items: - type: string - location: - type: string - properties: - type: object - additionalProperties: - type: string - transactionId: - type: array - description: | - If present, indicating the operation is long running and should be tracked using GetTransaction - items: - type: string - - DeregisterTableRequest: - type: object - description: | - The table content remains available in the storage. - properties: - id: - type: array - items: - type: string - - DeregisterTableResponse: - type: object - properties: - id: - type: array - items: - type: string - location: - type: string - properties: - type: object - additionalProperties: - type: string - - StringFtsQuery: - type: object - required: - - query - properties: - columns: - type: array - items: - type: string - query: - type: string - - StructuredFtsQuery: - type: object - required: - - query - properties: - query: - $ref: '#/components/schemas/FtsQuery' - - FtsQuery: - type: object - description: | - Full-text search query. Exactly one query type field must be provided. - This structure follows the same pattern as AlterTransactionAction to minimize - differences and compatibility issues across codegen in different languages. - properties: - match: - $ref: '#/components/schemas/MatchQuery' - phrase: - $ref: '#/components/schemas/PhraseQuery' - boost: - $ref: '#/components/schemas/BoostQuery' - multi_match: - $ref: '#/components/schemas/MultiMatchQuery' - boolean: - $ref: '#/components/schemas/BooleanQuery' - - MatchQuery: - type: object - required: - - terms - - column - properties: - boost: - type: number - format: float - column: - type: string - fuzziness: - type: integer - format: int32 - minimum: 0 - max_expansions: - type: integer - description: |- - The maximum number of terms to expand for fuzzy matching. - Default to 50. - minimum: 0 - operator: - $ref: '#/components/schemas/Operator' - description: |- - The operator to use for combining terms. - This can be either `And` or `Or`, it's 'Or' by default. - - `And`: All terms must match. - - `Or`: At least one term must match. - prefix_length: - type: integer - format: int32 - description: |- - The number of beginning characters being unchanged for fuzzy matching. - Default to 0. - minimum: 0 - terms: - type: string - - PhraseQuery: - type: object - required: - - terms - properties: - column: - type: string - slop: - type: integer - format: int32 - minimum: 0 - terms: - type: string - - BoostQuery: - type: object - description: Boost query that scores documents matching positive query higher and negative query lower - required: - - positive - - negative - properties: - positive: - $ref: '#/components/schemas/FtsQuery' - negative: - $ref: '#/components/schemas/FtsQuery' - negative_boost: - type: number - format: float - description: 'Boost factor for negative query (default: 0.5)' - default: 0.5 - - MultiMatchQuery: - type: object - required: - - match_queries - properties: - match_queries: - type: array - items: - $ref: '#/components/schemas/MatchQuery' - - BooleanQuery: - type: object - description: Boolean query with must, should, and must_not clauses - required: - - should - - must - - must_not - properties: - must: - type: array - items: - $ref: '#/components/schemas/FtsQuery' - description: Queries that must match (AND) - must_not: - type: array - items: - $ref: '#/components/schemas/FtsQuery' - description: Queries that must not match (NOT) - should: - type: array - items: - $ref: '#/components/schemas/FtsQuery' - description: Queries that should match (OR) - - Operator: - type: string - enum: - - And - - Or - - GetTableTagVersionRequest: - type: object - required: - - tag - properties: - id: - type: array - items: - type: string - tag: - type: string - description: Name of the tag to get version for - - GetTableTagVersionResponse: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - minimum: 0 - description: version number that the tag points to - - CreateTableTagRequest: - type: object - required: - - tag - - version - properties: - id: - type: array - items: - type: string - tag: - type: string - description: Name of the tag to create - version: - type: integer - format: int64 - minimum: 0 - description: Version number for the tag to point to - - DeleteTableTagRequest: - type: object - required: - - tag - properties: - id: - type: array - items: - type: string - tag: - type: string - description: Name of the tag to delete - - UpdateTableTagRequest: - type: object - required: - - tag - - version - properties: - id: - type: array - items: - type: string - tag: - type: string - description: Name of the tag to update - version: - type: integer - format: int64 - minimum: 0 - description: New version number for the tag to point to - - ListTableTagsResponse: - type: object - required: - - tags - properties: - tags: - type: object - additionalProperties: - $ref: '#/components/schemas/TagContents' - description: Map of tag names to their contents - - TagContents: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version number that the tag points to - - RestoreTableRequest: - type: object - properties: - id: - type: array - items: - type: string - version: - type: integer - format: int64 - minimum: 0 - description: Version to restore to (if not specified, restores to current version) - - RestoreTableResponse: - type: object - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version of the table after restore operation - - ListTableVersionsRequest: - type: object - properties: - id: - type: array - items: - type: string - page_token: - $ref: '#/components/schemas/PageToken' - limit: - $ref: '#/components/schemas/PageLimit' - - ListTableVersionsResponse: - type: object - required: - - versions - properties: - versions: - type: array - items: - $ref: '#/components/schemas/TableVersion' - description: List of table versions - page_token: - $ref: '#/components/schemas/PageToken' - - TableVersion: - type: object - required: - - version - - timestamp - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version number - timestamp: - type: string - format: date-time - description: Timestamp when the version was created - - ExplainTableQueryPlanRequest: - type: object - required: - - query - properties: - id: - type: array - items: - type: string - query: - $ref: '#/components/schemas/QueryTableRequest' - verbose: - type: boolean - default: false - description: Whether to return verbose explanation - - ExplainTableQueryPlanResponse: - type: object - required: - - plan - properties: - plan: - type: string - description: Human-readable query execution plan - - AnalyzeTableQueryPlanRequest: - type: object - required: - - query - properties: - id: - type: array - items: - type: string - query: - $ref: '#/components/schemas/QueryTableRequest' - - AnalyzeTableQueryPlanResponse: - type: object - required: - - analysis - properties: - analysis: - type: string - description: Detailed analysis of the query execution plan - - AlterTableAddColumnsRequest: - type: object - required: - - new_columns - properties: - id: - type: array - items: - type: string - new_columns: - type: array - items: - $ref: '#/components/schemas/NewColumnTransform' - description: List of new columns to add - - NewColumnTransform: - type: object - required: - - name - - expression - properties: - name: - type: string - description: Name of the new column - expression: - type: string - description: SQL expression to compute the column value - - AlterTableAddColumnsResponse: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version of the table after adding columns - - AlterTableAlterColumnsRequest: - type: object - required: - - alterations - properties: - id: - type: array - items: - type: string - alterations: - type: array - items: - $ref: '#/components/schemas/ColumnAlteration' - description: List of column alterations to perform - - ColumnAlteration: - type: object - required: - - column - properties: - column: - type: string - description: Name of the column to alter - rename: - type: string - description: New name for the column (optional) - cast_to: - type: string - description: New data type to cast the column to (optional) - - AlterTableAlterColumnsResponse: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version of the table after altering columns - - AlterTableDropColumnsRequest: - type: object - required: - - columns - properties: - id: - type: array - items: - type: string - columns: - type: array - items: - type: string - description: Names of columns to drop - - AlterTableDropColumnsResponse: - type: object - required: - - version - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version of the table after dropping columns - - GetTableStatsRequest: - type: object - properties: - id: - type: array - items: - type: string - - GetTableStatsResponse: - type: object - required: - - num_rows - - size_bytes - properties: - num_rows: - type: integer - format: int64 - minimum: 0 - description: Total number of rows in the table - size_bytes: - type: integer - format: int64 - minimum: 0 - description: Total size of the table in bytes - num_fragments: - type: integer - format: int64 - minimum: 0 - description: Number of data fragments - - DropTableIndexRequest: - type: object - required: - - id - - index_name - properties: - id: - type: array - items: - type: string - index_name: - type: string - description: Name of the index to drop - - DropTableIndexResponse: - type: object - properties: - version: - type: integer - format: int64 - minimum: 0 - description: Version of the table after dropping the index - - responses: - ListNamespacesResponse: - description: A list of namespaces - content: - application/json: - schema: - $ref: '#/components/schemas/ListNamespacesResponse' - examples: - NonEmptyResponse: - $ref: '#/components/examples/ListNamespacesNonEmptyExample' - EmptyResponse: - $ref: '#/components/examples/ListNamespacesEmptyExample' - - DescribeNamespaceResponse: - description: - Returns a namespace, as well as any properties stored on the namespace if namespace properties - are supported by the server. - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeNamespaceResponse' - - CreateNamespaceResponse: - description: - Result of creating a namespace - content: - application/json: - schema: - $ref: '#/components/schemas/CreateNamespaceResponse' - - DropNamespaceResponse: - description: - Result of dropping a namespace - content: - application/json: - schema: - $ref: '#/components/schemas/DropNamespaceResponse' - - ListTablesResponse: - description: A list of tables - content: - application/json: - schema: - $ref: '#/components/schemas/ListTablesResponse' - - DescribeTableResponse: - description: Table properties result when loading a table - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTableResponse' - - CountTableRowsResponse: - description: Result of counting rows in a table - content: - application/json: - schema: - $ref: '#/components/schemas/CountTableRowsResponse' - - CreateTableResponse: - description: Table properties result when creating a table - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTableResponse' - - InsertIntoTableResponse: - description: Result of inserting records into a table - content: - application/json: - schema: - $ref: '#/components/schemas/InsertIntoTableResponse' - - MergeInsertIntoTableResponse: - description: Result of merge insert operation - content: - application/json: - schema: - $ref: '#/components/schemas/MergeInsertIntoTableResponse' - - RegisterTableResponse: - description: Table properties result when registering a table - content: - application/json: - schema: - $ref: '#/components/schemas/RegisterTableResponse' - - DescribeTransactionResponse: - description: Response of GetTransaction - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTransactionResponse' - - AlterTransactionResponse: - description: Response of AlterTransaction - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTransactionResponse' - - DropTableResponse: - description: - Response of DropTable - content: - application/json: - schema: - $ref: '#/components/schemas/DropTableResponse' - - DeregisterTableResponse: - description: - Response of DeregisterTable - content: - application/json: - schema: - $ref: '#/components/schemas/DeregisterTableResponse' - - UpdateTableResponse: - description: Update successful - content: - application/json: - schema: - $ref: '#/components/schemas/UpdateTableResponse' - - DeleteFromTableResponse: - description: Delete successful - content: - application/json: - schema: - $ref: '#/components/schemas/DeleteFromTableResponse' - - QueryTableResponse: - description: Query results in Arrow IPC file or stream format - content: - application/vnd.apache.arrow.file: - schema: - type: string - format: binary - application/vnd.apache.arrow.stream: - schema: - type: string - format: binary - - CreateTableIndexResponse: - description: Index created successfully - content: - application/json: - schema: - $ref: '#/components/schemas/CreateTableIndexResponse' - - ListTableIndicesResponse: - description: List of indices on the table - content: - application/json: - schema: - $ref: '#/components/schemas/ListTableIndicesResponse' - - DescribeTableIndexStatsResponse: - description: Index statistics - content: - application/json: - schema: - $ref: '#/components/schemas/DescribeTableIndexStatsResponse' - - # Tag operation responses - - ListTableTagsResponse: - description: List of table tags - content: - application/json: - schema: - $ref: '#/components/schemas/ListTableTagsResponse' - - GetTableTagVersionResponse: - description: Tag version information - content: - application/json: - schema: - $ref: '#/components/schemas/GetTableTagVersionResponse' - - # Table operation responses - - ListTableVersionsResponse: - description: List of table versions - content: - application/json: - schema: - $ref: '#/components/schemas/ListTableVersionsResponse' - - ExplainTableQueryPlanResponse: - description: Query execution plan explanation - content: - application/json: - schema: - $ref: '#/components/schemas/ExplainTableQueryPlanResponse' - - AnalyzeTableQueryPlanResponse: - description: Query execution plan analysis - content: - application/json: - schema: - $ref: '#/components/schemas/AnalyzeTableQueryPlanResponse' - - AlterTableAddColumnsResponse: - description: Add columns operation result - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableAddColumnsResponse' - - AlterTableAlterColumnsResponse: - description: Alter columns operation result - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableAlterColumnsResponse' - - AlterTableDropColumnsResponse: - description: Drop columns operation result - content: - application/json: - schema: - $ref: '#/components/schemas/AlterTableDropColumnsResponse' - - GetTableStatsResponse: - description: Table statistics - content: - application/json: - schema: - $ref: '#/components/schemas/GetTableStatsResponse' - - RestoreTableResponse: - description: Table restore operation result - content: - application/json: - schema: - $ref: '#/components/schemas/RestoreTableResponse' - - DropTableIndexResponse: - description: Index drop operation result - content: - application/json: - schema: - $ref: '#/components/schemas/DropTableIndexResponse' - - # Error Responses - - BadRequestErrorResponse: - description: - Indicates a bad request error. It could be caused by an unexpected request - body format or other forms of request validation failure, such as invalid json. - Usually serves application/json content, although in some cases simple text/plain content might - be returned by the server's middleware. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/bad-request", - "title": "Malformed request", - "status": 400, - "detail": "", - "instance": "/v1/namespaces" - } - - UnauthorizedErrorResponse: - description: Unauthorized. The request lacks valid authentication credentials for the operation. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/unauthorized-request", - "title": "No valid authentication credentials for the operation", - "status": 401, - "detail": "", - "instance": "/v1/namespaces" - } - - ForbiddenErrorResponse: - description: Forbidden. Authenticated user does not have the necessary permissions. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/forbidden-request", - "title": "Not authorized to make this request", - "status": 403, - "detail": "", - "instance": "/v1/namespaces" - } - - NotFoundErrorResponse: - description: - A server-side problem that means can not find the specified resource. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/not-found-error", - "title": "Not found Error", - "status": 404, - "detail": "", - "instance": "/v1/namespaces/{ns}" - } - - UnsupportedOperationErrorResponse: - description: Not Acceptable / Unsupported Operation. The server does not support this operation. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/unsupported-operation", - "title": "The server does not support this operation", - "status": 406, - "detail": "", - "instance": "/v1/namespaces" - } - - ConflictErrorResponse: - description: The request conflicts with the current state of the target resource. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/conflict", - "title": "The namespace has been concurrently modified", - "status": 409, - "detail": "", - "instance": "/v1/namespaces/{ns}" - } - - ServiceUnavailableErrorResponse: - description: - The service is not ready to handle the request. The client should wait and retry. - The service may additionally send a Retry-After header to indicate when to retry. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/service-unavailable", - "title": "Slow down", - "status": 503, - "detail": "", - "instance": "/v1/namespaces" - } - - ServerErrorResponse: - description: - A server-side problem that might not be addressable from the client - side. Used for server 5xx errors without more specific documentation in - individual routes. - content: - application/json: - schema: - $ref: '#/components/schemas/ErrorResponse' - example: { - "type": "/errors/server-error", - "title": "Internal Server Error", - "status": 500, - "detail": "", - "instance": "/v1/namespaces" - } - - examples: - ListNamespacesEmptyExample: - summary: An empty list of namespaces - value: { - "namespaces": [ ] - } - - ListNamespacesNonEmptyExample: - summary: A non-empty list of namespaces - value: { - "namespaces": [ - "accounting", - "credits" - ] - } diff --git a/docs/src/sdk_docs.md b/docs/src/sdk_docs.md index cf7331d2d81..8b42a66e86a 100644 --- a/docs/src/sdk_docs.md +++ b/docs/src/sdk_docs.md @@ -6,4 +6,4 @@ all classes, functions, and methods available in each language. - [Python](https://lance-format.github.io/lance-python-doc) - [Rust](https://docs.rs/lance/latest/lance) -- [Java](https://www.javadoc.io/doc/com.lancedb/lance-core/latest/index.html) \ No newline at end of file +- [Java](https://www.javadoc.io/doc/org.lance/lance-core/latest/index.html) diff --git a/docs/theme/404.html b/docs/theme/404.html new file mode 100644 index 00000000000..51897bd0805 --- /dev/null +++ b/docs/theme/404.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block container %} +
+
404 · Page not found
+

This page doesn't exist.

+

The page you're looking for may have moved.

+ +
+{% endblock %} diff --git a/docs/theme/assets/site.css b/docs/theme/assets/site.css new file mode 100644 index 00000000000..a9d5286244c --- /dev/null +++ b/docs/theme/assets/site.css @@ -0,0 +1,792 @@ +/* Lance docs site chrome — faithful port of the "Lance Docs" design prototype. + Layout/nav classes (ld-*) come from the theme templates; article content + styles target python-markdown + pymdownx output. */ + +[hidden] { display: none !important; } + +.ld-shell { + min-height: 100vh; + display: flex; + flex-direction: column; + background: var(--surface-page); + font-family: var(--font-body); + color: var(--text-body); +} + +/* ---------- header ---------- */ +.ld-header { + position: sticky; + top: 0; + z-index: 60; + display: flex; + align-items: center; + gap: 20px; + height: 60px; + padding: 0 clamp(16px, 3vw, 32px); + min-width: 0; + background: var(--surface-header); + backdrop-filter: blur(12px); + border-bottom: 1px solid var(--line-1); +} +.ld-brand { + display: inline-flex; + align-items: center; + text-decoration: none; + user-select: none; + flex-shrink: 0; +} +.ld-brand img { display: block; height: 24px; width: auto; } +.ld-topnav { + display: flex; + gap: 2px; + margin-right: auto; + overflow-x: auto; + scrollbar-width: none; + min-width: 0; + flex: 1; +} +.ld-topnav::-webkit-scrollbar { display: none; } +.ld-toptab { + font-family: var(--font-body); + font-size: 14px; + font-weight: 500; + color: var(--text-muted); + text-decoration: none; + padding: 7px 12px; + cursor: pointer; + transition: color var(--dur-fast) var(--ease-out); + position: relative; + white-space: nowrap; +} +.ld-toptab:hover { color: var(--text-body); } +.ld-toptab.active { color: var(--fg-1); } +.ld-toptab.active::after { + content: ""; + position: absolute; + left: 12px; + right: 12px; + bottom: -10px; + height: 2px; + background: var(--beam-400); +} +.ld-header__actions { display: flex; gap: 10px; align-items: center; flex-shrink: 0; } +/* All header controls share one 34px height regardless of content (icon/text). */ +.ld-header__actions .ld-btn { height: 34px; padding-top: 0; padding-bottom: 0; } +.ld-header__actions .ld-btn--icon { width: 34px; padding: 0; justify-content: center; } + +/* ---------- buttons ---------- */ +.ld-btn { + display: inline-flex; + align-items: center; + gap: 8px; + text-decoration: none; + white-space: nowrap; + flex-shrink: 0; +} +.ld-btn--primary { + font-family: var(--font-body); + font-size: 13.5px; + font-weight: 500; + color: #ffffff; + background: var(--beam-400); + padding: 8px 16px; +} +.ld-btn--primary:hover { background: var(--beam-600); color: #ffffff; } +.ld-btn--ghost { + font-family: var(--font-mono); + font-size: 12.5px; + color: var(--text-secondary); + border: 1px solid var(--line-1); + padding: 7px 14px; +} +.ld-btn--ghost:hover { border-color: var(--beam-400); color: var(--fg-1); } +.ld-btn--icon { padding: 7px 9px; background: none; cursor: pointer; } +.ld-btn__count { + border-left: 1px solid var(--line-2); + padding-left: 8px; + color: var(--fg-1); + font-weight: 600; +} +:root[data-theme="light"] .ld-icon-sun { display: none; } +:root[data-theme="dark"] .ld-icon-moon { display: none; } +.ld-btn--outline { + font-size: 14px; + font-weight: 500; + color: var(--fg-1); + border: 1px solid var(--line-1); + padding: 11px 24px; +} +.ld-btn--outline:hover { border-color: var(--beam-400); color: var(--fg-1); } +.ld-btn--text { + font-size: 14px; + font-weight: 500; + color: var(--text-secondary); + padding: 11px 12px; +} +.ld-btn--text:hover { color: var(--fg-1); } +.ld-btn--lg.ld-btn--primary { font-size: 14px; padding: 12px 24px; } + +/* ---------- home: hero ---------- */ +.ld-hero { max-width: var(--container-max); margin: 0 auto; padding: 96px 32px 72px; width: 100%; } +.ld-kicker { + font-family: var(--font-mono); + font-size: 12.5px; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--beam-600); + margin-bottom: 24px; +} +/* Wrap only between segments (at the · separators), never inside one. */ +.ld-kicker span, .ld-kicker a { white-space: nowrap; } +.ld-kicker a { color: inherit; text-decoration: underline; text-underline-offset: 3px; } +.ld-kicker a:hover { color: var(--beam-700); } +.ld-hero h1 { + font-family: var(--font-display); + font-size: 72px; + font-weight: 600; + letter-spacing: var(--tracking-display); + line-height: 0.97; + color: var(--fg-1); + margin: 0 0 28px; + max-width: 900px; + text-wrap: balance; +} +.ld-hero__lead { + font-size: 17px; + line-height: 1.6; + color: var(--text-secondary); + max-width: 640px; + margin: 0 0 36px; + text-wrap: pretty; +} +.ld-hero__cta { display: flex; gap: 12px; flex-wrap: wrap; } + +/* ---------- home: stats band ---------- */ +.ld-band { max-width: var(--container-max); margin: 0 auto; padding: 0 32px; width: 100%; } +.ld-stats { + display: grid; + grid-template-columns: 1fr 1fr 1fr; + border-top: 1px solid var(--line-1); + border-bottom: 1px solid var(--line-1); +} +.ld-stat { padding: 28px 32px; } +.ld-stat:first-child { padding-left: 0; } +.ld-stat:last-child { padding-right: 0; } +.ld-stat + .ld-stat { border-left: 1px solid var(--line-2); } +.ld-stat--link { display: block; text-decoration: none; } +.ld-stat--link .ld-stat__label { transition: color var(--dur-fast) var(--ease-out); } +.ld-stat--link:hover .ld-stat__label { color: var(--beam-600); } +.ld-stat__value { + font-family: var(--font-display); + font-size: 40px; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--fg-1); +} +.ld-stat__value--beam { color: var(--beam-400); } +.ld-stat__label { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--text-muted); + margin-top: 6px; +} + +/* ---------- home: what is lance ---------- */ +.ld-what { max-width: var(--container-max); margin: 0 auto; padding: 72px 32px 40px; width: 100%; } +.ld-what h2 { + font-family: var(--font-display); + font-size: 34px; + font-weight: 600; + letter-spacing: -0.03em; + color: var(--fg-1); + margin: 0 0 16px; +} +.ld-what__body { + font-size: 16px; + line-height: 1.65; + color: var(--text-secondary); + max-width: 760px; + margin: 0 0 12px; + text-wrap: pretty; +} +.ld-what__more { font-size: 15px; line-height: 1.6; color: var(--text-secondary); margin: 0; } +.ld-inline-link { + color: var(--beam-600); + text-decoration: underline; + text-decoration-thickness: 2px; + text-underline-offset: 3px; +} + +/* ---------- home: features ---------- */ +.ld-features { max-width: var(--container-max); margin: 0 auto; padding: 24px 32px 96px; width: 100%; } +.ld-feature { + display: grid; + grid-template-columns: 90px minmax(0, 1fr) minmax(0, 460px); + gap: 40px; + align-items: center; + border-top: 1px solid var(--line-1); + padding: 40px 0; +} +.ld-feature__num { + font-family: var(--font-mono); + font-size: 15px; + color: var(--beam-600); + align-self: start; + padding-top: 6px; +} +.ld-feature__body h3 { + font-family: var(--font-display); + font-size: 24px; + font-weight: 600; + letter-spacing: -0.02em; + color: var(--fg-1); + margin: 0 0 12px; +} +.ld-feature__body p { + font-size: 15px; + line-height: 1.65; + color: var(--text-secondary); + margin: 0 0 16px; + text-wrap: pretty; +} +.ld-more { + font-family: var(--font-mono); + font-size: 13px; + color: var(--beam-600); + text-decoration: none; +} +.ld-more:hover { text-decoration: underline; } +/* Logo grids stay on white in both themes — the artwork assumes a light background. */ +.ld-feature__img { border: 1px solid var(--line-1); padding: 16px; background: #ffffff; } +.ld-feature__img img { display: block; width: 100%; height: auto; } + +/* homepage code windows (always ink-dark, like docs code blocks) */ +.ld-feature__code { border: 1px solid var(--line-2); background: var(--ink-900); min-width: 0; border-radius: var(--radius-box); overflow: hidden; } +.ld-win { display: flex; gap: 6px; padding: 12px 16px 0; } +.ld-win i { width: 10px; height: 10px; border-radius: 999px; background: var(--ink-600); } +.ld-feature__code pre { + margin: 0; + padding: 14px 20px 18px; + overflow-x: auto; + font-family: var(--font-mono); + font-size: 12.5px; + line-height: 1.7; + color: #EDEBF5; +} +.ld-feature__code code { font-family: inherit; } +.tok-kw { color: #B8A8FF; } +.tok-str { color: #7CC0FF; } +.tok-com { color: #6F6A85; font-style: italic; } +.tok-num { color: #F2CE6B; } +.tok-arg { color: #FF95A8; } +.tok-fn { color: #8FE8CE; } + +/* ---------- docs layout ---------- */ +.ld-docs { + flex: 1; + display: grid; + grid-template-columns: 240px minmax(0, 1fr) 190px; + max-width: 1280px; + width: 100%; + margin: 0 auto; + gap: 44px; + padding: 0 32px; +} +.ld-sidenav { + border-right: 1px solid var(--line-2); + padding: 36px 24px 48px 0; + position: sticky; + top: 60px; + align-self: start; + height: calc(100vh - 60px); + overflow-y: auto; +} +/* Mobile-only disclosure for the sidenav; hidden on desktop where the + sidenav is a sticky column. */ +.ld-sidenav-toggle { + display: none; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-secondary); + background: none; + border: 1px solid var(--line-2); + padding: 9px 12px; + margin: 16px 0 0; + cursor: pointer; +} +.ld-sidenav-toggle:hover { color: var(--fg-1); border-color: var(--beam-400); } +.ld-sidenav-toggle svg { transition: transform var(--dur-fast) var(--ease-out); } +.ld-sidenav-toggle.open svg { transform: rotate(180deg); } +.ld-sidenav__group { margin-bottom: 6px; } +.ld-sidenav__label { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-muted); + margin: 22px 0 8px; +} +.ld-sidenav a { + display: block; + font-size: 13.5px; + color: var(--text-secondary); + text-decoration: none; + padding: 5px 12px; + cursor: pointer; + border-left: 1px solid var(--line-2); + line-height: 1.45; +} +.ld-sidenav a:hover { color: var(--text-body); background: var(--surface-overlay); } +.ld-sidenav a.active { + color: var(--beam-600); + border-left: 2px solid var(--beam-400); + padding-left: 11px; + font-weight: 500; +} +.ld-sidenav a .ext { color: var(--text-muted); font-size: 11px; margin-left: 4px; } + +/* ---------- right-hand page TOC ---------- */ +.ld-toc { + position: sticky; + top: 60px; + align-self: start; + max-height: calc(100vh - 60px); + overflow-y: auto; + padding: 44px 0 48px; +} +.ld-toc__label { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 10px; +} +.ld-toc a { + display: block; + font-size: 12.5px; + line-height: 1.45; + color: var(--text-muted); + text-decoration: none; + border-left: 1px solid var(--line-2); + padding: 4px 0 4px 12px; +} +.ld-toc a.ld-toc__h3 { padding-left: 24px; } +.ld-toc a:hover { color: var(--text-body); } +.ld-toc a.active { + color: var(--beam-600); + border-left: 2px solid var(--beam-400); + padding-left: 11px; + font-weight: 500; +} +.ld-toc a.ld-toc__h3.active { padding-left: 23px; } + +.ld-crumbs { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-muted); + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 24px; +} +.ld-crumbs b { color: var(--beam-600); font-weight: 500; } + +.ld-pagenav { + display: flex; + justify-content: space-between; + gap: 16px; + border-top: 1px solid var(--line-1); + margin-top: 56px; + padding-top: 20px; +} +.ld-pagenav a { text-decoration: none; color: var(--fg-1); } +.ld-pagenav a:hover { color: var(--beam-600); } +.ld-pagenav a.next { text-align: right; } +.ld-pagenav__k { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-muted); + margin-bottom: 4px; +} +.ld-pagenav__t { font-size: 14.5px; font-weight: 500; } + +/* ---------- article prose (python-markdown output) ---------- */ +.ld-article-col { max-width: 760px; min-width: 0; padding: 40px 0 96px; } +.ld-article { font-family: var(--font-body); color: var(--text-secondary); } +/* Keep anchor targets clear of the sticky header. */ +.ld-article :is(h1, h2, h3, h4, h5, h6) { scroll-margin-top: 76px; } +.ld-article h1 { font-family: var(--font-display); font-size: 42px; font-weight: 600; letter-spacing: -0.03em; line-height: 1.05; color: var(--fg-1); margin: 0 0 20px; text-wrap: balance; } +.ld-article h2 { font-family: var(--font-display); font-size: 26px; font-weight: 600; letter-spacing: -0.02em; color: var(--fg-1); margin: 48px 0 14px; padding-top: 28px; border-top: 1px solid var(--line-2); } +.ld-article h3 { font-family: var(--font-display); font-size: 19px; font-weight: 600; letter-spacing: -0.01em; color: var(--fg-1); margin: 32px 0 10px; } +.ld-article h4, .ld-article h5, .ld-article h6 { font-family: var(--font-mono); font-size: 12.5px; font-weight: 600; letter-spacing: .14em; text-transform: uppercase; color: var(--fg-1); margin: 28px 0 8px; } +.ld-article p { font-size: 15.5px; line-height: 1.65; margin: 0 0 16px; text-wrap: pretty; } +.ld-article a { color: var(--beam-600); text-decoration: underline; text-decoration-thickness: 2px; text-underline-offset: 3px; text-decoration-color: rgba(98, 94, 255, .35); } +.ld-article a:hover { text-decoration-color: var(--beam-600); } +.ld-article code { font-family: var(--font-mono); font-size: 13px; background: var(--surface-inline-code); border-radius: var(--radius-chip); padding: 1px 5px; color: var(--fg-1); } +.ld-article ul, .ld-article ol { margin: 0 0 16px; padding-left: 22px; font-size: 15.5px; line-height: 1.65; } +.ld-article li { margin-bottom: 6px; } +.ld-article li p { margin-bottom: 8px; } +.ld-article hr { border: 0; border-top: 1px solid var(--line-1); margin: 40px 0; } +.ld-article blockquote { margin: 0 0 16px; padding: 4px 0 4px 20px; border-left: 2px solid var(--beam-400); } +.ld-article blockquote p:last-child { margin-bottom: 0; } + +/* heading permalinks (toc: permalink) */ +.ld-article .headerlink { + margin-left: 8px; + color: var(--beam-400); + text-decoration: none; + opacity: 0; + transition: opacity var(--dur-fast) var(--ease-out); +} +.ld-article :is(h1, h2, h3, h4, h5, h6):hover .headerlink { opacity: 1; } + +/* figures: lone images sit on a white plate in both themes */ +.ld-article img { max-width: 100%; height: auto; } +.ld-article p > img:only-child { + display: block; + margin: 24px auto; + border: 1px solid var(--line-1); + padding: 16px; + background: #ffffff; +} + +/* tables (JS wraps them in .ld-tablewrap for overflow) */ +.ld-tablewrap { overflow-x: auto; margin: 0 0 20px; border: 1px solid var(--line-1); } +.ld-article table { border-collapse: collapse; width: 100%; font-size: 14px; } +.ld-article th { font-family: var(--font-mono); font-size: 11px; font-weight: 600; letter-spacing: .12em; text-transform: uppercase; text-align: left; color: var(--fg-1); background: var(--surface-overlay); padding: 10px 14px; border-bottom: 1px solid var(--line-1); } +.ld-article td { padding: 10px 14px; border-bottom: 1px solid var(--line-2); vertical-align: top; line-height: 1.55; color: var(--text-secondary); } +.ld-article tr:last-child td { border-bottom: 0; } + +/* ---------- code blocks (pymdownx.highlight output, JS adds the bar) ---------- */ +.ld-code { margin: 0 0 20px; border: 1px solid var(--line-2); border-radius: var(--radius-box); overflow: hidden; } +.ld-code__bar { display: flex; justify-content: space-between; align-items: center; padding: 7px 14px; border-bottom: 1px solid var(--line-2); background: var(--surface-card); } +.ld-code__bar span { display: inline-flex; align-items: center; gap: 7px; font-family: var(--font-mono); font-size: 11px; letter-spacing: .12em; text-transform: uppercase; color: var(--text-muted); } +.ld-code__bar span svg { width: 13px; height: 13px; display: block; flex-shrink: 0; } +.ld-copy { font-family: var(--font-mono); font-size: 11px; letter-spacing: .08em; text-transform: uppercase; background: none; border: none; color: var(--beam-600); cursor: pointer; padding: 2px 0; } +.ld-copy:hover { color: var(--beam-700); } +.ld-code .highlight { margin: 0; } +.ld-code pre { margin: 0; background: var(--surface-code); padding: 16px 18px; overflow-x: auto; } +.ld-code pre code { background: none; border: none; padding: 0; font-size: 13px; line-height: 1.6; color: var(--code-fg); } +/* Ink code surfaces (dark-theme docs blocks + the always-dark homepage windows) + need an explicit light selection color; light-theme docs blocks use the default. */ +:root[data-theme="dark"] .ld-code pre ::selection, :root[data-theme="dark"] .ld-code pre::selection, +.ld-feature__code pre ::selection, .ld-feature__code pre::selection { + background: rgba(98, 94, 255, 0.55); + color: #ffffff; +} + +/* Pygments tokens — colors resolve per theme via the --code-* palette */ +.highlight .k, .highlight .kn, .highlight .kd, .highlight .kt, .highlight .kr, .highlight .kp, .highlight .ow { color: var(--code-kw); } +.highlight .kc { color: var(--code-kw); } +.highlight .s, .highlight .s1, .highlight .s2, .highlight .sb, .highlight .sd, .highlight .sa, .highlight .se, .highlight .si, .highlight .sx, .highlight .sr, .highlight .ss, .highlight .sh { color: var(--code-str); } +.highlight .c, .highlight .c1, .highlight .cm, .highlight .ch, .highlight .cs, .highlight .cp, .highlight .cpf { color: var(--code-com); font-style: italic; } +.highlight .m, .highlight .mi, .highlight .mf, .highlight .mh, .highlight .mo, .highlight .mb, .highlight .il { color: var(--code-num); } +.highlight .nf, .highlight .fm, .highlight .nd, .highlight .ne { color: var(--code-fn); } +.highlight .nc, .highlight .nn { color: var(--code-name); font-weight: 500; } +.highlight .nb, .highlight .bp { color: var(--code-kw); } +.highlight .nt { color: var(--code-tag); } +.highlight .na { color: var(--code-tag); } +.highlight .o { color: var(--code-punct); } +.highlight .p { color: var(--code-punct); } +.highlight .gp { color: var(--code-prompt); } +.highlight .go { color: var(--code-punct); } +.highlight .gh, .highlight .gu { color: var(--code-name); font-weight: 600; } +.highlight .hll { background: var(--code-hll); display: block; } + +/* mermaid diagrams: white plate (rendered by JS) */ +.ld-article pre.mermaid, .ld-article div.mermaid { + border: 1px solid var(--line-1); + padding: 16px; + background: #ffffff; + margin: 0 0 20px; + text-align: center; + overflow-x: auto; +} + +/* ---------- admonitions ---------- */ +.ld-article .admonition { + border: 1px solid var(--line-2); + border-radius: var(--radius-box); + overflow: hidden; + margin: 0 0 20px; + background: var(--surface-card); + padding: 0; + font-size: 14.5px; +} +.ld-article .admonition > .admonition-title { + display: flex; + align-items: center; + gap: 8px; + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + letter-spacing: .14em; + text-transform: uppercase; + color: var(--beam-600); + padding: 9px 16px; + margin: 0; + border-bottom: 1px solid var(--line-2); + background: none; +} +/* Material-style type icon, drawn in the title color via a CSS mask */ +.ld-article .admonition > .admonition-title::before { + content: ""; + width: 14px; + height: 14px; + flex-shrink: 0; + background-color: currentColor; + -webkit-mask: var(--ld-adm-icon) no-repeat center / contain; + mask: var(--ld-adm-icon) no-repeat center / contain; +} +/* Icon shapes are Material Design Icons paths (Apache 2.0), matching mkdocs-material's defaults. */ +.ld-article .admonition { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M20.71,7.04C21.1,6.65 21.1,6 20.71,5.63L18.37,3.29C18,2.9 17.35,2.9 16.96,3.29L15.12,5.12L18.87,8.87M3,17.25V21H6.75L17.81,9.93L14.06,6.18L3,17.25Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.abstract, .admonition.summary, .admonition.tldr) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M17,9H7V7H17M17,13H7V11H17M14,17H7V15H14M12,3A1,1 0 0,1 13,4A1,1 0 0,1 12,5A1,1 0 0,1 11,4A1,1 0 0,1 12,3M19,3H14.82C14.4,1.84 13.3,1 12,1C10.7,1 9.6,1.84 9.18,3H5A2,2 0 0,0 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V5A2,2 0 0,0 19,3Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.info, .admonition.todo) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M13,9H11V7H13M13,17H11V11H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12A10,10 0 0,0 12,2Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.tip, .admonition.hint, .admonition.important) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M17.66,11.2C17.43,10.9 17.15,10.64 16.89,10.38C16.22,9.78 15.46,9.35 14.82,8.72C13.33,7.26 13,4.85 13.95,3C13,3.23 12.17,3.75 11.46,4.32C8.87,6.4 7.85,10.07 9.07,13.22C9.11,13.32 9.15,13.42 9.15,13.55C9.15,13.77 9,13.97 8.8,14.05C8.57,14.15 8.33,14.09 8.14,13.93C8.08,13.88 8.04,13.83 8,13.76C6.87,12.33 6.69,10.28 7.45,8.64C5.78,10 4.87,12.3 5,14.47C5.06,14.97 5.12,15.47 5.29,15.97C5.43,16.57 5.7,17.17 6,17.7C7.08,19.43 8.95,20.67 10.96,20.92C13.1,21.19 15.39,20.8 17.03,19.32C18.86,17.66 19.5,15 18.56,12.72L18.43,12.46C18.22,12 17.66,11.2 17.66,11.2M14.5,17.5C14.22,17.74 13.76,18 13.4,18.1C12.28,18.5 11.16,17.94 10.5,17.28C11.69,17 12.4,16.12 12.61,15.23C12.78,14.43 12.46,13.77 12.33,13C12.21,12.26 12.23,11.63 12.5,10.94C12.69,11.32 12.89,11.7 13.13,12C13.9,13 15.11,13.44 15.37,14.8C15.41,14.94 15.43,15.08 15.43,15.23C15.46,16.05 15.1,16.95 14.5,17.5H14.5Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.success, .admonition.check, .admonition.done) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M9,20.42L2.79,14.21L5.62,11.38L9,14.77L18.88,4.88L21.71,7.71L9,20.42Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.question, .admonition.help, .admonition.faq) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M15.07,11.25L14.17,12.17C13.45,12.89 13,13.5 13,15H11V14.5C11,13.39 11.45,12.39 12.17,11.67L13.41,10.41C13.78,10.05 14,9.55 14,9C14,7.89 13.1,7 12,7A2,2 0 0,0 10,9H8A4,4 0 0,1 12,5A4,4 0 0,1 16,9C16,9.88 15.64,10.67 15.07,11.25M13,19H11V17H13M12,2A10,10 0 0,0 2,12A10,10 0 0,0 12,22A10,10 0 0,0 22,12C22,6.47 17.5,2 12,2Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.warning, .admonition.caution, .admonition.attention) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M13,14H11V9H13M13,18H11V16H13M1,21H23L12,2L1,21Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.failure, .admonition.fail, .admonition.missing) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M20 6.91L17.09 4L12 9.09L6.91 4L4 6.91L9.09 12L4 17.09L6.91 20L12 14.91L17.09 20L20 17.09L14.91 12L20 6.91Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.danger, .admonition.error) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M11,15H6L13,1V9H18L11,23V15Z"/%3E%3C/svg%3E'); } +.ld-article .admonition.bug { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M14,12H10V10H14M14,16H10V14H14M20,8H17.19C16.74,7.22 16.12,6.55 15.37,6.04L17,4.41L15.59,3L13.42,5.17C12.96,5.06 12.5,5 12,5C11.5,5 11.04,5.06 10.59,5.17L8.41,3L7,4.41L8.62,6.04C7.88,6.55 7.26,7.22 6.81,8H4V10H6.09C6.04,10.33 6,10.66 6,11V12H4V14H6V15C6,15.34 6.04,15.67 6.09,16H4V18H6.81C7.85,19.79 9.78,21 12,21C14.22,21 16.15,19.79 17.19,18H20V16H17.91C17.96,15.67 18,15.34 18,15V14H20V12H18V11C18,10.66 17.96,10.33 17.91,10H20V8Z"/%3E%3C/svg%3E'); } +.ld-article .admonition.example { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M7,13V11H21V13H7M7,19V17H21V19H7M7,7V5H21V7H7M3,8V5H2V4H4V8H3M2,17V16H5V20H2V19H4V18.5H3V17.5H4V17H2M4.25,10A0.75,0.75 0 0,1 5,10.75C5,10.95 4.92,11.14 4.79,11.27L3.12,13H5V14H2V13.08L4,11H2V10H4.25Z"/%3E%3C/svg%3E'); } +.ld-article :is(.admonition.quote, .admonition.cite) { --ld-adm-icon: url('data:image/svg+xml;charset=utf-8,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"%3E%3Cpath d="M14,17H17L19,13V7H13V13H16M6,17H9L11,13V7H5V13H8L6,17Z"/%3E%3C/svg%3E'); } +.ld-article .admonition > :not(.admonition-title) { margin: 14px 16px; font-size: 14.5px; } +.ld-article .admonition > .highlight, .ld-article .admonition > .ld-code { margin: 14px 16px; } +.ld-article .admonition.warning > .admonition-title, +.ld-article .admonition.caution > .admonition-title, +.ld-article .admonition.attention > .admonition-title { color: var(--warn-500); } +.ld-article .admonition.danger > .admonition-title, +.ld-article .admonition.error > .admonition-title, +.ld-article .admonition.bug > .admonition-title, +.ld-article .admonition.failure > .admonition-title { color: var(--danger-500); } +.ld-article .admonition.success > .admonition-title, +.ld-article .admonition.check > .admonition-title { color: var(--ok-500); } + +/* ---------- details / summary (pymdownx.details) ---------- */ +.ld-article details { + border: 1px solid var(--line-2); + border-radius: var(--radius-box); + overflow: hidden; + margin: 0 0 20px; + background: var(--surface-card); +} +.ld-article details > summary { + font-family: var(--font-mono); + font-size: 12px; + font-weight: 600; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--beam-600); + padding: 10px 16px; + cursor: pointer; + user-select: none; + list-style: none; +} +.ld-article details > summary::-webkit-details-marker { display: none; } +.ld-article details > summary:hover { background: var(--surface-overlay); } +.ld-article details[open] > summary { border-bottom: 1px solid var(--line-2); } +.ld-article details > :not(summary) { margin: 14px 16px; } + +/* ---------- content tabs (pymdownx.tabbed, alternate style) ---------- */ +.ld-article .tabbed-set { margin: 0 0 20px; border: 1px solid var(--line-2); border-radius: var(--radius-box); overflow: hidden; position: relative; } +.ld-article .tabbed-set > input { position: absolute; opacity: 0; pointer-events: none; } +.ld-article .tabbed-labels { + display: flex; + border-bottom: 1px solid var(--line-2); + background: var(--surface-card); + overflow-x: auto; + scrollbar-width: none; +} +.ld-article .tabbed-labels > label { + font-family: var(--font-mono); + font-size: 12px; + letter-spacing: .06em; + padding: 9px 18px; + border-right: 1px solid var(--line-2); + color: var(--text-muted); + cursor: pointer; + white-space: nowrap; +} +.ld-article .tabbed-labels > label:hover { color: var(--fg-1); } +.ld-article .tabbed-content { display: block; } +.ld-article .tabbed-block { display: none; padding: 16px 16px 0; } +.ld-article .tabbed-block > :last-child { margin-bottom: 16px; } +.ld-article .tabbed-block > .ld-code:last-child { margin-bottom: 16px; } +/* nth-input → nth-label/nth-block mapping (supports up to 8 tabs) */ +.ld-article .tabbed-set > input:nth-child(1):checked ~ .tabbed-labels > label:nth-child(1), +.ld-article .tabbed-set > input:nth-child(2):checked ~ .tabbed-labels > label:nth-child(2), +.ld-article .tabbed-set > input:nth-child(3):checked ~ .tabbed-labels > label:nth-child(3), +.ld-article .tabbed-set > input:nth-child(4):checked ~ .tabbed-labels > label:nth-child(4), +.ld-article .tabbed-set > input:nth-child(5):checked ~ .tabbed-labels > label:nth-child(5), +.ld-article .tabbed-set > input:nth-child(6):checked ~ .tabbed-labels > label:nth-child(6), +.ld-article .tabbed-set > input:nth-child(7):checked ~ .tabbed-labels > label:nth-child(7), +.ld-article .tabbed-set > input:nth-child(8):checked ~ .tabbed-labels > label:nth-child(8) { + color: var(--fg-1); + box-shadow: inset 0 -2px 0 var(--beam-400); +} +.ld-article .tabbed-set > input:nth-child(1):checked ~ .tabbed-content > .tabbed-block:nth-child(1), +.ld-article .tabbed-set > input:nth-child(2):checked ~ .tabbed-content > .tabbed-block:nth-child(2), +.ld-article .tabbed-set > input:nth-child(3):checked ~ .tabbed-content > .tabbed-block:nth-child(3), +.ld-article .tabbed-set > input:nth-child(4):checked ~ .tabbed-content > .tabbed-block:nth-child(4), +.ld-article .tabbed-set > input:nth-child(5):checked ~ .tabbed-content > .tabbed-block:nth-child(5), +.ld-article .tabbed-set > input:nth-child(6):checked ~ .tabbed-content > .tabbed-block:nth-child(6), +.ld-article .tabbed-set > input:nth-child(7):checked ~ .tabbed-content > .tabbed-block:nth-child(7), +.ld-article .tabbed-set > input:nth-child(8):checked ~ .tabbed-content > .tabbed-block:nth-child(8) { + display: block; +} + +/* ---------- search overlay ---------- */ +.ld-search { position: fixed; inset: 0; z-index: 100; } +.ld-search__scrim { position: absolute; inset: 0; background: rgba(14, 12, 20, 0.5); } +.ld-search__panel { + position: relative; + max-width: 640px; + margin: 96px auto 0; + background: var(--surface-page); + border: 1px solid var(--line-1); +} +.ld-search__bar { display: flex; align-items: center; border-bottom: 1px solid var(--line-1); } +.ld-search__input { + flex: 1; + font-family: var(--font-body); + font-size: 15px; + color: var(--fg-1); + background: none; + border: none; + outline: none; + padding: 14px 16px; +} +.ld-search__input::placeholder { color: var(--text-muted); } +.ld-search__close { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: .1em; + text-transform: uppercase; + color: var(--text-muted); + background: none; + border: 1px solid var(--line-2); + margin-right: 12px; + padding: 3px 8px; + cursor: pointer; +} +.ld-search__close:hover { color: var(--fg-1); border-color: var(--beam-400); } +.ld-search__results { max-height: 55vh; overflow-y: auto; } +.ld-search__hit { display: block; padding: 12px 16px; text-decoration: none; border-top: 1px solid var(--line-2); } +.ld-search__hit:first-child { border-top: 0; } +.ld-search__hit:hover, .ld-search__hit.active { background: var(--surface-overlay); } +.ld-search__hit-title { font-size: 14px; font-weight: 500; color: var(--fg-1); } +.ld-search__hit-title mark, .ld-search__hit-text mark { background: none; color: var(--beam-600); } +.ld-search__hit-crumb { font-family: var(--font-mono); font-size: 10.5px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted); margin-bottom: 2px; } +.ld-search__hit-text { font-size: 12.5px; color: var(--text-muted); line-height: 1.5; margin-top: 2px; } +.ld-search__empty { font-family: var(--font-mono); font-size: 12px; letter-spacing: .08em; text-transform: uppercase; color: var(--text-muted); padding: 20px 16px; } + +/* ---------- 404 ---------- */ +.ld-notfound { max-width: var(--container-max); margin: 0 auto; padding: 120px 32px 160px; width: 100%; flex: 1; } +.ld-notfound h1 { + font-family: var(--font-display); + font-size: 56px; + font-weight: 600; + letter-spacing: var(--tracking-display); + color: var(--fg-1); + margin: 0 0 16px; +} +.ld-notfound p { color: var(--text-secondary); margin: 0 0 32px; } + +/* ---------- footer ---------- */ +.ld-footer { + border-top: 1px solid var(--line-1); + padding: 40px 32px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 32px; + flex-wrap: wrap; +} +.ld-footer__brand img { display: block; height: 24px; width: auto; } +.ld-footer__brand p { + font-size: 12.5px; + color: var(--text-muted); + max-width: 300px; + line-height: 1.6; + margin: 12px 0 0; +} +.ld-footer__cols { display: flex; gap: 64px; flex-wrap: wrap; } +.ld-footer__col { display: flex; flex-direction: column; gap: 8px; } +.ld-footer__col h5 { + font-family: var(--font-mono); + font-size: 10.5px; + letter-spacing: var(--tracking-caps); + text-transform: uppercase; + color: var(--text-muted); + margin: 0 0 4px; +} +.ld-footer__col a { font-size: 13.5px; color: var(--text-secondary); text-decoration: none; } +.ld-footer__col a:hover { color: var(--fg-1); } + +/* ---------- responsive ---------- */ +@media (max-width: 1180px) { + .ld-docs { grid-template-columns: 240px minmax(0, 1fr); } + .ld-toc { display: none; } +} +@media (max-width: 960px) { + /* Header folds into two rows: brand + icon actions, then a scrollable + section-tab row. Text-heavy controls collapse to icons. */ + .ld-header { flex-wrap: wrap; height: auto; gap: 0 10px; padding: 10px 16px 0; } + .ld-header__actions { margin-left: auto; } + .ld-header__actions .ld-btn--primary, + .ld-header__actions .ld-btn__label, + .ld-header__actions .ld-btn__count { display: none; } + .ld-topnav { order: 1; flex-basis: 100%; margin: 4px 0 0; padding-bottom: 10px; } + .ld-toptab { padding: 7px 10px; } + .ld-toptab:first-child { margin-left: -10px; } + .ld-toptab.active::after { left: 10px; right: 10px; } + + .ld-article :is(h1, h2, h3, h4, h5, h6) { scroll-margin-top: 104px; } + .ld-article h1 { font-size: 32px; } + + .ld-hero h1 { font-size: clamp(38px, 9vw, 56px); } + .ld-stats { grid-template-columns: 1fr; } + .ld-stat { padding: 20px 0; } + .ld-stat + .ld-stat { border-left: 0; border-top: 1px solid var(--line-2); } + .ld-feature { grid-template-columns: 1fr; gap: 16px; } + .ld-feature__num { padding-top: 0; } + + /* Docs: sidenav collapses behind a disclosure button; article leads. */ + .ld-docs { display: block; padding: 0 20px; } + .ld-sidenav-toggle { display: flex; } + .ld-sidenav { + display: none; + position: static; + height: auto; + border-right: 0; + border-bottom: 1px solid var(--line-2); + padding: 0 0 20px; + } + .ld-sidenav.open { display: block; } + .ld-sidenav__group:first-child .ld-sidenav__label { margin-top: 16px; } + .ld-article-col { padding-top: 24px; } + + .ld-hero, .ld-band, .ld-what, .ld-features { padding-left: 20px; padding-right: 20px; } + .ld-hero { padding-top: 56px; padding-bottom: 48px; } + .ld-notfound { padding: 72px 20px 96px; } + .ld-footer { padding: 32px 20px; } + .ld-footer__cols { gap: 28px 40px; } +} +@media (max-width: 700px) { + /* Search becomes an edge-to-edge sheet under the top of the screen. */ + .ld-search__panel { margin: 0; max-width: none; border-left: 0; border-right: 0; border-top: 0; } + .ld-search__results { max-height: calc(100dvh - 110px); } +} diff --git a/docs/theme/assets/site.js b/docs/theme/assets/site.js new file mode 100644 index 00000000000..34378c820b2 --- /dev/null +++ b/docs/theme/assets/site.js @@ -0,0 +1,263 @@ +/* Lance docs theme behaviours: theme toggle, GitHub stars, code block chrome, + TOC scroll-spy, search overlay, mermaid rendering. */ +(function () { + "use strict"; + + var BASE = (window.LD_BASE || ".").replace(/\/$/, ""); + + /* ---------- theme toggle ---------- */ + var themeBtn = document.getElementById("theme-toggle"); + if (themeBtn) { + themeBtn.addEventListener("click", function () { + var next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; + document.documentElement.dataset.theme = next; + try { localStorage.setItem("ld-theme", next); } catch (e) { /* private mode */ } + }); + } + + /* ---------- GitHub stars ---------- */ + (function loadStars() { + var el = document.getElementById("gh-stars"); + if (!el) return; + var TTL = 3600e3; + function show(count) { + if (!(count > 0)) return; + var label = count >= 1000 ? (count / 1000).toFixed(1).replace(/\.0$/, "") + "k" : String(count); + el.textContent = "★ " + label; + el.hidden = false; + } + try { + var cached = JSON.parse(localStorage.getItem("ld-gh-stars") || "null"); + if (cached && Date.now() - cached.t < TTL) { show(cached.v); return; } + } catch (e) { /* fall through to fetch */ } + fetch("https://api.github.com/repos/lance-format/lance") + .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(function (d) { + show(d.stargazers_count); + try { localStorage.setItem("ld-gh-stars", JSON.stringify({ v: d.stargazers_count, t: Date.now() })); } catch (e) { /* ignore */ } + }) + .catch(function () { /* rate-limited or offline: button still works without the count */ }); + })(); + + /* ---------- mobile sidenav toggle ---------- */ + var sidenavToggle = document.querySelector(".ld-sidenav-toggle"); + var sidenav = document.querySelector(".ld-sidenav"); + if (sidenavToggle && sidenav) { + sidenavToggle.addEventListener("click", function () { + var open = sidenav.classList.toggle("open"); + sidenavToggle.classList.toggle("open", open); + sidenavToggle.setAttribute("aria-expanded", open ? "true" : "false"); + }); + } + + /* ---------- article enhancements ---------- */ + var article = document.querySelector(".ld-article"); + + if (article) { + // Language logos for code-bar labels (Simple Icons + Devicon paths, drawn in currentColor). + var LANG_ICONS = { + python: ["0 0 24 24", "M14.25.18l.9.2.73.26.59.3.45.32.34.34.25.34.16.33.1.3.04.26.02.2-.01.13V8.5l-.05.63-.13.55-.21.46-.26.38-.3.31-.33.25-.35.19-.35.14-.33.1-.3.07-.26.04-.21.02H8.77l-.69.05-.59.14-.5.22-.41.27-.33.32-.27.35-.2.36-.15.37-.1.35-.07.32-.04.27-.02.21v3.06H3.17l-.21-.03-.28-.07-.32-.12-.35-.18-.36-.26-.36-.36-.35-.46-.32-.59-.28-.73-.21-.88-.14-1.05-.05-1.23.06-1.22.16-1.04.24-.87.32-.71.36-.57.4-.44.42-.33.42-.24.4-.16.36-.1.32-.05.24-.01h.16l.06.01h8.16v-.83H6.18l-.01-2.75-.02-.37.05-.34.11-.31.17-.28.25-.26.31-.23.38-.2.44-.18.51-.15.58-.12.64-.1.71-.06.77-.04.84-.02 1.27.05zm-6.3 1.98l-.23.33-.08.41.08.41.23.34.33.22.41.09.41-.09.33-.22.23-.34.08-.41-.08-.41-.23-.33-.33-.22-.41-.09-.41.09zm13.09 3.95l.28.06.32.12.35.18.36.27.36.35.35.47.32.59.28.73.21.88.14 1.04.05 1.23-.06 1.23-.16 1.04-.24.86-.32.71-.36.57-.4.45-.42.33-.42.24-.4.16-.36.09-.32.05-.24.02-.16-.01h-8.22v.82h5.84l.01 2.76.02.36-.05.34-.11.31-.17.29-.25.25-.31.24-.38.2-.44.17-.51.15-.58.13-.64.09-.71.07-.77.04-.84.01-1.27-.04-1.07-.14-.9-.2-.73-.25-.59-.3-.45-.33-.34-.34-.25-.34-.16-.33-.1-.3-.04-.25-.02-.2.01-.13v-5.34l.05-.64.13-.54.21-.46.26-.38.3-.32.33-.24.35-.2.35-.14.33-.1.3-.06.26-.04.21-.02.13-.01h5.84l.69-.05.59-.14.5-.21.41-.28.33-.32.27-.35.2-.36.15-.36.1-.35.07-.32.04-.28.02-.21V6.07h2.09l.14.01zm-6.47 14.25l-.23.33-.08.41.08.41.23.33.33.23.41.08.41-.08.33-.23.23-.33.08-.41-.08-.41-.23-.33-.33-.23-.41-.08-.41.08z"], + rust: ["0 0 24 24", "M23.8346 11.7033l-1.0073-.6236a13.7268 13.7268 0 00-.0283-.2936l.8656-.8069a.3483.3483 0 00-.1154-.578l-1.1066-.414a8.4958 8.4958 0 00-.087-.2856l.6904-.9587a.3462.3462 0 00-.2257-.5446l-1.1663-.1894a9.3574 9.3574 0 00-.1407-.2622l.49-1.0761a.3437.3437 0 00-.0274-.3361.3486.3486 0 00-.3006-.154l-1.1845.0416a6.7444 6.7444 0 00-.1873-.2268l.2723-1.153a.3472.3472 0 00-.417-.4172l-1.1532.2724a14.0183 14.0183 0 00-.2278-.1873l.0415-1.1845a.3442.3442 0 00-.49-.328l-1.076.491c-.0872-.0476-.1742-.0952-.2623-.1407l-.1903-1.1673A.3483.3483 0 0016.256.955l-.9597.6905a8.4867 8.4867 0 00-.2855-.086l-.414-1.1066a.3483.3483 0 00-.5781-.1154l-.8069.8666a9.2936 9.2936 0 00-.2936-.0284L12.2946.1683a.3462.3462 0 00-.5892 0l-.6236 1.0073a13.7383 13.7383 0 00-.2936.0284L9.9803.3374a.3462.3462 0 00-.578.1154l-.4141 1.1065c-.0962.0274-.1903.0567-.2855.086L7.744.955a.3483.3483 0 00-.5447.2258L7.009 2.348a9.3574 9.3574 0 00-.2622.1407l-1.0762-.491a.3462.3462 0 00-.49.328l.0416 1.1845a7.9826 7.9826 0 00-.2278.1873L3.8413 3.425a.3472.3472 0 00-.4171.4171l.2713 1.1531c-.0628.075-.1255.1509-.1863.2268l-1.1845-.0415a.3462.3462 0 00-.328.49l.491 1.0761a9.167 9.167 0 00-.1407.2622l-1.1662.1894a.3483.3483 0 00-.2258.5446l.6904.9587a13.303 13.303 0 00-.087.2855l-1.1065.414a.3483.3483 0 00-.1155.5781l.8656.807a9.2936 9.2936 0 00-.0283.2935l-1.0073.6236a.3442.3442 0 000 .5892l1.0073.6236c.008.0982.0182.1964.0283.2936l-.8656.8079a.3462.3462 0 00.1155.578l1.1065.4141c.0273.0962.0567.1914.087.2855l-.6904.9587a.3452.3452 0 00.2268.5447l1.1662.1893c.0456.088.0922.1751.1408.2622l-.491 1.0762a.3462.3462 0 00.328.49l1.1834-.0415c.0618.0769.1235.1528.1873.2277l-.2713 1.1541a.3462.3462 0 00.4171.4161l1.153-.2713c.075.0638.151.1255.2279.1863l-.0415 1.1845a.3442.3442 0 00.49.327l1.0761-.49c.087.0486.1741.0951.2622.1407l.1903 1.1662a.3483.3483 0 00.5447.2268l.9587-.6904a9.299 9.299 0 00.2855.087l.414 1.1066a.3452.3452 0 00.5781.1154l.8079-.8656c.0972.0111.1954.0203.2936.0294l.6236 1.0073a.3472.3472 0 00.5892 0l.6236-1.0073c.0982-.0091.1964-.0183.2936-.0294l.8069.8656a.3483.3483 0 00.578-.1154l.4141-1.1066a8.4626 8.4626 0 00.2855-.087l.9587.6904a.3452.3452 0 00.5447-.2268l.1903-1.1662c.088-.0456.1751-.0931.2622-.1407l1.0762.49a.3472.3472 0 00.49-.327l-.0415-1.1845a6.7267 6.7267 0 00.2267-.1863l1.1531.2713a.3472.3472 0 00.4171-.416l-.2713-1.1542c.0628-.0749.1255-.1508.1863-.2278l1.1845.0415a.3442.3442 0 00.328-.49l-.49-1.076c.0475-.0872.0951-.1742.1407-.2623l1.1662-.1893a.3483.3483 0 00.2258-.5447l-.6904-.9587.087-.2855 1.1066-.414a.3462.3462 0 00.1154-.5781l-.8656-.8079c.0101-.0972.0202-.1954.0283-.2936l1.0073-.6236a.3442.3442 0 000-.5892zm-6.7413 8.3551a.7138.7138 0 01.2986-1.396.714.714 0 11-.2997 1.396zm-.3422-2.3142a.649.649 0 00-.7715.5l-.3573 1.6685c-1.1035.501-2.3285.7795-3.6193.7795a8.7368 8.7368 0 01-3.6951-.814l-.3574-1.6684a.648.648 0 00-.7714-.499l-1.473.3158a8.7216 8.7216 0 01-.7613-.898h7.1676c.081 0 .1356-.0141.1356-.088v-2.536c0-.074-.0536-.0881-.1356-.0881h-2.0966v-1.6077h2.2677c.2065 0 1.1065.0587 1.394 1.2088.0901.3533.2875 1.5044.4232 1.8729.1346.413.6833 1.2381 1.2685 1.2381h3.5716a.7492.7492 0 00.1296-.0131 8.7874 8.7874 0 01-.8119.9526zM6.8369 20.024a.714.714 0 11-.2997-1.396.714.714 0 01.2997 1.396zM4.1177 8.9972a.7137.7137 0 11-1.304.5791.7137.7137 0 011.304-.579zm-.8352 1.9813l1.5347-.6824a.65.65 0 00.33-.8585l-.3158-.7147h1.2432v5.6025H3.5669a8.7753 8.7753 0 01-.2834-3.348zm6.7343-.5437V8.7836h2.9601c.153 0 1.0792.1772 1.0792.8697 0 .575-.7107.7815-1.2948.7815zm10.7574 1.4862c0 .2187-.008.4363-.0243.651h-.9c-.09 0-.1265.0586-.1265.1477v.413c0 .973-.5487 1.1846-1.0296 1.2382-.4576.0517-.9648-.1913-1.0275-.4717-.2704-1.5186-.7198-1.8436-1.4305-2.4034.8817-.5599 1.799-1.386 1.799-2.4915 0-1.1936-.819-1.9458-1.3769-2.3153-.7825-.5163-1.6491-.6195-1.883-.6195H5.4682a8.7651 8.7651 0 014.907-2.7699l1.0974 1.151a.648.648 0 00.9182.0213l1.227-1.1743a8.7753 8.7753 0 016.0044 4.2762l-.8403 1.8982a.652.652 0 00.33.8585l1.6178.7188c.0283.2875.0425.577.0425.8717zm-9.3006-9.5993a.7128.7128 0 11.984 1.0316.7137.7137 0 01-.984-1.0316zm8.3389 6.71a.7107.7107 0 01.9395-.3625.7137.7137 0 11-.9405.3635z"], + java: ["0 0 128 128", "M47.617 98.12c-19.192 5.362 11.677 16.439 36.115 5.969-4.003-1.556-6.874-3.351-6.874-3.351-10.897 2.06-15.952 2.222-25.844 1.092-8.164-.935-3.397-3.71-3.397-3.71zm33.189-10.46c-14.444 2.779-22.787 2.69-33.354 1.6-8.171-.845-2.822-4.805-2.822-4.805-21.137 7.016 11.767 14.977 41.309 6.336-3.14-1.106-5.133-3.131-5.133-3.131zm11.319-60.575c.001 0-42.731 10.669-22.323 34.187 6.024 6.935-1.58 13.17-1.58 13.17s15.289-7.891 8.269-17.777c-6.559-9.215-11.587-13.793 15.634-29.58zm9.998 81.144s3.529 2.91-3.888 5.159c-14.102 4.272-58.706 5.56-71.095.171-4.45-1.938 3.899-4.625 6.526-5.192 2.739-.593 4.303-.485 4.303-.485-4.952-3.487-32.013 6.85-13.742 9.815 49.821 8.076 90.817-3.637 77.896-9.468zM85 77.896c2.395-1.634 5.703-3.053 5.703-3.053s-9.424 1.685-18.813 2.474c-11.494.964-23.823 1.154-30.012.326-14.652-1.959 8.033-7.348 8.033-7.348s-8.812-.596-19.644 4.644C17.455 81.134 61.958 83.958 85 77.896zm5.609 15.145c-.108.29-.468.616-.468.616 31.273-8.221 19.775-28.979 4.822-23.725-1.312.464-2 1.543-2 1.543s.829-.334 2.678-.72c7.559-1.575 18.389 10.119-5.032 22.286zM64.181 70.069c-4.614-10.429-20.26-19.553.007-35.559C89.459 14.563 76.492 1.587 76.492 1.587c5.23 20.608-18.451 26.833-26.999 39.667-5.821 8.745 2.857 18.142 14.688 28.815zm27.274 51.748c-19.187 3.612-42.854 3.191-56.887.874 0 0 2.874 2.38 17.646 3.331 22.476 1.437 57-.8 57.816-11.436.001 0-1.57 4.032-18.575 7.231z"], + bash: ["0 0 24 24", "M21.038,4.9l-7.577-4.498C13.009,0.134,12.505,0,12,0c-0.505,0-1.009,0.134-1.462,0.403L2.961,4.9 C2.057,5.437,1.5,6.429,1.5,7.503v8.995c0,1.073,0.557,2.066,1.462,2.603l7.577,4.497C10.991,23.866,11.495,24,12,24 c0.505,0,1.009-0.134,1.461-0.402l7.577-4.497c0.904-0.537,1.462-1.529,1.462-2.603V7.503C22.5,6.429,21.943,5.437,21.038,4.9z M15.17,18.946l0.013,0.646c0.001,0.078-0.05,0.167-0.111,0.198l-0.383,0.22c-0.061,0.031-0.111-0.007-0.112-0.085L14.57,19.29 c-0.328,0.136-0.66,0.169-0.872,0.084c-0.04-0.016-0.057-0.075-0.041-0.142l0.139-0.584c0.011-0.046,0.036-0.092,0.069-0.121 c0.012-0.011,0.024-0.02,0.036-0.026c0.022-0.011,0.043-0.014,0.062-0.006c0.229,0.077,0.521,0.041,0.802-0.101 c0.357-0.181,0.596-0.545,0.592-0.907c-0.003-0.328-0.181-0.465-0.613-0.468c-0.55,0.001-1.064-0.107-1.072-0.917 c-0.007-0.667,0.34-1.361,0.889-1.8l-0.007-0.652c-0.001-0.08,0.048-0.168,0.111-0.2l0.37-0.236 c0.061-0.031,0.111,0.007,0.112,0.087l0.006,0.653c0.273-0.109,0.511-0.138,0.726-0.088c0.047,0.012,0.067,0.076,0.048,0.151 l-0.144,0.578c-0.011,0.044-0.036,0.088-0.065,0.116c-0.012,0.012-0.025,0.021-0.038,0.028c-0.019,0.01-0.038,0.013-0.057,0.009 c-0.098-0.022-0.332-0.073-0.699,0.113c-0.385,0.195-0.52,0.53-0.517,0.778c0.003,0.297,0.155,0.387,0.681,0.396 c0.7,0.012,1.003,0.318,1.01,1.023C16.105,17.747,15.736,18.491,15.17,18.946z M19.143,17.859c0,0.06-0.008,0.116-0.058,0.145 l-1.916,1.164c-0.05,0.029-0.09,0.004-0.09-0.056v-0.494c0-0.06,0.037-0.093,0.087-0.122l1.887-1.129 c0.05-0.029,0.09-0.004,0.09,0.056V17.859z M20.459,6.797l-7.168,4.427c-0.894,0.523-1.553,1.109-1.553,2.187v8.833 c0,0.645,0.26,1.063,0.66,1.184c-0.131,0.023-0.264,0.039-0.398,0.039c-0.42,0-0.833-0.114-1.197-0.33L3.226,18.64 c-0.741-0.44-1.201-1.261-1.201-2.142V7.503c0-0.881,0.46-1.702,1.201-2.142l7.577-4.498c0.363-0.216,0.777-0.33,1.197-0.33 c0.419,0,0.833,0.114,1.197,0.33l7.577,4.498c0.624,0.371,1.046,1.013,1.164,1.732C21.686,6.557,21.12,6.411,20.459,6.797z"], + }; + var LANG_ALIASES = { py: "python", python3: "python", rs: "rust", sh: "bash", shell: "bash", console: "bash", zsh: "bash" }; + + // Code blocks: wrap .highlight in a window with a language bar + copy button. + article.querySelectorAll("div.highlight").forEach(function (hl) { + if (hl.closest(".ld-code")) return; + var code = hl.querySelector("code"); + var m = (hl.className + " " + (code ? code.className : "")).match(/language-([\w+-]+)/); + var lang = m ? m[1] : "text"; + var wrap = document.createElement("div"); + wrap.className = "ld-code"; + var bar = document.createElement("div"); + bar.className = "ld-code__bar"; + bar.innerHTML = ""; + var span = bar.querySelector("span"); + var icon = LANG_ICONS[LANG_ALIASES[lang] || lang]; + if (icon) span.innerHTML = ''; + span.appendChild(document.createTextNode(lang)); + hl.parentNode.insertBefore(wrap, hl); + wrap.appendChild(bar); + wrap.appendChild(hl); + }); + + document.addEventListener("click", function (e) { + var copy = e.target.closest(".ld-copy"); + if (!copy) return; + var pre = copy.closest(".ld-code").querySelector("pre"); + if (navigator.clipboard && pre) navigator.clipboard.writeText(pre.textContent); + copy.textContent = "Copied"; + setTimeout(function () { copy.textContent = "Copy"; }, 1400); + }); + + // Tables: wrap for horizontal overflow. + article.querySelectorAll("table").forEach(function (t) { + if (t.closest(".ld-tablewrap")) return; + var wrap = document.createElement("div"); + wrap.className = "ld-tablewrap"; + t.parentNode.insertBefore(wrap, t); + wrap.appendChild(t); + }); + + // Mermaid: render fenced diagrams on demand. + var mermaidNodes = article.querySelectorAll("pre.mermaid, div.mermaid"); + if (mermaidNodes.length) { + import("https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs").then(function (mod) { + var mermaid = mod.default; + mermaidNodes.forEach(function (node) { + if (node.tagName === "PRE") { + var div = document.createElement("div"); + div.className = "mermaid"; + div.textContent = node.textContent; + node.replaceWith(div); + } + }); + mermaid.initialize({ startOnLoad: false, securityLevel: "loose", theme: "neutral" }); + mermaid.run({ querySelector: ".ld-article div.mermaid" }); + }).catch(function () { /* offline: leave the diagram source visible */ }); + } + } + + /* ---------- TOC scroll-spy ---------- */ + var tocLinks = Array.prototype.slice.call(document.querySelectorAll(".ld-toc a")); + if (tocLinks.length && article) { + var targets = tocLinks.map(function (a) { + var id = decodeURIComponent((a.getAttribute("href") || "").replace(/^#/, "")); + return document.getElementById(id); + }); + var update = function () { + var active = 0; + for (var i = 0; i < targets.length; i++) { + if (targets[i] && targets[i].getBoundingClientRect().top <= 90) active = i; + } + tocLinks.forEach(function (a, i) { a.classList.toggle("active", i === active); }); + }; + window.addEventListener("scroll", update, { passive: true }); + update(); + } + + /* ---------- search overlay ---------- */ + var overlay = document.getElementById("search-overlay"); + var input = document.getElementById("search-input"); + var results = document.getElementById("search-results"); + var indexPromise = null; + + function loadIndex() { + if (!indexPromise) { + indexPromise = fetch(BASE + "/search/search_index.json") + .then(function (r) { if (!r.ok) throw new Error(r.status); return r.json(); }) + .then(function (d) { + return d.docs.map(function (doc) { + return { + location: doc.location, + title: doc.title || "", + text: (doc.text || "").replace(/\s+/g, " "), + }; + }); + }); + } + return indexPromise; + } + + function esc(s) { + return s.replace(/&/g, "&").replace(//g, ">"); + } + + function highlight(text, terms) { + var out = esc(text); + terms.forEach(function (t) { + out = out.replace(new RegExp("(" + t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + ")", "ig"), "$1"); + }); + return out; + } + + function search(docs, query) { + var terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!terms.length) return []; + var scored = []; + docs.forEach(function (doc) { + var title = doc.title.toLowerCase(); + var text = doc.text.toLowerCase(); + var score = 0; + for (var i = 0; i < terms.length; i++) { + var t = terms[i]; + var inTitle = title.indexOf(t) !== -1; + var inText = text.indexOf(t) !== -1; + if (!inTitle && !inText) { score = 0; break; } + score += (inTitle ? 10 : 0) + (inText ? 1 : 0); + } + // Prefer page-level entries slightly over deep anchors. + if (score > 0) scored.push({ doc: doc, score: score + (doc.location.indexOf("#") === -1 ? 2 : 0) }); + }); + scored.sort(function (a, b) { return b.score - a.score; }); + return scored.slice(0, 12).map(function (s) { return s.doc; }); + } + + function snippet(text, terms) { + var lower = text.toLowerCase(); + var pos = -1; + for (var i = 0; i < terms.length; i++) { + pos = lower.indexOf(terms[i]); + if (pos !== -1) break; + } + if (pos === -1) pos = 0; + var start = Math.max(0, pos - 60); + var s = (start > 0 ? "…" : "") + text.slice(start, start + 160) + (start + 160 < text.length ? "…" : ""); + return s; + } + + function render(docs, query) { + var terms = query.toLowerCase().split(/\s+/).filter(Boolean); + if (!docs.length) { + results.innerHTML = "
No results
"; + return; + } + results.innerHTML = docs.map(function (doc) { + var crumb = doc.location.split("#")[0].replace(/\/$/, "").replace(/\//g, " / ") || "home"; + return "" + + "
" + esc(crumb) + "
" + + "
" + highlight(doc.title, terms) + "
" + + "
" + highlight(snippet(doc.text, terms), terms) + "
" + + "
"; + }).join(""); + } + + function openSearch() { + overlay.hidden = false; + input.value = ""; + results.innerHTML = ""; + input.focus(); + loadIndex(); + } + function closeSearch() { overlay.hidden = true; } + + if (overlay && input && results) { + var openBtn = document.getElementById("search-open"); + if (openBtn) openBtn.addEventListener("click", openSearch); + overlay.addEventListener("click", function (e) { + if (e.target.closest("[data-search-close]")) closeSearch(); + }); + document.addEventListener("keydown", function (e) { + if (e.key === "Escape" && !overlay.hidden) { closeSearch(); return; } + var typing = /^(INPUT|TEXTAREA|SELECT)$/.test((document.activeElement || {}).tagName || ""); + if ((e.key === "/" && !typing) || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k")) { + e.preventDefault(); + if (overlay.hidden) openSearch(); else closeSearch(); + } + }); + var pending = 0; + input.addEventListener("input", function () { + var q = input.value.trim(); + var seq = ++pending; + if (q.length < 2) { results.innerHTML = ""; return; } + loadIndex().then(function (docs) { + if (seq !== pending) return; + render(search(docs, q), q); + }).catch(function () { + results.innerHTML = "
Search index unavailable
"; + }); + }); + } +})(); diff --git a/docs/theme/assets/tokens.css b/docs/theme/assets/tokens.css new file mode 100644 index 00000000000..29c08f2711e --- /dev/null +++ b/docs/theme/assets/tokens.css @@ -0,0 +1,142 @@ +/* Lance Design System — tokens (Swiss editorial: white, ink, brand purple #625EFF). + Merged from the design project's ds/tokens set; dark theme mirrors the same + structure with paper rules on ink. */ +@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=IBM+Plex+Sans:wght@400;500;600&family=Roboto+Mono:wght@400;500;600&display=swap"); + +:root { + color-scheme: light; + + /* ---- Typography ---- */ + --font-display: "Space Grotesk", "Segoe UI", sans-serif; + --font-body: "IBM Plex Sans", "Segoe UI", sans-serif; + --font-mono: "Roboto Mono", "SF Mono", monospace; + --text-base: 16px; + --leading-body: 1.6; + --tracking-display: -0.045em; + --tracking-caps: 0.14em; + + /* ---- Ink scale (dark values — code surfaces & tooltips only) ---- */ + --ink-950: #0E0C14; + --ink-900: #14111D; + --ink-850: #1A1626; + --ink-800: #221D30; + --ink-700: #2C2640; + --ink-600: #3A3352; + + /* ---- Foreground scale (on white) ---- */ + --fg-1: #0E0C14; + --fg-2: #4A4459; + --fg-3: #8A8499; + --fg-inverse: #ffffff; + + /* ---- Brand purple ramp (official #625EFF) ---- */ + --beam-300: #B3B1FF; + --beam-400: #625EFF; + --beam-500: #625EFF; + --beam-600: #4B47E0; + --beam-700: #3936B4; + --beam-glow: rgba(98, 94, 255, 0.25); + --beam-dim: rgba(98, 94, 255, 0.09); + + /* ---- Semantic status ---- */ + --ok-500: #0E8F63; + --warn-500: #B26205; + --danger-500: #D92D20; + + /* ---- Lines: ink rules carry the structure ---- */ + --line-1: #14111D; + --line-2: rgba(20, 17, 29, 0.16); + + /* ---- Surfaces ---- */ + --surface-page: #FFFFFF; + --surface-card: #FFFFFF; + --surface-overlay: #F5F4F8; + --surface-header: rgba(255, 255, 255, 0.88); + --surface-code: #F5F4F8; /* light code surface */ + --surface-inline-code: rgba(20, 17, 29, 0.05); + + --text-body: var(--fg-1); + --text-secondary: var(--fg-2); + --text-muted: var(--fg-3); + + /* ---- Syntax highlighting (Pygments), light theme ---- */ + --code-fg: #2A2635; + --code-kw: #7C3AED; /* keywords, builtins */ + --code-str: #0B62C4; /* strings */ + --code-com: #6E6A80; /* comments */ + --code-num: #9A6700; /* numbers */ + --code-fn: #0F766E; /* functions, decorators */ + --code-name: #2A2635; /* class/module names, headings */ + --code-tag: #BE123C; /* HTML tags, attributes */ + --code-punct: #57534E; /* operators, punctuation, output */ + --code-prompt: #8A8499; /* REPL prompts */ + --code-hll: rgba(98, 94, 255, 0.12); /* highlighted line */ + + /* ---- Layout ---- */ + --container-max: 1200px; + --radius-box: 6px; /* content boxes: code, admonitions, tabs */ + --radius-chip: 4px; /* inline code chips */ + + /* ---- Motion — fast, precise, no bounce ---- */ + --ease-out: cubic-bezier(0.2, 0.8, 0.2, 1); + --dur-fast: 120ms; +} + +/* ---- Dark theme: same structure, paper rules on ink ---- */ +:root[data-theme="dark"] { + color-scheme: dark; + + --fg-1: #F2F0FA; + --fg-2: #B7B1C6; + --fg-3: #837D96; + --fg-inverse: #0E0C14; + + /* text accents need a lighter purple to stay readable on ink */ + --beam-600: #8E8BFF; + --beam-700: #A9A6FF; + + --ok-500: #34C08E; + --warn-500: #E8A13C; + --danger-500: #FF6B5E; + + --line-1: rgba(237, 235, 245, 0.7); + --line-2: rgba(237, 235, 245, 0.15); + + --surface-page: #0E0C14; + --surface-card: #14111D; + --surface-overlay: #1A1626; + --surface-header: rgba(14, 12, 20, 0.85); + --surface-code: #14111D; + --surface-inline-code: rgba(237, 235, 245, 0.08); + + /* ---- Syntax highlighting (Pygments), dark theme (on ink) ---- */ + --code-fg: #EDEBF5; + --code-kw: #B8A8FF; + --code-str: #7CC0FF; + --code-com: #6F6A85; + --code-num: #F2CE6B; + --code-fn: #8FE8CE; + --code-name: #EDEBF5; + --code-tag: #FF95A8; + --code-punct: #B7B1C6; + --code-prompt: #6F6A85; + --code-hll: rgba(98, 94, 255, 0.22); +} + +/* ---- Minimal base ---- */ +* { box-sizing: border-box; } + +body { + margin: 0; + background: var(--surface-page); + color: var(--text-body); + font-family: var(--font-body); + font-size: var(--text-base); + line-height: var(--leading-body); + -webkit-font-smoothing: antialiased; +} + +/* No `color` here: forcing ink text breaks selection inside dark code blocks. */ +::selection { background: rgba(98, 94, 255, 0.3); } + +code, kbd, pre { font-family: var(--font-mono); } diff --git a/docs/theme/base.html b/docs/theme/base.html new file mode 100644 index 00000000000..d90b2ae02c4 --- /dev/null +++ b/docs/theme/base.html @@ -0,0 +1,118 @@ +{#- Lance Docs custom theme — shared skeleton: head, header, footer, scripts. -#} +{%- macro first_url(item) -%} + {%- if item.is_link or item.is_page -%} + {{- item.url -}} + {%- elif item.children -%} + {{- first_url(item.children[0]) -}} + {%- endif -%} +{%- endmacro -%} + + + + + +{% if page and page.title and not page.is_homepage %} +{{ page.title }} — {{ config.site_name }} +{% else %} +{{ config.site_name }} — The open lakehouse format for multimodal AI +{% endif %} +{% if page and page.meta and page.meta.description %} + +{% elif config.site_description %} + +{% endif %} +{% if page and page.canonical_url %}{% endif %} + + + + + + +
+
+ + {{ config.site_name }} + + +
+ + + + GitHub + + + + + + + Get started +
+
+ + {% block container %}{% endblock %} + + +
+ + + + + + + + diff --git a/docs/theme/home.html b/docs/theme/home.html new file mode 100644 index 00000000000..1b361168d46 --- /dev/null +++ b/docs/theme/home.html @@ -0,0 +1,147 @@ +{% extends "base.html" %} + +{% block container %} +
+
+
Lance format · Open source · Apache-2.0 · VLDB '25 paper ↗
+

The open lakehouse format for multimodal AI.

+

A file format, table format, and catalog spec for building a complete lakehouse on object storage — powering vector and full-text search, feature engineering, and model training with the fast random access and scans that AI workloads need.

+ +
+ +
+
+
+
100×
+
Faster random access than Parquet
+
+
+
1 line
+
To convert Parquet to Lance
+
+ +
VLDB '25
+
Peer-reviewed research paper →
+
+
+
+ +
+

What is Lance?

+

Lance is a modern, open source lakehouse format for multimodal AI. It brings high-performance vector and full-text search, feature engineering, and model training to the lakehouse, powered by fast random access and scans — while keeping SQL analytics, ACID transactions, time travel, and integrations with open engines (Apache Spark, Ray, PyTorch, Trino, DuckDB) and open catalogs (Apache Polaris, Unity Catalog, Apache Gravitino, Hive Metastore).

+

Learn more in the research paper published at VLDB 2025.

+
+ +
+
+
01
+
+

Expressive hybrid search

+

Combine vector similarity, full-text search (BM25), and SQL analytics on the same dataset. All query types are accelerated by secondary indexes that are part of the Lance specification.

+ Learn more → +
+
+ +
import lance
+
+ds = lance.dataset("s3://my-bucket/docs")
+
+# Full text search
+ds.to_table(full_text_query="machine learning")
+
+# Hybrid search
+ds.to_table(
+    nearest={
+        "column": "embedding", "q": query_vec, "k": 10
+    },
+    filter="year > 2020",
+)
+
+
+ +
+
02
+
+

Lightning-fast random access

+

100x faster random access than Parquet or Iceberg. An optimized file format plus row addressing and secondary indexes let you fetch individual records across files instantly — for ML serving, sampling, and interactive apps.

+ Learn more → +
+
+ +
import lance
+
+ds = lance.dataset("s3://my-bucket/embeddings.lance")
+
+# Access the 2nd & 51st rows
+ds.take([2, 51], columns=["id", "vec_gemma3"])
+
+# Take 1000 random samples
+ds.sample(1000, columns=["id", "vec_llama"])
+
+
+ +
+
03
+
+

Native multimodal data

+

Store images, videos, audio, text, and embeddings alongside tabular data in one format. Blob encoding handles large binary objects with lazy loading; optimized vector storage accelerates similarity search.

+ Learn more → +
+
+ +
import lance
+import av
+
+ds = lance.dataset("s3://my-bucket/videos.lance")
+
+# Get blobs from the 2nd and 51st rows
+blobs = ds.take_blobs("video", ids=[2, 51])
+
+for blob in blobs:
+    with av.open(blob) as container:
+        stream = container.streams.video[0]
+        container.seek(start_time=500, stream=stream)
+
+
+ +
+
04
+
+

Data evolution > schema evolution

+

Backfilling column values normally forces a full table rewrite. Lance supports efficient schema evolution with backfill — adding a column with data is just writing new Lance files to the table.

+ Learn more → +
+
+ +
import lance
+
+dataset = lance.dataset("my_data.lance")
+
+@lance.batch_udf()
+def add_embeddings(batch):
+    vectors = model.encode(batch["text"])
+    return {"embedding": vectors}
+
+dataset.add_columns(add_embeddings)
+
+
+ +
+
05
+
+

Rich ecosystem integrations

+

Works with Pandas, Polars, Ray, and PyTorch for processing and ML. Connects to Apache DataFusion, DuckDB, Apache Spark, Trino, and Apache Flink for SQL analytics and distributed processing.

+ View integrations → +
+
+ Lance ecosystem integrations +
+
+
+
+{% endblock %} diff --git a/docs/theme/main.html b/docs/theme/main.html new file mode 100644 index 00000000000..f9a4a7e6bd9 --- /dev/null +++ b/docs/theme/main.html @@ -0,0 +1,92 @@ +{% extends "base.html" %} + +{#- Render one nav section as sidenav groups: its direct pages/links under the + section's own label, then each child section as a further group. -#} +{%- macro sidenav_section(sec, label) -%} + {%- set direct = sec.children | selectattr("is_section", "ne", true) | list -%} + {%- if direct -%} +
+
{{ label }}
+ {%- for child in direct %} + {%- if child.is_link %} + {{ child.title }} ↗ + {%- else %} + {{ child.title }} + {%- endif %} + {%- endfor %} +
+ {%- endif -%} + {%- for child in sec.children if child.is_section -%} + {{ sidenav_section(child, child.title) }} + {%- endfor -%} +{%- endmacro -%} + +{% block container %} + {%- set active_section = namespace(item=none) -%} + {%- for item in nav %}{% if item.active %}{% set active_section.item = item %}{% endif %}{% endfor %} + +
+ + + +
+
+ Docs + {%- for crumb in page.ancestors | reverse %} + /{{ crumb.title }} + {%- endfor %} + /{{ page.title }} +
+ +
+ {{ page.content }} +
+ +
+ {%- if page.previous_page %} + +
� Previous
+
{{ page.previous_page.title }}
+
+ {%- else %}{% endif %} + {%- if page.next_page %} + + {%- endif %} +
+
+ + +
+{% endblock %} diff --git a/java/AGENTS.md b/java/AGENTS.md index 70d35e5e0db..ba492720239 100644 --- a/java/AGENTS.md +++ b/java/AGENTS.md @@ -1,13 +1,35 @@ -## Structure -Java code: `java/src/main/java` -Rust JNI bindings: `java/lance-jni` +# Java Guidelines + +Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. ## Commands + Use `./mvnw` instead of `mvn` to ensure the correct version of Maven is used. -format: `./mvnw spotless:apply && cargo fmt --manifest-path ./lance-jni/Cargo.toml --all` -format (check only): `./mvnw spotless:check` -lint rust: `cargo clippy --tests --manifest-path ./lance-jni/Cargo.toml` -compile: `./mvnw compile` -test: `./mvnw test` + +* Format: `./mvnw spotless:apply && cargo fmt --manifest-path ./lance-jni/Cargo.toml --all` +* Format (check only): `./mvnw spotless:check` +* Lint Rust: `cargo clippy --tests --manifest-path ./lance-jni/Cargo.toml` +* Compile: `./mvnw compile` +* Test: `./mvnw test` JDK: pom.xml targets Java 11 (`maven.compiler.release` 11); align Rust toolchain with repository `rust-toolchain.toml`. + +## Structure + +* Java code: `java/src/main/java` +* Rust JNI bindings: `java/lance-jni` + +## API Design + +- Encapsulate related/optional params in `XxxOptions`/`XxxParams` objects or use the builder pattern instead of growing argument lists. +- Use strongly-typed enums (not raw `String`s) for version/config values. Serialize via explicit fields (e.g., `toRustString()`) matching Rust-expected formats, not Java `SCREAMING_SNAKE_CASE` `toString()`. Test all enum values across the JNI boundary. +- Return protobuf-backed opaque handles (e.g., serialized `bytes`) from public APIs and IPC interfaces instead of exposing internal data structures. + +## Code Style + +- Prefer top-level imports over fully qualified class names — only use fully qualified names to resolve ambiguity. +- Use JavaBean-style `getXXX()` for getter methods, not bare accessor style — serialization frameworks and IDE tooling rely on this convention. + +## Documentation + +- Copy Rust docs (defaults, constraints, invariants) into Javadoc for binding classes — users shouldn't need to read Rust source to understand API behavior. diff --git a/java/CLAUDE.md b/java/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/java/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/java/JAVA_THIRD_PARTY_LICENSES.md b/java/JAVA_THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000000..c3d8b60f8ca --- /dev/null +++ b/java/JAVA_THIRD_PARTY_LICENSES.md @@ -0,0 +1,68 @@ + +List of third-party dependencies grouped by their license type. + + Apache 2.0: + + * error-prone annotations (com.google.errorprone:error_prone_annotations:2.28.0 - https://errorprone.info/error_prone_annotations) + + Apache License 2.0: + + * JsonNullable Jackson module (org.openapitools:jackson-databind-nullable:0.2.6 - https://github.com/OpenAPITools/jackson-databind-nullable) + + Apache License V2.0: + + * FlatBuffers Java API (com.google.flatbuffers:flatbuffers-java:25.2.10 - https://github.com/google/flatbuffers) + + Apache License, Version 2.0: + + * Apache HttpClient (org.apache.httpcomponents.client5:httpclient5:5.2.1 - https://hc.apache.org/httpcomponents-client-5.0.x/5.2.1/httpclient5/) + * Apache HttpComponents Core HTTP/1.1 (org.apache.httpcomponents.core5:httpcore5:5.2 - https://hc.apache.org/httpcomponents-core-5.2.x/5.2/httpcore5/) + * Apache HttpComponents Core HTTP/2 (org.apache.httpcomponents.core5:httpcore5-h2:5.2 - https://hc.apache.org/httpcomponents-core-5.2.x/5.2/httpcore5-h2/) + * Guava: Google Core Libraries for Java (com.google.guava:guava:33.3.1-jre - https://github.com/google/guava) + * J2ObjC Annotations (com.google.j2objc:j2objc-annotations:3.0.0 - https://github.com/google/j2objc/) + * Netty/Buffer (io.netty:netty-buffer:4.1.119.Final - https://netty.io/netty-buffer/) + * Netty/Common (io.netty:netty-common:4.1.119.Final - https://netty.io/netty-common/) + + Apache-2.0: + + * Apache Commons Codec (commons-codec:commons-codec:1.18.0 - https://commons.apache.org/proper/commons-codec/) + * Apache Commons Lang (org.apache.commons:commons-lang3:3.18.0 - https://commons.apache.org/proper/commons-lang/) + * Arrow Format (org.apache.arrow:arrow-format:18.3.0 - https://arrow.apache.org/) + * Arrow Java C Data Interface (org.apache.arrow:arrow-c-data:18.3.0 - https://arrow.apache.org/) + * Arrow Java Dataset (org.apache.arrow:arrow-dataset:18.3.0 - https://arrow.apache.org/) + * Arrow Memory - Core (org.apache.arrow:arrow-memory-core:18.3.0 - https://arrow.apache.org/) + * Arrow Memory - Netty (org.apache.arrow:arrow-memory-netty:18.3.0 - https://arrow.apache.org/) + * Arrow Memory - Netty Buffer (org.apache.arrow:arrow-memory-netty-buffer-patch:18.3.0 - https://arrow.apache.org/) + * Arrow Vectors (org.apache.arrow:arrow-vector:18.3.0 - https://arrow.apache.org/) + * lance-namespace-apache-client (org.lance:lance-namespace-apache-client:0.4.5 - https://github.com/openapitools/openapi-generator) + * lance-namespace-core (org.lance:lance-namespace-core:0.4.5 - https://lance.org/format/namespace/lance-namespace-core/) + + EDL 1.0: + + * Jakarta Activation API jar (jakarta.activation:jakarta.activation-api:1.2.2 - https://github.com/eclipse-ee4j/jaf/jakarta.activation-api) + + Eclipse Distribution License - v 1.0: + + * Jakarta XML Binding API (jakarta.xml.bind:jakarta.xml.bind-api:2.3.3 - https://github.com/eclipse-ee4j/jaxb-api/jakarta.xml.bind-api) + + MIT: + + * SLF4J API Module (org.slf4j:slf4j-api:2.0.17 - http://www.slf4j.org) + + The Apache Software License, Version 2.0: + + * FindBugs-jsr305 (com.google.code.findbugs:jsr305:3.0.2 - http://findbugs.sourceforge.net/) + * Guava InternalFutureFailureAccess and InternalFutures (com.google.guava:failureaccess:1.0.2 - https://github.com/google/guava/failureaccess) + * Guava ListenableFuture only (com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava - https://github.com/google/guava/listenablefuture) + * Jackson datatype: JSR310 (com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.18.3 - https://github.com/FasterXML/jackson-modules-java8/jackson-datatype-jsr310) + * Jackson module: Old JAXB Annotations (javax.xml.bind) (com.fasterxml.jackson.module:jackson-module-jaxb-annotations:2.17.1 - https://github.com/FasterXML/jackson-modules-base) + * Jackson-annotations (com.fasterxml.jackson.core:jackson-annotations:2.18.3 - https://github.com/FasterXML/jackson) + * Jackson-core (com.fasterxml.jackson.core:jackson-core:2.18.3 - https://github.com/FasterXML/jackson-core) + * jackson-databind (com.fasterxml.jackson.core:jackson-databind:2.15.2 - https://github.com/FasterXML/jackson) + * Jackson-JAXRS: base (com.fasterxml.jackson.jaxrs:jackson-jaxrs-base:2.17.1 - https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-base) + * Jackson-JAXRS: JSON (com.fasterxml.jackson.jaxrs:jackson-jaxrs-json-provider:2.17.1 - https://github.com/FasterXML/jackson-jaxrs-providers/jackson-jaxrs-json-provider) + * JAR JNI Loader (org.questdb:jar-jni:1.1.1 - https://github.com/questdb/rust-maven-plugin) + + The MIT License: + + * Checker Qual (org.checkerframework:checker-qual:3.43.0 - https://checkerframework.org/) diff --git a/java/README.md b/java/README.md index b49a4892527..304241d3241 100644 --- a/java/README.md +++ b/java/README.md @@ -38,6 +38,35 @@ Introduce the Lance SDK Java Maven dependency(It is recommended to choose the la ``` +### R2 Maven repository + +During the transition, new stable, beta, and RC releases are published to both +Maven Central and `https://maven.lance.org`. Add this repository alongside Maven +Central to use it: + +```xml + + + lance + https://maven.lance.org + + +``` + +For Gradle: + +```kotlin +repositories { + mavenCentral() + maven { url = uri("https://maven.lance.org") } +} +``` + +Use an explicit published version of `org.lance:lance-core`, including the full +`-beta.N` or `-rc.N` suffix for preview releases. These are release versions, not +Maven `SNAPSHOT` versions. Downloads require no credentials. The R2 repository +does not publish `maven-metadata.xml` for version discovery or version ranges. + ### Basic I/O * create empty dataset @@ -212,6 +241,29 @@ JVM engine connectors can be built using the Lance Java SDK. Here are some conne * [Flink Lance connector](https://github.com/lancedb/lance-flink) * [Trino Lance connector](https://github.com/lancedb/lance-trino) +## Publishing + +The `Build and publish Java packages` workflow publishes stable, beta, and RC +releases to Maven Central, then extracts the same signed Central bundle and +uploads it to R2 with the AWS CLI. Pull requests and manual `dry_run` runs do not +publish to either repository. + +The R2 upload uses these GitHub Actions settings: + +| Type | Name | Value | +| --- | --- | --- | +| Secret | `R2_ACCESS_KEY_ID` | R2 S3 access key ID | +| Secret | `R2_SECRET_ACCESS_KEY` | R2 S3 secret access key | +| Variable | `R2_ENDPOINT` | R2 S3 endpoint for the bucket's account | +| Variable | `R2_BUCKET` | `lance-maven` | + +Scope the credentials to Object Read & Write on `lance-maven` and keep its custom +domain `maven.lance.org` enabled. Keep the existing Central and GPG credentials. + +An R2 upload failure fails the publish job after Central publication has already +succeeded. Re-running that job also repeats Central publication; it is not an +independent R2 retry. + ## Contributing From the codebase dimension, the lance project is a multiple-lang project. All Java-related code is located in the `java` directory. diff --git a/java/RUST_THIRD_PARTY_LICENSES.html b/java/RUST_THIRD_PARTY_LICENSES.html new file mode 100644 index 00000000000..f301bf6cf0e --- /dev/null +++ b/java/RUST_THIRD_PARTY_LICENSES.html @@ -0,0 +1,14462 @@ + + + + + + + +
+
+

Third Party Licenses

+

This page lists the licenses of the projects used in cargo-about.

+
+ +

Overview of licenses:

+ + +

All license text:

+
    +
  • +

    BSD Zero Clause License

    +

    Used by:

    + +
    Copyright (C) Jonas Schievink <jonasschievink@gmail.com>
    +
    +Permission to use, copy, modify, and/or distribute this software for
    +any purpose with or without fee is hereby granted.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
    +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
    +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
    +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2023 Jacob Pratt et al.
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2024 Jacob Pratt et al.
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +This project includes code from Apache Aurora.
    +
    +* dev/release/{release,changelog,release-candidate} are based on the scripts from
    +  Apache Aurora
    +
    +Copyright: 2016 The Apache Software Foundation.
    +Home page: https://aurora.apache.org/
    +License: http://www.apache.org/licenses/LICENSE-2.0
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors)
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    +                                 Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright (c) Microsoft Corporation.
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2020 - 2026 Tatsuya Kawano
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2020 Tomasz "Soveu" Marx
    +
    +   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.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2021 Datafuse Labs
    +
    +   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.
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2023 The Fuchsia Authors
    +
    +   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.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2023 dAxpeDDa
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "[]"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +   
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2017 Juniper Networks, Inc.
    +
    +   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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                     Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
    +
    +   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.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                   Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright [yyyy] [name of copyright owner]
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright (c) 2016 Alex Crichton
    +Copyright (c) 2017 The Tokio Authors
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2014 Paho Lurie-Gregg
    +
    +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.
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2016 Sean McArthur
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2017 http-rs authors
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2017 quininer kel
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2018 The pin-utils authors
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2019 The CryptoCorrosion Contributors
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2019 quininer kel
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2019- Jake Goulding
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2020 Andrew Straw
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2020 ridiculous_fish
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2021 Ivan Smirnov
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2023 Dirkjan Ochtman
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2023 The BigDecimal-rs Contributors
    +
    +
    +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.
    +
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +    http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +   http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     https://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     https://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	https://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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
                                  Apache License
    +                        Version 2.0, January 2004
    +                     https://www.apache.org/licenses/LICENSE-2.0
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	https://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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Apache License
    +                           Version 2.0, January 2004
    +                        http://www.apache.org/licenses/
    +
    +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +   1. Definitions.
    +
    +      "License" shall mean the terms and conditions for use, reproduction,
    +      and distribution as defined by Sections 1 through 9 of this document.
    +
    +      "Licensor" shall mean the copyright owner or entity authorized by
    +      the copyright owner that is granting the License.
    +
    +      "Legal Entity" shall mean the union of the acting entity and all
    +      other entities that control, are controlled by, or are under common
    +      control with that entity. For the purposes of this definition,
    +      "control" means (i) the power, direct or indirect, to cause the
    +      direction or management of such entity, whether by contract or
    +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +      outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +      "You" (or "Your") shall mean an individual or Legal Entity
    +      exercising permissions granted by this License.
    +
    +      "Source" form shall mean the preferred form for making modifications,
    +      including but not limited to software source code, documentation
    +      source, and configuration files.
    +
    +      "Object" form shall mean any form resulting from mechanical
    +      transformation or translation of a Source form, including but
    +      not limited to compiled object code, generated documentation,
    +      and conversions to other media types.
    +
    +      "Work" shall mean the work of authorship, whether in Source or
    +      Object form, made available under the License, as indicated by a
    +      copyright notice that is included in or attached to the work
    +      (an example is provided in the Appendix below).
    +
    +      "Derivative Works" shall mean any work, whether in Source or Object
    +      form, that is based on (or derived from) the Work and for which the
    +      editorial revisions, annotations, elaborations, or other modifications
    +      represent, as a whole, an original work of authorship. For the purposes
    +      of this License, Derivative Works shall not include works that remain
    +      separable from, or merely link (or bind by name) to the interfaces of,
    +      the Work and Derivative Works thereof.
    +
    +      "Contribution" shall mean any work of authorship, including
    +      the original version of the Work and any modifications or additions
    +      to that Work or Derivative Works thereof, that is intentionally
    +      submitted to Licensor for inclusion in the Work by the copyright owner
    +      or by an individual or Legal Entity authorized to submit on behalf of
    +      the copyright owner. For the purposes of this definition, "submitted"
    +      means any form of electronic, verbal, or written communication sent
    +      to the Licensor or its representatives, including but not limited to
    +      communication on electronic mailing lists, source code control systems,
    +      and issue tracking systems that are managed by, or on behalf of, the
    +      Licensor for the purpose of discussing and improving the Work, but
    +      excluding communication that is conspicuously marked or otherwise
    +      designated in writing by the copyright owner as "Not a Contribution."
    +
    +      "Contributor" shall mean Licensor and any individual or Legal Entity
    +      on behalf of whom a Contribution has been received by Licensor and
    +      subsequently incorporated within the Work.
    +
    +   2. Grant of Copyright License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      copyright license to reproduce, prepare Derivative Works of,
    +      publicly display, publicly perform, sublicense, and distribute the
    +      Work and such Derivative Works in Source or Object form.
    +
    +   3. Grant of Patent License. Subject to the terms and conditions of
    +      this License, each Contributor hereby grants to You a perpetual,
    +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +      (except as stated in this section) patent license to make, have made,
    +      use, offer to sell, sell, import, and otherwise transfer the Work,
    +      where such license applies only to those patent claims licensable
    +      by such Contributor that are necessarily infringed by their
    +      Contribution(s) alone or by combination of their Contribution(s)
    +      with the Work to which such Contribution(s) was submitted. If You
    +      institute patent litigation against any entity (including a
    +      cross-claim or counterclaim in a lawsuit) alleging that the Work
    +      or a Contribution incorporated within the Work constitutes direct
    +      or contributory patent infringement, then any patent licenses
    +      granted to You under this License for that Work shall terminate
    +      as of the date such litigation is filed.
    +
    +   4. Redistribution. You may reproduce and distribute copies of the
    +      Work or Derivative Works thereof in any medium, with or without
    +      modifications, and in Source or Object form, provided that You
    +      meet the following conditions:
    +
    +      (a) You must give any other recipients of the Work or
    +          Derivative Works a copy of this License; and
    +
    +      (b) You must cause any modified files to carry prominent notices
    +          stating that You changed the files; and
    +
    +      (c) You must retain, in the Source form of any Derivative Works
    +          that You distribute, all copyright, patent, trademark, and
    +          attribution notices from the Source form of the Work,
    +          excluding those notices that do not pertain to any part of
    +          the Derivative Works; and
    +
    +      (d) If the Work includes a "NOTICE" text file as part of its
    +          distribution, then any Derivative Works that You distribute must
    +          include a readable copy of the attribution notices contained
    +          within such NOTICE file, excluding those notices that do not
    +          pertain to any part of the Derivative Works, in at least one
    +          of the following places: within a NOTICE text file distributed
    +          as part of the Derivative Works; within the Source form or
    +          documentation, if provided along with the Derivative Works; or,
    +          within a display generated by the Derivative Works, if and
    +          wherever such third-party notices normally appear. The contents
    +          of the NOTICE file are for informational purposes only and
    +          do not modify the License. You may add Your own attribution
    +          notices within Derivative Works that You distribute, alongside
    +          or as an addendum to the NOTICE text from the Work, provided
    +          that such additional attribution notices cannot be construed
    +          as modifying the License.
    +
    +      You may add Your own copyright statement to Your modifications and
    +      may provide additional or different license terms and conditions
    +      for use, reproduction, or distribution of Your modifications, or
    +      for any such Derivative Works as a whole, provided Your use,
    +      reproduction, and distribution of the Work otherwise complies with
    +      the conditions stated in this License.
    +
    +   5. Submission of Contributions. Unless You explicitly state otherwise,
    +      any Contribution intentionally submitted for inclusion in the Work
    +      by You to the Licensor shall be under the terms and conditions of
    +      this License, without any additional terms or conditions.
    +      Notwithstanding the above, nothing herein shall supersede or modify
    +      the terms of any separate license agreement you may have executed
    +      with Licensor regarding such Contributions.
    +
    +   6. Trademarks. This License does not grant permission to use the trade
    +      names, trademarks, service marks, or product names of the Licensor,
    +      except as required for reasonable and customary use in describing the
    +      origin of the Work and reproducing the content of the NOTICE file.
    +
    +   7. Disclaimer of Warranty. Unless required by applicable law or
    +      agreed to in writing, Licensor provides the Work (and each
    +      Contributor provides its Contributions) on an "AS IS" BASIS,
    +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +      implied, including, without limitation, any warranties or conditions
    +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +      PARTICULAR PURPOSE. You are solely responsible for determining the
    +      appropriateness of using or redistributing the Work and assume any
    +      risks associated with Your exercise of permissions under this License.
    +
    +   8. Limitation of Liability. In no event and under no legal theory,
    +      whether in tort (including negligence), contract, or otherwise,
    +      unless required by applicable law (such as deliberate and grossly
    +      negligent acts) or agreed to in writing, shall any Contributor be
    +      liable to You for damages, including any direct, indirect, special,
    +      incidental, or consequential damages of any character arising as a
    +      result of this License or out of the use or inability to use the
    +      Work (including but not limited to damages for loss of goodwill,
    +      work stoppage, computer failure or malfunction, or any and all
    +      other commercial damages or losses), even if such Contributor
    +      has been advised of the possibility of such damages.
    +
    +   9. Accepting Warranty or Additional Liability. While redistributing
    +      the Work or Derivative Works thereof, You may choose to offer,
    +      and charge a fee for, acceptance of support, warranty, indemnity,
    +      or other liability obligations and/or rights consistent with this
    +      License. However, in accepting such obligations, You may act only
    +      on Your own behalf and on Your sole responsibility, not on behalf
    +      of any other Contributor, and only if You agree to indemnify,
    +      defend, and hold each Contributor harmless for any liability
    +      incurred by, or claims asserted against, such Contributor by reason
    +      of your accepting any such warranty or additional liability.
    +
    +   END OF TERMS AND CONDITIONS
    +
    +   APPENDIX: How to apply the Apache License to your work.
    +
    +      To apply the Apache License to your work, attach the following
    +      boilerplate notice, with the fields enclosed by brackets "{}"
    +      replaced with your own identifying information. (Don't include
    +      the brackets!)  The text should be enclosed in the appropriate
    +      comment syntax for the file format. We also recommend that a
    +      file or class name and description of purpose be included on the
    +      same "printed page" as the copyright notice for easier
    +      identification within third-party archives.
    +
    +   Copyright {yyyy} {name of copyright owner}
    +
    +   Licensed under the Apache License, Version 2.0 (the "License");
    +   you may not use this file except in compliance with the License.
    +   You may obtain a copy of the License at
    +
    +       http://www.apache.org/licenses/LICENSE-2.0
    +
    +   Unless required by applicable law or agreed to in writing, software
    +   distributed under the License is distributed on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +   See the License for the specific language governing permissions and
    +   limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Apache License
    +Version 2.0, January 2004
    +http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +    1. Definitions.
    +
    +        "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
    +
    +        "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
    +
    +        "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +        "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
    +
    +        "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
    +
    +        "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
    +
    +        "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
    +
    +        "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
    +
    +        "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
    +
    +        "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
    +    2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
    +    3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
    +    4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
    +        (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
    +        (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
    +        (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
    +        (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
    +
    +        You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
    +    5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
    +    6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
    +    7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
    +    8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
    +    9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Apache License
    +Version 2.0, January 2004
    +http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +"License" shall mean the terms and conditions for use, reproduction,
    +and distribution as defined by Sections 1 through 9 of this document.
    +
    +"Licensor" shall mean the copyright owner or entity authorized by
    +the copyright owner that is granting the License.
    +
    +"Legal Entity" shall mean the union of the acting entity and all
    +other entities that control, are controlled by, or are under common
    +control with that entity. For the purposes of this definition,
    +"control" means (i) the power, direct or indirect, to cause the
    +direction or management of such entity, whether by contract or
    +otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +"You" (or "Your") shall mean an individual or Legal Entity
    +exercising permissions granted by this License.
    +
    +"Source" form shall mean the preferred form for making modifications,
    +including but not limited to software source code, documentation
    +source, and configuration files.
    +
    +"Object" form shall mean any form resulting from mechanical
    +transformation or translation of a Source form, including but
    +not limited to compiled object code, generated documentation,
    +and conversions to other media types.
    +
    +"Work" shall mean the work of authorship, whether in Source or
    +Object form, made available under the License, as indicated by a
    +copyright notice that is included in or attached to the work
    +(an example is provided in the Appendix below).
    +
    +"Derivative Works" shall mean any work, whether in Source or Object
    +form, that is based on (or derived from) the Work and for which the
    +editorial revisions, annotations, elaborations, or other modifications
    +represent, as a whole, an original work of authorship. For the purposes
    +of this License, Derivative Works shall not include works that remain
    +separable from, or merely link (or bind by name) to the interfaces of,
    +the Work and Derivative Works thereof.
    +
    +"Contribution" shall mean any work of authorship, including
    +the original version of the Work and any modifications or additions
    +to that Work or Derivative Works thereof, that is intentionally
    +submitted to Licensor for inclusion in the Work by the copyright owner
    +or by an individual or Legal Entity authorized to submit on behalf of
    +the copyright owner. For the purposes of this definition, "submitted"
    +means any form of electronic, verbal, or written communication sent
    +to the Licensor or its representatives, including but not limited to
    +communication on electronic mailing lists, source code control systems,
    +and issue tracking systems that are managed by, or on behalf of, the
    +Licensor for the purpose of discussing and improving the Work, but
    +excluding communication that is conspicuously marked or otherwise
    +designated in writing by the copyright owner as "Not a Contribution."
    +
    +"Contributor" shall mean Licensor and any individual or Legal Entity
    +on behalf of whom a Contribution has been received by Licensor and
    +subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +this License, each Contributor hereby grants to You a perpetual,
    +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +copyright license to reproduce, prepare Derivative Works of,
    +publicly display, publicly perform, sublicense, and distribute the
    +Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +this License, each Contributor hereby grants to You a perpetual,
    +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +(except as stated in this section) patent license to make, have made,
    +use, offer to sell, sell, import, and otherwise transfer the Work,
    +where such license applies only to those patent claims licensable
    +by such Contributor that are necessarily infringed by their
    +Contribution(s) alone or by combination of their Contribution(s)
    +with the Work to which such Contribution(s) was submitted. If You
    +institute patent litigation against any entity (including a
    +cross-claim or counterclaim in a lawsuit) alleging that the Work
    +or a Contribution incorporated within the Work constitutes direct
    +or contributory patent infringement, then any patent licenses
    +granted to You under this License for that Work shall terminate
    +as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +Work or Derivative Works thereof in any medium, with or without
    +modifications, and in Source or Object form, provided that You
    +meet the following conditions:
    +
    +(a) You must give any other recipients of the Work or
    +Derivative Works a copy of this License; and
    +
    +(b) You must cause any modified files to carry prominent notices
    +stating that You changed the files; and
    +
    +(c) You must retain, in the Source form of any Derivative Works
    +that You distribute, all copyright, patent, trademark, and
    +attribution notices from the Source form of the Work,
    +excluding those notices that do not pertain to any part of
    +the Derivative Works; and
    +
    +(d) If the Work includes a "NOTICE" text file as part of its
    +distribution, then any Derivative Works that You distribute must
    +include a readable copy of the attribution notices contained
    +within such NOTICE file, excluding those notices that do not
    +pertain to any part of the Derivative Works, in at least one
    +of the following places: within a NOTICE text file distributed
    +as part of the Derivative Works; within the Source form or
    +documentation, if provided along with the Derivative Works; or,
    +within a display generated by the Derivative Works, if and
    +wherever such third-party notices normally appear. The contents
    +of the NOTICE file are for informational purposes only and
    +do not modify the License. You may add Your own attribution
    +notices within Derivative Works that You distribute, alongside
    +or as an addendum to the NOTICE text from the Work, provided
    +that such additional attribution notices cannot be construed
    +as modifying the License.
    +
    +You may add Your own copyright statement to Your modifications and
    +may provide additional or different license terms and conditions
    +for use, reproduction, or distribution of Your modifications, or
    +for any such Derivative Works as a whole, provided Your use,
    +reproduction, and distribution of the Work otherwise complies with
    +the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +any Contribution intentionally submitted for inclusion in the Work
    +by You to the Licensor shall be under the terms and conditions of
    +this License, without any additional terms or conditions.
    +Notwithstanding the above, nothing herein shall supersede or modify
    +the terms of any separate license agreement you may have executed
    +with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +names, trademarks, service marks, or product names of the Licensor,
    +except as required for reasonable and customary use in describing the
    +origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +agreed to in writing, Licensor provides the Work (and each
    +Contributor provides its Contributions) on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +implied, including, without limitation, any warranties or conditions
    +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +PARTICULAR PURPOSE. You are solely responsible for determining the
    +appropriateness of using or redistributing the Work and assume any
    +risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +whether in tort (including negligence), contract, or otherwise,
    +unless required by applicable law (such as deliberate and grossly
    +negligent acts) or agreed to in writing, shall any Contributor be
    +liable to You for damages, including any direct, indirect, special,
    +incidental, or consequential damages of any character arising as a
    +result of this License or out of the use or inability to use the
    +Work (including but not limited to damages for loss of goodwill,
    +work stoppage, computer failure or malfunction, or any and all
    +other commercial damages or losses), even if such Contributor
    +has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +the Work or Derivative Works thereof, You may choose to offer,
    +and charge a fee for, acceptance of support, warranty, indemnity,
    +or other liability obligations and/or rights consistent with this
    +License. However, in accepting such obligations, You may act only
    +on Your own behalf and on Your sole responsibility, not on behalf
    +of any other Contributor, and only if You agree to indemnify,
    +defend, and hold each Contributor harmless for any liability
    +incurred by, or claims asserted against, such Contributor by reason
    +of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +To apply the Apache License to your work, attach the following
    +boilerplate notice, with the fields enclosed by brackets "[]"
    +replaced with your own identifying information. (Don't include
    +the brackets!)  The text should be enclosed in the appropriate
    +comment syntax for the file format. We also recommend that a
    +file or class name and description of purpose be included on the
    +same "printed page" as the copyright notice for easier
    +identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Apache License
    +Version 2.0, January 2004
    +http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
    +
    +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
    +
    +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
    +
    +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
    +
    +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
    +
    +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
    +
    +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
    +
    +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
    +
    +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
    +
    +     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
    +
    +     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
    +
    +     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
    +
    +     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
    +
    +     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Chrono-TZ is dual-licensed under the MIT License and Apache 2.0 Licence.
    +The licenses do not apply to files in the tzdb folder which are in the
    +public domain. parse-zoneinfo was forked from zoneinfo-parse, which
    +was originally created by Benjamin Sago under the MIT license.
    +
    +Copyright (c) 2016-2024 Benjamin Sago & the chronotope maintainers
    +
    +The MIT License
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright 2016 Djzin
    +
    +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.
    +
    +
  • +
  • +

    Apache License 2.0

    +

    Used by:

    + +
    Rust-chrono is dual-licensed under The MIT License [1] and
    +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and
    +contributors.
    +
    +Nota Bene: This is same as the Rust Project's own license.
    +
    +
    +[1]: <http://opensource.org/licenses/MIT>, which is reproduced below:
    +
    +~~~~
    +The MIT License (MIT)
    +
    +Copyright (c) 2014, Kang Seonghoon.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +~~~~
    +
    +
    +[2]: <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below:
    +
    +~~~~
    +                              Apache License
    +                        Version 2.0, January 2004
    +                     http://www.apache.org/licenses/
    +
    +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
    +
    +1. Definitions.
    +
    +   "License" shall mean the terms and conditions for use, reproduction,
    +   and distribution as defined by Sections 1 through 9 of this document.
    +
    +   "Licensor" shall mean the copyright owner or entity authorized by
    +   the copyright owner that is granting the License.
    +
    +   "Legal Entity" shall mean the union of the acting entity and all
    +   other entities that control, are controlled by, or are under common
    +   control with that entity. For the purposes of this definition,
    +   "control" means (i) the power, direct or indirect, to cause the
    +   direction or management of such entity, whether by contract or
    +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
    +   outstanding shares, or (iii) beneficial ownership of such entity.
    +
    +   "You" (or "Your") shall mean an individual or Legal Entity
    +   exercising permissions granted by this License.
    +
    +   "Source" form shall mean the preferred form for making modifications,
    +   including but not limited to software source code, documentation
    +   source, and configuration files.
    +
    +   "Object" form shall mean any form resulting from mechanical
    +   transformation or translation of a Source form, including but
    +   not limited to compiled object code, generated documentation,
    +   and conversions to other media types.
    +
    +   "Work" shall mean the work of authorship, whether in Source or
    +   Object form, made available under the License, as indicated by a
    +   copyright notice that is included in or attached to the work
    +   (an example is provided in the Appendix below).
    +
    +   "Derivative Works" shall mean any work, whether in Source or Object
    +   form, that is based on (or derived from) the Work and for which the
    +   editorial revisions, annotations, elaborations, or other modifications
    +   represent, as a whole, an original work of authorship. For the purposes
    +   of this License, Derivative Works shall not include works that remain
    +   separable from, or merely link (or bind by name) to the interfaces of,
    +   the Work and Derivative Works thereof.
    +
    +   "Contribution" shall mean any work of authorship, including
    +   the original version of the Work and any modifications or additions
    +   to that Work or Derivative Works thereof, that is intentionally
    +   submitted to Licensor for inclusion in the Work by the copyright owner
    +   or by an individual or Legal Entity authorized to submit on behalf of
    +   the copyright owner. For the purposes of this definition, "submitted"
    +   means any form of electronic, verbal, or written communication sent
    +   to the Licensor or its representatives, including but not limited to
    +   communication on electronic mailing lists, source code control systems,
    +   and issue tracking systems that are managed by, or on behalf of, the
    +   Licensor for the purpose of discussing and improving the Work, but
    +   excluding communication that is conspicuously marked or otherwise
    +   designated in writing by the copyright owner as "Not a Contribution."
    +
    +   "Contributor" shall mean Licensor and any individual or Legal Entity
    +   on behalf of whom a Contribution has been received by Licensor and
    +   subsequently incorporated within the Work.
    +
    +2. Grant of Copyright License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   copyright license to reproduce, prepare Derivative Works of,
    +   publicly display, publicly perform, sublicense, and distribute the
    +   Work and such Derivative Works in Source or Object form.
    +
    +3. Grant of Patent License. Subject to the terms and conditions of
    +   this License, each Contributor hereby grants to You a perpetual,
    +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
    +   (except as stated in this section) patent license to make, have made,
    +   use, offer to sell, sell, import, and otherwise transfer the Work,
    +   where such license applies only to those patent claims licensable
    +   by such Contributor that are necessarily infringed by their
    +   Contribution(s) alone or by combination of their Contribution(s)
    +   with the Work to which such Contribution(s) was submitted. If You
    +   institute patent litigation against any entity (including a
    +   cross-claim or counterclaim in a lawsuit) alleging that the Work
    +   or a Contribution incorporated within the Work constitutes direct
    +   or contributory patent infringement, then any patent licenses
    +   granted to You under this License for that Work shall terminate
    +   as of the date such litigation is filed.
    +
    +4. Redistribution. You may reproduce and distribute copies of the
    +   Work or Derivative Works thereof in any medium, with or without
    +   modifications, and in Source or Object form, provided that You
    +   meet the following conditions:
    +
    +   (a) You must give any other recipients of the Work or
    +       Derivative Works a copy of this License; and
    +
    +   (b) You must cause any modified files to carry prominent notices
    +       stating that You changed the files; and
    +
    +   (c) You must retain, in the Source form of any Derivative Works
    +       that You distribute, all copyright, patent, trademark, and
    +       attribution notices from the Source form of the Work,
    +       excluding those notices that do not pertain to any part of
    +       the Derivative Works; and
    +
    +   (d) If the Work includes a "NOTICE" text file as part of its
    +       distribution, then any Derivative Works that You distribute must
    +       include a readable copy of the attribution notices contained
    +       within such NOTICE file, excluding those notices that do not
    +       pertain to any part of the Derivative Works, in at least one
    +       of the following places: within a NOTICE text file distributed
    +       as part of the Derivative Works; within the Source form or
    +       documentation, if provided along with the Derivative Works; or,
    +       within a display generated by the Derivative Works, if and
    +       wherever such third-party notices normally appear. The contents
    +       of the NOTICE file are for informational purposes only and
    +       do not modify the License. You may add Your own attribution
    +       notices within Derivative Works that You distribute, alongside
    +       or as an addendum to the NOTICE text from the Work, provided
    +       that such additional attribution notices cannot be construed
    +       as modifying the License.
    +
    +   You may add Your own copyright statement to Your modifications and
    +   may provide additional or different license terms and conditions
    +   for use, reproduction, or distribution of Your modifications, or
    +   for any such Derivative Works as a whole, provided Your use,
    +   reproduction, and distribution of the Work otherwise complies with
    +   the conditions stated in this License.
    +
    +5. Submission of Contributions. Unless You explicitly state otherwise,
    +   any Contribution intentionally submitted for inclusion in the Work
    +   by You to the Licensor shall be under the terms and conditions of
    +   this License, without any additional terms or conditions.
    +   Notwithstanding the above, nothing herein shall supersede or modify
    +   the terms of any separate license agreement you may have executed
    +   with Licensor regarding such Contributions.
    +
    +6. Trademarks. This License does not grant permission to use the trade
    +   names, trademarks, service marks, or product names of the Licensor,
    +   except as required for reasonable and customary use in describing the
    +   origin of the Work and reproducing the content of the NOTICE file.
    +
    +7. Disclaimer of Warranty. Unless required by applicable law or
    +   agreed to in writing, Licensor provides the Work (and each
    +   Contributor provides its Contributions) on an "AS IS" BASIS,
    +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
    +   implied, including, without limitation, any warranties or conditions
    +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
    +   PARTICULAR PURPOSE. You are solely responsible for determining the
    +   appropriateness of using or redistributing the Work and assume any
    +   risks associated with Your exercise of permissions under this License.
    +
    +8. Limitation of Liability. In no event and under no legal theory,
    +   whether in tort (including negligence), contract, or otherwise,
    +   unless required by applicable law (such as deliberate and grossly
    +   negligent acts) or agreed to in writing, shall any Contributor be
    +   liable to You for damages, including any direct, indirect, special,
    +   incidental, or consequential damages of any character arising as a
    +   result of this License or out of the use or inability to use the
    +   Work (including but not limited to damages for loss of goodwill,
    +   work stoppage, computer failure or malfunction, or any and all
    +   other commercial damages or losses), even if such Contributor
    +   has been advised of the possibility of such damages.
    +
    +9. Accepting Warranty or Additional Liability. While redistributing
    +   the Work or Derivative Works thereof, You may choose to offer,
    +   and charge a fee for, acceptance of support, warranty, indemnity,
    +   or other liability obligations and/or rights consistent with this
    +   License. However, in accepting such obligations, You may act only
    +   on Your own behalf and on Your sole responsibility, not on behalf
    +   of any other Contributor, and only if You agree to indemnify,
    +   defend, and hold each Contributor harmless for any liability
    +   incurred by, or claims asserted against, such Contributor by reason
    +   of your accepting any such warranty or additional liability.
    +
    +END OF TERMS AND CONDITIONS
    +
    +APPENDIX: How to apply the Apache License to your work.
    +
    +   To apply the Apache License to your work, attach the following
    +   boilerplate notice, with the fields enclosed by brackets "[]"
    +   replaced with your own identifying information. (Don't include
    +   the brackets!)  The text should be enclosed in the appropriate
    +   comment syntax for the file format. We also recommend that a
    +   file or class name and description of purpose be included on the
    +   same "printed page" as the copyright notice for easier
    +   identification within third-party archives.
    +
    +Copyright [yyyy] [name of copyright owner]
    +
    +Licensed under the Apache License, Version 2.0 (the "License");
    +you may not use this file except in compliance with the License.
    +You may obtain a copy of the License at
    +
    +	http://www.apache.org/licenses/LICENSE-2.0
    +
    +Unless required by applicable law or agreed to in writing, software
    +distributed under the License is distributed on an "AS IS" BASIS,
    +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    +See the License for the specific language governing permissions and
    +limitations under the License.
    +~~~~
    +
    +
    +
  • +
  • +

    BSD 2-Clause "Simplified" License

    +

    Used by:

    + +
    Copyright (c) 2015 David Roundy <roundyd@physics.oregonstate.edu>
    +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.
    +
    +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
    +HOLDER 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.
    +
    +
  • +
  • +

    BSD 2-Clause "Simplified" License

    +

    Used by:

    + +
    Copyright (c) 2017-2020
    +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.
    +
    +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.
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    BSD 3-Clause License
    +
    +Copyright (c) 2013, Julien Schmidt
    +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 copyright holder 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 HOLDER 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.
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright (c) 2001, Dr Martin Porter
    +Copyright (c) 2004,2005, Richard Boulton
    +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.
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright (c) 2016 Dropbox, Inc.
    +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 copyright holder 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 HOLDER 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.
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.
    +Copyright (c) 2016-2024 Isis Agora Lovecruft. 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 copyright holder 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
    +HOLDER 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. 
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright (c) <year> <owner>. 
    +
    +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 copyright holder 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 HOLDER 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.
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright 2011, The Snappy-Rust Authors. All rights reserved.
    +
    +Redistribution and use in source and binary forms, with or without
    +modification, are permitted provided that the following conditions are
    +met:
    +
    +    * Redistributions of source code must retain the above copyright
    +notice, this list of conditions and the following disclaimer.
    +    * 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.
    +    * Neither the name of the copyright holder 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.
    +
    +
  • +
  • +

    BSD 3-Clause "New" or "Revised" License

    +

    Used by:

    + +
    Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
    +
    +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 copyright holder 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 HOLDER 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.
    +
    +
  • +
  • +

    Boost Software License 1.0

    +

    Used by:

    + +
    Boost Software License - Version 1.0 - August 17th, 2003
    +
    +Permission is hereby granted, free of charge, to any person or organization
    +obtaining a copy of the software and accompanying documentation covered by
    +this license (the "Software") to use, reproduce, display, distribute,
    +execute, and transmit the Software, and to prepare derivative works of the
    +Software, and to permit third-parties to whom the Software is furnished to
    +do so, all subject to the following:
    +
    +The copyright notices in the Software and this entire statement, including
    +the above license grant, this restriction and the following disclaimer,
    +must be included in all copies of the Software, in whole or in part, and
    +all derivative works of the Software, unless such copies or derivative
    +works are solely in the form of machine-executable object code generated by
    +a source language processor.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
    +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
    +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
    +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    Creative Commons Zero v1.0 Universal

    +

    Used by:

    + +
    Creative Commons Legal Code
    +
    +CC0 1.0 Universal
    +
    +    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
    +    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
    +    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
    +    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
    +    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
    +    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
    +    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
    +    HEREUNDER.
    +
    +Statement of Purpose
    +
    +The laws of most jurisdictions throughout the world automatically confer
    +exclusive Copyright and Related Rights (defined below) upon the creator
    +and subsequent owner(s) (each and all, an "owner") of an original work of
    +authorship and/or a database (each, a "Work").
    +
    +Certain owners wish to permanently relinquish those rights to a Work for
    +the purpose of contributing to a commons of creative, cultural and
    +scientific works ("Commons") that the public can reliably and without fear
    +of later claims of infringement build upon, modify, incorporate in other
    +works, reuse and redistribute as freely as possible in any form whatsoever
    +and for any purposes, including without limitation commercial purposes.
    +These owners may contribute to the Commons to promote the ideal of a free
    +culture and the further production of creative, cultural and scientific
    +works, or to gain reputation or greater distribution for their Work in
    +part through the use and efforts of others.
    +
    +For these and/or other purposes and motivations, and without any
    +expectation of additional consideration or compensation, the person
    +associating CC0 with a Work (the "Affirmer"), to the extent that he or she
    +is an owner of Copyright and Related Rights in the Work, voluntarily
    +elects to apply CC0 to the Work and publicly distribute the Work under its
    +terms, with knowledge of his or her Copyright and Related Rights in the
    +Work and the meaning and intended legal effect of CC0 on those rights.
    +
    +1. Copyright and Related Rights. A Work made available under CC0 may be
    +protected by copyright and related or neighboring rights ("Copyright and
    +Related Rights"). Copyright and Related Rights include, but are not
    +limited to, the following:
    +
    +  i. the right to reproduce, adapt, distribute, perform, display,
    +     communicate, and translate a Work;
    + ii. moral rights retained by the original author(s) and/or performer(s);
    +iii. publicity and privacy rights pertaining to a person's image or
    +     likeness depicted in a Work;
    + iv. rights protecting against unfair competition in regards to a Work,
    +     subject to the limitations in paragraph 4(a), below;
    +  v. rights protecting the extraction, dissemination, use and reuse of data
    +     in a Work;
    + vi. database rights (such as those arising under Directive 96/9/EC of the
    +     European Parliament and of the Council of 11 March 1996 on the legal
    +     protection of databases, and under any national implementation
    +     thereof, including any amended or successor version of such
    +     directive); and
    +vii. other similar, equivalent or corresponding rights throughout the
    +     world based on applicable law or treaty, and any national
    +     implementations thereof.
    +
    +2. Waiver. To the greatest extent permitted by, but not in contravention
    +of, applicable law, Affirmer hereby overtly, fully, permanently,
    +irrevocably and unconditionally waives, abandons, and surrenders all of
    +Affirmer's Copyright and Related Rights and associated claims and causes
    +of action, whether now known or unknown (including existing as well as
    +future claims and causes of action), in the Work (i) in all territories
    +worldwide, (ii) for the maximum duration provided by applicable law or
    +treaty (including future time extensions), (iii) in any current or future
    +medium and for any number of copies, and (iv) for any purpose whatsoever,
    +including without limitation commercial, advertising or promotional
    +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
    +member of the public at large and to the detriment of Affirmer's heirs and
    +successors, fully intending that such Waiver shall not be subject to
    +revocation, rescission, cancellation, termination, or any other legal or
    +equitable action to disrupt the quiet enjoyment of the Work by the public
    +as contemplated by Affirmer's express Statement of Purpose.
    +
    +3. Public License Fallback. Should any part of the Waiver for any reason
    +be judged legally invalid or ineffective under applicable law, then the
    +Waiver shall be preserved to the maximum extent permitted taking into
    +account Affirmer's express Statement of Purpose. In addition, to the
    +extent the Waiver is so judged Affirmer hereby grants to each affected
    +person a royalty-free, non transferable, non sublicensable, non exclusive,
    +irrevocable and unconditional license to exercise Affirmer's Copyright and
    +Related Rights in the Work (i) in all territories worldwide, (ii) for the
    +maximum duration provided by applicable law or treaty (including future
    +time extensions), (iii) in any current or future medium and for any number
    +of copies, and (iv) for any purpose whatsoever, including without
    +limitation commercial, advertising or promotional purposes (the
    +"License"). The License shall be deemed effective as of the date CC0 was
    +applied by Affirmer to the Work. Should any part of the License for any
    +reason be judged legally invalid or ineffective under applicable law, such
    +partial invalidity or ineffectiveness shall not invalidate the remainder
    +of the License, and in such case Affirmer hereby affirms that he or she
    +will not (i) exercise any of his or her remaining Copyright and Related
    +Rights in the Work or (ii) assert any associated claims and causes of
    +action with respect to the Work, in either case contrary to Affirmer's
    +express Statement of Purpose.
    +
    +4. Limitations and Disclaimers.
    +
    + a. No trademark or patent rights held by Affirmer are waived, abandoned,
    +    surrendered, licensed or otherwise affected by this document.
    + b. Affirmer offers the Work as-is and makes no representations or
    +    warranties of any kind concerning the Work, express, implied,
    +    statutory or otherwise, including without limitation warranties of
    +    title, merchantability, fitness for a particular purpose, non
    +    infringement, or the absence of latent or other defects, accuracy, or
    +    the present or absence of errors, whether or not discoverable, all to
    +    the greatest extent permissible under applicable law.
    + c. Affirmer disclaims responsibility for clearing rights of other persons
    +    that may apply to the Work or any use thereof, including without
    +    limitation any person's Copyright and Related Rights in the Work.
    +    Further, Affirmer disclaims responsibility for obtaining any necessary
    +    consents, permissions or other rights required for any use of the
    +    Work.
    + d. Affirmer understands and acknowledges that Creative Commons is not a
    +    party to this document and has no duty or obligation with respect to
    +    this CC0 or use of the Work.
    +
    +
  • +
  • +

    Community Data License Agreement Permissive 2.0

    +

    Used by:

    + +
    # Community Data License Agreement - Permissive - Version 2.0
    +
    +This is the Community Data License Agreement - Permissive, Version
    +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree
    +as follows:
    +
    +## 1. Provision of the Data
    +
    +1.1. A Data Recipient may use, modify, and share the Data made
    +available by Data Provider(s) under this agreement if that Data
    +Recipient follows the terms of this agreement.
    +
    +1.2. This agreement does not impose any restriction on a Data
    +Recipient's use, modification, or sharing of any portions of the
    +Data that are in the public domain or that may be used, modified,
    +or shared under any other legal exception or limitation.
    +
    +## 2. Conditions for Sharing Data
    +
    +2.1. A Data Recipient may share Data, with or without modifications, so
    +long as the Data Recipient makes available the text of this agreement
    +with the shared Data.
    +
    +## 3. No Restrictions on Results
    +
    +3.1. This agreement does not impose any restriction or obligations
    +with respect to the use, modification, or sharing of Results.
    +
    +## 4. No Warranty; Limitation of Liability
    +
    +4.1. All Data Recipients receive the Data subject to the following
    +terms:
    +
    +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS,
    +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED
    +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,
    +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
    +
    +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,
    +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING
    +WITHOUT LIMITATION LOST PROFITS), 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 DATA OR RESULTS,
    +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
    +
    +## 5. Definitions
    +
    +5.1. "Data" means the material received by a Data Recipient under
    +this agreement.
    +
    +5.2. "Data Provider" means any person who is the source of Data
    +provided under this agreement and in reliance on a Data Recipient's
    +agreement to its terms.
    +
    +5.3. "Data Recipient" means any person who receives Data directly
    +or indirectly from a Data Provider and agrees to the terms of this
    +agreement.
    +
    +5.4. "Results" means any outcome obtained by computational analysis
    +of Data, including for example machine learning models and models'
    +insights.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    // Copyright 2015-2016 Brian Smith.
    +//
    +// Permission to use, copy, modify, and/or distribute this software for any
    +// purpose with or without fee is hereby granted, provided that the above
    +// copyright notice and this permission notice appear in all copies.
    +//
    +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
    +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
    +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
    +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
    +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright (c) 2017 Adam Wick
    +
    +Permission to use, copy, modify, and/or distribute this software for any purpose
    +with or without fee is hereby granted, provided that the above copyright notice
    +and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
    +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
    +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
    +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
    +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
    +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
    +THIS SOFTWARE.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Copyright 2015-2025 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
    +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
    +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
    +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    Except as otherwise noted, this project is licensed under the following
    +(ISC-style) terms:
    +
    +Copyright 2015 Brian Smith.
    +
    +Permission to use, copy, modify, and/or distribute this software for any
    +purpose with or without fee is hereby granted, provided that the above
    +copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
    +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
    +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
    +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
    +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
    +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
    +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    +
    +The files under third-party/chromium are licensed as described in
    +third-party/chromium/LICENSE.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    ISC License
    +
    +Copyright (c) 2016, Mapbox
    +Copyright (c) 2018, Tree Cricket
    +
    +Permission to use, copy, modify, and/or distribute this software for any purpose
    +with or without fee is hereby granted, provided that the above copyright notice
    +and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
    +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
    +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
    +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
    +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
    +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
    +THIS SOFTWARE.
    +
    +
  • +
  • +

    ISC License

    +

    Used by:

    + +
    ISC License:
    +
    +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
    +Copyright (c) 1995-2003 by Internet Software Consortium
    +
    +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
    +
    +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2014 Carl Lerche and other MIO contributors
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2014-2019 Geoffroy Couprie
    +
    +Permission is hereby granted, free of charge, to any person obtaining
    +a copy of this software and associated documentation files (the
    +"Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish,
    +distribute, sublicense, and/or sell copies of the Software, and to
    +permit persons to whom the Software is furnished to do so, subject to
    +the following conditions:
    +
    +The above copyright notice and this permission notice shall be
    +included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2014-2026 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2015 Jonathan Reem
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2015 steffengy
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2016 Paul Masurel
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2017 Redox OS Developers
    +
    +MIT License
    +
    +Permission is hereby granted, free of charge, to any person obtaining
    +a copy of this software and associated documentation files (the
    +"Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish,
    +distribute, sublicense, and/or sell copies of the Software, and to
    +permit persons to whom the Software is furnished to do so, subject to
    +the following conditions:
    +
    +The above copyright notice and this permission notice shall be
    +included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2017 h2 authors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018 Carl Lerche
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2019 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2018-2023 Sean McArthur
    +Copyright (c) 2016 Alex Crichton
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Axum Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Carl Lerche
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Eliza Weisman
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Eliza Weisman
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Tokio Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019 Tower Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019-2021 Tower Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2020 RustCrypto Developers
    +Copyright (c) 2017 Project Nayuki, Artyom Pavlov
    +
    +Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright (c) 2023-2025 Sean McArthur
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright 2016 Nika Layzell
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Copyright 2021 Axum Contributors
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2016 Michael Ma
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 Denis Kurilenko
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 Elliot Linder <darfink@gmail.com>
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 Garrett Berg <vitiral@gmail.com>
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 Sam Rijs
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2017 Ted Driggs
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2018 myrrlyn (Alexander Payne)
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2018 sgodwincs
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Acrimon
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Aeledfyr
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Arne Beer
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Graham Esau
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Peter Glotfelty
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 Yoshua Wuyts
    +Copyright (c) Tokio Contributors
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2019 kneecaw (Nika Layzell)
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020 Anastasios Bakogiannis
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020 Scripta Qumranica Electronica
    +
    +Created by Bronson Brown-deVost
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020 myrrlyn (Alexander Payne)
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020-2022 Oliver Margetts
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2020-2026 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2021 Sam Sartor
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2022 Ibraheem Ahmed
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2022 Nugine
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2023 4lDO2
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2023 iShape-Rust
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2024 Spire Team
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) 2024 iShape-Rust
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) <year> <copyright holders>
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
    +associated documentation files (the "Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
    +following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial
    +portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
    +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
    +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
    +USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) Tokio Contributors
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    MIT License
    +
    +Copyright (c) [2021] [Marvin Countryman]
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Permission is hereby granted, free of charge, to any
    +person obtaining a copy of this software and associated
    +documentation files (the "Software"), to deal in the
    +Software without restriction, including without
    +limitation the rights to use, copy, modify, merge,
    +publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software
    +is furnished to do so, subject to the following
    +conditions:
    +
    +The above copyright notice and this permission notice
    +shall be included in all copies or substantial portions
    +of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
    +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
    +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
    +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
    +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
    +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
    +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
    +DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    Permission is hereby granted, free of charge, to any person obtaining
    +a copy of this software and associated documentation files (the
    +"Software"), to deal in the Software without restriction, including
    +without limitation the rights to use, copy, modify, merge, publish,
    +distribute, sublicense, and/or sell copies of the Software, and to
    +permit persons to whom the Software is furnished to do so, subject to
    +the following conditions:
    +
    +The above copyright notice and this permission notice shall be
    +included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
    +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
    +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
    +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
    +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
    +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Mathijs van de Nes
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Y. T. CHUNG
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of
    +this software and associated documentation files (the "Software"), to deal in
    +the Software without restriction, including without limitation the rights to
    +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software is furnished to do so,
    +subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
    +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
    +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Andrew Gallant
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Artem V. Navrotskiy
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Danny Guo
    +Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
    +Copyright (c) 2018 Akash Kurdekar
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Guillaume Gomez
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Jake Goulding
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Markus Westerlind
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Vincent Prouillet
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015-2020 the fiat-crypto authors (see
    +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS).
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official
    +Google product or in any way related!
    +Copyright (c) 2018-2020 Lewin Bormann (lbo@spheniscida.de)
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to
    +deal in the Software without restriction, including without limitation the
    +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
    +sell copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
    +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
    +IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2016 Jonathan Creekmore
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2016 nabijaczleweli
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2017 Andrew Gallant
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2017 Jose Narvaez
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2020 Pascal Seitz
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of
    +this software and associated documentation files (the "Software"), to deal in
    +the Software without restriction, including without limitation the rights to
    +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
    +the Software, and to permit persons to whom the Software is furnished to do so,
    +subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
    +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
    +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
    +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
    +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright 2017-2023 Eira Fransham.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +Copyright (c) 2016 Alexandre Bury
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2014 Benjamin Sago
    +Copyright (c) 2021-2022 The Nushell Project Developers
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Austin Bonander
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2015 Bartłomiej Kamiński
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in all
    +copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
    +SOFTWARE.
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    The MIT License (MIT)
    +
    +Copyright (c) 2016 Johann Tuffe
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    MIT License

    +

    Used by:

    + +
    © 2016 Bertram Truong
    +© 2021 Kornel Lesiński
    +
    +Permission is hereby granted, free of charge, to any person obtaining a copy
    +of this software and associated documentation files (the "Software"), to deal
    +in the Software without restriction, including without limitation the rights
    +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
    +copies of the Software, and to permit persons to whom the Software is
    +furnished to do so, subject to the following conditions:
    +
    +The above copyright notice and this permission notice shall be included in
    +all copies or substantial portions of the Software.
    +
    +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
    +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
    +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
    +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
    +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
    +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
    +THE SOFTWARE.
    +
    +
  • +
  • +

    Mozilla Public License 2.0

    +

    Used by:

    + +
    Mozilla Public License Version 2.0
    +==================================
    +
    +1. Definitions
    +--------------
    +
    +1.1. "Contributor"
    +    means each individual or legal entity that creates, contributes to
    +    the creation of, or owns Covered Software.
    +
    +1.2. "Contributor Version"
    +    means the combination of the Contributions of others (if any) used
    +    by a Contributor and that particular Contributor's Contribution.
    +
    +1.3. "Contribution"
    +    means Covered Software of a particular Contributor.
    +
    +1.4. "Covered Software"
    +    means Source Code Form to which the initial Contributor has attached
    +    the notice in Exhibit A, the Executable Form of such Source Code
    +    Form, and Modifications of such Source Code Form, in each case
    +    including portions thereof.
    +
    +1.5. "Incompatible With Secondary Licenses"
    +    means
    +
    +    (a) that the initial Contributor has attached the notice described
    +        in Exhibit B to the Covered Software; or
    +
    +    (b) that the Covered Software was made available under the terms of
    +        version 1.1 or earlier of the License, but not also under the
    +        terms of a Secondary License.
    +
    +1.6. "Executable Form"
    +    means any form of the work other than Source Code Form.
    +
    +1.7. "Larger Work"
    +    means a work that combines Covered Software with other material, in 
    +    a separate file or files, that is not Covered Software.
    +
    +1.8. "License"
    +    means this document.
    +
    +1.9. "Licensable"
    +    means having the right to grant, to the maximum extent possible,
    +    whether at the time of the initial grant or subsequently, any and
    +    all of the rights conveyed by this License.
    +
    +1.10. "Modifications"
    +    means any of the following:
    +
    +    (a) any file in Source Code Form that results from an addition to,
    +        deletion from, or modification of the contents of Covered
    +        Software; or
    +
    +    (b) any new file in Source Code Form that contains any Covered
    +        Software.
    +
    +1.11. "Patent Claims" of a Contributor
    +    means any patent claim(s), including without limitation, method,
    +    process, and apparatus claims, in any patent Licensable by such
    +    Contributor that would be infringed, but for the grant of the
    +    License, by the making, using, selling, offering for sale, having
    +    made, import, or transfer of either its Contributions or its
    +    Contributor Version.
    +
    +1.12. "Secondary License"
    +    means either the GNU General Public License, Version 2.0, the GNU
    +    Lesser General Public License, Version 2.1, the GNU Affero General
    +    Public License, Version 3.0, or any later versions of those
    +    licenses.
    +
    +1.13. "Source Code Form"
    +    means the form of the work preferred for making modifications.
    +
    +1.14. "You" (or "Your")
    +    means an individual or a legal entity exercising rights under this
    +    License. For legal entities, "You" includes any entity that
    +    controls, is controlled by, or is under common control with You. For
    +    purposes of this definition, "control" means (a) the power, direct
    +    or indirect, to cause the direction or management of such entity,
    +    whether by contract or otherwise, or (b) ownership of more than
    +    fifty percent (50%) of the outstanding shares or beneficial
    +    ownership of such entity.
    +
    +2. License Grants and Conditions
    +--------------------------------
    +
    +2.1. Grants
    +
    +Each Contributor hereby grants You a world-wide, royalty-free,
    +non-exclusive license:
    +
    +(a) under intellectual property rights (other than patent or trademark)
    +    Licensable by such Contributor to use, reproduce, make available,
    +    modify, display, perform, distribute, and otherwise exploit its
    +    Contributions, either on an unmodified basis, with Modifications, or
    +    as part of a Larger Work; and
    +
    +(b) under Patent Claims of such Contributor to make, use, sell, offer
    +    for sale, have made, import, and otherwise transfer either its
    +    Contributions or its Contributor Version.
    +
    +2.2. Effective Date
    +
    +The licenses granted in Section 2.1 with respect to any Contribution
    +become effective for each Contribution on the date the Contributor first
    +distributes such Contribution.
    +
    +2.3. Limitations on Grant Scope
    +
    +The licenses granted in this Section 2 are the only rights granted under
    +this License. No additional rights or licenses will be implied from the
    +distribution or licensing of Covered Software under this License.
    +Notwithstanding Section 2.1(b) above, no patent license is granted by a
    +Contributor:
    +
    +(a) for any code that a Contributor has removed from Covered Software;
    +    or
    +
    +(b) for infringements caused by: (i) Your and any other third party's
    +    modifications of Covered Software, or (ii) the combination of its
    +    Contributions with other software (except as part of its Contributor
    +    Version); or
    +
    +(c) under Patent Claims infringed by Covered Software in the absence of
    +    its Contributions.
    +
    +This License does not grant any rights in the trademarks, service marks,
    +or logos of any Contributor (except as may be necessary to comply with
    +the notice requirements in Section 3.4).
    +
    +2.4. Subsequent Licenses
    +
    +No Contributor makes additional grants as a result of Your choice to
    +distribute the Covered Software under a subsequent version of this
    +License (see Section 10.2) or under the terms of a Secondary License (if
    +permitted under the terms of Section 3.3).
    +
    +2.5. Representation
    +
    +Each Contributor represents that the Contributor believes its
    +Contributions are its original creation(s) or it has sufficient rights
    +to grant the rights to its Contributions conveyed by this License.
    +
    +2.6. Fair Use
    +
    +This License is not intended to limit any rights You have under
    +applicable copyright doctrines of fair use, fair dealing, or other
    +equivalents.
    +
    +2.7. Conditions
    +
    +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
    +in Section 2.1.
    +
    +3. Responsibilities
    +-------------------
    +
    +3.1. Distribution of Source Form
    +
    +All distribution of Covered Software in Source Code Form, including any
    +Modifications that You create or to which You contribute, must be under
    +the terms of this License. You must inform recipients that the Source
    +Code Form of the Covered Software is governed by the terms of this
    +License, and how they can obtain a copy of this License. You may not
    +attempt to alter or restrict the recipients' rights in the Source Code
    +Form.
    +
    +3.2. Distribution of Executable Form
    +
    +If You distribute Covered Software in Executable Form then:
    +
    +(a) such Covered Software must also be made available in Source Code
    +    Form, as described in Section 3.1, and You must inform recipients of
    +    the Executable Form how they can obtain a copy of such Source Code
    +    Form by reasonable means in a timely manner, at a charge no more
    +    than the cost of distribution to the recipient; and
    +
    +(b) You may distribute such Executable Form under the terms of this
    +    License, or sublicense it under different terms, provided that the
    +    license for the Executable Form does not attempt to limit or alter
    +    the recipients' rights in the Source Code Form under this License.
    +
    +3.3. Distribution of a Larger Work
    +
    +You may create and distribute a Larger Work under terms of Your choice,
    +provided that You also comply with the requirements of this License for
    +the Covered Software. If the Larger Work is a combination of Covered
    +Software with a work governed by one or more Secondary Licenses, and the
    +Covered Software is not Incompatible With Secondary Licenses, this
    +License permits You to additionally distribute such Covered Software
    +under the terms of such Secondary License(s), so that the recipient of
    +the Larger Work may, at their option, further distribute the Covered
    +Software under the terms of either this License or such Secondary
    +License(s).
    +
    +3.4. Notices
    +
    +You may not remove or alter the substance of any license notices
    +(including copyright notices, patent notices, disclaimers of warranty,
    +or limitations of liability) contained within the Source Code Form of
    +the Covered Software, except that You may alter any license notices to
    +the extent required to remedy known factual inaccuracies.
    +
    +3.5. Application of Additional Terms
    +
    +You may choose to offer, and to charge a fee for, warranty, support,
    +indemnity or liability obligations to one or more recipients of Covered
    +Software. However, You may do so only on Your own behalf, and not on
    +behalf of any Contributor. You must make it absolutely clear that any
    +such warranty, support, indemnity, or liability obligation is offered by
    +You alone, and You hereby agree to indemnify every Contributor for any
    +liability incurred by such Contributor as a result of warranty, support,
    +indemnity or liability terms You offer. You may include additional
    +disclaimers of warranty and limitations of liability specific to any
    +jurisdiction.
    +
    +4. Inability to Comply Due to Statute or Regulation
    +---------------------------------------------------
    +
    +If it is impossible for You to comply with any of the terms of this
    +License with respect to some or all of the Covered Software due to
    +statute, judicial order, or regulation then You must: (a) comply with
    +the terms of this License to the maximum extent possible; and (b)
    +describe the limitations and the code they affect. Such description must
    +be placed in a text file included with all distributions of the Covered
    +Software under this License. Except to the extent prohibited by statute
    +or regulation, such description must be sufficiently detailed for a
    +recipient of ordinary skill to be able to understand it.
    +
    +5. Termination
    +--------------
    +
    +5.1. The rights granted under this License will terminate automatically
    +if You fail to comply with any of its terms. However, if You become
    +compliant, then the rights granted under this License from a particular
    +Contributor are reinstated (a) provisionally, unless and until such
    +Contributor explicitly and finally terminates Your grants, and (b) on an
    +ongoing basis, if such Contributor fails to notify You of the
    +non-compliance by some reasonable means prior to 60 days after You have
    +come back into compliance. Moreover, Your grants from a particular
    +Contributor are reinstated on an ongoing basis if such Contributor
    +notifies You of the non-compliance by some reasonable means, this is the
    +first time You have received notice of non-compliance with this License
    +from such Contributor, and You become compliant prior to 30 days after
    +Your receipt of the notice.
    +
    +5.2. If You initiate litigation against any entity by asserting a patent
    +infringement claim (excluding declaratory judgment actions,
    +counter-claims, and cross-claims) alleging that a Contributor Version
    +directly or indirectly infringes any patent, then the rights granted to
    +You by any and all Contributors for the Covered Software under Section
    +2.1 of this License shall terminate.
    +
    +5.3. In the event of termination under Sections 5.1 or 5.2 above, all
    +end user license agreements (excluding distributors and resellers) which
    +have been validly granted by You or Your distributors under this License
    +prior to termination shall survive termination.
    +
    +************************************************************************
    +*                                                                      *
    +*  6. Disclaimer of Warranty                                           *
    +*  -------------------------                                           *
    +*                                                                      *
    +*  Covered Software is provided under this License on an "as is"       *
    +*  basis, without warranty of any kind, either expressed, implied, or  *
    +*  statutory, including, without limitation, warranties that the       *
    +*  Covered Software is free of defects, merchantable, fit for a        *
    +*  particular purpose or non-infringing. The entire risk as to the     *
    +*  quality and performance of the Covered Software is with You.        *
    +*  Should any Covered Software prove defective in any respect, You     *
    +*  (not any Contributor) assume the cost of any necessary servicing,   *
    +*  repair, or correction. This disclaimer of warranty constitutes an   *
    +*  essential part of this License. No use of any Covered Software is   *
    +*  authorized under this License except under this disclaimer.         *
    +*                                                                      *
    +************************************************************************
    +
    +************************************************************************
    +*                                                                      *
    +*  7. Limitation of Liability                                          *
    +*  --------------------------                                          *
    +*                                                                      *
    +*  Under no circumstances and under no legal theory, whether tort      *
    +*  (including negligence), contract, or otherwise, shall any           *
    +*  Contributor, or anyone who distributes Covered Software as          *
    +*  permitted above, be liable to You for any direct, indirect,         *
    +*  special, incidental, or consequential damages of any character      *
    +*  including, without limitation, damages for lost profits, loss of    *
    +*  goodwill, work stoppage, computer failure or malfunction, or any    *
    +*  and all other commercial damages or losses, even if such party      *
    +*  shall have been informed of the possibility of such damages. This   *
    +*  limitation of liability shall not apply to liability for death or   *
    +*  personal injury resulting from such party's negligence to the       *
    +*  extent applicable law prohibits such limitation. Some               *
    +*  jurisdictions do not allow the exclusion or limitation of           *
    +*  incidental or consequential damages, so this exclusion and          *
    +*  limitation may not apply to You.                                    *
    +*                                                                      *
    +************************************************************************
    +
    +8. Litigation
    +-------------
    +
    +Any litigation relating to this License may be brought only in the
    +courts of a jurisdiction where the defendant maintains its principal
    +place of business and such litigation shall be governed by laws of that
    +jurisdiction, without reference to its conflict-of-law provisions.
    +Nothing in this Section shall prevent a party's ability to bring
    +cross-claims or counter-claims.
    +
    +9. Miscellaneous
    +----------------
    +
    +This License represents the complete agreement concerning the subject
    +matter hereof. If any provision of this License is held to be
    +unenforceable, such provision shall be reformed only to the extent
    +necessary to make it enforceable. Any law or regulation which provides
    +that the language of a contract shall be construed against the drafter
    +shall not be used to construe this License against a Contributor.
    +
    +10. Versions of the License
    +---------------------------
    +
    +10.1. New Versions
    +
    +Mozilla Foundation is the license steward. Except as provided in Section
    +10.3, no one other than the license steward has the right to modify or
    +publish new versions of this License. Each version will be given a
    +distinguishing version number.
    +
    +10.2. Effect of New Versions
    +
    +You may distribute the Covered Software under the terms of the version
    +of the License under which You originally received the Covered Software,
    +or under the terms of any subsequent version published by the license
    +steward.
    +
    +10.3. Modified Versions
    +
    +If you create software not governed by this License, and you want to
    +create a new license for such software, you may create and use a
    +modified version of this License if you rename the license and remove
    +any references to the name of the license steward (except to note that
    +such modified license differs from this License).
    +
    +10.4. Distributing Source Code Form that is Incompatible With Secondary
    +Licenses
    +
    +If You choose to distribute Source Code Form that is Incompatible With
    +Secondary Licenses under the terms of this version of the License, the
    +notice described in Exhibit B of this License must be attached.
    +
    +Exhibit A - Source Code Form License Notice
    +-------------------------------------------
    +
    +  This Source Code Form is subject to the terms of the Mozilla Public
    +  License, v. 2.0. If a copy of the MPL was not distributed with this
    +  file, You can obtain one at http://mozilla.org/MPL/2.0/.
    +
    +If it is not possible or desirable to put the notice in a particular
    +file, then You may include the notice in a location (such as a LICENSE
    +file in a relevant directory) where a recipient would be likely to look
    +for such a notice.
    +
    +You may add additional accurate notices of copyright ownership.
    +
    +Exhibit B - "Incompatible With Secondary Licenses" Notice
    +---------------------------------------------------------
    +
    +  This Source Code Form is "Incompatible With Secondary Licenses", as
    +  defined by the Mozilla Public License, v. 2.0.
    +
    +
  • +
  • +

    Mozilla Public License 2.0

    +

    Used by:

    + +
    Mozilla Public License Version 2.0
    +==================================
    +
    +1. Definitions
    +--------------
    +
    +1.1. "Contributor"
    +    means each individual or legal entity that creates, contributes to
    +    the creation of, or owns Covered Software.
    +
    +1.2. "Contributor Version"
    +    means the combination of the Contributions of others (if any) used
    +    by a Contributor and that particular Contributor's Contribution.
    +
    +1.3. "Contribution"
    +    means Covered Software of a particular Contributor.
    +
    +1.4. "Covered Software"
    +    means Source Code Form to which the initial Contributor has attached
    +    the notice in Exhibit A, the Executable Form of such Source Code
    +    Form, and Modifications of such Source Code Form, in each case
    +    including portions thereof.
    +
    +1.5. "Incompatible With Secondary Licenses"
    +    means
    +
    +    (a) that the initial Contributor has attached the notice described
    +        in Exhibit B to the Covered Software; or
    +
    +    (b) that the Covered Software was made available under the terms of
    +        version 1.1 or earlier of the License, but not also under the
    +        terms of a Secondary License.
    +
    +1.6. "Executable Form"
    +    means any form of the work other than Source Code Form.
    +
    +1.7. "Larger Work"
    +    means a work that combines Covered Software with other material, in 
    +    a separate file or files, that is not Covered Software.
    +
    +1.8. "License"
    +    means this document.
    +
    +1.9. "Licensable"
    +    means having the right to grant, to the maximum extent possible,
    +    whether at the time of the initial grant or subsequently, any and
    +    all of the rights conveyed by this License.
    +
    +1.10. "Modifications"
    +    means any of the following:
    +
    +    (a) any file in Source Code Form that results from an addition to,
    +        deletion from, or modification of the contents of Covered
    +        Software; or
    +
    +    (b) any new file in Source Code Form that contains any Covered
    +        Software.
    +
    +1.11. "Patent Claims" of a Contributor
    +    means any patent claim(s), including without limitation, method,
    +    process, and apparatus claims, in any patent Licensable by such
    +    Contributor that would be infringed, but for the grant of the
    +    License, by the making, using, selling, offering for sale, having
    +    made, import, or transfer of either its Contributions or its
    +    Contributor Version.
    +
    +1.12. "Secondary License"
    +    means either the GNU General Public License, Version 2.0, the GNU
    +    Lesser General Public License, Version 2.1, the GNU Affero General
    +    Public License, Version 3.0, or any later versions of those
    +    licenses.
    +
    +1.13. "Source Code Form"
    +    means the form of the work preferred for making modifications.
    +
    +1.14. "You" (or "Your")
    +    means an individual or a legal entity exercising rights under this
    +    License. For legal entities, "You" includes any entity that
    +    controls, is controlled by, or is under common control with You. For
    +    purposes of this definition, "control" means (a) the power, direct
    +    or indirect, to cause the direction or management of such entity,
    +    whether by contract or otherwise, or (b) ownership of more than
    +    fifty percent (50%) of the outstanding shares or beneficial
    +    ownership of such entity.
    +
    +2. License Grants and Conditions
    +--------------------------------
    +
    +2.1. Grants
    +
    +Each Contributor hereby grants You a world-wide, royalty-free,
    +non-exclusive license:
    +
    +(a) under intellectual property rights (other than patent or trademark)
    +    Licensable by such Contributor to use, reproduce, make available,
    +    modify, display, perform, distribute, and otherwise exploit its
    +    Contributions, either on an unmodified basis, with Modifications, or
    +    as part of a Larger Work; and
    +
    +(b) under Patent Claims of such Contributor to make, use, sell, offer
    +    for sale, have made, import, and otherwise transfer either its
    +    Contributions or its Contributor Version.
    +
    +2.2. Effective Date
    +
    +The licenses granted in Section 2.1 with respect to any Contribution
    +become effective for each Contribution on the date the Contributor first
    +distributes such Contribution.
    +
    +2.3. Limitations on Grant Scope
    +
    +The licenses granted in this Section 2 are the only rights granted under
    +this License. No additional rights or licenses will be implied from the
    +distribution or licensing of Covered Software under this License.
    +Notwithstanding Section 2.1(b) above, no patent license is granted by a
    +Contributor:
    +
    +(a) for any code that a Contributor has removed from Covered Software;
    +    or
    +
    +(b) for infringements caused by: (i) Your and any other third party's
    +    modifications of Covered Software, or (ii) the combination of its
    +    Contributions with other software (except as part of its Contributor
    +    Version); or
    +
    +(c) under Patent Claims infringed by Covered Software in the absence of
    +    its Contributions.
    +
    +This License does not grant any rights in the trademarks, service marks,
    +or logos of any Contributor (except as may be necessary to comply with
    +the notice requirements in Section 3.4).
    +
    +2.4. Subsequent Licenses
    +
    +No Contributor makes additional grants as a result of Your choice to
    +distribute the Covered Software under a subsequent version of this
    +License (see Section 10.2) or under the terms of a Secondary License (if
    +permitted under the terms of Section 3.3).
    +
    +2.5. Representation
    +
    +Each Contributor represents that the Contributor believes its
    +Contributions are its original creation(s) or it has sufficient rights
    +to grant the rights to its Contributions conveyed by this License.
    +
    +2.6. Fair Use
    +
    +This License is not intended to limit any rights You have under
    +applicable copyright doctrines of fair use, fair dealing, or other
    +equivalents.
    +
    +2.7. Conditions
    +
    +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
    +in Section 2.1.
    +
    +3. Responsibilities
    +-------------------
    +
    +3.1. Distribution of Source Form
    +
    +All distribution of Covered Software in Source Code Form, including any
    +Modifications that You create or to which You contribute, must be under
    +the terms of this License. You must inform recipients that the Source
    +Code Form of the Covered Software is governed by the terms of this
    +License, and how they can obtain a copy of this License. You may not
    +attempt to alter or restrict the recipients' rights in the Source Code
    +Form.
    +
    +3.2. Distribution of Executable Form
    +
    +If You distribute Covered Software in Executable Form then:
    +
    +(a) such Covered Software must also be made available in Source Code
    +    Form, as described in Section 3.1, and You must inform recipients of
    +    the Executable Form how they can obtain a copy of such Source Code
    +    Form by reasonable means in a timely manner, at a charge no more
    +    than the cost of distribution to the recipient; and
    +
    +(b) You may distribute such Executable Form under the terms of this
    +    License, or sublicense it under different terms, provided that the
    +    license for the Executable Form does not attempt to limit or alter
    +    the recipients' rights in the Source Code Form under this License.
    +
    +3.3. Distribution of a Larger Work
    +
    +You may create and distribute a Larger Work under terms of Your choice,
    +provided that You also comply with the requirements of this License for
    +the Covered Software. If the Larger Work is a combination of Covered
    +Software with a work governed by one or more Secondary Licenses, and the
    +Covered Software is not Incompatible With Secondary Licenses, this
    +License permits You to additionally distribute such Covered Software
    +under the terms of such Secondary License(s), so that the recipient of
    +the Larger Work may, at their option, further distribute the Covered
    +Software under the terms of either this License or such Secondary
    +License(s).
    +
    +3.4. Notices
    +
    +You may not remove or alter the substance of any license notices
    +(including copyright notices, patent notices, disclaimers of warranty,
    +or limitations of liability) contained within the Source Code Form of
    +the Covered Software, except that You may alter any license notices to
    +the extent required to remedy known factual inaccuracies.
    +
    +3.5. Application of Additional Terms
    +
    +You may choose to offer, and to charge a fee for, warranty, support,
    +indemnity or liability obligations to one or more recipients of Covered
    +Software. However, You may do so only on Your own behalf, and not on
    +behalf of any Contributor. You must make it absolutely clear that any
    +such warranty, support, indemnity, or liability obligation is offered by
    +You alone, and You hereby agree to indemnify every Contributor for any
    +liability incurred by such Contributor as a result of warranty, support,
    +indemnity or liability terms You offer. You may include additional
    +disclaimers of warranty and limitations of liability specific to any
    +jurisdiction.
    +
    +4. Inability to Comply Due to Statute or Regulation
    +---------------------------------------------------
    +
    +If it is impossible for You to comply with any of the terms of this
    +License with respect to some or all of the Covered Software due to
    +statute, judicial order, or regulation then You must: (a) comply with
    +the terms of this License to the maximum extent possible; and (b)
    +describe the limitations and the code they affect. Such description must
    +be placed in a text file included with all distributions of the Covered
    +Software under this License. Except to the extent prohibited by statute
    +or regulation, such description must be sufficiently detailed for a
    +recipient of ordinary skill to be able to understand it.
    +
    +5. Termination
    +--------------
    +
    +5.1. The rights granted under this License will terminate automatically
    +if You fail to comply with any of its terms. However, if You become
    +compliant, then the rights granted under this License from a particular
    +Contributor are reinstated (a) provisionally, unless and until such
    +Contributor explicitly and finally terminates Your grants, and (b) on an
    +ongoing basis, if such Contributor fails to notify You of the
    +non-compliance by some reasonable means prior to 60 days after You have
    +come back into compliance. Moreover, Your grants from a particular
    +Contributor are reinstated on an ongoing basis if such Contributor
    +notifies You of the non-compliance by some reasonable means, this is the
    +first time You have received notice of non-compliance with this License
    +from such Contributor, and You become compliant prior to 30 days after
    +Your receipt of the notice.
    +
    +5.2. If You initiate litigation against any entity by asserting a patent
    +infringement claim (excluding declaratory judgment actions,
    +counter-claims, and cross-claims) alleging that a Contributor Version
    +directly or indirectly infringes any patent, then the rights granted to
    +You by any and all Contributors for the Covered Software under Section
    +2.1 of this License shall terminate.
    +
    +5.3. In the event of termination under Sections 5.1 or 5.2 above, all
    +end user license agreements (excluding distributors and resellers) which
    +have been validly granted by You or Your distributors under this License
    +prior to termination shall survive termination.
    +
    +************************************************************************
    +*                                                                      *
    +*  6. Disclaimer of Warranty                                           *
    +*  -------------------------                                           *
    +*                                                                      *
    +*  Covered Software is provided under this License on an "as is"       *
    +*  basis, without warranty of any kind, either expressed, implied, or  *
    +*  statutory, including, without limitation, warranties that the       *
    +*  Covered Software is free of defects, merchantable, fit for a        *
    +*  particular purpose or non-infringing. The entire risk as to the     *
    +*  quality and performance of the Covered Software is with You.        *
    +*  Should any Covered Software prove defective in any respect, You     *
    +*  (not any Contributor) assume the cost of any necessary servicing,   *
    +*  repair, or correction. This disclaimer of warranty constitutes an   *
    +*  essential part of this License. No use of any Covered Software is   *
    +*  authorized under this License except under this disclaimer.         *
    +*                                                                      *
    +************************************************************************
    +
    +************************************************************************
    +*                                                                      *
    +*  7. Limitation of Liability                                          *
    +*  --------------------------                                          *
    +*                                                                      *
    +*  Under no circumstances and under no legal theory, whether tort      *
    +*  (including negligence), contract, or otherwise, shall any           *
    +*  Contributor, or anyone who distributes Covered Software as          *
    +*  permitted above, be liable to You for any direct, indirect,         *
    +*  special, incidental, or consequential damages of any character      *
    +*  including, without limitation, damages for lost profits, loss of    *
    +*  goodwill, work stoppage, computer failure or malfunction, or any    *
    +*  and all other commercial damages or losses, even if such party      *
    +*  shall have been informed of the possibility of such damages. This   *
    +*  limitation of liability shall not apply to liability for death or   *
    +*  personal injury resulting from such party's negligence to the       *
    +*  extent applicable law prohibits such limitation. Some               *
    +*  jurisdictions do not allow the exclusion or limitation of           *
    +*  incidental or consequential damages, so this exclusion and          *
    +*  limitation may not apply to You.                                    *
    +*                                                                      *
    +************************************************************************
    +
    +8. Litigation
    +-------------
    +
    +Any litigation relating to this License may be brought only in the
    +courts of a jurisdiction where the defendant maintains its principal
    +place of business and such litigation shall be governed by laws of that
    +jurisdiction, without reference to its conflict-of-law provisions.
    +Nothing in this Section shall prevent a party's ability to bring
    +cross-claims or counter-claims.
    +
    +9. Miscellaneous
    +----------------
    +
    +This License represents the complete agreement concerning the subject
    +matter hereof. If any provision of this License is held to be
    +unenforceable, such provision shall be reformed only to the extent
    +necessary to make it enforceable. Any law or regulation which provides
    +that the language of a contract shall be construed against the drafter
    +shall not be used to construe this License against a Contributor.
    +
    +10. Versions of the License
    +---------------------------
    +
    +10.1. New Versions
    +
    +Mozilla Foundation is the license steward. Except as provided in Section
    +10.3, no one other than the license steward has the right to modify or
    +publish new versions of this License. Each version will be given a
    +distinguishing version number.
    +
    +10.2. Effect of New Versions
    +
    +You may distribute the Covered Software under the terms of the version
    +of the License under which You originally received the Covered Software,
    +or under the terms of any subsequent version published by the license
    +steward.
    +
    +10.3. Modified Versions
    +
    +If you create software not governed by this License, and you want to
    +create a new license for such software, you may create and use a
    +modified version of this License if you rename the license and remove
    +any references to the name of the license steward (except to note that
    +such modified license differs from this License).
    +
    +10.4. Distributing Source Code Form that is Incompatible With Secondary
    +Licenses
    +
    +If You choose to distribute Source Code Form that is Incompatible With
    +Secondary Licenses under the terms of this version of the License, the
    +notice described in Exhibit B of this License must be attached.
    +
    +Exhibit A - Source Code Form License Notice
    +-------------------------------------------
    +
    +  This Source Code Form is subject to the terms of the Mozilla Public
    +  License, v. 2.0. If a copy of the MPL was not distributed with this
    +  file, You can obtain one at https://mozilla.org/MPL/2.0/.
    +
    +If it is not possible or desirable to put the notice in a particular
    +file, then You may include the notice in a location (such as a LICENSE
    +file in a relevant directory) where a recipient would be likely to look
    +for such a notice.
    +
    +You may add additional accurate notices of copyright ownership.
    +
    +Exhibit B - "Incompatible With Secondary Licenses" Notice
    +---------------------------------------------------------
    +
    +  This Source Code Form is "Incompatible With Secondary Licenses", as
    +  defined by the Mozilla Public License, v. 2.0.
    +
    +
  • +
  • +

    Unicode License v3

    +

    Used by:

    + +
    UNICODE LICENSE V3
    +
    +COPYRIGHT AND PERMISSION NOTICE
    +
    +Copyright © 1991-2023 Unicode, Inc.
    +
    +NOTICE TO USER: Carefully read the following legal agreement. BY
    +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
    +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
    +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
    +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a
    +copy of data files and any associated documentation (the "Data Files") or
    +software and any associated documentation (the "Software") to deal in the
    +Data Files or Software without restriction, including without limitation
    +the rights to use, copy, modify, merge, publish, distribute, and/or sell
    +copies of the Data Files or Software, and to permit persons to whom the
    +Data Files or Software are furnished to do so, provided that either (a)
    +this copyright and permission notice appear with all copies of the Data
    +Files or Software, or (b) this copyright and permission notice appear in
    +associated Documentation.
    +
    +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
    +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
    +THIRD PARTY RIGHTS.
    +
    +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
    +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
    +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
    +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
    +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
    +FILES OR SOFTWARE.
    +
    +Except as contained in this notice, the name of a copyright holder shall
    +not be used in advertising or otherwise to promote the sale, use or other
    +dealings in these Data Files or Software without prior written
    +authorization of the copyright holder.
    +
    +
  • +
  • +

    Unicode License v3

    +

    Used by:

    + +
    UNICODE LICENSE V3
    +
    +COPYRIGHT AND PERMISSION NOTICE
    +
    +Copyright © 2020-2024 Unicode, Inc.
    +
    +NOTICE TO USER: Carefully read the following legal agreement. BY
    +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
    +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
    +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
    +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
    +
    +Permission is hereby granted, free of charge, to any person obtaining a
    +copy of data files and any associated documentation (the "Data Files") or
    +software and any associated documentation (the "Software") to deal in the
    +Data Files or Software without restriction, including without limitation
    +the rights to use, copy, modify, merge, publish, distribute, and/or sell
    +copies of the Data Files or Software, and to permit persons to whom the
    +Data Files or Software are furnished to do so, provided that either (a)
    +this copyright and permission notice appear with all copies of the Data
    +Files or Software, or (b) this copyright and permission notice appear in
    +associated Documentation.
    +
    +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
    +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
    +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
    +THIRD PARTY RIGHTS.
    +
    +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
    +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
    +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
    +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
    +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
    +FILES OR SOFTWARE.
    +
    +Except as contained in this notice, the name of a copyright holder shall
    +not be used in advertising or otherwise to promote the sale, use or other
    +dealings in these Data Files or Software without prior written
    +authorization of the copyright holder.
    +
    +SPDX-License-Identifier: Unicode-3.0
    +
    +—
    +
    +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
    +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
    +
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    (C) 2024 Trifecta Tech Foundation 
    +
    +This software is provided 'as-is', without any express or implied
    +warranty. In no event will the authors be held liable for any damages
    +arising from the use of this software.
    +
    +Permission is granted to anyone to use this software for any purpose,
    +including commercial applications, and to alter it and redistribute it
    +freely, subject to the following restrictions:
    +
    +1. The origin of this software must not be misrepresented; you must not
    +   claim that you wrote the original software. If you use this software
    +   in a product, an acknowledgment in the product documentation would be
    +   appreciated but is not required.
    +
    +2. Altered source versions must be plainly marked as such, and must not be
    +   misrepresented as being the original software.
    +
    +3. This notice may not be removed or altered from any source distribution.
    +
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    Copyright (c) 2021 Matias Rodriguez.
    +
    +This software is provided 'as-is', without any express or implied
    +warranty. In no event will the authors be held liable for any damages
    +arising from the use of this software.
    +
    +Permission is granted to anyone to use this software for any purpose,
    +including commercial applications, and to alter it and redistribute it
    +freely, subject to the following restrictions:
    +
    +1. The origin of this software must not be misrepresented; you must not
    +   claim that you wrote the original software. If you use this software
    +   in a product, an acknowledgment in the product documentation would be
    +   appreciated but is not required.
    +2. Altered source versions must be plainly marked as such, and must not be
    +   misrepresented as being the original software.
    +3. This notice may not be removed or altered from any source distribution.
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    Copyright (c) 2023 Matias Rodriguez.
    +
    +This software is provided 'as-is', without any express or implied
    +warranty. In no event will the authors be held liable for any damages
    +arising from the use of this software.
    +
    +Permission is granted to anyone to use this software for any purpose,
    +including commercial applications, and to alter it and redistribute it
    +freely, subject to the following restrictions:
    +
    +1. The origin of this software must not be misrepresented; you must not
    +   claim that you wrote the original software. If you use this software
    +   in a product, an acknowledgment in the product documentation would be
    +   appreciated but is not required.
    +2. Altered source versions must be plainly marked as such, and must not be
    +   misrepresented as being the original software.
    +3. This notice may not be removed or altered from any source distribution.
    +
  • +
  • +

    zlib License

    +

    Used by:

    + +
    Copyright (c) 2024 Orson Peters
    +
    +This software is provided 'as-is', without any express or implied warranty. In
    +no event will the authors be held liable for any damages arising from the use of
    +this software.
    +
    +Permission is granted to anyone to use this software for any purpose, including
    +commercial applications, and to alter it and redistribute it freely, subject to
    +the following restrictions:
    +
    +1. The origin of this software must not be misrepresented; you must not claim
    +    that you wrote the original software. If you use this software in a product,
    +    an acknowledgment in the product documentation would be appreciated but is
    +    not required.
    +
    +2. Altered source versions must be plainly marked as such, and must not be
    +    misrepresented as being the original software.
    +
    +3. This notice may not be removed or altered from any source distribution.
    +
  • +
+
+ + + 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/Cargo.lock b/java/lance-jni/Cargo.lock index 3193de8daa4..323d8db448a 100644 --- a/java/lance-jni/Cargo.lock +++ b/java/lance-jni/Cargo.lock @@ -14,9 +14,9 @@ version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -25,7 +25,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "const-random", "getrandom 0.3.4", "once_cell", @@ -42,21 +42,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.21" @@ -74,9 +59,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", @@ -89,15 +74,15 @@ 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" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -124,9 +109,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -139,9 +124,9 @@ dependencies = [ [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -160,9 +145,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2b10dcb159faf30d3f81f6d56c1211a5bea2ca424eabe477648a44b993320e" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" dependencies = [ "arrow-arith", "arrow-array", @@ -181,9 +166,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288015089e7931843c80ed4032c5274f02b37bcb720c4a42096d50b390e70372" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ "arrow-array", "arrow-buffer", @@ -195,9 +180,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ca404ea6191e06bf30956394173337fa9c35f445bd447fe6c21ab944e1a23c" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash", "arrow-buffer", @@ -206,7 +191,7 @@ dependencies = [ "chrono", "chrono-tz", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -214,9 +199,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36356383099be0151dacc4245309895f16ba7917d79bdb71a7148659c9206c56" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -226,9 +211,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8e372ed52bd4ee88cc1e6c3859aa7ecea204158ac640b10e187936e7e87074" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ "arrow-array", "arrow-buffer", @@ -237,7 +222,7 @@ dependencies = [ "arrow-schema", "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "comfy-table", "half", @@ -248,9 +233,9 @@ dependencies = [ [[package]] name = "arrow-csv" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4100b729fe656f2e4fb32bc5884f14acf9118d4ad532b7b33c1132e4dce896" +checksum = "af0dd6d90d1955e9f9a014c1e563ee8aeffc21909085d25623e1da44d96eca26" dependencies = [ "arrow-array", "arrow-cast", @@ -263,9 +248,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf87f4ff5fc13290aa47e499a8b669a82c5977c6a1fedce22c7f542c1fd5a597" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", @@ -276,9 +261,9 @@ dependencies = [ [[package]] name = "arrow-ipc" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3ca63edd2073fcb42ba112f8ae165df1de935627ead6e203d07c99445f2081" +checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b" dependencies = [ "arrow-array", "arrow-buffer", @@ -286,24 +271,25 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex", "zstd", ] [[package]] name = "arrow-json" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a36b2332559d3310ebe3e173f75b29989b4412df4029a26a30cc3f7da0869297" +checksum = "b8a96aed3931c076adee39ec2a40d8219fc7f09e79bcdaca1df16272993e1e14" dependencies = [ "arrow-array", "arrow-buffer", "arrow-cast", - "arrow-data", + "arrow-ord", "arrow-schema", + "arrow-select", "chrono", "half", - "indexmap", + "indexmap 2.14.0", "itoa", "lexical-core", "memchr", @@ -316,9 +302,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c4e0530272ca755d6814218dffd04425c5b7854b87fa741d5ff848bf50aa39" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ "arrow-array", "arrow-buffer", @@ -329,9 +315,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b07f52788744cc71c4628567ad834cadbaeb9f09026ff1d7a4120f69edf7abd3" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ "arrow-array", "arrow-buffer", @@ -342,20 +328,20 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb63203e8e0e54b288d0d8043ca8fa1013820822a27692ef1b78a977d879f2c" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ - "bitflags", + "bitflags 2.13.0", "serde_core", "serde_json", ] [[package]] name = "arrow-select" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96d8a1c180b44ecf2e66c9a2f2bbcb8b1b6f14e165ce46ac8bde211a363411b" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash", "arrow-array", @@ -367,9 +353,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ad6a81add9d3ea30bf8374ee8329992c7fd246ffd8b7e2f48a3cea5aa0cc9a" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" dependencies = [ "arrow-array", "arrow-buffer", @@ -396,9 +382,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.37" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d10e4f991a553474232bc0a31799f6d24b034a84c0971d80d2e2f78b2e576e40" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ "compression-codecs", "compression-core", @@ -425,18 +411,18 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.2", ] [[package]] @@ -448,6 +434,12 @@ dependencies = [ "loom", ] +[[package]] +name = "asyncband" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" + [[package]] name = "atoi" version = "2.0.0" @@ -465,15 +457,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.13" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -490,7 +482,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.0", + "http 1.4.2", "ring", "time", "tokio", @@ -501,9 +493,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -513,9 +505,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -523,21 +515,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.6.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -548,8 +541,9 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", + "bytes-utils", "fastrand", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -559,9 +553,9 @@ dependencies = [ [[package]] name = "aws-sdk-sso" -version = "1.93.0" +version = "1.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcb38bb33fc0a11f1ffc3e3e85669e0a11a37690b86f77e75306d8f369146a0" +checksum = "00c5ff27c6ba2cbd95e6e26e2e736676fdf6bcf96495b187733f521cfe4ce448" dependencies = [ "aws-credential-types", "aws-runtime", @@ -576,16 +570,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.95.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ada8ffbea7bd1be1f53df1dadb0f8fdb04badb13185b3321b929d1ee3caad09" +checksum = "4d186f1e5a3694a188e5a0640b3115ccc6e084d104e16fd6ba968dca072ffef8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -600,16 +594,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.97.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" dependencies = [ "aws-credential-types", "aws-runtime", @@ -625,16 +619,16 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.8" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -643,20 +637,20 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" dependencies = [ "futures-util", "pin-project-lite", @@ -665,9 +659,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.3" +version = "0.63.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -675,7 +669,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -686,15 +680,15 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.9" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", "h2", - "http 1.4.0", + "http 1.4.2", "hyper", "hyper-rustls", "hyper-util", @@ -710,27 +704,27 @@ dependencies = [ [[package]] name = "aws-smithy-json" -version = "0.62.3" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" dependencies = [ "aws-smithy-types", "urlencoding", @@ -738,9 +732,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.10.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -751,7 +745,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -763,15 +757,15 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.3" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" dependencies = [ "aws-smithy-async", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -780,15 +774,15 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" dependencies = [ "base64-simd", "bytes", "bytes-utils", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -803,18 +797,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.11" +version = "1.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -834,7 +828,7 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -867,7 +861,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -896,6 +890,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64-simd" version = "0.8.0" @@ -927,24 +927,21 @@ dependencies = [ [[package]] name = "bitflags" -version = "2.10.0" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] -name = "bitpacking" -version = "0.9.3" +name = "bitflags" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" -dependencies = [ - "crunchy", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -958,21 +955,21 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -984,6 +981,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -994,62 +1000,79 @@ dependencies = [ ] [[package]] -name = "bon" -version = "3.8.2" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "bon-macros", - "rustversion", + "tinyvec", ] [[package]] -name = "bon-macros" -version = "3.8.2" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "darling", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", + "memchr", + "regex-automata", + "serde", ] [[package]] -name = "brotli" -version = "8.0.2" +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecheck" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "26333eeac754f0ad8a6bcd0eb0ac012156302e4e16b852b72ee399aea4f12c29" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", ] [[package]] -name = "brotli-decompressor" -version = "5.0.0" +name = "bytecheck_derive" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "46d07918caa9eeaaf06b7873925c53a61daac173539b4f7715090745e44e4e69" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] -name = "bumpalo" -version = "3.19.1" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "byteorder" @@ -1059,9 +1082,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "bytes-utils" @@ -1084,9 +1107,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -1095,10 +1118,13 @@ dependencies = [ ] [[package]] -name = "census" -version = "0.4.2" +name = "cedarwood" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" +dependencies = [ + "smallvec", +] [[package]] name = "cesu8" @@ -1106,6 +1132,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +[[package]] +name = "cfg-if" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" + [[package]] name = "cfg-if" version = "1.0.4" @@ -1118,11 +1150,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1139,7 +1182,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" dependencies = [ "chrono", - "phf", + "phf 0.12.1", ] [[package]] @@ -1148,24 +1191,39 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[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", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] [[package]] name = "combine" @@ -1189,9 +1247,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.36" +version = "0.4.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00828ba6fd27b45a448e57dbfe84f1029d4c9f26b368157e9a448a5f49a2ec2a" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" dependencies = [ "compression-core", "flate2", @@ -1200,9 +1258,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.31" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75984efb6ed102a0d42db99afb6c1948f0380d1d91808d5529916e6c08b49d8d" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "concurrent-queue" @@ -1219,6 +1277,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1239,12 +1303,37 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + +[[package]] +name = "const_panic" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e262cdaac42494e3ae34c43969f9cdeb7da178bdb4b66fa6a1ea2edb4c8ae652" +dependencies = [ + "typewit", +] + [[package]] name = "constant_time_eq" version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1261,6 +1350,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "countio" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" +dependencies = [ + "futures-io", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1271,12 +1369,22 @@ dependencies = [ ] [[package]] -name = "crc32c" -version = "0.6.8" +name = "cpufeatures" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ - "rustc_version", + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin 0.10.1", ] [[package]] @@ -1285,7 +1393,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1309,18 +1417,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] @@ -1357,6 +1465,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -1378,14 +1495,63 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "daachorse" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db756b5eb7d81d31f31f660f4132f8cf5698de52fca144c143d0ae0cbb5f2e06" + +[[package]] +name = "darling" +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", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.118", ] [[package]] @@ -1398,7 +1564,18 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.118", +] + +[[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.118", ] [[package]] @@ -1407,18 +1584,18 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "dashmap" -version = "6.1.0" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -1428,14 +1605,13 @@ dependencies = [ [[package]] name = "datafusion" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba7cb113e9c0bedf9e9765926031e132fa05a1b09ba6e93a6d1a4d7044457b8" +checksum = "754ef4e8f073922a26f5b23133b9db4829342362b09be0bc94309cf261c2f098" dependencies = [ "arrow", "arrow-schema", "async-trait", - "bytes", "chrono", "datafusion-catalog", "datafusion-catalog-listing", @@ -1445,7 +1621,6 @@ dependencies = [ "datafusion-datasource-arrow", "datafusion-datasource-csv", "datafusion-datasource-json", - "datafusion-datasource-parquet", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", @@ -1463,14 +1638,11 @@ dependencies = [ "datafusion-session", "datafusion-sql", "futures", + "indexmap 2.14.0", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", - "parquet", - "rand 0.9.2", - "regex", - "rstest", "sqlparser", "tempfile", "tokio", @@ -1480,9 +1652,9 @@ dependencies = [ [[package]] name = "datafusion-catalog" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a3a799f914a59b1ea343906a0486f17061f39509af74e874a866428951130d" +checksum = "06afd1e38dd27bbb1258685a1fc6524df6aff4e07b25b393a47de59635178d99" dependencies = [ "arrow", "async-trait", @@ -1498,16 +1670,16 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "tokio", ] [[package]] name = "datafusion-catalog-listing" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db1b113c80d7a0febcd901476a57aef378e717c54517a163ed51417d87621b0" +checksum = "f0668fb32c12065ec242be0e5b4bc62bd7a06a0be3ecd83791ef877e4be67e02" dependencies = [ "arrow", "async-trait", @@ -1523,38 +1695,38 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", - "tokio", + "object_store 0.13.2", ] [[package]] name = "datafusion-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c10f7659e96127d25e8366be7c8be4109595d6a2c3eac70421f380a7006a1b0" +checksum = "ca43b263cdff57042cfa8fb817fb3469f4878933380dccff25f5e793580abbf9" dependencies = [ - "ahash", "arrow", "arrow-ipc", + "arrow-schema", "chrono", + "foldhash 0.2.0", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", "libc", "log", - "object_store", - "parquet", - "paste", + "object_store 0.13.2", "sqlparser", "tokio", + "uuid", "web-time", ] [[package]] name = "datafusion-common-runtime" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92065bbc6532c6651e2f7dd30b55cba0c7a14f860c7e1d15f165c41a1868d95" +checksum = "05f0ba2b864792bdca4d76c59a1de0ab6e1b61946596b9936888dbd6360035f2" dependencies = [ "futures", "log", @@ -1563,9 +1735,9 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde13794244bc7581cd82f6fff217068ed79cdc344cafe4ab2c3a1c3510b38d6" +checksum = "b840a8bce0bcbf5afad02946d438591e7c373f7afccaf3d874c04485772514dd" dependencies = [ "arrow", "async-trait", @@ -1584,17 +1756,18 @@ dependencies = [ "glob", "itertools 0.14.0", "log", - "object_store", - "rand 0.9.2", + "object_store 0.13.2", + "parking_lot", + "rand 0.9.5", "tokio", "url", ] [[package]] name = "datafusion-datasource-arrow" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804fa9b4ecf3157982021770617200ef7c1b2979d57bec9044748314775a9aea" +checksum = "a24cc0b9cf6e367f27f27406eff13abf48a11b72446aaa40b3105c0ded5c17d9" dependencies = [ "arrow", "arrow-ipc", @@ -1610,15 +1783,15 @@ dependencies = [ "datafusion-session", "futures", "itertools 0.14.0", - "object_store", + "object_store 0.13.2", "tokio", ] [[package]] name = "datafusion-datasource-csv" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a1641a40b259bab38131c5e6f48fac0717bedb7dc93690e604142a849e0568" +checksum = "e1abe56b2a7a2d1d6de5117dd1a203181e267f28529faa5da546947621b697d7" dependencies = [ "arrow", "async-trait", @@ -1632,38 +1805,16 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "object_store", + "object_store 0.13.2", "regex", "tokio", ] [[package]] name = "datafusion-datasource-json" -version = "51.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeacdb00c1d37271176f8fb6a1d8ce096baba16ea7a4b2671840c5c9c64fe85" -dependencies = [ - "arrow", - "async-trait", - "bytes", - "datafusion-common", - "datafusion-common-runtime", - "datafusion-datasource", - "datafusion-execution", - "datafusion-expr", - "datafusion-physical-expr-common", - "datafusion-physical-plan", - "datafusion-session", - "futures", - "object_store", - "tokio", -] - -[[package]] -name = "datafusion-datasource-parquet" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d0b60ffd66f28bfb026565d62b0a6cbc416da09814766a3797bba7d85a3cd9" +checksum = "9c3e467f0611ad7bdd5aad17c63c9bb6182d04e5282e5496d897ea2b49c024ba" dependencies = [ "arrow", "async-trait", @@ -1673,55 +1824,51 @@ dependencies = [ "datafusion-datasource", "datafusion-execution", "datafusion-expr", - "datafusion-functions-aggregate-common", - "datafusion-physical-expr", - "datafusion-physical-expr-adapter", "datafusion-physical-expr-common", "datafusion-physical-plan", - "datafusion-pruning", "datafusion-session", "futures", - "itertools 0.14.0", - "log", - "object_store", - "parking_lot", - "parquet", + "object_store 0.13.2", "tokio", + "tokio-stream", ] [[package]] name = "datafusion-doc" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b99e13947667b36ad713549237362afb054b2d8f8cc447751e23ec61202db07" +checksum = "d69bb69d8769e34f76839c960dbde24c1ac0c885a79b6c3c2287bdc56ec67891" [[package]] name = "datafusion-execution" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63695643190679037bc946ad46a263b62016931547bf119859c511f7ff2f5178" +checksum = "d8eac0a09bc8d263f52025cad9e001da4d8138d633fa288edda4d06b1772eae6" dependencies = [ "arrow", + "arrow-buffer", "async-trait", "dashmap", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr-common", "futures", "log", - "object_store", + "object_store 0.13.2", "parking_lot", - "rand 0.9.2", + "rand 0.9.5", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a4787cbf5feb1ab351f789063398f67654a6df75c4d37d7f637dc96f951a91" +checksum = "eeb14d374767ee0fc62dc79a5ba8bcf8a63c14e993c7d992d0e63adfa23d77d3" dependencies = [ "arrow", + "arrow-schema", "async-trait", "chrono", "datafusion-common", @@ -1730,63 +1877,62 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", "serde_json", "sqlparser", ] [[package]] name = "datafusion-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce2fb1b8c15c9ac45b0863c30b268c69dc9ee7a1ee13ecf5d067738338173dc" +checksum = "b7b19a8c95522bee8cbb313d74263b85e355d2b52f42e67ef5694bf5de9e9356" dependencies = [ "arrow", "datafusion-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", ] [[package]] name = "datafusion-functions" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794a9db7f7b96b3346fc007ff25e994f09b8f0511b4cf7dff651fadfe3ebb28f" +checksum = "5f64c983bbbdcb729d921a2b2ac3375598719b5cc0c30345ad664936f3176fc7" dependencies = [ "arrow", "arrow-buffer", - "base64", + "base64 0.22.1", "blake2", "blake3", "chrono", + "chrono-tz", "datafusion-common", "datafusion-doc", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", "datafusion-macros", + "datafusion-physical-expr-common", "hex", "itertools 0.14.0", "log", "md-5", + "memchr", "num-traits", - "rand 0.9.2", + "rand 0.9.5", "regex", - "sha2", - "unicode-segmentation", + "sha2 0.11.0", "uuid", ] [[package]] name = "datafusion-functions-aggregate" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25210520a9dcf9c2b2cbbce31ebd4131ef5af7fc60ee92b266dc7d159cb305" +checksum = "89bc17041e424a47ed062f43df24d84aab8b57c4c3221e5c1a5eef46d6c5718b" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-doc", @@ -1796,18 +1942,18 @@ dependencies = [ "datafusion-macros", "datafusion-physical-expr", "datafusion-physical-expr-common", + "foldhash 0.2.0", "half", "log", - "paste", + "num-traits", ] [[package]] name = "datafusion-functions-aggregate-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f4a66f3b87300bb70f4124b55434d2ae3fe80455f3574701d0348da040b55d" +checksum = "97dd2a9e865c6108059f5b37b77934f84b50bfb108f837bd0e5c9536e03f0545" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr-common", @@ -1816,9 +1962,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae5c06eed03918dc7fe7a9f082a284050f0e9ecf95d72f57712d1496da03b8c4" +checksum = "75f0bdfeef16d96417b9632ef855645376b242e9006a126dfd0bedfc54a93f5f" dependencies = [ "arrow", "arrow-ord", @@ -1832,32 +1978,34 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-macros", "datafusion-physical-expr-common", + "hashbrown 0.17.1", "itertools 0.14.0", + "itoa", "log", - "paste", + "memchr", ] [[package]] name = "datafusion-functions-table" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4fed1d71738fbe22e2712d71396db04c25de4111f1ec252b8f4c6d3b25d7f5" +checksum = "f4e4941673c917819616877e9993da4503e4f4739812be0bc32c5356184c6383" dependencies = [ "arrow", "async-trait", "datafusion-catalog", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr", "datafusion-physical-plan", "parking_lot", - "paste", ] [[package]] name = "datafusion-functions-window" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d92206aa5ae21892f1552b4d61758a862a70956e6fd7a95cb85db1de74bc6d1" +checksum = "12dd2e16c12b84b6f6b41b19f55b366dd1c46876bb35b86896c6349067379e8d" dependencies = [ "arrow", "datafusion-common", @@ -1868,14 +2016,13 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "log", - "paste", ] [[package]] name = "datafusion-functions-window-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53ae9bcc39800820d53a22d758b3b8726ff84a5a3e24cecef04ef4e5fdf1c7cc" +checksum = "4cdc5e4b6f8b6ef823cc1c761f85088ad4c884fe8df64df3cbcc6b2b84698441" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -1883,20 +2030,20 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1063ad4c9e094b3f798acee16d9a47bd7372d9699be2de21b05c3bd3f34ab848" +checksum = "1a3614234dd93578c92428cb4f408e020874f0d2b7e6c90c928d9d28b5df2ceb" dependencies = [ "datafusion-doc", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "datafusion-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35f9ec5d08b87fd1893a30c2929f2559c2f9806ca072d8fefca5009dc0f06a" +checksum = "a0635620b050b81bb92764e99250868f654e2cd5ad1bece413283b3f73c83179" dependencies = [ "arrow", "chrono", @@ -1904,7 +2051,7 @@ dependencies = [ "datafusion-expr", "datafusion-expr-common", "datafusion-physical-expr", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", "log", "regex", @@ -1913,11 +2060,10 @@ dependencies = [ [[package]] name = "datafusion-physical-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30cc8012e9eedcb48bbe112c6eff4ae5ed19cf3003cb0f505662e88b7014c5d" +checksum = "8cabf7a86eb70b816729e33c81bf7767c936ee1226f607a114f5dac2decac8d0" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr", @@ -1925,19 +2071,19 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-physical-expr-common", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "parking_lot", - "paste", "petgraph", + "tokio", ] [[package]] name = "datafusion-physical-expr-adapter" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9ff2dbd476221b1f67337699eff432781c4e6e1713d2aefdaa517dfbf79768" +checksum = "de222e04f7e6744501555a54ab0abe26bfdfebee380af79a9bdc175704246859" dependencies = [ "arrow", "datafusion-common", @@ -1950,23 +2096,26 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90da43e1ec550b172f34c87ec68161986ced70fd05c8d2a2add66eef9c276f03" +checksum = "72d0d0057fc5a502d45c870cb6d47c66eb7bdd5edb1bd71ad6f3f724975ac2a8" dependencies = [ - "ahash", "arrow", + "chrono", "datafusion-common", "datafusion-expr-common", - "hashbrown 0.14.5", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", + "parking_lot", + "pin-project", ] [[package]] name = "datafusion-physical-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce9804f799acd7daef3be7aaffe77c0033768ed8fdbf5fb82fc4c5f2e6bc14e6" +checksum = "86046eed10950c5f9aaed9acfd148e9bd2e1dfdfe4f9aef607d1447b271e4183" dependencies = [ "arrow", "datafusion-common", @@ -1982,30 +2131,32 @@ dependencies = [ [[package]] name = "datafusion-physical-plan" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0acf0ad6b6924c6b1aa7d213b181e012e2d3ec0a64ff5b10ee6282ab0f8532ac" +checksum = "9bc84da934c903407ba297971ebcc020c4c1a38aafd765d6c144c76eff3fa6a1" dependencies = [ - "ahash", "arrow", + "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "async-trait", - "chrono", "datafusion-common", "datafusion-common-runtime", "datafusion-execution", "datafusion-expr", + "datafusion-functions", "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr", "datafusion-physical-expr-common", "futures", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "log", + "num-traits", "parking_lot", "pin-project-lite", "tokio", @@ -2013,9 +2164,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2c2498a1f134a9e11a9f5ed202a2a7d7e9774bd9249295593053ea3be999db" +checksum = "eb63eeac6de19be40f487b65dd84e546195f783c5a9928618e0c4f2a3569b0d7" dependencies = [ "arrow", "datafusion-common", @@ -2024,15 +2175,14 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "datafusion-physical-plan", - "itertools 0.14.0", "log", ] [[package]] name = "datafusion-session" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f96eebd17555386f459037c65ab73aae8df09f464524c709d6a3134ad4f4776" +checksum = "5f961d209177f91bd014db5cbb2c33b7d28a2597b9003e77f17aeb712964315a" dependencies = [ "async-trait", "datafusion-common", @@ -2044,16 +2194,17 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fc195fe60634b2c6ccfd131b487de46dc30eccae8a3c35a13f136e7f440414f" +checksum = "b1d71cb454da682b2af7488e1fc1ddd72ee1b28f19297b8ccad73f0a21ee9a69" dependencies = [ "arrow", "bigdecimal", "chrono", "datafusion-common", "datafusion-expr", - "indexmap", + "datafusion-functions-nested", + "indexmap 2.14.0", "log", "regex", "sqlparser", @@ -2061,9 +2212,9 @@ dependencies = [ [[package]] name = "datafusion-substrait" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2505af06d103a55b4e8ded0c6aeb6c72a771948da939c0bd3f8eee67af475a9c" +checksum = "f047a6fbf967b6b523758a48d2377bb5f9373a20e7ae4c4326d3c569f80ba3d7" dependencies = [ "async-recursion", "async-trait", @@ -2071,34 +2222,19 @@ dependencies = [ "datafusion", "half", "itertools 0.14.0", - "object_store", + "object_store 0.13.2", "pbjson-types", "prost", "substrait", "tokio", "url", - "uuid", -] - -[[package]] -name = "deepsize" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" -dependencies = [ - "deepsize_derive", ] [[package]] -name = "deepsize_derive" -version = "0.1.2" +name = "datasketches" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "46c4cf71a36b46dcfc00e5014c0c20ccad2b1b6a008304d7d57d2749b2d41b3d" [[package]] name = "der" @@ -2106,33 +2242,76 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", ] +[[package]] +name = "derive_builder" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "507dfb09ea8b7fa618fcf76e953f4f5e192547945816d5358edffe39f6f94947" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d5bcf7b024d6835cfb3d473887cd966994907effbe9227e8c8219824d06c4e8" +dependencies = [ + "darling 0.20.11", + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "derive_builder_macro" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" +dependencies = [ + "derive_builder_core", + "syn 2.0.118", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2156,13 +2335,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -2174,12 +2353,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" @@ -2204,9 +2377,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -2214,14 +2387,23 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", +] + +[[package]] +name = "encoding_rs_io" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fba3fe847045ecff794b9c138293a80db914678c453ad63fbf0c6a9eb6e00b22" +dependencies = [ + "encoding_rs", ] [[package]] name = "env_filter" -version = "0.1.4" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -2229,9 +2411,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -2258,17 +2440,16 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2289,17 +2470,11 @@ 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.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" @@ -2319,7 +2494,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags", + "bitflags 2.13.0", "rustc_version", ] @@ -2331,7 +2506,6 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", - "zlib-rs", ] [[package]] @@ -2368,15 +2542,70 @@ dependencies = [ ] [[package]] -name = "fs4" -version = "0.8.4" +name = "foyer-common" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +checksum = "643b47d510032a01e5af70dca288b0c5ec531646c1a8392e793fb5b0c13bef30" dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", + "anyhow", + "bytes", + "cfg-if 1.0.4", + "foyer-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset", +] + +[[package]] +name = "foyer-memory" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51f5089f3d9025ae77f17ea66d2489ac0f82fbb1d91462807bea174d486d82" +dependencies = [ + "anyhow", + "asyncband", + "bitflags 2.13.0", + "datasketches", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "foyer-tokio", + "futures-util", + "hashbrown 0.17.1", + "itertools 0.15.0", + "mixtrics", + "parking_lot", + "paste", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-tokio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6741d1133dfaab64d3f8a9290bbfed3685084add28f8b6ee7a6264aa4dc26918" +dependencies = [ + "tokio", ] +[[package]] +name = "frostem" +version = "1.20260804.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0ae10cfccbae085dd8612669ccc116fe88bd5dfe39d202a8fa68c24c1e546f" + [[package]] name = "fs_extra" version = "1.3.0" @@ -2385,10 +2614,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", - "rand 0.9.2", + "rand 0.9.5", ] [[package]] @@ -2408,9 +2637,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2423,9 +2652,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2433,15 +2662,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2450,44 +2679,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2497,18 +2720,26 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] +[[package]] +name = "gearhash" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" +dependencies = [ + "cfg-if 0.1.10", +] + [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "libc", "log", "rustversion", @@ -2555,9 +2786,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", @@ -2568,9 +2799,9 @@ dependencies = [ [[package]] name = "geoarrow-array" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1cc4106ac0a0a512c398961ce95d8150475c84a84e17c4511c3643fa120a17" +checksum = "dafe7b7de3fab1a8b7099fd6a6434ca955fa65065f9c19f0f8a133693f3c2b0e" dependencies = [ "arrow-array", "arrow-buffer", @@ -2584,9 +2815,9 @@ dependencies = [ [[package]] name = "geoarrow-expr-geo" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa84300361ce57fb875bcaa6e32b95b0aff5c6b1af692b936bdd58ff343f4394" +checksum = "8e4a62ac19c86827c6ec81ea584594b3ee96db5a8119b9774d3466c6b373c434" dependencies = [ "arrow-array", "arrow-buffer", @@ -2598,9 +2829,9 @@ dependencies = [ [[package]] name = "geoarrow-schema" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97be4e9f523f92bd6a0e0458323f4b783d073d011664decd8dbf05651704f34" +checksum = "4d4a7edb2a1d87024a93805332a9c8184a0354836271d42c0d18cf628a5e3cd0" dependencies = [ "arrow-schema", "geo-traits", @@ -2611,12 +2842,13 @@ dependencies = [ [[package]] name = "geodatafusion" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773cfa1fb0d7f7661b76b3fde00f3ffd8e0ff7b3635096f0ff6294fe5ca62a2b" +checksum = "fecbdd00d0fff2b04635c1b1e4129c217908f0c2d17539e0a2275308afce2552" dependencies = [ "arrow-arith", "arrow-array", + "arrow-buffer", "arrow-schema", "datafusion", "geo", @@ -2631,18 +2863,18 @@ dependencies = [ [[package]] name = "geographiclib-rs" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f611040a2bb37eaa29a78a128d1e92a378a03e0b6e66ae27398d42b1ba9a7841" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" dependencies = [ "libm", ] [[package]] name = "geohash" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb94b1a65401d6cbf22958a9040aa364812c26674f841bee538b12c135db1e6" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" dependencies = [ "geo-types", "libm", @@ -2654,10 +2886,10 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -2667,14 +2899,46 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", - "js-sys", + "cfg-if 1.0.4", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", "wasm-bindgen", ] +[[package]] +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "glob" version = "0.3.3" @@ -2693,19 +2957,51 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "goosefs-sdk" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e471f7bce40936cf2023855c7350e31e11b3f703eb7cb2ce73c9ffc451f87d2" +dependencies = [ + "arc-swap", + "async-trait", + "bytes", + "dashmap", + "fastrand", + "foyer-common", + "foyer-memory", + "futures", + "hostname", + "io-uring", + "itoa", + "libc", + "lru", + "prost", + "rand 0.9.5", + "serde", + "thiserror 2.0.18", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", + "tracing", + "uuid", + "xxhash-rust", +] + [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap", + "http 1.4.2", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -2718,7 +3014,8 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "bytemuck", + "cfg-if 1.0.4", "crunchy", "num-traits", "zerocopy", @@ -2733,15 +3030,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -2749,8 +3048,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", ] @@ -2765,6 +3062,23 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heapless" version = "0.8.0" @@ -2793,29 +3107,59 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-xet" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http 1.4.2", + "more-asserts", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] -name = "htmlescape" -version = "0.3.1" +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "windows-link", +] [[package]] name = "http" @@ -2830,9 +3174,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2856,7 +3200,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -2867,7 +3211,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -2886,28 +3230,36 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +dependencies = [ + "typenum", +] [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", "h2", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2915,33 +3267,44 @@ 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", + "http 1.4.2", "hyper", "hyper-util", "rustls", "rustls-native-certs", - "rustls-pki-types", "tokio", "tokio-rustls", "tower-service", - "webpki-roots", ] [[package]] -name = "hyper-util" -version = "0.1.20" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "base64", - "bytes", - "futures-channel", - "futures-util", - "http 1.4.0", + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http 1.4.2", "http-body 1.0.1", "hyper", "ipnet", @@ -2949,9 +3312,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3032,35 +3397,58 @@ 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", ] +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[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", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + [[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", @@ -3072,15 +3460,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", @@ -3092,18 +3480,20 @@ 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", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3111,6 +3501,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "ident_case" version = "1.0.1" @@ -3130,9 +3541,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -3140,12 +3551,25 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.13.0" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -3159,26 +3583,21 @@ dependencies = [ ] [[package]] -name = "integer-encoding" -version = "3.0.4" +name = "io-uring" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" +checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "libc", +] [[package]] name = "ipnet" -version = "2.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" - -[[package]] -name = "iri-string" -version = "0.7.10" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -3197,59 +3616,84 @@ dependencies = [ [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] [[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" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34904340bc65749a9e9a02fcc7f3368e675427c18447b9bbe02df52c15c9a36a" +dependencies = [ + "phf_codegen", +] + +[[package]] +name = "jieba-rs" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" +dependencies = [ + "bytecount", + "cedarwood", + "jieba-macros", + "phf 0.13.1", + "regex", + "rustc-hash", +] [[package]] name = "jiff" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "wasm-bindgen", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "jiff-tzdb" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -3267,20 +3711,72 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" dependencies = [ "cesu8", - "cfg-if", + "cfg-if 1.0.4", "combine", - "jni-sys", + "jni-sys 0.3.1", "log", "thiserror 1.0.69", "walkdir", "windows-sys 0.45.0", ] +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if 1.0.4", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", +] + [[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.118", +] [[package]] name = "jobserver" @@ -3294,57 +3790,71 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.85" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" dependencies = [ - "once_cell", + "cfg-if 1.0.4", + "futures-util", "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", "fast-float2", "itoa", "jiff", - "nom 8.0.0", + "nom", "num-traits", - "ordered-float 5.1.0", - "rand 0.9.2", - "ryu", + "ordered-float", + "rand 0.9.5", "serde", "serde_json", + "zmij", ] [[package]] -name = "jsonwebtoken" -version = "9.3.1" +name = "kanaria" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "c0f9d9652540055ac4fded998a73aca97d965899077ab1212587437da44196ff" dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", + "bitflags 1.3.2", +] + +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", ] +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + [[package]] name = "lance" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -3353,10 +3863,11 @@ dependencies = [ "async-recursion", "async-trait", "async_cell", - "aws-credential-types", + "blake3", "byteorder", "bytes", "chrono", + "crossbeam-queue", "crossbeam-skiplist", "dashmap", "datafusion", @@ -3364,37 +3875,40 @@ dependencies = [ "datafusion-functions", "datafusion-physical-expr", "datafusion-physical-plan", - "deepsize", "either", + "fst", "futures", - "half", "humantime", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", + "lance-bitpacking", "lance-core", "lance-datafusion", "lance-encoding", "lance-file", - "lance-geo", "lance-index", "lance-io", "lance-linalg", "lance-namespace", + "lance-select", "lance-table", + "lance-tokenizer", "log", "moka", - "object_store", + "object_store 0.14.1", "permutation", "pin-project", "prost", + "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.5", + "rayon", "roaring", + "rustc-hash", "semver", "serde", "serde_json", "snafu", - "tantivy", "tokio", "tokio-stream", "tokio-util", @@ -3405,76 +3919,102 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", - "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", + "bytemuck", "bytes", + "futures", "getrandom 0.2.17", "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.5", +] + +[[package]] +name = "lance-arrow-scalar" +version = "58.0.0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-row", + "arrow-schema", + "half", +] + +[[package]] +name = "lance-arrow-stats" +version = "58.0.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "lance-arrow-scalar", ] [[package]] name = "lance-bitpacking" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrayref", + "crunchy", "paste", "seq-macro", ] [[package]] name = "lance-core" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", + "arrow-data", "arrow-schema", "async-trait", - "byteorder", + "blake3", "bytes", - "chrono", "datafusion-common", "datafusion-sql", - "deepsize", "futures", - "itertools 0.13.0", "lance-arrow", + "lance-derive", "libc", + "libm", "log", - "mock_instant", "moka", "num_cpus", - "object_store", + "object_store 0.14.1", "pin-project", "prost", - "rand 0.9.2", + "quick_cache", + "rand 0.9.5", "roaring", "serde_json", "snafu", "tempfile", "tokio", - "tokio-stream", "tokio-util", "tracing", + "twox-hash", "url", ] [[package]] name = "lance-datafusion" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", @@ -3486,40 +4026,31 @@ dependencies = [ "datafusion-physical-expr", "datafusion-substrait", "futures", + "half", "jsonb", "lance-arrow", "lance-core", - "lance-datagen", "lance-geo", "log", "pin-project", "prost", - "snafu", + "prost-build", "tokio", "tracing", ] [[package]] -name = "lance-datagen" -version = "3.0.0-beta.2" +name = "lance-derive" +version = "14.0.0-beta.6" dependencies = [ - "arrow", - "arrow-array", - "arrow-cast", - "arrow-schema", - "chrono", - "futures", - "half", - "hex", - "rand 0.9.2", - "rand_distr 0.5.1", - "rand_xoshiro", - "random_word", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] name = "lance-encoding" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -3535,7 +4066,7 @@ dependencies = [ "futures", "hex", "hyperloglogplus", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", "lance-bitpacking", "lance-core", @@ -3544,10 +4075,6 @@ dependencies = [ "num-traits", "prost", "prost-build", - "prost-types", - "rand 0.9.2", - "snafu", - "strum", "tokio", "tracing", "xxhash-rust", @@ -3556,11 +4083,12 @@ dependencies = [ [[package]] name = "lance-file" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-data", "arrow-schema", "arrow-select", @@ -3569,7 +4097,6 @@ dependencies = [ "byteorder", "bytes", "datafusion-common", - "deepsize", "futures", "lance-arrow", "lance-core", @@ -3577,18 +4104,18 @@ dependencies = [ "lance-io", "log", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "snafu", + "serde", "tokio", "tracing", ] [[package]] name = "lance-geo" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -3602,18 +4129,19 @@ dependencies = [ [[package]] name = "lance-index" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-arith", "arrow-array", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", "async-channel", "async-recursion", "async-trait", - "bitpacking", "bitvec", "bytes", "crossbeam-queue", @@ -3621,8 +4149,6 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "datafusion-sql", - "deepsize", "dirs", "fst", "futures", @@ -3630,94 +4156,127 @@ dependencies = [ "geoarrow-array", "geoarrow-schema", "half", - "itertools 0.13.0", + "hashbrown 0.16.1", + "hex", + "itertools 0.14.0", + "jieba-rs", "jsonb", "lance-arrow", + "lance-arrow-stats", + "lance-bitpacking", "lance-core", "lance-datafusion", - "lance-datagen", "lance-encoding", "lance-file", "lance-geo", + "lance-index-core", "lance-io", "lance-linalg", + "lance-select", "lance-table", - "libm", + "lance-tokenizer", + "libsais-rs", "log", "ndarray", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "rand 0.9.2", - "rand_distr 0.5.1", + "rand 0.9.5", + "rand_distr", "rangemap", "rayon", + "regex-syntax", "roaring", "serde", "serde_json", "smallvec", - "snafu", - "tantivy", "tempfile", "tokio", "tracing", "twox-hash", - "uuid", ] [[package]] -name = "lance-io" -version = "3.0.0-beta.2" +name = "lance-index-core" +version = "14.0.0-beta.6" dependencies = [ - "arrow", - "arrow-arith", "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", "arrow-schema", "arrow-select", - "async-recursion", "async-trait", - "aws-config", + "bytes", + "datafusion", + "datafusion-common", + "datafusion-expr", + "futures", + "lance-core", + "lance-io", + "lance-select", + "prost-types", + "roaring", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "lance-io" +version = "14.0.0-beta.6" +dependencies = [ + "arrow", + "arrow-array", + "arrow-schema", + "async-trait", + "aws-config", "aws-credential-types", "byteorder", "bytes", "chrono", - "deepsize", "futures", - "lance-arrow", + "goosefs-sdk", + "http 1.4.2", + "io-uring", "lance-core", "lance-namespace", "log", - "object_store", + "metrics", + "moka", + "object_store 0.14.1", "object_store_opendal", "opendal", + "opendal-service-goosefs", "path_abs", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.5", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", "serde", - "shellexpand", - "snafu", + "serde_json", + "tempfile", "tokio", "tracing", "url", + "uuid", ] [[package]] name = "lance-jni" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", + "arrow-array", "arrow-schema", "async-trait", "bytes", "chrono", + "datafusion", + "datafusion-common", "env_logger", - "jni", + "jni 0.21.1", "lance", "lance-core", "lance-datafusion", @@ -3728,92 +4287,114 @@ dependencies = [ "lance-linalg", "lance-namespace", "lance-namespace-impls", + "lance-table", "log", - "object_store", + "metrics", + "metrics-util", + "object_store 0.14.1", "prost", "prost-types", "roaring", "serde", "serde_json", - "snafu", "tokio", "uuid", ] [[package]] name = "lance-linalg" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", - "arrow-buffer", "arrow-schema", "cc", - "deepsize", "half", "lance-arrow", "lance-core", "num-traits", - "rand 0.9.2", + "rayon", ] [[package]] name = "lance-namespace" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", ] [[package]] name = "lance-namespace-impls" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-ipc", "arrow-schema", "async-trait", "axum", "bytes", - "chrono", + "datafusion-common", + "datafusion-physical-plan", "futures", "lance", "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", + "lance-table", "log", - "object_store", - "rand 0.9.2", - "reqwest", + "object_store 0.14.1", + "rand 0.9.5", + "reqwest 0.12.28", + "roaring", "serde", "serde_json", - "snafu", "tokio", "tower", "tower-http 0.5.2", "url", + "uuid", ] [[package]] name = "lance-namespace-reqwest-client" -version = "0.4.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2acdba67f84190067532fce07b51a435dd390d7cdc1129a05003e5cb3274cf0" +checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1" dependencies = [ - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", + "serde_with", "url", ] +[[package]] +name = "lance-select" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "byteorder", + "itertools 0.14.0", + "lance-core", + "roaring", + "tracing", +] + [[package]] name = "lance-table" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -3821,21 +4402,22 @@ dependencies = [ "arrow-ipc", "arrow-schema", "async-trait", + "blake3", "byteorder", "bytes", "chrono", - "deepsize", "futures", "lance-arrow", "lance-core", "lance-file", "lance-io", + "lance-select", "log", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.5", "rangemap", "roaring", "semver", @@ -3848,21 +4430,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "lance-tokenizer" +version = "14.0.0-beta.6" +dependencies = [ + "frostem", + "icu_segmenter", + "jieba-rs", + "lindera", + "serde", + "stop-words", + "unicode-normalization", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.9", ] -[[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" @@ -3922,9 +4511,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.180" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -3934,31 +4523,99 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags", "libc", ] [[package]] -name = "linux-raw-sys" -version = "0.4.15" +name = "libsais-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fe164dbd47ea0c20e78a121c980ef673326905f1d4fba55e3645a20ef6717f" +dependencies = [ + "rayon", +] + +[[package]] +name = "lindera" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74cda79d7161e99b414e4d292ff673cc3f8d22f070d8be3b6185c033363a9216" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "kanaria", + "lindera-dictionary", + "log", + "once_cell", + "percent-encoding", + "regex", + "serde", + "serde_json", + "serde_yaml_ng", + "strum", + "strum_macros", + "unicode-blocks", + "unicode-normalization", + "unicode-segmentation", + "url", +] + +[[package]] +name = "lindera-dictionary" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2385456ca9fe87c29072c5f156b52fdd5e28d5b5738ddfb3979501dbd736530" +dependencies = [ + "anyhow", + "byteorder", + "csv", + "daachorse", + "derive_builder", + "encoding_rs", + "encoding_rs_io", + "glob", + "log", + "memmap2", + "num_cpus", + "once_cell", + "regex", + "rkyv", + "serde", + "serde_json", + "strum", + "strum_macros", + "thiserror 2.0.18", +] + +[[package]] +name = "link-section" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" [[package]] name = "linux-raw-sys" -version = "0.11.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +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" @@ -3971,9 +4628,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -3981,7 +4638,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "generator", "scoped-tls", "tracing", @@ -3990,11 +4647,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4024,15 +4681,9 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" - -[[package]] -name = "lz4_flex" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" dependencies = [ "twox-hash", ] @@ -4067,38 +4718,72 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ - "cfg-if", - "digest", + "cfg-if 1.0.4", + "digest 0.11.3", ] [[package]] -name = "measure_time" -version = "0.9.0" +name = "mea" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" dependencies = [ - "log", + "slab", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "metrics", + "rand 0.9.5", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -4115,12 +4800,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" @@ -4133,26 +4812,30 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] [[package]] -name = "mock_instant" -version = "0.6.0" +name = "mixtrics" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" +checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" +dependencies = [ + "itertools 0.15.0", + "parking_lot", +] [[package]] name = "moka" -version = "0.12.13" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -4168,6 +4851,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "multimap" version = "0.10.1" @@ -4175,10 +4864,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "murmurhash32" -version = "0.3.1" +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "ndarray" @@ -4196,13 +4899,15 @@ dependencies = [ ] [[package]] -name = "nom" -version = "7.1.3" +name = "nix" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "memchr", - "minimal-lexical", + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", ] [[package]] @@ -4214,6 +4919,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4244,7 +4958,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -4260,9 +4974,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -4306,9 +5020,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", @@ -4316,42 +5030,100 @@ 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", "quote", - "syn 2.0.114", + "syn 2.0.118", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", ] [[package]] name = "object_store" -version = "0.12.5" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "object_store" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" dependencies = [ "async-trait", - "base64", + "aws-lc-rs", + "base64 0.22.1", "bytes", "chrono", + "crc-fast", "form_urlencoded", - "futures", - "http 1.4.0", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", "http-body-util", "httparse", "humantime", "hyper", - "itertools 0.14.0", + "itertools 0.15.0", "md-5", + "nix", "parking_lot", "percent-encoding", - "quick-xml 0.38.4", - "rand 0.9.2", - "reqwest", - "ring", - "rustls-pemfile", + "quick-xml", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", @@ -4362,19 +5134,21 @@ dependencies = [ "walkdir", "wasm-bindgen-futures", "web-time", + "windows-sys 0.61.2", ] [[package]] name = "object_store_opendal" -version = "0.55.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113ab0769e972eee585e57407b98de08bda5354fa28e8ba4d89038d6cb6a8991" +checksum = "c479bcc317f0ed98972b7184e0f9cc1bea83a07f60cdade96d7207a48ebfb779" dependencies = [ "async-trait", + "asyncband", "bytes", "chrono", "futures", - "object_store", + "object_store 0.14.1", "opendal", "pin-project", "tokio", @@ -4382,9 +5156,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" @@ -4400,32 +5174,286 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "opendal" -version = "0.55.0" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" +dependencies = [ + "ctor", + "opendal-core", + "opendal-http-transport-reqwest", + "opendal-layer-concurrent-limit", + "opendal-layer-logging", + "opendal-layer-retry", + "opendal-layer-timeout", + "opendal-service-azblob", + "opendal-service-azdls", + "opendal-service-cos", + "opendal-service-gcs", + "opendal-service-goosefs", + "opendal-service-hf", + "opendal-service-oss", + "opendal-service-s3", + "opendal-service-tos", +] + +[[package]] +name = "opendal-core" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" dependencies = [ "anyhow", - "backon", - "base64", + "asyncband", + "base64 0.23.0", "bytes", - "crc32c", "futures", - "getrandom 0.2.17", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", "jiff", "log", "md-5", "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest", + "quick-xml", + "reqsign-core", "serde", "serde_json", - "sha2", "tokio", "url", "uuid", + "web-time", +] + +[[package]] +name = "opendal-http-transport-reqwest" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" +dependencies = [ + "bytes", + "futures", + "http 1.4.2", + "http-body 1.0.1", + "opendal-core", + "reqwest 0.13.4", +] + +[[package]] +name = "opendal-layer-concurrent-limit" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3734c648617e5ad724d49175dde8323550624f9614f50e9e81b7aafae30b1734" +dependencies = [ + "asyncband", + "futures", + "http 1.4.2", + "opendal-core", +] + +[[package]] +name = "opendal-layer-logging" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a8daab5a91eac76f94516e7c71e677195c9221840ce8f5e71815e3fc8207742" +dependencies = [ + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-retry" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb8e22cb1f86ef9af771a8947d3ccf380c8f5b76236f628314dd41c9ba73331a" +dependencies = [ + "backon", + "log", + "opendal-core", +] + +[[package]] +name = "opendal-layer-timeout" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08ac6c77fe0d1b18e0064a02c19b98dbebcab78bf5e7753ad1f3abbc18850ff4" +dependencies = [ + "opendal-core", + "tokio", +] + +[[package]] +name = "opendal-service-azblob" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6cf31b02e7b44191d97c07254eea109b5da9c17210a29e478c8d0dc26dffb1f" +dependencies = [ + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "sha2 0.11.0", + "uuid", +] + +[[package]] +name = "opendal-service-azdls" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "354f4a8d26f0fb7639f5c4ad3a924d7c75625830f69550b8c807c080ea0df8bc" +dependencies = [ + "asyncband", + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "serde_json", +] + +[[package]] +name = "opendal-service-azure-common" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8107324c3f3cb9970fe20526c2c341b7c5769acbd4699bab73bd525f5fd63d62" +dependencies = [ + "http 1.4.2", + "opendal-core", +] + +[[package]] +name = "opendal-service-cos" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "feb737e6609b4fd42e4773b7b2d8d05031b63cd0df46c7ba68ddd64bae1dd3f3" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-tencent-cos", + "serde", +] + +[[package]] +name = "opendal-service-gcs" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d80eca6f9227bdf65d43e5b8d5fe75dca8cfab1da07571442a0420400847bea" +dependencies = [ + "async-trait", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", + "serde", + "serde_json", + "tokio", + "uuid", +] + +[[package]] +name = "opendal-service-goosefs" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2134aca0ef64dbff844cb1e608d28833f2a1b34b884e7d6b307416e966694e9f" +dependencies = [ + "asyncband", + "bytes", + "goosefs-sdk", + "log", + "opendal-core", + "serde", +] + +[[package]] +name = "opendal-service-hf" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0cba627efaee637746804b74bc2c2b2b207fe214307e1e2ba31b49aad708a3" +dependencies = [ + "asyncband", + "bytes", + "hf-xet", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "opendal-service-oss" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ebfa355b1d4edb0e5b2a5b86d8de9f811fae1d7baca6d65246b230b2afc0bd" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml", + "reqsign-aliyun-oss", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", +] + +[[package]] +name = "opendal-service-s3" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" +dependencies = [ + "base64 0.23.0", + "bytes", + "crc-fast", + "http 1.4.2", + "log", + "md-5", + "opendal-core", + "quick-xml", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "url", +] + +[[package]] +name = "opendal-service-tos" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f4ee342d2e20a8fc109f854fd8cbaeab9682b3109b7efc8682fa321b5352e2" +dependencies = [ + "bytes", + "http 1.4.2", + "opendal-core", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-volcengine-tos", + "serde", + "serde_json", ] [[package]] @@ -4442,18 +5470,9 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" [[package]] name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[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", ] @@ -4469,19 +5488,19 @@ dependencies = [ ] [[package]] -name = "outref" -version = "0.5.2" +name = "os_str_bytes" +version = "6.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] [[package]] -name = "ownedbytes" -version = "0.9.0" +name = "outref" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" -dependencies = [ - "stable_deref_trait", -] +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" [[package]] name = "parking" @@ -4505,50 +5524,13 @@ version = "0.9.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "libc", "redox_syscall", "smallvec", "windows-link", ] -[[package]] -name = "parquet" -version = "57.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6a2926a30477c0b95fea6c28c3072712b139337a242c2cc64817bdc20a8854" -dependencies = [ - "ahash", - "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", - "arrow-ipc", - "arrow-schema", - "arrow-select", - "base64", - "brotli", - "bytes", - "chrono", - "flate2", - "futures", - "half", - "hashbrown 0.16.1", - "lz4_flex 0.12.0", - "num-bigint", - "num-integer", - "num-traits", - "object_store", - "paste", - "seq-macro", - "simdutf8", - "snap", - "thrift", - "tokio", - "twox-hash", - "zstd", -] - [[package]] name = "paste" version = "1.0.15" @@ -4573,7 +5555,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "898bac3fa00d0ba57a4e8289837e965baa2dee8c3749f3b11d45a64b4223d9c3" dependencies = [ - "base64", + "base64 0.22.1", "serde", ] @@ -4610,8 +5592,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -4620,7 +5602,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -4653,7 +5635,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "serde", ] @@ -4663,7 +5645,37 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" dependencies = [ - "phf_shared", + "phf_shared 0.12.1", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", ] [[package]] @@ -4675,31 +5687,40 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -4729,7 +5750,7 @@ dependencies = [ "der", "pbkdf2", "scrypt", - "sha2", + "sha2 0.10.9", "spki", ] @@ -4747,9 +5768,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" @@ -4759,19 +5780,21 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[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 = [ + "serde_core", + "writeable", "zerovec", ] @@ -4797,14 +5820,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.118", ] [[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", ] @@ -4820,9 +5843,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -4830,9 +5853,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -4843,52 +5866,74 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.114", + "syn 2.0.118", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] [[package]] -name = "quick-xml" -version = "0.37.5" +name = "ptr_meta" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "743da816b98c921cdbe8628ef7381b76f25ecf4da599fc80aca90eae7ef70cc0" dependencies = [ - "memchr", - "serde", + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c8d9ca532f185d5d4db7a7c9d51420b452168ea1c2b913953281bd6fe1fcbd0" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", ] [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -4911,14 +5956,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", "rustls", @@ -4946,9 +5993,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -4959,36 +6006,61 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "rancor" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b534442d0fcdb55d66f373d9cac6d33b6293a2335bc2136dbd06ce0e87d2572" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] [[package]] -name = "rand_chacha" -version = "0.3.1" +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" dependencies = [ @@ -5025,14 +6097,10 @@ dependencies = [ ] [[package]] -name = "rand_distr" -version = "0.4.3" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -5041,29 +6109,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.5", ] [[package]] -name = "rand_xoshiro" -version = "0.7.0" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] -name = "random_word" -version = "0.5.2" +name = "rand_xoshiro" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47a395bdb55442b883c89062d6bcff25dc90fa5f8369af81e0ac6d49d78cf81" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "ahash", - "brotli", - "paste", - "rand 0.9.2", - "unicase", + "rand_core 0.9.5", ] [[package]] @@ -5072,6 +6136,15 @@ version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" +[[package]] +name = "rapidhash" +version = "4.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" +dependencies = [ + "rustversion", +] + [[package]] name = "rawpointer" version = "0.2.1" @@ -5080,9 +6153,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", @@ -5098,13 +6171,22 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redb" +version = "3.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" +dependencies = [ + "libc", +] + [[package]] name = "redox_syscall" version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.0", ] [[package]] @@ -5118,11 +6200,31 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -5149,9 +6251,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" @@ -5164,56 +6266,183 @@ dependencies = [ ] [[package]] -name = "relative-path" -version = "1.9.3" +name = "rend" +version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "663ba70707f96e871406fe10d68128412e619b06d1d47cb91c3a4c6501176240" +dependencies = [ + "bytecheck", +] [[package]] -name = "reqsign" -version = "0.16.5" +name = "reqsign-aliyun-oss" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "9c0f9f69a519dd6958c4b43606bb8e1278cdc76d611fc8fed4b796eee548dc0f" dependencies = [ "anyhow", - "async-trait", - "base64", - "chrono", "form_urlencoded", - "getrandom 0.2.17", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-aws-core" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" +dependencies = [ + "bytes", + "form_urlencoded", "hex", - "hmac", - "home", - "http 1.4.0", - "jsonwebtoken", + "http 1.4.2", "log", - "once_cell", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.5", - "reqwest", - "rsa", + "quick-xml", + "reqsign-core", "rust-ini", "serde", "serde_json", + "serde_urlencoded", + "sha1", +] + +[[package]] +name = "reqsign-aws-v4" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "quick-xml", + "reqsign-aws-core", + "reqsign-core", + "serde", +] + +[[package]] +name = "reqsign-azure-storage" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ebd8524185ce9c64063e3095f83968acfa90922f00c601a4a0f3aca15b077e" +dependencies = [ + "anyhow", + "base64 0.22.1", + "bytes", + "form_urlencoded", + "http 1.4.2", + "log", + "pem", + "percent-encoding", + "reqsign-core", + "rsa", + "serde", + "serde_json", + "sha1", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +dependencies = [ + "anyhow", + "base64 0.23.0", + "bytes", + "futures", + "hex", + "hmac 0.13.0", + "http 1.4.2", + "jiff", + "log", + "mea", + "percent-encoding", + "rsa", + "serde", + "serde_json", "sha1", - "sha2", + "sha2 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +dependencies = [ + "anyhow", + "reqsign-core", + "tokio", +] + +[[package]] +name = "reqsign-google" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96da0b579b846d358090cb06b9e3c2ad1375529efbe3e0c45f96bd7bcf043ea" +dependencies = [ + "form_urlencoded", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-aws-v4", + "reqsign-core", + "rsa", + "serde", + "serde_json", "tokio", ] +[[package]] +name = "reqsign-tencent-cos" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6497dd9f6e3d1349b420521484099b284f95e8d3a65f088fccef42493a7b644" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-volcengine-tos" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7e7a3a0635e0aa9ef851577c63cba5b3c98fb584f6a6637c2fd5cc50702c99" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "futures-util", "h2", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "hyper", @@ -5237,14 +6466,68 @@ dependencies = [ "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.8", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.4.2", + "web-sys", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.5.0", "web-sys", - "webpki-roots", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.2", + "reqwest 0.13.4", + "thiserror 2.0.18", + "tower-service", ] [[package]] @@ -5254,18 +6537,48 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9776093b7ca170454ab1406954f7b7d97a57c51dc6c0642957fb2ef25c2d399" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c25ef604ac7dd839d44d64648952ea23c97866f124ff671b0ed2cf3ad9bb06e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.2", +] + [[package]] name = "roaring" -version = "0.10.12" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", @@ -5283,15 +6596,15 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -5309,60 +6622,21 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.114", - "unicode-ident", -] - [[package]] name = "rust-ini" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "ordered-multimap", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -5375,35 +6649,22 @@ dependencies = [ [[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.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.36" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", @@ -5416,9 +6677,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -5427,29 +6688,47 @@ dependencies = [ ] [[package]] -name = "rustls-pemfile" -version = "2.2.0" +name = "rustls-pki-types" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ - "rustls-pki-types", + "web-time", + "zeroize", ] [[package]] -name = "rustls-pki-types" -version = "1.14.0" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "web-time", - "zeroize", + "core-foundation 0.10.1", + "core-foundation-sys", + "jni 0.22.4", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -5465,9 +6744,15 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe-transmute" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" [[package]] name = "salsa20" @@ -5489,9 +6774,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", ] @@ -5508,6 +6793,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -5517,7 +6826,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -5540,17 +6849,17 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "sha2", + "sha2 0.10.9", ] [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", - "core-foundation", + "bitflags 2.13.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -5558,9 +6867,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -5568,9 +6877,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", @@ -5584,9 +6893,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -5594,22 +6903,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.2", ] [[package]] @@ -5620,15 +6929,16 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -5655,19 +6965,19 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -5682,13 +6992,58 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -5697,13 +7052,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5712,9 +7067,20 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -5728,18 +7094,20 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ + "bstr", "dirs", + "os_str_bytes", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -5757,48 +7125,43 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[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" -version = "0.1.5" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] -name = "simple_asn1" -version = "0.6.3" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "sketches-ddsketch" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" -dependencies = [ - "serde", -] +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "slab" @@ -5808,54 +7171,48 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snafu" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] -[[package]] -name = "snap" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b6b67fb9a61334225b5b790716f609cd58395f895b3fe8b328786812a40bc3b" - [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[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", @@ -5863,9 +7220,15 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spin" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -5879,9 +7242,9 @@ dependencies = [ [[package]] name = "sqlparser" -version = "0.59.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4591acadbcf52f0af60eafbb2c003232b2b4cd8de5f0e9437cb8b1b59046cc0f" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" dependencies = [ "log", "sqlparser_derive", @@ -5889,13 +7252,13 @@ dependencies = [ [[package]] name = "sqlparser_derive" -version = "0.3.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -5904,6 +7267,22 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "statrs" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" +dependencies = [ + "approx", + "num-traits", +] + [[package]] name = "std_prelude" version = "0.2.12" @@ -5916,6 +7295,15 @@ version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e51f1e89f093f99e7432c491c382b88a6860a5adbe6bf02574bf0a08efff1978" +[[package]] +name = "stop-words" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d68df56303396bcfb639455b3c166804aeb7994005010aab5e9e8a1277b8871d" +dependencies = [ + "serde_json", +] + [[package]] name = "strsim" version = "0.11.1" @@ -5924,33 +7312,33 @@ checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] name = "strum" -version = "0.26.3" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ "strum_macros", ] [[package]] name = "strum_macros" -version = "0.26.4" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "rustversion", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "substrait" -version = "0.62.2" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fc4b483a129b9772ccb9c3f7945a472112fdd9140da87f8a4e7f1d44e045d0" +checksum = "e620ff4d5c02fd6f7752931aa74b16a26af66a63022cc1ad412c77edbe0bab47" dependencies = [ "heck", + "indexmap 2.14.0", "pbjson", "pbjson-build", "pbjson-types", @@ -5959,12 +7347,12 @@ dependencies = [ "prost-build", "prost-types", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_yaml", - "syn 2.0.114", + "syn 2.0.118", "typify", "walkdir", ] @@ -5975,11 +7363,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" -version = "1.0.109" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -5988,9 +7382,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -6014,160 +7408,49 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "tagptr" -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.5", - "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", + "syn 2.0.118", ] [[package]] -name = "tantivy-query-grammar" -version = "0.24.0" +name = "sysinfo" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] -name = "tantivy-sstable" -version = "0.5.0" +name = "system-configuration" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", ] [[package]] -name = "tantivy-stacker" -version = "0.5.0" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", + "core-foundation-sys", + "libc", ] [[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" +name = "tagptr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tap" @@ -6177,14 +7460,14 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -6214,7 +7497,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6225,7 +7508,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6243,18 +7526,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", -] - -[[package]] -name = "thrift" -version = "0.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e54bc85fc7faa8bc175c4bab5b92ba8d9a3ce893d0e9f42cc455c8ab16a9e09" -dependencies = [ - "byteorder", - "integer-encoding", - "ordered-float 2.10.1", + "cfg-if 1.0.4", ] [[package]] @@ -6299,19 +7571,20 @@ 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", + "serde_core", "zerovec", ] [[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", ] @@ -6324,9 +7597,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -6341,13 +7614,24 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", +] + +[[package]] +name = "tokio-retry" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" +dependencies = [ + "pin-project-lite", + "rand 0.10.1", + "tokio", ] [[package]] @@ -6362,56 +7646,119 @@ dependencies = [ [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", + "async-trait", + "base64 0.22.1", + "bytes", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", ] [[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" +name = "tonic-prost" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ - "winnow", + "bytes", + "prost", + "tonic", ] [[package]] @@ -6422,9 +7769,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -6436,9 +7786,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags", + "bitflags 2.13.0", "bytes", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -6449,25 +7799,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6494,6 +7844,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -6502,7 +7865,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6526,22 +7889,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[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", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -6552,18 +7928,24 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "twox-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" dependencies = [ - "rand 0.9.2", + "rand 0.10.1", ] [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "typewit" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "typify" @@ -6586,11 +7968,11 @@ dependencies = [ "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.118", "thiserror 2.0.18", "unicode-ident", ] @@ -6603,12 +7985,12 @@ checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.118", "typify-impl", ] @@ -6618,17 +8000,32 @@ version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" +[[package]] +name = "unicode-blocks" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328edfd6d0bb91a22e592bed3a3b54c7b1f5c92a517f5a7aca24a2caef10c363" + [[package]] name = "unicode-ident" -version = "1.0.22" +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 = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -6686,11 +8083,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -6739,22 +8136,40 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -6763,23 +8178,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6787,22 +8198,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -6820,11 +8231,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -6841,14 +8265,27 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.6" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -6880,6 +8317,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -6893,6 +8351,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -6901,7 +8370,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6912,7 +8381,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6921,6 +8390,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -6957,15 +8447,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" @@ -7032,6 +8513,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -7172,18 +8662,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wkb" @@ -7212,9 +8702,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" @@ -7225,6 +8715,147 @@ dependencies = [ "tap", ] +[[package]] +name = "xet-client" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" +dependencies = [ + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "crc32fast", + "futures", + "http 1.4.2", + "hyper", + "more-asserts", + "rand 0.10.1", + "redb", + "reqwest 0.13.4", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio_with_wasm", + "tracing", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-core-structures" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "countio", + "futures", + "futures-util", + "getrandom 0.4.3", + "heapify", + "itertools 0.14.0", + "lz4_flex", + "more-asserts", + "rand 0.10.1", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] + +[[package]] +name = "xet-data" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "gearhash", + "http 1.4.2", + "itertools 0.14.0", + "more-asserts", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "url", + "uuid", + "web-time", + "xet-client", + "xet-core-structures", + "xet-runtime", +] + +[[package]] +name = "xet-runtime" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "tracing-appender", + "tracing-subscriber", + "web-time", + "whoami", + "winapi", +] + [[package]] name = "xmlparser" version = "0.13.6" @@ -7233,15 +8864,15 @@ checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7250,80 +8881,82 @@ 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", - "syn 2.0.114", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" 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", - "syn 2.0.114", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[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", "zerofrom", + "zerovec", ] [[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 = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -7331,26 +8964,20 @@ 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", - "syn 2.0.114", + "syn 2.0.118", ] -[[package]] -name = "zlib-rs" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" - [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/java/lance-jni/Cargo.toml b/java/lance-jni/Cargo.toml index 43624a70e5f..30560f76e25 100644 --- a/java/lance-jni/Cargo.toml +++ b/java/lance-jni/Cargo.toml @@ -1,34 +1,45 @@ [package] name = "lance-jni" -version = "3.0.0-beta.2" -edition = "2021" +version = "14.0.0-beta.6" +edition = "2024" authors = ["Lance Devs "] -rust-version = "1.88" +rust-version = "1.94" license = "Apache-2.0" repository = "https://github.com/lance-format/lance" readme = "../../README.md" description = "JNI bindings for Lance Columnar format" +# `java/lance-jni/` is excluded from the top-level workspace (see ../../Cargo.toml) and +# keeps its own Cargo.lock. Declaring an empty workspace here makes this crate an explicit +# workspace root, so cargo resolves it standalone even when the checkout is nested inside +# another cargo project (e.g. a git worktree placed under the main checkout). +[workspace] + [lib] crate-type = ["cdylib"] [features] default = [] +backtrace = ["lance/backtrace", "lance-core/backtrace"] [dependencies] -lance = { path = "../../rust/lance", features = ["substrait"] } +lance = { path = "../../rust/lance", features = ["substrait", "metrics"] } lance-datafusion = { path = "../../rust/lance-datafusion" } lance-encoding = { path = "../../rust/lance-encoding" } lance-linalg = { path = "../../rust/lance-linalg" } -lance-index = { path = "../../rust/lance-index" } -lance-io = { path = "../../rust/lance-io" } +lance-index = { path = "../../rust/lance-index", features = ["tokenizer-jieba", "tokenizer-lindera"] } +lance-io = { path = "../../rust/lance-io", features = ["metrics"] } lance-namespace = { path = "../../rust/lance-namespace" } -lance-namespace-impls = { path = "../../rust/lance-namespace-impls", features = ["rest", "rest-adapter"] } +lance-namespace-impls = { path = "../../rust/lance-namespace-impls", features = ["rest", "rest-adapter", "dir-goosefs"] } lance-core = { path = "../../rust/lance-core" } lance-file = { path = "../../rust/lance-file" } -arrow = { version = "57.1", features = ["ffi"] } -arrow-schema = "57.1" -object_store = { version = "0.12.2" } +lance-table = { path = "../../rust/lance-table" } +arrow = { version = "58.0.0", features = ["ffi"] } +arrow-array = "58.0.0" +arrow-schema = "58.0.0" +datafusion = { version = "54.0.0", default-features = false } +datafusion-common = "54.0.0" +object_store = { version = "0.13.2" } tokio = { version = "1.23", features = [ "rt-multi-thread", "macros", @@ -36,16 +47,17 @@ tokio = { version = "1.23", features = [ "sync", ] } async-trait = "0.1" -snafu = "0.8" jni = "0.21.1" serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1" } bytes = "1.11" log = "0.4" +metrics = "0.24" +metrics-util = { version = "0.19", default-features = false, features = ["registry"] } env_logger = "0.11.7" uuid = { version = "1.17.0", features = ["v4"] } prost = "0.14.1" -roaring = "0.10.1" +roaring = "0.11.4" prost-types = "0.14.1" chrono = "0.4.41" @@ -60,4 +72,10 @@ incremental = false debug = false debug-assertions = false strip = "debuginfo" -incremental = false \ No newline at end of file +incremental = false + +[profile.release] +strip = true + +[lints.clippy] +disallowed_macros = "deny" diff --git a/java/lance-jni/src/async_scanner.rs b/java/lance-jni/src/async_scanner.rs new file mode 100644 index 00000000000..6510ec63b8f --- /dev/null +++ b/java/lance-jni/src/async_scanner.rs @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +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 crate::{RT, block_on}; +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 + }); + + 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 + 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>, + index_segments_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + batch_size_bytes_obj: JObject<'local>, + io_buffer_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, + fragment_readahead_obj: JObject<'local>, + scan_in_order: jboolean, + late_materialization_obj: JObject<'local>, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + fast_search: jboolean, + substrait_aggregate_obj: JObject<'local>, + include_deleted_rows: jboolean, + strict_batch_size: jboolean, + disable_scoring_autoprojection: jboolean, +) -> JObject<'local> { + crate::ok_or_throw!( + env, + inner_create_async_scanner( + &mut env, + jdataset, + fragment_ids_obj, + index_segments_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + batch_size_bytes_obj, + io_buffer_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + fragment_readahead_obj, + scan_in_order, + late_materialization_obj, + column_orderings, + use_scalar_index, + fast_search, + substrait_aggregate_obj, + include_deleted_rows, + strict_batch_size, + disable_scoring_autoprojection, + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_create_async_scanner<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, + index_segments_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + batch_size_bytes_obj: JObject<'local>, + io_buffer_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, + fragment_readahead_obj: JObject<'local>, + scan_in_order: jboolean, + late_materialization_obj: JObject<'local>, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + fast_search: jboolean, + substrait_aggregate_obj: JObject<'local>, + include_deleted_rows: jboolean, + strict_batch_size: jboolean, + disable_scoring_autoprojection: jboolean, +) -> 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, + index_segments_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + batch_size_bytes_obj, + io_buffer_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + fragment_readahead_obj, + scan_in_order, + late_materialization_obj, + column_orderings, + use_scalar_index, + fast_search, + substrait_aggregate_obj, + include_deleted_rows, + strict_batch_size, + disable_scoring_autoprojection, + }; + + 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, +) { + 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 = 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_blob.rs b/java/lance-jni/src/blocking_blob.rs index 6d14fb82bb9..8f6f54ee53c 100755 --- a/java/lance-jni/src/blocking_blob.rs +++ b/java/lance-jni/src/blocking_blob.rs @@ -4,18 +4,23 @@ use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; use crate::error::Result; use crate::traits::{FromJString, IntoJava}; -use crate::{JNIEnvExt, RT}; -use jni::objects::{JByteArray, JObject, JString, JValueGen}; -use jni::sys::{jbyteArray, jint, jlong}; +use crate::{JNIEnvExt, block_on}; use jni::JNIEnv; +use jni::objects::{JByteArray, JObject, JString, JValueGen}; +use jni::sys::{jbyte, jbyteArray, jint, jlong}; use lance::dataset::BlobFile; -use std::mem::transmute; use std::sync::Arc; const BLOB_FILE_CLASS: &str = "org/lance/BlobFile"; const BLOB_FILE_CTOR_SIG: &str = "()V"; const NATIVE_BLOB: &str = "nativeBlobHandle"; +fn as_jbytes(bytes: &[u8]) -> &[jbyte] { + // SAFETY: jbyte is i8; u8 and i8 have identical size and alignment, and + // both permit every bit pattern. The returned slice retains the input lifetime. + unsafe { std::slice::from_raw_parts(bytes.as_ptr().cast(), bytes.len()) } +} + pub struct BlockingBlobFile { pub(crate) inner: BlobFile, } @@ -34,7 +39,7 @@ impl From for BlockingBlobFile { } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeTakeBlobs<'local>( mut env: JNIEnv<'local>, jdataset: JObject, @@ -59,23 +64,26 @@ fn inner_take_blobs<'local>( let blobs = { let dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; - RT.block_on(Arc::new(dataset.inner.clone()).take_blobs(&row_ids_u64, col_name))? + block_on(Arc::new(dataset.inner.clone()).take_blobs(&row_ids_u64, col_name))? }; let j_blobs = blobs .into_iter() - .map(BlockingBlobFile::from) - .collect::>(); + .map(|blob| blob.map(BlockingBlobFile::from)) + .collect::>(); transform_vec(env, j_blobs) } fn transform_vec<'local>( env: &mut JNIEnv<'local>, - vec: Vec, + vec: Vec>, ) -> Result> { let array_list_class = env.find_class("java/util/ArrayList")?; let array_list = env.new_object(array_list_class, "()V", &[])?; for blob_file in vec { - let blob_file_obj = blob_file.into_java(env)?; + let blob_file_obj = match blob_file { + Some(blob_file) => blob_file.into_java(env)?, + None => JObject::null(), + }; env.call_method( &array_list, "add", @@ -86,7 +94,7 @@ fn transform_vec<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeTakeBlobsByIndices<'local>( mut env: JNIEnv<'local>, jdataset: JObject, @@ -111,18 +119,16 @@ fn inner_take_blobs_by_indices<'local>( let blobs = { let dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; - RT.block_on( - Arc::new(dataset.inner.clone()).take_blobs_by_indices(&row_indices_u64, col_name), - )? + block_on(Arc::new(dataset.inner.clone()).take_blobs_by_indices(&row_indices_u64, col_name))? }; let j_blobs = blobs .into_iter() - .map(BlockingBlobFile::from) - .collect::>(); + .map(|blob| blob.map(BlockingBlobFile::from)) + .collect::>(); transform_vec(env, j_blobs) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeRead( mut env: JNIEnv, jblob: JObject, @@ -137,17 +143,14 @@ pub extern "system" fn Java_org_lance_BlobFile_nativeRead( fn inner_blob_read<'local>(env: &mut JNIEnv<'local>, jblob: JObject) -> Result> { let bytes = { let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; - RT.block_on(blob.inner.read())? + block_on(blob.inner.read())? }; 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)?; + env.set_byte_array_region(&arr, 0, as_jbytes(bytes.as_ref()))?; Ok(arr) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeReadUpTo<'local>( mut env: JNIEnv<'local>, jblob: JObject, @@ -167,17 +170,46 @@ fn inner_blob_read_up_to<'local>( ) -> Result> { let bytes = { let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; - RT.block_on(blob.inner.read_up_to(len as usize))? + block_on(blob.inner.read_up_to(len as usize))? }; 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, as_jbytes(bytes.as_ref()))?; + Ok(arr) +} - env.set_byte_array_region(&arr, 0, i8_slice)?; +#[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) }?; + block_on(blob.inner.read_range(offset as u64..end))? + }; + let arr = env.new_byte_array(bytes.len() as jint)?; + env.set_byte_array_region(&arr, 0, as_jbytes(bytes.as_ref()))?; Ok(arr) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeSeek( mut env: JNIEnv, jblob: JObject, @@ -188,11 +220,38 @@ pub extern "system" fn Java_org_lance_BlobFile_nativeSeek( fn inner_blob_seek(env: &mut JNIEnv, jblob: JObject, new_cursor: jlong) -> Result<()> { let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; - RT.block_on(blob.inner.seek(new_cursor as u64))?; + block_on(blob.inner.seek(new_cursor as u64))?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_BlobFile_nativeSetReadBufferSize( + mut env: JNIEnv, + jblob: JObject, + buffer_size: jlong, +) { + ok_or_throw_without_return!( + env, + inner_blob_set_read_buffer_size(&mut env, jblob, buffer_size) + ); +} + +fn inner_blob_set_read_buffer_size( + env: &mut JNIEnv, + jblob: JObject, + buffer_size: jlong, +) -> Result<()> { + if buffer_size < 0 { + return Err( + lance::Error::invalid_input("bufferSize must be non-negative".to_string()).into(), + ); + } + let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; + block_on(blob.inner.set_buffer_size(buffer_size as usize))?; Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeTell( mut env: JNIEnv, jblob: JObject, @@ -202,10 +261,10 @@ pub extern "system" fn Java_org_lance_BlobFile_nativeTell( fn inner_blob_tell(env: &mut JNIEnv, jblob: JObject) -> Result { let blob = unsafe { env.get_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB) }?; - Ok(RT.block_on(blob.inner.tell())?) + Ok(block_on(blob.inner.tell())?) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeSize( mut env: JNIEnv, jblob: JObject, @@ -218,13 +277,24 @@ fn inner_blob_size(env: &mut JNIEnv, jblob: JObject) -> Result { Ok(blob.inner.size()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_BlobFile_nativeClose(mut env: JNIEnv, jblob: JObject) { ok_or_throw_without_return!(env, inner_blob_close(&mut env, jblob)); } fn inner_blob_close(env: &mut JNIEnv, jblob: JObject) -> Result<()> { let blob = unsafe { env.take_rust_field::<_, _, BlockingBlobFile>(jblob, NATIVE_BLOB)? }; - RT.block_on(blob.inner.close())?; + block_on(blob.inner.close())?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::as_jbytes; + + #[test] + fn byte_slice_cast_preserves_bits() { + assert_eq!(as_jbytes(&[0, 127, 128, 255]), &[0, 127, -128, -1]); + assert!(as_jbytes(&[]).is_empty()); + } +} diff --git a/java/lance-jni/src/blocking_dataset.rs b/java/lance-jni/src/blocking_dataset.rs index 354141d69a4..4f7a3f37e1f 100644 --- a/java/lance-jni/src/blocking_dataset.rs +++ b/java/lance-jni/src/blocking_dataset.rs @@ -3,13 +3,18 @@ use crate::error::{Error, Result}; use crate::ffi::JNIEnvExt; -use crate::storage_options::JavaStorageOptionsProvider; -use crate::traits::{export_vec, import_vec, FromJObjectWithEnv, FromJString}; +use crate::index_progress::JavaIndexBuildProgress; +use crate::namespace::{ + BlockingDirectoryNamespace, BlockingRestNamespace, create_java_lance_namespace, +}; +use crate::session::{handle_from_session, session_from_handle}; +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, + build_compaction_options, extract_base_store_params, extract_storage_options, + extract_write_params, get_scalar_index_params, get_vector_index_params, to_java_map, + to_rust_map, }; -use crate::{traits::IntoJava, RT}; +use crate::{block_on, traits::IntoJava}; use arrow::array::RecordBatchReader; use arrow::datatypes::Schema; use arrow::ffi::FFI_ArrowSchema; @@ -17,16 +22,18 @@ use arrow::ffi_stream::ArrowArrayStreamReader; use arrow::ffi_stream::FFI_ArrowArrayStream; use arrow::ipc::writer::StreamWriter; use arrow::record_batch::RecordBatchIterator; -use arrow_schema::DataType; use arrow_schema::Schema as ArrowSchema; use chrono::{DateTime, Utc}; use jni::objects::{JMap, JString, JValue}; use jni::sys::{jboolean, jint}; use jni::sys::{jbyteArray, jlong}; -use jni::{objects::JObject, JNIEnv}; +use jni::{JNIEnv, objects::JObject}; use lance::dataset::builder::DatasetBuilder; -use lance::dataset::cleanup::{CleanupPolicy, RemovalStats}; -use lance::dataset::optimize::{compact_files, CompactionOptions as RustCompactionOptions}; +use lance::dataset::cleanup::{ + CleanupCandidateFile, CleanupExplanation, CleanupFileKind, CleanupPolicy, + CleanupReferencedBranch, RemovalStats, +}; +use lance::dataset::optimize::{CompactionOptions as RustCompactionOptions, compact_files}; use lance::dataset::refs::{Ref, TagContents}; use lance::dataset::statistics::{DataStatistics, DatasetStatisticsExt}; use lance::dataset::transaction::{Operation, Transaction}; @@ -34,29 +41,39 @@ use lance::dataset::{ ColumnAlteration, CommitBuilder, Dataset, NewColumnTransform, ProjectionRequest, ReadParams, Version, WriteParams, }; +use lance::index::{DatasetIndexExt, IndexSegment, IntoIndexSegment}; +use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; use lance::io::{ObjectStore, ObjectStoreParams}; +use lance::session::Session as LanceSession; use lance::table::format::IndexMetadata; -use lance::table::format::{BasePath, Fragment}; +use lance::table::format::{BasePath, Fragment, WriterVersion}; use lance_core::datatypes::Schema as LanceSchema; -use lance_index::optimize::OptimizeOptions; -use lance_index::scalar::btree::BTreeParameters; -use lance_index::DatasetIndexExt; +use lance_file::version::LanceFileVersion; use lance_index::IndexCriteria as RustIndexCriteria; +use lance_index::optimize::OptimizeOptions; +use lance_index::progress::{IndexBuildProgress, noop_progress}; use lance_index::{IndexParams, IndexType}; use lance_io::object_store::ObjectStoreRegistry; -use lance_io::object_store::StorageOptionsProvider; -use std::collections::HashMap; +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; +use lance_table::io::commit::{ManifestLocation, ManifestNamingScheme}; +use std::collections::{HashMap, HashSet}; use std::future::IntoFuture; use std::iter::empty; -use std::str::FromStr; use std::sync::Arc; use std::time::{Duration, UNIX_EPOCH}; +use uuid::Uuid; pub const NATIVE_DATASET: &str = "nativeDatasetHandle"; impl FromJObjectWithEnv for JObject<'_> { fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { - let id = env.get_u32_from_method(self, "getId")?; + let java_id = env.call_method(self, "getId", "()I", &[])?.i()?; + let id = u32::try_from(java_id).map_err(|_| { + Error::input_error(format!("BasePath.id must be non-negative, got {java_id}")) + })?; let name = env.get_optional_string_from_method(self, "getName")?; let path = env.get_string_from_method(self, "getPath")?; let is_dataset_root = env.get_boolean_from_method(self, "isDatasetRoot")?; @@ -69,6 +86,37 @@ impl FromJObjectWithEnv for JObject<'_> { } } +impl IntoJava for &BasePath { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let id = i32::try_from(self.id).map_err(|_| { + Error::runtime_error(format!("Base path id {} exceeds Java int range", self.id)) + })?; + let name = match self.name.as_ref() { + Some(name) => env.new_string(name)?.into(), + None => JObject::null(), + }; + let name = env + .call_static_method( + "java/util/Optional", + "ofNullable", + "(Ljava/lang/Object;)Ljava/util/Optional;", + &[JValue::Object(&name)], + )? + .l()?; + let path = env.new_string(&self.path)?; + Ok(env.new_object( + "org/lance/BasePath", + "(ILjava/util/Optional;Ljava/lang/String;Z)V", + &[ + JValue::Int(id), + JValue::Object(&name), + JValue::Object(&path), + JValue::Bool(self.is_dataset_root.into()), + ], + )?) + } +} + #[derive(Clone)] pub struct BlockingDataset { pub(crate) inner: Dataset, @@ -89,13 +137,13 @@ impl BlockingDataset { /// If a storage options provider was configured and credentials are expiring, /// this will refresh them. pub fn latest_storage_options(&self) -> Result>> { - RT.block_on(async { self.inner.latest_storage_options().await }) + block_on(async { self.inner.latest_storage_options().await }) .map(|opt| opt.map(|opts| opts.0)) .map_err(|e| Error::io_error(e.to_string())) } pub fn drop(uri: &str, storage_options: HashMap) -> Result<()> { - RT.block_on(async move { + block_on(async move { let registry = Arc::new(ObjectStoreRegistry::default()); let object_store_params = ObjectStoreParams { storage_options_accessor: Some(Arc::new( @@ -107,31 +155,67 @@ impl BlockingDataset { ObjectStore::from_uri_and_params(registry, uri, &object_store_params) .await .map_err(|e| Error::io_error(e.to_string()))?; + lance::dataset::validate_dataset_root_for_drop(&object_store, &path) + .await + .map_err(|e| Error::input_error(e.to_string()))?; object_store .remove_dir_all(path) .await .map_err(|e| Error::io_error(e.to_string())) }) } + + pub fn list_manifest_locations( + uri: &str, + storage_options: HashMap, + ) -> Result> { + let accessor = (!storage_options.is_empty()).then(|| { + Arc::new(lance::io::StorageOptionsAccessor::with_static_options( + storage_options, + )) + }); + let params = ReadParams { + store_options: Some(ObjectStoreParams { + storage_options_accessor: accessor, + ..Default::default() + }), + ..Default::default() + }; + Ok(block_on( + DatasetBuilder::from_uri(uri) + .with_read_params(params) + .list_manifest_locations(), + )?) + } + pub fn write( reader: impl RecordBatchReader + Send + 'static, uri: &str, params: Option, ) -> Result { - let inner = RT.block_on(Dataset::write(reader, uri, params))?; + let inner = block_on(Dataset::write(reader, uri, params))?; Ok(Self { inner }) } + pub fn new(dataset: Dataset) -> Self { + Self { inner: dataset } + } + #[allow(clippy::too_many_arguments)] pub fn open( uri: &str, - version: Option, + reference: Option, block_size: Option, index_cache_size_bytes: i64, metadata_cache_size_bytes: i64, storage_options: HashMap, + base_store_params: HashMap, serialized_manifest: Option<&[u8]>, storage_options_provider: Option>, + 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) { @@ -159,20 +243,42 @@ impl BlockingDataset { index_cache_size_bytes: index_cache_size_bytes as usize, metadata_cache_size_bytes: metadata_cache_size_bytes as usize, store_options: Some(store_params), + session, ..Default::default() }; let mut builder = DatasetBuilder::from_uri(uri).with_read_params(params); - if let Some(ver) = version { - builder = builder.with_version(ver); + for (base_path, store_params) in base_store_params { + builder = builder.with_base_store_params(base_path, store_params); } + builder = match reference { + Some(Ref::VersionNumber(version)) | Some(Ref::Version(None, Some(version))) => { + builder.with_version(version) + } + Some(Ref::Version(Some(branch), version)) => builder.with_branch(&branch, version), + Some(Ref::Tag(tag)) => builder.with_tag(&tag), + Some(Ref::Version(None, None)) | None => builder, + }; + if let Some(serialized_manifest) = serialized_manifest { builder = builder.with_serialized_manifest(serialized_manifest)?; } - let inner = RT.block_on(builder.load())?; + // 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::for_table_uri(namespace_client, tid, uri)?; + let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + builder = builder.with_commit_handler(commit_handler); + } + + let inner = block_on(builder.load())?; Ok(Self { inner }) } @@ -189,7 +295,7 @@ impl BlockingDataset { lance::io::StorageOptionsAccessor::with_static_options(storage_options), )) }; - let inner = RT.block_on(Dataset::commit( + let inner = block_on(Dataset::commit( uri, operation, read_version, @@ -205,51 +311,55 @@ impl BlockingDataset { } pub fn latest_version(&self) -> Result { - let version = RT.block_on(self.inner.latest_version_id())?; + let version = block_on(self.inner.latest_version_id())?; Ok(version) } pub fn list_versions(&self) -> Result> { - let versions = RT.block_on(self.inner.versions())?; + let versions = block_on(self.inner.versions())?; Ok(versions) } + pub fn count_versions(&self) -> Result { + Ok(block_on(self.inner.count_versions())?) + } + pub fn version(&self) -> Result { Ok(self.inner.version()) } pub fn checkout_version(&mut self, version: u64) -> Result { - let inner = RT.block_on(self.inner.checkout_version(version))?; + let inner = block_on(self.inner.checkout_version(version))?; Ok(Self { inner }) } pub fn checkout_tag(&mut self, tag: &str) -> Result { - let inner = RT.block_on(self.inner.checkout_version(tag))?; + let inner = block_on(self.inner.checkout_version(tag))?; Ok(Self { inner }) } pub fn checkout_latest(&mut self) -> Result<()> { - RT.block_on(self.inner.checkout_latest())?; + block_on(self.inner.checkout_latest())?; Ok(()) } pub fn restore(&mut self) -> Result<()> { - RT.block_on(self.inner.restore())?; + block_on(self.inner.restore())?; Ok(()) } pub fn list_tags(&self) -> Result> { - let tags = RT.block_on(self.inner.tags().list())?; + let tags = block_on(self.inner.tags().list())?; Ok(tags) } pub fn list_branches(&self) -> Result> { - let branches = RT.block_on(self.inner.list_branches())?; + let branches = block_on(self.inner.branches().list())?; Ok(branches) } pub fn delete_branch(&mut self, branch: &str) -> Result<()> { - RT.block_on(self.inner.delete_branch(branch))?; + block_on(self.inner.branches().delete(branch, true))?; Ok(()) } @@ -264,64 +374,103 @@ impl BlockingDataset { } else { Ref::Version(branch, version) }; - let inner = RT.block_on(self.inner.checkout_version(reference))?; + let inner = block_on(self.inner.checkout_version(reference))?; Ok(Self { inner }) } pub fn create_tag(&mut self, tag: &str, reference: Ref) -> Result<()> { - RT.block_on(self.inner.tags().create(tag, reference))?; + block_on(self.inner.tags().create(tag, reference))?; Ok(()) } pub fn delete_tag(&mut self, tag: &str) -> Result<()> { - RT.block_on(self.inner.tags().delete(tag))?; + block_on(self.inner.tags().delete(tag))?; Ok(()) } pub fn update_tag(&mut self, tag: &str, reference: Ref) -> Result<()> { - RT.block_on(self.inner.tags().update(tag, reference))?; + block_on(self.inner.tags().update(tag, reference))?; + Ok(()) + } + + pub fn replace_tag_metadata( + &mut self, + tag: &str, + metadata: HashMap, + ) -> Result<()> { + block_on(self.inner.tags().replace_metadata(tag, metadata))?; + Ok(()) + } + + pub fn replace_branch_metadata( + &mut self, + branch: &str, + metadata: HashMap, + ) -> Result<()> { + block_on(self.inner.branches().replace_metadata(branch, metadata))?; Ok(()) } pub fn get_version(&self, tag: &str) -> Result { - let version = RT.block_on(self.inner.tags().get_version(tag))?; + let version = block_on(self.inner.tags().get_version(tag))?; Ok(version) } pub fn count_rows(&self, filter: Option) -> Result { - let rows = RT.block_on(self.inner.count_rows(filter))?; + let rows = block_on(self.inner.count_rows(filter))?; Ok(rows) } pub fn calculate_data_stats(&self) -> Result { - let stats = RT.block_on(Arc::new(self.clone().inner).calculate_data_stats())?; + let stats = block_on(Arc::new(self.clone().inner).calculate_data_stats())?; Ok(stats) } pub fn list_indexes(&self) -> Result>> { - let indexes = RT.block_on(self.inner.load_indices())?; + let indexes = block_on(self.inner.load_indices())?; Ok(indexes) } + #[allow(clippy::too_many_arguments)] pub fn commit_transaction( &mut self, transaction: Transaction, store_params: ObjectStoreParams, detached: bool, enable_v2_manifest_paths: bool, + use_stable_row_ids: Option, + storage_format: Option, + max_retries: u32, + skip_auto_cleanup: bool, + commit_handler: Option>, + commit_timeout: Option, ) -> Result { - let new_dataset = RT.block_on( - CommitBuilder::new(Arc::new(self.clone().inner)) - .with_store_params(store_params) - .with_detached(detached) - .enable_v2_manifest_paths(enable_v2_manifest_paths) - .execute(transaction), - )?; + let mut builder = CommitBuilder::new(Arc::new(self.clone().inner)) + .with_store_params(store_params) + .with_detached(detached) + .enable_v2_manifest_paths(enable_v2_manifest_paths) + .with_timeout(commit_timeout); + if let Some(use_stable) = use_stable_row_ids { + builder = builder.use_stable_row_ids(use_stable); + } + if let Some(format) = storage_format { + builder = builder.with_storage_format(format); + } + if max_retries > 0 { + builder = builder.with_max_retries(max_retries); + } + 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 = block_on(builder.execute(transaction))?; Ok(BlockingDataset { inner: new_dataset }) } pub fn read_transaction(&self) -> Result> { - let transaction = RT.block_on(self.inner.read_transaction())?; + let transaction = block_on(self.inner.read_transaction())?; Ok(transaction) } @@ -330,12 +479,24 @@ impl BlockingDataset { } pub fn compact(&mut self, options: RustCompactionOptions) -> Result<()> { - RT.block_on(compact_files(&mut self.inner, options, None))?; + block_on(compact_files(&mut self.inner, options, None))?; Ok(()) } pub fn cleanup_with_policy(&mut self, policy: CleanupPolicy) -> Result { - Ok(RT.block_on(self.inner.cleanup_with_policy(policy))?) + Ok(block_on(self.inner.cleanup_with_policy(policy))?) + } + + pub fn explain_cleanup_with_policy( + &self, + policy: CleanupPolicy, + max_candidate_files: Option, + ) -> Result { + let mut op = self.inner.cleanup(policy); + if let Some(limit) = max_candidate_files { + op = op.with_max_candidate_files(limit); + } + Ok(block_on(op.explain())?) } pub fn close(&self) {} @@ -344,7 +505,7 @@ impl BlockingDataset { /////////////////// // Write Methods // /////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_createWithFfiSchema<'local>( mut env: JNIEnv<'local>, _obj: JObject, @@ -358,8 +519,12 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiSchema<'local>( data_storage_version: JObject, // Optional enable_v2_manifest_paths: JObject, // Optional storage_options_obj: JObject, // Map + base_store_params_obj: JObject, // Map> initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions ) -> JObject<'local> { ok_or_throw!( env, @@ -375,8 +540,12 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiSchema<'local>( data_storage_version, enable_v2_manifest_paths, storage_options_obj, + base_store_params_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, ) ) } @@ -394,8 +563,12 @@ fn inner_create_with_ffi_schema<'local>( data_storage_version: JObject, // Optional enable_v2_manifest_paths: JObject, // Optional storage_options_obj: JObject, // Map + base_store_params_obj: JObject, // Map> initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions ) -> Result> { let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; let c_schema = unsafe { FFI_ArrowSchema::from_raw(c_schema_ptr) }; @@ -413,14 +586,19 @@ 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 + base_store_params_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, reader, + None, // No namespace for schema-only creation + false, // No managed versioning for schema-only creation ) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_drop<'local>( mut env: JNIEnv<'local>, _obj: JObject, @@ -434,7 +612,7 @@ pub extern "system" fn Java_org_lance_Dataset_drop<'local>( JObject::null() } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeMigrateManifestPathsV2( mut env: JNIEnv, java_dataset: JObject, @@ -448,65 +626,34 @@ pub extern "system" fn Java_org_lance_Dataset_nativeMigrateManifestPathsV2( fn inner_native_migrate_manifest_paths_v2(env: &mut JNIEnv, java_dataset: JObject) -> Result<()> { let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.migrate_manifest_paths_v2())?; + block_on(dataset_guard.inner.migrate_manifest_paths_v2())?; Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] 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, - enable_v2_manifest_paths, - data_storage_version, - storage_options_obj, - JObject::null(), - initial_bases, - target_bases - ) - ) -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_Dataset_createWithFfiStreamAndProvider<'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> + 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 + base_store_params_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 + file_write_options: JObject, // FileWriteOptions + 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, @@ -522,9 +669,15 @@ pub extern "system" fn Java_org_lance_Dataset_createWithFfiStreamAndProvider<'lo data_storage_version, enable_v2_manifest_paths, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, initial_bases, - target_bases + target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, + namespace_obj, + table_id_obj, + namespace_client_managed_versioning != 0, ) ) } @@ -534,20 +687,30 @@ 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> + 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 + base_store_params_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 + file_write_options: JObject, // FileWriteOptions + 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) }?; + + // Create the namespace wrapper for commit handling (if provided) + let namespace_info = extract_namespace_info(env, &namespace_obj, &table_id_obj)?; + create_dataset( env, path, @@ -559,13 +722,23 @@ fn inner_create_with_ffi_stream<'local>( data_storage_version, enable_v2_manifest_paths, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, initial_bases, target_bases, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, 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>, @@ -578,14 +751,19 @@ fn create_dataset<'local>( data_storage_version: JObject, enable_v2_manifest_paths: JObject, storage_options_obj: JObject, - storage_options_provider_obj: JObject, // Optional + base_store_params_obj: JObject, initial_bases: JObject, target_bases: JObject, + allow_external_blob_outside_bases: JObject, + blob_pack_file_size_threshold: JObject, + file_write_options: JObject, reader: impl RecordBatchReader + Send + 'static, + namespace_info: Option<(Arc, Vec)>, + namespace_client_managed_versioning: bool, ) -> Result> { let path_str = path.extract(env)?; - let write_params = extract_write_params( + let mut write_params = extract_write_params( env, &max_rows_per_file, &max_rows_per_group, @@ -595,11 +773,54 @@ fn create_dataset<'local>( &data_storage_version, Some(&enable_v2_manifest_paths), &storage_options_obj, - &storage_options_provider_obj, + &base_store_params_obj, &initial_bases, &target_bases, + &allow_external_blob_outside_bases, + &blob_pack_file_size_threshold, + &file_write_options, )?; + // Set up namespace commit handler and storage options provider if namespace is provided + if let Some((namespace, table_id)) = namespace_info { + // Set up commit handler only if namespace manages versioning + if namespace_client_managed_versioning { + let external_store = LanceNamespaceExternalManifestStore::for_table_uri( + namespace.clone(), + table_id.clone(), + &path_str, + )?; + 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() + }); + } + let dataset = BlockingDataset::write(reader, &path_str, Some(write_params))?; dataset.into_java(env) } @@ -645,6 +866,60 @@ impl IntoJava for Version { } } +impl IntoJava for WriterVersion { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let library = env.new_string(self.library)?; + let version = env.new_string(self.version)?; + let prerelease = match self.prerelease { + Some(value) => JObject::from(env.new_string(value)?), + None => JObject::null(), + }; + let build_metadata = match self.build_metadata { + Some(value) => JObject::from(env.new_string(value)?), + None => JObject::null(), + }; + + Ok(env.new_object( + "org/lance/WriterVersion", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&library), + JValue::Object(&version), + JValue::Object(&prerelease), + JValue::Object(&build_metadata), + ], + )?) + } +} + +impl IntoJava for ManifestLocation { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let size = self.size.ok_or_else(|| { + Error::runtime_error(format!("Manifest size is unavailable for {}", self.path)) + })?; + let path = env.new_string(self.path.to_string())?; + let naming_scheme = env.new_string(match self.naming_scheme { + ManifestNamingScheme::V1 => "V1", + ManifestNamingScheme::V2 => "V2", + })?; + let e_tag = match self.e_tag { + Some(value) => JObject::from(env.new_string(value)?), + None => JObject::null(), + }; + Ok(env.new_object( + "org/lance/ManifestLocation", + "(JLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Long(self.version as i64), + JValue::Object(&path), + JValue::Long(size as i64), + JValue::Object(&naming_scheme), + JValue::Object(&e_tag), + ], + )?) + } +} + fn attach_native_dataset<'local>( env: &mut JNIEnv<'local>, dataset: BlockingDataset, @@ -668,7 +943,7 @@ fn create_java_dataset_object<'a>(env: &mut JNIEnv<'a>) -> Result> { Ok(object) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_commitAppend<'local>( mut env: JNIEnv<'local>, _obj: JObject, @@ -709,7 +984,7 @@ pub fn inner_commit_append<'local>( dataset.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_commitOverwrite<'local>( mut env: JNIEnv<'local>, _obj: JObject, @@ -764,7 +1039,7 @@ pub fn inner_commit_overwrite<'local>( dataset.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_releaseNativeDataset(mut env: JNIEnv, obj: JObject) { ok_or_throw_without_return!(env, inner_release_native_dataset(&mut env, obj)) } @@ -775,7 +1050,7 @@ fn inner_release_native_dataset(env: &mut JNIEnv, obj: JObject) -> Result<()> { Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCreateIndex<'local>( mut env: JNIEnv<'local>, java_dataset: JObject<'local>, @@ -803,10 +1078,77 @@ pub extern "system" fn Java_org_lance_Dataset_nativeCreateIndex<'local>( fragments_jobj, index_uuid_jobj, arrow_stream_addr_jobj, + None, + ) + ) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeCreateIndexWithProgress<'local>( + mut env: JNIEnv<'local>, + java_dataset: JObject<'local>, + columns_jobj: JObject<'local>, // List + index_type_code_jobj: jint, + name_jobj: JObject<'local>, // Optional + params_jobj: JObject<'local>, // IndexParams + replace_jobj: jboolean, // replace + train_jobj: jboolean, // train + fragments_jobj: JObject<'local>, // List + index_uuid_jobj: JObject<'local>, // String + arrow_stream_addr_jobj: JObject<'local>, // Optional + progress_jobj: JObject<'local>, // IndexBuildProgress +) -> JObject<'local> { + ok_or_throw!( + env, + inner_create_index_with_progress( + &mut env, + java_dataset, + columns_jobj, + index_type_code_jobj, + name_jobj, + params_jobj, + replace_jobj, + train_jobj, + fragments_jobj, + index_uuid_jobj, + arrow_stream_addr_jobj, + progress_jobj, ) ) } +#[allow(clippy::too_many_arguments)] +fn inner_create_index_with_progress<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject<'local>, + columns_jobj: JObject<'local>, + index_type_code_jobj: jint, + name_jobj: JObject<'local>, + params_jobj: JObject<'local>, + replace_jobj: jboolean, + train_jobj: jboolean, + fragments_jobj: JObject<'local>, + index_uuid_jobj: JObject<'local>, + arrow_stream_addr_jobj: JObject<'local>, + progress_jobj: JObject<'local>, +) -> Result> { + let progress = Arc::new(JavaIndexBuildProgress::new(env, &progress_jobj)?); + inner_create_index( + env, + java_dataset, + columns_jobj, + index_type_code_jobj, + name_jobj, + params_jobj, + replace_jobj, + train_jobj, + fragments_jobj, + index_uuid_jobj, + arrow_stream_addr_jobj, + Some(progress), + ) +} + #[allow(clippy::too_many_arguments)] fn inner_create_index<'local>( env: &mut JNIEnv<'local>, @@ -820,6 +1162,7 @@ fn inner_create_index<'local>( fragments_jobj: JObject<'local>, // Optional> index_uuid_jobj: JObject<'local>, // Optional arrow_stream_addr_jobj: JObject<'local>, // Optional + progress: Option>, ) -> Result> { let columns = env.get_strings(&columns_jobj)?; let index_type = IndexType::try_from(index_type_code_jobj)?; @@ -830,7 +1173,13 @@ fn inner_create_index<'local>( let fragment_ids = env .get_ints_opt(&fragments_jobj)? .map(|vec| vec.into_iter().map(|i| i as u32).collect()); - let index_uuid = env.get_string_opt(&index_uuid_jobj)?; + let index_uuid = env + .get_string_opt(&index_uuid_jobj)? + .map(|s| { + Uuid::parse_str(&s) + .map_err(|e| Error::input_error(format!("Invalid UUID string for index_uuid: {e}"))) + }) + .transpose()?; let arrow_stream_addr_opt = env.get_long_opt(&arrow_stream_addr_jobj)?; let batch_reader = if let Some(arrow_stream_addr) = arrow_stream_addr_opt { let stream_ptr = arrow_stream_addr as *mut FFI_ArrowArrayStream; @@ -853,14 +1202,16 @@ fn inner_create_index<'local>( | IndexType::NGram | IndexType::ZoneMap | IndexType::BloomFilter - | IndexType::RTree => { + | IndexType::Fm + | IndexType::RTree + | IndexType::MinHashLsh => { // For scalar indices, create a scalar IndexParams let (index_type_str, params_opt) = get_scalar_index_params(env, params_jobj)?; let scalar_params = lance_index::scalar::ScalarIndexParams { index_type: index_type_str, params: params_opt.clone(), }; - skip_commit = skip_commit || should_skip_commit(index_type, ¶ms_opt)?; + skip_commit = skip_commit || (index_type == IndexType::BTree && batch_reader.is_some()); Ok(Box::new(scalar_params)) } IndexType::FragmentReuse | IndexType::MemWal => { @@ -885,59 +1236,163 @@ fn inner_create_index<'local>( let params = params_result?; - // Execute index creation in a block to ensure dataset_guard is dropped - // before we call into_java (which needs to borrow env again) - let index_metadata = { - let mut dataset_guard = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + let index_metadata = match progress { + Some(progress) => { + // Progress callbacks may re-enter read-only methods on this Dataset. Clone the Dataset + // and release the native field guard before the long-running build so those callbacks + // do not deadlock on the field mutex. + let mut working_dataset = { + let dataset_guard = unsafe { + env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) + }?; + dataset_guard.inner.clone() + }; + let initial_version = working_dataset.version().version; + let index_metadata = execute_create_index( + &mut working_dataset, + &columns_slice, + index_type, + params.as_ref(), + replace, + train, + name, + fragment_ids, + index_uuid, + batch_reader, + Some(progress), + skip_commit, + ) + .inspect_err(|_| { + // A committed build may fail while materializing its return metadata. The clone + // has already observed the durable commit, so publish it before propagating. + if should_publish_working_dataset_on_error( + skip_commit, + initial_version, + working_dataset.version().version, + ) { + match unsafe { + env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) + } { + Ok(mut dataset_guard) => { + dataset_guard.inner = working_dataset.clone(); + } + Err(publish_error) => { + log::warn!( + "Failed to publish committed dataset version {} after create-index failure: {}", + working_dataset.version().version, + publish_error + ); + } + } + } + })?; + + if !skip_commit { + let mut dataset_guard = unsafe { + env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) + }?; + dataset_guard.inner = working_dataset; + } + index_metadata + } + None => { + // Preserve the existing no-progress path, including its native-field locking behavior. + let mut dataset_guard = unsafe { + env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) + }?; + execute_create_index( + &mut dataset_guard.inner, + &columns_slice, + index_type, + params.as_ref(), + replace, + train, + name, + fragment_ids, + index_uuid, + batch_reader, + None, + skip_commit, + )? + } + }; - let mut index_builder = dataset_guard - .inner - .create_index_builder(&columns_slice, index_type, params.as_ref()) - .replace(replace) - .train(train); + (&index_metadata).into_java(env) +} - if let Some(name) = name { - index_builder = index_builder.name(name); - } +fn should_publish_working_dataset_on_error( + skip_commit: bool, + initial_version: u64, + current_version: u64, +) -> bool { + !skip_commit && current_version != initial_version +} - if let Some(fragment_ids) = fragment_ids { - index_builder = index_builder.fragments(fragment_ids); - } +#[allow(clippy::too_many_arguments)] +fn execute_create_index( + dataset: &mut Dataset, + columns: &[&str], + index_type: IndexType, + params: &dyn IndexParams, + replace: bool, + train: bool, + name: Option, + fragment_ids: Option>, + index_uuid: Option, + batch_reader: Option, + progress: Option>, + skip_commit: bool, +) -> Result { + let mut index_builder = dataset + .create_index_builder(columns, index_type, params) + .replace(replace) + .train(train); + + if let Some(name) = name { + index_builder = index_builder.name(name); + } - if let Some(index_uuid) = index_uuid { - index_builder = index_builder.index_uuid(index_uuid); - } + if let Some(fragment_ids) = fragment_ids { + index_builder = index_builder.fragments(fragment_ids); + } - if let Some(reader) = batch_reader { - index_builder = index_builder.preprocessed_data(Box::new(reader)); - } + if let Some(index_uuid) = index_uuid { + index_builder = index_builder.index_uuid(index_uuid); + } - if skip_commit { - RT.block_on(index_builder.execute_uncommitted())? - } else { - RT.block_on(index_builder.into_future())? - } - }; + if let Some(reader) = batch_reader { + index_builder = index_builder.preprocessed_data(Box::new(reader)); + } - (&index_metadata).into_java(env) -} + if let Some(progress) = progress { + index_builder = index_builder.progress(progress); + } -fn should_skip_commit(index_type: IndexType, params_opt: &Option) -> Result { - match index_type { - IndexType::BTree => { - // Should defer the commit if we are building range-based BTree index - if let Some(params) = params_opt { - let btree_parameters = serde_json::from_str::(params)?; - return Ok(btree_parameters.range_id.is_some()); - } - Ok(false) - } - _ => Ok(false), + if skip_commit { + Ok(block_on(index_builder.execute_uncommitted())?) + } else { + Ok(block_on(index_builder.into_future())?) } } -#[no_mangle] +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeDropIndex( + mut env: JNIEnv, + java_dataset: JObject, + name: JString, +) { + ok_or_throw_without_return!(env, inner_drop_index(&mut env, java_dataset, name)) +} + +fn inner_drop_index(env: &mut JNIEnv, java_dataset: JObject, name: JString) -> Result<()> { + let name = name.extract(env)?; + let mut dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + block_on(dataset_guard.inner.drop_index(&name))?; + Ok(()) +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_innerMergeIndexMetadata<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -964,57 +1419,215 @@ fn inner_merge_index_metadata( index_type_code_jobj: jint, batch_readhead_jobj: JObject, // Optional ) -> Result<()> { - let index_uuid = index_uuid.extract(env)?; - let index_type = IndexType::try_from(index_type_code_jobj)?; - let batch_readhead = env - .get_int_opt(&batch_readhead_jobj)? - .map(|val| val as usize); + let (index_uuid, index_type, batch_readhead) = parse_merge_index_metadata_args( + env, + index_uuid, + index_type_code_jobj, + batch_readhead_jobj, + )?; - let dataset_guard = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + // Clone the inner Dataset out of the `get_rust_field` guard and drop the + // guard before the long-lived merge. Otherwise nested JNI callbacks that + // touch the same Dataset would deadlock on the native field mutex. + let inner_dataset = unsafe { + let dataset_guard = + env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET)?; + dataset_guard.inner.clone() + }; - RT.block_on(async { - dataset_guard - .inner - .merge_index_metadata(&index_uuid, index_type, batch_readhead) + block_on(async { + inner_dataset + .merge_index_metadata(&index_uuid, index_type, batch_readhead, noop_progress()) .await })?; Ok(()) } -#[no_mangle] -pub extern "system" fn Java_org_lance_Dataset_nativeOptimizeIndices( - mut env: JNIEnv, +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_innerMergeIndexMetadataWithProgress<'local>( + mut env: JNIEnv<'local>, java_dataset: JObject, - options_obj: JObject, // OptimizeOptions + index_uuid: JString, + index_type_code_jobj: jint, + batch_readhead_jobj: JObject, + progress_jobj: JObject, ) { ok_or_throw_without_return!( env, - inner_optimize_indices(&mut env, java_dataset, options_obj) + inner_merge_index_metadata_with_progress( + &mut env, + java_dataset, + index_uuid, + index_type_code_jobj, + batch_readhead_jobj, + progress_jobj, + ) ); } -fn inner_optimize_indices( +fn inner_merge_index_metadata_with_progress( env: &mut JNIEnv, java_dataset: JObject, - java_options: JObject, // OptimizeOptions + index_uuid: JString, + index_type_code_jobj: jint, + batch_readhead_jobj: JObject, + progress_jobj: JObject, ) -> Result<()> { - let mut options = OptimizeOptions::default(); + let (index_uuid, index_type, batch_readhead) = parse_merge_index_metadata_args( + env, + index_uuid, + index_type_code_jobj, + batch_readhead_jobj, + )?; + let progress = Arc::new(JavaIndexBuildProgress::new(env, &progress_jobj)?); - if !java_options.is_null() { - options.num_indices_to_merge = - env.get_optional_usize_from_method(&java_options, "getNumIndicesToMerge")?; + // Clone the inner Dataset out of the `get_rust_field` guard and drop the + // guard before the long-lived merge. Progress callbacks are allowed to + // re-enter Dataset JNI methods; holding the guard across those callbacks + // would deadlock on the native field mutex (see update.rs). + let inner_dataset = unsafe { + let dataset_guard = + env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET)?; + dataset_guard.inner.clone() + }; - // getIndexNames(): Optional> - let index_names_obj = env - .call_method( - &java_options, - "getIndexNames", - "()Ljava/util/Optional;", - &[], - )? - .l()?; - let index_names = env.get_strings_opt(&index_names_obj)?; + block_on(async { + inner_dataset + .merge_index_metadata(&index_uuid, index_type, batch_readhead, progress) + .await + })?; + Ok(()) +} + +fn parse_merge_index_metadata_args( + env: &mut JNIEnv, + index_uuid: JString, + index_type_code_jobj: jint, + batch_readhead_jobj: JObject, +) -> Result<(Uuid, IndexType, Option)> { + let index_uuid_str = index_uuid.extract(env)?; + let index_uuid = Uuid::parse_str(&index_uuid_str) + .map_err(|e| Error::input_error(format!("Invalid UUID string for index_uuid: {e}")))?; + let index_type = IndexType::try_from(index_type_code_jobj)?; + let batch_readhead = env + .get_int_opt(&batch_readhead_jobj)? + .map(|val| val as usize); + Ok((index_uuid, index_type, batch_readhead)) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeMergeExistingIndexSegments<'local>( + mut env: JNIEnv<'local>, + java_dataset: JObject, + java_segments: JObject, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_merge_existing_index_segments(&mut env, java_dataset, java_segments) + ) +} + +fn inner_merge_existing_index_segments<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, + java_segments: JObject, +) -> Result> { + let segments = import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; + let merged_segment = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + block_on(dataset_guard.inner.merge_existing_index_segments(segments))? + }; + (&merged_segment).into_java(env) +} + +#[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) }?; + block_on(dataset_guard.inner.commit_existing_index_segments( + &index_name, + &column, + segments, + ))?; + block_on(dataset_guard.inner.load_indices_by_name(&index_name))? + }; + + export_vec(env, &committed) +} + +fn index_metadata_to_segment(metadata: &IndexMetadata) -> Result { + Ok(metadata.clone().into_index_segment()?) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeOptimizeIndices( + mut env: JNIEnv, + java_dataset: JObject, + options_obj: JObject, // OptimizeOptions +) { + ok_or_throw_without_return!( + env, + inner_optimize_indices(&mut env, java_dataset, options_obj) + ); +} + +fn inner_optimize_indices( + env: &mut JNIEnv, + java_dataset: JObject, + java_options: JObject, // OptimizeOptions +) -> Result<()> { + let mut options = OptimizeOptions::default(); + + if !java_options.is_null() { + options.num_indices_to_merge = + env.get_optional_usize_from_method(&java_options, "getNumIndicesToMerge")?; + + // getIndexNames(): Optional> + let index_names_obj = env + .call_method( + &java_options, + "getIndexNames", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let index_names = env.get_strings_opt(&index_names_obj)?; options.index_names = index_names; // isRetrain(): boolean @@ -1026,25 +1639,30 @@ fn inner_optimize_indices( let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.optimize_indices(&options))?; + block_on(dataset_guard.inner.optimize_indices(&options))?; Ok(()) } ////////////////// // Read Methods // ////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_openNative<'local>( mut env: JNIEnv<'local>, _obj: JObject, path: JString, version_obj: JObject, // Optional + ref_obj: JObject, // Optional 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 + storage_options_obj: JObject, // Map + base_store_params_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, @@ -1052,58 +1670,186 @@ pub extern "system" fn Java_org_lance_Dataset_openNative<'local>( &mut env, path, version_obj, + ref_obj, block_size_obj, index_cache_size_bytes, metadata_cache_size_bytes, storage_options_obj, + base_store_params_obj, serialized_manifest, - storage_options_provider_obj, + session_handle, + namespace_obj, + table_id_obj, + namespace_client_managed_versioning != 0, ) ) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_listManifestLocationsNative<'local>( + mut env: JNIEnv<'local>, + _obj: JObject, + path: JString, + storage_options_obj: JObject, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_list_manifest_locations(&mut env, path, storage_options_obj) + ) +} + +fn inner_list_manifest_locations<'local>( + env: &mut JNIEnv<'local>, + path: JString, + storage_options_obj: JObject, +) -> Result> { + let path: String = path.extract(env)?; + let storage_options = JMap::from_env(env, &storage_options_obj)?; + let storage_options = to_rust_map(env, &storage_options)?; + let locations = BlockingDataset::list_manifest_locations(&path, storage_options)?; + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for location in locations { + env.with_local_frame(8, |env| { + let java_location = location.into_java(env)?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&java_location)], + )?; + Ok::<(), Error>(()) + })?; + } + Ok(list) +} + #[allow(clippy::too_many_arguments)] fn inner_open_native<'local>( env: &mut JNIEnv<'local>, path: JString, version_obj: JObject, // Optional + ref_obj: JObject, // Optional 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 + storage_options_obj: JObject, // Map + base_store_params_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)?; + // ReadOptions.Builder rejects setting both, so at most one of them is present. + let reference = env + .get_optional(&ref_obj, |env, jref| transform_jref_to_ref(jref, env))? + .or(version.map(Ref::from)); let block_size = env.get_int_opt(&block_size_obj)?; let jmap = JMap::from_env(env, &storage_options_obj)?; let storage_options = to_rust_map(env, &jmap)?; + let base_store_params = extract_base_store_params(env, &base_store_params_obj)?; - // 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) - })?; + // 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 { + Some((ns, tid)) => (Some(ns), Some(tid)), + None => (None, None), + }; - let storage_options_provider_arc = - storage_options_provider.map(|v| Arc::new(v) as Arc); + // 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 + let session = session_from_handle(session_handle); + let dataset = BlockingDataset::open( &path_str, - version, + reference, block_size, index_cache_size_bytes, metadata_cache_size_bytes, storage_options, + base_store_params, serialized_manifest, storage_options_provider_arc, + session, + namespace, + table_id, + namespace_client_managed_versioning, )?; dataset.into_java(env) } -#[no_mangle] +/// Check if the Java object is an instance of DirectoryNamespace. +fn is_directory_namespace(env: &mut JNIEnv, namespace_obj: &JObject) -> Result { + let class = env + .find_class("org/lance/namespace/DirectoryNamespace") + .map_err(|e| { + Error::runtime_error(format!("Failed to find DirectoryNamespace class: {}", e)) + })?; + env.is_instance_of(namespace_obj, class) + .map_err(|e| Error::runtime_error(format!("Failed to check instanceof: {}", e))) +} + +/// Check if the Java object is an instance of RestNamespace. +fn is_rest_namespace(env: &mut JNIEnv, namespace_obj: &JObject) -> Result { + let class = env + .find_class("org/lance/namespace/RestNamespace") + .map_err(|e| Error::runtime_error(format!("Failed to find RestNamespace class: {}", e)))?; + env.is_instance_of(namespace_obj, class) + .map_err(|e| Error::runtime_error(format!("Failed to check instanceof: {}", e))) +} + +/// Get the native handle from a Java LanceNamespace object. +fn get_native_namespace_handle(env: &mut JNIEnv, namespace_obj: &JObject) -> Result { + env.call_method(namespace_obj, "getNativeHandle", "()J", &[]) + .map_err(|e| Error::runtime_error(format!("Failed to call getNativeHandle: {}", e)))? + .j() + .map_err(|e| Error::runtime_error(format!("getNativeHandle did not return a long: {}", e))) +} + +/// Extract namespace and table_id from Java objects into Rust types. +/// +/// Returns `None` if `namespace_obj` is null, otherwise returns the namespace +/// and table_id pair. +#[allow(clippy::type_complexity)] +pub(crate) fn extract_namespace_info( + env: &mut JNIEnv, + namespace_obj: &JObject, + table_id_obj: &JObject, +) -> Result, Vec)>> { + if namespace_obj.is_null() { + return Ok(None); + } + + let namespace_client: Arc = if is_directory_namespace(env, namespace_obj)? { + let native_handle = get_native_namespace_handle(env, namespace_obj)?; + 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 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_client, table_id))) +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_getFragmentsNative<'a>( mut env: JNIEnv<'a>, jdataset: JObject, @@ -1127,7 +1873,98 @@ fn inner_get_fragments<'local>( export_vec(env, &fragments) } -#[no_mangle] +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetBasePaths<'a>( + mut env: JNIEnv<'a>, + jdataset: JObject, +) -> JObject<'a> { + ok_or_throw!(env, inner_get_base_paths(&mut env, jdataset)) +} + +fn inner_get_base_paths<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject, +) -> Result> { + let mut base_paths = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + dataset + .inner + .manifest() + .base_paths + .values() + .cloned() + .collect::>() + }; + base_paths.sort_by_key(|base_path| base_path.id); + export_vec(env, &base_paths) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetFragmentStatistics<'a>( + mut env: JNIEnv<'a>, + jdataset: JObject, +) -> JObject<'a> { + ok_or_throw!(env, inner_get_fragment_statistics(&mut env, jdataset)) +} + +/// Returns per-fragment statistics in their final Java primitive arrays. +/// +/// Row count semantics match Java `FragmentMetadata.getNumRows()`: +/// physical rows minus deleted rows, with absent values treated as 0. +/// Data file count is the number of data files in the fragment. +fn inner_get_fragment_statistics<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject, +) -> Result> { + let fragments = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + dataset.inner.fragments().clone() + }; + let fragment_count = i32::try_from(fragments.len()).map_err(|_| { + Error::runtime_error(format!( + "Fragment statistics contain {} fragments, exceeding the Java array limit of {}", + fragments.len(), + i32::MAX + )) + })?; + let ids = env.new_int_array(fragment_count)?; + let row_counts = env.new_long_array(fragment_count)?; + let data_file_nums = env.new_int_array(fragment_count)?; + + let mut id_values = Vec::with_capacity(fragments.len()); + let mut row_count_values = Vec::with_capacity(fragments.len()); + let mut data_file_num_values = Vec::with_capacity(fragments.len()); + + for fragment in fragments.iter() { + let physical_rows = fragment.physical_rows.unwrap_or(0) as i64; + let deleted_rows = fragment + .deletion_file + .as_ref() + .and_then(|deletion_file| deletion_file.num_deleted_rows) + .unwrap_or(0) as i64; + id_values.push(fragment.id as i32); + row_count_values.push(physical_rows - deleted_rows); + data_file_num_values.push(fragment.files.len() as i32); + } + + env.set_int_array_region(&ids, 0, &id_values)?; + env.set_long_array_region(&row_counts, 0, &row_count_values)?; + env.set_int_array_region(&data_file_nums, 0, &data_file_num_values)?; + + Ok(env.new_object( + "org/lance/FragmentStatistics", + "([I[J[I)V", + &[ + JValue::Object(&ids), + JValue::Object(&row_counts), + JValue::Object(&data_file_nums), + ], + )?) +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_getFragmentNative<'a>( mut env: JNIEnv<'a>, jdataset: JObject, @@ -1153,7 +1990,7 @@ fn inner_get_fragment<'local>( Ok(obj) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetLanceSchema<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1173,7 +2010,7 @@ fn inner_get_lance_schema<'local>( schema.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_importFfiSchema( mut env: JNIEnv, jdataset: JObject, @@ -1201,7 +2038,7 @@ fn inner_import_ffi_schema( Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeUri<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1224,7 +2061,7 @@ fn inner_uri<'local>(env: &mut JNIEnv<'local>, java_dataset: JObject) -> Result< Ok(jstring_uri) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeListVersions<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1232,6 +2069,20 @@ pub extern "system" fn Java_org_lance_Dataset_nativeListVersions<'local>( ok_or_throw!(env, inner_list_versions(&mut env, java_dataset)) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetVersionCount( + mut env: JNIEnv, + java_dataset: JObject, +) -> jlong { + ok_or_throw_with_return!(env, inner_get_version_count(&mut env, java_dataset), -1) as jlong +} + +fn inner_get_version_count(env: &mut JNIEnv, java_dataset: JObject) -> Result { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + dataset_guard.count_versions() +} + fn inner_list_versions<'local>( env: &mut JNIEnv<'local>, java_dataset: JObject, @@ -1260,7 +2111,7 @@ fn inner_list_versions<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetVersion<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1280,7 +2131,7 @@ fn inner_get_version<'local>( version.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetLatestVersionId( mut env: JNIEnv, java_dataset: JObject, @@ -1294,7 +2145,7 @@ fn inner_latest_version_id(env: &mut JNIEnv, java_dataset: JObject) -> Result( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1320,7 +2171,7 @@ fn inner_get_initial_storage_options<'local>( } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetLatestStorageOptions<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1346,7 +2197,7 @@ fn inner_get_latest_storage_options<'local>( } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCheckoutLatest( mut env: JNIEnv, java_dataset: JObject, @@ -1360,7 +2211,7 @@ fn inner_checkout_latest(env: &mut JNIEnv, java_dataset: JObject) -> Result<()> dataset_guard.checkout_latest() } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCheckoutVersion<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1383,7 +2234,7 @@ fn inner_checkout_version<'local>( new_dataset.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCheckoutTag<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1407,7 +2258,7 @@ fn inner_checkout_tag<'local>( new_dataset.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeRestore( mut env: JNIEnv, java_dataset: JObject, @@ -1421,7 +2272,7 @@ fn inner_restore(env: &mut JNIEnv, java_dataset: JObject) -> Result<()> { dataset_guard.restore() } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeShallowClone<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1454,7 +2305,7 @@ fn inner_shallow_clone<'local>( let new_ds = { let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.shallow_clone( + block_on(dataset_guard.inner.shallow_clone( target_path_str.as_str(), reference, storage_opts, @@ -1464,7 +2315,7 @@ fn inner_shallow_clone<'local>( BlockingDataset { inner: new_ds }.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCountRows( mut env: JNIEnv, java_dataset: JObject, @@ -1488,7 +2339,7 @@ fn inner_count_rows( dataset_guard.count_rows(filter) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetDataStatistics<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1525,7 +2376,7 @@ fn inner_get_data_statistics<'local>( Ok(data_stats) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeListIndexes<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1562,7 +2413,7 @@ fn inner_list_indexes<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetConfig<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1604,7 +2455,77 @@ fn inner_get_config<'local>( Ok(java_hashmap) } -#[no_mangle] +#[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) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetWriterVersion<'local>( + mut env: JNIEnv<'local>, + java_dataset: JObject, +) -> JObject<'local> { + ok_or_throw!(env, inner_get_writer_version(&mut env, java_dataset)) +} + +fn inner_get_writer_version<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, +) -> Result> { + let writer_version = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + dataset_guard.inner.manifest().writer_version.clone() + }; + match writer_version { + Some(writer_version) => writer_version.into_java(env), + None => Ok(JObject::null()), + } +} + +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>, + java_dataset: JObject, +) -> JString<'local> { + ok_or_throw_with_return!( + env, + inner_get_lance_file_format_version(&mut env, java_dataset), + JObject::null().into() + ) +} + +fn inner_get_lance_file_format_version<'local>( + env: &mut JNIEnv<'local>, + java_dataset: JObject, +) -> Result> { + let version_string = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + dataset_guard + .inner + .manifest() + .data_storage_format + .lance_file_format() + .to_manifest_string() + .to_string() + }; + + Ok(env + .new_string(&version_string) + .expect("Failed to create Java String")) +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeTake( mut env: JNIEnv, java_dataset: JObject, @@ -1638,7 +2559,7 @@ fn inner_take( let projection = ProjectionRequest::from_columns(columns, dataset.schema()); - match RT.block_on(dataset.take(indices_slice, projection)) { + match block_on(dataset.take(indices_slice, projection)) { Ok(res) => res, Err(e) => { return Err(e.into()); @@ -1657,42 +2578,152 @@ fn inner_take( Ok(**byte_array) } -#[no_mangle] -pub extern "system" fn Java_org_lance_Dataset_nativeDelete( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeTakeRows( mut env: JNIEnv, java_dataset: JObject, - predicate: JString, -) { - ok_or_throw_without_return!(env, inner_delete(&mut env, java_dataset, predicate)) -} - -fn inner_delete(env: &mut JNIEnv, java_dataset: JObject, predicate: JString) -> Result<()> { - let predicate_str = predicate.extract(env)?; - let mut dataset_guard = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.delete(&predicate_str))?; - Ok(()) + row_ids_obj: JObject, // List + columns_obj: JObject, // List +) -> jbyteArray { + match inner_take_rows(&mut env, java_dataset, row_ids_obj, columns_obj) { + Ok(byte_array) => byte_array, + Err(e) => { + let _ = env.throw_new("java/lang/RuntimeException", format!("{:?}", e)); + std::ptr::null_mut() + } + } } -#[no_mangle] -pub extern "system" fn Java_org_lance_Dataset_nativeTruncateTable( - mut env: JNIEnv, +fn inner_take_rows( + env: &mut JNIEnv, java_dataset: JObject, -) { - ok_or_throw_without_return!(env, inner_truncate_table(&mut env, java_dataset)) -} + row_ids_obj: JObject, // List + columns_obj: JObject, // List +) -> Result { + let row_ids: Vec = env.get_longs(&row_ids_obj)?; + let row_ids_u64: Vec = row_ids.iter().map(|&x| x as u64).collect(); + let columns: Vec = env.get_strings(&columns_obj)?; -fn inner_truncate_table(env: &mut JNIEnv, java_dataset: JObject) -> Result<()> { - let mut dataset_guard = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.truncate_table())?; - Ok(()) -} + let result = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + let dataset = &dataset_guard.inner; -////////////////////////////// -// Schema evolution Methods // -////////////////////////////// -#[no_mangle] + let projection = ProjectionRequest::from_columns(columns, dataset.schema()); + + match block_on(dataset.take_rows(&row_ids_u64, projection)) { + Ok(res) => res, + Err(e) => { + return Err(e.into()); + } + } + }; + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &result.schema())?; + writer.write(&result)?; + writer.finish()?; + } + + let byte_array = env.byte_array_from_slice(&buffer)?; + Ok(**byte_array) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeSample( + mut env: JNIEnv, + java_dataset: JObject, + n: jlong, + columns_obj: JObject, // List + fragment_ids_obj: JObject, // Optional> +) -> jbyteArray { + match inner_sample(&mut env, java_dataset, n, columns_obj, fragment_ids_obj) { + Ok(byte_array) => byte_array, + Err(e) => { + let _ = env.throw_new("java/lang/RuntimeException", format!("{:?}", e)); + std::ptr::null_mut() + } + } +} + +fn inner_sample( + env: &mut JNIEnv, + java_dataset: JObject, + n: jlong, + columns_obj: JObject, // List + fragment_ids_obj: JObject, // Optional> +) -> Result { + let columns: Vec = env.get_strings(&columns_obj)?; + let fragment_ids: Option> = env.get_ints_opt(&fragment_ids_obj)?; + let fragment_ids_u32: Option> = + fragment_ids.map(|ids| ids.iter().map(|&id| id as u32).collect()); + + let result = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + let dataset = &dataset_guard.inner; + + let projection = dataset + .schema() + .project_preserve_system_columns(&columns) + .map_err(|e| Error::runtime_error(e.to_string()))?; + + match block_on(dataset.sample(n as usize, &projection, fragment_ids_u32.as_deref())) { + Ok(res) => res, + Err(e) => { + return Err(e.into()); + } + } + }; + + let mut buffer = Vec::new(); + { + let mut writer = StreamWriter::try_new(&mut buffer, &result.schema())?; + writer.write(&result)?; + writer.finish()?; + } + + let byte_array = env.byte_array_from_slice(&buffer)?; + Ok(**byte_array) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeDelete( + mut env: JNIEnv, + java_dataset: JObject, + predicate: JString, +) { + ok_or_throw_without_return!(env, inner_delete(&mut env, java_dataset, predicate)) +} + +fn inner_delete(env: &mut JNIEnv, java_dataset: JObject, predicate: JString) -> Result<()> { + let predicate_str = predicate.extract(env)?; + let mut dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + block_on(dataset_guard.inner.delete(&predicate_str))?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeTruncateTable( + mut env: JNIEnv, + java_dataset: JObject, +) { + ok_or_throw_without_return!(env, inner_truncate_table(&mut env, java_dataset)) +} + +fn inner_truncate_table(env: &mut JNIEnv, java_dataset: JObject) -> Result<()> { + let mut dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + block_on(dataset_guard.inner.truncate_table())?; + Ok(()) +} + +////////////////////////////// +// Schema evolution Methods // +////////////////////////////// +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeDropColumns( mut env: JNIEnv, java_dataset: JObject, @@ -1710,26 +2741,32 @@ fn inner_drop_columns( let columns_slice: Vec<&str> = columns.iter().map(AsRef::as_ref).collect(); let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.drop_columns(&columns_slice))?; + block_on(dataset_guard.inner.drop_columns(&columns_slice))?; Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeAlterColumns( mut env: JNIEnv, java_dataset: JObject, column_alterations_obj: JObject, // List + cast_schema_addr: jlong, ) { ok_or_throw_without_return!( env, - inner_alter_columns(&mut env, java_dataset, column_alterations_obj) + inner_alter_columns( + &mut env, + java_dataset, + column_alterations_obj, + cast_schema_addr + ) ) } fn create_column_alteration( env: &mut JNIEnv, column_alteration_jobj: JObject, // ColumnAlteration -) -> Result { +) -> Result<(ColumnAlteration, bool)> { let path_obj = env .get_field(&column_alteration_jobj, "path", "Ljava/lang/String;")? .l()?; @@ -1768,58 +2805,65 @@ fn create_column_alteration( None }; + // The cast target type (if any) is not read here: it is transferred separately through the + // Arrow C Data Interface (see inner_alter_columns), because ArrowType#toString() does not + // round-trip through DataType::from_str for parameterized types. This flag records whether a + // cast was requested so the caller can attach the imported type in order. let data_type_obj = env .get_field(&column_alteration_jobj, "dataType", "Ljava/util/Optional;")? .l()?; - let data_type = if env + let wants_cast = env .call_method(&data_type_obj, "isPresent", "()Z", &[])? - .z()? - { - let j_data_type: JObject = env - .call_method(data_type_obj, "get", "()Ljava/lang/Object;", &[])? - .l()?; - let jstring: JString = env - .call_method(j_data_type, "toString", "()Ljava/lang/String;", &[])? - .l()? - .into(); - let data_type_str: String = env.get_string(&jstring)?.into(); // Intermediate variable - DataType::from_str(&data_type_str) - .map_err(|e| Error::input_error(e.to_string())) - .ok() - } else { - None - }; + .z()?; - Ok(ColumnAlteration { + let alteration = ColumnAlteration { path, rename, nullable, - data_type, - }) + data_type: None, + }; + Ok((alteration, wants_cast)) } fn inner_alter_columns( env: &mut JNIEnv, java_dataset: JObject, column_alterations_obj: JObject, // List + cast_schema_addr: jlong, ) -> Result<()> { let list = env.get_list(&column_alterations_obj)?; let mut iter = list.iter(env)?; let mut column_alterations = Vec::new(); + let mut cast_flags = Vec::new(); while let Some(elem) = iter.next(env)? { - let alteration = create_column_alteration(env, elem)?; + let (alteration, wants_cast) = create_column_alteration(env, elem)?; column_alterations.push(alteration); + cast_flags.push(wants_cast); + } + + // Cast target types arrive as one Arrow schema field per requested cast, in the same order + // as the alterations that requested one. + let cast_schema = unsafe { FFI_ArrowSchema::from_raw(cast_schema_addr as *mut _) }; + let cast_schema = ArrowSchema::try_from(&cast_schema) + .map_err(|_| Error::input_error("ArrowSchema conversion error".to_string()))?; + let mut cast_types = cast_schema.fields.iter().map(|f| f.data_type().clone()); + for (alteration, wants_cast) in column_alterations.iter_mut().zip(cast_flags) { + if wants_cast { + alteration.data_type = Some(cast_types.next().ok_or_else(|| { + Error::input_error("Missing cast type for column alteration".to_string()) + })?); + } } let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.alter_columns(&column_alterations))?; + block_on(dataset_guard.inner.alter_columns(&column_alterations))?; Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeAddColumnsBySqlExpressions( mut env: JNIEnv, java_dataset: JObject, @@ -1873,7 +2917,7 @@ fn inner_add_columns_by_sql_expressions( let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on( + block_on( dataset_guard .inner .add_columns(rust_transform, None, batch_size), @@ -1881,7 +2925,7 @@ fn inner_add_columns_by_sql_expressions( Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeAddColumnsByReader( mut env: JNIEnv, java_dataset: JObject, @@ -1918,12 +2962,12 @@ fn inner_add_columns_by_reader( let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.add_columns(transform, None, batch_size))?; + block_on(dataset_guard.inner.add_columns(transform, None, batch_size))?; Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeAddColumnsBySchema( mut env: JNIEnv, java_dataset: JObject, @@ -1949,7 +2993,7 @@ fn inner_add_columns_by_schema( let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.add_columns(transform, None, None))?; + block_on(dataset_guard.inner.add_columns(transform, None, None))?; Ok(()) } @@ -1957,7 +3001,27 @@ fn inner_add_columns_by_schema( ////////////////////////////// // Tag operation Methods // ////////////////////////////// -#[no_mangle] +fn optional_datetime_to_java_instant<'local>( + env: &mut JNIEnv<'local>, + timestamp: Option<&DateTime>, +) -> Result> { + if let Some(timestamp) = timestamp { + let seconds = timestamp.timestamp(); + let nanos = timestamp.timestamp_subsec_nanos() as i64; + Ok(env + .call_static_method( + "java/time/Instant", + "ofEpochSecond", + "(JJ)Ljava/time/Instant;", + &[JValue::Long(seconds), JValue::Long(nanos)], + )? + .l()?) + } else { + Ok(JObject::null()) + } +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeListTags<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -1969,27 +3033,34 @@ fn inner_list_tags<'local>( env: &mut JNIEnv<'local>, java_dataset: JObject, ) -> Result> { - let tag_map = { + let mut tags: Vec<_> = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - dataset_guard.list_tags()? + dataset_guard.list_tags()?.into_iter().collect() }; + tags.sort_unstable_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; - for (tag_name, tag_contents) in tag_map { + for (tag_name, tag_contents) in tags { let branch_name: JObject = if let Some(branch_name) = tag_contents.branch.as_ref() { env.new_string(branch_name)?.into() } else { JObject::null() }; + let created_at = optional_datetime_to_java_instant(env, tag_contents.created_at.as_ref())?; + let updated_at = optional_datetime_to_java_instant(env, tag_contents.updated_at.as_ref())?; + let java_metadata = to_java_map(env, &tag_contents.metadata)?; let java_tag = env.new_object( "org/lance/Tag", - "(Ljava/lang/String;Ljava/lang/String;JI)V", + "(Ljava/lang/String;Ljava/lang/String;JILjava/time/Instant;Ljava/time/Instant;Ljava/util/Map;)V", &[ JValue::Object(&env.new_string(tag_name)?.into()), JValue::Object(&branch_name), JValue::Long(tag_contents.version as i64), JValue::Int(tag_contents.manifest_size as i32), + JValue::Object(&created_at), + JValue::Object(&updated_at), + JValue::Object(&java_metadata), ], )?; env.call_method( @@ -2002,7 +3073,7 @@ fn inner_list_tags<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCreateTag( mut env: JNIEnv, java_dataset: JObject, @@ -2029,7 +3100,7 @@ fn inner_create_tag( Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeDeleteTag( mut env: JNIEnv, java_dataset: JObject, @@ -2045,7 +3116,7 @@ fn inner_delete_tag(env: &mut JNIEnv, java_dataset: JObject, jtag_name: JString) dataset_guard.delete_tag(tag.as_str()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeUpdateTag( mut env: JNIEnv, java_dataset: JObject, @@ -2071,7 +3142,33 @@ fn inner_update_tag( dataset_guard.update_tag(tag.as_str(), reference) } -#[no_mangle] +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeReplaceTagMetadata( + mut env: JNIEnv, + java_dataset: JObject, + jtag_name: JString, + jmetadata: JObject, +) { + ok_or_throw_without_return!( + env, + inner_replace_tag_metadata(&mut env, java_dataset, jtag_name, jmetadata) + ) +} + +fn inner_replace_tag_metadata( + env: &mut JNIEnv, + java_dataset: JObject, + jtag_name: JString, + jmetadata: JObject, +) -> Result<()> { + let tag = jtag_name.extract(env)?; + let metadata = extract_metadata_map(env, &jmetadata)?; + let mut dataset_guard = + { unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }? }; + dataset_guard.replace_tag_metadata(tag.as_str(), metadata) +} + +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetVersionByTag( mut env: JNIEnv, java_dataset: JObject, @@ -2098,7 +3195,7 @@ fn inner_get_version_by_tag( ////////////////////////////// // Branch operation Methods // ////////////////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeListBranches<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2110,11 +3207,12 @@ fn inner_list_branches<'local>( env: &mut JNIEnv<'local>, java_dataset: JObject, ) -> Result> { - let branches = { + let mut branches: Vec<_> = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - dataset_guard.list_branches()? + dataset_guard.list_branches()?.into_iter().collect() }; + branches.sort_unstable_by(|(left_name, _), (right_name, _)| left_name.cmp(right_name)); let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; for (name, contents) in branches { @@ -2124,15 +3222,37 @@ 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 java_metadata = to_java_map(env, &contents.metadata)?; 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;JJILjava/util/Map;)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), + JValue::Object(&java_metadata), ], )?; env.call_method( @@ -2145,7 +3265,7 @@ fn inner_list_branches<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCreateBranch<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2173,7 +3293,7 @@ fn inner_create_branch<'local>( let new_blocking_dataset = { let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - let inner = RT.block_on(dataset_guard.inner.create_branch( + let inner = block_on(dataset_guard.inner.create_branch( branch_name.as_str(), reference, storage_opts, @@ -2183,6 +3303,37 @@ fn inner_create_branch<'local>( new_blocking_dataset.into_java(env) } +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeReplaceBranchMetadata( + mut env: JNIEnv, + java_dataset: JObject, + jbranch: JString, + jmetadata: JObject, +) { + ok_or_throw_without_return!( + env, + inner_replace_branch_metadata(&mut env, java_dataset, jbranch, jmetadata) + ) +} + +fn inner_replace_branch_metadata( + env: &mut JNIEnv, + java_dataset: JObject, + jbranch: JString, + jmetadata: JObject, +) -> Result<()> { + let branch: String = jbranch.extract(env)?; + let metadata = extract_metadata_map(env, &jmetadata)?; + let mut dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + dataset_guard.replace_branch_metadata(&branch, metadata) +} + +fn extract_metadata_map(env: &mut JNIEnv, jmetadata: &JObject) -> Result> { + let jmap = JMap::from_env(env, jmetadata)?; + to_rust_map(env, &jmap) +} + fn transform_jref_to_ref(jref: JObject, env: &mut JNIEnv) -> Result { let source_tag_name = env.get_optional_string_from_method(&jref, "getTagName")?; let source_version_number = env.get_optional_u64_from_method(&jref, "getVersionNumber")?; @@ -2214,7 +3365,7 @@ fn transform_jstorage_options( .unwrap_or(None)) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeDeleteBranch( mut env: JNIEnv, java_dataset: JObject, @@ -2230,7 +3381,7 @@ fn inner_delete_branch(env: &mut JNIEnv, java_dataset: JObject, jbranch: JString dataset_guard.delete_branch(branch_name.as_str()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCheckout<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2282,7 +3433,7 @@ fn inner_checkout_ref<'local>( // Unified metadata API JNI methods -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetTableMetadata<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2327,7 +3478,7 @@ fn inner_get_table_metadata<'local>( ////////////////////////////// // Compaction Methods // ////////////////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCompact( mut env: JNIEnv, java_dataset: JObject, @@ -2344,7 +3495,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)?; @@ -2354,6 +3510,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( @@ -2414,6 +3571,62 @@ fn convert_java_compaction_options_to_rust( &[], )? .l()?; + let compaction_mode = env + .call_method( + &java_options, + "getCompactionMode", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let binary_copy_read_batch_bytes = env + .call_method( + &java_options, + "getBinaryCopyReadBatchBytes", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let max_source_fragments = env + .call_method( + &java_options, + "getMaxSourceFragments", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let max_source_rows = env + .call_method( + &java_options, + "getMaxSourceRows", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let max_source_bytes = env + .call_method( + &java_options, + "getMaxSourceBytes", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + let excluded_fragment_ids = env + .call_method( + &java_options, + "getExcludedFragmentIds", + "()Ljava/util/List;", + &[], + )? + .l()?; + let data_storage_version = env + .call_method( + &java_options, + "getDataStorageVersion", + "()Ljava/util/Optional;", + &[], + )? + .l()?; build_compaction_options( env, @@ -2425,10 +3638,18 @@ fn convert_java_compaction_options_to_rust( &num_threads, &batch_size, &defer_index_remap, + &compaction_mode, + &binary_copy_read_batch_bytes, + &max_source_fragments, + &max_source_rows, + &max_source_bytes, + &excluded_fragment_ids, + &data_storage_version, + config, ) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCleanupWithPolicy<'local>( mut env: JNIEnv<'local>, jdataset: JObject, @@ -2442,57 +3663,251 @@ fn inner_cleanup_with_policy<'local>( jdataset: JObject, jpolicy: JObject, ) -> Result> { - let before_ts_millis = - env.get_optional_u64_from_method(&jpolicy, "getBeforeTimestampMillis")?; + let policy = extract_cleanup_policy(env, &jpolicy)?; + + let stats = { + let mut dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + dataset.cleanup_with_policy(policy) + }?; + + cleanup_stats_to_java(env, stats) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeExplainCleanupWithPolicy<'local>( + mut env: JNIEnv<'local>, + jdataset: JObject, + jpolicy: JObject, + jmax_candidate_files: JObject, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_explain_cleanup_with_policy(&mut env, jdataset, jpolicy, jmax_candidate_files) + ) +} + +fn inner_explain_cleanup_with_policy<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject, + jpolicy: JObject, + jmax_candidate_files: JObject, +) -> Result> { + let policy = extract_cleanup_policy(env, &jpolicy)?; + let max_candidate_files = env + .get_optional(&jmax_candidate_files, |env, inner| { + Ok(env.call_method(inner, "longValue", "()J", &[])?.j()?) + })? + .map(|v| { + usize::try_from(v).map_err(|e| { + Error::input_error(format!( + "maxCandidateFiles must be a non-negative usize value, got {}: {:?}", + v, e + )) + }) + }) + .transpose()?; + + let explanation = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + dataset.explain_cleanup_with_policy(policy, max_candidate_files) + }?; + + cleanup_explanation_to_java(env, explanation) +} + +fn extract_cleanup_policy(env: &mut JNIEnv<'_>, jpolicy: &JObject) -> Result { + let before_ts_millis = env.get_optional_u64_from_method(jpolicy, "getBeforeTimestampMillis")?; let before_timestamp = before_ts_millis.map(|millis| { let st = UNIX_EPOCH + Duration::from_millis(millis); DateTime::::from(st) }); - let before_version = env.get_optional_u64_from_method(&jpolicy, "getBeforeVersion")?; + let before_version = env.get_optional_u64_from_method(jpolicy, "getBeforeVersion")?; + + let versions = env.get_optional_from_method(jpolicy, "getVersions", |env, list_obj| { + let mut versions = HashSet::new(); + for version in env.get_longs(&list_obj)? { + versions.insert(version as u64); + } + Ok(versions) + })?; let delete_unverified = env - .get_optional_from_method(&jpolicy, "getDeleteUnverified", |env, obj| { + .get_optional_from_method(jpolicy, "getDeleteUnverified", |env, obj| { Ok(env.call_method(obj, "booleanValue", "()Z", &[])?.z()?) })? .unwrap_or(false); let error_if_tagged_old_versions = env - .get_optional_from_method(&jpolicy, "getErrorIfTaggedOldVersions", |env, obj| { + .get_optional_from_method(jpolicy, "getErrorIfTaggedOldVersions", |env, obj| { Ok(env.call_method(obj, "booleanValue", "()Z", &[])?.z()?) })? .unwrap_or(true); - let policy = CleanupPolicy { + let clean_referenced_branches = env + .get_optional_from_method(jpolicy, "getCleanReferencedBranches", |env, obj| { + Ok(env.call_method(obj, "booleanValue", "()Z", &[])?.z()?) + })? + .unwrap_or(false); + + let delete_rate_limit = env.get_optional_u64_from_method(jpolicy, "getDeleteRateLimit")?; + + Ok(CleanupPolicy { before_timestamp, before_version, + versions, delete_unverified, error_if_tagged_old_versions, - }; - - let stats = { - let mut dataset = - unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; - dataset.cleanup_with_policy(policy) - }?; + clean_referenced_branches, + delete_rate_limit, + }) +} - let jstats = env.new_object( +fn cleanup_stats_to_java<'local>( + env: &mut JNIEnv<'local>, + stats: RemovalStats, +) -> Result> { + Ok(env.new_object( "org/lance/cleanup/RemovalStats", - "(JJ)V", + "(JJJJJJJ)V", &[ JValue::Long(stats.bytes_removed as i64), JValue::Long(stats.old_versions as i64), + JValue::Long(stats.data_files_removed as i64), + JValue::Long(stats.transaction_files_removed as i64), + JValue::Long(stats.index_files_removed as i64), + JValue::Long(stats.deletion_files_removed as i64), + JValue::Long(stats.failed_deletes as i64), ], - )?; + )?) +} - Ok(jstats) +fn cleanup_file_kind_to_java(kind: CleanupFileKind) -> &'static str { + match kind { + CleanupFileKind::Manifest => "manifest", + CleanupFileKind::Data => "data", + CleanupFileKind::Transaction => "transaction", + CleanupFileKind::Index => "index", + CleanupFileKind::Deletion => "deletion", + CleanupFileKind::TemporaryManifest => "temporary_manifest", + } +} + +fn cleanup_candidate_files_to_java<'local>( + env: &mut JNIEnv<'local>, + files: Vec, +) -> Result> { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + // Wrap each iteration in a local frame so the temporary path/kind/candidate + // references do not accumulate on the JNI local reference table for large + // explanations (default limit is 1000 candidate files, but users can raise it). + for file in files { + env.with_local_frame(8, |env| { + let path = env.new_string(file.path)?; + let kind = env.new_string(cleanup_file_kind_to_java(file.kind))?; + let candidate = env.new_object( + "org/lance/cleanup/CleanupCandidateFile", + "(Ljava/lang/String;Ljava/lang/String;ZJ)V", + &[ + JValue::Object(&path), + JValue::Object(&kind), + JValue::Bool(file.unverified as jboolean), + JValue::Long(file.size_bytes as i64), + ], + )?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&candidate)], + )?; + Ok::<(), Error>(()) + })?; + } + Ok(list) +} + +fn cleanup_referenced_branches_to_java<'local>( + env: &mut JNIEnv<'local>, + branches: Vec, +) -> Result> { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for branch in branches { + env.with_local_frame(8, |env| { + let name = env.new_string(branch.name)?; + let referenced_branch = env.new_object( + "org/lance/cleanup/CleanupReferencedBranch", + "(Ljava/lang/String;JZ)V", + &[ + JValue::Object(&name), + JValue::Long(branch.referenced_version as i64), + JValue::Bool(branch.cleanup_candidate as jboolean), + ], + )?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&referenced_branch)], + )?; + Ok::<(), Error>(()) + })?; + } + Ok(list) +} + +fn cleanup_warnings_to_java<'local>( + env: &mut JNIEnv<'local>, + warnings: Vec, +) -> Result> { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for warning in warnings { + env.with_local_frame(4, |env| { + let warning = env.new_string(warning)?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&warning)], + )?; + Ok::<(), Error>(()) + })?; + } + Ok(list) +} + +fn cleanup_explanation_to_java<'local>( + env: &mut JNIEnv<'local>, + explanation: CleanupExplanation, +) -> Result> { + let stats = cleanup_stats_to_java(env, explanation.stats)?; + let candidate_files = cleanup_candidate_files_to_java(env, explanation.candidate_files)?; + let referenced_branches = + cleanup_referenced_branches_to_java(env, explanation.referenced_branches)?; + let warnings = cleanup_warnings_to_java(env, explanation.warnings)?; + + Ok(env.new_object( + "org/lance/cleanup/CleanupExplanation", + "(JLorg/lance/cleanup/RemovalStats;Ljava/util/List;ZJLjava/util/List;Ljava/util/List;)V", + &[ + JValue::Long(explanation.read_version as i64), + JValue::Object(&stats), + JValue::Object(&candidate_files), + JValue::Bool(explanation.candidate_files_truncated as jboolean), + JValue::Long(explanation.candidate_file_limit as i64), + JValue::Object(&referenced_branches), + JValue::Object(&warnings), + ], + )?) } ////////////////////////////// // Index operation Methods // ////////////////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetIndexes<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2525,7 +3940,7 @@ fn inner_get_indexes<'local>( Ok(array_list) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeGetIndexStatistics<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2547,13 +3962,13 @@ fn inner_get_index_statistics<'local>( let stats_json = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.index_statistics(&index_name))? + block_on(dataset_guard.inner.index_statistics(&index_name))? }; let jstats = env.new_string(stats_json)?; Ok(jstats) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeDescribeIndices<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -2582,20 +3997,22 @@ fn inner_describe_indices<'local>( for_column: for_column.as_deref(), has_name: has_name.as_deref(), must_support_fts, + fts_document_granularity: None, must_support_exact_equality, + must_support_minhash: false, }) })?; let descriptions = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(dataset_guard.inner.describe_indices(index_criteria))? + block_on(dataset_guard.inner.describe_indices(index_criteria))? }; export_vec(env, &descriptions) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeCountIndexedRows( mut env: JNIEnv, java_dataset: JObject, @@ -2613,71 +4030,347 @@ pub extern "system" fn Java_org_lance_Dataset_nativeCountIndexedRows( fn inner_count_indexed_rows( env: &mut JNIEnv, java_dataset: JObject, - _jindex_name: JString, + jindex_name: JString, jfilter: JString, jfragment_ids: JObject, // Optional> ) -> Result { + let index_name: String = jindex_name.extract(env)?; let filter: String = jfilter.extract(env)?; + let fragment_ids = extract_count_fragment_ids(env, &jfragment_ids)?; - // Extract optional fragment IDs - let fragment_ids: Option> = if env - .call_method(&jfragment_ids, "isPresent", "()Z", &[])? - .z()? - { - let list_obj = env - .call_method(&jfragment_ids, "get", "()Ljava/lang/Object;", &[])? - .l()?; - let list = env.get_list(&list_obj)?; - let mut ids = Vec::new(); - let mut iter = list.iter(env)?; - while let Some(elem) = iter.next(env)? { - let int_val = env.call_method(&elem, "intValue", "()I", &[])?.i()?; - ids.push(int_val as u32); - } - Some(ids) - } else { - None - }; + count_indexed_rows(env, java_dataset, index_name, filter, None, fragment_ids) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeCountIndexedRowsWithSegments( + mut env: JNIEnv, + java_dataset: JObject, + jindex_name: JString, + jfilter: JString, + jsegment_uuids: JObject, // List + jfragment_ids: JObject, // Optional> +) -> jlong { + ok_or_throw_with_return!( + env, + inner_count_indexed_rows_with_segments( + &mut env, + java_dataset, + jindex_name, + jfilter, + jsegment_uuids, + jfragment_ids, + ), + -1 + ) +} + +fn inner_count_indexed_rows_with_segments( + env: &mut JNIEnv, + java_dataset: JObject, + jindex_name: JString, + jfilter: JString, + jsegment_uuids: JObject, + jfragment_ids: JObject, +) -> Result { + let index_name: String = jindex_name.extract(env)?; + let filter: String = jfilter.extract(env)?; + let segment_uuids = + import_vec_to_rust(env, &jsegment_uuids, |env, obj| obj.extract_object(env))?; + let fragment_ids = extract_count_fragment_ids(env, &jfragment_ids)?; + count_indexed_rows( + env, + java_dataset, + index_name, + filter, + Some(segment_uuids), + fragment_ids, + ) +} + +fn extract_count_fragment_ids( + env: &mut JNIEnv, + jfragment_ids: &JObject, +) -> Result>> { + env.get_ints_opt(jfragment_ids)? + .map(|fragment_ids| { + fragment_ids + .into_iter() + .map(|fragment_id| { + u32::try_from(fragment_id).map_err(|_| { + Error::input_error(format!( + "fragment ID must be non-negative, got {fragment_id}" + )) + }) + }) + .collect() + }) + .transpose() +} + +fn count_indexed_rows( + env: &mut JNIEnv, + java_dataset: JObject, + index_name: String, + filter: String, + segment_uuids: Option>, + fragment_ids: Option>, +) -> Result { let count = { let dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + block_on(dataset_guard.inner.count_indexed_rows( + &index_name, + &filter, + segment_uuids.as_deref(), + fragment_ids.as_deref(), + ))? + }; - // Use a scanner with fragment filtering to count rows - // This ensures we only count rows in the specified fragments - let inner = dataset_guard.inner.clone(); + i64::try_from(count) + .map_err(|_| Error::input_error(format!("indexed row count {count} exceeds Java long"))) +} - RT.block_on(async { - let mut scanner = inner.scan(); +////////////////////////////// +// Session Methods // +////////////////////////////// - // Apply filter - if !filter.is_empty() { - scanner.filter(&filter)?; - } +/// Returns the session handle from a dataset. +/// The returned handle can be used to create a Java Session object. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetSessionHandle( + mut env: JNIEnv, + java_dataset: JObject, +) -> jlong { + ok_or_throw_with_return!(env, inner_get_session_handle(&mut env, java_dataset), 0) +} - // Empty projection and enable row_id for count_rows to work - // count_rows() requires metadata-only projection - scanner.project::(&[])?; - scanner.with_row_id(); - - // Apply fragment filter if specified - if let Some(frag_ids) = fragment_ids { - // Convert FileFragment to Fragment by extracting metadata - let filtered_fragments: Vec<_> = inner - .get_fragments() - .into_iter() - .filter(|f| frag_ids.contains(&(f.id() as u32))) - .map(|f| f.metadata().clone()) - .collect(); - scanner.with_fragments(filtered_fragments); - } +fn inner_get_session_handle(env: &mut JNIEnv, java_dataset: JObject) -> Result { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; + let session = dataset_guard.inner.session(); + Ok(handle_from_session(session)) +} - // Use the scanner's count_rows method - let count = scanner.count_rows().await?; +///////////////////////////////// +// Zonemap Stats Methods // +///////////////////////////////// - Ok::(count as i64) +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeGetZonemapStats<'local>( + 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 every committed zonemap segment for this column + let zonemap_batches = { + 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 + 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, + fts_document_granularity: None, + must_support_exact_equality: false, + must_support_minhash: 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 mut indices = dataset + .load_indices_by_name(desc.name()) + .await + .map_err(Error::from)?; + indices.sort_by_key(|idx| { + idx.fragment_bitmap + .as_ref() + .and_then(|bitmap| bitmap.iter().next()) + .unwrap_or_default() + }); + + let mut batches = Vec::with_capacity(indices.len()); + for index in &indices { + let index_store = Arc::new( + LanceIndexStore::from_dataset_for_existing(&dataset, index) + .await + .map_err(Error::from)?, + ); + let index_file = index_store + .open_index_file("zonemap.lance") + .await + .map_err(Error::from)?; + if index_file.num_rows() == 0 { + continue; + } + batches.push( + index_file + .read_range(0..index_file.num_rows(), None) + .await + .map_err(Error::from)?, + ); + } + Ok::<_, Error>(batches) + } + None => Ok(Vec::new()), + } })? }; - Ok(count) + // 3. Convert the RecordBatch rows to a Java ArrayList + let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; + if zonemap_batches.is_empty() { + return Ok(array_list); + } + + for record_batch in zonemap_batches { + 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; + + 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) +} + +#[cfg(test)] +mod tests { + use super::should_publish_working_dataset_on_error; + + #[test] + fn post_commit_failure_publishes_advanced_dataset() { + assert!(should_publish_working_dataset_on_error(false, 1, 2)); + } + + #[test] + fn pre_commit_failure_keeps_original_dataset() { + assert!(!should_publish_working_dataset_on_error(false, 1, 1)); + } + + #[test] + fn uncommitted_failure_keeps_original_dataset() { + assert!(!should_publish_working_dataset_on_error(true, 1, 2)); + } } diff --git a/java/lance-jni/src/blocking_scanner.rs b/java/lance-jni/src/blocking_scanner.rs index 8c6bc402544..03f5157c89e 100644 --- a/java/lance-jni/src/blocking_scanner.rs +++ b/java/lance-jni/src/blocking_scanner.rs @@ -1,31 +1,40 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; use crate::error::{Error, Result}; use crate::ffi::JNIEnvExt; -use crate::traits::{import_vec_from_method, import_vec_to_rust}; -use arrow::array::Float32Array; +use crate::traits::{FromJObjectWithEnv, import_vec_from_method, import_vec_to_rust}; +use arrow::array::{ArrayRef, FixedSizeListArray, Float32Array}; use arrow::{ffi::FFI_ArrowSchema, ffi_stream::FFI_ArrowArrayStream}; -use arrow_schema::SchemaRef; -use jni::objects::{JObject, JString}; -use jni::sys::{jboolean, jint, JNI_TRUE}; -use jni::{sys::jlong, JNIEnv}; -use lance::dataset::scanner::{ColumnOrdering, DatasetRecordBatchStream, Scanner}; -use lance_index::scalar::inverted::query::{ - BooleanQuery as FtsBooleanQuery, BoostQuery as FtsBoostQuery, FtsQuery, - MatchQuery as FtsMatchQuery, MultiMatchQuery as FtsMultiMatchQuery, Occur as FtsOccur, - PhraseQuery as FtsPhraseQuery, +use arrow_schema::{DataType, Field, SchemaRef}; +use jni::objects::{JObject, JString, JValueGen}; +use jni::sys::{JNI_TRUE, jboolean, jint}; +use jni::{JNIEnv, sys::jlong}; +use lance::dataset::scanner::{ + AggregateExpr, ColumnOrdering, DatasetRecordBatchStream, ExecutionStatsCallback, + ExecutionSummaryCounts, MaterializationStyle, Scanner, }; use lance_index::scalar::FullTextSearchQuery; +use lance_index::scalar::inverted::{ + DocumentGranularity, + query::{ + BooleanQuery as FtsBooleanQuery, BoostQuery as FtsBoostQuery, + CombinedFieldsQuery as FtsCombinedFieldsQuery, FtsQuery, MatchQuery as FtsMatchQuery, + MultiMatchQuery as FtsMultiMatchQuery, Occur as FtsOccur, PhraseQuery as FtsPhraseQuery, + }, +}; use lance_io::ffi::to_ffi_arrow_array_stream; use lance_linalg::distance::DistanceType; +use uuid::Uuid; use crate::{ + RT, block_on, blocking_dataset::{BlockingDataset, NATIVE_DATASET}, traits::IntoJava, - RT, + utils::parse_approx_mode, }; pub const NATIVE_SCANNER: &str = "nativeScannerHandle"; @@ -33,32 +42,63 @@ pub const NATIVE_SCANNER: &str = "nativeScannerHandle"; #[derive(Clone)] pub struct BlockingScanner { pub(crate) inner: Arc, + stats: Arc>>, } impl BlockingScanner { - pub fn create(scanner: Scanner) -> Self { + pub fn create(mut scanner: Scanner, collect_stats: bool) -> Self { + let stats = Arc::new(Mutex::new(None)); + if collect_stats { + let stats_for_callback = stats.clone(); + let callback: ExecutionStatsCallback = Arc::new(move |counts| { + let mut guard = stats_for_callback.lock().unwrap_or_else(|e| e.into_inner()); + *guard = Some(counts.clone()); + }); + scanner.scan_stats_callback(callback); + } + Self { inner: Arc::new(scanner), + stats, } } + fn reset_stats(&self) { + let mut guard = self.stats.lock().unwrap_or_else(|e| e.into_inner()); + *guard = None; + } + pub fn open_stream(&self) -> Result { - let res = RT.block_on(self.inner.try_into_stream())?; + self.reset_stats(); + let res = block_on(self.inner.try_into_stream())?; Ok(res) } pub fn schema(&self) -> Result { - let res = RT.block_on(self.inner.schema())?; + let res = block_on(self.inner.schema())?; Ok(res) } pub fn count_rows(&self) -> Result { - let res = RT.block_on(self.inner.count_rows())?; + let res = block_on(self.inner.count_rows())?; Ok(res) } + + pub fn get_stats(&self) -> Option { + self.stats.lock().unwrap().clone() + } } -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, @@ -78,6 +118,7 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> let max_expansions = env.get_int_as_usize_from_method(&java_obj, "getMaxExpansions")?; let operator = env.get_fts_operator_from_method(&java_obj)?; let prefix_length = env.get_u32_from_method(&java_obj, "getPrefixLength")?; + let document_granularity = get_document_granularity(env, &java_obj)?; let mut query = FtsMatchQuery::new(query_text); query = query.with_column(Some(column)); @@ -87,6 +128,9 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> .with_max_expansions(max_expansions) .with_operator(operator) .with_prefix_length(prefix_length); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity(document_granularity); + } Ok(FtsQuery::Match(query)) } @@ -94,10 +138,14 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> let query_text = env.get_string_from_method(&java_obj, "getQueryText")?; let column = env.get_string_from_method(&java_obj, "getColumn")?; let slop = env.get_u32_from_method(&java_obj, "getSlop")?; + let document_granularity = get_document_granularity(env, &java_obj)?; let mut query = FtsPhraseQuery::new(query_text); query = query.with_column(Some(column)); query = query.with_slop(slop); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity(document_granularity); + } Ok(FtsQuery::Phrase(query)) } @@ -126,6 +174,33 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> Ok(FtsQuery::MultiMatch(query)) } + "COMBINED_FIELDS" => { + let query_text = env.get_string_from_method(&java_obj, "getQueryText")?; + let columns: Vec = + import_vec_from_method(env, &java_obj, "getColumns", |env, elem| { + let jstr = JString::from(elem); + let value: String = env.get_string(&jstr)?.into(); + Ok(value) + })?; + + let boosts: Option> = + env.get_optional_from_method(&java_obj, "getBoosts", |env, list_obj| { + import_vec_to_rust(env, &list_obj, |env, elem| { + env.get_f32_from_method(&elem, "floatValue") + }) + })?; + let operator = env.get_fts_operator_from_method(&java_obj)?; + + // Column uniqueness and boost (>= 1) validation live in the Rust core; + // `?` surfaces those errors across the JNI boundary. + let mut query = FtsCombinedFieldsQuery::try_new(query_text, columns)?; + if let Some(boosts) = boosts { + query = query.try_with_boosts(boosts)?; + } + query = query.with_operator(operator); + + Ok(FtsQuery::CombinedFields(query)) + } "BOOST" => { let positive_obj = env .call_method( @@ -193,79 +268,64 @@ fn build_full_text_search_query<'a>(env: &mut JNIEnv<'a>, java_obj: JObject) -> } } -/////////////////// -// Write Methods // -/////////////////// -#[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 - with_row_id: jboolean, // boolean - with_row_address: jboolean, // boolean - batch_readahead: jint, // int - column_orderings: 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, - with_row_id, - with_row_address, - batch_readahead, - column_orderings - ) +fn get_document_granularity( + env: &mut JNIEnv<'_>, + java_obj: &JObject, +) -> Result> { + env.get_optional_from_method( + java_obj, + "getDocumentGranularity", + |env, granularity_obj| { + let value = env.get_string_from_method(&granularity_obj, "toRustString")?; + DocumentGranularity::try_from(value.as_str()).map_err(Error::from) + }, ) } -#[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, - with_row_id: jboolean, - with_row_address: jboolean, - batch_readahead: jint, - column_orderings: 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) }?; +/// Scanner options passed from JNI - shared between blocking and async scanners +pub(crate) struct ScannerOptions<'a> { + pub fragment_ids_obj: JObject<'a>, + pub index_segments_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 batch_size_bytes_obj: JObject<'a>, + pub io_buffer_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 fragment_readahead_obj: JObject<'a>, + pub scan_in_order: jboolean, + pub late_materialization_obj: JObject<'a>, + pub column_orderings: JObject<'a>, + pub use_scalar_index: jboolean, + pub fast_search: jboolean, + pub substrait_aggregate_obj: JObject<'a>, + pub include_deleted_rows: jboolean, + pub strict_batch_size: jboolean, + pub disable_scoring_autoprojection: jboolean, +} - 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" ))); @@ -274,49 +334,123 @@ fn inner_create_scanner<'local>( } scanner.with_fragments(fragments); } - drop(dataset_guard); - let columns_opt = env.get_strings_opt(&columns_obj)?; + env.get_optional(&options.index_segments_obj, |env, java_segments| { + let index_segments: Vec = + import_vec_to_rust(env, &java_segments, |env, obj| obj.extract_object(env))?; + scanner.with_index_segments(index_segments)?; + Ok(()) + })?; + + 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) })?; + 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 batch_size_bytes_opt = env.get_long_opt(&options.batch_size_bytes_obj)?; + if let Some(batch_size_bytes) = batch_size_bytes_opt { + let batch_size_bytes = u64::try_from(batch_size_bytes).map_err(|_| { + Error::input_error(format!( + "batchSizeBytes must be non-negative, got {batch_size_bytes}" + )) + })?; + scanner.batch_size_bytes(batch_size_bytes); + } + + let io_buffer_size_opt = env.get_long_opt(&options.io_buffer_size_obj)?; + if let Some(io_buffer_size) = io_buffer_size_opt { + let io_buffer_size = u64::try_from(io_buffer_size).map_err(|_| { + Error::input_error(format!( + "ioBufferSize must be non-negative, got {io_buffer_size}" + )) + })?; + scanner.io_buffer_size(io_buffer_size); + } + + 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(); } - env.get_optional(&query_obj, |env, java_obj| { + if options.prefilter == JNI_TRUE { + scanner.prefilter(true); + } + + scanner.use_scalar_index(options.use_scalar_index == JNI_TRUE); + + 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")?; let key = Float32Array::from(key_array); let k = env.get_int_as_usize_from_method(&java_obj, "getK")?; - let _ = scanner.nearest(&column, &key, k); + let query_vector_dim = env + .call_method(&java_obj, "getQueryVectorDim", "()I", &[])? + .i()?; + if query_vector_dim > 0 { + // The core interprets a list-shaped query against a multivector column as ONE + // multivector query (no `query_index`), which would silently break the + // `setKeys` batch contract, so reject it here. + if let Some(field) = dataset.schema().field(&column) + && matches!( + field.data_type(), + DataType::List(_) | DataType::LargeList(_) + ) + { + return Err(Error::input_error(format!( + "Batch vector search (setKeys) is not supported on multivector column '{}' \ + of type {:?}", + column, + field.data_type() + ))); + } + // Batch nearest-neighbor search: the flat buffer packs multiple query + // vectors of `query_vector_dim` values each. Wrapping it in a FixedSizeList + // makes the core scanner run a shared partition scan across the batch and + // emit a `query_index` column tagging each result row with its query. + let batch_keys = FixedSizeListArray::try_new( + Arc::new(Field::new("item", DataType::Float32, false)), + query_vector_dim, + Arc::new(key) as ArrayRef, + None, + ) + .map_err(|e| { + Error::input_error(format!( + "Failed to construct FixedSizeListArray for batch query: {e}" + )) + })?; + scanner + .nearest(&column, &batch_keys, k) + .map_err(|err| Error::input_error(err.to_string()))?; + } else { + scanner + .nearest(&column, &key, k) + .map_err(|err| Error::input_error(err.to_string()))?; + } let minimum_nprobes = env.get_int_as_usize_from_method(&java_obj, "getMinimumNprobes")?; scanner.minimum_nprobes(minimum_nprobes); @@ -345,19 +479,72 @@ fn inner_create_scanner<'local>( let use_index = env.get_boolean_from_method(&java_obj, "isUseIndex")?; scanner.use_index(use_index); + + let query_parallelism = env + .call_method(&java_obj, "getQueryParallelism", "()I", &[])? + .i()?; + scanner.query_parallelism(query_parallelism); + + let approx_mode_str = env.get_string_from_method(&java_obj, "getApproxModeString")?; + scanner.approx_mode(parse_approx_mode(&approx_mode_str)?); 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); + if options.fast_search == JNI_TRUE { + scanner.fast_search(); + } - env.get_optional(&column_orderings, |env, java_obj| { + scanner.batch_readahead(options.batch_readahead as usize); + + let fragment_readahead_opt = env.get_int_opt(&options.fragment_readahead_obj)?; + if let Some(fragment_readahead) = fragment_readahead_opt { + let fragment_readahead = usize::try_from(fragment_readahead).map_err(|_| { + Error::input_error(format!( + "fragmentReadahead must be greater than 0, got {fragment_readahead}" + )) + })?; + if fragment_readahead == 0 { + return Err(Error::input_error( + "fragmentReadahead must be greater than 0, got 0".to_string(), + )); + } + scanner.fragment_readahead(fragment_readahead); + } + + scanner.scan_in_order(options.scan_in_order == JNI_TRUE); + + env.get_optional(&options.late_materialization_obj, |env, java_obj| { + let style_name = env.get_string_from_method(&java_obj, "toRustString")?; + let style = match style_name.as_str() { + "heuristic" => MaterializationStyle::Heuristic, + "all_late" => MaterializationStyle::AllLate, + "all_early" => MaterializationStyle::AllEarly, + "all_early_except" => { + let columns: Vec = + import_vec_from_method(env, &java_obj, "getColumns", |env, elem| { + let jstr = JString::from(elem); + Ok(env.get_string(&jstr)?.into()) + })?; + MaterializationStyle::all_early_except(&columns, dataset.schema())? + } + other => { + return Err(Error::input_error(format!( + "Unsupported materialization style: {other}" + ))); + } + }; + scanner.materialization_style(style); + Ok(()) + })?; + + 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); @@ -376,11 +563,169 @@ fn inner_create_scanner<'local>( Ok(()) })?; - let scanner = BlockingScanner::create(scanner); + 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))?; + } + + if options.include_deleted_rows == JNI_TRUE { + scanner.include_deleted_rows(); + } + + scanner.strict_batch_size(options.strict_batch_size == JNI_TRUE); + + if options.disable_scoring_autoprojection == JNI_TRUE { + scanner.disable_scoring_autoprojection(); + } + + 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> + index_segments_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 + batch_size_bytes_obj: JObject<'local>, // Optional + io_buffer_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 + fragment_readahead_obj: JObject<'local>, // Optional + scan_in_order: jboolean, // boolean + late_materialization_obj: JObject<'local>, // Optional + column_orderings: JObject<'local>, // Optional> + use_scalar_index: jboolean, // boolean + fast_search: jboolean, // boolean + substrait_aggregate_obj: JObject<'local>, // Optional + collect_stats: jboolean, // boolean + include_deleted_rows: jboolean, // boolean + strict_batch_size: jboolean, // boolean + disable_scoring_autoprojection: jboolean, // boolean +) -> JObject<'local> { + ok_or_throw!( + env, + inner_create_scanner( + &mut env, + jdataset, + fragment_ids_obj, + index_segments_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + batch_size_bytes_obj, + io_buffer_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + fragment_readahead_obj, + scan_in_order, + late_materialization_obj, + column_orderings, + use_scalar_index, + fast_search, + substrait_aggregate_obj, + collect_stats, + include_deleted_rows, + strict_batch_size, + disable_scoring_autoprojection, + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_create_scanner<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject<'local>, + fragment_ids_obj: JObject<'local>, + index_segments_obj: JObject<'local>, + columns_obj: JObject<'local>, + substrait_filter_obj: JObject<'local>, + filter_obj: JObject<'local>, + batch_size_obj: JObject<'local>, + batch_size_bytes_obj: JObject<'local>, + io_buffer_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, + fragment_readahead_obj: JObject<'local>, + scan_in_order: jboolean, + late_materialization_obj: JObject<'local>, + column_orderings: JObject<'local>, + use_scalar_index: jboolean, + fast_search: jboolean, + substrait_aggregate_obj: JObject<'local>, + collect_stats: jboolean, + include_deleted_rows: jboolean, + strict_batch_size: jboolean, + disable_scoring_autoprojection: jboolean, +) -> 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, + index_segments_obj, + columns_obj, + substrait_filter_obj, + filter_obj, + batch_size_obj, + batch_size_bytes_obj, + io_buffer_size_obj, + limit_obj, + offset_obj, + query_obj, + fts_query_obj, + prefilter, + with_row_id, + with_row_address, + batch_readahead, + fragment_readahead_obj, + scan_in_order, + late_materialization_obj, + column_orderings, + use_scalar_index, + fast_search, + substrait_aggregate_obj, + include_deleted_rows, + strict_batch_size, + disable_scoring_autoprojection, + }; + + let scanner = build_scanner_with_options(env, &dataset, options)?; + + let scanner = BlockingScanner::create(scanner, collect_stats == JNI_TRUE); scanner.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_ipc_LanceScanner_releaseNativeScanner( mut env: JNIEnv, j_scanner: JObject, @@ -425,7 +770,7 @@ fn create_java_scanner_object<'a>(env: &mut JNIEnv<'a>) -> Result> { ////////////////// // Read Methods // ////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_ipc_LanceScanner_openStream( mut env: JNIEnv, j_scanner: JObject, @@ -435,6 +780,38 @@ pub extern "system" fn Java_org_lance_ipc_LanceScanner_openStream( } fn inner_open_stream(env: &mut JNIEnv, j_scanner: JObject, stream_addr: jlong) -> Result<()> { + if stream_addr == 0 { + return Err(Error::input_error( + "ArrowArrayStream address must not be null".to_string(), + )); + } + + // Reject a stream that already holds a producer. We write the C struct in place below with + // `ptr::write_unaligned`, which does not run any destructor on the previous contents. If the + // caller passed a stream whose `release` callback is already set (e.g. it was populated by an + // earlier export and not yet released), overwriting it would drop that callback and leak the + // first producer's resources. A freshly-allocated `ArrowArrayStream` has a null `release`, per + // the Arrow C Data Interface, so requiring `release == None` is the contract for "empty". + // + // The struct is allocated by Arrow Java inside an ArrowBuf and is not guaranteed to be aligned + // (hence `write_unaligned` below), so we must not form a reference to it. We read only the + // `release` field through an unaligned read: `addr_of!` computes the field address without + // creating an intermediate, possibly-unaligned reference, and the field is an `Option` + // which is `Copy` with no destructor, so reading a copy of it leaves the caller's stream + // untouched. + let release_is_set = unsafe { + let stream_ptr = stream_addr as *const FFI_ArrowArrayStream; + let release = std::ptr::read_unaligned(std::ptr::addr_of!((*stream_ptr).release)); + release.is_some() + }; + if release_is_set { + return Err(Error::input_error( + "ArrowArrayStream is already populated; exporting into it would leak the existing \ + producer. Pass a freshly-allocated, empty stream." + .to_string(), + )); + } + let record_batch_stream = { let scanner_guard = unsafe { env.get_rust_field::<_, _, BlockingScanner>(j_scanner, NATIVE_SCANNER) }?; @@ -445,7 +822,7 @@ fn inner_open_stream(env: &mut JNIEnv, j_scanner: JObject, stream_addr: jlong) - Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_ipc_LanceScanner_importFfiSchema( mut env: JNIEnv, j_scanner: JObject, @@ -468,7 +845,7 @@ fn inner_import_ffi_schema(env: &mut JNIEnv, j_scanner: JObject, schema_addr: jl Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_ipc_LanceScanner_nativeCountRows( mut env: JNIEnv, j_scanner: JObject, @@ -481,3 +858,70 @@ fn inner_count_rows(env: &mut JNIEnv, j_scanner: JObject) -> Result { unsafe { env.get_rust_field::<_, _, BlockingScanner>(j_scanner, NATIVE_SCANNER) }?; scanner_guard.count_rows() } + +const SCAN_STATS_CLASS: &str = "org/lance/ipc/ScanStats"; +const SCAN_STATS_CONSTRUCTOR_SIG: &str = "(JJJJJJJJLjava/util/Map;Ljava/util/Map;)V"; + +fn export_usize_map<'a>(env: &mut JNIEnv<'a>, map: &HashMap) -> Result> { + let hash_map = env.new_object("java/util/HashMap", "()V", &[])?; + for (key, value) in map { + let java_key: JObject = env.new_string(key)?.into(); + let java_value = + env.new_object("java/lang/Long", "(J)V", &[JValueGen::Long(*value as i64)])?; + env.call_method( + &hash_map, + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + &[JValueGen::Object(&java_key), JValueGen::Object(&java_value)], + )?; + } + Ok(hash_map) +} + +impl IntoJava for &ExecutionSummaryCounts { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let all_counts = export_usize_map(env, &self.all_counts)?; + let all_times = export_usize_map(env, &self.all_times)?; + let obj = env.new_object( + SCAN_STATS_CLASS, + SCAN_STATS_CONSTRUCTOR_SIG, + &[ + JValueGen::Long(self.iops as i64), + JValueGen::Long(self.requests as i64), + JValueGen::Long(self.bytes_read as i64), + JValueGen::Long(self.indices_loaded as i64), + JValueGen::Long(self.parts_loaded as i64), + JValueGen::Long(self.index_comparisons as i64), + JValueGen::Long(self.index_cache_hits() as i64), + JValueGen::Long(self.index_cache_misses() as i64), + JValueGen::Object(&all_counts), + JValueGen::Object(&all_times), + ], + )?; + Ok(obj) + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_ipc_LanceScanner_nativeGetStats<'local>( + mut env: JNIEnv<'local>, + j_scanner: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_get_stats(&mut env, j_scanner)) +} + +fn inner_get_stats<'local>( + env: &mut JNIEnv<'local>, + j_scanner: JObject<'local>, +) -> Result> { + let stats = { + let scanner_guard = + unsafe { env.get_rust_field::<_, _, BlockingScanner>(j_scanner, NATIVE_SCANNER) }?; + scanner_guard.get_stats() + }; + + match stats { + Some(stats) => (&stats).into_java(env), + None => Ok(JObject::null()), + } +} diff --git a/java/lance-jni/src/delta.rs b/java/lance-jni/src/delta.rs index 8a407a19167..5cd5300787f 100755 --- a/java/lance-jni/src/delta.rs +++ b/java/lance-jni/src/delta.rs @@ -5,11 +5,11 @@ use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; use crate::error::Result; use crate::ffi::JNIEnvExt; use crate::transaction::convert_to_java_transaction; -use crate::RT; +use crate::{RT, block_on}; use arrow::ffi_stream::FFI_ArrowArrayStream; +use jni::JNIEnv; use jni::objects::{JObject, JValue}; use jni::sys::jlong; -use jni::JNIEnv; use lance::dataset::delta::DatasetDelta as RustDatasetDelta; use lance::dataset::scanner::DatasetRecordBatchStream; use lance::dataset::transaction::Transaction; @@ -39,7 +39,7 @@ fn attach_native_delta<'local>( Ok(j_delta) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_delta_DatasetDeltaBuilder_nativeBuild<'local>( mut env: JNIEnv<'local>, _obj: JObject<'local>, @@ -60,7 +60,7 @@ pub extern "system" fn Java_org_lance_delta_DatasetDeltaBuilder_nativeBuild<'loc ) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeBuildDelta<'local>( mut env: JNIEnv<'local>, java_dataset: JObject<'local>, @@ -108,8 +108,8 @@ fn inner_native_build<'local>( attach_native_delta(env, blocking_delta, &java_dataset) } -#[no_mangle] -pub extern "system" fn Java_org_lance_delta_DatasetDelta_listTransactions<'local>( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_delta_DatasetDelta_nativeListTransactions<'local>( mut env: JNIEnv<'local>, j_delta: JObject<'local>, ) -> JObject<'local> { @@ -123,16 +123,12 @@ fn inner_list_transactions<'local>( let txs: Vec = { let delta_guard = unsafe { env.get_rust_field::<_, _, BlockingDatasetDelta>(&j_delta, NATIVE_DELTA) }?; - RT.block_on(delta_guard.inner.list_transactions())? + block_on(delta_guard.inner.list_transactions())? }; - let java_dataset = env - .get_field(&j_delta, "dataset", "Lorg/lance/Dataset;")? - .l()?; - let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; for tx in txs.into_iter() { - let jtx = convert_to_java_transaction(env, tx, &java_dataset)?; + let jtx = convert_to_java_transaction(env, tx)?; env.call_method( &array_list, "add", @@ -143,8 +139,8 @@ fn inner_list_transactions<'local>( Ok(array_list) } -#[no_mangle] -pub extern "system" fn Java_org_lance_delta_DatasetDelta_getInsertedRows<'local>( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_delta_DatasetDelta_nativeGetInsertedRows<'local>( mut env: JNIEnv<'local>, j_delta: JObject<'local>, stream_addr: jlong, @@ -160,15 +156,15 @@ fn inner_get_inserted_rows<'local>( let delta_guard = unsafe { env.get_rust_field::<_, _, BlockingDatasetDelta>(&j_delta, NATIVE_DELTA) }?; - let stream: DatasetRecordBatchStream = RT.block_on(delta_guard.inner.get_inserted_rows())?; + let stream: DatasetRecordBatchStream = block_on(delta_guard.inner.get_inserted_rows())?; let ffi_stream = to_ffi_arrow_array_stream(stream, RT.handle().clone())?; unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } Ok(()) } -#[no_mangle] -pub extern "system" fn Java_org_lance_delta_DatasetDelta_getUpdatedRows<'local>( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_delta_DatasetDelta_nativeGetUpdatedRows<'local>( mut env: JNIEnv<'local>, j_delta: JObject<'local>, stream_addr: jlong, @@ -184,14 +180,41 @@ fn inner_get_updated_rows<'local>( let delta_guard = unsafe { env.get_rust_field::<_, _, BlockingDatasetDelta>(&j_delta, NATIVE_DELTA) }?; - let stream: DatasetRecordBatchStream = RT.block_on(delta_guard.inner.get_updated_rows())?; + let stream: DatasetRecordBatchStream = block_on(delta_guard.inner.get_updated_rows())?; + let ffi_stream = to_ffi_arrow_array_stream(stream, RT.handle().clone())?; + + unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_delta_DatasetDelta_nativeGetDeletedRowIds<'local>( + mut env: JNIEnv<'local>, + j_delta: JObject<'local>, + stream_addr: jlong, +) { + ok_or_throw_without_return!( + env, + inner_get_deleted_row_ids(&mut env, j_delta, stream_addr) + ) +} + +fn inner_get_deleted_row_ids<'local>( + env: &mut JNIEnv, + j_delta: JObject<'local>, + stream_addr: jlong, +) -> Result<()> { + let delta_guard = + unsafe { env.get_rust_field::<_, _, BlockingDatasetDelta>(&j_delta, NATIVE_DELTA) }?; + + let stream: DatasetRecordBatchStream = block_on(delta_guard.inner.get_deleted_row_ids())?; let ffi_stream = to_ffi_arrow_array_stream(stream, RT.handle().clone())?; unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_delta_DatasetDelta_releaseNativeDelta( mut env: JNIEnv, obj: JObject, diff --git a/java/lance-jni/src/dispatcher.rs b/java/lance-jni/src/dispatcher.rs new file mode 100644 index 00000000000..2f0acb5f954 --- /dev/null +++ b/java/lance-jni/src/dispatcher.rs @@ -0,0 +1,163 @@ +// 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. + /// + /// `async_scanner_class` must be a `GlobalRef` to the `AsyncScanner` + /// Java class, resolved on a thread that has the correct application + /// classloader (typically the `JNI_OnLoad` thread). + pub fn initialize(jvm: Arc, async_scanner_class: GlobalRef) -> 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 as daemon thread so it won't prevent JVM from exiting. + // The thread stays attached for the entire lifetime (never detach), + // but being a daemon thread means JVM won't wait for it on shutdown. + let mut env = jvm + .attach_current_thread_as_daemon() + .expect("Failed to attach dispatcher to JVM"); + + log::info!("JNI dispatcher thread started"); + + // Cache method IDs for completeTask and failTask. + // Use the pre-resolved GlobalRef instead of find_class to + // avoid classloader issues on this native thread. + 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/error.rs b/java/lance-jni/src/error.rs index ef05b8cdb5c..0affdca8f98 100644 --- a/java/lance-jni/src/error.rs +++ b/java/lance-jni/src/error.rs @@ -4,7 +4,7 @@ use std::str::Utf8Error; use arrow_schema::ArrowError; -use jni::{errors::Error as JniError, JNIEnv}; +use jni::{JNIEnv, errors::Error as JniError}; use lance::Error as LanceError; use lance_namespace::error::NamespaceError; use serde_json::Error as JsonError; @@ -17,6 +17,7 @@ pub enum JavaExceptionClass { UnsupportedOperationException, AlreadyInException, LanceNamespaceException, + LanceTimeoutException, } impl JavaExceptionClass { @@ -29,6 +30,7 @@ impl JavaExceptionClass { // Included for display purposes. This is not a real exception. Self::AlreadyInException => "AlreadyInException", Self::LanceNamespaceException => "org/lance/namespace/errors/LanceNamespaceException", + Self::LanceTimeoutException => "org/lance/LanceTimeoutException", } } } @@ -69,6 +71,10 @@ impl Error { Self::new(message, JavaExceptionClass::UnsupportedOperationException) } + pub fn timeout_error(message: String) -> Self { + Self::new(message, JavaExceptionClass::LanceTimeoutException) + } + pub fn namespace_error(code: u32, message: String) -> Self { Self { message, @@ -92,21 +98,21 @@ impl Error { } // For namespace errors, throw the specific LanceNamespaceException - if self.java_class == JavaExceptionClass::LanceNamespaceException { - if let Some(code) = self.namespace_error_code { - // Call LanceNamespaceException.fromCode static method - if self.throw_namespace_exception(env, code).is_err() { - // lance-namespace is bundled as a dependency, so the exception classes - // should always be available. Panic if they're not. - panic!( - "Failed to throw LanceNamespaceException (code={}). \ + if self.java_class == JavaExceptionClass::LanceNamespaceException + && let Some(code) = self.namespace_error_code + { + // Call LanceNamespaceException.fromCode static method + if self.throw_namespace_exception(env, code).is_err() { + // lance-namespace is bundled as a dependency, so the exception classes + // should always be available. Panic if they're not. + panic!( + "Failed to throw LanceNamespaceException (code={}). \ org.lance.namespace.errors.LanceNamespaceException and ErrorCode classes \ must be available in the classpath.", - code - ); - } - return; + code + ); } + return; } if let Err(e) = env.throw_new(self.java_class.as_str(), &self.message) { @@ -120,60 +126,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"; - - // Find the ErrorCode.fromCode method - let error_code_cls = env.find_class(error_code_class).map_err(|_| ())?; - let from_code_method = env + // Use ErrorFactory.fromErrorCode(code, message) to get the specific exception subclass + // (e.g., TableNotFoundException, NamespaceNotFoundException, etc.) + let factory_class = "org/lance/namespace/errors/ErrorFactory"; + + 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 +158,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(()) @@ -201,28 +181,36 @@ impl std::fmt::Display for Error { impl From for Error { fn from(err: LanceError) -> Self { + let backtrace_suffix = err + .backtrace() + .map(|bt| format!("\n\nRust backtrace:\n{}", bt)) + .unwrap_or_default(); + let message = format!("{}{}", err, backtrace_suffix); + match &err { LanceError::DatasetNotFound { .. } | LanceError::DatasetAlreadyExists { .. } | LanceError::CommitConflict { .. } - | LanceError::InvalidInput { .. } => Self::input_error(err.to_string()), - LanceError::IO { .. } => Self::io_error(err.to_string()), - LanceError::NotSupported { .. } => Self::unsupported_error(err.to_string()), - LanceError::NotFound { .. } => Self::io_error(err.to_string()), + | LanceError::InvalidInput { .. } => Self::input_error(message), + LanceError::IO { .. } => Self::io_error(message), + LanceError::Timeout { .. } => Self::timeout_error(message), + LanceError::NotSupported { .. } => Self::unsupported_error(message), + LanceError::NotFound { .. } => Self::io_error(message), LanceError::Namespace { source, .. } => { // Try to downcast to NamespaceError and get the error code if let Some(ns_err) = source.downcast_ref::() { - Self::namespace_error(ns_err.code().as_u32(), ns_err.to_string()) + let ns_message = format!("{}{}", ns_err, backtrace_suffix); + Self::namespace_error(ns_err.code().as_u32(), ns_message) } else { log::warn!( "Failed to downcast NamespaceError source, falling back to runtime error. \ This may indicate a version mismatch. Source type: {:?}", source ); - Self::runtime_error(err.to_string()) + Self::runtime_error(message) } } - _ => Self::runtime_error(err.to_string()), + _ => Self::runtime_error(message), } } } @@ -260,3 +248,104 @@ impl From for Error { Self::input_error(err.to_string()) } } + +#[cfg(test)] +mod tests { + use super::*; + + // Helper: extract the java_class from an Error via Display output + fn java_class(err: &Error) -> &JavaExceptionClass { + &err.java_class + } + + #[test] + fn test_invalid_input_maps_to_illegal_argument() { + let lance_err = LanceError::invalid_input("bad input"); + let jni_err: Error = lance_err.into(); + assert_eq!( + *java_class(&jni_err), + JavaExceptionClass::IllegalArgumentException + ); + assert!(jni_err.message.contains("bad input")); + } + + #[test] + fn test_dataset_not_found_maps_to_illegal_argument() { + let lance_err = LanceError::dataset_not_found("my_dataset", "not found".to_string().into()); + let jni_err: Error = lance_err.into(); + assert_eq!( + *java_class(&jni_err), + JavaExceptionClass::IllegalArgumentException + ); + assert!(jni_err.message.contains("my_dataset")); + } + + #[test] + fn test_dataset_already_exists_maps_to_illegal_argument() { + let lance_err = LanceError::dataset_already_exists("my_dataset"); + let jni_err: Error = lance_err.into(); + assert_eq!( + *java_class(&jni_err), + JavaExceptionClass::IllegalArgumentException + ); + assert!(jni_err.message.contains("my_dataset")); + } + + #[test] + fn test_commit_conflict_maps_to_illegal_argument() { + let lance_err = LanceError::commit_conflict_source(42, "conflict".to_string().into()); + let jni_err: Error = lance_err.into(); + assert_eq!( + *java_class(&jni_err), + JavaExceptionClass::IllegalArgumentException + ); + } + + #[test] + fn test_io_maps_to_ioexception() { + let lance_err = LanceError::io("disk failure"); + let jni_err: Error = lance_err.into(); + assert_eq!(*java_class(&jni_err), JavaExceptionClass::IOException); + assert!(jni_err.message.contains("disk failure")); + } + + #[test] + fn test_not_supported_maps_to_unsupported() { + let lance_err = LanceError::not_supported("nope"); + let jni_err: Error = lance_err.into(); + assert_eq!( + *java_class(&jni_err), + JavaExceptionClass::UnsupportedOperationException + ); + assert!(jni_err.message.contains("nope")); + } + + #[test] + fn test_not_found_maps_to_ioexception() { + let lance_err = LanceError::not_found("missing_uri"); + let jni_err: Error = lance_err.into(); + assert_eq!(*java_class(&jni_err), JavaExceptionClass::IOException); + assert!(jni_err.message.contains("missing_uri")); + } + + #[test] + fn test_fallthrough_maps_to_runtime() { + let lance_err = LanceError::internal("internal oops"); + let jni_err: Error = lance_err.into(); + assert_eq!(*java_class(&jni_err), JavaExceptionClass::RuntimeException); + assert!(jni_err.message.contains("internal oops")); + } + + #[test] + fn test_no_backtrace_suffix_when_backtrace_is_none() { + // Without the backtrace feature enabled in lance-core default tests, + // backtrace() returns None, so no suffix should be appended. + let lance_err = LanceError::io("clean message"); + let jni_err: Error = lance_err.into(); + assert!( + !jni_err.message.contains("Rust backtrace:"), + "Expected no backtrace suffix, got: {}", + jni_err.message + ); + } +} diff --git a/java/lance-jni/src/ffi.rs b/java/lance-jni/src/ffi.rs index d1f656873d8..5c4bc716455 100644 --- a/java/lance-jni/src/ffi.rs +++ b/java/lance-jni/src/ffi.rs @@ -3,12 +3,12 @@ use core::slice; +use crate::Error; use crate::error::Result; use crate::utils::{get_query, get_vector_index_params}; -use crate::Error; use jni::objects::{JByteBuffer, JFloatArray, JObjectArray, JString}; use jni::sys::jobjectArray; -use jni::{objects::JObject, JNIEnv}; +use jni::{JNIEnv, objects::JObject}; use lance_index::scalar::inverted::query::{Occur, Operator}; /// Extend JNIEnv with helper functions. @@ -473,7 +473,7 @@ impl JNIEnvExt for JNIEnv<'_> { } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_test_JniTestHelper_parseInts( mut env: JNIEnv, _obj: JObject, @@ -482,7 +482,7 @@ pub extern "system" fn Java_org_lance_test_JniTestHelper_parseInts( ok_or_throw_without_return!(env, env.get_integers(&list_obj)); } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_test_JniTestHelper_parseLongs( mut env: JNIEnv, _obj: JObject, @@ -491,7 +491,7 @@ pub extern "system" fn Java_org_lance_test_JniTestHelper_parseLongs( ok_or_throw_without_return!(env, env.get_longs(&list_obj)); } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_test_JniTestHelper_parseIntsOpt( mut env: JNIEnv, _obj: JObject, @@ -500,7 +500,7 @@ pub extern "system" fn Java_org_lance_test_JniTestHelper_parseIntsOpt( ok_or_throw_without_return!(env, env.get_ints_opt(&list_obj)); } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_test_JniTestHelper_parseQuery( mut env: JNIEnv, _obj: JObject, @@ -509,7 +509,7 @@ pub extern "system" fn Java_org_lance_test_JniTestHelper_parseQuery( ok_or_throw_without_return!(env, get_query(&mut env, query_opt)); } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_test_JniTestHelper_parseIndexParams( mut env: JNIEnv, _obj: JObject, diff --git a/java/lance-jni/src/file_reader.rs b/java/lance-jni/src/file_reader.rs index ccaac121579..35d2ecd8f77 100644 --- a/java/lance-jni/src/file_reader.rs +++ b/java/lance-jni/src/file_reader.rs @@ -1,33 +1,35 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::BTreeMap; use std::ops::Range; use std::sync::{Arc, Mutex}; use crate::utils::to_rust_map; use crate::{ + JNIEnvExt, RT, block_on, error::{Error, Result}, traits::IntoJava, - JNIEnvExt, RT, }; use arrow::{array::RecordBatchReader, ffi::FFI_ArrowSchema, ffi_stream::FFI_ArrowArrayStream}; use arrow_schema::SchemaRef; use jni::objects::JMap; use jni::{ + JNIEnv, objects::{JObject, JString}, sys::{jint, jlong}, - JNIEnv, }; use lance::io::ObjectStore; use lance_core::cache::LanceCache; -use lance_core::datatypes::Schema; +use lance_core::datatypes::{BlobHandling, OnMissing, Projection, Schema}; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; use lance_file::reader::{FileReader, FileReaderOptions, ReaderProjection}; +use lance_file::versions as file_versions; use lance_io::object_store::{ObjectStoreParams, ObjectStoreRegistry}; use lance_io::{ + ReadBatchParams, scheduler::{ScanScheduler, SchedulerConfig}, utils::CachedFileSize, - ReadBatchParams, }; use object_store::path::Path; @@ -51,16 +53,15 @@ impl BlockingFileReader { filter_expression: FilterExpression, ) -> Result> { let reader = self.inner.clone(); - Ok(RT - .block_on(RT.spawn_blocking(move || { - reader.read_stream_projected_blocking( - read_batch_params, - batch_size, - reader_projection, - filter_expression, - ) - })) - .unwrap()?) + Ok(block_on(RT.spawn_blocking(move || { + reader.read_stream_projected_blocking( + read_batch_params, + batch_size, + reader_projection, + filter_expression, + ) + })) + .unwrap()?) } pub fn schema(&self) -> Result { @@ -92,7 +93,7 @@ fn create_java_reader_object<'a>(env: &mut JNIEnv<'a>) -> Result> { Ok(res) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileReader_openNative<'local>( mut env: JNIEnv<'local>, _reader_class: JObject, @@ -110,7 +111,7 @@ fn inner_open<'local>( let file_uri_str: String = env.get_string(&file_uri)?.into(); let jmap = JMap::from_env(env, &storage_options_obj)?; let storage_options = to_rust_map(env, &jmap)?; - let reader = RT.block_on(async move { + let reader = block_on(async move { let object_params = ObjectStoreParams { storage_options_accessor: Some(Arc::new( lance::io::StorageOptionsAccessor::with_static_options(storage_options), @@ -144,7 +145,7 @@ fn inner_open<'local>( reader.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileReader_closeNative<'local>( mut env: JNIEnv<'local>, reader: JObject, @@ -160,7 +161,7 @@ pub extern "system" fn Java_org_lance_file_LanceFileReader_closeNative<'local>( JObject::null() } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileReader_numRowsNative( mut env: JNIEnv<'_>, reader: JObject, @@ -192,7 +193,7 @@ fn inner_num_rows(env: &mut JNIEnv<'_>, reader: JObject) -> Result { Ok(reader.num_rows() as i64) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileReader_populateSchemaNative( mut env: JNIEnv, reader: JObject, @@ -210,7 +211,7 @@ fn inner_populate_schema(env: &mut JNIEnv, reader: JObject, schema_addr: jlong) Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileReader_readAllNative( mut env: JNIEnv<'_>, reader: JObject, @@ -218,10 +219,10 @@ pub extern "system" fn Java_org_lance_file_LanceFileReader_readAllNative( projected_names: JObject, selection_ranges: JObject, stream_addr: jlong, + blob_read_mode: jint, ) { let result = (|| -> Result<()> { let mut read_parameter = ReadBatchParams::default(); - let mut reader_projection: Option = None; // We get reader here not from env.get_rust_field, because we need reader: MutexGuard has no relationship with the env lifecycle. // If we get reader from env.get_rust_field, we can't use env (can't borrow again) until we drop the reader. #[allow(unused_variables)] @@ -239,17 +240,43 @@ pub extern "system" fn Java_org_lance_file_LanceFileReader_readAllNative( }; let file_version = reader.inner.metadata().version(); + let base_schema = Schema::try_from(reader.schema()?.as_ref())?; + + let blob_handling = if blob_read_mode == 1 { + BlobHandling::BlobsDescriptions + } else { + BlobHandling::AllBinary + }; + + let reader_projection = { + let mut projection = + Projection::empty(Arc::new(base_schema.clone())).with_blob_handling(blob_handling); - if !projected_names.is_null() { - let schema = Schema::try_from(reader.schema()?.as_ref())?; - let column_names: Vec = env.get_strings(&projected_names)?; - let names: Vec<&str> = column_names.iter().map(|s| s.as_str()).collect(); - reader_projection = Some(ReaderProjection::from_column_names( + if !projected_names.is_null() { + let column_names: Vec = env.get_strings(&projected_names)?; + projection = projection.union_columns(&column_names, OnMissing::Error)?; + } else { + projection = projection.union_predicate(|_| true); + } + + let transformed_schema = projection.to_bare_schema(); + + let (field_ids, column_indices) = + file_versions::data_file_columns(file_version, &base_schema); + let field_id_to_column_index = field_ids + .into_iter() + .zip(column_indices) + .filter_map(|(field_id, column_index)| { + (column_index >= 0).then_some((field_id as u32, column_index as u32)) + }) + .collect::>(); + + Some(file_versions::reader_projection_from_field_ids( file_version, - &schema, - names.as_slice(), - )?); - } + &transformed_schema, + &field_id_to_column_index, + )?) + }; if !selection_ranges.is_null() { let mut ranges: Vec> = Vec::new(); diff --git a/java/lance-jni/src/file_writer.rs b/java/lance-jni/src/file_writer.rs index ebc5b1c328b..372ea4a4382 100644 --- a/java/lance-jni/src/file_writer.rs +++ b/java/lance-jni/src/file_writer.rs @@ -3,28 +3,25 @@ use std::sync::{Arc, Mutex}; -use crate::utils::to_rust_map; +use crate::utils::{extract_file_writer_options, to_rust_map}; use crate::{ + JNIEnvExt, block_on, error::{Error, Result}, traits::IntoJava, - JNIEnvExt, RT, }; use arrow::{ array::{RecordBatch, StructArray}, - ffi::{from_ffi_and_data_type, FFI_ArrowArray, FFI_ArrowSchema}, + ffi::{FFI_ArrowArray, FFI_ArrowSchema, from_ffi_and_data_type}, }; use arrow_schema::DataType; use jni::objects::JMap; use jni::{ + JNIEnv, objects::{JObject, JString}, sys::jlong, - JNIEnv, }; use lance::io::ObjectStore; -use lance_file::{ - version::LanceFileVersion, - writer::{FileWriter, FileWriterOptions}, -}; +use lance_file::{version::LanceFileVersion, versions as file_versions, writer::FileWriter}; use lance_io::object_store::{ObjectStoreParams, ObjectStoreRegistry}; pub const NATIVE_WRITER: &str = "nativeFileWriterHandle"; @@ -62,13 +59,14 @@ fn create_java_writer_object<'a>(env: &mut JNIEnv<'a>) -> Result> { Ok(res) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileWriter_openNative<'local>( mut env: JNIEnv<'local>, _writer_class: JObject, file_uri: JString, data_storage_version: JObject, // Optional storage_options_obj: JObject, // Map + file_write_options: JObject, // FileWriteOptions ) -> JObject<'local> { ok_or_throw!( env, @@ -76,7 +74,8 @@ pub extern "system" fn Java_org_lance_file_LanceFileWriter_openNative<'local>( &mut env, file_uri, data_storage_version, - storage_options_obj + storage_options_obj, + file_write_options ) ) } @@ -86,13 +85,16 @@ fn inner_open<'local>( file_uri: JString, data_storage_version: JObject, storage_options_obj: JObject, + file_write_options: JObject, ) -> Result> { let file_uri_str: String = env.get_string(&file_uri)?.into(); let jmap = JMap::from_env(env, &storage_options_obj)?; let data_storage_version_opt = env.get_string_opt(&data_storage_version)?; let storage_options = to_rust_map(env, &jmap)?; + let file_writer_options = + extract_file_writer_options(env, &file_write_options)?.unwrap_or_default(); - let writer = RT.block_on(async move { + let writer = block_on(async move { let object_params = ObjectStoreParams { storage_options_accessor: Some(Arc::new( lance::io::StorageOptionsAccessor::with_static_options(storage_options), @@ -108,15 +110,12 @@ fn inner_open<'local>( let obj_store = Arc::new(obj_store); let obj_writer = obj_store.create(&path).await?; - Result::Ok(FileWriter::new_lazy( - obj_writer, - FileWriterOptions { - format_version: data_storage_version_opt - .map(|v| v.parse::()) - .transpose()?, - ..Default::default() - }, - )) + let version = data_storage_version_opt + .map(|value| value.parse::()) + .transpose()? + .unwrap_or_default() + .resolve(); + file_versions::create_lazy_writer(version, obj_writer, file_writer_options) })?; let writer = BlockingFileWriter::create(writer); @@ -124,7 +123,7 @@ fn inner_open<'local>( writer.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileWriter_closeNative<'local>( mut env: JNIEnv<'local>, writer: JObject, @@ -141,7 +140,7 @@ pub extern "system" fn Java_org_lance_file_LanceFileWriter_closeNative<'local>( } }; if let Some(writer) = writer { - match RT.block_on(writer.inner.lock().unwrap().finish()) { + match block_on(writer.inner.lock().unwrap().finish()) { Ok(_) => {} Err(e) => { Error::from(e).throw(&mut env); @@ -151,7 +150,7 @@ pub extern "system" fn Java_org_lance_file_LanceFileWriter_closeNative<'local>( JObject::null() } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileWriter_nativeAddSchemaMetadata<'local>( mut env: JNIEnv<'local>, writer: JObject, @@ -180,7 +179,7 @@ fn inner_add_schema_metadata( Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_file_LanceFileWriter_writeNative<'local>( mut env: JNIEnv<'local>, writer: JObject, @@ -213,6 +212,6 @@ fn inner_write_batch( let writer = unsafe { env.get_rust_field::<_, _, BlockingFileWriter>(writer, NATIVE_WRITER) }?; let mut writer = writer.inner.lock().unwrap(); - RT.block_on(writer.write_batch(&record_batch))?; + block_on(writer.write_batch(&record_batch))?; Ok(()) } diff --git a/java/lance-jni/src/fragment.rs b/java/lance-jni/src/fragment.rs index 07cc0d53d73..09ea3416e77 100644 --- a/java/lance-jni/src/fragment.rs +++ b/java/lance-jni/src/fragment.rs @@ -2,31 +2,44 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use arrow::array::{RecordBatch, RecordBatchIterator, StructArray}; -use arrow::ffi::{from_ffi_and_data_type, FFI_ArrowArray, FFI_ArrowSchema}; +use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema, from_ffi_and_data_type}; use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream}; -use arrow_schema::DataType; -use jni::objects::{JIntArray, JValue, JValueGen}; +use arrow_schema::{DataType, Schema as ArrowSchema}; +use jni::objects::{JByteArray, JIntArray, JValue, JValueGen}; use jni::{ - objects::{JObject, JString}, - sys::{jint, jlong}, JNIEnv, + objects::{JClass, JLongArray, JObject, JString}, + sys::{jint, jlong, jstring}, }; use lance::datatypes::Schema; -use lance::table::format::{DataFile, DeletionFile, DeletionFileType, Fragment, RowIdMeta}; +use lance::table::format::{ + DataFile, DeletionFile, DeletionFileType, Fragment, RowDatasetVersionMeta, RowIdMeta, +}; use lance_io::utils::CachedFileSize; +use lance_table::rowids::{RowIdSequence, write_row_ids}; use std::iter::once; -use lance::dataset::fragment::FileFragment; +use roaring::RoaringBitmap; + +use lance::dataset::NewColumnTransform; +use lance::dataset::fragment::write::FragmentCreateBuilder; +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::{export_vec, import_vec, FromJObjectWithEnv, IntoJava, JLance}; +use crate::session::session_from_handle; +use crate::traits::{FromJObjectWithEnv, IntoJava, JLance, export_vec, import_vec}; +use crate::utils::extract_storage_options; use crate::{ + block_on, blocking_dataset::{BlockingDataset, NATIVE_DATASET}, traits::FromJString, utils::extract_write_params, - RT, }; #[derive(Debug, Clone)] @@ -39,12 +52,14 @@ pub(crate) struct FragmentMergeResult { pub(crate) struct FragmentUpdateResult { updated_fragment: Fragment, fields_modified: Vec, + /// Matched row offsets serialized as portable RoaringBitmap bytes. + updated_row_offset_bytes: Vec, } ////////////////// // Read Methods // ////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_countRowsNative( mut env: JNIEnv, _jfragment: JObject, @@ -69,28 +84,37 @@ fn inner_count_rows_native( "Fragment not found: {fragment_id}" ))); }; - let res = RT.block_on(fragment.count_rows(None))?; + let res = block_on(fragment.count_rows(None))?; Ok(res) } /////////////////// // Write Methods // /////////////////// -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( mut env: JNIEnv<'local>, _obj: JObject, 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 + base_store_params_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) + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions + schema_addr: jlong, + session_handle: jlong, // Session handle, 0 means no session ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -106,7 +130,16 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiArray<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, + initial_bases, + target_bases, + namespace_obj, + table_id_obj, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, + schema_addr, + session_handle, ), JObject::default() ) @@ -118,14 +151,23 @@ 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 + base_store_params_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) + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions + schema_addr: jlong, + session_handle: jlong, // Session handle, 0 means no session ) -> Result> { let c_array_ptr = arrow_array_addr as *mut FFI_ArrowArray; let c_schema_ptr = arrow_schema_addr as *mut FFI_ArrowSchema; @@ -150,25 +192,43 @@ fn inner_create_with_ffi_array<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, + initial_bases, + target_bases, + namespace_obj, + table_id_obj, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, + schema_addr, + session_handle, reader, ) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( mut env: JNIEnv<'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 + base_store_params_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) + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions + schema_addr: jlong, + session_handle: jlong, // Session handle, 0 means no session ) -> JObject<'a> { ok_or_throw_with_return!( env, @@ -183,7 +243,16 @@ pub extern "system" fn Java_org_lance_Fragment_createWithFfiStream<'a>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, + initial_bases, + target_bases, + namespace_obj, + table_id_obj, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, + schema_addr, + session_handle, ), JObject::null() ) @@ -194,14 +263,23 @@ 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 + base_store_params_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) + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions + schema_addr: jlong, + session_handle: jlong, // Session handle, 0 means no session ) -> Result> { let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; @@ -216,7 +294,16 @@ fn inner_create_with_ffi_stream<'local>( enable_stable_row_ids, data_storage_version, storage_options_obj, - storage_options_provider_obj, + base_store_params_obj, + initial_bases, + target_bases, + namespace_obj, + table_id_obj, + allow_external_blob_outside_bases, + blob_pack_file_size_threshold, + file_write_options, + schema_addr, + session_handle, reader, ) } @@ -225,19 +312,28 @@ 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 + base_store_params_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) + allow_external_blob_outside_bases: JObject, // Optional + blob_pack_file_size_threshold: JObject, // Optional + file_write_options: JObject, // FileWriteOptions + schema_addr: jlong, + session_handle: jlong, // Session handle, 0 means no session 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,20 +343,59 @@ 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 + &base_store_params_obj, + &initial_bases, + &target_bases, + &allow_external_blob_outside_bases, + &blob_pack_file_size_threshold, + &file_write_options, )?; - let fragments = RT.block_on(FileFragment::create_fragments( - &path_str, - source, - Some(write_params), - ))?; + write_params.session = session_from_handle(session_handle); + + // 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 mut builder = FragmentCreateBuilder::new(&path_str).write_params(&write_params); + let schema; + if schema_addr != 0 { + let c_schema_ptr = schema_addr as *mut FFI_ArrowSchema; + let c_schema = unsafe { FFI_ArrowSchema::from_raw(c_schema_ptr) }; + let arrow_schema = ArrowSchema::try_from(&c_schema)?; + // Schema::try_from restores Lance field IDs from the LANCE_FIELD_ID_KEY + // metadata inserted by LanceSchema.asArrowSchemaWithFieldIds(). + schema = Schema::try_from(&arrow_schema)?; + builder = builder.schema(&schema); + } + + let fragments = block_on(builder.write_fragments(source))?; export_vec(env, &fragments) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_nativeDeleteRows<'a>( mut env: JNIEnv<'a>, _obj: JObject, @@ -298,7 +433,7 @@ fn inner_delete_rows<'local>( .map(|x| x as u32) .collect(); - let res = RT.block_on(async move { fragment.extend_deletions(indexes).await }); + let res = block_on(async move { fragment.extend_deletions(indexes).await }); let obj = match res { Ok(Some(f)) => f.metadata().into_java(env)?, @@ -307,14 +442,14 @@ fn inner_delete_rows<'local>( return Err(Error::runtime_error(format!( "Cannot delete rows in fragment {}: {:?}", fragment_id, e - ))) + ))); } }; Ok(obj) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_nativeMergeColumns<'a>( mut env: JNIEnv<'a>, _obj: JObject, @@ -347,20 +482,17 @@ fn inner_merge_column<'local>( left_on: JString, right_on: JString, ) -> Result> { - let (fragment_opt, max_field_id) = { + let fragment_opt = { let dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; - ( - dataset.inner.get_fragment(fragment_id as usize), - dataset.inner.manifest().max_field_id(), - ) + dataset.inner.get_fragment(fragment_id as usize) }; let mut fragment = match fragment_opt { Some(fragment) => fragment, None => { return Err(Error::input_error(format!( "Fragment not found: {fragment_id}" - ))) + ))); } }; @@ -370,7 +502,7 @@ fn inner_merge_column<'local>( let right_on_str: String = right_on.extract(env)?; let (new_frag, new_schema) = - RT.block_on(fragment.merge_columns(reader, &left_on_str, &right_on_str, max_field_id))?; + block_on(fragment.merge_columns(reader, &left_on_str, &right_on_str))?; let result = FragmentMergeResult { fragment: new_frag, schema: new_schema, @@ -378,7 +510,7 @@ fn inner_merge_column<'local>( result.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Fragment_nativeUpdateColumns<'a>( mut env: JNIEnv<'a>, _obj: JObject, @@ -420,18 +552,207 @@ fn inner_update_column<'local>( None => { return Err(Error::input_error(format!( "Fragment not found: {fragment_id}" - ))) + ))); } }; let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; let left_on_str: String = left_on.extract(env)?; let right_on_str: String = right_on.extract(env)?; - let (updated_fragment, fields_modified) = - RT.block_on(fragment.update_columns(reader, &left_on_str, &right_on_str))?; + let r = block_on(fragment.update_columns_with_offsets(reader, &left_on_str, &right_on_str))?; + let updated_row_offset_bytes = serialize_matched_offsets(&r.matched_offsets)?; let result = FragmentUpdateResult { - updated_fragment, - fields_modified, + updated_fragment: r.fragment, + fields_modified: r.fields_modified, + updated_row_offset_bytes, + }; + result.into_java(env) +} + +fn serialize_matched_offsets(bitmap: &RoaringBitmap) -> Result> { + let mut buf = Vec::new(); + bitmap.serialize_into(&mut buf).map_err(|e| { + Error::runtime_error(format!( + "failed to serialize matched row offsets RoaringBitmap: {e}" + )) + })?; + Ok(buf) +} + +fn deserialize_row_offset_bytes(bytes: &[u8]) -> Result { + if bytes.is_empty() { + return Ok(RoaringBitmap::new()); + } + RoaringBitmap::deserialize_from(bytes).map_err(|e| { + Error::input_error(format!( + "invalid updatedRowOffsetBytes RoaringBitmap bytes: {e}" + )) + }) +} + +fn expand_row_offset_bytes_to_i64(bitmap: &RoaringBitmap) -> Vec { + bitmap.iter().map(|o| o as i64).collect() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_fragment_FragmentUpdateResult_expandRowOffsetsFromBytes< + 'local, +>( + mut env: JNIEnv<'local>, + _cls: JClass, + jbytes: JByteArray, +) -> JLongArray<'local> { + ok_or_throw_with_return!( + env, + inner_expand_updated_row_offset_bytes(&mut env, jbytes), + unsafe { JLongArray::from_raw(std::ptr::null_mut()) } + ) +} + +fn inner_expand_updated_row_offset_bytes<'local>( + env: &mut JNIEnv<'local>, + jbytes: JByteArray, +) -> Result> { + let buf = env.convert_byte_array(&jbytes)?; + let bitmap = deserialize_row_offset_bytes(&buf)?; + let offsets = expand_row_offset_bytes_to_i64(&bitmap); + let arr = env.new_long_array(offsets.len() as i32)?; + if !offsets.is_empty() { + env.set_long_array_region(&arr, 0, &offsets)?; + } + Ok(arr) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_fragment_FragmentUpdateResult_encodeRowOffsetsToBytes< + 'local, +>( + mut env: JNIEnv<'local>, + _cls: JClass, + joffsets: JLongArray, +) -> JByteArray<'local> { + ok_or_throw_with_return!( + env, + inner_encode_updated_row_offset_bytes(&mut env, joffsets), + unsafe { JByteArray::from_raw(std::ptr::null_mut()) } + ) +} + +fn inner_encode_updated_row_offset_bytes<'local>( + env: &mut JNIEnv<'local>, + joffsets: JLongArray, +) -> Result> { + let len = env.get_array_length(&joffsets)?; + let mut buf: Vec = vec![0; len as usize]; + if len > 0 { + env.get_long_array_region(&joffsets, 0, buf.as_mut_slice())?; + } + let mut bitmap = RoaringBitmap::new(); + for offset in buf { + if offset < 0 { + return Err(Error::input_error(format!( + "updatedRowOffsets must be non-negative, got {offset}" + ))); + } + if offset > u32::MAX as i64 { + return Err(Error::input_error(format!( + "updatedRowOffsets value {offset} exceeds u32::MAX" + ))); + } + bitmap.insert(offset as u32); + } + let bytes = serialize_matched_offsets(&bitmap)?; + Ok(env.byte_array_from_slice(&bytes)?) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_fragment_RowIdMeta_nativeEncodeRowIds( + mut env: JNIEnv, + _cls: JClass, + row_ids: JLongArray, +) -> jstring { + ok_or_throw_with_return!( + env, + inner_encode_row_ids(&mut env, &row_ids) + .and_then(|json| env.new_string(json).map_err(Error::from)), + std::ptr::null_mut() + ) + .into_raw() +} + +fn inner_encode_row_ids(env: &mut JNIEnv, row_ids: &JLongArray) -> Result { + let len = env.get_array_length(row_ids)?; + let mut buf: Vec = vec![0; len as usize]; + env.get_long_array_region(row_ids, 0, buf.as_mut_slice())?; + let ids: Vec = buf.into_iter().map(|x| x as u64).collect(); + let seq = RowIdSequence::from(ids.as_slice()); + let meta = RowIdMeta::Inline(write_row_ids(&seq).into()); + let json = serde_json::to_string(&meta)?; + Ok(json) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Fragment_nativeAddColumnsByReader<'a>( + mut env: JNIEnv<'a>, + _obj: JObject, + jdataset: JObject, // Java DataSet + fragment_id: jlong, // FragmentID + arrow_array_stream_addr: jlong, // memoryAddress of ArrowStream + batch_size: JObject, // Optional +) -> JObject<'a> { + ok_or_throw_with_return!( + env, + inner_add_columns_by_reader( + &mut env, + jdataset, + fragment_id, + arrow_array_stream_addr, + batch_size + ), + JObject::null() + ) +} + +fn inner_add_columns_by_reader<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject, + fragment_id: jlong, + arrow_array_stream_addr: jlong, + batch_size: JObject, +) -> Result> { + let fragment_opt = { + let dataset = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET) }?; + dataset.inner.get_fragment(fragment_id as usize) + }; + let fragment = match fragment_opt { + Some(fragment) => fragment, + None => { + return Err(Error::input_error(format!( + "Fragment not found: {fragment_id}" + ))); + } + }; + + let stream_ptr = arrow_array_stream_addr as *mut FFI_ArrowArrayStream; + let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; + let batch_size = match env.get_long_opt(&batch_size)? { + Some(value) => Some( + value + .try_into() + .map_err(|_| Error::input_error(format!("Invalid batch size: {value}")))?, + ), + None => None, + }; + + let (new_frag, new_schema) = block_on(fragment.add_columns( + NewColumnTransform::Reader(Box::new(reader)), + None, + batch_size, + ))?; + let result = FragmentMergeResult { + fragment: new_frag, + schema: new_schema, }; result.into_java(env) } @@ -444,14 +765,16 @@ const DELETE_FILE_CONSTRUCTOR_SIG: &str = "(JJLjava/lang/Long;Lorg/lance/fragment/DeletionFileType;Ljava/lang/Integer;)V"; const DELETE_FILE_TYPE_CLASS: &str = "org/lance/fragment/DeletionFileType"; const FRAGMENT_METADATA_CLASS: &str = "org/lance/FragmentMetadata"; -const FRAGMENT_METADATA_CONSTRUCTOR_SIG: &str ="(ILjava/util/List;Ljava/lang/Long;Lorg/lance/fragment/DeletionFile;Lorg/lance/fragment/RowIdMeta;)V"; +const FRAGMENT_METADATA_CONSTRUCTOR_SIG: &str = "(ILjava/util/List;Ljava/lang/Long;Lorg/lance/fragment/DeletionFile;Lorg/lance/fragment/RowIdMeta;Lorg/lance/fragment/VersionMeta;Lorg/lance/fragment/VersionMeta;Ljava/util/List;)V"; const ROW_ID_META_CLASS: &str = "org/lance/fragment/RowIdMeta"; const ROW_ID_META_CONSTRUCTOR_SIG: &str = "(Ljava/lang/String;)V"; +const VERSION_META_CLASS: &str = "org/lance/fragment/VersionMeta"; +const VERSION_META_CONSTRUCTOR_SIG: &str = "(Ljava/lang/String;)V"; const FRAGMENT_MERGE_RESULT_CLASS: &str = "org/lance/fragment/FragmentMergeResult"; const FRAGMENT_MERGE_RESULT_CONSTRUCTOR_SIG: &str = "(Lorg/lance/FragmentMetadata;Lorg/lance/schema/LanceSchema;)V"; const FRAGMENT_UPDATE_RESULT_CLASS: &str = "org/lance/fragment/FragmentUpdateResult"; -const FRAGMENT_UPDATE_RESULT_CONSTRUCTOR_SIG: &str = "(Lorg/lance/FragmentMetadata;[J)V"; +const FRAGMENT_UPDATE_RESULT_CONSTRUCTOR_SIG: &str = "(Lorg/lance/FragmentMetadata;[J[B)V"; impl IntoJava for &FragmentMergeResult { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { @@ -472,12 +795,15 @@ impl IntoJava for &FragmentUpdateResult { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { let java_updated_fragment = self.updated_fragment.into_java(env)?; let java_fields_modified = JLance(self.fields_modified.clone()).into_java(env)?; + let java_updated_row_offset_bytes = + env.byte_array_from_slice(&self.updated_row_offset_bytes)?; Ok(env.new_object( FRAGMENT_UPDATE_RESULT_CLASS, FRAGMENT_UPDATE_RESULT_CONSTRUCTOR_SIG, &[ JValueGen::Object(&java_updated_fragment), JValueGen::Object(&java_fields_modified), + JValueGen::Object(&java_updated_row_offset_bytes), ], )?) } @@ -486,8 +812,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(), @@ -561,10 +887,27 @@ impl IntoJava for &RowIdMeta { } } +impl IntoJava for &RowDatasetVersionMeta { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let json_str = serde_json::to_string(self)?; + let json = env.new_string(json_str)?.into(); + Ok(env.new_object( + VERSION_META_CLASS, + VERSION_META_CONSTRUCTOR_SIG, + &[JValueGen::Object(&json)], + )?) + } +} + impl IntoJava for &Fragment { fn into_java<'local>(self, env: &mut JNIEnv<'local>) -> Result> { let files = self.files.clone(); let files = export_vec::(env, &files)?; + let referenced_data_files = self + .referenced_lance_files() + .cloned() + .collect::>(); + let referenced_data_files = export_vec::(env, &referenced_data_files)?; let deletion_file = match &self.deletion_file { Some(f) => f.into_java(env)?, None => JObject::null(), @@ -574,6 +917,14 @@ impl IntoJava for &Fragment { Some(m) => m.into_java(env)?, None => JObject::null(), }; + let created_at = match &self.created_at_version_meta { + Some(m) => m.into_java(env)?, + None => JObject::null(), + }; + let last_updated_at = match &self.last_updated_at_version_meta { + Some(m) => m.into_java(env)?, + None => JObject::null(), + }; env.new_object( FRAGMENT_METADATA_CLASS, @@ -584,6 +935,9 @@ impl IntoJava for &Fragment { JValueGen::Object(physical_rows), JValueGen::Object(&deletion_file), JValueGen::Object(&row_id_meta), + JValueGen::Object(&created_at), + JValueGen::Object(&last_updated_at), + JValueGen::Object(&referenced_data_files), ], ) .map_err(|e| { @@ -603,6 +957,38 @@ impl FromJObjectWithEnv for JObject<'_> { } } +impl FromJObjectWithEnv for JObject<'_> { + fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { + let metadata = env + .call_method(self, "getMetadata", "()Ljava/lang/String;", &[])? + .l()?; + let s: String = env.get_string(&JString::from(metadata))?.into(); + let meta: RowDatasetVersionMeta = serde_json::from_str(&s)?; + Ok(meta) + } +} + +/// Extract an optional field from a Java object by calling a getter method. +/// Returns `None` if the getter returns null, otherwise deserializes the JObject. +fn extract_nullable_field( + env: &mut JNIEnv<'_>, + obj: &JObject<'_>, + method: &str, + class: &str, +) -> Result> +where + for<'a> JObject<'a>: FromJObjectWithEnv, +{ + let result = env + .call_method(obj, method, format!("()L{};", class), &[])? + .l()?; + if result.is_null() { + Ok(None) + } else { + Ok(Some(result.extract_object(env)?)) + } +} + impl FromJObjectWithEnv for JObject<'_> { fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { let id = env.call_method(self, "getId", "()I", &[])?.i()? as u64; @@ -615,41 +1001,26 @@ impl FromJObjectWithEnv for JObject<'_> { for f in file_objs { files.push(f.extract_object(env)?); } - let deletion_file = env - .call_method( - self, - "getDeletionFile", - format!("()L{};", DELETE_FILE_CLASS), - &[], - )? - .l()?; - let deletion_file = if deletion_file.is_null() { - None - } else { - Some(deletion_file.extract_object(env)?) - }; - let row_id_meta = env - .call_method( - self, - "getRowIdMeta", - format!("()L{};", ROW_ID_META_CLASS), - &[], - )? - .l()?; - let row_id_meta = if row_id_meta.is_null() { - None - } else { - Some(row_id_meta.extract_object(env)?) - }; + let deletion_file = + extract_nullable_field(env, self, "getDeletionFile", DELETE_FILE_CLASS)?; + let row_id_meta = extract_nullable_field(env, self, "getRowIdMeta", ROW_ID_META_CLASS)?; + let created_at_version_meta = + extract_nullable_field(env, self, "getCreatedAtVersionMeta", VERSION_META_CLASS)?; + let last_updated_at_version_meta = + extract_nullable_field(env, self, "getLastUpdatedAtVersionMeta", VERSION_META_CLASS)?; + Ok(Fragment { id, files, deletion_file, physical_rows: Some(physical_rows), row_id_meta, - created_at_version_meta: None, - last_updated_at_version_meta: None, + created_at_version_meta, + last_updated_at_version_meta, + // Overlays are not exposed to Java yet, and the reverse conversion + // does not export them, so this round-trip is overlay-free. + overlays: vec![], }) } } @@ -725,8 +1096,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/java/lance-jni/src/index.rs b/java/lance-jni/src/index.rs index 6360627cb11..48756eb953b 100644 --- a/java/lance-jni/src/index.rs +++ b/java/lance-jni/src/index.rs @@ -1,33 +1,54 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use crate::error::Result; -use crate::traits::{export_vec, IntoJava}; +use crate::error::{Error, Result}; +use crate::traits::{IntoJava, export_vec}; +use jni::JNIEnv; use jni::objects::{JObject, JValue}; use jni::sys::jbyte; -use jni::JNIEnv; use lance::table::format::IndexMetadata; use lance_index::IndexDescription; use prost::Message; use prost_types::Any; use std::sync::Arc; +/// Build a `java.util.List`. +/// +/// Not `JLance>`'s `IntoJava`: that produces a primitive `int[]`, while +/// the Java constructors here take `List`. +fn int_list<'a>(env: &mut JNIEnv<'a>, ids: impl IntoIterator) -> Result> { + let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; + for id in ids { + let id_obj = env.new_object("java/lang/Integer", "(I)V", &[JValue::Int(id)])?; + env.call_method( + &array_list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&id_obj)], + )?; + } + Ok(array_list) +} + +fn u64_to_jlong(field: &str, value: u64) -> Result { + i64::try_from(value).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust index field {field}={value} to Java long" + )) + }) +} + +fn u32_to_jint(field: &str, value: u32) -> Result { + i32::try_from(value).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust index field {field}={value} to Java int" + )) + }) +} + impl IntoJava for &Arc { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { - let field_ids_list = { - let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; - for id in self.field_ids() { - let int_obj = - env.new_object("java/lang/Integer", "(I)V", &[JValue::Int(*id as i32)])?; - env.call_method( - &array_list, - "add", - "(Ljava/lang/Object;)Z", - &[JValue::Object(&int_obj)], - )?; - } - array_list - }; + let field_ids_list = int_list(env, self.field_ids().iter().map(|id| *id as i32))?; let name = env.new_string(self.name())?; let type_url = env.new_string(self.type_url())?; let index_type = env.new_string(self.index_type())?; @@ -35,10 +56,15 @@ impl IntoJava for &Arc { let metadata_list = export_vec(env, self.metadata())?; let details_json = self.details()?; let details = env.new_string(details_json)?; + let total_size_bytes = if let Some(size) = self.total_size_bytes() { + env.new_object("java/lang/Long", "(J)V", &[JValue::Long(size as i64)])? + } else { + JObject::null() + }; let j_index_desc = env.new_object( "org/lance/index/IndexDescription", - "(Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JLjava/util/List;Ljava/lang/String;)V", + "(Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;JLjava/util/List;Ljava/lang/String;Ljava/lang/Long;)V", &[ JValue::Object(&name), JValue::Object(&field_ids_list), @@ -47,6 +73,7 @@ impl IntoJava for &Arc { JValue::Long(rows_indexed), JValue::Object(&metadata_list), JValue::Object(&details), + JValue::Object(&total_size_bytes), ], )?; Ok(j_index_desc) @@ -57,37 +84,19 @@ impl IntoJava for &IndexMetadata { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { let uuid = self.uuid.into_java(env)?; - let fields = { - let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; - for field in &self.fields { - let field_obj = - env.new_object("java/lang/Integer", "(I)V", &[JValue::Int(*field)])?; - env.call_method( - &array_list, - "add", - "(Ljava/lang/Object;)Z", - &[JValue::Object(&field_obj)], - )?; - } - array_list - }; + let fields = int_list(env, self.fields.iter().copied())?; + let covering_fields = int_list(env, self.covering_fields.iter().copied())?; let name = env.new_string(&self.name)?; - let fragments = if let Some(bitmap) = &self.fragment_bitmap { - let array_list = env.new_object("java/util/ArrayList", "()V", &[])?; - for frag_id in bitmap.iter() { - let id_obj = - env.new_object("java/lang/Integer", "(I)V", &[JValue::Int(frag_id as i32)])?; - env.call_method( - &array_list, - "add", - "(Ljava/lang/Object;)Z", - &[JValue::Object(&id_obj)], - )?; + let fragments = match &self.fragment_bitmap { + Some(bitmap) => { + let ids = bitmap + .iter() + .map(|id| u32_to_jint("fragments", id)) + .collect::>>()?; + int_list(env, ids)? } - array_list - } else { - JObject::null() + None => JObject::null(), }; // Convert index_details to byte array @@ -120,28 +129,55 @@ impl IntoJava for &IndexMetadata { // Convert base_id from Option to Integer for Java let base_id = if let Some(id) = self.base_id { - env.new_object("java/lang/Integer", "(I)V", &[JValue::Int(id as i32)])? + env.new_object( + "java/lang/Integer", + "(I)V", + &[JValue::Int(u32_to_jint("baseId", id)?)], + )? } else { JObject::null() }; + let size_bytes = match &self.files { + Some(files) => { + let size = files.iter().try_fold(0_u64, |total, file| { + total.checked_add(file.size_bytes).ok_or_else(|| { + Error::runtime_error("Index file sizes overflow u64".to_string()) + }) + })?; + env.new_object( + "java/lang/Long", + "(J)V", + &[JValue::Long(u64_to_jlong("sizeBytes", size)?)], + )? + } + None => JObject::null(), + }; + let files = match &self.files { + Some(files) => export_vec(env, files)?, + None => JObject::null(), + }; + // Determine index type from index_details type_url let index_type = determine_index_type(env, &self.index_details)?; // Create Index object Ok(env.new_object( "org/lance/index/Index", - "(Ljava/util/UUID;Ljava/util/List;Ljava/lang/String;JLjava/util/List;[BILjava/time/Instant;Ljava/lang/Integer;Lorg/lance/index/IndexType;)V", + "(Ljava/util/UUID;Ljava/util/List;Ljava/util/List;Ljava/lang/String;JLjava/util/List;[BILjava/time/Instant;Ljava/lang/Integer;Ljava/lang/Long;Ljava/util/List;Lorg/lance/index/IndexType;)V", &[ JValue::Object(&uuid), JValue::Object(&fields), + JValue::Object(&covering_fields), JValue::Object(&name), - JValue::Long(self.dataset_version as i64), + JValue::Long(u64_to_jlong("datasetVersion", self.dataset_version)?), JValue::Object(&fragments), JValue::Object(&index_details), JValue::Int(self.index_version), JValue::Object(&created_at), JValue::Object(&base_id), + JValue::Object(&size_bytes), + JValue::Object(&files), JValue::Object(&index_type), ], )?) @@ -173,6 +209,8 @@ fn determine_index_type<'local>( Some("ZONEMAP") } else if lower.contains("bloomfilter") { Some("BLOOM_FILTER") + } else if lower.contains("rtree") { + Some("RTREE") } else if lower.contains("ivfhnsw") { if lower.contains("sq") { Some("IVF_HNSW_SQ") diff --git a/java/lance-jni/src/index_progress.rs b/java/lance-jni/src/index_progress.rs new file mode 100644 index 00000000000..a9275668db1 --- /dev/null +++ b/java/lance-jni/src/index_progress.rs @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use async_trait::async_trait; +use jni::objects::{GlobalRef, JObject, JString, JValue}; +use jni::{JNIEnv, JavaVM}; +use lance_index::progress::IndexBuildProgress; + +use crate::error::{Error, Result}; + +/// Bridges Rust index progress events to a Java callback. +pub(crate) struct JavaIndexBuildProgress { + callback: GlobalRef, + jvm: Arc, +} + +impl std::fmt::Debug for JavaIndexBuildProgress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("JavaIndexBuildProgress") + } +} + +impl JavaIndexBuildProgress { + pub(crate) fn new(env: &mut JNIEnv, callback: &JObject) -> Result { + if callback.is_null() { + return Err(Error::input_error( + "IndexBuildProgress callback cannot be null".to_string(), + )); + } + Ok(Self { + callback: env.new_global_ref(callback)?, + jvm: Arc::new(env.get_java_vm()?), + }) + } + + fn call_stage_start( + &self, + stage: &str, + total: Option, + unit: &str, + ) -> lance_core::Result<()> { + let total = total + .map(|value| { + i64::try_from(value).map_err(|_| { + lance_core::Error::invalid_input(format!( + "IndexBuildProgress total exceeds Java long for stage '{stage}': {value}" + )) + }) + }) + .transpose()?; + let mut env = self.jvm.attach_current_thread().map_err(|error| { + lance_core::Error::internal(format!( + "IndexBuildProgress.stageStart failed to attach JVM thread for stage '{stage}': {error}" + )) + })?; + + env.with_local_frame(16, |env| { + let stage_obj = env + .new_string(stage) + .map_err(|error| callback_error(env, "stageStart", stage, error))?; + let unit_obj = env + .new_string(unit) + .map_err(|error| callback_error(env, "stageStart", stage, error))?; + let total_obj = match total { + Some(value) => env + .new_object("java/lang/Long", "(J)V", &[JValue::Long(value)]) + .map_err(|error| callback_error(env, "stageStart", stage, error))?, + None => JObject::null(), + }; + let total_optional = env + .call_static_method( + "java/util/Optional", + "ofNullable", + "(Ljava/lang/Object;)Ljava/util/Optional;", + &[JValue::Object(&total_obj)], + ) + .and_then(|value| value.l()) + .map_err(|error| callback_error(env, "stageStart", stage, error))?; + + env.call_method( + &self.callback, + "stageStart", + "(Ljava/lang/String;Ljava/util/Optional;Ljava/lang/String;)V", + &[ + JValue::Object(&stage_obj), + JValue::Object(&total_optional), + JValue::Object(&unit_obj), + ], + ) + .map_err(|error| callback_error(env, "stageStart", stage, error))?; + Ok::<(), Error>(()) + }) + .map_err(|error| lance_core::Error::internal(error.to_string())) + } + + fn call_stage_progress(&self, stage: &str, completed: u64) -> lance_core::Result<()> { + let completed = i64::try_from(completed).map_err(|_| { + lance_core::Error::invalid_input(format!( + "IndexBuildProgress completed value exceeds Java long for stage '{stage}': {completed}" + )) + })?; + let mut env = self.jvm.attach_current_thread().map_err(|error| { + lance_core::Error::internal(format!( + "IndexBuildProgress.stageProgress failed to attach JVM thread for stage '{stage}': {error}" + )) + })?; + + env.with_local_frame(16, |env| { + let stage_obj = env + .new_string(stage) + .map_err(|error| callback_error(env, "stageProgress", stage, error))?; + + env.call_method( + &self.callback, + "stageProgress", + "(Ljava/lang/String;J)V", + &[JValue::Object(&stage_obj), JValue::Long(completed)], + ) + .map_err(|error| callback_error(env, "stageProgress", stage, error))?; + Ok::<(), Error>(()) + }) + .map_err(|error| lance_core::Error::internal(error.to_string())) + } + + fn call_stage_complete(&self, stage: &str) -> lance_core::Result<()> { + let mut env = self.jvm.attach_current_thread().map_err(|error| { + lance_core::Error::internal(format!( + "IndexBuildProgress.stageComplete failed to attach JVM thread for stage '{stage}': {error}" + )) + })?; + + env.with_local_frame(16, |env| { + let stage_obj = env + .new_string(stage) + .map_err(|error| callback_error(env, "stageComplete", stage, error))?; + + env.call_method( + &self.callback, + "stageComplete", + "(Ljava/lang/String;)V", + &[JValue::Object(&stage_obj)], + ) + .map_err(|error| callback_error(env, "stageComplete", stage, error))?; + Ok::<(), Error>(()) + }) + .map_err(|error| lance_core::Error::internal(error.to_string())) + } +} + +fn callback_error(env: &mut JNIEnv, method: &str, stage: &str, error: jni::errors::Error) -> Error { + let java_exception = take_pending_java_exception(env); + let detail = java_exception.unwrap_or_else(|| error.to_string()); + Error::runtime_error(format!( + "IndexBuildProgress.{method} callback failed for stage '{stage}': {detail}" + )) +} + +fn take_pending_java_exception(env: &mut JNIEnv) -> Option { + if !env.exception_check().unwrap_or(false) { + return None; + } + + let throwable = env.exception_occurred().ok(); + let _ = env.exception_clear(); + + let description = throwable.and_then(|throwable| { + if throwable.is_null() { + return None; + } + let description = env + .call_method(&throwable, "toString", "()Ljava/lang/String;", &[]) + .and_then(|value| value.l()) + .ok()?; + if description.is_null() { + return None; + } + let description = JString::from(description); + env.get_string(&description).ok().map(|value| value.into()) + }); + + if env.exception_check().unwrap_or(false) { + let _ = env.exception_clear(); + } + description +} + +#[async_trait] +impl IndexBuildProgress for JavaIndexBuildProgress { + async fn stage_start( + &self, + stage: &str, + total: Option, + unit: &str, + ) -> lance_core::Result<()> { + self.call_stage_start(stage, total, unit) + } + + async fn stage_progress(&self, stage: &str, completed: u64) -> lance_core::Result<()> { + self.call_stage_progress(stage, completed) + } + + async fn stage_complete(&self, stage: &str) -> lance_core::Result<()> { + if let Err(error) = self.call_stage_complete(stage) { + log::warn!( + "Ignoring IndexBuildProgress.stageComplete callback failure for stage '{}': {}", + stage, + error + ); + } + Ok(()) + } +} diff --git a/java/lance-jni/src/lib.rs b/java/lance-jni/src/lib.rs index 6fbba6050a5..3e342a3eb9a 100644 --- a/java/lance-jni/src/lib.rs +++ b/java/lance-jni/src/lib.rs @@ -39,31 +39,38 @@ 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; mod file_writer; mod fragment; mod index; +mod index_progress; +mod mem_wal; mod merge_insert; mod namespace; mod optimize; +mod otel; mod schema; +mod session; mod sql; mod storage_options; +mod task_tracker; pub mod traits; mod transaction; +mod update; pub mod utils; 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; @@ -80,6 +87,22 @@ pub static RT: LazyLock = LazyLock::new(|| { .expect("Failed to create tokio runtime") }); +/// Drive a future on the shared JNI runtime, including nested calls. +/// +/// Progress callbacks (and similar JNI re-entry) may invoke Dataset methods while +/// already inside `RT.block_on`. Calling `Runtime::block_on` again panics with +/// "Cannot start a runtime from within a runtime". When a Tokio handle is already +/// available, use `block_in_place` + `Handle::block_on` instead. +/// +/// JNI entry points should use this helper instead of calling `RT.block_on` +/// directly so they remain safe when invoked from a callback. +pub fn block_on(future: F) -> F::Output { + match tokio::runtime::Handle::try_current() { + Ok(handle) => tokio::task::block_in_place(|| handle.block_on(future)), + Err(_) => RT.block_on(future), + } +} + fn set_timestamp_precision(builder: &mut env_logger::Builder) { if let Ok(timestamp_precision) = env::var("LANCE_LOG_TS_PRECISION") { match timestamp_precision.as_str() { @@ -111,14 +134,14 @@ fn set_log_file_target(builder: &mut env_logger::Builder) { let path = Path::new(&log_file_path); // Create parent directories if they don't exist - if let Some(parent) = path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - println!( - "Failed to create parent directories for log file '{}': {}, using stderr", - log_file_path, e - ); - return; - } + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + println!( + "Failed to create parent directories for log file '{}': {}, using stderr", + log_file_path, e + ); + return; } // Try to open/create the log file @@ -136,7 +159,7 @@ fn set_log_file_target(builder: &mut env_logger::Builder) { } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_JniLoader_initLanceLogger() { let env = Env::new() .filter_or("LANCE_LOG", "warn") @@ -150,3 +173,38 @@ 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 { + // Resolve AsyncScanner class on the current thread which has the correct + // application classloader. A newly spawned native thread only gets the + // system classloader after attach_current_thread_permanently(), which + // cannot find application classes in environments like Spark, web + // containers, or shaded JARs. + let mut env = vm.get_env().expect("Failed to get JNIEnv in JNI_OnLoad"); + let async_scanner_local = env + .find_class("org/lance/ipc/AsyncScanner") + .expect("AsyncScanner class not found"); + let async_scanner_class = env + .new_global_ref(async_scanner_local) + .expect("Failed to create GlobalRef for AsyncScanner class"); + + let jvm_arc = Arc::new(vm); + + // Initialize global dispatcher with persistent thread, passing the + // pre-resolved class reference so the dispatcher thread does not need + // to look up the class with the wrong classloader. + let dispatcher = dispatcher::Dispatcher::initialize(jvm_arc, async_scanner_class); + + // 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/mem_wal.rs b/java/lance-jni/src/mem_wal.rs new file mode 100644 index 00000000000..ea4cb09cf8a --- /dev/null +++ b/java/lance-jni/src/mem_wal.rs @@ -0,0 +1,1585 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! JNI bindings for the Lance MemWAL feature. +//! +//! Mirrors the Python MemWAL binding (`python/src/mem_wal.rs`): a stateful +//! [`ShardWriter`], an LSM-aware [`LsmScanner`], an [`ExecutionPlan`] wrapper, +//! and the point-lookup / vector-search planners. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use arrow::ffi::{FFI_ArrowArray, FFI_ArrowSchema, from_ffi}; +use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream}; +use arrow_array::{ + Array, ArrayRef, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator, + RecordBatchReader, StructArray, make_array, +}; +use arrow_schema::{DataType, Field, Schema as ArrowSchema}; +use datafusion::common::ScalarValue; +use datafusion::physical_plan::{ExecutionPlan, collect, displayable}; +use datafusion::prelude::SessionContext; +use jni::JNIEnv; +use jni::objects::{JClass, JMap, JObject, JString, JValueGen}; +use jni::sys::{jdouble, jint, jlong}; +use lance::dataset::Dataset as LanceDataset; +use lance::dataset::mem_wal::scanner::{ + LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, SsTable, + parse_filter_expr as parse_lsm_filter_expr, write_pk_sidecar, +}; +use lance::dataset::mem_wal::write::{MemTableStats, ShardMemory, WriteStatsSnapshot}; +use lance::dataset::mem_wal::{ + DatasetMemWalExt, LsmScanner, ShardSnapshot, ShardWriter, ShardWriterConfig, + arrow_schema_with_field_ids, evaluate_sharding_spec_with_source_columns, +}; +use lance::dataset::scanner::DatasetRecordBatchStream; +use lance_index::mem_wal::{MemWalIndexDetails, ShardManifest, ShardingField, ShardingSpec}; +use lance_index::vector::hnsw::builder::HnswBuildParams; +use lance_io::ffi::to_ffi_arrow_array_stream; +use lance_linalg::distance::DistanceType; +use uuid::Uuid; + +use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; +use crate::error::{Error, Result}; +use crate::ffi::JNIEnvExt; +use crate::traits::{ + FromJString, IntoJava, export_vec, import_vec, import_vec_from_method, import_vec_to_rust, +}; +use crate::utils::to_rust_map; +use crate::{RT, block_on}; + +const NATIVE_SHARD_WRITER: &str = "nativeShardWriterHandle"; +const NATIVE_LSM_SCANNER: &str = "nativeLsmScannerHandle"; +const NATIVE_EXECUTION_PLAN: &str = "nativeExecutionPlanHandle"; +const NATIVE_LOOKUP_PLANNER: &str = "nativeLookupPlannerHandle"; +const NATIVE_VECTOR_PLANNER: &str = "nativeVectorPlannerHandle"; + +/// Native handle backing `org.lance.memwal.ShardWriter`. +struct BlockingShardWriter { + writer: ShardWriter, + shard_id: Uuid, + dataset: Arc, +} + +/// Native handle backing `org.lance.memwal.LsmScanner`. +struct BlockingLsmScanner { + inner: Option, +} + +/// Native handle backing `org.lance.memwal.ExecutionPlan`. +struct BlockingExecutionPlan { + plan: Arc, + dataset_schema: Arc, +} + +/// Native handle backing `org.lance.memwal.LsmPointLookupPlanner`. +struct BlockingLsmPointLookupPlanner { + planner: LsmPointLookupPlanner, + dataset_schema: Arc, + pk_columns: Vec, +} + +/// Native handle backing `org.lance.memwal.LsmVectorSearchPlanner`. +struct BlockingLsmVectorSearchPlanner { + planner: LsmVectorSearchPlanner, + vector_dim: usize, + dataset_schema: Arc, +} + +/////////////////// +// ShardWriter // +/////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_createNative<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + dataset: JObject<'local>, + shard_id: JString<'local>, + config: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_create_shard_writer(&mut env, dataset, shard_id, config) + ) +} + +fn inner_create_shard_writer<'local>( + env: &mut JNIEnv<'local>, + dataset: JObject<'local>, + shard_id: JString<'local>, + config: JObject<'local>, +) -> Result> { + let shard_id: String = env.get_string(&shard_id)?.into(); + let uuid = parse_uuid(&shard_id)?; + let writer_config = if config.is_null() { + ShardWriterConfig::default() + } else { + build_writer_config(env, &config)? + }; + + let dataset = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&dataset, NATIVE_DATASET) }?; + Arc::new(guard.inner.clone()) + }; + + let writer = block_on(dataset.mem_wal_writer(uuid, writer_config))?; + let blocking = BlockingShardWriter { + writer, + shard_id: uuid, + dataset, + }; + + let java_obj = env.new_object("org/lance/memwal/ShardWriter", "()V", &[])?; + unsafe { env.set_rust_field(&java_obj, NATIVE_SHARD_WRITER, blocking) }?; + Ok(java_obj) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativeShardId<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_shard_id(&mut env, this)) +} + +fn inner_shard_id<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, +) -> Result> { + let shard_id = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + guard.shard_id.to_string() + }; + Ok(env.new_string(shard_id)?.into()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativePut( + mut env: JNIEnv, + this: JObject, + stream_addr: jlong, +) { + ok_or_throw_without_return!(env, inner_put(&mut env, this, stream_addr)); +} + +fn inner_put(env: &mut JNIEnv, this: JObject, stream_addr: jlong) -> Result<()> { + let stream_ptr = stream_addr as *mut FFI_ArrowArrayStream; + let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; + let batches: Vec = reader.collect::>()?; + if batches.is_empty() { + return Ok(()); + } + + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + block_on(guard.writer.put(batches))?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativeDelete( + mut env: JNIEnv, + this: JObject, + stream_addr: jlong, +) { + ok_or_throw_without_return!(env, inner_delete(&mut env, this, stream_addr)); +} + +fn inner_delete(env: &mut JNIEnv, this: JObject, stream_addr: jlong) -> Result<()> { + let stream_ptr = stream_addr as *mut FFI_ArrowArrayStream; + let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; + let batches: Vec = reader.collect::>()?; + if batches.is_empty() { + return Ok(()); + } + + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + block_on(guard.writer.delete(batches))?; + Ok(()) +} + +/// Test-support: write a primary-key dedup sidecar (`_pk_index/`) for an +/// SSTable dataset already staged at `gen_path`, mirroring what +/// production flush emits. Lets Java tests stage a *faithful* SSTable +/// (dataset + sidecar); production always writes the sidecar during +/// flush, so a dataset-without-sidecar is not a state the system produces. +/// Mirrors the Python `_write_pk_sidecar` binding. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_MemWalTest_nativeWritePkSidecar( + mut env: JNIEnv, + _class: JClass, + gen_path: JString, + stream_addr: jlong, + pk_columns: JObject, +) { + ok_or_throw_without_return!( + env, + inner_write_pk_sidecar(&mut env, gen_path, stream_addr, pk_columns) + ); +} + +fn inner_write_pk_sidecar( + env: &mut JNIEnv, + gen_path: JString, + stream_addr: jlong, + pk_columns: JObject, +) -> Result<()> { + let gen_path: String = env.get_string(&gen_path)?.into(); + let pk_columns = env.get_strings(&pk_columns)?; + let stream_ptr = stream_addr as *mut FFI_ArrowArrayStream; + let reader = unsafe { ArrowArrayStreamReader::from_raw(stream_ptr) }?; + let batches: Vec = reader.collect::>()?; + let pk_refs: Vec<&str> = pk_columns.iter().map(String::as_str).collect(); + block_on(write_pk_sidecar(&gen_path, &batches, &pk_refs))?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativeStats<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_writer_stats(&mut env, this)) +} + +fn inner_writer_stats<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, +) -> Result> { + let stats = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + guard.writer.stats() + }; + write_stats_to_java(env, &stats) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativeMemtableStats<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_memtable_stats(&mut env, this)) +} + +fn inner_memtable_stats<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, +) -> Result> { + let (stats, memory) = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + // Byte totals live on `memory()` now, not on `MemTableStats`. + ( + block_on(guard.writer.memtable_stats())?, + guard.writer.memory(), + ) + }; + memtable_stats_to_java(env, &stats, &memory) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_nativeLsmScanner<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, + shard_snapshots: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_writer_lsm_scanner(&mut env, this, shard_snapshots) + ) +} + +fn inner_writer_lsm_scanner<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, + shard_snapshots: JObject<'local>, +) -> Result> { + let mut snapshots = read_shard_snapshots(env, &shard_snapshots)?; + + let (in_memory_memtables, writer_snapshot, dataset, shard_id, pk_columns) = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingShardWriter>(&this, NATIVE_SHARD_WRITER) }?; + let pk_columns = get_pk_columns(&guard.dataset)?; + // Capture the active memtable *and* any frozen-awaiting-flush memtables + // so a concurrent flush rollover cannot hide acknowledged writes from + // this read-your-writes scanner. + let in_memory_memtables = block_on(guard.writer.in_memory_memtable_refs())?; + let writer_snapshot = block_on(guard.writer.manifest())? + .map(shard_snapshot_from_manifest) + .unwrap_or_else(|| ShardSnapshot::new(guard.shard_id)); + ( + in_memory_memtables, + writer_snapshot, + guard.dataset.clone(), + guard.shard_id, + pk_columns, + ) + }; + + snapshots.retain(|snapshot| snapshot.shard_id != shard_id); + snapshots.push(writer_snapshot); + + let scanner = LsmScanner::new(dataset, snapshots, pk_columns) + .with_in_memory_memtables(shard_id, in_memory_memtables); + attach_lsm_scanner(env, scanner) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardWriter_releaseNativeShardWriter( + mut env: JNIEnv, + this: JObject, + _handle: jlong, +) { + ok_or_throw_without_return!(env, inner_release_shard_writer(&mut env, this)); +} + +fn inner_release_shard_writer(env: &mut JNIEnv, this: JObject) -> Result<()> { + let blocking: BlockingShardWriter = unsafe { env.take_rust_field(&this, NATIVE_SHARD_WRITER) }?; + block_on(blocking.writer.close())?; + Ok(()) +} + +/////////////////// +// LsmScanner // +/////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_createFromSnapshots<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + dataset: JObject<'local>, + shard_snapshots: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_lsm_scanner_from_snapshots(&mut env, dataset, shard_snapshots) + ) +} + +fn inner_lsm_scanner_from_snapshots<'local>( + env: &mut JNIEnv<'local>, + dataset: JObject<'local>, + shard_snapshots: JObject<'local>, +) -> Result> { + let snapshots = read_shard_snapshots(env, &shard_snapshots)?; + let dataset = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&dataset, NATIVE_DATASET) }?; + Arc::new(guard.inner.clone()) + }; + let pk_columns = get_pk_columns(&dataset)?; + let scanner = LsmScanner::new(dataset, snapshots, pk_columns); + attach_lsm_scanner(env, scanner) +} + +fn attach_lsm_scanner<'local>( + env: &mut JNIEnv<'local>, + scanner: LsmScanner, +) -> Result> { + let java_obj = env.new_object("org/lance/memwal/LsmScanner", "()V", &[])?; + unsafe { + env.set_rust_field( + &java_obj, + NATIVE_LSM_SCANNER, + BlockingLsmScanner { + inner: Some(scanner), + }, + ) + }?; + Ok(java_obj) +} + +/// Take the wrapped scanner out, apply `f`, then store the result back. +fn with_lsm_scanner(env: &mut JNIEnv, this: &JObject, f: F) -> Result<()> +where + F: FnOnce(LsmScanner) -> Result, +{ + let mut guard = + unsafe { env.get_rust_field::<_, _, BlockingLsmScanner>(this, NATIVE_LSM_SCANNER) }?; + let scanner = guard + .inner + .take() + .ok_or_else(|| Error::runtime_error("LsmScanner is no longer usable because an earlier builder call (e.g. filter) failed; create a new scanner".to_string()))?; + guard.inner = Some(f(scanner)?); + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeProject( + mut env: JNIEnv, + this: JObject, + columns: JObject, +) { + ok_or_throw_without_return!(env, inner_scanner_project(&mut env, this, columns)); +} + +fn inner_scanner_project(env: &mut JNIEnv, this: JObject, columns: JObject) -> Result<()> { + let columns = env.get_strings(&columns)?; + with_lsm_scanner(env, &this, |scanner| { + let cols: Vec<&str> = columns.iter().map(String::as_str).collect(); + Ok(scanner.project(&cols)?) + }) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeFilter( + mut env: JNIEnv, + this: JObject, + expr: JString, +) { + ok_or_throw_without_return!(env, inner_scanner_filter(&mut env, this, expr)); +} + +fn inner_scanner_filter(env: &mut JNIEnv, this: JObject, expr: JString) -> Result<()> { + let expr: String = env.get_string(&expr)?.into(); + with_lsm_scanner(env, &this, |scanner| Ok(scanner.filter(&expr)?)) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeLimit( + mut env: JNIEnv, + this: JObject, + limit: JObject, + offset: JObject, +) { + ok_or_throw_without_return!(env, inner_scanner_limit(&mut env, this, limit, offset)); +} + +fn inner_scanner_limit( + env: &mut JNIEnv, + this: JObject, + limit: JObject, + offset: JObject, +) -> Result<()> { + let limit = env.get_u64_opt(&limit)?.map(|v| v as i64); + let offset = env.get_u64_opt(&offset)?.map(|v| v as i64); + with_lsm_scanner(env, &this, |scanner| Ok(scanner.limit(limit, offset)?)) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeWithRowAddress( + mut env: JNIEnv, + this: JObject, +) { + ok_or_throw_without_return!( + env, + with_lsm_scanner(&mut env, &this, |scanner| Ok(scanner.with_row_address())) + ); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeWithMemtableGen( + mut env: JNIEnv, + this: JObject, +) { + ok_or_throw_without_return!( + env, + with_lsm_scanner(&mut env, &this, |scanner| Ok(scanner.with_memtable_gen())) + ); +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeOpenStream( + mut env: JNIEnv, + this: JObject, + stream_addr: jlong, +) { + ok_or_throw_without_return!(env, inner_scanner_open_stream(&mut env, this, stream_addr)); +} + +fn inner_scanner_open_stream(env: &mut JNIEnv, this: JObject, stream_addr: jlong) -> Result<()> { + let stream = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingLsmScanner>(&this, NATIVE_LSM_SCANNER) }?; + let scanner = guard.inner.as_ref().ok_or_else(|| { + Error::runtime_error("LsmScanner is no longer usable because an earlier builder call (e.g. filter) failed; create a new scanner".to_string()) + })?; + block_on(scanner.try_into_stream())? + }; + let ffi_stream = + to_ffi_arrow_array_stream(DatasetRecordBatchStream::new(stream), RT.handle().clone())?; + unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_nativeCountRows( + mut env: JNIEnv, + this: JObject, +) -> jlong { + ok_or_throw_with_return!(env, inner_scanner_count_rows(&mut env, this), -1) as jlong +} + +fn inner_scanner_count_rows(env: &mut JNIEnv, this: JObject) -> Result { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingLsmScanner>(&this, NATIVE_LSM_SCANNER) }?; + let scanner = guard + .inner + .as_ref() + .ok_or_else(|| Error::runtime_error("LsmScanner is no longer usable because an earlier builder call (e.g. filter) failed; create a new scanner".to_string()))?; + Ok(block_on(scanner.count_rows())?) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmScanner_releaseNativeLsmScanner( + mut env: JNIEnv, + this: JObject, + _handle: jlong, +) { + ok_or_throw_without_return!(env, inner_release_lsm_scanner(&mut env, this)); +} + +fn inner_release_lsm_scanner(env: &mut JNIEnv, this: JObject) -> Result<()> { + let _: BlockingLsmScanner = unsafe { env.take_rust_field(&this, NATIVE_LSM_SCANNER) }?; + Ok(()) +} + +/////////////////// +// ExecutionPlan // +/////////////////// + +fn attach_execution_plan<'local>( + env: &mut JNIEnv<'local>, + plan: Arc, + dataset_schema: Arc, +) -> Result> { + let java_obj = env.new_object("org/lance/memwal/ExecutionPlan", "()V", &[])?; + unsafe { + env.set_rust_field( + &java_obj, + NATIVE_EXECUTION_PLAN, + BlockingExecutionPlan { + plan, + dataset_schema, + }, + ) + }?; + Ok(java_obj) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ExecutionPlan_nativeImportSchema( + mut env: JNIEnv, + this: JObject, + schema_addr: jlong, +) { + ok_or_throw_without_return!(env, inner_plan_import_schema(&mut env, this, schema_addr)); +} + +fn inner_plan_import_schema(env: &mut JNIEnv, this: JObject, schema_addr: jlong) -> Result<()> { + let schema = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingExecutionPlan>(&this, NATIVE_EXECUTION_PLAN) + }?; + guard.plan.schema() + }; + let ffi_schema = FFI_ArrowSchema::try_from(schema.as_ref())?; + unsafe { std::ptr::write_unaligned(schema_addr as *mut FFI_ArrowSchema, ffi_schema) } + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ExecutionPlan_nativeImportDatasetSchema( + mut env: JNIEnv, + this: JObject, + schema_addr: jlong, +) { + ok_or_throw_without_return!( + env, + inner_plan_import_dataset_schema(&mut env, this, schema_addr) + ); +} + +fn inner_plan_import_dataset_schema( + env: &mut JNIEnv, + this: JObject, + schema_addr: jlong, +) -> Result<()> { + let schema = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingExecutionPlan>(&this, NATIVE_EXECUTION_PLAN) + }?; + guard.dataset_schema.clone() + }; + let ffi_schema = FFI_ArrowSchema::try_from(schema.as_ref())?; + unsafe { std::ptr::write_unaligned(schema_addr as *mut FFI_ArrowSchema, ffi_schema) } + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ExecutionPlan_nativeExplain<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_plan_explain(&mut env, this)) +} + +fn inner_plan_explain<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, +) -> Result> { + let explained = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingExecutionPlan>(&this, NATIVE_EXECUTION_PLAN) + }?; + format!("{}", displayable(guard.plan.as_ref()).indent(true)) + }; + Ok(env.new_string(explained)?.into()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ExecutionPlan_nativeOpenStream( + mut env: JNIEnv, + this: JObject, + stream_addr: jlong, +) { + ok_or_throw_without_return!(env, inner_plan_open_stream(&mut env, this, stream_addr)); +} + +fn inner_plan_open_stream(env: &mut JNIEnv, this: JObject, stream_addr: jlong) -> Result<()> { + let plan = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingExecutionPlan>(&this, NATIVE_EXECUTION_PLAN) + }?; + guard.plan.clone() + }; + let schema = plan.schema(); + let batches = block_on(async move { + let ctx = SessionContext::new(); + collect(plan, ctx.task_ctx()).await + }) + .map_err(|e| Error::io_error(format!("Plan execution failed: {}", e)))?; + + let reader: Box = Box::new(RecordBatchIterator::new( + batches.into_iter().map(Ok), + schema, + )); + let ffi_stream = FFI_ArrowArrayStream::new(reader); + unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } + Ok(()) +} + +////////////////////////// +// Sharding evaluation // +////////////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ShardingEvaluator_nativeEvaluate<'local>( + mut env: JNIEnv<'local>, + _class: JClass<'local>, + arrow_array_addr: jlong, + arrow_schema_addr: jlong, + sharding_spec: JObject<'local>, + source_id_to_column: JObject<'local>, + stream_addr: jlong, +) { + ok_or_throw_without_return!( + env, + inner_evaluate_sharding( + &mut env, + arrow_array_addr, + arrow_schema_addr, + sharding_spec, + source_id_to_column, + stream_addr, + ) + ); +} + +fn inner_evaluate_sharding( + env: &mut JNIEnv, + arrow_array_addr: jlong, + arrow_schema_addr: jlong, + sharding_spec: JObject, + source_id_to_column: JObject, + stream_addr: jlong, +) -> Result<()> { + let input = import_ffi_array(arrow_array_addr, arrow_schema_addr)?; + let struct_array = input + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error( + "ShardingEvaluator expects a VectorSchemaRoot struct array".to_string(), + ) + })?; + let input_batch = RecordBatch::from(struct_array.clone()); + let spec = sharding_spec_from_java(env, &sharding_spec)?; + let source_id_to_column = i32_string_map_from_java(env, source_id_to_column)?; + let result = + evaluate_sharding_spec_with_source_columns(&input_batch, &spec, &source_id_to_column)?; + let schema = result.schema(); + let reader: Box = + Box::new(RecordBatchIterator::new([Ok(result)].into_iter(), schema)); + let ffi_stream = FFI_ArrowArrayStream::new(reader); + unsafe { std::ptr::write_unaligned(stream_addr as *mut FFI_ArrowArrayStream, ffi_stream) } + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_ExecutionPlan_releaseNativeExecutionPlan( + mut env: JNIEnv, + this: JObject, + _handle: jlong, +) { + ok_or_throw_without_return!(env, inner_release_execution_plan(&mut env, this)); +} + +fn inner_release_execution_plan(env: &mut JNIEnv, this: JObject) -> Result<()> { + let _: BlockingExecutionPlan = unsafe { env.take_rust_field(&this, NATIVE_EXECUTION_PLAN) }?; + Ok(()) +} + +///////////////////////// +// LsmPointLookupPlanner // +///////////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmPointLookupPlanner_nativeCreate( + mut env: JNIEnv, + this: JObject, + dataset: JObject, + shard_snapshots: JObject, + pk_columns: JObject, +) { + ok_or_throw_without_return!( + env, + inner_create_lookup_planner(&mut env, this, dataset, shard_snapshots, pk_columns) + ); +} + +fn inner_create_lookup_planner( + env: &mut JNIEnv, + this: JObject, + dataset: JObject, + shard_snapshots: JObject, + pk_columns: JObject, +) -> Result<()> { + let snapshots = read_shard_snapshots(env, &shard_snapshots)?; + let pk_columns = env.get_strings_opt(&pk_columns)?; + let dataset = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&dataset, NATIVE_DATASET) }?; + Arc::new(guard.inner.clone()) + }; + let pk_columns = match pk_columns { + Some(cols) => cols, + None => get_pk_columns(&dataset)?, + }; + let base_schema = Arc::new(ArrowSchema::from(dataset.schema())); + // A sealed generation stores the names it was written with, so the planner + // resolves its columns by id; `base_schema` carries none. Built before the + // collector takes the dataset. + let identity_schema = Arc::new(arrow_schema_with_field_ids(dataset.schema())); + let collector = LsmDataSourceCollector::new(dataset, snapshots); + let planner = LsmPointLookupPlanner::new(collector, pk_columns.clone(), base_schema.clone())? + .with_identity_schema(identity_schema); + + let blocking = BlockingLsmPointLookupPlanner { + planner, + dataset_schema: base_schema, + pk_columns, + }; + unsafe { env.set_rust_field(&this, NATIVE_LOOKUP_PLANNER, blocking) }?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmPointLookupPlanner_nativePlanLookup<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, + array_addr: jlong, + schema_addr: jlong, + columns: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_plan_lookup(&mut env, this, array_addr, schema_addr, columns) + ) +} + +fn inner_plan_lookup<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, + array_addr: jlong, + schema_addr: jlong, + columns: JObject<'local>, +) -> Result> { + let pk_value = import_ffi_array(array_addr, schema_addr)?; + let columns = env.get_strings_opt(&columns)?; + + let (plan, dataset_schema) = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingLsmPointLookupPlanner>(&this, NATIVE_LOOKUP_PLANNER) + }?; + let pk_values = scalar_values_from_pk_value(pk_value.as_ref(), &guard.pk_columns)?; + let plan = block_on(guard.planner.plan_lookup(&pk_values, columns.as_deref()))?; + (plan, guard.dataset_schema.clone()) + }; + attach_execution_plan(env, plan, dataset_schema) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmPointLookupPlanner_releaseNativeLookupPlanner( + mut env: JNIEnv, + this: JObject, + _handle: jlong, +) { + ok_or_throw_without_return!(env, inner_release_lookup_planner(&mut env, this)); +} + +fn inner_release_lookup_planner(env: &mut JNIEnv, this: JObject) -> Result<()> { + let _: BlockingLsmPointLookupPlanner = + unsafe { env.take_rust_field(&this, NATIVE_LOOKUP_PLANNER) }?; + Ok(()) +} + +/////////////////////////// +// LsmVectorSearchPlanner // +/////////////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmVectorSearchPlanner_nativeCreate( + mut env: JNIEnv, + this: JObject, + dataset: JObject, + shard_snapshots: JObject, + vector_column: JString, + pk_columns: JObject, + distance_type: JObject, + filter: JObject, +) { + ok_or_throw_without_return!( + env, + inner_create_vector_planner( + &mut env, + this, + dataset, + shard_snapshots, + vector_column, + pk_columns, + distance_type, + filter, + ) + ); +} + +#[allow(clippy::too_many_arguments)] +fn inner_create_vector_planner( + env: &mut JNIEnv, + this: JObject, + dataset: JObject, + shard_snapshots: JObject, + vector_column: JString, + pk_columns: JObject, + distance_type: JObject, + filter: JObject, +) -> Result<()> { + let snapshots = read_shard_snapshots(env, &shard_snapshots)?; + let vector_column: String = env.get_string(&vector_column)?.into(); + let pk_columns = env.get_strings_opt(&pk_columns)?; + let distance_type = env.get_string_opt(&distance_type)?; + let filter = env.get_string_opt(&filter)?; + let dataset = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&dataset, NATIVE_DATASET) }?; + Arc::new(guard.inner.clone()) + }; + let pk_columns = match pk_columns { + Some(cols) => cols, + None => get_pk_columns(&dataset)?, + }; + let base_schema = Arc::new(ArrowSchema::from(dataset.schema())); + // See the point-lookup planner: a rename moves a name and keeps the id. + let identity_schema = Arc::new(arrow_schema_with_field_ids(dataset.schema())); + let dist_type = parse_distance_type(distance_type.as_deref().unwrap_or("l2"))?; + let vector_dim = get_vector_dim(&dataset, &vector_column)?; + let filter = filter + .as_deref() + .map(|filter| parse_lsm_filter_expr(base_schema.as_ref(), filter).map_err(Error::from)) + .transpose()?; + + let collector = LsmDataSourceCollector::new(dataset.clone(), snapshots); + let mut planner = LsmVectorSearchPlanner::new( + collector, + pk_columns, + base_schema.clone(), + vector_column, + dist_type, + ) + .with_identity_schema(identity_schema) + .with_dataset(dataset); + if let Some(filter) = filter { + planner = planner.with_filter(Some(filter)); + } + + let blocking = BlockingLsmVectorSearchPlanner { + planner, + vector_dim, + dataset_schema: base_schema, + }; + unsafe { env.set_rust_field(&this, NATIVE_VECTOR_PLANNER, blocking) }?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmVectorSearchPlanner_nativePlanSearch<'local>( + mut env: JNIEnv<'local>, + this: JObject<'local>, + array_addr: jlong, + schema_addr: jlong, + k: jint, + nprobes: jint, + columns: JObject<'local>, + refine_base_table: bool, + overfetch_factor: jdouble, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_plan_search( + &mut env, + this, + array_addr, + schema_addr, + k, + nprobes, + columns, + refine_base_table, + overfetch_factor, + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_plan_search<'local>( + env: &mut JNIEnv<'local>, + this: JObject<'local>, + array_addr: jlong, + schema_addr: jlong, + k: jint, + nprobes: jint, + columns: JObject<'local>, + refine_base_table: bool, + overfetch_factor: f64, +) -> Result> { + let query = import_ffi_array(array_addr, schema_addr)?; + let columns = env.get_strings_opt(&columns)?; + let float32_array = query + .as_any() + .downcast_ref::() + .ok_or_else(|| Error::input_error("query must be a Float32 array".to_string()))?; + + let (plan, dataset_schema) = { + let guard = unsafe { + env.get_rust_field::<_, _, BlockingLsmVectorSearchPlanner>(&this, NATIVE_VECTOR_PLANNER) + }?; + if float32_array.len() != guard.vector_dim { + return Err(Error::input_error(format!( + "Query vector has {} dimensions, expected {}", + float32_array.len(), + guard.vector_dim + ))); + } + let field = Arc::new(Field::new("item", DataType::Float32, true)); + let fsl = FixedSizeListArray::try_new( + field, + guard.vector_dim as i32, + Arc::new(float32_array.clone()), + None, + )?; + let plan = block_on(guard.planner.plan_search( + &fsl, + k as usize, + nprobes as usize, + columns.as_deref(), + refine_base_table, + overfetch_factor, + ))?; + (plan, guard.dataset_schema.clone()) + }; + attach_execution_plan(env, plan, dataset_schema) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_memwal_LsmVectorSearchPlanner_releaseNativeVectorPlanner( + mut env: JNIEnv, + this: JObject, + _handle: jlong, +) { + ok_or_throw_without_return!(env, inner_release_vector_planner(&mut env, this)); +} + +fn inner_release_vector_planner(env: &mut JNIEnv, this: JObject) -> Result<()> { + let _: BlockingLsmVectorSearchPlanner = + unsafe { env.take_rust_field(&this, NATIVE_VECTOR_PLANNER) }?; + Ok(()) +} + +/////////////////////////// +// Dataset MemWAL methods // +/////////////////////////// + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeInitializeMemWal( + mut env: JNIEnv, + jdataset: JObject, + params: JObject, +) { + ok_or_throw_without_return!(env, inner_initialize_mem_wal(&mut env, jdataset, params)); +} + +fn inner_initialize_mem_wal(env: &mut JNIEnv, jdataset: JObject, params: JObject) -> Result<()> { + let maintained_indexes = + env.get_optional_from_method(¶ms, "maintainedIndexes", |env, list| { + env.get_strings(&list) + })?; + let bucket_column = env.get_optional_string_from_method(¶ms, "bucketColumn")?; + let num_buckets = env.get_optional_u32_from_method(¶ms, "numBuckets")?; + let identity_column = env.get_optional_string_from_method(¶ms, "identityColumn")?; + let unsharded = env.call_method(¶ms, "unsharded", "()Z", &[])?.z()?; + let writer_config = + env.get_optional_from_method(¶ms, "writerConfigDefaults", |env, config_obj| { + build_writer_config(env, &config_obj) + })?; + + let bucket = match (bucket_column.as_deref(), num_buckets) { + (Some(_), Some(_)) => true, + (None, None) => false, + _ => { + return Err(Error::input_error( + "bucket sharding requires both bucketColumn and numBuckets".to_string(), + )); + } + }; + let modes = [bucket, identity_column.is_some(), unsharded] + .into_iter() + .filter(|&m| m) + .count(); + if modes > 1 { + return Err(Error::input_error( + "at most one of bucket sharding, identityColumn, or unsharded may be set".to_string(), + )); + } + + let mut guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&jdataset, NATIVE_DATASET) }?; + let mut builder = guard.inner.initialize_mem_wal(); + if let (Some(column), Some(n)) = (bucket_column, num_buckets) { + builder = builder.bucket_sharding(column, n); + } else if let Some(column) = identity_column { + builder = builder.identity_sharding(column); + } else if unsharded { + builder = builder.unsharded(); + } + // Flattening an absent list to an empty one here would ask for no index + // at all rather than every one. + if let Some(maintained_indexes) = maintained_indexes { + builder = builder.maintained_indexes(maintained_indexes); + } + if let Some(config) = writer_config { + builder = builder.writer_config_defaults(config); + } + block_on(builder.execute())?; + Ok(()) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeMemWalIndexDetails<'local>( + mut env: JNIEnv<'local>, + jdataset: JObject<'local>, +) -> JObject<'local> { + ok_or_throw!(env, inner_mem_wal_index_details(&mut env, jdataset)) +} + +fn inner_mem_wal_index_details<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject<'local>, +) -> Result> { + let details = { + let guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&jdataset, NATIVE_DATASET) }?; + block_on(guard.inner.mem_wal_index_details())? + }; + match details { + Some(details) => index_details_to_java(env, &details), + None => Ok(JObject::null()), + } +} + +/////////////// +// Helpers // +/////////////// + +fn parse_uuid(shard_id: &str) -> Result { + Uuid::parse_str(shard_id) + .map_err(|e| Error::input_error(format!("Invalid shard_id UUID: {}", e))) +} + +/// Read a `List` Java object into Rust `ShardSnapshot`s. +fn read_shard_snapshots(env: &mut JNIEnv, list_obj: &JObject) -> Result> { + import_vec_to_rust(env, list_obj, |env, obj| { + let shard_id = env.get_string_from_method(&obj, "shardId")?; + let uuid = parse_uuid(&shard_id)?; + let spec_id = env.get_u32_from_method(&obj, "specId")?; + let current_generation = env.get_u64_from_method(&obj, "currentGeneration")?; + let mut snapshot = ShardSnapshot::new(uuid) + .with_spec_id(spec_id) + .with_current_generation(current_generation); + + let sstable_list = env + .call_method(&obj, "sstables", "()Ljava/util/List;", &[])? + .l()?; + for sstable in import_vec(env, &sstable_list)? { + let generation = env.get_u64_from_method(&sstable, "generation")?; + let path = env.get_string_from_method(&sstable, "path")?; + snapshot = snapshot.with_sstable(generation, path); + } + Ok(snapshot) + }) +} + +/// Reconstruct a [`ShardSnapshot`] from a writer's [`ShardManifest`]. +fn shard_snapshot_from_manifest(manifest: ShardManifest) -> ShardSnapshot { + ShardSnapshot { + shard_id: manifest.shard_id, + spec_id: manifest.shard_spec_id, + current_generation: manifest.current_generation, + sstables: manifest + .sstables + .into_iter() + .map(|sstable| SsTable { + generation: sstable.generation, + path: sstable.path, + }) + .collect(), + } +} + +/// Extract primary key column names from the dataset schema. +fn get_pk_columns(dataset: &LanceDataset) -> Result> { + let pk_fields = dataset.schema().unenforced_primary_key(); + if pk_fields.is_empty() { + return Err(Error::input_error( + "Dataset has no primary key. Set 'lance-schema:unenforced-primary-key' metadata \ + on the primary key field(s)." + .to_string(), + )); + } + Ok(pk_fields.iter().map(|f| f.name.clone()).collect()) +} + +/// Parse a distance type string into a [`DistanceType`]. +fn parse_distance_type(s: &str) -> Result { + match s.to_lowercase().as_str() { + "l2" | "euclidean" => Ok(DistanceType::L2), + "cosine" => Ok(DistanceType::Cosine), + "dot" | "inner_product" => Ok(DistanceType::Dot), + "hamming" => Ok(DistanceType::Hamming), + _ => Err(Error::input_error(format!( + "Unknown distanceType '{}'. Valid values: 'l2', 'cosine', 'dot', 'hamming'", + s + ))), + } +} + +/// Get the vector dimension of a `FixedSizeList` column from the dataset schema. +fn get_vector_dim(dataset: &LanceDataset, column: &str) -> Result { + let schema = ArrowSchema::from(dataset.schema()); + let field = schema.field_with_name(column).map_err(|_| { + Error::input_error(format!("Column '{}' not found in dataset schema", column)) + })?; + match field.data_type() { + DataType::FixedSizeList(_, size) => Ok(*size as usize), + other => Err(Error::input_error(format!( + "Column '{}' is not a FixedSizeList (got {:?}). \ + Vector columns must be FixedSizeList.", + column, other + ))), + } +} + +/// Convert a primary key Arrow array (single row) into DataFusion scalars. +fn scalar_values_from_pk_value( + pk_value: &dyn Array, + pk_columns: &[String], +) -> Result> { + if pk_value.len() != 1 { + return Err(Error::input_error(format!( + "pkValue must contain exactly one row, got {}", + pk_value.len() + ))); + } + + if pk_columns.len() == 1 { + let scalar = ScalarValue::try_from_array(pk_value, 0) + .map_err(|e| Error::input_error(format!("Cannot convert pkValue: {}", e)))?; + return Ok(vec![scalar]); + } + + let struct_array = pk_value + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::input_error(format!( + "Composite primary key lookup requires a struct vector with one row and {} fields", + pk_columns.len() + )) + })?; + + if struct_array.num_columns() != pk_columns.len() { + return Err(Error::input_error(format!( + "Composite primary key lookup expected {} fields, got {}", + pk_columns.len(), + struct_array.num_columns() + ))); + } + + let mut pk_values = Vec::with_capacity(pk_columns.len()); + for column_name in pk_columns { + let column = struct_array.column_by_name(column_name).ok_or_else(|| { + Error::input_error(format!( + "Composite primary key lookup requires field '{}' in pkValue", + column_name + )) + })?; + let scalar = ScalarValue::try_from_array(column.as_ref(), 0) + .map_err(|e| Error::input_error(format!("Cannot convert composite pkValue: {}", e)))?; + pk_values.push(scalar); + } + Ok(pk_values) +} + +/// Import an Arrow array exported through the C Data Interface. +/// +/// The FFI structs at the given addresses are moved out and replaced with empty +/// placeholders, so the producer's `close()` on the Java side is a safe no-op. +fn import_ffi_array(array_addr: jlong, schema_addr: jlong) -> Result { + let ffi_array = + unsafe { std::ptr::replace(array_addr as *mut FFI_ArrowArray, FFI_ArrowArray::empty()) }; + let ffi_schema = unsafe { + std::ptr::replace( + schema_addr as *mut FFI_ArrowSchema, + FFI_ArrowSchema::empty(), + ) + }; + let array_data = unsafe { from_ffi(ffi_array, &ffi_schema) }?; + Ok(make_array(array_data)) +} + +/// Build a [`ShardWriterConfig`] from a Java `ShardWriterConfig` object. +fn build_writer_config(env: &mut JNIEnv, config: &JObject) -> Result { + let mut writer_config = ShardWriterConfig::default(); + if let Some(v) = read_optional_bool(env, config, "durableWrite")? { + writer_config = writer_config.with_durable_write(v); + } + if let Some(v) = read_optional_u64(env, config, "maxWalBufferSize")? { + writer_config = writer_config.with_max_wal_buffer_size(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "maxWalFlushIntervalMs")? { + writer_config = writer_config.with_max_wal_flush_interval(Duration::from_millis(v)); + } + if let Some(v) = read_optional_u64(env, config, "maxMemtableSize")? { + writer_config = writer_config.with_max_memtable_size(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "maxMemtableRows")? { + writer_config = writer_config.with_max_memtable_rows(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "maxMemtableBatches")? { + writer_config = writer_config.with_max_memtable_batches(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "maxUnflushedMemtableBytes")? { + writer_config = writer_config.with_max_unflushed_memtable_bytes(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "manifestScanBatchSize")? { + writer_config = writer_config.with_manifest_scan_batch_size(v as usize); + } + if let Some(v) = read_optional_u64(env, config, "backpressureLogIntervalMs")? { + writer_config = writer_config.with_backpressure_log_interval(Duration::from_millis(v)); + } + if let Some(v) = read_optional_u64(env, config, "statsLogIntervalMs")? { + let interval = if v == 0 { + None + } else { + Some(Duration::from_millis(v)) + }; + writer_config = writer_config.with_stats_log_interval(interval); + } + let hnsw_params = import_vec_from_method( + env, + config, + "hnswParams", + |env, item| -> Result<(String, HnswBuildParams)> { + let index_name = env + .call_method(&item, "indexName", "()Ljava/lang/String;", &[])? + .l()?; + let index_name: String = JString::from(index_name).extract(env)?; + let num_edges = env.call_method(&item, "numEdges", "()I", &[])?.i()? as usize; + let ef_construction = + env.call_method(&item, "efConstruction", "()I", &[])?.i()? as usize; + let max_level = env.call_method(&item, "maxLevel", "()I", &[])?.i()? as u16; + Ok(( + index_name, + HnswBuildParams::default() + .num_edges(num_edges) + .ef_construction(ef_construction) + .max_level(max_level), + )) + }, + )?; + for (index_name, params) in hnsw_params { + writer_config = writer_config.with_hnsw_params(index_name, params); + } + Ok(writer_config) +} + +fn read_optional_bool(env: &mut JNIEnv, obj: &JObject, method: &str) -> Result> { + env.get_optional_from_method(obj, method, |env, value| { + Ok(env.call_method(&value, "booleanValue", "()Z", &[])?.z()?) + }) +} + +fn read_optional_u64(env: &mut JNIEnv, obj: &JObject, method: &str) -> Result> { + env.get_optional_from_method(obj, method, |env, value| { + Ok(env.call_method(&value, "longValue", "()J", &[])?.j()? as u64) + }) +} + +fn write_stats_to_java<'a>( + env: &mut JNIEnv<'a>, + stats: &WriteStatsSnapshot, +) -> Result> { + Ok(env.new_object( + "org/lance/memwal/WriteStats", + "(JJJJJJJJ)V", + &[ + JValueGen::Long(stats.put_count as i64), + JValueGen::Long(stats.put_time.as_millis() as i64), + JValueGen::Long(stats.wal_flush_count as i64), + JValueGen::Long(stats.wal_flush_bytes as i64), + JValueGen::Long(stats.wal_flush_time.as_millis() as i64), + JValueGen::Long(stats.memtable_flush_count as i64), + JValueGen::Long(stats.memtable_flush_rows as i64), + JValueGen::Long(stats.memtable_flush_time.as_millis() as i64), + ], + )?) +} + +fn memtable_stats_to_java<'a>( + env: &mut JNIEnv<'a>, + stats: &MemTableStats, + memory: &ShardMemory, +) -> Result> { + let max_buffered = box_u64_opt(env, stats.max_buffered_batch_position)?; + let pending_start = box_u64_opt(env, stats.pending_wal_start_batch_position)?; + let pending_end = box_u64_opt(env, stats.pending_wal_end_batch_position)?; + Ok(env.new_object( + "org/lance/memwal/MemTableStats", + "(JJJJLjava/lang/Long;JJLjava/lang/Long;Ljava/lang/Long;JJJJJJ)V", + &[ + JValueGen::Long(stats.row_count as i64), + JValueGen::Long(stats.batch_count as i64), + JValueGen::Long(memory.row_bytes() as i64), + JValueGen::Long(stats.generation as i64), + JValueGen::Object(&max_buffered), + JValueGen::Long(stats.durable_batch_count as i64), + JValueGen::Long(stats.global_offset as i64), + JValueGen::Object(&pending_start), + JValueGen::Object(&pending_end), + JValueGen::Long(stats.pending_wal_batch_count as i64), + JValueGen::Long(stats.pending_wal_row_count as i64), + JValueGen::Long(stats.pending_wal_estimated_bytes as i64), + JValueGen::Long(memory.index_bytes() as i64), + JValueGen::Long(memory.grace_bytes() as i64), + JValueGen::Long(memory.retained_bytes() as i64), + ], + )?) +} + +fn box_u64_opt<'a>(env: &mut JNIEnv<'a>, value: Option) -> Result> { + match value { + Some(v) => Ok(env.new_object("java/lang/Long", "(J)V", &[JValueGen::Long(v as i64)])?), + None => Ok(JObject::null()), + } +} + +fn index_details_to_java<'a>( + env: &mut JNIEnv<'a>, + details: &MemWalIndexDetails, +) -> Result> { + let maintained_indexes = export_vec(env, &details.maintained_indexes)?; + let writer_config_defaults = details.writer_config_defaults.clone().into_java(env)?; + + let sharding_specs = env.new_object("java/util/ArrayList", "()V", &[])?; + for spec in &details.sharding_specs { + let spec_obj = sharding_spec_to_java(env, spec)?; + env.call_method( + &sharding_specs, + "add", + "(Ljava/lang/Object;)Z", + &[JValueGen::Object(&spec_obj)], + )?; + } + + Ok(env.new_object( + "org/lance/memwal/MemWalIndexDetails", + "(JLjava/util/List;ZLjava/util/Map;Ljava/util/List;)V", + &[ + JValueGen::Long(details.num_shards as i64), + JValueGen::Object(&maintained_indexes), + JValueGen::Bool(u8::from(details.maintain_all_indexes)), + JValueGen::Object(&writer_config_defaults), + JValueGen::Object(&sharding_specs), + ], + )?) +} + +fn sharding_spec_to_java<'a>(env: &mut JNIEnv<'a>, spec: &ShardingSpec) -> Result> { + let fields = env.new_object("java/util/ArrayList", "()V", &[])?; + for field in &spec.fields { + let field_obj = sharding_field_to_java(env, field)?; + env.call_method( + &fields, + "add", + "(Ljava/lang/Object;)Z", + &[JValueGen::Object(&field_obj)], + )?; + } + Ok(env.new_object( + "org/lance/memwal/ShardingSpec", + "(ILjava/util/List;)V", + &[ + JValueGen::Int(spec.spec_id as i32), + JValueGen::Object(&fields), + ], + )?) +} + +fn sharding_field_to_java<'a>(env: &mut JNIEnv<'a>, field: &ShardingField) -> Result> { + let field_id: JObject = env.new_string(&field.field_id)?.into(); + let source_ids = int_list_to_java(env, &field.source_ids)?; + let transform: JObject = match &field.transform { + Some(t) => env.new_string(t)?.into(), + None => JObject::null(), + }; + let expression: JObject = match &field.expression { + Some(e) => env.new_string(e)?.into(), + None => JObject::null(), + }; + let result_type: JObject = env.new_string(&field.result_type)?.into(); + let parameters = field.parameters.clone().into_java(env)?; + Ok(env.new_object( + "org/lance/memwal/ShardingField", + "(Ljava/lang/String;Ljava/util/List;Ljava/lang/String;Ljava/lang/String;\ + Ljava/lang/String;Ljava/util/Map;)V", + &[ + JValueGen::Object(&field_id), + JValueGen::Object(&source_ids), + JValueGen::Object(&transform), + JValueGen::Object(&expression), + JValueGen::Object(&result_type), + JValueGen::Object(¶meters), + ], + )?) +} + +fn sharding_spec_from_java(env: &mut JNIEnv, spec: &JObject) -> Result { + if spec.is_null() { + return Err(Error::input_error( + "ShardingSpec must not be null".to_string(), + )); + } + + env.with_local_frame(32, |env| { + let spec_id = env.call_method(spec, "specId", "()I", &[])?.i()? as u32; + let fields_obj = env + .call_method(spec, "fields", "()Ljava/util/List;", &[])? + .l()?; + let fields_list = env.get_list(&fields_obj)?; + let mut iter = fields_list.iter(env)?; + let mut fields = Vec::with_capacity(fields_list.size(env)? as usize); + while let Some(field_obj) = iter.next(env)? { + fields.push(sharding_field_from_java(env, &field_obj)?); + } + + Ok::<_, Error>(ShardingSpec { spec_id, fields }) + }) +} + +fn sharding_field_from_java(env: &mut JNIEnv, field: &JObject) -> Result { + env.with_local_frame(32, |env| { + let field_id = string_from_method(env, field, "fieldId")?; + let source_ids_obj = env + .call_method(field, "sourceIds", "()Ljava/util/List;", &[])? + .l()?; + let source_ids = env.get_integers(&source_ids_obj)?; + let transform = env.get_optional_string_from_method(field, "transform")?; + let expression = env.get_optional_string_from_method(field, "expression")?; + let result_type = string_from_method(env, field, "resultType")?; + let parameters_obj = env + .call_method(field, "parameters", "()Ljava/util/Map;", &[])? + .l()?; + let parameters = if parameters_obj.is_null() { + HashMap::new() + } else { + let parameters_map = JMap::from_env(env, ¶meters_obj)?; + to_rust_map(env, ¶meters_map)? + }; + + Ok::<_, Error>(ShardingField { + field_id, + source_ids, + transform, + expression, + result_type, + parameters, + }) + }) +} + +fn i32_string_map_from_java(env: &mut JNIEnv, map_obj: JObject) -> Result> { + if map_obj.is_null() { + return Ok(HashMap::new()); + } + + env.with_local_frame(32, |env| { + let jmap = JMap::from_env(env, &map_obj)?; + let mut iter = jmap.iter(env)?; + let mut map = HashMap::new(); + while let Some((key, value)) = iter.next(env)? { + let key = env.call_method(&key, "intValue", "()I", &[])?.i()?; + let value = JString::from(value); + let value: String = env.get_string(&value)?.into(); + map.insert(key, value); + } + Ok::<_, Error>(map) + }) +} + +fn string_from_method(env: &mut JNIEnv, obj: &JObject, method: &str) -> Result { + let value = env + .call_method(obj, method, "()Ljava/lang/String;", &[])? + .l()?; + let value = JString::from(value); + Ok(env.get_string(&value)?.into()) +} + +fn int_list_to_java<'a>(env: &mut JNIEnv<'a>, ints: &[i32]) -> Result> { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for &value in ints { + let integer = env.new_object("java/lang/Integer", "(I)V", &[JValueGen::Int(value)])?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValueGen::Object(&integer)], + )?; + } + Ok(list) +} diff --git a/java/lance-jni/src/merge_insert.rs b/java/lance-jni/src/merge_insert.rs index dca9e163ff8..699f9c418ea 100644 --- a/java/lance-jni/src/merge_insert.rs +++ b/java/lance-jni/src/merge_insert.rs @@ -3,21 +3,27 @@ use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; use crate::error::Result; +use crate::traits::import_vec_to_rust; use crate::traits::{FromJString, IntoJava}; -use crate::{Error, JNIEnvExt, RT}; +use crate::{Error, JNIEnvExt, block_on}; use arrow::ffi_stream::{ArrowArrayStreamReader, FFI_ArrowArrayStream}; +use jni::JNIEnv; use jni::objects::{JObject, JString, JValueGen}; use jni::sys::jlong; -use jni::JNIEnv; use lance::dataset::scanner::ExprFilter; use lance::dataset::{ - MergeInsertBuilder, MergeStats, WhenMatched, WhenNotMatched, WhenNotMatchedBySource, + MergeInsertBuilder, MergeInsertWriteMode, MergeStats, WhenMatched, WhenNotMatched, + WhenNotMatchedBySource, }; use lance_core::datatypes::Schema; +use lance_file::version::LanceFileVersion; +use lance_index::mem_wal::CompactedSsTable; +use std::str::FromStr; use std::sync::Arc; use std::time::Duration; +use uuid::Uuid; -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeMergeInsert<'a>( mut env: JNIEnv<'a>, jdataset: JObject, // Dataset object @@ -48,6 +54,10 @@ fn inner_merge_insert<'local>( let conflict_retries = extract_conflict_retries(env, &jparam)?; let retry_timeout_ms = extract_retry_timeout_ms(env, &jparam)?; let skip_auto_cleanup = extract_skip_auto_cleanup(env, &jparam)?; + let use_index = extract_use_index(env, &jparam)?; + let write_mode = extract_write_mode(env, &jparam)?; + let compacted_sstables = extract_compacted_sstables(env, &jparam)?; + let data_storage_version = extract_data_storage_version(env, &jparam)?; let (new_ds, merge_stats) = unsafe { let dataset = env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET)?; @@ -58,19 +68,26 @@ fn inner_merge_insert<'local>( when_not_matched_by_source_delete_expr, )?; - let merge_insert_job = MergeInsertBuilder::try_new(Arc::new(dataset.clone().inner), on)? + let mut builder = MergeInsertBuilder::try_new(Arc::new(dataset.clone().inner), on)?; + if let Some(version) = data_storage_version { + builder.data_storage_version(LanceFileVersion::from_str(&version)?); + } + let merge_insert_job = builder .when_matched(when_matched) .when_not_matched(when_not_matched) .when_not_matched_by_source(when_not_matched_by_source) .conflict_retries(conflict_retries) .retry_timeout(Duration::from_millis(retry_timeout_ms as u64)) .skip_auto_cleanup(skip_auto_cleanup) + .use_index(use_index) + .write_mode(write_mode) + .mark_sstables_as_compacted(compacted_sstables) .try_build()?; let stream_ptr = batch_address as *mut FFI_ArrowArrayStream; let source_stream = ArrowArrayStreamReader::from_raw(stream_ptr)?; - RT.block_on(async move { merge_insert_job.execute_reader(source_stream).await })? + block_on(async move { merge_insert_job.execute_reader(source_stream).await })? }; MergeResult( @@ -229,6 +246,66 @@ fn extract_skip_auto_cleanup<'local>(env: &mut JNIEnv<'local>, jparam: &JObject) Ok(skip_auto_cleanup) } +fn extract_use_index<'local>(env: &mut JNIEnv<'local>, jparam: &JObject) -> Result { + let use_index = env.call_method(jparam, "useIndex", "()Z", &[])?.z()?; + Ok(use_index) +} + +fn extract_data_storage_version<'local>( + env: &mut JNIEnv<'local>, + jparam: &JObject, +) -> Result> { + let version = env + .call_method( + jparam, + "getDataStorageVersion", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + env.get_string_opt(&version) +} + +fn extract_write_mode<'local>( + env: &mut JNIEnv<'local>, + jparam: &JObject, +) -> Result { + let write_mode: JString = env + .call_method(jparam, "writeModeValue", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let write_mode = write_mode.extract(env)?; + + match write_mode.as_str() { + "Auto" => Ok(MergeInsertWriteMode::Auto), + "RewriteRows" => Ok(MergeInsertWriteMode::RewriteRows), + "RewriteColumns" => Ok(MergeInsertWriteMode::RewriteColumns), + _ => Err(Error::input_error(format!( + "Illegal write_mode: {write_mode}", + ))), + } +} + +fn extract_compacted_sstables<'local>( + env: &mut JNIEnv<'local>, + jparam: &JObject, +) -> Result> { + let list = env + .call_method(jparam, "getCompactedSstables", "()Ljava/util/List;", &[])? + .l()?; + import_vec_to_rust(env, &list, |env, obj| { + let shard_id: JString = env + .call_method(&obj, "getShardId", "()Ljava/lang/String;", &[])? + .l()? + .into(); + let shard_id = shard_id.extract(env)?; + let generation = env.call_method(&obj, "getGeneration", "()J", &[])?.j()? as u64; + let uuid = Uuid::parse_str(&shard_id) + .map_err(|e| Error::input_error(format!("Invalid shard_id UUID: {}", e)))?; + Ok(CompactedSsTable::new(uuid, generation)) + }) +} + const MERGE_STATS_CLASS: &str = "org/lance/merge/MergeInsertStats"; const MERGE_STATS_CONSTRUCTOR_SIG: &str = "(JJJIJJ)V"; const MERGE_RESULT_CLASS: &str = "org/lance/merge/MergeInsertResult"; diff --git a/java/lance-jni/src/namespace.rs b/java/lance-jni/src/namespace.rs index b9db171c064..9a670af0e2a 100644 --- a/java/lance-jni/src/namespace.rs +++ b/java/lance-jni/src/namespace.rs @@ -4,21 +4,23 @@ use std::collections::HashMap; use std::sync::Arc; +use async_trait::async_trait; use bytes::Bytes; -use jni::objects::{GlobalRef, JByteArray, JMap, JObject, JString, JValue}; -use jni::sys::{jbyteArray, jlong, jstring}; use jni::JNIEnv; -use lance_namespace::models::*; +use jni::objects::{GlobalRef, JByteArray, JMap, JObject, JString, JValue}; +use jni::sys::{jbyteArray, jlong, jobject, jstring}; use lance_namespace::LanceNamespace as LanceNamespaceTrait; +use lance_namespace::compat::merge_insert_request_from_json; +use lance_namespace::models::*; use lance_namespace_impls::{ ConnectBuilder, DirectoryNamespace, DirectoryNamespaceBuilder, DynamicContextProvider, OperationInfo, RestAdapter, RestAdapterConfig, RestNamespace, RestNamespaceBuilder, }; use serde::{Deserialize, Serialize}; +use crate::block_on; use crate::error::{Error, Result}; use crate::utils::to_rust_map; -use crate::RT; /// Java-implemented dynamic context provider. /// @@ -116,21 +118,1421 @@ 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: DirectoryNamespace, + pub(crate) inner: Arc, } /// Blocking wrapper for RestNamespace pub struct BlockingRestNamespace { - pub(crate) inner: RestNamespace, + pub(crate) inner: Arc, +} + +// ============================================================================ +// JavaLanceNamespace - Generic wrapper for any Java LanceNamespace implementation +// ============================================================================ + +/// Java-implemented LanceNamespace wrapper. +/// +/// This wraps any Java object that implements the LanceNamespace interface +/// and forwards calls to the Java implementation via JNI. +pub struct JavaLanceNamespace { + java_namespace: GlobalRef, + jvm: Arc, + namespace_id: String, +} + +impl std::fmt::Debug for JavaLanceNamespace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "JavaLanceNamespace({})", self.namespace_id) + } +} + +impl JavaLanceNamespace { + /// Create a new wrapper for a Java LanceNamespace object. + pub fn new(env: &mut JNIEnv, java_namespace: &JObject) -> Result { + let java_namespace = env.new_global_ref(java_namespace)?; + let jvm = Arc::new(env.get_java_vm()?); + + // Cache namespace_id since it's called frequently and won't change + let namespace_id = Self::call_namespace_id_internal(env, &java_namespace)?; + + Ok(Self { + java_namespace, + jvm, + namespace_id, + }) + } + + fn call_namespace_id_internal(env: &mut JNIEnv, java_namespace: &GlobalRef) -> Result { + let result = env + .call_method(java_namespace, "namespaceId", "()Ljava/lang/String;", &[]) + .map_err(|e| { + Error::runtime_error(format!( + "Failed to call namespaceId on Java namespace: {}", + e + )) + })?; + + let jstring = result.l().map_err(|e| { + Error::runtime_error(format!("namespaceId did not return an object: {}", e)) + })?; + + if jstring.is_null() { + return Err(Error::runtime_error( + "namespaceId returned null".to_string(), + )); + } + + let jstring_ref = JString::from(jstring); + let java_string = env.get_string(&jstring_ref).map_err(|e| { + Error::runtime_error(format!( + "Failed to convert namespaceId to Rust string: {}", + e + )) + })?; + + Ok(java_string.into()) + } +} + +impl JavaLanceNamespace { + /// Helper to deserialize JSON to Java object using ObjectMapper. + fn deserialize_request<'a>( + env: &mut JNIEnv<'a>, + json: &str, + request_class: &str, + ) -> lance_core::Result> { + let jrequest_json = env.new_string(json).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to create request JSON string: {}", + e + )))) + })?; + + // Create ObjectMapper + let object_mapper_class = env + .find_class("com/fasterxml/jackson/databind/ObjectMapper") + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to find ObjectMapper class: {}", + e + )))) + })?; + + let object_mapper = env + .new_object(&object_mapper_class, "()V", &[]) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to create ObjectMapper: {}", + e + )))) + })?; + + // Get request class + let request_class_obj = env.find_class(request_class).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to find request class {}: {}", + request_class, e + )))) + })?; + + // Call objectMapper.readValue(json, class) + env.call_method( + &object_mapper, + "readValue", + "(Ljava/lang/String;Ljava/lang/Class;)Ljava/lang/Object;", + &[ + JValue::Object(&jrequest_json), + JValue::Object(&request_class_obj), + ], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to deserialize request via ObjectMapper: {}", + e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "ObjectMapper.readValue did not return an object: {}", + e + )))) + }) + } + + /// Helper to serialize Java object to JSON using ObjectMapper. + fn serialize_response(env: &mut JNIEnv, response_obj: &JObject) -> lance_core::Result { + // Create ObjectMapper + let object_mapper_class = env + .find_class("com/fasterxml/jackson/databind/ObjectMapper") + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to find ObjectMapper class: {}", + e + )))) + })?; + + let object_mapper = env + .new_object(&object_mapper_class, "()V", &[]) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to create ObjectMapper: {}", + e + )))) + })?; + + // Call objectMapper.writeValueAsString(obj) + let response_json_obj = env + .call_method( + &object_mapper, + "writeValueAsString", + "(Ljava/lang/Object;)Ljava/lang/String;", + &[JValue::Object(response_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to serialize response via ObjectMapper: {}", + e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "ObjectMapper.writeValueAsString did not return a string: {}", + e + )))) + })?; + + let response_str: String = env + .get_string(&JString::from(response_json_obj)) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to convert response JSON to string: {}", + e + )))) + })? + .into(); + + Ok(response_str) + } + + /// Helper to call a Java method that takes a request object and returns a response object. + /// JSON conversion is done via Jackson ObjectMapper. + async fn call_json_method( + &self, + method_name: &'static str, + request_class: &str, + response_class: &str, + request: Req, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + Resp: serde::de::DeserializeOwned + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + let response_class = response_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call the interface method with request object + let method_sig = format!("(L{};)L{};", request_class, response_class); + let response_obj = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if response_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + // Serialize Java response to JSON via ObjectMapper + let response_str = Self::serialize_response(&mut env, &response_obj)?; + + serde_json::from_str(&response_str).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to deserialize response: {}", + e + )))) + }) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for void methods (return ()). + async fn call_void_method( + &self, + method_name: &'static str, + request_class: &str, + request: Req, + ) -> lance_core::Result<()> + where + Req: serde::Serialize + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call the interface method with request object + let method_sig = format!("(L{};)V", request_class); + env.call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })?; + + Ok(()) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for methods returning a string directly. + async fn call_string_method( + &self, + method_name: &'static str, + request_class: &str, + request: Req, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call the interface method with request object + let method_sig = format!("(L{};)Ljava/lang/String;", request_class); + let result = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })?; + + let response_obj = result.l().map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if response_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + let response_str: String = env + .get_string(&JString::from(response_obj)) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to convert response to string: {}", + e + )))) + })? + .into(); + + Ok(response_str) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for methods returning Long (boxed). + async fn call_long_method( + &self, + method_name: &'static str, + request_class: &str, + request: Req, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call the interface method with request object - returns Long (boxed) + let method_sig = format!("(L{};)Ljava/lang/Long;", request_class); + let result = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })?; + + let long_obj = result.l().map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if long_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + // Unbox Long to long + let long_value = env + .call_method(&long_obj, "longValue", "()J", &[]) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call longValue: {}", + e + )))) + })? + .j() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "longValue did not return a long: {}", + e + )))) + })?; + + Ok(long_value) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for methods with Bytes parameter (request + byte[] data). + async fn call_with_bytes_method( + &self, + method_name: &'static str, + request_class: &str, + response_class: &str, + request: Req, + data: Bytes, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + Resp: serde::de::DeserializeOwned + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + let response_class = response_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + let jdata = env.byte_array_from_slice(&data).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to create byte array: {}", + e + )))) + })?; + + // Call the interface method with request object and byte array + let method_sig = format!("(L{};[B)L{};", request_class, response_class); + let response_obj = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj), JValue::Object(&jdata)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if response_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + // Serialize Java response to JSON via ObjectMapper + let response_str = Self::serialize_response(&mut env, &response_obj)?; + + serde_json::from_str(&response_str).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to deserialize response: {}", + e + )))) + }) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for methods returning Bytes (byte[]). + async fn call_bytes_method( + &self, + method_name: &'static str, + request_class: &str, + request: Req, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call the interface method with request object - returns byte[] + let method_sig = format!("(L{};)[B", request_class); + let result = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[JValue::Object(&request_obj)], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })?; + + let response_obj = result.l().map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if response_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + let byte_array = JByteArray::from(response_obj); + let bytes = env.convert_byte_array(byte_array).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to convert byte array: {}", + e + )))) + })?; + + Ok(Bytes::from(bytes)) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } + + /// Helper for methods with request + extra String parameter (e.g., indexName). + /// Extracts the extra string via getter_method on the request object. + async fn call_json_method_with_extra_string( + &self, + method_name: &'static str, + request_class: &str, + response_class: &str, + getter_method: &'static str, + request: Req, + ) -> lance_core::Result + where + Req: serde::Serialize + Send + 'static, + Resp: serde::de::DeserializeOwned + Send + 'static, + { + let java_namespace = self.java_namespace.clone(); + let jvm = self.jvm.clone(); + let request_class = request_class.to_string(); + let response_class = response_class.to_string(); + + tokio::task::spawn_blocking(move || { + 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 + )))) + })?; + + // Serialize request to JSON + 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 + )))) + })?; + + // Deserialize JSON to Java request object via ObjectMapper + let request_obj = Self::deserialize_request(&mut env, &request_json, &request_class)?; + + // Call getter method to extract extra string (e.g., getIndexName) + let extra_string_obj = env + .call_method(&request_obj, getter_method, "()Ljava/lang/String;", &[]) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + getter_method, e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + getter_method, e + )))) + })?; + + // Call the interface method with request object and extra string + let method_sig = format!( + "(L{};Ljava/lang/String;)L{};", + request_class, response_class + ); + let response_obj = env + .call_method( + &java_namespace, + method_name, + &method_sig, + &[ + JValue::Object(&request_obj), + JValue::Object(&extra_string_obj), + ], + ) + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to call {}: {}", + method_name, e + )))) + })? + .l() + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "{} did not return an object: {}", + method_name, e + )))) + })?; + + if response_obj.is_null() { + return Err(lance_core::Error::io_source(Box::new( + std::io::Error::other(format!("{} returned null", method_name)), + ))); + } + + // Serialize Java response to JSON via ObjectMapper + let response_str = Self::serialize_response(&mut env, &response_obj)?; + + serde_json::from_str(&response_str).map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to deserialize response: {}", + e + )))) + }) + }) + .await + .map_err(|e| { + lance_core::Error::io_source(Box::new(std::io::Error::other(format!( + "Failed to spawn blocking task: {}", + e + )))) + })? + } +} + +const MODEL_PKG: &str = "org/lance/namespace/model"; + +#[async_trait] +impl LanceNamespaceTrait for JavaLanceNamespace { + fn namespace_id(&self) -> String { + self.namespace_id.clone() + } + + async fn list_namespaces( + &self, + request: ListNamespacesRequest, + ) -> lance_core::Result { + self.call_json_method( + "listNamespaces", + &format!("{}/ListNamespacesRequest", MODEL_PKG), + &format!("{}/ListNamespacesResponse", MODEL_PKG), + request, + ) + .await + } + + async fn describe_namespace( + &self, + request: DescribeNamespaceRequest, + ) -> lance_core::Result { + self.call_json_method( + "describeNamespace", + &format!("{}/DescribeNamespaceRequest", MODEL_PKG), + &format!("{}/DescribeNamespaceResponse", MODEL_PKG), + request, + ) + .await + } + + async fn create_namespace( + &self, + request: CreateNamespaceRequest, + ) -> lance_core::Result { + self.call_json_method( + "createNamespace", + &format!("{}/CreateNamespaceRequest", MODEL_PKG), + &format!("{}/CreateNamespaceResponse", MODEL_PKG), + request, + ) + .await + } + + async fn drop_namespace( + &self, + request: DropNamespaceRequest, + ) -> lance_core::Result { + self.call_json_method( + "dropNamespace", + &format!("{}/DropNamespaceRequest", MODEL_PKG), + &format!("{}/DropNamespaceResponse", MODEL_PKG), + request, + ) + .await + } + + async fn namespace_exists(&self, request: NamespaceExistsRequest) -> lance_core::Result<()> { + self.call_void_method( + "namespaceExists", + &format!("{}/NamespaceExistsRequest", MODEL_PKG), + request, + ) + .await + } + + async fn list_tables( + &self, + request: ListTablesRequest, + ) -> lance_core::Result { + self.call_json_method( + "listTables", + &format!("{}/ListTablesRequest", MODEL_PKG), + &format!("{}/ListTablesResponse", MODEL_PKG), + request, + ) + .await + } + + async fn describe_table( + &self, + request: DescribeTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "describeTable", + &format!("{}/DescribeTableRequest", MODEL_PKG), + &format!("{}/DescribeTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn register_table( + &self, + request: RegisterTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "registerTable", + &format!("{}/RegisterTableRequest", MODEL_PKG), + &format!("{}/RegisterTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn table_exists(&self, request: TableExistsRequest) -> lance_core::Result<()> { + self.call_void_method( + "tableExists", + &format!("{}/TableExistsRequest", MODEL_PKG), + request, + ) + .await + } + + async fn drop_table(&self, request: DropTableRequest) -> lance_core::Result { + self.call_json_method( + "dropTable", + &format!("{}/DropTableRequest", MODEL_PKG), + &format!("{}/DropTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn deregister_table( + &self, + request: DeregisterTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "deregisterTable", + &format!("{}/DeregisterTableRequest", MODEL_PKG), + &format!("{}/DeregisterTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn count_table_rows(&self, request: CountTableRowsRequest) -> lance_core::Result { + self.call_long_method( + "countTableRows", + &format!("{}/CountTableRowsRequest", MODEL_PKG), + request, + ) + .await + } + + async fn create_table( + &self, + request: CreateTableRequest, + data: Bytes, + ) -> lance_core::Result { + self.call_with_bytes_method( + "createTable", + &format!("{}/CreateTableRequest", MODEL_PKG), + &format!("{}/CreateTableResponse", MODEL_PKG), + request, + data, + ) + .await + } + + async fn declare_table( + &self, + request: DeclareTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "declareTable", + &format!("{}/DeclareTableRequest", MODEL_PKG), + &format!("{}/DeclareTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn insert_into_table( + &self, + request: InsertIntoTableRequest, + data: Bytes, + ) -> lance_core::Result { + self.call_with_bytes_method( + "insertIntoTable", + &format!("{}/InsertIntoTableRequest", MODEL_PKG), + &format!("{}/InsertIntoTableResponse", MODEL_PKG), + request, + data, + ) + .await + } + + async fn merge_insert_into_table( + &self, + request: MergeInsertIntoTableRequest, + data: Bytes, + ) -> lance_core::Result { + self.call_with_bytes_method( + "mergeInsertIntoTable", + &format!("{}/MergeInsertIntoTableRequest", MODEL_PKG), + &format!("{}/MergeInsertIntoTableResponse", MODEL_PKG), + request, + data, + ) + .await + } + + async fn update_table( + &self, + request: UpdateTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "updateTable", + &format!("{}/UpdateTableRequest", MODEL_PKG), + &format!("{}/UpdateTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn delete_from_table( + &self, + request: DeleteFromTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "deleteFromTable", + &format!("{}/DeleteFromTableRequest", MODEL_PKG), + &format!("{}/DeleteFromTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn query_table(&self, request: QueryTableRequest) -> lance_core::Result { + self.call_bytes_method( + "queryTable", + &format!("{}/QueryTableRequest", MODEL_PKG), + request, + ) + .await + } + + async fn create_table_index( + &self, + request: CreateTableIndexRequest, + ) -> lance_core::Result { + self.call_json_method( + "createTableIndex", + &format!("{}/CreateTableIndexRequest", MODEL_PKG), + &format!("{}/CreateTableIndexResponse", MODEL_PKG), + request, + ) + .await + } + + async fn list_table_indices( + &self, + request: ListTableIndicesRequest, + ) -> lance_core::Result { + self.call_json_method( + "listTableIndices", + &format!("{}/ListTableIndicesRequest", MODEL_PKG), + &format!("{}/ListTableIndicesResponse", MODEL_PKG), + request, + ) + .await + } + + async fn describe_table_index_stats( + &self, + request: DescribeTableIndexStatsRequest, + ) -> lance_core::Result { + self.call_json_method_with_extra_string( + "describeTableIndexStats", + &format!("{}/DescribeTableIndexStatsRequest", MODEL_PKG), + &format!("{}/DescribeTableIndexStatsResponse", MODEL_PKG), + "getIndexName", + request, + ) + .await + } + + async fn describe_transaction( + &self, + request: DescribeTransactionRequest, + ) -> lance_core::Result { + self.call_json_method( + "describeTransaction", + &format!("{}/DescribeTransactionRequest", MODEL_PKG), + &format!("{}/DescribeTransactionResponse", MODEL_PKG), + request, + ) + .await + } + + async fn alter_transaction( + &self, + request: AlterTransactionRequest, + ) -> lance_core::Result { + self.call_json_method( + "alterTransaction", + &format!("{}/AlterTransactionRequest", MODEL_PKG), + &format!("{}/AlterTransactionResponse", MODEL_PKG), + request, + ) + .await + } + + async fn create_table_scalar_index( + &self, + request: CreateTableIndexRequest, + ) -> lance_core::Result { + self.call_json_method( + "createTableScalarIndex", + &format!("{}/CreateTableIndexRequest", MODEL_PKG), + &format!("{}/CreateTableScalarIndexResponse", MODEL_PKG), + request, + ) + .await + } + + async fn drop_table_index( + &self, + request: DropTableIndexRequest, + ) -> lance_core::Result { + self.call_json_method_with_extra_string( + "dropTableIndex", + &format!("{}/DropTableIndexRequest", MODEL_PKG), + &format!("{}/DropTableIndexResponse", MODEL_PKG), + "getIndexName", + request, + ) + .await + } + + async fn list_all_tables( + &self, + request: ListTablesRequest, + ) -> lance_core::Result { + self.call_json_method( + "listAllTables", + &format!("{}/ListTablesRequest", MODEL_PKG), + &format!("{}/ListTablesResponse", MODEL_PKG), + request, + ) + .await + } + + async fn restore_table( + &self, + request: RestoreTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "restoreTable", + &format!("{}/RestoreTableRequest", MODEL_PKG), + &format!("{}/RestoreTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn rename_table( + &self, + request: RenameTableRequest, + ) -> lance_core::Result { + self.call_json_method( + "renameTable", + &format!("{}/RenameTableRequest", MODEL_PKG), + &format!("{}/RenameTableResponse", MODEL_PKG), + request, + ) + .await + } + + async fn list_table_versions( + &self, + request: ListTableVersionsRequest, + ) -> lance_core::Result { + self.call_json_method( + "listTableVersions", + &format!("{}/ListTableVersionsRequest", MODEL_PKG), + &format!("{}/ListTableVersionsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn create_table_version( + &self, + request: CreateTableVersionRequest, + ) -> lance_core::Result { + self.call_json_method( + "createTableVersion", + &format!("{}/CreateTableVersionRequest", MODEL_PKG), + &format!("{}/CreateTableVersionResponse", MODEL_PKG), + request, + ) + .await + } + + async fn describe_table_version( + &self, + request: DescribeTableVersionRequest, + ) -> lance_core::Result { + self.call_json_method( + "describeTableVersion", + &format!("{}/DescribeTableVersionRequest", MODEL_PKG), + &format!("{}/DescribeTableVersionResponse", MODEL_PKG), + request, + ) + .await + } + + async fn batch_delete_table_versions( + &self, + request: BatchDeleteTableVersionsRequest, + ) -> lance_core::Result { + self.call_json_method( + "batchDeleteTableVersions", + &format!("{}/BatchDeleteTableVersionsRequest", MODEL_PKG), + &format!("{}/BatchDeleteTableVersionsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn update_table_schema_metadata( + &self, + request: UpdateTableSchemaMetadataRequest, + ) -> lance_core::Result { + self.call_json_method( + "updateTableSchemaMetadata", + &format!("{}/UpdateTableSchemaMetadataRequest", MODEL_PKG), + &format!("{}/UpdateTableSchemaMetadataResponse", MODEL_PKG), + request, + ) + .await + } + + async fn get_table_stats( + &self, + request: GetTableStatsRequest, + ) -> lance_core::Result { + self.call_json_method( + "getTableStats", + &format!("{}/GetTableStatsRequest", MODEL_PKG), + &format!("{}/GetTableStatsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn explain_table_query_plan( + &self, + request: ExplainTableQueryPlanRequest, + ) -> lance_core::Result { + self.call_string_method( + "explainTableQueryPlan", + &format!("{}/ExplainTableQueryPlanRequest", MODEL_PKG), + request, + ) + .await + } + + async fn analyze_table_query_plan( + &self, + request: AnalyzeTableQueryPlanRequest, + ) -> lance_core::Result { + self.call_string_method( + "analyzeTableQueryPlan", + &format!("{}/AnalyzeTableQueryPlanRequest", MODEL_PKG), + request, + ) + .await + } + + async fn alter_table_add_columns( + &self, + request: AlterTableAddColumnsRequest, + ) -> lance_core::Result { + self.call_json_method( + "alterTableAddColumns", + &format!("{}/AlterTableAddColumnsRequest", MODEL_PKG), + &format!("{}/AlterTableAddColumnsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn alter_table_alter_columns( + &self, + request: AlterTableAlterColumnsRequest, + ) -> lance_core::Result { + self.call_json_method( + "alterTableAlterColumns", + &format!("{}/AlterTableAlterColumnsRequest", MODEL_PKG), + &format!("{}/AlterTableAlterColumnsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn alter_table_drop_columns( + &self, + request: AlterTableDropColumnsRequest, + ) -> lance_core::Result { + self.call_json_method( + "alterTableDropColumns", + &format!("{}/AlterTableDropColumnsRequest", MODEL_PKG), + &format!("{}/AlterTableDropColumnsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn list_table_tags( + &self, + request: ListTableTagsRequest, + ) -> lance_core::Result { + self.call_json_method( + "listTableTags", + &format!("{}/ListTableTagsRequest", MODEL_PKG), + &format!("{}/ListTableTagsResponse", MODEL_PKG), + request, + ) + .await + } + + async fn get_table_tag_version( + &self, + request: GetTableTagVersionRequest, + ) -> lance_core::Result { + self.call_json_method( + "getTableTagVersion", + &format!("{}/GetTableTagVersionRequest", MODEL_PKG), + &format!("{}/GetTableTagVersionResponse", MODEL_PKG), + request, + ) + .await + } + + async fn create_table_tag( + &self, + request: CreateTableTagRequest, + ) -> lance_core::Result { + self.call_json_method( + "createTableTag", + &format!("{}/CreateTableTagRequest", MODEL_PKG), + &format!("{}/CreateTableTagResponse", MODEL_PKG), + request, + ) + .await + } + + async fn delete_table_tag( + &self, + request: DeleteTableTagRequest, + ) -> lance_core::Result { + self.call_json_method( + "deleteTableTag", + &format!("{}/DeleteTableTagRequest", MODEL_PKG), + &format!("{}/DeleteTableTagResponse", MODEL_PKG), + request, + ) + .await + } + + async fn update_table_tag( + &self, + request: UpdateTableTagRequest, + ) -> lance_core::Result { + self.call_json_method( + "updateTableTag", + &format!("{}/UpdateTableTagRequest", MODEL_PKG), + &format!("{}/UpdateTableTagResponse", MODEL_PKG), + request, + ) + .await + } +} + +/// Create a JavaLanceNamespace wrapper from a JNI environment and Java object. +pub fn create_java_lance_namespace( + env: &mut JNIEnv, + java_namespace: &JObject, +) -> Result> { + let wrapper = JavaLanceNamespace::new(env, java_namespace)?; + Ok(Arc::new(wrapper)) } // ============================================================================ // DirectoryNamespace JNI Functions // ============================================================================ -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNative( mut env: JNIEnv, _obj: JObject, @@ -143,7 +1545,7 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNative( ) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNativeWithProvider( mut env: JNIEnv, _obj: JObject, @@ -173,23 +1575,24 @@ fn create_directory_namespace_internal( })?; // Add context provider if provided - if let Some(provider_obj) = context_provider { - if !provider_obj.is_null() { - let java_provider = JavaDynamicContextProvider::new(env, &provider_obj)?; - builder = builder.context_provider(Arc::new(java_provider)); - } + if let Some(provider_obj) = context_provider + && !provider_obj.is_null() + { + let java_provider = JavaDynamicContextProvider::new(env, &provider_obj)?; + builder = builder.context_provider(Arc::new(java_provider)); } - let namespace = RT - .block_on(builder.build()) + let namespace = block_on(builder.build()) .map_err(|e| Error::runtime_error(format!("Failed to build DirectoryNamespace: {}", e)))?; - let blocking_namespace = BlockingDirectoryNamespace { inner: namespace }; + let blocking_namespace = BlockingDirectoryNamespace { + inner: Arc::new(namespace), + }; let handle = Box::into_raw(Box::new(blocking_namespace)) as jlong; Ok(handle) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_releaseNative( _env: JNIEnv, _obj: JObject, @@ -202,13 +1605,964 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_releaseNative } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_namespaceIdNative( mut env: JNIEnv, _obj: JObject, handle: jlong, ) -> jstring { - let namespace = unsafe { &*(handle as *const BlockingDirectoryNamespace) }; + let namespace = unsafe { &*(handle as *const BlockingDirectoryNamespace) }; + let namespace_id = namespace.inner.namespace_id(); + ok_or_throw_with_return!( + env, + env.new_string(namespace_id).map_err(Error::from), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listNamespacesNative( + 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| { + block_on(namespace_client.inner.list_namespaces(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeNamespaceNative( + 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| { + block_on(namespace_client.inner.describe_namespace(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNamespaceNative( + 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| { + block_on(namespace_client.inner.create_namespace(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropNamespaceNative( + 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| { + block_on(namespace_client.inner.drop_namespace(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_namespaceExistsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) { + ok_or_throw_without_return!( + env, + call_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.namespace_exists(req)) + }) + ) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTablesNative( + 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| { + block_on(namespace_client.inner.list_tables(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTableNative( + 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| { + block_on(namespace_client.inner.describe_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_registerTableNative( + 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| { + block_on(namespace_client.inner.register_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_tableExistsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) { + ok_or_throw_without_return!( + env, + call_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.table_exists(req)) + }) + ) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropTableNative( + 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| { + block_on(namespace_client.inner.drop_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deregisterTableNative( + 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| { + block_on(namespace_client.inner.deregister_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_countTableRowsNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jlong { + ok_or_throw_with_return!( + env, + call_namespace_count_method(&mut env, handle, request_json), + 0 + ) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, + request_data: JByteArray, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_with_data_method( + &mut env, + handle, + request_json, + request_data, + |namespace_client, req, data| { + block_on(namespace_client.inner.create_table(req, data)) + } + ), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_declareTableNative( + 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| { + block_on(namespace_client.inner.declare_table(req)) + }), + std::ptr::null_mut() + ) + .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| { + 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, + _obj: JObject, + handle: jlong, + request_json: JString, + request_data: JByteArray, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_with_data_method( + &mut env, + handle, + request_json, + request_data, + |namespace_client, req, data| { + block_on(namespace_client.inner.insert_into_table(req, data)) + } + ), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIntoTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, + request_data: JByteArray, +) -> jstring { + ok_or_throw_with_return!( + env, + call_namespace_with_data_method( + &mut env, + handle, + request_json, + request_data, + |namespace_client, req: serde_json::Value, data| { + let req = merge_insert_request_from_json(req)?; + block_on(namespace_client.inner.merge_insert_into_table(req, data)) + } + ), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_updateTableNative( + 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| { + block_on(namespace_client.inner.update_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deleteFromTableNative( + 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| { + block_on(namespace_client.inner.delete_from_table(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_queryTableNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, + request_json: JString, +) -> jbyteArray { + ok_or_throw_with_return!( + env, + call_namespace_query_method(&mut env, handle, request_json), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableIndexNative( + 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| { + block_on(namespace_client.inner.create_table_index(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableIndicesNative( + 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| { + block_on(namespace_client.inner.list_table_indices(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTableIndexStatsNative( + 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| { + block_on(namespace_client.inner.describe_table_index_stats(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTransactionNative( + 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| { + block_on(namespace_client.inner.describe_transaction(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTransactionNative( + 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| { + block_on(namespace_client.inner.alter_transaction(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableVersionsNative( + 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| { + block_on(namespace_client.inner.list_table_versions(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableVersionNative( + 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| { + block_on(namespace_client.inner.create_table_version(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTableVersionNative( + 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| { + block_on(namespace_client.inner.describe_table_version(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_batchDeleteTableVersionsNative( + 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| { + 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_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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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| { + 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_alterTableBackfillColumnsNative( + 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| { + block_on(namespace_client.inner.alter_table_backfill_columns(req)) + }), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_refreshMaterializedViewNative( + 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| { + block_on(namespace_client.inner.refresh_materialized_view(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| { + 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| { + 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| { + 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| { + 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| { + 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_createMaterializedViewNative( + 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| { + block_on(namespace_client.inner.create_materialized_view(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 +// ============================================================================ + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNative( + mut env: JNIEnv, + _obj: JObject, + properties_map: JObject, +) -> jlong { + ok_or_throw_with_return!( + env, + create_rest_namespace_internal(&mut env, properties_map, None), + 0 + ) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNativeWithProvider( + mut env: JNIEnv, + _obj: JObject, + properties_map: JObject, + context_provider: JObject, +) -> jlong { + ok_or_throw_with_return!( + env, + create_rest_namespace_internal(&mut env, properties_map, Some(context_provider)), + 0 + ) +} + +fn create_rest_namespace_internal( + env: &mut JNIEnv, + properties_map: JObject, + context_provider: Option, +) -> Result { + // Convert Java HashMap to Rust HashMap + let jmap = JMap::from_env(env, &properties_map)?; + let properties = to_rust_map(env, &jmap)?; + + // Build RestNamespace using builder + let mut builder = RestNamespaceBuilder::from_properties(properties).map_err(|e| { + Error::runtime_error(format!("Failed to create RestNamespaceBuilder: {}", e)) + })?; + + // Add context provider if provided + if let Some(provider_obj) = context_provider + && !provider_obj.is_null() + { + let java_provider = JavaDynamicContextProvider::new(env, &provider_obj)?; + builder = builder.context_provider(Arc::new(java_provider)); + } + + let namespace = builder.build(); + + let blocking_namespace = BlockingRestNamespace { + inner: Arc::new(namespace), + }; + let handle = Box::into_raw(Box::new(blocking_namespace)) as jlong; + Ok(handle) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_releaseNative( + _env: JNIEnv, + _obj: JObject, + handle: jlong, +) { + if handle != 0 { + unsafe { + let _ = Box::from_raw(handle as *mut BlockingRestNamespace); + } + } +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_namespaceIdNative( + mut env: JNIEnv, + _obj: JObject, + handle: jlong, +) -> jstring { + let namespace = unsafe { &*(handle as *const BlockingRestNamespace) }; let namespace_id = namespace.inner.namespace_id(); ok_or_throw_with_return!( env, @@ -218,8 +2572,8 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_namespaceIdNa .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listNamespacesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listNamespacesNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -227,16 +2581,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.list_namespaces(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeNamespaceNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -244,16 +2598,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.describe_namespace(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNamespaceNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -261,16 +2615,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.create_namespace(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropNamespaceNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -278,16 +2632,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.drop_namespace(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_namespaceExistsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_namespaceExistsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -295,14 +2649,14 @@ 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_rest_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.namespace_exists(req)) }) ) } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTablesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTablesNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -310,16 +2664,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.list_tables(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -327,16 +2681,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.describe_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_registerTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_registerTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -344,16 +2698,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.register_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_tableExistsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_tableExistsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -361,14 +2715,14 @@ 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_rest_namespace_void_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.table_exists(req)) }) ) } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_dropTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -376,16 +2730,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.drop_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deregisterTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_deregisterTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -393,16 +2747,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.deregister_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_countTableRowsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_countTableRowsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -410,13 +2764,13 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_countTableRow ) -> jlong { ok_or_throw_with_return!( env, - call_namespace_count_method(&mut env, handle, request_json), + call_rest_namespace_count_method(&mut env, handle, request_json), 0 ) } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -425,21 +2779,22 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableNa ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_with_data_method( + call_rest_namespace_with_data_method( &mut env, handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.create_table(req, data)) } + |namespace_client, req, data| { + block_on(namespace_client.inner.create_table(req, data)) + } ), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -#[allow(deprecated)] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createEmptyTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_declareTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -447,16 +2802,16 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createEmptyTa ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_empty_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.declare_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_declareTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_renameTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -464,16 +2819,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.rename_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_insertIntoTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_insertIntoTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -482,20 +2837,22 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_insertIntoTab ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_with_data_method( + call_rest_namespace_with_data_method( &mut env, handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.insert_into_table(req, data)) } + |namespace_client, req, data| { + block_on(namespace_client.inner.insert_into_table(req, data)) + } ), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIntoTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_mergeInsertIntoTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -504,20 +2861,23 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_mergeInsertIn ) -> jstring { ok_or_throw_with_return!( env, - call_namespace_with_data_method( + call_rest_namespace_with_data_method( &mut env, handle, request_json, request_data, - |ns, req, data| { RT.block_on(ns.inner.merge_insert_into_table(req, data)) } + |namespace_client, req: serde_json::Value, data| { + let req = merge_insert_request_from_json(req)?; + block_on(namespace_client.inner.merge_insert_into_table(req, data)) + } ), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_updateTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -525,16 +2885,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.update_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_deleteFromTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_deleteFromTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -542,16 +2902,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.delete_from_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_queryTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_queryTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -559,14 +2919,14 @@ pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_queryTableNat ) -> jbyteArray { ok_or_throw_with_return!( env, - call_namespace_query_method(&mut env, handle, request_json), + call_rest_namespace_query_method(&mut env, handle, request_json), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_createTableIndexNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableIndexNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -574,16 +2934,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.create_table_index(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_listTableIndicesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableIndicesNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -591,16 +2951,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.list_table_indices(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTableIndexStatsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableIndexStatsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -608,16 +2968,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.describe_table_index_stats(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_describeTransactionNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTransactionNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -625,16 +2985,16 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.describe_transaction(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_DirectoryNamespace_alterTransactionNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTransactionNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -642,105 +3002,33 @@ 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_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.alter_transaction(req)) }), std::ptr::null_mut() ) .into_raw() } -// ============================================================================ -// RestNamespace JNI Functions -// ============================================================================ - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNative( - mut env: JNIEnv, - _obj: JObject, - properties_map: JObject, -) -> jlong { - ok_or_throw_with_return!( - env, - create_rest_namespace_internal(&mut env, properties_map, None), - 0 - ) -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNativeWithProvider( - mut env: JNIEnv, - _obj: JObject, - properties_map: JObject, - context_provider: JObject, -) -> jlong { - ok_or_throw_with_return!( - env, - create_rest_namespace_internal(&mut env, properties_map, Some(context_provider)), - 0 - ) -} - -fn create_rest_namespace_internal( - env: &mut JNIEnv, - properties_map: JObject, - context_provider: Option, -) -> Result { - // Convert Java HashMap to Rust HashMap - let jmap = JMap::from_env(env, &properties_map)?; - let properties = to_rust_map(env, &jmap)?; - - // Build RestNamespace using builder - let mut builder = RestNamespaceBuilder::from_properties(properties).map_err(|e| { - Error::runtime_error(format!("Failed to create RestNamespaceBuilder: {}", e)) - })?; - - // Add context provider if provided - if let Some(provider_obj) = context_provider { - if !provider_obj.is_null() { - let java_provider = JavaDynamicContextProvider::new(env, &provider_obj)?; - builder = builder.context_provider(Arc::new(java_provider)); - } - } - - let namespace = builder.build(); - - let blocking_namespace = BlockingRestNamespace { inner: namespace }; - let handle = Box::into_raw(Box::new(blocking_namespace)) as jlong; - Ok(handle) -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_releaseNative( - _env: JNIEnv, - _obj: JObject, - handle: jlong, -) { - if handle != 0 { - unsafe { - let _ = Box::from_raw(handle as *mut BlockingRestNamespace); - } - } -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_namespaceIdNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableVersionsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, + request_json: JString, ) -> jstring { - let namespace = unsafe { &*(handle as *const BlockingRestNamespace) }; - let namespace_id = namespace.inner.namespace_id(); ok_or_throw_with_return!( env, - env.new_string(namespace_id).map_err(Error::from), + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.list_table_versions(req)) + }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_listNamespacesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableVersionNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -748,16 +3036,16 @@ 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| { + block_on(namespace_client.inner.create_table_version(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableVersionNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -765,16 +3053,16 @@ 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| { + block_on(namespace_client.inner.describe_table_version(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_batchDeleteTableVersionsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -782,16 +3070,16 @@ 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| { + block_on(namespace_client.inner.batch_delete_table_versions(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropNamespaceNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableScalarIndexNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -799,31 +3087,16 @@ 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| { + block_on(namespace_client.inner.create_table_scalar_index(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_namespaceExistsNative( - mut env: JNIEnv, - _obj: JObject, - handle: jlong, - request_json: JString, -) { - 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)) - }) - ) -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTablesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropTableIndexNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -831,16 +3104,16 @@ 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| { + block_on(namespace_client.inner.drop_table_index(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listAllTablesNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -848,16 +3121,16 @@ 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| { + block_on(namespace_client.inner.list_all_tables(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_registerTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_restoreTableNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -865,31 +3138,16 @@ 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| { + block_on(namespace_client.inner.restore_table(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_tableExistsNative( - mut env: JNIEnv, - _obj: JObject, - handle: jlong, - request_json: JString, -) { - 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)) - }) - ) -} - -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_dropTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableSchemaMetadataNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -897,16 +3155,16 @@ 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| { + block_on(namespace_client.inner.update_table_schema_metadata(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_deregisterTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_getTableStatsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -914,53 +3172,60 @@ 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| { + block_on(namespace_client.inner.get_table_stats(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_countTableRowsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_explainTableQueryPlanNative( mut env: JNIEnv, _obj: JObject, handle: jlong, request_json: JString, -) -> jlong { +) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_count_method(&mut env, handle, request_json), - 0 + call_rest_namespace_string_method( + &mut env, + handle, + request_json, + |namespace_client, req| { + block_on(namespace_client.inner.explain_table_query_plan(req)) + } + ), + std::ptr::null_mut() ) + .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_analyzeTableQueryPlanNative( mut env: JNIEnv, _obj: JObject, handle: jlong, request_json: JString, - request_data: JByteArray, ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_with_data_method( + call_rest_namespace_string_method( &mut env, handle, request_json, - request_data, - |ns, req, data| { RT.block_on(ns.inner.create_table(req, data)) } + |namespace_client, req| { + block_on(namespace_client.inner.analyze_table_query_plan(req)) + } ), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -#[allow(deprecated)] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createEmptyTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableAddColumnsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -968,16 +3233,16 @@ pub extern "system" fn Java_org_lance_namespace_RestNamespace_createEmptyTableNa ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_method(&mut env, handle, request_json, |ns, req| { - RT.block_on(ns.inner.create_empty_table(req)) + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.alter_table_add_columns(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_declareTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableAlterColumnsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -985,60 +3250,50 @@ 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| { + block_on(namespace_client.inner.alter_table_alter_columns(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_insertIntoTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableDropColumnsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, request_json: JString, - request_data: JByteArray, ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_with_data_method( - &mut env, - handle, - request_json, - request_data, - |ns, req, data| { RT.block_on(ns.inner.insert_into_table(req, data)) } - ), + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.alter_table_drop_columns(req)) + }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_mergeInsertIntoTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTableBackfillColumnsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, request_json: JString, - request_data: JByteArray, ) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_with_data_method( - &mut env, - handle, - request_json, - request_data, - |ns, req, data| { RT.block_on(ns.inner.merge_insert_into_table(req, data)) } - ), + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.alter_table_backfill_columns(req)) + }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_refreshMaterializedViewNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1046,16 +3301,16 @@ 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| { + block_on(namespace_client.inner.refresh_materialized_view(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_deleteFromTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableTagsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1063,31 +3318,33 @@ 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| { + block_on(namespace_client.inner.list_table_tags(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_queryTableNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_getTableTagVersionNative( mut env: JNIEnv, _obj: JObject, handle: jlong, request_json: JString, -) -> jbyteArray { +) -> jstring { ok_or_throw_with_return!( env, - call_rest_namespace_query_method(&mut env, handle, request_json), + call_rest_namespace_method(&mut env, handle, request_json, |namespace_client, req| { + block_on(namespace_client.inner.get_table_tag_version(req)) + }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableIndexNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createTableTagNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1095,16 +3352,16 @@ 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| { + block_on(namespace_client.inner.create_table_tag(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_listTableIndicesNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_deleteTableTagNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1112,16 +3369,16 @@ 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| { + block_on(namespace_client.inner.delete_table_tag(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTableIndexStatsNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_updateTableTagNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1129,16 +3386,16 @@ 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| { + block_on(namespace_client.inner.update_table_tag(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_describeTransactionNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_createMaterializedViewNative( mut env: JNIEnv, _obj: JObject, handle: jlong, @@ -1146,31 +3403,45 @@ 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| { + block_on(namespace_client.inner.create_materialized_view(req)) }), std::ptr::null_mut() ) .into_raw() } -#[no_mangle] -pub extern "system" fn Java_org_lance_namespace_RestNamespace_alterTransactionNative( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_namespace_RestNamespace_retrieveOpsMetricsNative( mut env: JNIEnv, _obj: JObject, handle: jlong, - request_json: JString, -) -> jstring { +) -> jobject { 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)) - }), + { + 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 // ============================================================================ @@ -1192,8 +3463,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)))?; @@ -1217,12 +3487,32 @@ 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(()) } +/// 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, @@ -1234,9 +3524,7 @@ fn call_namespace_count_method( let request: CountTableRowsRequest = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let count = RT - .block_on(namespace.inner.count_table_rows(request)) - .map_err(|e| Error::runtime_error(format!("Count table rows failed: {}", e)))?; + let count = block_on(namespace.inner.count_table_rows(request)).map_err(Error::from)?; Ok(count) } @@ -1262,8 +3550,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)))?; @@ -1282,9 +3569,7 @@ fn call_namespace_query_method<'local>( let request: QueryTableRequest = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let result_bytes = RT - .block_on(namespace.inner.query_table(request)) - .map_err(|e| Error::runtime_error(format!("Query table failed: {}", e)))?; + let result_bytes = block_on(namespace.inner.query_table(request)).map_err(Error::from)?; let byte_array = env.byte_array_from_slice(&result_bytes)?; Ok(byte_array) @@ -1307,8 +3592,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)))?; @@ -1332,12 +3616,32 @@ 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(()) } +/// 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, @@ -1349,9 +3653,7 @@ fn call_rest_namespace_count_method( let request: CountTableRowsRequest = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let count = RT - .block_on(namespace.inner.count_table_rows(request)) - .map_err(|e| Error::runtime_error(format!("Count table rows failed: {}", e)))?; + let count = block_on(namespace.inner.count_table_rows(request)).map_err(Error::from)?; Ok(count) } @@ -1377,8 +3679,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)))?; @@ -1397,9 +3698,7 @@ fn call_rest_namespace_query_method<'local>( let request: QueryTableRequest = serde_json::from_str(&request_str) .map_err(|e| Error::input_error(format!("Failed to parse request JSON: {}", e)))?; - let result_bytes = RT - .block_on(namespace.inner.query_table(request)) - .map_err(|e| Error::runtime_error(format!("Query table failed: {}", e)))?; + let result_bytes = block_on(namespace.inner.query_table(request)).map_err(Error::from)?; let byte_array = env.byte_array_from_slice(&result_bytes)?; Ok(byte_array) @@ -1415,31 +3714,31 @@ pub struct BlockingRestAdapter { server_handle: Option, } -#[no_mangle] +#[unsafe(no_mangle)] 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)?; @@ -1451,8 +3750,7 @@ fn create_rest_adapter_internal( builder = builder.property(k, v); } - let backend = RT - .block_on(builder.connect()) + let backend = block_on(builder.connect()) .map_err(|e| Error::runtime_error(format!("Failed to build backend namespace: {}", e)))?; // Build config with defaults, overriding if values provided @@ -1482,7 +3780,7 @@ fn create_rest_adapter_internal( Ok(handle) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_RestAdapter_start( mut env: JNIEnv, _obj: JObject, @@ -1494,12 +3792,12 @@ pub extern "system" fn Java_org_lance_namespace_RestAdapter_start( fn start_internal(handle: jlong) -> Result<()> { let adapter = unsafe { &mut *(handle as *mut BlockingRestAdapter) }; let rest_adapter = RestAdapter::new(adapter.backend.clone(), adapter.config.clone()); - let server_handle = RT.block_on(rest_adapter.start())?; + let server_handle = block_on(rest_adapter.start())?; adapter.server_handle = Some(server_handle); Ok(()) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_RestAdapter_getPort( _env: JNIEnv, _obj: JObject, @@ -1513,7 +3811,7 @@ pub extern "system" fn Java_org_lance_namespace_RestAdapter_getPort( .unwrap_or(0) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_RestAdapter_stop( _env: JNIEnv, _obj: JObject, @@ -1526,7 +3824,7 @@ pub extern "system" fn Java_org_lance_namespace_RestAdapter_stop( } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_namespace_RestAdapter_releaseNative( _env: JNIEnv, _obj: JObject, diff --git a/java/lance-jni/src/optimize.rs b/java/lance-jni/src/optimize.rs index 11a1f986e37..ce0151d202a 100644 --- a/java/lance-jni/src/optimize.rs +++ b/java/lance-jni/src/optimize.rs @@ -1,36 +1,36 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{collections::HashMap, sync::Arc}; +use std::sync::Arc; use jni::{ - objects::{JByteArray, JMap, JObject, JValue, JValueGen}, - sys::jlong, JNIEnv, + objects::{JByteArray, JObject, JValueGen}, + sys::jlong, }; use lance::dataset::{ index::DatasetIndexRemapperOptions, optimize::{ - commit_compaction, plan_compaction, CompactionMetrics, CompactionOptions, CompactionPlan, - CompactionTask, IndexRemapperOptions, RewriteResult, TaskData, + CompactionMetrics, CompactionMode, CompactionOptions, CompactionPlan, CompactionTask, + IndexRemapperOptions, RewriteResult, TaskData, commit_compaction, plan_compaction, }, }; use crate::{ + block_on, blocking_dataset::{BlockingDataset, NATIVE_DATASET}, traits::{ - export_vec, import_vec_from_method, import_vec_to_rust, FromJObjectWithEnv, IntoJava, + FromJObjectWithEnv, IntoJava, export_vec, import_vec_from_method, import_vec_to_rust, }, utils::{ - build_compaction_options, to_java_boolean_obj, to_java_float_obj, to_java_long_obj, - to_java_optional, + build_compaction_options, to_java_boolean_obj, to_java_float_obj, to_java_list, + to_java_long_obj, to_java_optional, }, - RT, }; use crate::error::Result; -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_compaction_Compaction_nativePlanCompaction<'local>( mut env: JNIEnv<'local>, _obj: JObject, @@ -43,6 +43,13 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativePlanCompaction num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List + data_storage_version: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -56,7 +63,14 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativePlanCompaction materialize_deletions_threshold, num_threads, batch_size, - defer_index_remap + defer_index_remap, + compaction_mode, + binary_copy_read_batch_bytes, + max_source_fragments, + max_source_rows, + max_source_bytes, + excluded_fragment_ids, + data_storage_version ), JObject::null() ) @@ -74,7 +88,19 @@ fn inner_plan_compaction<'local>( num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List + data_storage_version: 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, @@ -85,18 +111,26 @@ fn inner_plan_compaction<'local>( &num_threads, &batch_size, &defer_index_remap, + &compaction_mode, + &binary_copy_read_batch_bytes, + &max_source_fragments, + &max_source_rows, + &max_source_bytes, + &excluded_fragment_ids, + &data_storage_version, + &config, )?; let plan = { let dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(plan_compaction(&dataset.inner, &compaction_options))? + block_on(plan_compaction(&dataset.inner, &compaction_options))? }; plan.into_java(env) } -#[no_mangle] -pub extern "system" fn Java_org_lance_compaction_Compaction_nativeCommitCompaction<'local>( +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_compaction_Compaction_commitCompactionNative<'local>( mut env: JNIEnv<'local>, _obj: JObject, java_dataset: JObject, // Dataset @@ -109,6 +143,12 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativeCommitCompacti num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -124,6 +164,12 @@ pub extern "system" fn Java_org_lance_compaction_Compaction_nativeCommitCompacti num_threads, batch_size, defer_index_remap, + compaction_mode, + binary_copy_read_batch_bytes, + max_source_fragments, + max_source_rows, + max_source_bytes, + excluded_fragment_ids, ), JObject::null() ) @@ -142,7 +188,18 @@ fn inner_commit_compaction<'local>( num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List ) -> 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, @@ -153,6 +210,14 @@ fn inner_commit_compaction<'local>( &num_threads, &batch_size, &defer_index_remap, + &compaction_mode, + &binary_copy_read_batch_bytes, + &max_source_fragments, + &max_source_rows, + &max_source_bytes, + &excluded_fragment_ids, + &JObject::null(), + &config, )?; let completed_tasks = import_vec_to_rust(env, &rewrite_results, |env, rewrite_result| { rewrite_result.extract_object(env) @@ -161,7 +226,7 @@ fn inner_commit_compaction<'local>( let committed_metrics = { let mut dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(commit_compaction( + block_on(commit_compaction( &mut dataset.inner, completed_tasks, remap_options, @@ -171,7 +236,7 @@ fn inner_commit_compaction<'local>( committed_metrics.into_java(env) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_compaction_CompactionTask_nativeExecute<'local>( mut env: JNIEnv<'local>, _obj: JObject, // CompactionTask itself @@ -186,6 +251,13 @@ pub extern "system" fn Java_org_lance_compaction_CompactionTask_nativeExecute<'l num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List + data_storage_version: JObject, // Optional ) -> JObject<'local> { ok_or_throw_with_return!( env, @@ -201,7 +273,14 @@ pub extern "system" fn Java_org_lance_compaction_CompactionTask_nativeExecute<'l materialize_deletions_threshold, num_threads, batch_size, - defer_index_remap + defer_index_remap, + compaction_mode, + binary_copy_read_batch_bytes, + max_source_fragments, + max_source_rows, + max_source_bytes, + excluded_fragment_ids, + data_storage_version ), JObject::null() ) @@ -221,8 +300,20 @@ fn inner_execute_task<'local>( num_threads: JObject, // Optional batch_size: JObject, // Optional defer_index_remap: JObject, // Optional + compaction_mode: JObject, // Optional + binary_copy_read_batch_bytes: JObject, // Optional + max_source_fragments: JObject, // Optional + max_source_rows: JObject, // Optional + max_source_bytes: JObject, // Optional + excluded_fragment_ids: JObject, // List + data_storage_version: 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, @@ -233,6 +324,14 @@ fn inner_execute_task<'local>( &num_threads, &batch_size, &defer_index_remap, + &compaction_mode, + &binary_copy_read_batch_bytes, + &max_source_fragments, + &max_source_rows, + &max_source_bytes, + &excluded_fragment_ids, + &data_storage_version, + &config, )?; let compaction_task = CompactionTask { task: task_data, @@ -242,7 +341,7 @@ fn inner_execute_task<'local>( let rewrite_result = { let dataset = unsafe { env.get_rust_field::<_, _, BlockingDataset>(java_dataset, NATIVE_DATASET) }?; - RT.block_on(compaction_task.execute(&dataset.inner))? + block_on(compaction_task.execute(&dataset.inner))? }; rewrite_result.into_java(env) } @@ -256,10 +355,10 @@ const COMPACTION_PLAN_CONSTRUCTOR_SIG: &str = "(Ljava/util/List;JLorg/lance/compaction/CompactionOptions;)V"; 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;JLjava/util/Map;[B)V"; + "(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;)V"; +const COMPACTION_MODE_CLASS: &str = "org/lance/compaction/CompactionMode"; +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;Ljava/util/Optional;Ljava/util/Optional;Ljava/util/List;Ljava/util/Optional;)V"; impl IntoJava for &TaskData { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { @@ -308,6 +407,44 @@ impl IntoJava for &CompactionOptions { let batch_size_opt = to_java_optional(env, batch_size)?; let defer_index_remap = to_java_boolean_obj(env, Some(self.defer_index_remap))?; let defer_index_remap_opt = to_java_optional(env, defer_index_remap)?; + let compaction_mode_obj = match self.compaction_mode { + Some(mode) => { + let name = match mode { + CompactionMode::Reencode => "REENCODE", + CompactionMode::TryBinaryCopy => "TRY_BINARY_COPY", + CompactionMode::ForceBinaryCopy => "FORCE_BINARY_COPY", + }; + env.get_static_field( + COMPACTION_MODE_CLASS, + name, + format!("L{};", COMPACTION_MODE_CLASS), + )? + .l()? + } + None => JObject::null(), + }; + let compaction_mode_opt = to_java_optional(env, compaction_mode_obj)?; + 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)?; + let max_source_rows = to_java_long_obj(env, self.max_source_rows.map(|v| v as i64))?; + let max_source_rows_opt = to_java_optional(env, max_source_rows)?; + let max_source_bytes = to_java_long_obj(env, self.max_source_bytes.map(|v| v as i64))?; + let max_source_bytes_opt = to_java_optional(env, max_source_bytes)?; + let excluded_fragment_ids = self + .excluded_fragment_ids + .iter() + .map(|fragment_id| to_java_long_obj(env, Some(*fragment_id as i64))) + .collect::>>()?; + let excluded_fragment_ids = to_java_list(env, &excluded_fragment_ids)?; + let data_storage_version = match self.data_storage_version { + Some(version) => env.new_string(version.to_string())?.into(), + None => JObject::null(), + }; + let data_storage_version_opt = to_java_optional(env, data_storage_version)?; Ok(env.new_object( COMPACTION_OPTIONS_CLASS, @@ -321,6 +458,13 @@ impl IntoJava for &CompactionOptions { JValueGen::Object(&num_threads_opt), JValueGen::Object(&batch_size_opt), 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), + JValueGen::Object(&max_source_rows_opt), + JValueGen::Object(&max_source_bytes_opt), + JValueGen::Object(&excluded_fragment_ids), + JValueGen::Object(&data_storage_version_opt), ], )?) } @@ -347,25 +491,8 @@ impl IntoJava for &RewriteResult { let metrics = self.metrics.into_java(env)?; let new_fragments = export_vec(env, &self.new_fragments)?; let original_fragments = export_vec(env, &self.original_fragments)?; - let changed_row_addrs: JObject<'_> = - if let Some(changed_row_addrs) = &self.changed_row_addrs { - env.byte_array_from_slice(changed_row_addrs)?.into() - } else { - JObject::null() - }; - let row_id_map = if let Some(row_id_map) = &self.row_id_map { - let java_map = env.new_object("java/util/HashMap", "()V", &[])?; - for (k, v) in row_id_map { - let k_obj = to_java_long_obj(env, Some(*k as i64))?; - let v_obj = to_java_long_obj(env, v.map(|val| val as i64))?; - env.call_method( - &java_map, - "put", - "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", - &[JValue::Object(&k_obj), JValue::Object(&v_obj)], - )?; - } - java_map + let row_addrs: JObject<'_> = if let Some(row_addrs) = &self.row_addrs { + env.byte_array_from_slice(row_addrs)?.into() } else { JObject::null() }; @@ -377,8 +504,7 @@ impl IntoJava for &RewriteResult { JValueGen::Object(&new_fragments), JValueGen::Object(&original_fragments), JValueGen::Long(self.read_version as i64), - JValueGen::Object(&row_id_map), - JValueGen::Object(&changed_row_addrs), + JValueGen::Object(&row_addrs), ], )?) } @@ -434,38 +560,21 @@ impl FromJObjectWithEnv for JObject<'_> { import_vec_from_method(env, self, "getOriginalFragments", |env, fragment| { fragment.extract_object(env) })?; - let changed_row_addrs_obj: JByteArray<'_> = env - .call_method(self, "getChangedRowAddrs", "()[B", &[])? + let row_addrs_obj: JByteArray<'_> = env + .call_method(self, "getRowAddrs", "()[B", &[])? .l()? .into(); - let changed_row_addrs = if changed_row_addrs_obj.is_null() { + let row_addrs = if row_addrs_obj.is_null() { None } else { - Some(env.convert_byte_array(changed_row_addrs_obj)?) - }; - let row_id_map_obj = env - .call_method(self, "getRowIdMap", "()Ljava/util/Map;", &[])? - .l()?; - let row_id_map = if row_id_map_obj.is_null() { - None - } else { - let row_id_jmap = JMap::from_env(env, &row_id_map_obj)?; - let mut map = HashMap::new(); - let mut iter = row_id_jmap.iter(env)?; - while let Some((key, value)) = iter.next(env)? { - let key: Option = key.extract_object(env)?; - let value: Option = value.extract_object(env)?; - map.insert(key.unwrap() as u64, value.map(|v| v as u64)); - } - Some(map) + Some(env.convert_byte_array(row_addrs_obj)?) }; Ok(RewriteResult { metrics, new_fragments, read_version, original_fragments, - row_id_map, - changed_row_addrs, + row_addrs, }) } } diff --git a/java/lance-jni/src/otel.rs b/java/lance-jni/src/otel.rs new file mode 100644 index 00000000000..54f46c7955a --- /dev/null +++ b/java/lance-jni/src/otel.rs @@ -0,0 +1,541 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Bridge from the [`metrics`] crate facade to Java OpenTelemetry. +//! +//! The Java layer owns the actual OpenTelemetry instruments. This module +//! installs the process-global Rust [`metrics::Recorder`], keeps cumulative +//! metric state, and exposes catalog/snapshot calls over JNI. + +use std::collections::HashMap; +use std::sync::atomic::Ordering; +use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; + +use jni::JNIEnv; +use jni::objects::{JClass, JObject, JValue}; +use jni::sys::{jboolean, jobject}; +use metrics::atomics::AtomicU64; +use metrics::{Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit}; +use metrics_util::registry::{Registry, Storage}; + +use crate::error::Result; + +const DEFAULT_BOUNDS: &[f64] = &[ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, +]; + +#[derive(Clone, Copy)] +enum MetricKind { + Counter, + Gauge, + Histogram, +} + +impl MetricKind { + fn as_str(self) -> &'static str { + match self { + Self::Counter => "counter", + Self::Gauge => "gauge", + Self::Histogram => "histogram", + } + } +} + +struct MetricDescription { + kind: MetricKind, + unit: Option, + description: String, +} + +static CATALOG: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +static HISTOGRAM_BOUNDS: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +static REGISTRY: OnceLock>> = OnceLock::new(); + +fn bounds_for(name: &str) -> Arc<[f64]> { + HISTOGRAM_BOUNDS + .read() + .unwrap() + .get(name) + .cloned() + .unwrap_or_else(|| Arc::from(DEFAULT_BOUNDS)) +} + +struct BucketedHistogram { + bounds: Arc<[f64]>, + counts: Box<[AtomicU64]>, + count: AtomicU64, + sum_bits: AtomicU64, +} + +impl BucketedHistogram { + fn new(bounds: Arc<[f64]>) -> Self { + let counts = (0..=bounds.len()) + .map(|_| AtomicU64::new(0)) + .collect::>(); + Self { + bounds, + counts, + count: AtomicU64::new(0), + sum_bits: AtomicU64::new(0), + } + } + + fn add_to_sum(&self, value: f64) { + let mut current = self.sum_bits.load(Ordering::Relaxed); + loop { + let updated = (f64::from_bits(current) + value).to_bits(); + match self.sum_bits.compare_exchange_weak( + current, + updated, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(actual) => current = actual, + } + } + } + + fn snapshot(&self) -> MetricValue { + let mut cumulative = 0u64; + let mut buckets = Vec::with_capacity(self.bounds.len() + 1); + for (i, bound) in self.bounds.iter().enumerate() { + cumulative += self.counts[i].load(Ordering::Relaxed); + buckets.push((bound.to_string(), cumulative)); + } + cumulative += self.counts[self.bounds.len()].load(Ordering::Relaxed); + buckets.push(("+Inf".to_string(), cumulative)); + MetricValue::Histogram { + buckets, + count: self.count.load(Ordering::Relaxed), + sum: f64::from_bits(self.sum_bits.load(Ordering::Relaxed)), + } + } +} + +impl metrics::HistogramFn for BucketedHistogram { + fn record(&self, value: f64) { + let idx = self.bounds.partition_point(|&bound| bound < value); + self.counts[idx].fetch_add(1, Ordering::Relaxed); + self.count.fetch_add(1, Ordering::Relaxed); + self.add_to_sum(value); + } +} + +struct LanceStorage; + +impl Storage for LanceStorage { + type Counter = Arc; + type Gauge = Arc; + type Histogram = Arc; + + fn counter(&self, _key: &Key) -> Self::Counter { + Arc::new(AtomicU64::new(0)) + } + + fn gauge(&self, _key: &Key) -> Self::Gauge { + Arc::new(AtomicU64::new(0)) + } + + fn histogram(&self, key: &Key) -> Self::Histogram { + Arc::new(BucketedHistogram::new(bounds_for(key.name()))) + } +} + +struct LanceRecorder { + registry: Arc>, +} + +impl LanceRecorder { + fn describe( + &self, + key: KeyName, + kind: MetricKind, + unit: Option, + description: SharedString, + ) { + CATALOG.lock().unwrap().insert( + key.as_str().to_string(), + MetricDescription { + kind, + unit: unit.map(|u| u.as_canonical_label().to_string()), + description: description.into_owned(), + }, + ); + } +} + +impl Recorder for LanceRecorder { + fn describe_counter(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Counter, unit, description); + } + + fn describe_gauge(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Gauge, unit, description); + } + + fn describe_histogram(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Histogram, unit, description); + } + + fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter { + self.registry + .get_or_create_counter(key, |counter| Counter::from_arc(counter.clone())) + } + + fn register_gauge(&self, key: &Key, _metadata: &Metadata<'_>) -> Gauge { + self.registry + .get_or_create_gauge(key, |gauge| Gauge::from_arc(gauge.clone())) + } + + fn register_histogram(&self, key: &Key, _metadata: &Metadata<'_>) -> Histogram { + self.registry + .get_or_create_histogram(key, |histogram| Histogram::from_arc(histogram.clone())) + } +} + +fn register_bounds() { + let mut bounds = HISTOGRAM_BOUNDS.write().unwrap(); + for (name, values) in lance_io::object_store::metrics::histogram_bounds() { + bounds.insert((*name).to_string(), Arc::from(*values)); + } +} + +fn describe_all() { + lance_io::object_store::metrics::describe_metrics(); +} + +enum MetricValue { + Scalar(f64), + Histogram { + buckets: Vec<(String, u64)>, + count: u64, + sum: f64, + }, +} + +struct MetricPoint { + name: String, + kind: &'static str, + attributes: HashMap, + value: MetricValue, +} + +fn labels(key: &Key) -> HashMap { + key.labels() + .map(|label| (label.key().to_string(), label.value().to_string())) + .collect() +} + +fn collect_points(registry: &Registry) -> Vec { + let mut points = Vec::new(); + for (key, handle) in registry.get_counter_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "counter", + attributes: labels(&key), + value: MetricValue::Scalar(handle.load(Ordering::Relaxed) as f64), + }); + } + for (key, handle) in registry.get_gauge_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "gauge", + attributes: labels(&key), + value: MetricValue::Scalar(f64::from_bits(handle.load(Ordering::Relaxed))), + }); + } + for (key, handle) in registry.get_histogram_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "histogram", + attributes: labels(&key), + value: handle.snapshot(), + }); + } + points +} + +fn register_lance_metrics_recorder() -> bool { + if REGISTRY.get().is_some() { + return true; + } + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + register_bounds(); + match metrics::set_global_recorder(recorder) { + Ok(()) => { + let _ = REGISTRY.set(registry); + describe_all(); + true + } + Err(_) => false, + } +} + +fn object_list<'local>(env: &mut JNIEnv<'local>) -> Result> { + Ok(env.new_object("java/util/ArrayList", "()V", &[])?) +} + +fn add_to_list(env: &mut JNIEnv, list: &JObject, item: &JObject) -> Result<()> { + env.call_method( + list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(item)], + )?; + Ok(()) +} + +fn string_object<'local>(env: &mut JNIEnv<'local>, value: Option<&str>) -> Result> { + match value { + Some(value) => Ok(env.new_string(value)?.into()), + None => Ok(JObject::null()), + } +} + +fn attributes_to_java<'local>( + env: &mut JNIEnv<'local>, + attributes: &HashMap, +) -> Result> { + let java_map = env.new_object("java/util/HashMap", "()V", &[])?; + for (key, value) in attributes { + let java_key = env.new_string(key)?; + let java_value = env.new_string(value)?; + env.call_method( + &java_map, + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + &[JValue::Object(&java_key), JValue::Object(&java_value)], + )?; + env.delete_local_ref(java_key)?; + env.delete_local_ref(java_value)?; + } + Ok(java_map) +} + +fn buckets_to_java<'local>( + env: &mut JNIEnv<'local>, + buckets: Option<&[(String, u64)]>, +) -> Result> { + let Some(buckets) = buckets else { + return Ok(JObject::null()); + }; + let list = object_list(env)?; + for (le, cumulative_count) in buckets { + let le = env.new_string(le)?; + let bucket = env.new_object( + "org/lance/otel/MetricBucket", + "(Ljava/lang/String;J)V", + &[JValue::Object(&le), JValue::Long(*cumulative_count as i64)], + )?; + add_to_list(env, &list, &bucket)?; + env.delete_local_ref(le)?; + env.delete_local_ref(bucket)?; + } + Ok(list) +} + +fn boxed_double<'local>(env: &mut JNIEnv<'local>, value: Option) -> Result> { + match value { + Some(value) => Ok(env.new_object("java/lang/Double", "(D)V", &[JValue::Double(value)])?), + None => Ok(JObject::null()), + } +} + +fn boxed_long<'local>(env: &mut JNIEnv<'local>, value: Option) -> Result> { + match value { + Some(value) => { + Ok(env.new_object("java/lang/Long", "(J)V", &[JValue::Long(value as i64)])?) + } + None => Ok(JObject::null()), + } +} + +fn metric_description_to_java<'local>( + env: &mut JNIEnv<'local>, + name: &str, + desc: &MetricDescription, +) -> Result> { + let name = env.new_string(name)?; + let kind = env.new_string(desc.kind.as_str())?; + let unit = string_object(env, desc.unit.as_deref())?; + let description = env.new_string(&desc.description)?; + Ok(env.new_object( + "org/lance/otel/MetricDescription", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&name), + JValue::Object(&kind), + JValue::Object(&unit), + JValue::Object(&description), + ], + )?) +} + +fn metric_point_to_java<'local>( + env: &mut JNIEnv<'local>, + point: MetricPoint, +) -> Result> { + let (value, buckets, count, sum) = match point.value { + MetricValue::Scalar(value) => (Some(value), None, None, None), + MetricValue::Histogram { + buckets, + count, + sum, + } => (None, Some(buckets), Some(count), Some(sum)), + }; + let name = env.new_string(&point.name)?; + let kind = env.new_string(point.kind)?; + let attributes = attributes_to_java(env, &point.attributes)?; + let value = boxed_double(env, value)?; + let buckets = buckets_to_java(env, buckets.as_deref())?; + let count = boxed_long(env, count)?; + let sum = boxed_double(env, sum)?; + Ok(env.new_object( + "org/lance/otel/MetricPoint", + "(Ljava/lang/String;Ljava/lang/String;Ljava/util/Map;Ljava/lang/Double;Ljava/util/List;Ljava/lang/Long;Ljava/lang/Double;)V", + &[ + JValue::Object(&name), + JValue::Object(&kind), + JValue::Object(&attributes), + JValue::Object(&value), + JValue::Object(&buckets), + JValue::Object(&count), + JValue::Object(&sum), + ], + )?) +} + +fn lance_metrics_catalog_native<'local>(env: &mut JNIEnv<'local>) -> Result> { + let catalog = CATALOG.lock().unwrap(); + let list = object_list(env)?; + for (name, desc) in catalog.iter() { + env.with_local_frame(16, |env| { + let item = metric_description_to_java(env, name, desc)?; + add_to_list(env, &list, &item) + })?; + } + Ok(list) +} + +fn snapshot_lance_metrics_native<'local>(env: &mut JNIEnv<'local>) -> Result> { + let list = object_list(env)?; + if let Some(registry) = REGISTRY.get() { + for point in collect_points(registry) { + env.with_local_frame(64, |env| { + let item = metric_point_to_java(env, point)?; + add_to_list(env, &list, &item) + })?; + } + } + Ok(list) +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_otel_LanceMetrics_registerLanceMetricsRecorderNative( + _env: JNIEnv, + _class: JClass, +) -> jboolean { + register_lance_metrics_recorder() as jboolean +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_otel_LanceMetrics_lanceMetricsCatalogNative( + mut env: JNIEnv, + _class: JClass, +) -> jobject { + ok_or_throw_with_return!( + env, + lance_metrics_catalog_native(&mut env), + std::ptr::null_mut() + ) + .into_raw() +} + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_otel_LanceMetrics_snapshotLanceMetricsNative( + mut env: JNIEnv, + _class: JClass, +) -> jobject { + ok_or_throw_with_return!( + env, + snapshot_lance_metrics_native(&mut env), + std::ptr::null_mut() + ) + .into_raw() +} + +#[cfg(test)] +mod tests { + use super::*; + use metrics::HistogramFn; + + fn bucket_count(buckets: &[(String, u64)], le: &str) -> u64 { + buckets + .iter() + .find(|(bound, _)| bound == le) + .map(|(_, count)| *count) + .unwrap_or_else(|| panic!("no bucket with le={le}")) + } + + #[test] + fn test_bucketed_histogram_records_cumulative_buckets() { + let histogram = BucketedHistogram::new(Arc::from([0.1f64, 1.0, 10.0].as_slice())); + histogram.record(0.05); + histogram.record(0.1); + histogram.record(0.5); + histogram.record(1.0); + histogram.record(5.0); + histogram.record(50.0); + + let MetricValue::Histogram { + buckets, + count, + sum, + } = histogram.snapshot() + else { + panic!("expected histogram"); + }; + + assert_eq!(bucket_count(&buckets, "0.1"), 2); + assert_eq!(bucket_count(&buckets, "1"), 4); + assert_eq!(bucket_count(&buckets, "10"), 5); + assert_eq!(bucket_count(&buckets, "+Inf"), 6); + assert_eq!(bucket_count(&buckets, "+Inf"), count); + assert!((sum - 56.65).abs() < 1e-9); + } + + #[test] + fn test_registry_aggregates_counters_and_gauges() { + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + metrics::with_local_recorder(&recorder, || { + metrics::counter!("test_counter", "operation" => "get").increment(2); + metrics::counter!("test_counter", "operation" => "get").increment(3); + metrics::gauge!("test_gauge", "operation" => "get").increment(3.5); + metrics::gauge!("test_gauge", "operation" => "get").decrement(1.25); + }); + + let points = collect_points(®istry); + let counter = points + .iter() + .find(|point| point.name == "test_counter") + .expect("counter recorded"); + let gauge = points + .iter() + .find(|point| point.name == "test_gauge") + .expect("gauge recorded"); + + assert!(matches!(counter.value, MetricValue::Scalar(value) if value == 5.0)); + assert!(matches!(gauge.value, MetricValue::Scalar(value) if (value - 2.25).abs() < 1e-9)); + } +} diff --git a/java/lance-jni/src/schema.rs b/java/lance-jni/src/schema.rs index d0952fd833f..01e8d3c96e8 100644 --- a/java/lance-jni/src/schema.rs +++ b/java/lance-jni/src/schema.rs @@ -6,9 +6,9 @@ use crate::traits::IntoJava; use crate::utils::to_java_map; use arrow::datatypes::DataType; use arrow_schema::{TimeUnit, UnionFields}; +use jni::JNIEnv; use jni::objects::{JObject, JValue}; use jni::sys::{jboolean, jint}; -use jni::JNIEnv; use lance_core::datatypes::{Field, Schema}; impl IntoJava for Schema { @@ -46,8 +46,9 @@ pub fn convert_to_java_field<'local>( + "Lorg/apache/arrow/vector/types/pojo/ArrowType;" + "Lorg/apache/arrow/vector/types/pojo/DictionaryEncoding;" + "Ljava/util/Map;" - + "Ljava/util/List;ZI)V"; + + "Ljava/util/List;ZIZI)V"; let pk_position = lance_field.unenforced_primary_key_position.unwrap_or(0) as jint; + let ck_position = lance_field.unenforced_clustering_key_position.unwrap_or(0) as jint; let field_obj = env.new_object( "org/lance/schema/LanceField", ctor_sig.as_str(), @@ -63,6 +64,8 @@ pub fn convert_to_java_field<'local>( JValue::Object(&children), JValue::Bool(lance_field.is_unenforced_primary_key() as jboolean), JValue::Int(pk_position), + JValue::Bool(lance_field.is_unenforced_clustering_key() as jboolean), + JValue::Int(ck_position), ], )?; diff --git a/java/lance-jni/src/session.rs b/java/lance-jni/src/session.rs new file mode 100644 index 00000000000..6afae56f43a --- /dev/null +++ b/java/lance-jni/src/session.rs @@ -0,0 +1,274 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use jni::JNIEnv; +use jni::objects::{JMap, JObject, JString, JValue}; +use jni::sys::jlong; +use lance::session::{CacheSpec, Session as LanceSession}; +use lance_core::cache::{BackendConfig, build_from_config, build_from_uri}; +use lance_io::object_store::ObjectStoreRegistry; + +use crate::block_on; +use crate::error::{Error, Result}; +use crate::utils::to_rust_map; + +/// Creates a new Session and returns a handle to it. +/// +/// The handle is a raw pointer to a Box>, which allows +/// the session to be shared between multiple datasets. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Session_createNative( + mut env: JNIEnv, + _obj: JObject, + index_cache_size_bytes: jlong, + metadata_cache_size_bytes: jlong, + index_cache_backend_uri: JString, + index_cache_backend_kind: JString, + index_cache_backend_options: JObject, + metadata_cache_backend_uri: JString, + metadata_cache_backend_kind: JString, + metadata_cache_backend_options: JObject, +) -> jlong { + ok_or_throw_with_return!( + env, + create_session( + &mut env, + index_cache_size_bytes, + metadata_cache_size_bytes, + index_cache_backend_uri, + index_cache_backend_kind, + index_cache_backend_options, + metadata_cache_backend_uri, + metadata_cache_backend_kind, + metadata_cache_backend_options, + ), + 0 + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_session( + env: &mut JNIEnv, + index_cache_size_bytes: jlong, + metadata_cache_size_bytes: jlong, + index_cache_backend_uri: JString, + index_cache_backend_kind: JString, + index_cache_backend_options: JObject, + metadata_cache_backend_uri: JString, + metadata_cache_backend_kind: JString, + metadata_cache_backend_options: JObject, +) -> Result { + let index_cache = resolve_cache_spec( + env, + "indexCacheBackend", + "indexCacheSizeBytes", + index_cache_size_bytes, + index_cache_backend_uri, + index_cache_backend_kind, + index_cache_backend_options, + )?; + let metadata_cache = resolve_cache_spec( + env, + "metadataCacheBackend", + "metadataCacheSizeBytes", + metadata_cache_size_bytes, + metadata_cache_backend_uri, + metadata_cache_backend_kind, + metadata_cache_backend_options, + )?; + + let session = LanceSession::with_cache_backends( + index_cache, + metadata_cache, + Arc::new(ObjectStoreRegistry::default()), + ); + + // Wrap in Arc and Box, then convert to raw pointer + let boxed: Box> = Box::new(Arc::new(session)); + let handle = Box::into_raw(boxed) as jlong; + Ok(handle) +} + +#[allow(clippy::too_many_arguments)] +fn resolve_cache_spec( + env: &mut JNIEnv, + backend_field: &str, + size_field: &str, + size_bytes: jlong, + backend_uri: JString, + backend_kind: JString, + backend_options: JObject, +) -> Result { + let has_uri = !backend_uri.is_null(); + let has_kind = !backend_kind.is_null(); + if has_uri && has_kind { + return Err(Error::input_error(format!( + "{} must use either a URI or a structured config, not both", + backend_field + ))); + } + if size_bytes >= 0 && (has_uri || has_kind) { + return Err(Error::input_error(format!( + "{} and {} are mutually exclusive; set one or the other", + size_field, backend_field + ))); + } + + if has_uri { + let uri: String = env.get_string(&backend_uri)?.into(); + return build_from_uri(&uri) + .map(CacheSpec::Backend) + .map_err(Error::from); + } + + if has_kind { + let kind: String = env.get_string(&backend_kind)?.into(); + let mut config = BackendConfig::new(&kind)?; + if !backend_options.is_null() { + let options = JMap::from_env(env, &backend_options)?; + config.options = to_rust_map(env, &options)?; + } + return build_from_config(&config) + .map(CacheSpec::Backend) + .map_err(Error::from); + } + + if size_bytes >= 0 { + let size = usize::try_from(size_bytes).map_err(|_| { + Error::input_error(format!( + "{} value {} does not fit in usize", + size_field, size_bytes + )) + })?; + Ok(CacheSpec::Size(size)) + } else { + Ok(CacheSpec::Default) + } +} + +/// Returns the current size of the session in bytes. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Session_sizeBytesNative( + mut env: JNIEnv, + obj: JObject, +) -> jlong { + ok_or_throw_with_return!(env, size_bytes_native(&mut env, obj), 0) +} + +fn size_bytes_native(env: &mut JNIEnv, obj: JObject) -> Result { + let handle = get_session_handle(env, &obj)?; + if handle == 0 { + return Err(Error::input_error("Session is closed".to_string())); + } + + // Safety: We trust that the handle is valid and was created by createNative + let session_arc = unsafe { &*(handle as *const Arc) }; + Ok(session_arc.size_bytes() as jlong) +} + +/// Returns statistics for the session's metadata cache as an org.lance.CacheStats object. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Session_metadataCacheStatsNative<'local>( + mut env: JNIEnv<'local>, + obj: JObject, +) -> JObject<'local> { + ok_or_throw!(env, metadata_cache_stats_native(&mut env, obj)) +} + +fn metadata_cache_stats_native<'local>( + env: &mut JNIEnv<'local>, + obj: JObject, +) -> Result> { + let handle = get_session_handle(env, &obj)?; + if handle == 0 { + return Err(Error::input_error("Session is closed".to_string())); + } + + // Safety: We trust that the handle is valid and was created by createNative + let session_arc = unsafe { &*(handle as *const Arc) }; + let stats = block_on(session_arc.metadata_cache_stats()); + + let stats_obj = env.new_object( + "org/lance/CacheStats", + "(JJJJ)V", + &[ + JValue::Long(stats.hits as jlong), + JValue::Long(stats.misses as jlong), + JValue::Long(stats.num_entries as jlong), + JValue::Long(stats.size_bytes as jlong), + ], + )?; + Ok(stats_obj) +} + +/// Releases the native session handle. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Session_releaseNative( + _env: JNIEnv, + _obj: JObject, + handle: jlong, +) { + if handle != 0 { + // Safety: We trust that the handle is valid and was created by createNative + let _ = unsafe { Box::from_raw(handle as *mut Arc) }; + // The Box is dropped here, which decrements the Arc reference count + } +} + +/// Helper function to get the session handle from a Session object +fn get_session_handle(env: &mut JNIEnv, obj: &JObject) -> Result { + let handle = env.get_field(obj, "nativeSessionHandle", "J")?; + Ok(handle.j()?) +} + +/// Creates an Arc from a raw handle. +/// This is used when passing a session to dataset operations. +/// +/// # Safety +/// The handle must be a valid pointer created by `create_session`. +pub fn session_from_handle(handle: jlong) -> Option> { + if handle == 0 { + return None; + } + + // Safety: We trust that the handle is valid and was created by createNative + let session_arc = unsafe { &*(handle as *const Arc) }; + Some(session_arc.clone()) +} + +/// Creates a raw handle from an Arc. +/// This is used when returning a session handle from a dataset. +/// +/// Note: This creates a new Box, so the caller is responsible for +/// managing its lifetime or converting it back to a Java Session object. +pub fn handle_from_session(session: Arc) -> jlong { + let boxed: Box> = Box::new(session); + Box::into_raw(boxed) as jlong +} + +/// Compares two session handles to see if they point to the same underlying session. +/// This is needed because each call to handle_from_session creates a new Box, +/// resulting in different pointer addresses even for the same session. +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Session_isSameAsNative( + _env: JNIEnv, + _obj: JObject, + handle1: jlong, + handle2: jlong, +) -> jni::sys::jboolean { + if handle1 == 0 || handle2 == 0 { + return 0; // false + } + + // Safety: We trust that the handles are valid and were created by createNative + let session1 = unsafe { &*(handle1 as *const Arc) }; + let session2 = unsafe { &*(handle2 as *const Arc) }; + + if Arc::ptr_eq(session1, session2) { + 1 // true + } else { + 0 // false + } +} diff --git a/java/lance-jni/src/sql.rs b/java/lance-jni/src/sql.rs index 07bc3d8e2fa..2111d02c9fd 100644 --- a/java/lance-jni/src/sql.rs +++ b/java/lance-jni/src/sql.rs @@ -4,16 +4,16 @@ use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; use crate::error::Result; use crate::traits::FromJString; -use crate::{Error, JNIEnvExt, RT}; +use crate::{Error, JNIEnvExt, RT, block_on}; use arrow::ffi_stream::FFI_ArrowArrayStream; -use jni::objects::{JClass, JObject, JString}; -use jni::sys::{jboolean, jlong, JNI_TRUE}; use jni::JNIEnv; +use jni::objects::{JClass, JObject, JString}; +use jni::sys::{JNI_TRUE, jboolean, jlong}; use lance::dataset::scanner::DatasetRecordBatchStream; use lance::dataset::sql::SqlQueryBuilder; use lance_io::ffi::to_ffi_arrow_array_stream; -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_SqlQuery_intoBatchRecords( mut env: JNIEnv, _class: JClass, @@ -57,7 +57,7 @@ fn inner_into_batch_records( with_row_addr, )?; - let stream = RT.block_on(async move { + let stream = block_on(async move { let query = builder.build().await?; query.into_stream().await })?; diff --git a/java/lance-jni/src/storage_options.rs b/java/lance-jni/src/storage_options.rs index a79a346f751..7ec607c26e7 100644 --- a/java/lance-jni/src/storage_options.rs +++ b/java/lance-jni/src/storage_options.rs @@ -1,197 +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::objects::{JMap, JObject, JString}; -use jni::JNIEnv; -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 - ))), - location: snafu::location!(), - })?; - - // 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 - ))), - location: snafu::location!(), - })?; - - 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 - ))), - location: snafu::location!(), - })?; - - // 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 - ))), - location: snafu::location!(), - })?; - - 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 - ))), - location: snafu::location!(), - })?; - - 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 - ))), - location: snafu::location!(), - })? - { - 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 - ))), - location: snafu::location!(), - })? - .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 - ))), - location: snafu::location!(), - })? - .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 - ))), - location: snafu::location!(), - })? - } - - 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/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/lance-jni/src/traits.rs b/java/lance-jni/src/traits.rs index c5aaec7215e..5a8c9aba6d1 100644 --- a/java/lance-jni/src/traits.rs +++ b/java/lance-jni/src/traits.rs @@ -3,8 +3,8 @@ use std::collections::HashMap; -use jni::objects::{JIntArray, JLongArray, JMap, JObject, JString, JValue, JValueGen}; use jni::JNIEnv; +use jni::objects::{JIntArray, JLongArray, JMap, JObject, JString, JValue, JValueGen}; use crate::error::Result; @@ -202,6 +202,14 @@ impl IntoJava for JLance> { } } +impl IntoJava for JLance> { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let arr = env.new_long_array(self.0.len() as i32)?; + env.set_long_array_region(&arr, 0, &self.0)?; + Ok(arr.into()) + } +} + impl IntoJava for JLance { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { Ok(env.new_object("java/lang/Long", "(J)V", &[JValueGen::Long(self.0 as i64)])?) @@ -262,6 +270,15 @@ impl IntoJava for JLance> { } } +impl IntoJava for JLance> { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + Ok(match self.0 { + Some(value) => env.new_string(value)?.into(), + None => JObject::null(), + }) + } +} + impl FromJObjectWithEnv> for JObject<'_> { fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result> { let ret = if self.is_null() { diff --git a/java/lance-jni/src/transaction.rs b/java/lance-jni/src/transaction.rs index ea5996aaeed..e1ca961af72 100644 --- a/java/lance-jni/src/transaction.rs +++ b/java/lance-jni/src/transaction.rs @@ -1,32 +1,207 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; -use crate::error::Result; -use crate::traits::{export_vec, import_vec_from_method, FromJObjectWithEnv, IntoJava, JLance}; -use crate::utils::{to_java_map, to_rust_map}; use crate::Error; use crate::JNIEnvExt; +use crate::block_on; +use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET, extract_namespace_info}; +use crate::error::Result; +use crate::traits::{ + FromJObjectWithEnv, FromJString, IntoJava, JLance, export_vec, import_vec_from_method, +}; +use crate::utils::{to_java_map, to_rust_map}; use arrow::datatypes::Schema; use arrow_schema::ffi::FFI_ArrowSchema; use chrono::DateTime; -use jni::objects::{JByteArray, JLongArray, JMap, JObject, JString, JValue, JValueGen}; -use jni::sys::jboolean; use jni::JNIEnv; +use jni::objects::{JByteArray, JIntArray, JLongArray, JMap, JObject, JString, JValue, JValueGen}; +use jni::sys::{jboolean, jint, jlong}; +use lance::dataset::CommitBuilder; use lance::dataset::transaction::{ - DataReplacementGroup, Operation, RewriteGroup, RewrittenIndex, Transaction, TransactionBuilder, - UpdateMap, UpdateMapEntry, UpdateMode, + DataOverlayGroup, DataReplacementGroup, Operation, RewriteGroup, RewrittenIndex, Transaction, + TransactionBuilder, UpdateMap, UpdateMapEntry, UpdateMode, UpdatedFragmentOffsets, }; use lance::io::ObjectStoreParams; -use lance::table::format::{Fragment, IndexMetadata}; +use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; +use lance::table::format::key_existence::{FilterType, KeyExistenceFilter}; +use lance::table::format::overlay::{DataOverlayFile, OverlayCoverage}; +use lance::table::format::{DataFile, Fragment, IndexFile, IndexMetadata}; +use lance_core::datatypes::Field; use lance_core::datatypes::Schema as LanceSchema; +use lance_file::version::{LanceFileVersion, V2_FORMAT_2_0, V2_FORMAT_2_1, V2_FORMAT_2_2}; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, StorageOptionsProvider}; +use lance_table::io::commit::CommitHandler; +use lance_table::io::commit::external_manifest::ExternalManifestCommitHandler; use prost::Message; use prost_types::Any; use roaring::RoaringBitmap; +use std::cell::Cell; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; +fn u64_to_jlong(field: &str, value: u64) -> Result { + i64::try_from(value).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust transaction field {field}={value} to Java long" + )) + }) +} + +fn u32_to_jint(field: &str, value: u32) -> Result { + i32::try_from(value).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust transaction field {field}={value} to Java int" + )) + }) +} + +fn checked_field_ids(field: &str, values: &[i64]) -> Result> { + values + .iter() + .enumerate() + .map(|(index, value)| { + u32::try_from(*value).map_err(|_| { + Error::input_error(format!( + "Java transaction field {field}[{index}] must be between 0 and {}, got {value}", + u32::MAX + )) + }) + }) + .collect() +} + +fn import_field_ids( + env: &mut JNIEnv<'_>, + object: &JObject<'_>, + method: &str, + field: &str, +) -> Result> { + let array = env.call_method(object, method, "()[J", &[])?.l()?; + let array = JLongArray::from(array); + let mut values = vec![0_i64; env.get_array_length(&array)? as usize]; + env.get_long_array_region(&array, 0, &mut values)?; + checked_field_ids(field, &values) +} + +fn nonnegative_jlong_to_u64(field: &str, value: i64) -> Result { + u64::try_from(value).map_err(|_| { + Error::input_error(format!( + "Java transaction field {field} must be non-negative, got {value}" + )) + }) +} + +fn import_unsigned_longs( + env: &mut JNIEnv<'_>, + object: &JObject<'_>, + method: &str, + field: &str, +) -> Result> { + let index = Cell::new(0_usize); + import_vec_from_method(env, object, method, |env, value| { + let position = index.get(); + index.set(position + 1); + nonnegative_jlong_to_u64( + &format!("{field}[{position}]"), + env.call_method(value, "longValue", "()J", &[])?.j()?, + ) + }) +} + +fn export_unsigned_longs<'a>( + env: &mut JNIEnv<'a>, + values: &[u64], + field: &str, +) -> Result> { + let values = values + .iter() + .enumerate() + .map(|(index, value)| u64_to_jlong(&format!("{field}[{index}]"), *value).map(JLance)) + .collect::>>()?; + export_vec(env, &values) +} + +impl IntoJava for &IndexFile { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let path = env.new_string(&self.path)?; + Ok(env.new_object( + "org/lance/index/IndexFile", + "(Ljava/lang/String;J)V", + &[ + JValue::Object(&path), + JValue::Long(u64_to_jlong("newIndexFiles.sizeBytes", self.size_bytes)?), + ], + )?) + } +} + +impl FromJObjectWithEnv for JObject<'_> { + fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { + Ok(IndexFile { + path: env.get_string_from_method(self, "getPath")?, + size_bytes: nonnegative_jlong_to_u64( + "newIndexFiles.sizeBytes", + env.call_method(self, "getSizeBytes", "()J", &[])?.j()?, + )?, + }) + } +} + +fn compacted_sstable_into_java<'a>( + env: &mut JNIEnv<'a>, + sstable: &lance_index::mem_wal::CompactedSsTable, +) -> Result> { + let shard_id = env.new_string(sstable.shard_id.to_string())?; + Ok(env.new_object( + "org/lance/memwal/CompactedSsTable", + "(Ljava/lang/String;J)V", + &[ + JValue::Object(&shard_id), + JValue::Long(u64_to_jlong( + "compactedSstables.generation", + sstable.generation, + )?), + ], + )?) +} + +fn compacted_sstable_from_java( + env: &mut JNIEnv<'_>, + object: &JObject<'_>, +) -> Result { + let shard_id = env.get_string_from_method(object, "getShardId")?; + let shard_id = Uuid::parse_str(&shard_id).map_err(|e| { + Error::input_error(format!( + "Invalid compacted SSTable shardId '{shard_id}': {e}" + )) + })?; + let generation = nonnegative_jlong_to_u64( + "compactedSstables.generation", + env.call_method(object, "getGeneration", "()J", &[])?.j()?, + )?; + Ok(lance_index::mem_wal::CompactedSsTable::new( + shard_id, generation, + )) +} + +fn export_compacted_sstables<'a>( + env: &mut JNIEnv<'a>, + sstables: &[lance_index::mem_wal::CompactedSsTable], +) -> Result> { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for sstable in sstables { + let object = compacted_sstable_into_java(env, sstable)?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&object)], + )?; + } + Ok(list) +} + impl IntoJava for &RewriteGroup { fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { let old_fragments = export_vec(env, &self.old_fragments)?; @@ -50,16 +225,26 @@ impl IntoJava for &RewrittenIndex { let new_index_details_type_url = env.new_string(self.new_index_details.type_url.clone())?; let new_index_details_value = env.byte_array_from_slice(&self.new_index_details.value)?; + let new_index_files = match &self.new_index_files { + Some(files) => export_vec(env, files)?, + None => JObject::null(), + }; Ok(env.new_object( "org/lance/operation/RewrittenIndex", - "(Ljava/util/UUID;Ljava/util/UUID;Ljava/lang/String;[BII)V", + "(Ljava/util/UUID;Ljava/util/UUID;Ljava/lang/String;[BILjava/util/List;)V", &[ JValue::Object(&old_id), JValue::Object(&new_id), JValue::Object(&new_index_details_type_url), JValue::Object(&new_index_details_value), - JValue::Int(self.new_index_version as i32), + JValue::Int(i32::try_from(self.new_index_version).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust transaction field newIndexVersion={} to Java int", + self.new_index_version + )) + })?), + JValue::Object(&new_index_files), ], )?) } @@ -73,7 +258,10 @@ impl IntoJava for &DataReplacementGroup { Ok(env.new_object( "org/lance/operation/DataReplacement$DataReplacementGroup", "(JLorg/lance/fragment/DataFile;)V", - &[JValue::Long(fragment_id as i64), JValue::Object(&new_file)], + &[ + JValue::Long(u64_to_jlong("dataReplacement.fragmentId", fragment_id)?), + JValue::Object(&new_file), + ], )?) } } @@ -134,6 +322,10 @@ impl FromJObjectWithEnv for JObject<'_> { env.convert_byte_array(JByteArray::from(new_index_details_value))?; let new_index_version = env.get_field(self, "newIndexVersion", "I")?.i()?; + let new_index_files = + env.get_optional_from_method(self, "getNewIndexFiles", |env, files| { + crate::traits::import_vec_to_rust(env, &files, |env, file| file.extract_object(env)) + })?; Ok(RewrittenIndex { old_id: java_old_id, new_id: java_new_id, @@ -141,7 +333,296 @@ impl FromJObjectWithEnv for JObject<'_> { type_url: new_index_details_type_url, value: new_index_details_value, }, - new_index_version: new_index_version as u32, + new_index_version: u32::try_from(new_index_version).map_err(|_| { + Error::input_error(format!( + "Java transaction field newIndexVersion must be non-negative, got {new_index_version}" + )) + })?, + new_index_files, + }) + } +} + +fn key_existence_filter_into_java<'a>( + env: &mut JNIEnv<'a>, + filter: &KeyExistenceFilter, +) -> Result> { + let field_ids = JLance(filter.field_ids.clone()).into_java(env)?; + match &filter.filter { + FilterType::ExactSet(hashes) => { + let hashes = JLance(hashes.iter().map(|hash| *hash as i64).collect::>()) + .into_java(env)?; + env.call_static_method( + "org/lance/operation/KeyExistenceFilter", + "exact", + "([I[J)Lorg/lance/operation/KeyExistenceFilter;", + &[JValue::Object(&field_ids), JValue::Object(&hashes)], + )? + .l() + .map_err(Into::into) + } + FilterType::Bloom { + bitmap, + num_bits, + number_of_items, + probability, + } => { + let bitmap = env.byte_array_from_slice(bitmap)?; + env.call_static_method( + "org/lance/operation/KeyExistenceFilter", + "bloom", + "([I[BIJD)Lorg/lance/operation/KeyExistenceFilter;", + &[ + JValue::Object(&field_ids), + JValue::Object(&bitmap), + JValue::Int(i32::try_from(*num_bits).map_err(|_| { + Error::runtime_error(format!( + "Cannot convert Rust transaction field insertedRowsFilter.numBits={num_bits} to Java int" + )) + })?), + JValue::Long(u64_to_jlong( + "insertedRowsFilter.numberOfItems", + *number_of_items, + )?), + JValue::Double(*probability), + ], + )? + .l() + .map_err(Into::into) + } + } +} + +fn key_existence_filter_from_java( + env: &mut JNIEnv<'_>, + object: &JObject<'_>, +) -> Result { + let field_ids = env.call_method(object, "getFieldIds", "()[I", &[])?.l()?; + let field_ids = JIntArray::from(field_ids).extract_object(env)?; + let filter_type = env + .call_method( + object, + "getType", + "()Lorg/lance/operation/KeyExistenceFilter$Type;", + &[], + )? + .l()?; + let filter_type = env.get_string_from_method(&filter_type, "name")?; + let filter = match filter_type.as_str() { + "EXACT" => { + let hashes = env + .call_method(object, "getExactKeyHashes", "()[J", &[])? + .l()?; + let hashes = JLongArray::from(hashes); + let len = env.get_array_length(&hashes)?; + let mut values = vec![0_i64; len as usize]; + env.get_long_array_region(&hashes, 0, &mut values)?; + FilterType::ExactSet(values.into_iter().map(|value| value as u64).collect()) + } + "BLOOM" => { + let bitmap = env + .call_method(object, "getBloomBitmap", "()[B", &[])? + .l()?; + let bitmap = env.convert_byte_array(JByteArray::from(bitmap))?; + let num_bits = u32::try_from( + env.call_method(object, "getBloomNumBits", "()I", &[])? + .i()?, + ) + .map_err(|_| { + Error::input_error("insertedRowsFilter.numBits must be positive".to_string()) + })?; + let number_of_items = nonnegative_jlong_to_u64( + "insertedRowsFilter.numberOfItems", + env.call_method(object, "getBloomNumberOfItems", "()J", &[])? + .j()?, + )?; + let probability = env + .call_method(object, "getBloomProbability", "()D", &[])? + .d()?; + let bitmap_bits = u32::try_from(bitmap.len()) + .ok() + .and_then(|len| len.checked_mul(8)) + .ok_or_else(|| { + Error::input_error( + "insertedRowsFilter.bitmap is too large to represent".to_string(), + ) + })?; + if num_bits == 0 || num_bits != bitmap_bits { + return Err(Error::input_error(format!( + "insertedRowsFilter.numBits={num_bits} must equal bitmap length in bits ({bitmap_bits})" + ))); + } + if number_of_items == 0 { + return Err(Error::input_error( + "insertedRowsFilter.numberOfItems must be positive".to_string(), + )); + } + if !probability.is_finite() || !(0.0..1.0).contains(&probability) || probability == 0.0 + { + return Err(Error::input_error(format!( + "insertedRowsFilter.probability must be finite and between 0 and 1, got {probability}" + ))); + } + FilterType::Bloom { + bitmap, + num_bits, + number_of_items, + probability, + } + } + other => { + return Err(Error::input_error(format!( + "Unknown KeyExistenceFilter.Type: {other}" + ))); + } + }; + Ok(KeyExistenceFilter { field_ids, filter }) +} + +fn serialize_bitmap(bitmap: &RoaringBitmap) -> Result> { + let mut bytes = Vec::with_capacity(bitmap.serialized_size()); + bitmap.serialize_into(&mut bytes).map_err(|e| { + Error::runtime_error(format!("failed to serialize overlay coverage bitmap: {e}")) + })?; + Ok(bytes) +} + +impl IntoJava for &DataOverlayFile { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let data_file = self.data_file.into_java(env)?; + let coverage = match &self.coverage { + OverlayCoverage::Shared(bitmap) => { + let bytes = serialize_bitmap(bitmap)?; + let bytes = env.byte_array_from_slice(&bytes)?; + env.call_static_method( + "org/lance/operation/DataOverlay$OverlayCoverage", + "shared", + "([B)Lorg/lance/operation/DataOverlay$OverlayCoverage;", + &[JValue::Object(&bytes)], + )? + .l()? + } + OverlayCoverage::PerField(bitmaps) => { + let list = env.new_object("java/util/ArrayList", "()V", &[])?; + for bitmap in bitmaps { + let bytes = serialize_bitmap(bitmap)?; + let bytes = env.byte_array_from_slice(&bytes)?; + env.call_method( + &list, + "add", + "(Ljava/lang/Object;)Z", + &[JValue::Object(&bytes)], + )?; + } + env.call_static_method( + "org/lance/operation/DataOverlay$OverlayCoverage", + "perField", + "(Ljava/util/List;)Lorg/lance/operation/DataOverlay$OverlayCoverage;", + &[JValue::Object(&list)], + )? + .l()? + } + }; + Ok(env.new_object( + "org/lance/operation/DataOverlay$DataOverlayFile", + "(Lorg/lance/fragment/DataFile;Lorg/lance/operation/DataOverlay$OverlayCoverage;J)V", + &[ + JValue::Object(&data_file), + JValue::Object(&coverage), + JValue::Long(u64_to_jlong( + "dataOverlay.committedVersion", + self.committed_version, + )?), + ], + )?) + } +} + +impl FromJObjectWithEnv for JObject<'_> { + fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { + let data_file: DataFile = env + .call_method(self, "getDataFile", "()Lorg/lance/fragment/DataFile;", &[])? + .l()? + .extract_object(env)?; + let coverage = env + .call_method( + self, + "getCoverage", + "()Lorg/lance/operation/DataOverlay$OverlayCoverage;", + &[], + )? + .l()?; + let is_shared = env.call_method(&coverage, "isShared", "()Z", &[])?.z()?; + let bitmap_bytes: Vec> = + import_vec_from_method(env, &coverage, "getBitmaps", |env, bytes| { + env.convert_byte_array(JByteArray::from(bytes)) + .map_err(Into::into) + })?; + let mut bitmaps = Vec::with_capacity(bitmap_bytes.len()); + for (position, bytes) in bitmap_bytes.into_iter().enumerate() { + bitmaps.push( + RoaringBitmap::deserialize_from(bytes.as_slice()).map_err(|e| { + Error::input_error(format!( + "invalid overlay coverage RoaringBitmap at position {position}: {e}" + )) + })?, + ); + } + let coverage = if is_shared { + let [bitmap]: [RoaringBitmap; 1] = bitmaps.try_into().map_err(|bitmaps: Vec<_>| { + Error::input_error(format!( + "shared overlay coverage requires exactly one bitmap, got {}", + bitmaps.len() + )) + })?; + OverlayCoverage::dense(bitmap) + } else { + if bitmaps.len() != data_file.fields.len() { + return Err(Error::input_error(format!( + "per-field overlay coverage for {} has {} bitmaps but the data file has {} fields", + data_file.path, + bitmaps.len(), + data_file.fields.len() + ))); + } + OverlayCoverage::sparse(bitmaps) + }; + Ok(DataOverlayFile { + data_file, + coverage, + committed_version: nonnegative_jlong_to_u64( + "dataOverlay.committedVersion", + env.call_method(self, "getCommittedVersion", "()J", &[])? + .j()?, + )?, + }) + } +} + +impl IntoJava for &DataOverlayGroup { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let overlays = export_vec(env, &self.overlays)?; + Ok(env.new_object( + "org/lance/operation/DataOverlay$DataOverlayGroup", + "(JLjava/util/List;)V", + &[ + JValue::Long(u64_to_jlong("dataOverlay.fragmentId", self.fragment_id)?), + JValue::Object(&overlays), + ], + )?) + } +} + +impl FromJObjectWithEnv for JObject<'_> { + fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { + Ok(DataOverlayGroup { + fragment_id: nonnegative_jlong_to_u64( + "dataOverlay.fragmentId", + env.call_method(self, "getFragmentId", "()J", &[])?.j()?, + )?, + overlays: import_vec_from_method(env, self, "getOverlays", |env, overlay| { + overlay.extract_object(env) + })?, }) } } @@ -156,17 +637,31 @@ impl FromJObjectWithEnv for JObject<'_> { let fields: Vec = import_vec_from_method(env, self, "fields", |env, field_id| { field_id.extract_object(env) })?; + let covering_fields: Vec = + import_vec_from_method(env, self, "coveringFields", |env, field_id| { + field_id.extract_object(env) + })?; let name = env.get_string_from_method(self, "name")?; - let dataset_version = env.get_field(self, "datasetVersion", "J")?.j()? as u64; + let dataset_version = nonnegative_jlong_to_u64( + "index.datasetVersion", + env.get_field(self, "datasetVersion", "J")?.j()?, + )?; let fragment_bitmap: Option = env.get_optional_from_method(self, "fragments", |env, fragments_obj| { let frag_ids = env.get_integers(&fragments_obj)?; let bitmap = frag_ids .iter() - .map(|val| *val as u32) - .collect::(); + .enumerate() + .map(|(index, value)| { + u32::try_from(*value).map_err(|_| { + Error::input_error(format!( + "index.fragments[{index}] must be non-negative, got {value}" + )) + }) + }) + .collect::>()?; Ok(bitmap) })?; @@ -189,13 +684,51 @@ impl FromJObjectWithEnv for JObject<'_> { let nanos = env .call_method(&created_at_obj, "getNano", "()I", &[])? .i()? as u32; - Ok(DateTime::from_timestamp(seconds, nanos).unwrap()) + DateTime::from_timestamp(seconds, nanos).ok_or_else(|| { + Error::input_error(format!( + "Invalid index createdAt timestamp: seconds={seconds}, nanos={nanos}" + )) + }) })?; let base_id = env.get_optional_u32_from_method(self, "baseId")?; + let files: Option> = + env.get_optional_from_method(self, "getFiles", |env, files| { + crate::traits::import_vec_to_rust(env, &files, |env, file| file.extract_object(env)) + })?; + let files_size = files + .as_ref() + .map(|files| { + files.iter().try_fold(0_u64, |total, file| { + total.checked_add(file.size_bytes).ok_or_else(|| { + Error::input_error("index file sizes overflow u64".to_string()) + }) + }) + }) + .transpose()?; + if let Some(files_size) = files_size { + i64::try_from(files_size).map_err(|_| { + Error::input_error(format!( + "index file sizes total {files_size}, which cannot be represented by Java long" + )) + })?; + } + if let Some(size_bytes) = env.get_optional_u64_from_method(self, "getSizeBytes")? { + let files_size = files_size.ok_or_else(|| { + Error::input_error(format!( + "index sizeBytes={size_bytes} cannot be represented without files" + )) + })?; + if size_bytes != files_size { + return Err(Error::input_error(format!( + "index sizeBytes={size_bytes} does not match the sum of file sizes ({files_size})" + ))); + } + } Ok(IndexMetadata { uuid, fields, + covering_fields, name, dataset_version, fragment_bitmap, @@ -203,13 +736,17 @@ impl FromJObjectWithEnv for JObject<'_> { index_version, created_at, base_id, + files, }) } } impl FromJObjectWithEnv for JObject<'_> { fn extract_object(&self, env: &mut JNIEnv<'_>) -> Result { - let fragment_id = env.call_method(self, "fragmentId", "()J", &[])?.j()? as u64; + let fragment_id = nonnegative_jlong_to_u64( + "dataReplacement.fragmentId", + env.call_method(self, "fragmentId", "()J", &[])?.j()?, + )?; let new_file = env .call_method(self, "replacedFile", "()Lorg/lance/fragment/DataFile;", &[])? .l()? @@ -225,10 +762,12 @@ impl FromJObjectWithEnv for JObject<'_> { .call_method(self, "toString", "()Ljava/lang/String;", &[])? .l()?; let s: String = env.get_string(&JString::from(s))?.into(); - let t = if s == "RewriteRows" { - UpdateMode::RewriteRows - } else { - UpdateMode::RewriteColumns + let t = match s.as_str() { + "RewriteRows" => UpdateMode::RewriteRows, + "RewriteColumns" => UpdateMode::RewriteColumns, + _ => { + return Err(Error::input_error(format!("Unknown UpdateMode value: {s}"))); + } }; Ok(t) } @@ -267,7 +806,7 @@ impl FromJObjectWithEnv for JObject<'_> { } } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_Dataset_nativeReadTransaction<'local>( mut env: JNIEnv<'local>, java_dataset: JObject, @@ -286,7 +825,7 @@ fn inner_read_transaction<'local>( }; let transaction = match transaction { - Some(transaction) => convert_to_java_transaction(env, transaction, &java_dataset)?, + Some(transaction) => convert_to_java_transaction(env, transaction)?, None => JObject::null(), }; Ok(transaction) @@ -295,9 +834,12 @@ fn inner_read_transaction<'local>( pub(crate) fn convert_to_java_transaction<'local>( env: &mut JNIEnv<'local>, transaction: Transaction, - java_dataset: &JObject, ) -> Result> { let uuid = env.new_string(transaction.uuid)?; + let tag = match transaction.tag { + Some(tag) => JObject::from(env.new_string(tag)?), + None => JObject::null(), + }; let transaction_properties = match transaction.transaction_properties { Some(properties) => to_java_map(env, &properties)?, _ => JObject::null(), @@ -306,13 +848,15 @@ pub(crate) fn convert_to_java_transaction<'local>( let java_transaction = env.new_object( "org/lance/Transaction", - "(Lorg/lance/Dataset;JLjava/lang/String;Lorg/lance/operation/Operation;Ljava/util/Map;Ljava/util/Map;)V", + "(JLjava/lang/String;Lorg/lance/operation/Operation;Ljava/lang/String;Ljava/util/Map;)V", &[ - JValue::Object(java_dataset), - JValue::Long(transaction.read_version as i64), + JValue::Long(u64_to_jlong( + "transaction.readVersion", + transaction.read_version, + )?), JValue::Object(&uuid), JValue::Object(&operation), - JValue::Object(&JObject::null()), + JValue::Object(&tag), JValue::Object(&transaction_properties), ], )?; @@ -353,11 +897,8 @@ fn convert_to_java_operation_inner<'local>( } => { let updated_fragments_obj = export_vec(env, &updated_fragments)?; - let deleted_ids: Vec> = deleted_fragment_ids - .iter() - .map(|x| JLance(*x as i64)) - .collect(); - let removed_fragment_ids_obj = export_vec(env, &deleted_ids)?; + let removed_fragment_ids_obj = + export_unsigned_longs(env, &deleted_fragment_ids, "delete.deletedFragmentIds")?; let predicate_obj = env.new_string(&predicate)?; @@ -375,7 +916,7 @@ fn convert_to_java_operation_inner<'local>( fragments: rust_fragments, schema, config_upsert_values, - initial_bases: _, + initial_bases, } => { let java_fragments = export_vec(env, &rust_fragments)?; let java_schema = convert_to_java_schema(env, schema)?; @@ -383,14 +924,19 @@ fn convert_to_java_operation_inner<'local>( Some(config_upsert_values) => to_java_map(env, &config_upsert_values)?, _ => JObject::null(), }; + let java_initial_bases = match initial_bases { + Some(initial_bases) => export_vec(env, &initial_bases)?, + None => JObject::null(), + }; Ok(env.new_object( "org/lance/operation/Overwrite", - "(Ljava/util/List;Lorg/apache/arrow/vector/types/pojo/Schema;Ljava/util/Map;)V", + "(Ljava/util/List;Lorg/apache/arrow/vector/types/pojo/Schema;Ljava/util/Map;Ljava/util/List;)V", &[ JValue::Object(&java_fragments), JValue::Object(&java_schema), JValue::Object(&java_config), + JValue::Object(&java_initial_bases), ], )?) } @@ -415,16 +961,14 @@ fn convert_to_java_operation_inner<'local>( updated_fragments, new_fragments, fields_modified, - merged_generations: _, + compacted_sstables, fields_for_preserving_frag_bitmap, update_mode, - inserted_rows_filter: _, + inserted_rows_filter, + updated_fragment_offsets, } => { - let removed_ids: Vec> = removed_fragment_ids - .iter() - .map(|x| JLance(*x as i64)) - .collect(); - let removed_fragment_ids_obj = export_vec(env, &removed_ids)?; + let removed_fragment_ids_obj = + export_unsigned_longs(env, &removed_fragment_ids, "update.removedFragmentIds")?; let updated_fragments_obj = export_vec(env, &updated_fragments)?; let new_fragments_obj = export_vec(env, &new_fragments)?; let fields_modified = JLance(fields_modified.clone()).into_java(env)?; @@ -442,9 +986,56 @@ fn convert_to_java_operation_inner<'local>( &[JValue::Object(&update_mode)], )? .l()?; + let compacted_sstables = export_compacted_sstables(env, &compacted_sstables)?; + let inserted_rows_filter = match inserted_rows_filter { + Some(filter) => key_existence_filter_into_java(env, &filter)?, + None => JObject::null(), + }; + // Serialize updated_fragment_offsets to Java Map. + // Values are portable RoaringBitmap bytes so the JNI boundary stays O(bitmap size) + // rather than O(n rows). Empty HashMap when None so the Java constructor always + // receives a non-null map. + let java_offsets_map = { + let java_map = env.new_object("java/util/HashMap", "()V", &[])?; + if let Some(UpdatedFragmentOffsets(ref map)) = updated_fragment_offsets { + for (frag_id, bitmap) in map { + let mut buf: Vec = Vec::new(); + bitmap.serialize_into(&mut buf).map_err(|e| { + Error::runtime_error(format!( + "failed to serialize updatedFragmentOffsets for fragment \ + {frag_id}: {e}" + )) + })?; + // JNI byte arrays are signed i8; reinterpret without copying. + let buf_i8: &[i8] = unsafe { + std::slice::from_raw_parts(buf.as_ptr() as *const i8, buf.len()) + }; + env.with_local_frame(4, |env| { + let java_key = env.new_object( + "java/lang/Long", + "(J)V", + &[JValue::Long(u64_to_jlong( + "update.updatedFragmentOffsets.fragmentId", + *frag_id, + )?)], + )?; + let java_arr = env.new_byte_array(buf_i8.len() as i32)?; + env.set_byte_array_region(&java_arr, 0, buf_i8)?; + env.call_method( + &java_map, + "put", + "(Ljava/lang/Object;Ljava/lang/Object;)Ljava/lang/Object;", + &[JValue::Object(&java_key), JValue::Object(&*java_arr)], + )?; + Ok::(JObject::null()) + })?; + } + } + java_map + }; Ok(env.new_object( "org/lance/operation/Update", - "(Ljava/util/List;Ljava/util/List;Ljava/util/List;[J[JLjava/util/Optional;)V", + "(Ljava/util/List;Ljava/util/List;Ljava/util/List;[J[JLjava/util/Optional;Ljava/util/Map;Ljava/util/List;Lorg/lance/operation/KeyExistenceFilter;)V", &[ JValue::Object(&removed_fragment_ids_obj), JValue::Object(&updated_fragments_obj), @@ -452,16 +1043,25 @@ fn convert_to_java_operation_inner<'local>( JValueGen::Object(&fields_modified), JValueGen::Object(&fields_for_preserving_frag_bitmap), JValue::Object(&update_mode_optional), + JValue::Object(&java_offsets_map), + JValue::Object(&compacted_sstables), + JValue::Object(&inserted_rows_filter), ], )?) } - Operation::Project { schema } => { + Operation::Project { + schema, + preserves_nullability, + } => { let java_schema = convert_to_java_schema(env, schema)?; Ok(env.new_object( "org/lance/operation/Project", - "(Lorg/apache/arrow/vector/types/pojo/Schema;)V", - &[JValue::Object(&java_schema)], + "(Lorg/apache/arrow/vector/types/pojo/Schema;Z)V", + &[ + JValue::Object(&java_schema), + JValue::Bool(preserves_nullability as u8), + ], )?) } Operation::Rewrite { @@ -534,33 +1134,92 @@ fn convert_to_java_operation_inner<'local>( &[JValue::Object(&java_replacements)], )?) } + Operation::DataOverlay { groups } => { + let groups = export_vec(env, &groups)?; + Ok(env.new_object( + "org/lance/operation/DataOverlay", + "(Ljava/util/List;)V", + &[JValue::Object(&groups)], + )?) + } Operation::Merge { fragments: rust_fragments, schema, + preserves_nullability, } => { let java_fragments = export_vec(env, &rust_fragments)?; let java_schema = convert_to_java_schema(env, schema)?; Ok(env.new_object( "org/lance/operation/Merge", - "(Ljava/util/List;Lorg/apache/arrow/vector/types/pojo/Schema;)V", + "(Ljava/util/List;Lorg/apache/arrow/vector/types/pojo/Schema;Z)V", &[ JValue::Object(&java_fragments), JValue::Object(&java_schema), + JValue::Bool(preserves_nullability as u8), ], )?) } Operation::Restore { version } => Ok(env.new_object( "org/lance/operation/Restore", "(J)V", - &[JValue::Long(version as i64)], + &[JValue::Long(u64_to_jlong("restore.version", version)?)], )?), Operation::ReserveFragments { num_fragments } => Ok(env.new_object( "org/lance/operation/ReserveFragments", "(I)V", - &[JValue::Int(num_fragments as i32)], + &[JValue::Int(u32_to_jint( + "reserveFragments.numFragments", + num_fragments, + )?)], )?), - _ => unimplemented!(), + Operation::UpdateMemWalState { compacted_sstables } => { + let compacted_sstables = export_compacted_sstables(env, &compacted_sstables)?; + Ok(env.new_object( + "org/lance/operation/UpdateMemWalState", + "(Ljava/util/List;)V", + &[JValue::Object(&compacted_sstables)], + )?) + } + Operation::Clone { + is_shallow, + ref_name, + ref_version, + ref_path, + branch_name, + } => { + let ref_name = match ref_name { + Some(ref_name) => env.new_string(ref_name)?.into(), + None => JObject::null(), + }; + let ref_path = env.new_string(ref_path)?; + let branch_name = match branch_name { + Some(branch_name) => env.new_string(branch_name)?.into(), + None => JObject::null(), + }; + Ok(env.new_object( + "org/lance/operation/Clone", + "(ZLjava/lang/String;JLjava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Bool(is_shallow as u8), + JValue::Object(&ref_name), + JValue::Long(u64_to_jlong("clone.refVersion", ref_version)?), + JValue::Object(&ref_path), + JValue::Object(&branch_name), + ], + )?) + } + Operation::UpdateBases { new_bases } => { + let new_bases = export_vec(env, &new_bases)?; + Ok(env.new_object( + "org/lance/operation/UpdateBases", + "(Ljava/util/List;)V", + &[JValue::Object(&new_bases)], + )?) + } + Operation::Unknown { .. } => Err(Error::input_error( + "Cannot convert an operation written by a newer version of Lance".to_string(), + )), } } @@ -579,38 +1238,135 @@ pub(crate) fn convert_to_java_schema<'local>( .l()?) } -#[no_mangle] -pub extern "system" fn Java_org_lance_Dataset_nativeCommitTransaction<'local>( +/// Parse a `CommitBuilder.storageFormat` string into a [`LanceFileVersion`]. +/// +/// The canonical spellings ("2.1", "stable", ...) are the ones every other Lance +/// binding accepts and the ones [`LanceFileVersion`]'s `Display` emits. +/// +/// The `v`-prefixed spellings are a Java-only accident: this function originally +/// hand-rolled its match by walking the `LanceFileVersion` variant identifiers +/// (`V2_1` -> `"v2_1"`) instead of delegating to `FromStr`, so it accepted those +/// identifiers and rejected the canonical "2.1". They were documented on +/// `CommitBuilder.storageFormat` and shipped from 3.0.0, so they are translated +/// here for compatibility. The set is deliberately frozen to what shipped — +/// newer versions are reachable only by their canonical name. +fn parse_storage_format(name: &str) -> Result { + let requested = name.to_lowercase(); + let canonical = match requested.as_str() { + "v2_0" | "v2.0" => V2_FORMAT_2_0, + "v2_1" | "v2.1" => V2_FORMAT_2_1, + "v2_2" | "v2.2" => V2_FORMAT_2_2, + _ => requested.as_str(), + }; + + if canonical != requested { + log::warn!( + "Storage format \"{}\" is deprecated and will be removed in a future release; use \"{}\" instead", + name, + canonical + ); + } + + canonical + .parse::() + .map_err(|_| Error::input_error(format!("Unknown storage format: {}", name))) +} + +/// Translate the Java `commitTimeoutNanos` sentinel into an +/// `Option` for [`CommitBuilder::with_timeout`]. The Java side is +/// the source of truth for the default (30 minutes) and for rejecting +/// zero/negative-from-the-user inputs; here `< 0` simply means "disabled" and +/// any other value is the timeout in nanoseconds. +fn parse_commit_timeout(nanos: i64) -> Option { + if nanos < 0 { + None + } else { + Some(std::time::Duration::from_nanos(nanos as u64)) + } +} + +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToDataset<'local>( mut env: JNIEnv<'local>, + _cls: JObject, java_dataset: JObject, java_transaction: JObject, detached_jbool: jboolean, enable_v2_manifest_paths: jboolean, + write_params_obj: JObject, + use_stable_row_ids_obj: JObject, + storage_format_obj: JObject, + max_retries: jint, + skip_auto_cleanup: jboolean, + namespace_obj: JObject, + table_id_obj: JObject, + namespace_client_managed_versioning: jboolean, + commit_timeout_nanos: jlong, ) -> JObject<'local> { ok_or_throw!( env, - inner_commit_transaction( + inner_commit_to_dataset( &mut env, java_dataset, java_transaction, detached_jbool != 0, enable_v2_manifest_paths != 0, + write_params_obj, + use_stable_row_ids_obj, + storage_format_obj, + max_retries as u32, + skip_auto_cleanup != 0, + namespace_obj, + table_id_obj, + namespace_client_managed_versioning != 0, + commit_timeout_nanos, ) ) } -fn inner_commit_transaction<'local>( +#[allow(clippy::too_many_arguments)] +fn inner_commit_to_dataset<'local>( env: &mut JNIEnv<'local>, java_dataset: JObject, java_transaction: JObject, detached: bool, enable_v2_manifest_paths: bool, + write_params_obj: JObject, + use_stable_row_ids_obj: JObject, + storage_format_obj: JObject, + max_retries: u32, + skip_auto_cleanup: bool, + namespace_obj: JObject, + table_id_obj: JObject, + namespace_client_managed_versioning: bool, + commit_timeout_nanos: jlong, ) -> Result> { - let write_param_jobj = env - .call_method(&java_transaction, "writeParams", "()Ljava/util/Map;", &[])? - .l()?; - let write_param_jmap = JMap::from_env(env, &write_param_jobj)?; - let write_param = to_rust_map(env, &write_param_jmap)?; + let commit_timeout = parse_commit_timeout(commit_timeout_nanos); + let write_param = if write_params_obj.is_null() { + HashMap::new() + } else { + let write_param_jmap = JMap::from_env(env, &write_params_obj)?; + to_rust_map(env, &write_param_jmap)? + }; + + // Parse optional use_stable_row_ids (boxed Boolean) + let use_stable_row_ids = if use_stable_row_ids_obj.is_null() { + None + } else { + let val = env + .call_method(&use_stable_row_ids_obj, "booleanValue", "()Z", &[])? + .z()?; + Some(val) + }; + + // Parse optional storage format string + let storage_format = if storage_format_obj.is_null() { + None + } else { + let format_str: String = JString::from(storage_format_obj).extract(env)?; + Some(parse_storage_format(&format_str)?) + }; // Get the Dataset's storage_options_accessor and merge with write_param let storage_options_accessor = { @@ -656,7 +1412,47 @@ fn inner_commit_transaction<'local>( ..Default::default() }; - let transaction = convert_to_rust_transaction(env, java_transaction, Some(&java_dataset))?; + let java_allocator = env + .call_method( + &java_dataset, + "allocator", + "()Lorg/apache/arrow/memory/BufferAllocator;", + &[], + )? + .l()?; + + // BlockingDataset from java dataset. + let mut java_blocking_ds = { + let dataset_guard = + unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; + BlockingDataset::new(dataset_guard.inner.clone()) + }; + let transaction = convert_to_rust_transaction( + env, + java_transaction, + Some(&java_allocator), + Some(&mut java_blocking_ds), + )?; + + // 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 = if namespace_client_managed_versioning { + match namespace_info { + Some((ns, tid)) => { + // The store derives the branch a request targets from the base + // path it is handed, resolved against the table root. + let table_root = java_blocking_ds.inner.branch_location().find_main()?.path; + let external_store = LanceNamespaceExternalManifestStore::new(ns, tid, table_root); + Some(Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }) as Arc) + } + None => None, + } + } else { + None + }; + let new_blocking_ds = { let mut dataset_guard = unsafe { env.get_rust_field::<_, _, BlockingDataset>(&java_dataset, NATIVE_DATASET) }?; @@ -665,6 +1461,12 @@ fn inner_commit_transaction<'local>( store_params, detached, enable_v2_manifest_paths, + use_stable_row_ids, + storage_format, + max_retries, + skip_auto_cleanup, + commit_handler, + commit_timeout, )? }; new_blocking_ds.into_java(env) @@ -673,9 +1475,14 @@ fn inner_commit_transaction<'local>( fn convert_to_rust_transaction( env: &mut JNIEnv, java_transaction: JObject, - java_dataset: Option<&JObject>, + allocator: Option<&JObject>, + dataset: Option<&mut BlockingDataset>, ) -> Result { - let read_ver = env.get_u64_from_method(&java_transaction, "readVersion")?; + let read_ver = nonnegative_jlong_to_u64( + "transaction.readVersion", + env.call_method(&java_transaction, "readVersion", "()J", &[])? + .j()?, + )?; let uuid = env.get_string_from_method(&java_transaction, "uuid")?; let op = env .call_method( @@ -685,7 +1492,12 @@ fn convert_to_rust_transaction( &[], )? .l()?; - let op = convert_to_rust_operation(env, &op, java_dataset)?; + let op = convert_to_rust_operation(env, &op, allocator, dataset, read_ver)?; + + let tag = env.get_optional_from_method(&java_transaction, "tag", |env, tag_obj| { + let tag_str = JString::from(tag_obj); + tag_str.extract(env) + })?; let transaction_properties = env.get_optional_from_method( &java_transaction, @@ -697,6 +1509,7 @@ fn convert_to_rust_transaction( )?; Ok(TransactionBuilder::new(read_ver, op) .uuid(uuid) + .tag(tag) .transaction_properties(transaction_properties.map(Arc::new)) .build()) } @@ -704,42 +1517,194 @@ fn convert_to_rust_transaction( fn convert_schema_from_operation( env: &mut JNIEnv, java_operation: &JObject, - java_dataset: &JObject, + java_allocator: &JObject, + dataset: Option<&mut BlockingDataset>, + read_version: u64, ) -> Result { - let java_buffer_allocator = env - .call_method( - java_dataset, - "allocator", - "()Lorg/apache/arrow/memory/BufferAllocator;", - &[], - )? - .l()?; let schema_ptr = env .call_method( java_operation, "exportSchema", "(Lorg/apache/arrow/memory/BufferAllocator;)J", - &[JValue::Object(&java_buffer_allocator)], + &[JValue::Object(java_allocator)], )? .j()?; let c_schema_ptr = schema_ptr as *mut FFI_ArrowSchema; let c_schema = unsafe { FFI_ArrowSchema::from_raw(c_schema_ptr) }; - let schema = Schema::try_from(&c_schema)?; - Ok( - LanceSchema::try_from(&schema) - .expect("Failed to convert from arrow schema to lance schema"), - ) + + if let Some(dataset) = dataset { + let arrow_schema = Schema::try_from(&c_schema)?; + + // Derive field ids based on the transaction read dataset schema. + let read_schema = { + if dataset.inner.version().version == read_version { + dataset.inner.schema().clone() + } else { + let read_dataset = dataset.checkout_version(read_version)?; + read_dataset.inner.schema().clone() + } + }; + + let max_field_id = dataset.inner.manifest().max_field_id(); + let schema = + LanceSchema::from_arrow_schema(&arrow_schema, Some(read_schema), Some(max_field_id))?; + Ok(schema) + } else { + let schema = Schema::try_from(&c_schema)?; + LanceSchema::try_from(&schema).map_err(|e| { + Error::input_error(format!( + "Failed to convert Arrow schema to Lance schema: {}", + e + )) + }) + } +} + +trait SchemaExt { + /// Walk through the fields and assign a new field id to each field that does not have one + /// (e.g. is set to -1) + /// + /// If this schema is on an existing dataset, pass the schema of the dataset to `base_schema` + /// and the result of `Manifest::max_field_id` to `max_existing_id`. + /// + /// If this schema is not associated with a dataset, pass `None` to `base_schema` and + /// `max_existing_id`. + /// + /// The rule of assigning id is: + /// 1. If a lance field with same name exists in `base_schema` (including nested field), id is + /// derived from the field. + /// 2. Otherwise, set field id based on max id, which is computed from `max_existing_id`, + /// `base_schema` max id and self max id. + fn set_field_id_from_schema( + &mut self, + base_schema: Option, + max_existing_id: Option, + ) -> Result<()>; + + /// Create schema from `arrow_schema`, with field id priority below: + /// 1. arrow metadata field id. + /// 2. field id from `base_schema`. + /// 3. field id from `max_existing_id`. + fn from_arrow_schema( + arrow_schema: &Schema, + base_schema: Option, + max_existing_id: Option, + ) -> Result; +} + +impl SchemaExt for LanceSchema { + fn set_field_id_from_schema( + &mut self, + base_schema: Option, + max_existing_id: Option, + ) -> Result<()> { + // Set id from base_schema + if let Some(base_schema) = &base_schema { + for field in self.fields.iter_mut() { + if let Some(base_field) = base_schema.field(&field.name) { + field.set_field_id_from_field(-1, base_field)?; + } + } + }; + + // Set id from max_id + let max_id = base_schema + .map(|s| s.max_field_id().unwrap_or(-1)) + .unwrap_or(-1); + let max_id = max_id.max(max_existing_id.unwrap_or(-1)); + self.set_field_id(Some(max_id)); + Ok(()) + } + + fn from_arrow_schema( + arrow_schema: &Schema, + base_schema: Option, + max_existing_id: Option, + ) -> Result { + let mut schema = Self { + fields: arrow_schema + .fields + .iter() + .map(|f| Field::try_from(f.as_ref())) + .collect::>()?, + metadata: arrow_schema.metadata.clone(), + }; + schema.set_field_id_from_schema(base_schema, max_existing_id)?; + schema.validate()?; + schema.verify_primary_key()?; + + Ok(schema) + } +} + +trait FieldExt { + /// Recursively set field ID and parent ID for this field and all its children. + fn set_field_id_from_field( + &mut self, + parent_id: i32, + base_field: &Field, + ) -> lance_core::Result<()>; +} + +impl FieldExt for Field { + fn set_field_id_from_field( + &mut self, + parent_id: i32, + base_field: &Field, + ) -> lance_core::Result<()> { + self.parent_id = parent_id; + + if self.name != base_field.name { + return Ok(()); + } + + if self.logical_type != base_field.logical_type { + return Err(lance_core::Error::invalid_input_source( + format!( + "Expecting logical type {} but got {} for field {}", + base_field.logical_type, self.logical_type, self.name + ) + .into(), + )); + } + + if self.id < 0 { + // use id from base + self.id = base_field.id; + } + + for child in &mut self.children { + if let Some(base_child) = base_field.children.iter().find(|f| f.name == child.name) { + child.set_field_id_from_field(self.id, base_child)?; + } + } + Ok(()) + } } fn convert_to_rust_operation( env: &mut JNIEnv<'_>, java_operation: &JObject<'_>, - java_dataset: Option<&JObject<'_>>, + allocator: Option<&JObject<'_>>, + dataset: Option<&mut BlockingDataset>, + read_version: u64, ) -> Result { let op_name = env.get_string_from_method(java_operation, "name")?; let op = match op_name.as_str() { "Project" => Operation::Project { - schema: convert_schema_from_operation(env, java_operation, java_dataset.unwrap())?, + preserves_nullability: env + .get_boolean_from_method(java_operation, "preservesNullability")?, + schema: convert_schema_from_operation( + env, + java_operation, + allocator.ok_or_else(|| { + Error::input_error( + "BufferAllocator is required for Project operations".to_string(), + ) + })?, + dataset, + read_version, + )?, }, "UpdateConfig" => { let config_updates_obj = env @@ -829,13 +1794,11 @@ fn convert_to_rust_operation( |env, fragment| fragment.extract_object(env), )?; - let deleted_fragment_ids: Vec = import_vec_from_method( + let deleted_fragment_ids = import_unsigned_longs( env, java_operation, "deletedFragmentIds", - |env, fragment_id| { - Ok(env.call_method(fragment_id, "longValue", "()J", &[])?.j()? as u64) - }, + "delete.deletedFragmentIds", )?; let predicate = env.get_string_from_method(java_operation, "predicate")?; @@ -860,12 +1823,31 @@ fn convert_to_rust_operation( to_rust_map(env, &config_upsert_values) }, )?; - let schema = convert_schema_from_operation(env, java_operation, java_dataset.unwrap())?; + let initial_bases = + env.get_optional_from_method(java_operation, "getInitialBases", |env, bases| { + crate::traits::import_vec_to_rust(env, &bases, |env, base| { + base.extract_object(env) + }) + })?; + // 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, + allocator.ok_or_else(|| { + Error::input_error( + "BufferAllocator is required for Overwrite operations".to_string(), + ) + })?, + None, + read_version, + )?; Operation::Overwrite { fragments, schema, config_upsert_values, - initial_bases: None, + initial_bases, } } "Rewrite" => { @@ -892,13 +1874,11 @@ fn convert_to_rust_operation( } } "Update" => { - let removed_fragment_ids = import_vec_from_method( + let removed_fragment_ids = import_unsigned_longs( env, java_operation, "removedFragmentIds", - |env, fragment_id| { - Ok(env.call_method(fragment_id, "longValue", "()J", &[])?.j()? as u64) - }, + "update.removedFragmentIds", )?; let updated_fragments: Vec = import_vec_from_method( @@ -913,31 +1893,107 @@ fn convert_to_rust_operation( fragment.extract_object(env) })?; - let fields_modified = env - .call_method(java_operation, "fieldsModified", "()[J", &[])? - .l()?; - let fields_modified = JLongArray::from(fields_modified).extract_object(env)?; - - let fields_for_preserving_frag_bitmap = env - .call_method(java_operation, "fieldsForPreservingFragBitmap", "()[J", &[])? - .l()?; - let fields_for_preserving_frag_bitmap = - JLongArray::from(fields_for_preserving_frag_bitmap).extract_object(env)?; + let fields_modified = import_field_ids( + env, + java_operation, + "fieldsModified", + "update.fieldsModified", + )?; + let fields_for_preserving_frag_bitmap = import_field_ids( + env, + java_operation, + "fieldsForPreservingFragBitmap", + "update.fieldsForPreservingFragBitmap", + )?; let update_mode: Option = env.get_optional_from_method(java_operation, "updateMode", |env, update_mode| { update_mode.extract_object(env) })?; + if update_mode.is_none() { + return Err(Error::input_error( + "update.updateMode must be specified because the transaction format cannot \ + persist an absent update mode distinctly from RewriteRows" + .to_string(), + )); + } + let compacted_sstables = import_vec_from_method( + env, + java_operation, + "getCompactedSstables", + |env, sstable| compacted_sstable_from_java(env, &sstable), + )?; + let inserted_rows_filter = env.get_optional_from_method( + java_operation, + "getInsertedRowsFilter", + |env, filter| key_existence_filter_from_java(env, &filter), + )?; + + let updated_fragment_offsets = { + let offsets_obj = env + .call_method( + java_operation, + "updatedFragmentOffsets", + "()Ljava/util/Map;", + &[], + )? + .l()?; + if offsets_obj.is_null() { + None + } else { + let jmap = JMap::from_env(env, &offsets_obj)?; + let mut iter = jmap.iter(env)?; + let mut offsets: HashMap = HashMap::new(); + // Per-iteration local frame: iterator key/value JNI refs are released each + // loop so large multi-fragment maps cannot exhaust the local reference table. + loop { + let entry = env.with_local_frame( + 8, + |env| -> Result> { + let Some((key, value)) = iter.next(env)? else { + return Ok(None); + }; + let frag_id = nonnegative_jlong_to_u64( + "update.updatedFragmentOffsets.fragmentId", + env.call_method(&key, "longValue", "()J", &[])?.j()?, + )?; + let buf: Vec = + env.convert_byte_array(JByteArray::from(value))?; + let bitmap = RoaringBitmap::deserialize_from(buf.as_slice()) + .map_err(|e| { + Error::input_error(format!( + "invalid updatedFragmentOffsets RoaringBitmap bytes \ + for fragment {frag_id}: {e}" + )) + })?; + Ok(Some((frag_id, bitmap))) + }, + )?; + match entry { + None => break, + Some((frag_id, bitmap)) => { + offsets.insert(frag_id, bitmap); + } + } + } + if offsets.is_empty() { + None + } else { + Some(UpdatedFragmentOffsets(offsets)) + } + } + }; Operation::Update { removed_fragment_ids, updated_fragments, new_fragments, fields_modified, - merged_generations: vec![], + compacted_sstables, fields_for_preserving_frag_bitmap, update_mode, - inserted_rows_filter: None, + inserted_rows_filter, + updated_fragment_offsets, } } "DataReplacement" => { @@ -947,6 +2003,12 @@ fn convert_to_rust_operation( })?; Operation::DataReplacement { replacements } } + "DataOverlay" => { + let groups = import_vec_from_method(env, java_operation, "getGroups", |env, group| { + group.extract_object(env) + })?; + Operation::DataOverlay { groups } + } "Merge" => { let fragments: Vec = import_vec_from_method(env, java_operation, "fragments", |env, fragment| { @@ -954,19 +2016,38 @@ fn convert_to_rust_operation( })?; Operation::Merge { fragments, - schema: convert_schema_from_operation(env, java_operation, java_dataset.unwrap())?, + preserves_nullability: env + .get_boolean_from_method(java_operation, "preservesNullability")?, + schema: convert_schema_from_operation( + env, + java_operation, + allocator.ok_or_else(|| { + Error::input_error( + "BufferAllocator is required for Merge operations".to_string(), + ) + })?, + dataset, + read_version, + )?, } } "Restore" => { - let version: u64 = env - .call_method(java_operation, "version", "()J", &[])? - .j()? as u64; + let version = nonnegative_jlong_to_u64( + "restore.version", + env.call_method(java_operation, "version", "()J", &[])? + .j()?, + )?; return Ok(Operation::Restore { version }); } "ReserveFragments" => { - let num_fragments = env + let java_num_fragments = env .call_method(java_operation, "numFragments", "()I", &[])? - .i()? as u32; + .i()?; + let num_fragments = u32::try_from(java_num_fragments).map_err(|_| { + Error::input_error(format!( + "reserveFragments.numFragments must be non-negative, got {java_num_fragments}" + )) + })?; return Ok(Operation::ReserveFragments { num_fragments }); } "CreateIndex" => { @@ -983,7 +2064,38 @@ fn convert_to_rust_operation( removed_indices, }); } - _ => unimplemented!(), + "UpdateMemWalState" => { + let compacted_sstables = import_vec_from_method( + env, + java_operation, + "getCompactedSstables", + |env, sstable| compacted_sstable_from_java(env, &sstable), + )?; + Operation::UpdateMemWalState { compacted_sstables } + } + "Clone" => Operation::Clone { + is_shallow: env.get_boolean_from_method(java_operation, "isShallow")?, + ref_name: env.get_optional_string_from_method(java_operation, "getRefName")?, + ref_version: nonnegative_jlong_to_u64( + "clone.refVersion", + env.call_method(java_operation, "getRefVersion", "()J", &[])? + .j()?, + )?, + ref_path: env.get_string_from_method(java_operation, "getRefPath")?, + branch_name: env.get_optional_string_from_method(java_operation, "getBranchName")?, + }, + "UpdateBases" => { + let new_bases = + import_vec_from_method(env, java_operation, "getNewBases", |env, base| { + base.extract_object(env) + })?; + Operation::UpdateBases { new_bases } + } + _ => { + return Err(Error::input_error(format!( + "Unsupported Java transaction operation: {op_name}" + ))); + } }; Ok(op) } @@ -1068,3 +2180,552 @@ fn export_update_map<'a>( } } } + +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub extern "system" fn Java_org_lance_CommitBuilder_nativeCommitToUri<'local>( + mut env: JNIEnv<'local>, + _cls: JObject, + uri: JString, + java_transaction: JObject, + detached_jbool: jboolean, + enable_v2_manifest_paths: jboolean, + namespace_obj: JObject, + table_id_obj: JObject, + allocator_obj: JObject, + write_params_obj: JObject, + use_stable_row_ids_obj: JObject, + storage_format_obj: JObject, + max_retries: jint, + skip_auto_cleanup: jboolean, + namespace_client_managed_versioning: jboolean, + commit_timeout_nanos: jlong, +) -> JObject<'local> { + ok_or_throw!( + env, + inner_commit_to_uri( + &mut env, + uri, + java_transaction, + detached_jbool != 0, + enable_v2_manifest_paths != 0, + namespace_obj, + table_id_obj, + allocator_obj, + write_params_obj, + use_stable_row_ids_obj, + storage_format_obj, + max_retries as u32, + skip_auto_cleanup != 0, + namespace_client_managed_versioning != 0, + commit_timeout_nanos, + ) + ) +} + +#[allow(clippy::too_many_arguments)] +fn inner_commit_to_uri<'local>( + env: &mut JNIEnv<'local>, + uri: JString, + java_transaction: JObject, + detached: bool, + enable_v2_manifest_paths: bool, + namespace_obj: JObject, + table_id_obj: JObject, + allocator_obj: JObject, + write_params_obj: JObject, + use_stable_row_ids_obj: JObject, + storage_format_obj: JObject, + max_retries: u32, + skip_auto_cleanup: bool, + namespace_client_managed_versioning: bool, + commit_timeout_nanos: jlong, +) -> Result> { + let commit_timeout = parse_commit_timeout(commit_timeout_nanos); + let uri_str: String = uri.extract(env)?; + + // Extract write params from parameter + let write_param = if write_params_obj.is_null() { + HashMap::new() + } else { + let write_param_jmap = JMap::from_env(env, &write_params_obj)?; + to_rust_map(env, &write_param_jmap)? + }; + + // Parse optional use_stable_row_ids (boxed Boolean) + let use_stable_row_ids = if use_stable_row_ids_obj.is_null() { + None + } else { + let val = env + .call_method(&use_stable_row_ids_obj, "booleanValue", "()Z", &[])? + .z()?; + Some(val) + }; + + // Parse optional storage format string + let storage_format = if storage_format_obj.is_null() { + None + } else { + let format_str: String = JString::from(storage_format_obj).extract(env)?; + Some(parse_storage_format(&format_str)?) + }; + + // 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(); + + let accessor = match (write_param.is_empty(), storage_options_provider.clone()) { + (false, Some(provider)) => Some(Arc::new( + lance::io::StorageOptionsAccessor::with_initial_and_provider(write_param, provider), + )), + (false, None) => Some(Arc::new( + lance::io::StorageOptionsAccessor::with_static_options(write_param), + )), + (true, Some(provider)) => Some(Arc::new(lance::io::StorageOptionsAccessor::with_provider( + provider, + ))), + (true, None) => None, + }; + + let store_params = ObjectStoreParams { + storage_options_accessor: accessor, + ..Default::default() + }; + + let (open_namespace, open_table_id) = match &namespace_info { + Some((namespace_client, tid)) => (Some(namespace_client.clone()), Some(tid.clone())), + None => (None, None), + }; + + // Open the read dataset using the same storage options (and provider, if any) so that + // `convert_to_rust_transaction` can derive schema/field ids based on the target dataset. + let mut ds = BlockingDataset::open( + &uri_str, + None, + None, + 6 * 1024 * 1024, + 1024 * 1024, + initial_storage_options, + HashMap::new(), + None, + storage_options_provider, + None, + open_namespace, + open_table_id, + namespace_client_managed_versioning, + ) + .ok(); + + // Convert Java transaction to Rust + let allocator_ref = if allocator_obj.is_null() { + None + } else { + Some(allocator_obj) + }; + let transaction = + convert_to_rust_transaction(env, java_transaction, allocator_ref.as_ref(), ds.as_mut())?; + + // Build CommitBuilder with URI + let mut builder = CommitBuilder::new(&*uri_str) + .with_store_params(store_params) + .with_detached(detached) + .enable_v2_manifest_paths(enable_v2_manifest_paths) + .with_timeout(commit_timeout); + + if let Some(use_stable) = use_stable_row_ids { + builder = builder.use_stable_row_ids(use_stable); + } + if let Some(format) = storage_format { + builder = builder.with_storage_format(format); + } + if max_retries > 0 { + builder = builder.with_max_retries(max_retries); + } + if skip_auto_cleanup { + builder = builder.with_skip_auto_cleanup(true); + } + + // 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::for_table_uri(namespace_client, tid, &uri_str)?; + let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + builder = builder.with_commit_handler(commit_handler); + } + + let dataset = block_on(builder.execute(transaction))?; + let blocking_ds = BlockingDataset { inner: dataset }; + blocking_ds.into_java(env) +} + +#[cfg(test)] +mod tests { + use arrow_schema::{ + DataType as ArrowDataType, Field as ArrowField, Fields as ArrowFields, + Schema as ArrowSchema, + }; + use std::{collections::HashMap, sync::Arc}; + + use super::*; + + pub const LANCE_FIELD_ID_KEY: &str = "lance:field_id"; + + #[test] + fn test_create_schema_from_arrow() { + // base_schema has an existing field id + let mut base_a = Field::new_arrow("a", ArrowDataType::Int32, false).unwrap(); + base_a.set_id(-1, &mut 10); + let mut base_b = Field::new_arrow("b", ArrowDataType::Int32, false).unwrap(); + base_b.set_id(-1, &mut 11); + + // base struct: s{x,y} + let mut base_s = Field::try_from(&ArrowField::new( + "s", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("x", ArrowDataType::Int32, false), + ArrowField::new("y", ArrowDataType::Int32, false), + ])), + false, + )) + .unwrap(); + base_s.set_id(-1, &mut 20); + let base_s_x = base_s.children.iter_mut().find(|c| c.name == "x").unwrap(); + base_s_x.set_id(20, &mut 21); + let base_s_y = base_s.children.iter_mut().find(|c| c.name == "y").unwrap(); + base_s_y.set_id(20, &mut 22); + + // base list: l + let mut base_l = Field::try_from(&ArrowField::new( + "l", + ArrowDataType::List(Arc::new(ArrowField::new( + "item", + ArrowDataType::Int32, + true, + ))), + true, + )) + .unwrap(); + base_l.set_id(-1, &mut 30); + let base_l_item = base_l + .children + .iter_mut() + .find(|c| c.name == "item") + .unwrap(); + base_l_item.set_id(30, &mut 31); + + // base map: m + let base_map_entries = ArrowField::new( + "entries", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("key", ArrowDataType::Utf8, false), + ArrowField::new("value", ArrowDataType::Int32, true), + ])), + false, + ); + let mut base_m = Field::try_from(&ArrowField::new( + "m", + ArrowDataType::Map(Arc::new(base_map_entries), false), + true, + )) + .unwrap(); + base_m.set_id(-1, &mut 40); + + let base_m_entries = base_m + .children + .iter_mut() + .find(|c| c.name == "entries") + .unwrap(); + base_m_entries.set_id(40, &mut 41); + + let base_m_key = base_m_entries + .children + .iter_mut() + .find(|c| c.name == "key") + .unwrap(); + base_m_key.set_id(41, &mut 42); + + let base_m_val = base_m_entries + .children + .iter_mut() + .find(|c| c.name == "value") + .unwrap(); + base_m_val.set_id(41, &mut 43); + + let base_schema = LanceSchema { + fields: vec![base_a, base_b, base_s, base_l, base_m], + metadata: HashMap::from([("base_schema_k".to_string(), "base_schema_v".to_string())]), + }; + + // new_schema specifies: + // - field a: manual field id + // - field b: no id -> should inherit from base_schema + // - field c: new field -> should be assigned based on max_field_id + // - struct s: parent+child(x) manual, child(y) inherit, child(z) max_field_id + // - list l: parent manual, child(item) inherit + // - list l2: parent manual, child(item) max_field_id + // - map m: parent manual, child(entries/key/value) inherit + // - map m2: parent manual, child(entries/key/value) max_field_id + let mut a_meta = HashMap::new(); + a_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "5".to_string()); + let arrow_a = ArrowField::new("a", ArrowDataType::Int32, false).with_metadata(a_meta); + let arrow_b = ArrowField::new("b", ArrowDataType::Int32, false); + let arrow_c = ArrowField::new("c", ArrowDataType::Int32, false); + + // struct s: manual parent + manual child x + let mut s_meta = HashMap::new(); + s_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "50".to_string()); + let mut x_meta = HashMap::new(); + x_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "51".to_string()); + let arrow_s = ArrowField::new( + "s", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("x", ArrowDataType::Int32, false).with_metadata(x_meta), + ArrowField::new("y", ArrowDataType::Int32, false), + ArrowField::new("z", ArrowDataType::Int32, true), + ])), + false, + ) + .with_metadata(s_meta); + + // list l: parent manual, item inherit + let mut l_meta = HashMap::new(); + l_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "60".to_string()); + let arrow_l = ArrowField::new( + "l", + ArrowDataType::List(Arc::new(ArrowField::new( + "item", + ArrowDataType::Int32, + true, + ))), + true, + ) + .with_metadata(l_meta); + + // list l2: parent manual, item max_field_id (no base match) + let mut l2_meta = HashMap::new(); + l2_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "61".to_string()); + let arrow_l2 = ArrowField::new( + "l2", + ArrowDataType::List(Arc::new(ArrowField::new( + "item", + ArrowDataType::Int32, + true, + ))), + true, + ) + .with_metadata(l2_meta); + + // map m: parent manual, entries/key/value inherit + let map_entries = ArrowField::new( + "entries", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("key", ArrowDataType::Utf8, false), + ArrowField::new("value", ArrowDataType::Int32, true), + ])), + false, + ); + let mut m_meta = HashMap::new(); + m_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "70".to_string()); + let arrow_m = ArrowField::new("m", ArrowDataType::Map(Arc::new(map_entries), false), true) + .with_metadata(m_meta); + + // map m2: parent manual, entries/key/value max_field_id (no base match) + let map_entries = ArrowField::new( + "entries", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("key", ArrowDataType::Utf8, false), + ArrowField::new("value", ArrowDataType::Int32, true), + ])), + false, + ); + let mut m2_meta = HashMap::new(); + m2_meta.insert(LANCE_FIELD_ID_KEY.to_string(), "71".to_string()); + let arrow_m2 = + ArrowField::new("m2", ArrowDataType::Map(Arc::new(map_entries), false), true) + .with_metadata(m2_meta); + + let arrow_schema = ArrowSchema::new_with_metadata( + vec![ + arrow_a, arrow_b, arrow_c, arrow_s, arrow_l, arrow_l2, arrow_m, arrow_m2, + ], + HashMap::from([("new_schema_k".to_string(), "new_schema_v".to_string())]), + ); + + let schema = + LanceSchema::from_arrow_schema(&arrow_schema, Some(base_schema), Some(100)).unwrap(); + + // 1. Manually specified field id + let got_a = schema.field("a").unwrap(); + assert_eq!(got_a.id, 5); + assert!(!got_a.metadata.contains_key(LANCE_FIELD_ID_KEY)); + + // 2. Inherit field id + metadata from base_schema (field b) + let got_b = schema.field("b").unwrap(); + assert_eq!(got_b.id, 11); + + // 3. Assign a new field id using max_field_id (field c) + let got_c = schema.field("c").unwrap(); + assert_eq!(got_c.id, 101); + + // 4. struct: parent+child(x) manual, child(y) inherit, child(z) max_field_id + let got_s = schema.field("s").unwrap(); + assert_eq!(got_s.id, 50); + let got_sx = schema.field("s.x").unwrap(); + assert_eq!(got_sx.id, 51); + let got_sy = schema.field("s.y").unwrap(); + assert_eq!(got_sy.id, 22); + let got_sz = schema.field("s.z").unwrap(); + assert_eq!(got_sz.id, 102); + + // 5. list l: parent manual, item inherit + let got_l = schema.field("l").unwrap(); + assert_eq!(got_l.id, 60); + let got_li = schema.field("l.item").unwrap(); + assert_eq!(got_li.id, 31); + + // 6. list l2: parent manual, item max_field_id + let got_l2 = schema.field("l2").unwrap(); + assert_eq!(got_l2.id, 61); + let got_l2i = schema.field("l2.item").unwrap(); + assert_eq!(got_l2i.id, 103); + + // 7. map m: parent manual, entries/key/value inherit + let got_m = schema.field("m").unwrap(); + assert_eq!(got_m.id, 70); + let got_me = schema.field("m.entries").unwrap(); + assert_eq!(got_me.id, 41); + let got_mk = schema.field("m.entries.key").unwrap(); + assert_eq!(got_mk.id, 42); + let got_mv = schema.field("m.entries.value").unwrap(); + assert_eq!(got_mv.id, 43); + + // 8. map m2: parent manual, entries/key/value max_field_id + let got_m2 = schema.field("m2").unwrap(); + assert_eq!(got_m2.id, 71); + let got_m2e = schema.field("m2.entries").unwrap(); + assert_eq!(got_m2e.id, 104); + let got_m2k = schema.field("m2.entries.key").unwrap(); + assert_eq!(got_m2k.id, 105); + let got_m2v = schema.field("m2.entries.value").unwrap(); + assert_eq!(got_m2v.id, 106); + + // 9. Schema metadata: when new_schema.metadata is non-empty, use new_schema metadata + assert_eq!( + schema.metadata, + HashMap::from([("new_schema_k".to_string(), "new_schema_v".to_string())]) + ); + } + + #[test] + fn test_parse_storage_format_canonical_forms() { + let cases = [ + ("2.0", LanceFileVersion::V2_0), + ("2.1", LanceFileVersion::V2_1), + ("2.2", LanceFileVersion::V2_2), + ("2.3", LanceFileVersion::V2_3), + ("0.1", LanceFileVersion::Legacy), + ("legacy", LanceFileVersion::Legacy), + ("stable", LanceFileVersion::Stable), + ("next", LanceFileVersion::Next), + ]; + for (input, expected) in cases { + assert_eq!( + parse_storage_format(input).unwrap(), + expected, + "parse_storage_format({:?}) failed", + input + ); + } + } + + /// The `v`-prefixed spellings shipped in the `CommitBuilder.storageFormat` + /// Javadoc and must keep working for existing Java callers. + #[test] + fn test_parse_storage_format_deprecated_aliases() { + let cases = [ + ("v2_0", LanceFileVersion::V2_0), + ("v2.0", LanceFileVersion::V2_0), + ("v2_1", LanceFileVersion::V2_1), + ("v2.1", LanceFileVersion::V2_1), + ("v2_2", LanceFileVersion::V2_2), + ("v2.2", LanceFileVersion::V2_2), + ]; + for (input, expected) in cases { + assert_eq!( + parse_storage_format(input).unwrap(), + expected, + "parse_storage_format({:?}) failed", + input + ); + } + } + + /// The alias set is frozen to what shipped, so versions added after the + /// aliases were deprecated are reachable only by their canonical name. + #[test] + fn test_parse_storage_format_does_not_extend_aliases_to_new_versions() { + assert!(parse_storage_format("v2_3").is_err()); + assert!(parse_storage_format("v2.3").is_err()); + assert_eq!(parse_storage_format("2.3").unwrap(), LanceFileVersion::V2_3); + } + + #[test] + fn test_parse_storage_format_case_insensitive() { + assert_eq!( + parse_storage_format("LEGACY").unwrap(), + LanceFileVersion::Legacy + ); + assert_eq!( + parse_storage_format("Stable").unwrap(), + LanceFileVersion::Stable + ); + assert_eq!( + parse_storage_format("V2_1").unwrap(), + LanceFileVersion::V2_1 + ); + } + + #[test] + fn test_parse_storage_format_rejects_invalid() { + assert!(parse_storage_format("v3.0").is_err()); + assert!(parse_storage_format("").is_err()); + assert!(parse_storage_format("foo").is_err()); + } + + #[test] + fn test_checked_transaction_integer_conversions() { + assert_eq!( + u32_to_jint("basePath.id", i32::MAX as u32).unwrap(), + i32::MAX + ); + assert!(u32_to_jint("basePath.id", i32::MAX as u32 + 1).is_err()); + assert_eq!( + checked_field_ids("update.fieldsModified", &[0, u32::MAX as i64]).unwrap(), + vec![0, u32::MAX] + ); + for invalid in [-1, u32::MAX as i64 + 1] { + let error = checked_field_ids("update.fieldsModified", &[invalid]).unwrap_err(); + let message = error.to_string(); + assert!(message.contains("update.fieldsModified[0]")); + assert!(message.contains(&invalid.to_string())); + } + assert_eq!( + u64_to_jlong("dataOverlay.fragmentId", i64::MAX as u64).unwrap(), + i64::MAX + ); + assert!(u64_to_jlong("dataOverlay.fragmentId", i64::MAX as u64 + 1).is_err()); + } +} diff --git a/java/lance-jni/src/update.rs b/java/lance-jni/src/update.rs new file mode 100644 index 00000000000..b35a3b24ebe --- /dev/null +++ b/java/lance-jni/src/update.rs @@ -0,0 +1,141 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; +use crate::error::Result; +use crate::traits::IntoJava; +use crate::utils::to_rust_map; +use crate::{JNIEnvExt, block_on}; +use jni::JNIEnv; +use jni::objects::{JMap, JObject, JValueGen}; +use lance::dataset::UpdateBuilder; +use lance_file::version::LanceFileVersion; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +#[unsafe(no_mangle)] +pub extern "system" fn Java_org_lance_Dataset_nativeUpdate<'a>( + mut env: JNIEnv<'a>, + jdataset: JObject, // Dataset object + jparam: JObject, // UpdateParams object +) -> JObject<'a> { + ok_or_throw!(env, inner_update(&mut env, jdataset, jparam)) +} + +fn inner_update<'local>( + env: &mut JNIEnv<'local>, + jdataset: JObject, + jparam: JObject, +) -> Result> { + let updates = extract_updates(env, &jparam)?; + let where_clause = extract_where(env, &jparam)?; + let conflict_retries = extract_conflict_retries(env, &jparam)?; + let retry_timeout_ms = extract_retry_timeout_ms(env, &jparam)?; + let data_storage_version = extract_data_storage_version(env, &jparam)?; + + // Clone the inner Dataset out of the `get_rust_field` guard and drop the + // guard before running the long-lived async update. Otherwise the guard + // would block any other JNI call on the same dataset for the entire + // duration of `execute()`. + let inner_dataset = unsafe { + let dataset = env.get_rust_field::<_, _, BlockingDataset>(jdataset, NATIVE_DATASET)?; + dataset.inner.clone() + }; + + let mut builder = UpdateBuilder::new(Arc::new(inner_dataset)) + .conflict_retries(conflict_retries) + .retry_timeout(Duration::from_millis(retry_timeout_ms)); + + if let Some(version) = data_storage_version { + builder = builder.data_storage_version(LanceFileVersion::from_str(&version)?); + } + + if let Some(predicate) = where_clause { + builder = builder.update_where(&predicate)?; + } + + for (column, expr) in &updates { + builder = builder.set(column, expr)?; + } + + let job = builder.build()?; + let update_result = block_on(job.execute())?; + + // Avoid panicking if Lance core retains a clone of the Arc; fall back to a + // deep clone so the JNI boundary stays panic-free. + let new_ds = Arc::try_unwrap(update_result.new_dataset).unwrap_or_else(|arc| (*arc).clone()); + + UpdateResultJava { + dataset: BlockingDataset { inner: new_ds }, + rows_updated: update_result.rows_updated, + } + .into_java(env) +} + +fn extract_updates<'local>( + env: &mut JNIEnv<'local>, + jparam: &JObject, +) -> Result> { + let updates_obj: JObject = env + .call_method(jparam, "updates", "()Ljava/util/Map;", &[])? + .l()?; + let jmap = JMap::from_env(env, &updates_obj)?; + to_rust_map(env, &jmap) +} + +fn extract_where<'local>(env: &mut JNIEnv<'local>, jparam: &JObject) -> Result> { + let where_opt = env + .call_method(jparam, "whereClause", "()Ljava/util/Optional;", &[])? + .l()?; + env.get_string_opt(&where_opt) +} + +fn extract_conflict_retries<'local>(env: &mut JNIEnv<'local>, jparam: &JObject) -> Result { + let retries = env + .call_method(jparam, "conflictRetries", "()I", &[])? + .i()? as u32; + Ok(retries) +} + +fn extract_retry_timeout_ms<'local>(env: &mut JNIEnv<'local>, jparam: &JObject) -> Result { + let timeout_ms = env.call_method(jparam, "retryTimeoutMs", "()J", &[])?.j()? as u64; + Ok(timeout_ms) +} + +fn extract_data_storage_version<'local>( + env: &mut JNIEnv<'local>, + jparam: &JObject, +) -> Result> { + let version = env + .call_method( + jparam, + "getDataStorageVersion", + "()Ljava/util/Optional;", + &[], + )? + .l()?; + env.get_string_opt(&version) +} + +const UPDATE_RESULT_CLASS: &str = "org/lance/update/UpdateResult"; +const UPDATE_RESULT_CONSTRUCTOR_SIG: &str = "(Lorg/lance/Dataset;J)V"; + +struct UpdateResultJava { + dataset: BlockingDataset, + rows_updated: u64, +} + +impl IntoJava for UpdateResultJava { + fn into_java<'a>(self, env: &mut JNIEnv<'a>) -> Result> { + let jdataset = self.dataset.into_java(env)?; + Ok(env.new_object( + UPDATE_RESULT_CLASS, + UPDATE_RESULT_CONSTRUCTOR_SIG, + &[ + JValueGen::Object(&jdataset), + JValueGen::Long(self.rows_updated as i64), + ], + )?) + } +} diff --git a/java/lance-jni/src/utils.rs b/java/lance-jni/src/utils.rs index 56ff617c821..5a3f795880c 100644 --- a/java/lance-jni/src/utils.rs +++ b/java/lance-jni/src/utils.rs @@ -5,28 +5,28 @@ use std::sync::Arc; use arrow::array::{ArrayRef, FixedSizeListArray, Float32Array}; use arrow_schema::{DataType, Field}; +use jni::JNIEnv; use jni::objects::{JFloatArray, JMap, JObject, JString, JValue, JValueGen}; use jni::sys::{jboolean, jfloat, jlong}; -use jni::JNIEnv; -use lance::dataset::optimize::CompactionOptions; +use lance::dataset::optimize::{CompactionMode, CompactionOptions}; use lance::dataset::{WriteMode, WriteParams}; use lance::index::vector::{IndexFileVersion, StageParams, VectorIndexParams}; use lance::io::ObjectStoreParams; -use lance_encoding::version::LanceFileVersion; +use lance_file::version::LanceFileVersion; +use lance_file::writer::FileWriterOptions; +use lance_index::IndexParams; +use lance_index::vector::bq::RQBuildParams; 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::IndexParams; 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 lance_index::vector::{ApproxMode, Query}; use std::collections::HashMap; use std::str::FromStr; @@ -39,6 +39,75 @@ pub fn extract_storage_options( Ok(storage_options) } +pub fn extract_base_store_params( + env: &mut JNIEnv, + base_store_params_obj: &JObject, +) -> Result> { + if base_store_params_obj.is_null() { + return Ok(HashMap::new()); + } + + env.with_local_frame(16, |env| { + let jmap = JMap::from_env(env, base_store_params_obj)?; + let mut base_store_params = HashMap::new(); + let mut iter = jmap.iter(env)?; + + while let Some((key, value)) = iter.next(env)? { + if value.is_null() { + return Err(Error::input_error( + "baseStoreParams values must be non-null maps".to_string(), + )); + } + + let key_jstring = JString::from(key); + let base_path: String = env.get_string(&key_jstring)?.into(); + let storage_options = extract_storage_options(env, &value)?; + base_store_params.insert( + base_path, + ObjectStoreParams { + storage_options_accessor: Some(Arc::new( + lance::io::StorageOptionsAccessor::with_static_options(storage_options), + )), + ..Default::default() + }, + ); + } + + Ok::<_, Error>(base_store_params) + }) +} + +pub(crate) fn parse_approx_mode(value: &str) -> Result { + match value { + "fast" => Ok(ApproxMode::Fast), + "normal" => Ok(ApproxMode::Normal), + "accurate" => Ok(ApproxMode::Accurate), + _ => Err(Error::input_error(format!( + "Invalid approx mode '{}'. Expected one of: fast, normal, accurate", + value + ))), + } +} + +pub(crate) fn extract_file_writer_options( + env: &mut JNIEnv, + file_write_options: &JObject, +) -> Result> { + let data_cache_bytes = + env.get_optional_u64_from_method(file_write_options, "getDataCacheBytes")?; + let max_page_bytes = env.get_optional_u64_from_method(file_write_options, "getMaxPageBytes")?; + + if data_cache_bytes.is_none() && max_page_bytes.is_none() { + return Ok(None); + } + + Ok(Some(FileWriterOptions { + data_cache_bytes, + max_page_bytes, + ..Default::default() + })) +} + #[allow(clippy::too_many_arguments)] pub fn extract_write_params( env: &mut JNIEnv, @@ -50,9 +119,12 @@ 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 + base_store_params_obj: &JObject, + initial_bases: &JObject, // Optional + target_bases: &JObject, // Optional + allow_external_blob_outside_bases: &JObject, // Optional + blob_pack_file_size_threshold: &JObject, // Optional + file_write_options: &JObject, // FileWriteOptions ) -> Result { let mut write_params = WriteParams::default(); @@ -88,13 +160,7 @@ 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); + let base_store_params = extract_base_store_params(env, base_store_params_obj)?; if let Some(initial_bases) = env.get_list_opt(initial_bases, |env, elem| elem.extract_object(env))? @@ -106,24 +172,30 @@ 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( + 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); + } + write_params.file_writer_options = extract_file_writer_options(env, file_write_options)?; + + // 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 { storage_options_accessor: accessor, ..Default::default() }); + for (base_path, store_params) in base_store_params { + write_params = write_params.with_base_store_params(base_path, store_params); + } Ok(write_params) } @@ -138,8 +210,16 @@ pub fn build_compaction_options( num_threads: &JObject, // Optional batch_size: &JObject, // Optional defer_index_remap: &JObject, // Optional + compaction_mode: &JObject, // Optional + binary_copy_read_batch_bytes: &JObject, // Optional + max_source_fragments: &JObject, // Optional + max_source_rows: &JObject, // Optional + max_source_bytes: &JObject, // Optional + excluded_fragment_ids: &JObject, // List + data_storage_version: &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; @@ -167,6 +247,41 @@ pub fn build_compaction_options( if let Some(defer_index_remap_val) = env.get_boolean_opt(defer_index_remap)? { compaction_options.defer_index_remap = defer_index_remap_val; } + if let Some(compaction_mode_val) = env.get_string_opt(compaction_mode)? { + compaction_options.compaction_mode = + Some(CompactionMode::try_from(compaction_mode_val.as_str())?); + } + if let Some(binary_copy_read_batch_bytes_val) = + env.get_long_opt(binary_copy_read_batch_bytes)? + { + 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); + } + if let Some(max_source_rows_val) = env.get_long_opt(max_source_rows)? { + compaction_options.max_source_rows = Some(max_source_rows_val as usize); + } + if let Some(max_source_bytes_val) = env.get_long_opt(max_source_bytes)? { + compaction_options.max_source_bytes = Some(max_source_bytes_val as u64); + } + compaction_options.excluded_fragment_ids = env + .get_longs(excluded_fragment_ids)? + .into_iter() + .map(|fragment_id| { + u32::try_from(fragment_id).map_err(|_| { + Error::input_error(format!( + "excluded_fragment_ids must contain values between 0 and {}, got {}", + u32::MAX, + fragment_id + )) + }) + }) + .collect::>>()?; + if let Some(version) = env.get_string_opt(data_storage_version)? { + compaction_options.data_storage_version = Some(LanceFileVersion::from_str(&version)?); + } Ok(compaction_options) } @@ -195,6 +310,11 @@ pub fn get_query(env: &mut JNIEnv, query_obj: JObject) -> Result> }; let use_index = env.get_boolean_from_method(&java_obj, "isUseIndex")?; + let query_parallelism = env + .call_method(&java_obj, "getQueryParallelism", "()I", &[])? + .i()?; + let approx_mode_str = env.get_string_from_method(&java_obj, "getApproxModeString")?; + let approx_mode = parse_approx_mode(&approx_mode_str)?; Ok(Query { column, @@ -209,6 +329,8 @@ pub fn get_query(env: &mut JNIEnv, query_obj: JObject) -> Result> metric_type: distance_type, use_index, dist_q_c: 0.0, + query_parallelism, + approx_mode, }) })?; @@ -399,10 +521,26 @@ pub fn get_vector_index_params( stages.push(StageParams::SQ(sq_params)); } + // Parse RQBuildParams + let rq_params = env.get_optional_from_method( + &vector_index_params_obj, + "getRqParams", + |env, rq_obj| { + let num_bits = env.call_method(&rq_obj, "getNumBits", "()B", &[])?.b()? as u8; + Ok(RQBuildParams::new(num_bits)) + }, + )?; + + if let Some(rq_params) = rq_params { + stages.push(StageParams::RQ(rq_params)); + } + Ok(VectorIndexParams { metric_type: distance_type, stages, version: IndexFileVersion::V3, + skip_transpose: false, + runtime_hints: Default::default(), }) }, )?; @@ -547,3 +685,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/lance-jni/src/vector_trainer.rs b/java/lance-jni/src/vector_trainer.rs index e2d6012859e..d988aebd468 100755 --- a/java/lance-jni/src/vector_trainer.rs +++ b/java/lance-jni/src/vector_trainer.rs @@ -1,15 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::sync::Arc; + +use crate::block_on; use crate::blocking_dataset::{BlockingDataset, NATIVE_DATASET}; use crate::error::{Error, Result}; use crate::ffi::JNIEnvExt; -use crate::RT; use arrow::array::{FixedSizeListArray, Float32Array}; +use jni::JNIEnv; use jni::objects::{JClass, JFloatArray, JObject, JString}; use jni::sys::jfloatArray; -use jni::JNIEnv; +use lance::index::NoopIndexBuildProgress; use lance::index::vector::utils::get_vector_dim; use lance_index::vector::ivf::builder::IvfBuildParams as RustIvfBuildParams; use lance_index::vector::pq::builder::PQBuildParams as RustPQBuildParams; @@ -31,6 +34,11 @@ fn flatten_fixed_size_list_to_f32(arr: &FixedSizeListArray) -> Result> Ok(values.values().to_vec()) } +fn parse_distance_type(env: &mut JNIEnv, distance_type_jstr: &JString) -> Result { + let distance_type_str: String = env.get_string(distance_type_jstr)?.into(); + MetricType::try_from(distance_type_str.as_str()).map_err(|e| Error::input_error(e.to_string())) +} + fn build_ivf_params_from_java( env: &mut JNIEnv, ivf_params_obj: &JObject, @@ -73,18 +81,25 @@ fn build_pq_params_from_java( }) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_index_vector_VectorTrainer_nativeTrainIvfCentroids<'local>( mut env: JNIEnv<'local>, _class: JClass<'local>, - dataset_obj: JObject<'local>, // org.lance.Dataset - column_jstr: JString<'local>, // java.lang.String - ivf_params_obj: JObject<'local>, // org.lance.index.vector.IvfBuildParams + dataset_obj: JObject<'local>, // org.lance.Dataset + column_jstr: JString<'local>, // java.lang.String + ivf_params_obj: JObject<'local>, // org.lance.index.vector.IvfBuildParams + distance_type_jstr: JString<'local>, // org.lance.index.DistanceType#toString() ) -> jfloatArray { ok_or_throw_with_return!( env, - inner_train_ivf_centroids(&mut env, dataset_obj, column_jstr, ivf_params_obj) - .map(|arr| arr.into_raw()), + inner_train_ivf_centroids( + &mut env, + dataset_obj, + column_jstr, + ivf_params_obj, + distance_type_jstr, + ) + .map(|arr| arr.into_raw()), JFloatArray::default().into_raw() ) } @@ -94,9 +109,11 @@ fn inner_train_ivf_centroids<'local>( dataset_obj: JObject<'local>, column_jstr: JString<'local>, ivf_params_obj: JObject<'local>, + distance_type_jstr: JString<'local>, ) -> Result> { let column: String = env.get_string(&column_jstr)?.into(); let ivf_params = build_ivf_params_from_java(env, &ivf_params_obj)?; + let metric_type = parse_distance_type(env, &distance_type_jstr)?; let flattened: Vec = { let dataset_guard = @@ -105,15 +122,14 @@ fn inner_train_ivf_centroids<'local>( let dim = get_vector_dim(dataset.schema(), &column)?; - // For now we default to L2 metric; tests and Java bindings currently use L2. - let metric_type = MetricType::L2; - - let ivf_model = RT.block_on(lance::index::vector::ivf::build_ivf_model( + let ivf_model = block_on(lance::index::vector::ivf::build_ivf_model( dataset, &column, dim, metric_type, &ivf_params, + None, + Arc::new(NoopIndexBuildProgress), ))?; let centroids = ivf_model @@ -128,18 +144,25 @@ fn inner_train_ivf_centroids<'local>( Ok(jarray) } -#[no_mangle] +#[unsafe(no_mangle)] pub extern "system" fn Java_org_lance_index_vector_VectorTrainer_nativeTrainPqCodebook<'local>( mut env: JNIEnv<'local>, _class: JClass<'local>, - dataset_obj: JObject<'local>, // org.lance.Dataset - column_jstr: JString<'local>, // java.lang.String - pq_params_obj: JObject<'local>, // org.lance.index.vector.PQBuildParams + dataset_obj: JObject<'local>, // org.lance.Dataset + column_jstr: JString<'local>, // java.lang.String + pq_params_obj: JObject<'local>, // org.lance.index.vector.PQBuildParams + distance_type_jstr: JString<'local>, // org.lance.index.DistanceType#toString() ) -> jfloatArray { ok_or_throw_with_return!( env, - inner_train_pq_codebook(&mut env, dataset_obj, column_jstr, pq_params_obj) - .map(|arr| arr.into_raw()), + inner_train_pq_codebook( + &mut env, + dataset_obj, + column_jstr, + pq_params_obj, + distance_type_jstr, + ) + .map(|arr| arr.into_raw()), JFloatArray::default().into_raw() ) } @@ -149,9 +172,11 @@ fn inner_train_pq_codebook<'local>( dataset_obj: JObject<'local>, column_jstr: JString<'local>, pq_params_obj: JObject<'local>, + distance_type_jstr: JString<'local>, ) -> Result> { let column: String = env.get_string(&column_jstr)?.into(); let pq_params = build_pq_params_from_java(env, &pq_params_obj)?; + let metric_type = parse_distance_type(env, &distance_type_jstr)?; let flattened: Vec = { let dataset_guard = @@ -159,9 +184,8 @@ fn inner_train_pq_codebook<'local>( let dataset = &dataset_guard.inner; let dim = get_vector_dim(dataset.schema(), &column)?; - let metric_type = MetricType::L2; - let pq = RT.block_on(lance::index::vector::pq::build_pq_model( + let pq = block_on(lance::index::vector::pq::build_pq_model( dataset, &column, dim, diff --git a/java/pom.xml b/java/pom.xml index 51f7614a251..2f059602bde 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -7,7 +7,7 @@ org.lance lance-core Lance Core - 3.0.0-beta.2 + 14.0.0-beta.6 jar Lance Format Java API @@ -29,6 +29,7 @@ UTF-8 18.3.0 + 1.64.0 0.28.1 false 2.43.0 @@ -39,6 +40,7 @@ 3.7.5 package false + false org.lance.shaded @@ -109,18 +111,23 @@ org.lance lance-namespace-core - 0.4.5 + 0.12.0 org.lance lance-namespace-apache-client - 0.4.5 + 0.12.0 com.fasterxml.jackson.core jackson-databind 2.15.2 + + io.opentelemetry + opentelemetry-api + ${opentelemetry.version} + software.amazon.awssdk @@ -134,6 +141,18 @@ 2.20.26 test + + io.opentelemetry + opentelemetry-sdk + ${opentelemetry.version} + test + + + io.opentelemetry + opentelemetry-sdk-testing + ${opentelemetry.version} + test + @@ -274,6 +293,19 @@ com.diffplug.spotless spotless-maven-plugin + + org.codehaus.mojo + license-maven-plugin + 2.4.0 + + ${project.basedir} + JAVA_THIRD_PARTY_LICENSES.md + /org/codehaus/mojo/license/third-party-file-groupByLicense.ftl + compile,runtime + test,provided + true + + @@ -383,6 +415,9 @@ lance-jni ${rust.release.build} + + ${rust.features} + ${project.build.directory}/classes/nativelib true @@ -396,6 +431,9 @@ lance-jni ${rust.release.build} + + ${rust.features} + -v @@ -452,6 +490,9 @@ false + + ${project.basedir}/src/test/resources/language_models + diff --git a/java/src/main/java/org/lance/BlobFile.java b/java/src/main/java/org/lance/BlobFile.java index 03c8408ab57..fb7e6695463 100755 --- a/java/src/main/java/org/lance/BlobFile.java +++ b/java/src/main/java/org/lance/BlobFile.java @@ -41,6 +41,9 @@ public final class BlobFile implements Closeable { JniLoader.ensureLoaded(); } + /** Default sequential read-ahead size in bytes. */ + public static final int DEFAULT_READ_BUFFER_SIZE = 512 * 1024; + /** Opaque native handle managed by lance-jni. */ @SuppressWarnings("FieldCanBeLocal") private long nativeBlobHandle; @@ -59,12 +62,30 @@ 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"); nativeSeek(newCursor); } + /** + * Sequential read-ahead size in bytes. {@code 0} disables read-ahead. Range reads do not use this + * buffer. + */ + public void setReadBufferSize(long bufferSize) throws IOException { + if (bufferSize < 0) { + throw new IllegalArgumentException("bufferSize must be non-negative"); + } + nativeSetReadBufferSize(bufferSize); + } + /** Return current cursor position. */ public long tell() throws IOException { return nativeTell(); @@ -90,6 +111,8 @@ public void close() throws IOException { private native void nativeSeek(long newCursor) throws IOException; + private native void nativeSetReadBufferSize(long bufferSize) throws IOException; + private native long nativeTell() throws IOException; private native long nativeSize(); diff --git a/java/src/main/java/org/lance/Branch.java b/java/src/main/java/org/lance/Branch.java index f2a1f5b21ea..526a65c33da 100755 --- a/java/src/main/java/org/lance/Branch.java +++ b/java/src/main/java/org/lance/Branch.java @@ -14,30 +14,94 @@ package org.lance; import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; 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; + private final Map metadata; public Branch( String name, String parentBranch, long parentVersion, long createAt, int manifestSize) { + this( + name, + parentBranch, + ImmutableList.of(), + parentVersion, + createAt, + manifestSize, + Collections.emptyMap()); + } + + public Branch( + String name, + String parentBranch, + List branchIdentifier, + long parentVersion, + long createAt, + int manifestSize, + Map metadata) { this.name = name; this.parentBranch = Optional.ofNullable(parentBranch); + this.branchIdentifier = ImmutableList.copyOf(Objects.requireNonNull(branchIdentifier)); this.parentVersion = parentVersion; this.createAt = createAt; this.manifestSize = manifestSize; + this.metadata = Collections.unmodifiableMap(new HashMap<>(metadata)); } public String getName() { @@ -48,6 +112,10 @@ public Optional getParentBranch() { return parentBranch; } + public ImmutableList getBranchIdentifier() { + return branchIdentifier; + } + public long getParentVersion() { return parentVersion; } @@ -60,14 +128,20 @@ public int getManifestSize() { return manifestSize; } + public Map getMetadata() { + return metadata; + } + @Override 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) + .add("metadata", metadata) .toString(); } @@ -80,11 +154,14 @@ 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) + && Objects.equals(metadata, branch.metadata); } @Override public int hashCode() { - return Objects.hash(name, parentBranch, parentVersion, createAt, manifestSize); + return Objects.hash( + name, parentBranch, branchIdentifier, parentVersion, createAt, manifestSize, metadata); } } diff --git a/java/src/main/java/org/lance/CacheBackendConfig.java b/java/src/main/java/org/lance/CacheBackendConfig.java new file mode 100644 index 00000000000..b43a49e9404 --- /dev/null +++ b/java/src/main/java/org/lance/CacheBackendConfig.java @@ -0,0 +1,113 @@ +/* + * 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.apache.arrow.util.Preconditions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** + * Structured configuration for a cache backend registered with the native Lance runtime. + * + *

The {@code kind} selects a registered backend constructor, while {@code options} contains + * backend-specific string settings. For example: + * + *

{@code
+ * CacheBackendConfig config = CacheBackendConfig.builder("moka")
+ *     .option("capacity", "1048576")
+ *     .build();
+ * }
+ * + *

Third-party native backend crates register their constructors with Lance at application + * startup. This class selects and configures one of those registered constructors; it does not + * register a Java implementation as a native cache backend. + */ +public final class CacheBackendConfig { + private final String kind; + private final Map options; + + private CacheBackendConfig(Builder builder) { + this.kind = builder.kind; + this.options = Collections.unmodifiableMap(new HashMap<>(builder.options)); + } + + /** + * Creates a builder for a registered backend kind. + * + * @param kind registered backend identifier, such as {@code moka} + * @return a new builder + */ + public static Builder builder(String kind) { + return new Builder(kind); + } + + /** Returns the registered backend identifier. */ + public String getKind() { + return kind; + } + + /** Returns an immutable map of backend-specific options. */ + public Map getOptions() { + return options; + } + + /** Builder for {@link CacheBackendConfig}. */ + public static final class Builder { + private final String kind; + private final Map options = new HashMap<>(); + + private Builder(String kind) { + Preconditions.checkNotNull(kind, "kind must not be null"); + Preconditions.checkArgument(!kind.isEmpty(), "kind must not be empty"); + this.kind = kind; + } + + /** + * Adds a backend-specific option. + * + * @param key option name + * @param value option value + * @return this builder + */ + public Builder option(String key, String value) { + Preconditions.checkNotNull(key, "cache backend option key must not be null"); + Preconditions.checkNotNull(value, "cache backend option value must not be null"); + Preconditions.checkArgument(!key.isEmpty(), "cache backend option key must not be empty"); + options.put(key, value); + return this; + } + + /** + * Replaces the current backend options. + * + * @param options backend-specific options + * @return this builder + */ + public Builder options(Map options) { + Preconditions.checkNotNull(options, "options must not be null"); + this.options.clear(); + for (Map.Entry option : options.entrySet()) { + option(option.getKey(), option.getValue()); + } + return this; + } + + /** Builds the immutable backend configuration. */ + public CacheBackendConfig build() { + return new CacheBackendConfig(this); + } + } +} diff --git a/java/src/main/java/org/lance/CacheStats.java b/java/src/main/java/org/lance/CacheStats.java new file mode 100644 index 00000000000..9df3388dabb --- /dev/null +++ b/java/src/main/java/org/lance/CacheStats.java @@ -0,0 +1,105 @@ +/* + * 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; + +/** + * Statistics for a session cache. + * + *

A snapshot of cache activity, useful for monitoring hit/miss rates over time. + */ +public class CacheStats { + private final long hits; + private final long misses; + private final long numEntries; + private final long sizeBytes; + + /** + * Constructs cache statistics. Instances are created by the native layer. + * + * @param hits number of cache lookups that found an item + * @param misses number of cache lookups that did not find an item + * @param numEntries number of entries currently in the cache + * @param sizeBytes total size in bytes of all entries in the cache + */ + public CacheStats(long hits, long misses, long numEntries, long sizeBytes) { + if (hits < 0 || misses < 0 || numEntries < 0 || sizeBytes < 0) { + throw new IllegalArgumentException( + String.format( + "Cache statistics must be non-negative: " + + "hits=%d, misses=%d, numEntries=%d, sizeBytes=%d", + hits, misses, numEntries, sizeBytes)); + } + this.hits = hits; + this.misses = misses; + this.numEntries = numEntries; + this.sizeBytes = sizeBytes; + } + + /** + * Returns the number of cache lookups that found an item in the cache. + * + * @return the number of cache hits + */ + public long getHits() { + return hits; + } + + /** + * Returns the number of cache lookups that did not find an item in the cache. + * + * @return the number of cache misses + */ + public long getMisses() { + return misses; + } + + /** + * Returns the number of entries currently in the cache. + * + * @return the number of cache entries + */ + public long getNumEntries() { + return numEntries; + } + + /** + * Returns the total size in bytes of all entries in the cache. + * + * @return the cache size in bytes + */ + public long getSizeBytes() { + return sizeBytes; + } + + /** + * Returns the ratio of hits to total lookups, or 0 if there have been no lookups. + * + * @return the cache hit ratio in the range [0, 1] + */ + public double getHitRatio() { + // Sum in double to avoid long overflow for very large counters + double total = (double) hits + (double) misses; + if (total == 0) { + return 0.0; + } + return hits / total; + } + + @Override + public String toString() { + return String.format( + "CacheStats(hits=%d, misses=%d, numEntries=%d, sizeBytes=%d)", + hits, misses, numEntries, sizeBytes); + } +} diff --git a/java/src/main/java/org/lance/CleanupOperation.java b/java/src/main/java/org/lance/CleanupOperation.java new file mode 100644 index 00000000000..1f64473f43e --- /dev/null +++ b/java/src/main/java/org/lance/CleanupOperation.java @@ -0,0 +1,74 @@ +/* + * 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.cleanup.CleanupExplanation; +import org.lance.cleanup.CleanupPolicy; +import org.lance.cleanup.RemovalStats; + +import org.apache.arrow.util.Preconditions; + +import java.util.Optional; + +/** + * A cleanup operation with separate read-only explain and destructive execute actions. + * + *

This is not a deletion plan. Calling {@link #execute()} re-evaluates the current dataset and + * reference state before deleting files. + */ +public class CleanupOperation { + private final Dataset dataset; + private final CleanupPolicy policy; + private Optional maxCandidateFiles = Optional.empty(); + + CleanupOperation(Dataset dataset, CleanupPolicy policy) { + this.dataset = Preconditions.checkNotNull(dataset, "dataset cannot be null"); + this.policy = Preconditions.checkNotNull(policy, "policy cannot be null"); + } + + /** + * Set the maximum number of candidate files included in the {@link #explain()} result. + * + *

Defaults to 1000 if not set. The aggregate {@link RemovalStats} returned by {@link + * #explain()} still account for all files that would be removed regardless of this limit; only + * the per-file {@code candidateFiles} list is truncated. + * + * @param maxCandidateFiles maximum number of candidate files to include; must be positive + * @return this operation for chaining + */ + public CleanupOperation withMaxCandidateFiles(long maxCandidateFiles) { + Preconditions.checkArgument( + maxCandidateFiles > 0, "maxCandidateFiles must be positive, got %s", maxCandidateFiles); + this.maxCandidateFiles = Optional.of(maxCandidateFiles); + return this; + } + + /** + * Explain what cleanup would remove without deleting files. + * + * @return cleanup explanation + */ + public CleanupExplanation explain() { + return dataset.explainCleanup(policy, maxCandidateFiles); + } + + /** + * Execute cleanup, re-evaluating the current dataset and reference state before deleting files. + * + * @return removal stats + */ + public RemovalStats execute() { + return dataset.executeCleanup(policy); + } +} diff --git a/java/src/main/java/org/lance/CommitBuilder.java b/java/src/main/java/org/lance/CommitBuilder.java new file mode 100644 index 00000000000..abeb60f5158 --- /dev/null +++ b/java/src/main/java/org/lance/CommitBuilder.java @@ -0,0 +1,359 @@ +/* + * 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.LanceNamespace; + +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.util.Preconditions; + +import java.time.Duration; +import java.util.List; +import java.util.Map; + +/** + * Builder for committing a {@link Transaction} to a Lance dataset. + * + *

Supports two modes: + * + *

    + *
  • Dataset-based commit: commits against an existing dataset. + *
  • URI-based commit: creates or updates a dataset at a URI. + *
+ * + *

Example usage (dataset-based): + * + *

{@code
+ * try (Transaction txn = new Transaction.Builder()
+ *     .readVersion(dataset.version())
+ *     .operation(Append.builder().fragments(fragments).build())
+ *     .build();
+ *     Dataset committed = new CommitBuilder(dataset).execute(txn)) {
+ *     // use committed dataset
+ * }
+ * }
+ * + *

Example usage (URI-based): + * + *

{@code
+ * try (Transaction txn = new Transaction.Builder()
+ *     .operation(Overwrite.builder().fragments(fragments).schema(schema).build())
+ *     .build();
+ *     Dataset committed = new CommitBuilder(uri, allocator).execute(txn)) {
+ *     // use committed dataset
+ * }
+ * }
+ */ +public class CommitBuilder { + static { + JniLoader.ensureLoaded(); + } + + private final Dataset dataset; + private final String uri; + private final BufferAllocator allocator; + + private Map writeParams; + private LanceNamespace namespaceClient; + private List tableId; + private boolean namespaceClientManagedVersioning = false; + private boolean enableV2ManifestPaths = true; + private boolean detached = false; + private Boolean useStableRowIds; + private String storageFormat; + private int maxRetries = 0; + private boolean skipAutoCleanup = false; + // -1 disables the timeout; any positive value is the timeout in nanoseconds. + private long commitTimeoutNanos = Duration.ofMinutes(30).toNanos(); + + /** + * Create a commit builder for committing against an existing dataset. + * + * @param dataset the existing dataset to commit against + */ + public CommitBuilder(Dataset dataset) { + Preconditions.checkNotNull(dataset, "Dataset must not be null"); + this.dataset = dataset; + this.uri = null; + this.allocator = null; + } + + /** + * Create a commit builder for creating or updating a dataset at the given URI. + * + * @param uri the target URI for the dataset + * @param allocator the Arrow buffer allocator for schema export + */ + public CommitBuilder(String uri, BufferAllocator allocator) { + Preconditions.checkNotNull(uri, "URI must not be null"); + Preconditions.checkNotNull(allocator, "Allocator must not be null"); + this.dataset = null; + this.uri = uri; + this.allocator = allocator; + } + + /** + * Set write parameters (storage options) for the commit. + * + * @param writeParams the write parameters + * @return this builder instance + */ + public CommitBuilder writeParams(Map writeParams) { + this.writeParams = writeParams; + return this; + } + + /** + * 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 namespaceClient the LanceNamespace client instance + * @return this builder instance + */ + public CommitBuilder namespaceClient(LanceNamespace namespaceClient) { + this.namespaceClient = namespaceClient; + return this; + } + + /** + * Set the table ID for namespace client-based commit handling. + * + *

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

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 namespaceClientManagedVersioning(boolean namespaceClientManagedVersioning) { + this.namespaceClientManagedVersioning = namespaceClientManagedVersioning; + return this; + } + + /** + * Enable or disable v2 manifest paths for new datasets. + * + *

Defaults to true. V2 manifest paths allow constant-time lookups for the latest manifest on + * object storage. Warning: enabling this makes the dataset unreadable for Lance versions prior to + * 0.17.0. + * + * @param enable whether to enable v2 manifest paths + * @return this builder instance + */ + public CommitBuilder enableV2ManifestPaths(boolean enable) { + this.enableV2ManifestPaths = enable; + return this; + } + + /** + * Set whether the commit should be detached from the main dataset lineage. + * + * @param detached if true, the commit will not be part of the main dataset lineage + * @return this builder instance + */ + public CommitBuilder detached(boolean detached) { + this.detached = detached; + return this; + } + + /** + * Whether to use stable row ids. This makes the {@code _rowid} column stable after compaction, + * but not updates. + * + *

This is only used for new datasets. Existing datasets will use their existing setting. + * Default is false. + * + * @param useStableRowIds whether to use stable row ids + * @return this builder instance + */ + public CommitBuilder useStableRowIds(boolean useStableRowIds) { + this.useStableRowIds = useStableRowIds; + return this; + } + + /** + * Set the storage format to use for the dataset. + * + *

On creation, this sets the default storage version. If omitted, the version is inferred from + * homogeneous data files, or uses the stable version for an empty table. Creating from mixed + * prewritten files requires an explicit default. For an existing dataset, only overwrite changes + * the default; other operations preserve it. Commit finalization validates the referenced file + * versions and derives the required mixed-version capability. + * + *

Valid values are the numeric versions ("0.1", "2.0", "2.1", "2.2", "2.3") and the release + * selectors ("legacy", "stable", "next"), matching {@link + * WriteParams.Builder#withDataStorageVersion(String)}. Parsing is case-insensitive. + * + *

The {@code v}-prefixed spellings ("v2_0", "v2.0", "v2_1", "v2.1", "v2_2", "v2.2") are + * deprecated. They were accepted only by this method, never by the rest of Lance, and will be + * removed in a future release — use the numeric version instead ("v2_1" becomes "2.1"). + * + * @param storageFormat the storage format name + * @return this builder instance + */ + public CommitBuilder storageFormat(String storageFormat) { + this.storageFormat = storageFormat; + return this; + } + + /** + * Set the maximum number of retries for commit operations. + * + *

If a commit operation fails, it will be retried up to {@code maxRetries} times. Default is + * 0. + * + * @param maxRetries the maximum number of retries + * @return this builder instance + */ + public CommitBuilder maxRetries(int maxRetries) { + this.maxRetries = maxRetries; + return this; + } + + /** + * Set whether to skip automatic cleanup after commit. + * + *

Default is false. + * + * @param skipAutoCleanup if true, skip automatic cleanup + * @return this builder instance + */ + public CommitBuilder skipAutoCleanup(boolean skipAutoCleanup) { + this.skipAutoCleanup = skipAutoCleanup; + return this; + } + + /** + * Set a timeout for the commit operation. + * + *

If the commit (including retries on conflict) does not complete within {@code timeout}, + * {@link #execute(Transaction)} will fail. Pass {@code null} to disable the timeout entirely. The + * default is 30 minutes. + * + * @param timeout the commit timeout, or {@code null} to disable + * @return this builder instance + * @throws IllegalArgumentException if {@code timeout} is zero or negative + */ + public CommitBuilder commitTimeout(Duration timeout) { + if (timeout == null) { + this.commitTimeoutNanos = -1L; + } else { + Preconditions.checkArgument( + !timeout.isZero() && !timeout.isNegative(), + "commit timeout must be a positive duration; pass null to disable"); + this.commitTimeoutNanos = timeout.toNanos(); + } + return this; + } + + /** + * Execute the commit with the given transaction. + * + *

The caller is responsible for closing the transaction (via try-with-resources or {@link + * Transaction#close()}) to release any native resources held by the operation. + * + * @param transaction the transaction to commit + * @return a new Dataset at the committed version + */ + public Dataset execute(Transaction transaction) { + Preconditions.checkNotNull(transaction, "Transaction must not be null"); + if (dataset != null) { + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + Dataset result = + nativeCommitToDataset( + dataset, + transaction, + detached, + enableV2ManifestPaths, + writeParams, + useStableRowIds, + storageFormat, + maxRetries, + skipAutoCleanup, + namespaceClient, + tableId, + namespaceClientManagedVersioning, + commitTimeoutNanos); + result.setAllocator(dataset.allocator()); + return result; + } + } + if (uri != null) { + Dataset result = + nativeCommitToUri( + uri, + transaction, + detached, + enableV2ManifestPaths, + namespaceClient, + tableId, + allocator, + writeParams, + useStableRowIds, + storageFormat, + maxRetries, + skipAutoCleanup, + namespaceClientManagedVersioning, + commitTimeoutNanos); + result.setAllocator(allocator); + return result; + } + throw new IllegalStateException("CommitBuilder requires either a dataset or a URI"); + } + + private static native Dataset nativeCommitToDataset( + Dataset dataset, + Transaction transaction, + boolean detached, + boolean enableV2ManifestPaths, + Map writeParams, + Boolean useStableRowIds, + String storageFormat, + int maxRetries, + boolean skipAutoCleanup, + Object namespace, + Object tableId, + boolean namespaceClientManagedVersioning, + long commitTimeoutNanos); + + private static native Dataset nativeCommitToUri( + String uri, + Transaction transaction, + boolean detached, + boolean enableV2ManifestPaths, + Object namespace, + Object tableId, + Object allocator, + Map writeParams, + Boolean useStableRowIds, + String storageFormat, + int maxRetries, + boolean skipAutoCleanup, + boolean namespaceClientManagedVersioning, + long commitTimeoutNanos); +} diff --git a/java/src/main/java/org/lance/ContextIndexBuildProgress.java b/java/src/main/java/org/lance/ContextIndexBuildProgress.java new file mode 100644 index 00000000000..618a1dd3c48 --- /dev/null +++ b/java/src/main/java/org/lance/ContextIndexBuildProgress.java @@ -0,0 +1,134 @@ +/* + * 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.index.IndexBuildProgress; + +import java.util.Collections; +import java.util.IdentityHashMap; +import java.util.Optional; +import java.util.Set; + +/** + * Marks the current thread as executing an index progress callback for one or more Datasets. + * + *

The active contexts are tracked by Dataset identity and nesting count. This lets Dataset give + * callback threads a scoped read lease and reject conflicting write re-entry without rejecting + * unrelated concurrent callers. + */ +final class ContextIndexBuildProgress implements IndexBuildProgress { + private static final ThreadLocal> ACTIVE_CALLBACKS = + new ThreadLocal<>(); + + private final Dataset dataset; + private final IndexBuildProgress delegate; + private final Set inheritedDatasets; + + ContextIndexBuildProgress(Dataset dataset, IndexBuildProgress delegate) { + this.dataset = dataset; + this.delegate = delegate; + IdentityHashMap activeCallbacks = ACTIVE_CALLBACKS.get(); + if (activeCallbacks == null || activeCallbacks.isEmpty()) { + this.inheritedDatasets = Collections.emptySet(); + } else { + Set inheritedDatasets = Collections.newSetFromMap(new IdentityHashMap<>()); + inheritedDatasets.addAll(activeCallbacks.keySet()); + this.inheritedDatasets = inheritedDatasets; + } + } + + static boolean isActive(Dataset dataset) { + IdentityHashMap activeCallbacks = ACTIVE_CALLBACKS.get(); + return activeCallbacks != null && activeCallbacks.containsKey(dataset); + } + + static boolean isCallbackActive() { + IdentityHashMap activeCallbacks = ACTIVE_CALLBACKS.get(); + return activeCallbacks != null && !activeCallbacks.isEmpty(); + } + + private static IdentityHashMap activeCallbacks() { + IdentityHashMap activeCallbacks = ACTIVE_CALLBACKS.get(); + if (activeCallbacks == null) { + activeCallbacks = new IdentityHashMap<>(); + ACTIVE_CALLBACKS.set(activeCallbacks); + } + return activeCallbacks; + } + + private static void begin(Dataset dataset) { + IdentityHashMap activeCallbacks = activeCallbacks(); + activeCallbacks.computeIfAbsent(dataset, key -> new int[1])[0]++; + } + + private static void end(Dataset dataset) { + IdentityHashMap activeCallbacks = ACTIVE_CALLBACKS.get(); + int[] count = activeCallbacks.get(dataset); + if (count == null) { + throw new IllegalStateException("Callback context is not active"); + } + count[0]--; + if (count[0] == 0) { + activeCallbacks.remove(dataset); + } + if (activeCallbacks.isEmpty()) { + ACTIVE_CALLBACKS.remove(); + } + } + + private void beginCallback() { + for (Dataset inheritedDataset : inheritedDatasets) { + begin(inheritedDataset); + } + begin(dataset); + } + + private void endCallback() { + // Release in reverse acquisition order so nested contexts unwind cleanly. + end(dataset); + for (Dataset inheritedDataset : inheritedDatasets) { + end(inheritedDataset); + } + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + beginCallback(); + try { + delegate.stageStart(stage, total, unit); + } finally { + endCallback(); + } + } + + @Override + public void stageProgress(String stage, long completed) { + beginCallback(); + try { + delegate.stageProgress(stage, completed); + } finally { + endCallback(); + } + } + + @Override + public void stageComplete(String stage) { + beginCallback(); + try { + delegate.stageComplete(stage); + } finally { + endCallback(); + } + } +} diff --git a/java/src/main/java/org/lance/DataStorageVersion.java b/java/src/main/java/org/lance/DataStorageVersion.java new file mode 100644 index 00000000000..f8bee63ed57 --- /dev/null +++ b/java/src/main/java/org/lance/DataStorageVersion.java @@ -0,0 +1,64 @@ +/* + * 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; + +/** + * Output data file version. Exact selectors name a specific format version; release selectors are + * resolved by the Rust engine. An operation cannot cross the dataset's V1/V2 boundary. + * + *

{@code
+ * CompactionOptions.builder().withDataStorageVersion(DataStorageVersion.V2_2).build();
+ * }
+ */ +public enum DataStorageVersion { + /** Legacy V1 format, usable only with legacy datasets. */ + LEGACY("0.1"), + /** Exact V2.0 format. */ + V2_0("2.0"), + /** Exact V2.1 format. */ + V2_1("2.1"), + /** Exact V2.2 format. */ + V2_2("2.2"), + /** Exact V2.3 format, currently unstable and intended for experimentation. */ + V2_3("2.3"), + /** The stable format selected by the executing engine release. */ + STABLE("stable"), + /** The next format selected by the executing engine release. */ + NEXT("next"); + + private final String rustString; + + DataStorageVersion(String rustString) { + this.rustString = rustString; + } + + /** Returns the selector understood by the Rust API. */ + public String toRustString() { + return rustString; + } + + /** + * Decodes a canonical selector returned by Rust or stored in serialized compaction options. + * + * @throws IllegalArgumentException if the selector is unknown + */ + public static DataStorageVersion fromRustString(String value) { + for (DataStorageVersion version : values()) { + if (version.rustString.equals(value)) { + return version; + } + } + throw new IllegalArgumentException("Unknown data storage version: " + value); + } +} diff --git a/java/src/main/java/org/lance/Dataset.java b/java/src/main/java/org/lance/Dataset.java index 7ac360d7488..85b11030d08 100644 --- a/java/src/main/java/org/lance/Dataset.java +++ b/java/src/main/java/org/lance/Dataset.java @@ -13,28 +13,38 @@ */ package org.lance; +import org.lance.cleanup.CleanupExplanation; import org.lance.cleanup.CleanupPolicy; import org.lance.cleanup.RemovalStats; import org.lance.compaction.CompactionOptions; import org.lance.delta.DatasetDelta; +import org.lance.file.FileWriteOptions; import org.lance.index.Index; +import org.lance.index.IndexBuildProgress; import org.lance.index.IndexCriteria; import org.lance.index.IndexDescription; import org.lance.index.IndexOptions; import org.lance.index.IndexParams; import org.lance.index.IndexType; import org.lance.index.OptimizeOptions; -import org.lance.io.StorageOptionsProvider; +import org.lance.index.scalar.ZoneStats; import org.lance.ipc.DataStatistics; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; +import org.lance.memwal.InitializeMemWalParams; +import org.lance.memwal.MemWalIndexDetails; +import org.lance.memwal.ShardWriter; +import org.lance.memwal.ShardWriterConfig; import org.lance.merge.MergeInsertParams; import org.lance.merge.MergeInsertResult; +import org.lance.namespace.LanceNamespace; import org.lance.operation.UpdateConfig; import org.lance.operation.UpdateMap; import org.lance.schema.ColumnAlteration; import org.lance.schema.LanceSchema; import org.lance.schema.SqlExpressions; +import org.lance.update.UpdateParams; +import org.lance.update.UpdateResult; import org.lance.util.JsonUtils; import org.apache.arrow.c.ArrowArrayStream; @@ -46,6 +56,7 @@ import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.ipc.ArrowStreamReader; import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import java.io.ByteArrayInputStream; @@ -54,11 +65,14 @@ import java.nio.ByteBuffer; import java.nio.channels.Channels; import java.nio.channels.ReadableByteChannel; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.UUID; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Collectors; /** @@ -76,8 +90,16 @@ public class Dataset implements Closeable { private BufferAllocator allocator; private boolean selfManagedAllocator = false; + private Session session; + private boolean ownsSession = false; - private final LockManager lockManager = new LockManager(); + /** + * Serializes create/merge index builds on this Dataset handle. Always acquire the lifecycle read + * lock first so a queued close cannot deadlock a reentrant read-lock owner. + */ + private final ReentrantLock indexBuildLock = new ReentrantLock(); + + private final LockManager lockManager = new LockManager(this); private Dataset() {} @@ -97,12 +119,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();
@@ -147,8 +169,12 @@ public static Dataset create(
               params.getDataStorageVersion(),
               params.getEnableV2ManifestPaths(),
               params.getStorageOptions(),
+              params.getBaseStoreParams(),
               params.getInitialBases(),
-              params.getTargetBases());
+              params.getTargetBases(),
+              params.getAllowExternalBlobOutsideBases(),
+              params.getBlobPackFileSizeThreshold(),
+              params.getFileWriteOptions());
       dataset.allocator = allocator;
       return dataset;
     }
@@ -166,23 +192,99 @@ public static Dataset create(
    *     Dataset.write().allocator(allocator).stream(stream).uri(path)
    *     .mode(WriteMode.CREATE).execute()}
    */
+  /**
+   * Create a dataset with given stream.
+   *
+   * @param allocator buffer allocator
+   * @param stream arrow stream
+   * @param path dataset uri
+   * @param params write parameters
+   * @return Dataset
+   * @deprecated Use {@link #write()} builder instead.
+   */
   @Deprecated
   public static Dataset create(
       BufferAllocator allocator, ArrowArrayStream stream, String path, WriteParams params) {
-    return create(allocator, stream, path, params, null);
+    return create(allocator, stream, path, params, null, null, false);
   }
 
+  private static native Dataset createWithFfiSchema(
+      long arrowSchemaMemoryAddress,
+      String path,
+      Optional maxRowsPerFile,
+      Optional maxRowsPerGroup,
+      Optional maxBytesPerFile,
+      Optional mode,
+      Optional enableStableRowIds,
+      Optional dataStorageVersion,
+      Optional enableV2ManifestPaths,
+      Map storageOptions,
+      Map> baseStoreParams,
+      Optional> initialBases,
+      Optional> targetBases,
+      Optional allowExternalBlobOutsideBases,
+      Optional blobPackFileSizeThreshold,
+      FileWriteOptions fileWriteOptions);
+
   /**
-   * Create a dataset with given stream and storage options provider.
+   * Creates a dataset from an FFI arrow stream.
    *
-   * 

This method supports credential vending through the StorageOptionsProvider interface, which - * allows for dynamic credential refresh during long-running write operations. + * @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 baseStoreParams runtime-only object store parameters keyed by base path URI + * @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, + Optional maxRowsPerFile, + Optional maxRowsPerGroup, + Optional maxBytesPerFile, + Optional mode, + Optional enableStableRowIds, + Optional dataStorageVersion, + Optional enableV2ManifestPaths, + Map storageOptions, + Map> baseStoreParams, + Optional> initialBases, + Optional> targetBases, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold, + FileWriteOptions fileWriteOptions, + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning); + + /** + * Creates a dataset with optional namespace client support for managed versioning. + * + *

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 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( @@ -190,13 +292,15 @@ static Dataset create( ArrowArrayStream stream, String path, WriteParams params, - StorageOptionsProvider storageOptionsProvider) { + 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(), @@ -207,56 +311,19 @@ static Dataset create( params.getDataStorageVersion(), params.getEnableV2ManifestPaths(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider), + params.getBaseStoreParams(), params.getInitialBases(), - params.getTargetBases()); + params.getTargetBases(), + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), + params.getFileWriteOptions(), + namespaceClient, + tableId, + namespaceClientManagedVersioning); dataset.allocator = allocator; return dataset; } - private static native Dataset createWithFfiSchema( - long arrowSchemaMemoryAddress, - String path, - Optional maxRowsPerFile, - Optional maxRowsPerGroup, - Optional maxBytesPerFile, - Optional mode, - Optional enableStableRowIds, - Optional dataStorageVersion, - Optional enableV2ManifestPaths, - Map storageOptions, - Optional> initialBases, - Optional> targetBases); - - private static native Dataset createWithFfiStream( - long arrowStreamMemoryAddress, - String path, - Optional maxRowsPerFile, - Optional maxRowsPerGroup, - Optional maxBytesPerFile, - Optional mode, - Optional enableStableRowIds, - Optional dataStorageVersion, - 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); - /** * Open a dataset from the specified path. * @@ -266,7 +333,13 @@ private static native Dataset createWithFfiStreamAndProvider( */ @Deprecated public static Dataset open(String path) { - return open(new RootAllocator(Long.MAX_VALUE), true, path, new ReadOptions.Builder().build()); + return open( + new RootAllocator(Long.MAX_VALUE), + true, + path, + new ReadOptions.Builder().build(), + new HashMap<>(), + null); } /** @@ -280,7 +353,8 @@ public static Dataset open(String path) { */ @Deprecated public static Dataset open(String path, ReadOptions options) { - return open(new RootAllocator(Long.MAX_VALUE), true, path, options); + return open( + new RootAllocator(Long.MAX_VALUE), true, path, options, options.getBaseStoreParams(), null); } /** @@ -309,7 +383,7 @@ public static Dataset open(String path, BufferAllocator allocator) { */ @Deprecated public static Dataset open(BufferAllocator allocator, String path, ReadOptions options) { - return open(allocator, false, path, options); + return open(allocator, false, path, options, options.getBaseStoreParams(), null); } /** @@ -320,34 +394,150 @@ public static Dataset open(BufferAllocator allocator, String path, ReadOptions o * @return Dataset */ static Dataset open( - BufferAllocator allocator, boolean selfManagedAllocator, String path, ReadOptions options) { + BufferAllocator allocator, + boolean selfManagedAllocator, + String path, + ReadOptions options, + Session session) { + return open( + allocator, selfManagedAllocator, path, options, options.getBaseStoreParams(), session); + } + + static Dataset open( + BufferAllocator allocator, + boolean selfManagedAllocator, + String path, + ReadOptions options, + Map> baseStoreParams, + Session session) { + return open( + allocator, + selfManagedAllocator, + path, + options, + baseStoreParams, + session, + null, + null, + false); + } + + /** + * 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 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( + BufferAllocator allocator, + boolean selfManagedAllocator, + String path, + ReadOptions options, + Map> baseStoreParams, + Session session, + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning) { Preconditions.checkNotNull(path); Preconditions.checkNotNull(allocator); Preconditions.checkNotNull(options); + + Session effectiveSession = session; + if (effectiveSession == null && options.getSession().isPresent()) { + effectiveSession = options.getSession().get(); + } + long sessionHandle = effectiveSession != null ? effectiveSession.getNativeHandle() : 0; + Dataset dataset = openNative( path, options.getVersion(), + options.getRef(), options.getBlockSize(), options.getIndexCacheSizeBytes(), options.getMetadataCacheSizeBytes(), options.getStorageOptions(), + baseStoreParams, options.getSerializedManifest(), - options.getStorageOptionsProvider()); + sessionHandle, + namespaceClient, + tableId, + namespaceClientManagedVersioning); dataset.allocator = allocator; dataset.selfManagedAllocator = selfManagedAllocator; + if (effectiveSession != null) { + dataset.session = effectiveSession; + } else { + dataset.session = Session.fromHandle(dataset.nativeGetSessionHandle()); + dataset.ownsSession = true; + } return dataset; } private static native Dataset openNative( String path, Optional version, + Optional ref, Optional blockSize, long indexCacheSize, long metadataCacheSizeBytes, Map storageOptions, + Map> baseStoreParams, Optional serializedManifest, - Optional storageOptionsProvider); + long sessionHandle, + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning); + + /** + * List manifest locations without reading or deserializing the manifest contents. + * + *

The returned locations are not guaranteed to be ordered. This operation may list and + * materialize the full manifest history. + * + *

This method is for datasets whose committed manifests can be listed authoritatively from the + * object store. Namespace-managed tables, external version stores such as {@code s3+ddb}, and + * tables using a custom commit handler are not supported. + * + * @param uri dataset URI + * @return manifest locations + */ + public static List listManifestLocations(String uri) { + return listManifestLocations(uri, new HashMap<>()); + } + + /** + * List manifest locations without reading or deserializing the manifest contents. + * + *

The returned locations are not guaranteed to be ordered. This operation may list and + * materialize the full manifest history. + * + *

This method is for datasets whose committed manifests can be listed authoritatively from the + * object store. Namespace-managed tables, external version stores such as {@code s3+ddb}, and + * tables using a custom commit handler are not supported. + * + * @param uri dataset URI + * @param storageOptions object-store credentials and connection options + * @return manifest locations + */ + public static List listManifestLocations( + String uri, Map storageOptions) { + Preconditions.checkNotNull(uri, "uri must not be null"); + Preconditions.checkNotNull(storageOptions, "storageOptions must not be null"); + return listManifestLocationsNative(uri, storageOptions); + } + + private static native List listManifestLocationsNative( + String uri, Map storageOptions); /** * Creates a builder for opening a dataset. @@ -363,11 +553,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();
    * }
@@ -434,14 +624,19 @@ public BufferAllocator allocator() { return allocator; } + /** Package-private setter for allocator, used by {@link CommitBuilder}. */ + void setAllocator(BufferAllocator allocator) { + this.allocator = allocator; + } + /** * Create a new transaction builder at current version for the dataset. The dataset itself will * not refresh after the transaction committed. * - * @return A new instance of {@link Transaction.Builder} linked to the opened dataset. + * @return A new instance of {@link SourcedTransaction.Builder} linked to the opened dataset. */ - public Transaction.Builder newTransactionBuilder() { - return new Transaction.Builder(this).readVersion(version()); + public SourcedTransaction.Builder newTransactionBuilder() { + return new SourcedTransaction.Builder(this); } /** @@ -471,27 +666,33 @@ public Dataset commitTransaction(Transaction transaction) { public Dataset commitTransaction( Transaction transaction, boolean detached, boolean enableV2ManifestPaths) { Preconditions.checkNotNull(transaction); - try { - Dataset dataset = nativeCommitTransaction(transaction, detached, enableV2ManifestPaths); - if (selfManagedAllocator) { - dataset.allocator = new RootAllocator(Long.MAX_VALUE); - } else { - dataset.allocator = allocator; - } - return dataset; - } finally { - transaction.release(); + Dataset dataset = + new CommitBuilder(this) + .detached(detached) + .enableV2ManifestPaths(enableV2ManifestPaths) + .execute(transaction); + if (selfManagedAllocator) { + dataset.allocator = new RootAllocator(Long.MAX_VALUE); + } else { + dataset.allocator = allocator; } + return dataset; } - private native Dataset nativeCommitTransaction( - Transaction transaction, boolean detached, boolean enableV2ManifestPaths); - /** - * Drop a Dataset. + * Drop a Dataset, deleting everything under {@code path} recursively. + * + *

To limit the damage a mistyped or misconfigured path can do, {@code path} must be a dataset + * root, meaning it holds a manifest that can be read, or a namespace declare/deregister marker. + * Anything else throws {@link IllegalArgumentException}, including a path that holds only data + * files or only unreadable manifests: such leftovers need an explicit storage-level delete. + * + *

Note that a path which passes this check is deleted in full, including any unmanaged files + * kept next to the dataset. * * @param path The file path of the dataset * @param storageOptions Storage options + * @throws IllegalArgumentException if {@code path} is not a Lance dataset root */ public static native void drop(String path, Map storageOptions); @@ -599,11 +800,31 @@ public void dropColumns(List columns) { public void alterColumns(List columnAlterations) { try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - nativeAlterColumns(columnAlterations); + // Cast target types are carried across the FFI boundary through the Arrow C Data + // Interface rather than ArrowType#toString(), which does not round-trip reliably on + // the native side (parameterized types such as Int(64, true) fail to parse and the + // cast would otherwise be silently dropped). One field is exported per alteration that + // requests a type change, in the same order as {@code columnAlterations}. + List castFields = new ArrayList<>(); + int castIndex = 0; + for (ColumnAlteration alteration : columnAlterations) { + if (alteration.getDataType().isPresent()) { + castFields.add(new Field("f" + castIndex++, castFieldType(alteration), null)); + } + } + try (ArrowSchema castSchema = ArrowSchema.allocateNew(allocator)) { + Data.exportSchema(allocator, new Schema(castFields), null, castSchema); + nativeAlterColumns(columnAlterations, castSchema.memoryAddress()); + } } } - private native void nativeAlterColumns(List columnAlterations); + private static FieldType castFieldType(ColumnAlteration alteration) { + boolean nullable = alteration.getNullable().orElse(true); + return new FieldType(nullable, alteration.getDataType().get(), null); + } + + private native void nativeAlterColumns(List columnAlterations, long castAddr); /** * Create a new Dataset Scanner. @@ -664,6 +885,86 @@ public void close() throws IOException { private native byte[] nativeTake(List indices, List columns); + /** + * Select rows of data by their physical row IDs. + * + * @param rowIds the physical row IDs to retrieve (from the _rowid column) + * @param columns the columns to include in the result + * @return an ArrowReader containing the requested rows in input order + * @throws IOException if a row ID does not exist or an I/O error occurs + */ + public ArrowReader takeRows(List rowIds, List columns) throws IOException { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + Preconditions.checkArgument( + rowIds != null && !rowIds.isEmpty(), "rowIds cannot be null or empty"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + byte[] arrowData = nativeTakeRows(rowIds, columns); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(arrowData); + ReadableByteChannel readChannel = Channels.newChannel(byteArrayInputStream); + return new ArrowStreamReader(readChannel, allocator) { + @Override + public void close() throws IOException { + super.close(); + readChannel.close(); + byteArrayInputStream.close(); + } + }; + } + } + + private native byte[] nativeTakeRows(List rowIds, List columns); + + /** + * Randomly sample n rows from the dataset. + * + *

The returned rows are in row-id order (not random order), which allows the underlying take + * operation to use an efficient sorted code path. + * + * @param n the number of rows to sample + * @param columns the columns to include in the result + * @return an ArrowReader containing the sampled rows + * @throws IOException if an I/O error occurs + */ + public ArrowReader sample(long n, List columns) throws IOException { + return sample(n, columns, Optional.empty()); + } + + /** + * Randomly sample n rows from specific fragments of the dataset. + * + *

The returned rows are in row-id order (not random order), which allows the underlying take + * operation to use an efficient sorted code path. + * + * @param n the number of rows to sample + * @param columns the columns to include in the result + * @param fragmentIds optional list of fragment IDs to restrict sampling to + * @return an ArrowReader containing the sampled rows + * @throws IOException if an I/O error occurs + */ + public ArrowReader sample(long n, List columns, Optional> fragmentIds) + throws IOException { + Preconditions.checkArgument(n > 0, "n must be greater than 0"); + Preconditions.checkNotNull(columns, "columns cannot be null"); + Preconditions.checkArgument(!columns.isEmpty(), "columns cannot be empty"); + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + byte[] arrowData = nativeSample(n, columns, fragmentIds); + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(arrowData); + ReadableByteChannel readChannel = Channels.newChannel(byteArrayInputStream); + return new ArrowStreamReader(readChannel, allocator) { + @Override + public void close() throws IOException { + super.close(); + readChannel.close(); + byteArrayInputStream.close(); + } + }; + } + } + + private native byte[] nativeSample( + long n, List columns, Optional> fragmentIds); + /** * Delete rows of data by predicate. * @@ -742,11 +1043,28 @@ public List listVersions() { private native List nativeListVersions(); + /** + * Get the number of versions in the current version history. + * + *

Unlike {@link #listVersions()}, this method does not read or deserialize every manifest. + * Detached versions are not included. + * + * @return the number of versions + */ + public long getVersionCount() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetVersionCount(); + } + } + + private native long nativeGetVersionCount(); + /** * @return the latest version of the dataset. */ public long latestVersion() { - try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); return nativeGetLatestVersionId(); } @@ -811,13 +1129,7 @@ public Dataset checkoutVersion(long version) { Preconditions.checkArgument(version > 0, "version number must be greater than 0"); try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - Dataset newDataset = nativeCheckoutVersion(version); - if (selfManagedAllocator) { - newDataset.allocator = new RootAllocator(Long.MAX_VALUE); - } else { - newDataset.allocator = allocator; - } - return newDataset; + return initializeCheckoutDataset(nativeCheckoutVersion(version)); } } @@ -834,18 +1146,23 @@ public Dataset checkoutTag(String tag) { Preconditions.checkArgument(tag != null, "Tag can not be null"); try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - Dataset newDataset = nativeCheckoutTag(tag); - if (selfManagedAllocator) { - newDataset.allocator = new RootAllocator(Long.MAX_VALUE); - } else { - newDataset.allocator = allocator; - } - return newDataset; + return initializeCheckoutDataset(nativeCheckoutTag(tag)); } } private native Dataset nativeCheckoutTag(String tag); + private Dataset initializeCheckoutDataset(Dataset checkedOutDataset) { + if (selfManagedAllocator) { + checkedOutDataset.allocator = new RootAllocator(Long.MAX_VALUE); + } else { + checkedOutDataset.allocator = allocator; + } + checkedOutDataset.session = Session.fromHandle(checkedOutDataset.nativeGetSessionHandle()); + checkedOutDataset.ownsSession = true; + return checkedOutDataset; + } + /** * Restore the currently checked out version of the dataset as the latest version. This operation * produces a new version and doesn't influence any old versions and tags. @@ -887,22 +1204,93 @@ public Index createIndex( /** * Creates a new index on the dataset. * + *

Concurrent {@link #createIndex} / {@link #mergeIndexMetadata} calls on the same Dataset + * handle are serialized. + * * @param options options for building index * @return the metadata of the created index */ public Index createIndex(IndexOptions options) { + Preconditions.checkNotNull(options, "options cannot be null"); + return createIndexInternal(options, null); + } + + /** + * Creates a new index on the dataset while reporting stage-level progress. + * + *

Stage names, work units, and whether a total is available depend on the index type. The + * callback must be thread-safe because Lance may invoke it concurrently from native runtime + * threads. Callbacks may re-enter read-only methods on this Dataset. Conflicting write re-entry + * from a callback is rejected; unrelated concurrent callers keep their normal wait behavior. + * Concurrent {@link #createIndex} / {@link #mergeIndexMetadata} calls on the same Dataset handle + * are serialized. + * + *

{@code
+   * Index index = dataset.createIndex(options, new IndexBuildProgress() {
+   *   public void stageStart(String stage, Optional total, String unit) { }
+   *   public void stageProgress(String stage, long completed) { }
+   *   public void stageComplete(String stage) { }
+   * });
+   * }
+ * + * @param options options for building index + * @param progress thread-safe progress callback + * @return the metadata of the created index + */ + public Index createIndex(IndexOptions options, IndexBuildProgress progress) { + Preconditions.checkNotNull(options, "options cannot be null"); + Preconditions.checkNotNull(progress, "progress cannot be null"); + return createIndexInternal(options, progress); + } + + private Index createIndexInternal(IndexOptions options, IndexBuildProgress progress) { + if (ContextIndexBuildProgress.isActive(this)) { + throw new IllegalStateException("Dataset is busy in an index progress callback"); + } try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - return nativeCreateIndex( - options.getColumns(), - options.getIndexType().getValue(), - options.getIndexName(), - options.getIndexParams(), - options.isReplace(), - options.isTrain(), - options.getFragmentIds(), - options.getIndexUUID(), - options.getPreprocessedData().map(ArrowArrayStream::memoryAddress)); + acquireIndexBuildLock(); + try { + if (progress == null) { + return nativeCreateIndex( + options.getColumns(), + options.getIndexType().getValue(), + options.getIndexName(), + options.getIndexParams(), + options.isReplace(), + options.isTrain(), + options.getFragmentIds(), + options.getIndexUUID(), + options.getPreprocessedData().map(ArrowArrayStream::memoryAddress)); + } + return nativeCreateIndexWithProgress( + options.getColumns(), + options.getIndexType().getValue(), + options.getIndexName(), + options.getIndexParams(), + options.isReplace(), + options.isTrain(), + options.getFragmentIds(), + options.getIndexUUID(), + options.getPreprocessedData().map(ArrowArrayStream::memoryAddress), + new ContextIndexBuildProgress(this, progress)); + } finally { + indexBuildLock.unlock(); + } + } + } + + private void acquireIndexBuildLock() { + if (ContextIndexBuildProgress.isCallbackActive()) { + // An outer index build waits for its callback to return, so waiting here could create a + // cross-Dataset lock cycle between two concurrent builds. + if (!indexBuildLock.tryLock()) { + throw new IllegalStateException( + "Dataset is busy with an index build and cannot start another build " + + "from an index progress callback"); + } + } else { + indexBuildLock.lock(); } } @@ -917,14 +1305,132 @@ private native Index nativeCreateIndex( Optional indexUUID, Optional arrowStreamMemoryAddress); + private native Index nativeCreateIndexWithProgress( + List columns, + int indexTypeCode, + Optional name, + IndexParams params, + boolean replace, + boolean train, + Optional> fragments, + Optional indexUUID, + Optional arrowStreamMemoryAddress, + IndexBuildProgress progress); + + /** + * Drop an index by name. + * + * @param name the index name to drop + */ + public void dropIndex(String name) { + Preconditions.checkArgument(name != null && !name.isEmpty(), "name cannot be null or empty"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + nativeDropIndex(name); + } + } + + private native void nativeDropIndex(String name); + public void mergeIndexMetadata( String indexUUID, IndexType indexType, Optional batchReadHead) { - innerMergeIndexMetadata(indexUUID, indexType.getValue(), batchReadHead); + mergeIndexMetadataInternal(indexUUID, indexType, batchReadHead, null); } private native void innerMergeIndexMetadata( String indexUUID, int indexType, Optional batchReadHead); + /** + * Merge distributed index metadata while reporting stage-level progress. + * + *

Callback re-entry semantics match {@link #createIndex(IndexOptions, IndexBuildProgress)}: + * read-only Dataset methods are allowed, conflicting write re-entry is rejected, and concurrent + * create/merge builds on this handle are serialized. + * + * @param indexUUID shared UUID used by the distributed index parts + * @param indexType type of index metadata to merge + * @param batchReadHead optional limit for metadata read concurrency + * @param progress thread-safe progress callback + */ + public void mergeIndexMetadata( + String indexUUID, + IndexType indexType, + Optional batchReadHead, + IndexBuildProgress progress) { + Preconditions.checkNotNull(progress, "progress cannot be null"); + mergeIndexMetadataInternal(indexUUID, indexType, batchReadHead, progress); + } + + private void mergeIndexMetadataInternal( + String indexUUID, + IndexType indexType, + Optional batchReadHead, + IndexBuildProgress progress) { + if (ContextIndexBuildProgress.isActive(this)) { + throw new IllegalStateException("Dataset is busy in an index progress callback"); + } + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + acquireIndexBuildLock(); + try { + if (progress == null) { + innerMergeIndexMetadata(indexUUID, indexType.getValue(), batchReadHead); + } else { + innerMergeIndexMetadataWithProgress( + indexUUID, + indexType.getValue(), + batchReadHead, + new ContextIndexBuildProgress(this, progress)); + } + } finally { + indexBuildLock.unlock(); + } + } + } + + private native void innerMergeIndexMetadataWithProgress( + String indexUUID, + int indexType, + Optional batchReadHead, + IndexBuildProgress progress); + + /** 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 nativeMergeExistingIndexSegments(segments); + } + } + + private native Index nativeMergeExistingIndexSegments(List segments); + + /** + * 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. * @@ -954,11 +1460,35 @@ public long countRows(String filter) { private native long nativeCountRows(Optional filter); + /** + * Returns the session associated with this dataset. + * + *

The session holds runtime state for the dataset, including index and metadata caches. If a + * session was provided when opening the dataset, that session is returned. Otherwise, a new + * session was created automatically. + * + *

The returned session can be used to open other datasets to share caches. + * + * @return the session associated with this dataset + */ + public Session session() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return session; + } + } + + private native long nativeGetSessionHandle(); + /** * Count rows matching a filter using a specific scalar index. This directly queries the index and * counts matching row addresses, which is more efficient than scanning when the index covers the * filter column. * + *

Planning is pinned to {@code indexName}. A filter that cannot be answered by that scalar + * index is rejected instead of scanning the table or selecting another index. Deleted rows are + * excluded. + * * @param indexName the name of the scalar index to use * @param filter the filter expression (e.g., "column = 5") * @param fragmentIds optional list of fragment IDs to restrict the count to @@ -976,9 +1506,58 @@ public long countIndexedRows( } } + /** + * Count rows matching a filter using explicit physical segments of a scalar index. + * + *

Only {@code segmentUuids} are opened. Their current fragment coverage defines the count + * scope: matching deleted rows inside that scope are excluded, and rows outside it are not + * counted. When {@code fragmentIds} is omitted, the scope is derived from that coverage. When it + * is present, its set must equal the coverage; order does not matter. A mismatch is rejected with + * an error that reports both sets. + * + *

The selection is accepted only when it includes every segment that contributes to that + * coverage. After fragment reuse, one source segment can advertise every destination fragment + * while still depending on its siblings. An incomplete selection is rejected, and the error names + * the missing segment UUIDs. A segment whose coverage does not overlap the selection can be + * queried alone. + * + *

An empty segment list, duplicate segment UUIDs, an unknown UUID, a UUID from a different + * index, or a segment without fragment coverage is rejected. The existing three-argument method + * remains available and does not take a segment list. + * + * @param indexName the logical scalar index name that every selected segment must belong to + * @param filter the filter expression (e.g., "column = 5") + * @param segmentUuids physical segment UUIDs to open; must be non-empty and contain no duplicates + * @param fragmentIds optional fragment IDs that must match the selected segments' current + * coverage + * @return count of matching rows in the selected segment scope + */ + public long countIndexedRows( + String indexName, + String filter, + List segmentUuids, + Optional> fragmentIds) { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + Preconditions.checkArgument( + indexName != null && !indexName.isEmpty(), "indexName cannot be null or empty"); + Preconditions.checkArgument( + filter != null && !filter.isEmpty(), "filter cannot be null or empty"); + Preconditions.checkNotNull(segmentUuids, "segmentUuids cannot be null"); + Preconditions.checkArgument(!segmentUuids.isEmpty(), "segmentUuids cannot be empty"); + return nativeCountIndexedRowsWithSegments(indexName, filter, segmentUuids, fragmentIds); + } + } + private native long nativeCountIndexedRows( String indexName, String filter, Optional> fragmentIds); + private native long nativeCountIndexedRowsWithSegments( + String indexName, + String filter, + List segmentUuids, + Optional> fragmentIds); + /** * Calculate the size of the dataset. * @@ -1018,6 +1597,40 @@ public List getFragments() { private native List getFragmentsNative(); + /** + * Returns the storage bases registered by the current manifest. + * + *

Files without a base id resolve against this dataset's own URI. Files with a base id resolve + * against the matching entry in this list. + */ + public List getBasePaths() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetBasePaths(); + } + } + + private native List nativeGetBasePaths(); + + /** + * Get per-fragment statistics for all fragments in this dataset version. + * + *

Unlike {@link #getFragments()}, this is a metadata-only bulk operation: no per-fragment Java + * objects are materialized, and native code fills the returned primitive arrays directly. This + * makes it suitable for planning over datasets with a very large number of fragments. Row counts + * match {@link FragmentMetadata#getNumRows()} (physical rows minus deleted rows). + * + * @return per-fragment statistics as parallel arrays, in manifest order + */ + public FragmentStatistics getFragmentStatistics() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetFragmentStatistics(); + } + } + + private native FragmentStatistics nativeGetFragmentStatistics(); + /** * Gets the arrow schema of the dataset. * @@ -1152,6 +1765,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. * @@ -1166,6 +1802,55 @@ 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 library version that wrote the current manifest. + * + *

Older manifests may not contain writer version metadata. + * + * @return the current manifest writer version, or empty if unavailable + */ + public Optional getWriterVersion() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return Optional.ofNullable(nativeGetWriterVersion()); + } + } + + private native WriterVersion nativeGetWriterVersion(); + + /** + * Get the Lance file format version of this dataset. + * + *

The returned string will be one of: "0.1" (legacy), "2.0", "2.1", or "2.2". + * + * @return the Lance file format version string + */ + public String getLanceFileFormatVersion() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeGetLanceFileFormatVersion(); + } + } + + private native String nativeGetLanceFileFormatVersion(); + /** * Compact the dataset to improve performance. * @@ -1202,6 +1887,9 @@ public void compact() { */ @Deprecated public void updateConfig(Map tableConfig) { + if (ContextIndexBuildProgress.isActive(this)) { + throw new IllegalStateException("Dataset is busy in an index progress callback"); + } UpdateMap configUpdate = UpdateMap.builder().updates(tableConfig).replace(true).build(); UpdateConfig operation = UpdateConfig.builder().configUpdates(configUpdate).build(); @@ -1218,6 +1906,9 @@ public void updateConfig(Map tableConfig) { */ @Deprecated public void deleteConfigKeys(Set deleteKeys) { + if (ContextIndexBuildProgress.isActive(this)) { + throw new IllegalStateException("Dataset is busy in an index progress callback"); + } Map deleteMap = new HashMap<>(); deleteKeys.forEach(key -> deleteMap.put(key, null)); UpdateMap configUpdate = UpdateMap.builder().updates(deleteMap).replace(false).build(); @@ -1245,6 +1936,26 @@ private void updateToNewDataset(Dataset newDataset) { newDataset.nativeDatasetHandle = 0; } + /** + * Acquires a shared read lock that pins the native dataset handle, blocking a concurrent {@link + * #close()} until the lock is released. + * + *

Any code that passes this {@link Dataset} into a native method must hold this lock for the + * whole native call; otherwise {@code close()} can release the native dataset mid-call and crash + * the JVM. The lock is reentrant and intended for try-with-resources use. + * + * @return the acquired read lock + * @throws IllegalArgumentException if the dataset is already closed + */ + public LockManager.ReadLock acquireReadLock() { + LockManager.ReadLock readLock = lockManager.acquireReadLock(); + if (nativeDatasetHandle == 0) { + readLock.close(); + throw new IllegalArgumentException("Dataset is closed"); + } + return readLock; + } + /** * Closes this dataset and releases any system resources associated with it. If the dataset is * already closed, then invoking this method has no effect. @@ -1259,6 +1970,11 @@ public void close() { if (selfManagedAllocator) { allocator.close(); } + if (ownsSession && session != null) { + session.close(); + session = null; + ownsSession = false; + } } } @@ -1274,12 +1990,55 @@ public void close() { private native List nativeTakeBlobsByIndices(List rowIndices, String column); + private static void checkReadBufferSize(long bufferSize) { + if (bufferSize < 0) { + throw new IllegalArgumentException("bufferSize must be non-negative"); + } + } + + static void setBlobReadBufferSize(List blobs, long bufferSize) throws IOException { + try { + for (BlobFile blob : blobs) { + if (blob != null) { + blob.setReadBufferSize(bufferSize); + } + } + } catch (IOException | RuntimeException e) { + for (BlobFile blob : blobs) { + if (blob != null) { + try { + blob.close(); + } catch (IOException | RuntimeException closeError) { + e.addSuppressed(closeError); + } + } + } + throw e; + } + } + /** - * Open blob files for given row ids on a blob column. Names and semantics align with Rust/Python. + * Open {@link BlobFile} handles for given row IDs on a blob column. Names and semantics align + * with Rust/Python. + * + *

Pass logical row IDs read from {@code _rowid}, not physical row addresses from {@code + * _rowaddr}. * - * @param rowIds stable row ids (row addresses) + *

{@code
+   * long rowId = 42L; // Example value from the _rowid column.
+   * List blobs = dataset.takeBlobs(List.of(rowId), "images");
+   * for (BlobFile blob : blobs) {
+   *   if (blob != null) {
+   *     try (BlobFile file = blob) {
+   *       byte[] data = file.read();
+   *     }
+   *   }
+   * }
+   * }
+ * + * @param rowIds logical row IDs from the {@code _rowid} column * @param column blob column name - * @return list of BlobFile objects + * @return one {@link BlobFile} per row ID; null blob values are represented by null elements */ public List takeBlobs(List rowIds, String column) { try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { @@ -1293,11 +2052,35 @@ public List takeBlobs(List rowIds, String column) { } /** - * Open blob files for given row indices on a blob column. + * Open {@link BlobFile} handles and set sequential read-ahead size. + * + * @param bufferSize sequential read-ahead size in bytes. {@code 0} disables read-ahead + */ + public List takeBlobs(List rowIds, String column, long bufferSize) + throws IOException { + checkReadBufferSize(bufferSize); + List blobs = takeBlobs(rowIds, column); + setBlobReadBufferSize(blobs, bufferSize); + return blobs; + } + + /** + * Open {@link BlobFile} handles for given row indices on a blob column. + * + *
{@code
+   * List blobs = dataset.takeBlobsByIndices(List.of(0L), "images");
+   * for (BlobFile blob : blobs) {
+   *   if (blob != null) {
+   *     try (BlobFile file = blob) {
+   *       byte[] data = file.read();
+   *     }
+   *   }
+   * }
+   * }
* * @param rowIndices row offsets within dataset * @param column blob column name - * @return list of BlobFile objects + * @return one {@link BlobFile} per row index; null blob values are represented by null elements */ public List takeBlobsByIndices(List rowIndices, String column) { try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { @@ -1310,6 +2093,19 @@ public List takeBlobsByIndices(List rowIndices, String column) { } } + /** + * Open {@link BlobFile} handles by row index and set sequential read-ahead size. + * + * @param bufferSize sequential read-ahead size in bytes. {@code 0} disables read-ahead + */ + public List takeBlobsByIndices(List rowIndices, String column, long bufferSize) + throws IOException { + checkReadBufferSize(bufferSize); + List blobs = takeBlobsByIndices(rowIndices, column); + setBlobReadBufferSize(blobs, bufferSize); + return blobs; + } + /** * Checks if the dataset is closed. * @@ -1345,10 +2141,12 @@ public Branches branches() { /** * Create a branch at a specified version. The returned Dataset points to the created branch's - * initial version. + * initial version. The branch name {@code "main"} is reserved for the default branch and cannot + * be used as a new branch name. * * @param branch the branch name to create - * @param ref the reference to create branch from + * @param ref the reference to create branch from. In reference contexts, {@code "main"} is an + * alias for the default branch. * @return a new Dataset of the branch */ public Dataset createBranch(String branch, Ref ref) { @@ -1358,10 +2156,12 @@ public Dataset createBranch(String branch, Ref ref) { /** * Create a branch at a specified version. The returned Dataset points to the created branch's - * initial version. + * initial version. The branch name {@code "main"} is reserved for the default branch and cannot + * be used as a new branch name. * * @param branch the branch name to create - * @param ref the reference to create branch from + * @param ref the reference to create branch from. In reference contexts, {@code "main"} is an + * alias for the default branch. * @param storageOptions the storage options to create branch with * @return a new Dataset of the branch */ @@ -1382,8 +2182,9 @@ private Dataset innerCreateBranch( } /** - * Checkout using a unified {@link Ref} which can be a tag, the latest version on main/branch or a - * specified (branch_name, version_number). + * Checkout using a unified {@link Ref} which can be a tag, the latest version on the default + * branch or a named branch, or a specified (branch_name, version_number). In reference contexts, + * {@code "main"} is an alias for the default branch. * * @param ref the checkout reference * @return a new Dataset instance checked out to the specified reference @@ -1392,13 +2193,7 @@ public Dataset checkout(Ref ref) { Preconditions.checkNotNull(ref); try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); - Dataset newDataset = nativeCheckout(ref); - if (selfManagedAllocator) { - newDataset.allocator = new RootAllocator(Long.MAX_VALUE); - } else { - newDataset.allocator = allocator; - } - return newDataset; + return initializeCheckoutDataset(nativeCheckout(ref)); } } @@ -1420,7 +2215,7 @@ public Map getTableMetadata() { public class Tags { /** - * Create a new tag on main branch. This is left for compatibility. We should use {@link + * Create a new tag on the default branch. This is left for compatibility. We should use {@link * #create(String, Ref)} instead. * * @param tag the tag name @@ -1435,7 +2230,8 @@ public void create(String tag, long versionNumber) { * Create a new tag on a specified branch. * * @param tag the tag name - * @param ref the referenced version to tag + * @param ref the referenced version to tag. In reference contexts, {@code "main"} is an alias + * for the default branch. */ public void create(String tag, Ref ref) { Preconditions.checkArgument(tag != null, "Tag name cannot be null"); @@ -1452,6 +2248,8 @@ public void create(String tag, Ref ref) { * * @param tag the name of the tag to create * @param versionNumber the version number (or commit reference) to associate with the tag + * @param targetBranch the branch to tag. In reference contexts, {@code "main"} is an alias for + * the default branch. */ @Deprecated public void create(String tag, long versionNumber, String targetBranch) { @@ -1471,11 +2269,11 @@ public void delete(String tag) { } /** - * Update a tag to a new version_number on main. This is left for compatibility. We should use - * {@link #update(String, Ref)} instead. + * Update a tag to a new version_number on the default branch. This is left for compatibility. + * We should use {@link #update(String, Ref)} instead. * * @param tag the tag name - * @param versionNumber the versionNumber on main. + * @param versionNumber the versionNumber on the default branch. */ public void update(String tag, long versionNumber) { Preconditions.checkArgument(versionNumber > 0, "version_number must be greater than 0"); @@ -1486,7 +2284,8 @@ public void update(String tag, long versionNumber) { * Update a tag to a new reference. * * @param tag the tag name - * @param ref the referenced version to tag + * @param ref the referenced version to tag. In reference contexts, {@code "main"} is an alias + * for the default branch. */ public void update(String tag, Ref ref) { Preconditions.checkArgument(tag != null, "tag cannot be null"); @@ -1497,6 +2296,15 @@ public void update(String tag, Ref ref) { } } + public void replaceMetadata(String tag, Map metadata) { + Preconditions.checkArgument(tag != null, "tag cannot be null"); + Preconditions.checkArgument(metadata != null, "metadata cannot be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + nativeReplaceTagMetadata(tag, metadata); + } + } + /** * List all tags of the dataset. * @@ -1529,7 +2337,8 @@ public class Branches { /** * Delete a branch and its metadata. * - * @param branchName the branch to delete + * @param branchName the branch to delete. {@code "main"} is reserved for the default branch and + * cannot be deleted as a named branch. */ public void delete(String branchName) { try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { @@ -1549,6 +2358,15 @@ public List list() { return nativeListBranches(); } } + + public void replaceMetadata(String branchName, Map metadata) { + Preconditions.checkArgument(branchName != null, "branchName cannot be null"); + Preconditions.checkArgument(metadata != null, "metadata cannot be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + nativeReplaceBranchMetadata(branchName, metadata); + } + } } /** @@ -1628,12 +2446,107 @@ public MergeInsertResult mergeInsert(MergeInsertParams mergeInsert, ArrowArraySt private native MergeInsertResult nativeMergeInsert( MergeInsertParams mergeInsert, long arrowStreamMemoryAddress); + /** + * Update column values for rows matching an optional predicate. + * + *

This is similar to SQL's {@code UPDATE} statement: the entries of {@link + * UpdateParams#updates()} map target column names to SQL expressions evaluated for every row that + * satisfies {@link UpdateParams#whereClause()}. If no predicate is provided, every row is + * updated. + * + *

The predicate may reference dataset columns as well as the system columns {@code _rowid}, + * {@code _rowaddr}, and {@code _rowoffset}, allowing rows to be targeted by stable row id (e.g. + * {@code "_rowid IN (1, 2, 3)"}). + * + *

This call does not mutate the current {@code Dataset} instance: it still references the + * pre-update version. Callers should close this {@code Dataset} and switch to {@link + * UpdateResult#getDataset()}, which holds the newly committed version. + * + * @param params update parameters + * @return UpdateResult containing the new committed Dataset and the number of rows updated. + */ + public UpdateResult update(UpdateParams params) { + Preconditions.checkNotNull(params, "params must not be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + UpdateResult result = nativeUpdate(params); + + Dataset newDataset = result.getDataset(); + if (selfManagedAllocator) { + newDataset.allocator = new RootAllocator(Long.MAX_VALUE); + } else { + newDataset.allocator = allocator; + } + + return result; + } + } + + private native UpdateResult nativeUpdate(UpdateParams params); + + /** + * Initialize MemWAL on this dataset. + * + *

Must be called once before any call to {@link #memWalWriter}. Append-only tables may omit + * primary-key metadata; primary keys are only required for primary-key lookup and last-write-wins + * deduplication workflows. + * + * @param params MemWAL initialization parameters + */ + public void initializeMemWal(InitializeMemWalParams params) { + Preconditions.checkNotNull(params, "params must not be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + nativeInitializeMemWal(params); + } + } + + private native void nativeInitializeMemWal(InitializeMemWalParams params); + + /** + * Return the MemWAL index details for this dataset, or {@link Optional#empty()} if MemWAL has not + * been initialized. + * + * @return the MemWAL index details, if any + */ + public Optional memWalIndexDetails() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return Optional.ofNullable(nativeMemWalIndexDetails()); + } + } + + private native MemWalIndexDetails nativeMemWalIndexDetails(); + + /** + * Get a {@link ShardWriter} for the specified shard. + * + *

{@link #initializeMemWal} must be called before using this method. + * + * @param shardId UUID string identifying the write shard + * @param config writer configuration; pass {@code null} to use the Lance default configuration + * @return a ShardWriter for the shard + */ + public ShardWriter memWalWriter(String shardId, ShardWriterConfig config) { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return ShardWriter.create(this, shardId, config); + } + } + + /** Get a {@link ShardWriter} for the specified shard using the Lance default configuration. */ + public ShardWriter memWalWriter(String shardId) { + return memWalWriter(shardId, null); + } + private native void nativeCreateTag(String tag, Ref ref); private native void nativeDeleteTag(String tag); private native void nativeUpdateTag(String tag, Ref ref); + private native void nativeReplaceTagMetadata(String tag, Map metadata); + private native List nativeListTags(); private native long nativeGetVersionByTag(String tag); @@ -1648,6 +2561,8 @@ private native Dataset nativeCreateBranch( private native List nativeListBranches(); + private native void nativeReplaceBranchMetadata(String branch, Map metadata); + public Dataset shallowClone(String targetPath, Ref ref) { return shallowClone(targetPath, ref, null); } @@ -1682,6 +2597,20 @@ public Dataset shallowClone(String targetPath, Ref ref, Map stor private native Dataset nativeShallowClone( String targetPath, Ref ref, Optional> storageOptions); + /** + * Create a cleanup operation for the specified policy. + * + *

Use {@link CleanupOperation#explain()} to inspect what cleanup would remove without deleting + * files, or {@link CleanupOperation#execute()} to perform cleanup. + * + * @param policy cleanup policy + * @return cleanup operation + */ + public CleanupOperation cleanup(CleanupPolicy policy) { + Preconditions.checkNotNull(policy, "policy cannot be null"); + return new CleanupOperation(this, policy); + } + /** * Cleanup dataset based on a specified policy. * @@ -1689,6 +2618,10 @@ private native Dataset nativeShallowClone( * @return removal stats */ public RemovalStats cleanupWithPolicy(CleanupPolicy policy) { + return cleanup(policy).execute(); + } + + RemovalStats executeCleanup(CleanupPolicy policy) { try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); return nativeCleanupWithPolicy(policy); @@ -1696,4 +2629,14 @@ public RemovalStats cleanupWithPolicy(CleanupPolicy policy) { } private native RemovalStats nativeCleanupWithPolicy(CleanupPolicy policy); + + CleanupExplanation explainCleanup(CleanupPolicy policy, Optional maxCandidateFiles) { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDatasetHandle != 0, "Dataset is closed"); + return nativeExplainCleanupWithPolicy(policy, maxCandidateFiles); + } + } + + private native CleanupExplanation nativeExplainCleanupWithPolicy( + CleanupPolicy policy, Optional maxCandidateFiles); } diff --git a/java/src/main/java/org/lance/DocumentGranularity.java b/java/src/main/java/org/lance/DocumentGranularity.java new file mode 100644 index 00000000000..c540cc1ff9c --- /dev/null +++ b/java/src/main/java/org/lance/DocumentGranularity.java @@ -0,0 +1,34 @@ +/* + * 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; + +/** The unit treated as one full-text-search document. */ +public enum DocumentGranularity { + /** All text selected from one dataset row belongs to one document. */ + ROW("row"), + + /** Each element of the deepest list on the field path is one document. */ + LIST_ELEMENT("list_element"); + + private final String rustString; + + DocumentGranularity(String rustString) { + this.rustString = rustString; + } + + /** Return the stable value understood by the Rust API and serialized index parameters. */ + public String toRustString() { + return rustString; + } +} diff --git a/java/src/main/java/org/lance/Fragment.java b/java/src/main/java/org/lance/Fragment.java index 8eb1f70053d..fe48b495c71 100644 --- a/java/src/main/java/org/lance/Fragment.java +++ b/java/src/main/java/org/lance/Fragment.java @@ -13,11 +13,13 @@ */ package org.lance; +import org.lance.file.FileWriteOptions; 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.lance.schema.LanceSchema; import org.apache.arrow.c.ArrowArray; import org.apache.arrow.c.ArrowArrayStream; @@ -112,7 +114,9 @@ public LanceScanner newScan(ScanOptions options) { * returns a new fragment with the updated deletion vector. */ public FragmentMetadata deleteRows(List rowIndexes) { - return nativeDeleteRows(dataset, fragmentMetadata.getId(), rowIndexes); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeDeleteRows(dataset, fragmentMetadata.getId(), rowIndexes); + } } private static native FragmentMetadata nativeDeleteRows( @@ -128,7 +132,9 @@ public int getId() { * @return row counts in this Fragment */ public int countRows() { - return countRowsNative(dataset, fragmentMetadata.getId()); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return countRowsNative(dataset, fragmentMetadata.getId()); + } } /** @@ -152,8 +158,10 @@ public int countRows() { * @return the fragment metadata and new schema. */ public FragmentMergeResult mergeColumns(ArrowArrayStream stream, String leftOn, String rightOn) { - return nativeMergeColumns( - dataset, fragmentMetadata.getId(), stream.memoryAddress(), leftOn, rightOn); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeMergeColumns( + dataset, fragmentMetadata.getId(), stream.memoryAddress(), leftOn, rightOn); + } } private native FragmentMergeResult nativeMergeColumns( @@ -185,8 +193,10 @@ private native FragmentMergeResult nativeMergeColumns( */ public FragmentUpdateResult updateColumns( ArrowArrayStream stream, String leftOn, String rightOn) { - return nativeUpdateColumns( - dataset, fragmentMetadata.getId(), stream.memoryAddress(), leftOn, rightOn); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeUpdateColumns( + dataset, fragmentMetadata.getId(), stream.memoryAddress(), leftOn, rightOn); + } } public FragmentUpdateResult updateColumns(ArrowArrayStream stream) { @@ -200,6 +210,33 @@ private native FragmentUpdateResult nativeUpdateColumns( String leftOn, String rightOn); + /** + * Append new columns to this Fragment from a stream of new-column values. This is the + * fragment-level equivalent of {@link Dataset#addColumns(ArrowArrayStream, Optional)}: the stream + * is zipped positionally against the fragment, so it must contain exactly one row for every live + * (non-deleted) row of this fragment, in row address order, and only the new columns. A stream + * with too few or too many rows fails; use {@link #mergeColumns} for inputs that cover a subset + * of the rows. + * + *

Unlike {@link #mergeColumns}, the stream is never buffered in full, so it can backfill + * columns far larger than memory. + * + *

The returned Result will be further committed. + * + * @param stream the new column values, one row per live row in row address order + * @param batchSize read batch size for zipping, or empty for the default + * @return the fragment metadata and new schema + */ + public FragmentMergeResult addColumns(ArrowArrayStream stream, Optional batchSize) { + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeAddColumnsByReader( + dataset, fragmentMetadata.getId(), stream.memoryAddress(), batchSize); + } + } + + private native FragmentMergeResult nativeAddColumnsByReader( + Dataset dataset, long fragmentId, long arrowStreamMemoryAddress, Optional batchSize); + /** * Create a new fragment writer builder. * @@ -234,37 +271,79 @@ 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) { + return create(datasetUri, allocator, root, params, namespaceClient, tableId, null, null); + } + + /** Create a fragment from the given arrow array and schema. */ + static List create( + String datasetUri, + BufferAllocator allocator, + VectorSchemaRoot root, + WriteParams params, + LanceNamespace namespaceClient, + List tableId, + LanceSchema schema, + Session session) { Preconditions.checkNotNull(datasetUri); Preconditions.checkNotNull(allocator); Preconditions.checkNotNull(root); Preconditions.checkNotNull(params); + long sessionHandle = getSessionHandle(session); try (ArrowSchema arrowSchema = ArrowSchema.allocateNew(allocator); ArrowArray arrowArray = ArrowArray.allocateNew(allocator)) { Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchema); + if (schema != null) { + try (ArrowSchema lanceSchema = ArrowSchema.allocateNew(allocator)) { + Data.exportSchema(allocator, schema.asArrowSchemaWithFieldIds(), null, lanceSchema); + return createWithFfiArray( + datasetUri, + arrowArray.memoryAddress(), + arrowSchema.memoryAddress(), + params.getMaxRowsPerFile(), + params.getMaxRowsPerGroup(), + params.getMaxBytesPerFile(), + params.getMode(), + params.getEnableStableRowIds(), + params.getDataStorageVersion(), + params.getStorageOptions(), + params.getBaseStoreParams(), + params.getInitialBases(), + params.getTargetBases(), + namespaceClient, + tableId, + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), + params.getFileWriteOptions(), + lanceSchema.memoryAddress(), + sessionHandle); + } + } return createWithFfiArray( datasetUri, arrowArray.memoryAddress(), @@ -276,48 +355,69 @@ public static List create( params.getEnableStableRowIds(), params.getDataStorageVersion(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider)); + params.getBaseStoreParams(), + params.getInitialBases(), + params.getTargetBases(), + namespaceClient, + tableId, + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), + params.getFileWriteOptions(), + 0L, + sessionHandle); } } - /** - * 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. */ + static List create( + String datasetUri, + ArrowArrayStream stream, + WriteParams params, + LanceNamespace namespaceClient, + List tableId) { + return create(datasetUri, null, stream, params, namespaceClient, tableId, null, 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, + BufferAllocator allocator, ArrowArrayStream stream, WriteParams params, - StorageOptionsProvider storageOptionsProvider) { + LanceNamespace namespaceClient, + List tableId, + LanceSchema schema, + Session session) { Preconditions.checkNotNull(datasetUri); Preconditions.checkNotNull(stream); Preconditions.checkNotNull(params); + long sessionHandle = getSessionHandle(session); + if (schema != null) { + Preconditions.checkNotNull(allocator, "allocator is required with schema"); + try (ArrowSchema lanceSchema = ArrowSchema.allocateNew(allocator)) { + Data.exportSchema(allocator, schema.asArrowSchemaWithFieldIds(), null, lanceSchema); + return createWithFfiStream( + datasetUri, + stream.memoryAddress(), + params.getMaxRowsPerFile(), + params.getMaxRowsPerGroup(), + params.getMaxBytesPerFile(), + params.getMode(), + params.getEnableStableRowIds(), + params.getDataStorageVersion(), + params.getStorageOptions(), + params.getBaseStoreParams(), + params.getInitialBases(), + params.getTargetBases(), + namespaceClient, + tableId, + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), + params.getFileWriteOptions(), + lanceSchema.memoryAddress(), + sessionHandle); + } + } return createWithFfiStream( datasetUri, stream.memoryAddress(), @@ -328,14 +428,27 @@ public static List create( params.getEnableStableRowIds(), params.getDataStorageVersion(), params.getStorageOptions(), - Optional.ofNullable(storageOptionsProvider)); + params.getBaseStoreParams(), + params.getInitialBases(), + params.getTargetBases(), + namespaceClient, + tableId, + params.getAllowExternalBlobOutsideBases(), + params.getBlobPackFileSizeThreshold(), + params.getFileWriteOptions(), + 0L, + sessionHandle); } /** - * Create a fragment from the given arrow array and schema. - * - * @return the fragment metadata + * Resolves the native handle of an optional session. A closed session has a zero handle and is + * treated as absent, matching how Dataset handles closed sessions. */ + private static long getSessionHandle(Session session) { + return session == null ? 0L : session.getNativeHandle(); + } + + /** Create a fragment from the given arrow array and schema. */ private static native List createWithFfiArray( String datasetUri, long arrowArrayMemoryAddress, @@ -347,13 +460,18 @@ private static native List createWithFfiArray( Optional enableStableRowIds, Optional dataStorageVersion, Map storageOptions, - Optional storageOptionsProvider); + Map> baseStoreParams, + Optional> initialBases, + Optional> targetBases, + LanceNamespace namespaceClient, + List tableId, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold, + FileWriteOptions fileWriteOptions, + long schemaMemoryAddress, + long sessionHandle); - /** - * 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 +482,14 @@ private static native List createWithFfiStream( Optional enableStableRowIds, Optional dataStorageVersion, Map storageOptions, - Optional storageOptionsProvider); + Map> baseStoreParams, + Optional> initialBases, + Optional> targetBases, + LanceNamespace namespaceClient, + List tableId, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold, + FileWriteOptions fileWriteOptions, + long schemaMemoryAddress, + long sessionHandle); } diff --git a/java/src/main/java/org/lance/FragmentMetadata.java b/java/src/main/java/org/lance/FragmentMetadata.java index 8bc701b6351..acb132a6925 100644 --- a/java/src/main/java/org/lance/FragmentMetadata.java +++ b/java/src/main/java/org/lance/FragmentMetadata.java @@ -16,6 +16,7 @@ import org.lance.fragment.DataFile; import org.lance.fragment.DeletionFile; import org.lance.fragment.RowIdMeta; +import org.lance.fragment.VersionMeta; import com.google.common.base.MoreObjects; @@ -28,9 +29,12 @@ public class FragmentMetadata implements Serializable { private static final long serialVersionUID = -5886811251944130460L; private final int id; private final List files; + private final List referencedLanceFiles; private final long physicalRows; private final DeletionFile deletionFile; private final RowIdMeta rowIdMeta; + private final VersionMeta createdAtVersionMeta; + private final VersionMeta lastUpdatedAtVersionMeta; public FragmentMetadata( int id, @@ -38,11 +42,45 @@ public FragmentMetadata( Long physicalRows, DeletionFile deletionFile, RowIdMeta rowIdMeta) { + this(id, files, physicalRows, deletionFile, rowIdMeta, null, null, files); + } + + public FragmentMetadata( + int id, + List files, + Long physicalRows, + DeletionFile deletionFile, + RowIdMeta rowIdMeta, + VersionMeta createdAtVersionMeta, + VersionMeta lastUpdatedAtVersionMeta) { + this( + id, + files, + physicalRows, + deletionFile, + rowIdMeta, + createdAtVersionMeta, + lastUpdatedAtVersionMeta, + files); + } + + public FragmentMetadata( + int id, + List files, + Long physicalRows, + DeletionFile deletionFile, + RowIdMeta rowIdMeta, + VersionMeta createdAtVersionMeta, + VersionMeta lastUpdatedAtVersionMeta, + List referencedLanceFiles) { this.id = id; this.files = files; + this.referencedLanceFiles = referencedLanceFiles; this.physicalRows = physicalRows; this.deletionFile = deletionFile; this.rowIdMeta = rowIdMeta; + this.createdAtVersionMeta = createdAtVersionMeta; + this.lastUpdatedAtVersionMeta = lastUpdatedAtVersionMeta; } public int getId() { @@ -53,6 +91,16 @@ public List getFiles() { return files; } + /** + * Returns every Lance data file referenced by this fragment. + * + *

This includes both the base data files returned by {@link #getFiles()} and data overlay + * files. Prefer this method when accounting for storage or copying a fragment. + */ + public List getReferencedLanceFiles() { + return referencedLanceFiles; + } + public long getPhysicalRows() { return physicalRows; } @@ -80,6 +128,14 @@ public RowIdMeta getRowIdMeta() { return rowIdMeta; } + public VersionMeta getCreatedAtVersionMeta() { + return createdAtVersionMeta; + } + + public VersionMeta getLastUpdatedAtVersionMeta() { + return lastUpdatedAtVersionMeta; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -92,8 +148,24 @@ public boolean equals(Object o) { return id == that.id && physicalRows == that.physicalRows && Objects.equals(this.files, that.files) + && Objects.equals(referencedLanceFiles, that.referencedLanceFiles) && Objects.equals(deletionFile, that.deletionFile) - && Objects.equals(rowIdMeta, that.rowIdMeta); + && Objects.equals(rowIdMeta, that.rowIdMeta) + && Objects.equals(createdAtVersionMeta, that.createdAtVersionMeta) + && Objects.equals(lastUpdatedAtVersionMeta, that.lastUpdatedAtVersionMeta); + } + + @Override + public int hashCode() { + return Objects.hash( + id, + physicalRows, + files, + referencedLanceFiles, + deletionFile, + rowIdMeta, + createdAtVersionMeta, + lastUpdatedAtVersionMeta); } @Override @@ -102,8 +174,11 @@ public String toString() { .add("id", id) .add("physicalRows", physicalRows) .add("files", files) + .add("referencedLanceFiles", referencedLanceFiles) .add("deletionFile", deletionFile) .add("rowIdMeta", rowIdMeta) + .add("createdAtVersionMeta", createdAtVersionMeta) + .add("lastUpdatedAtVersionMeta", lastUpdatedAtVersionMeta) .toString(); } } diff --git a/java/src/main/java/org/lance/FragmentStatistics.java b/java/src/main/java/org/lance/FragmentStatistics.java new file mode 100644 index 00000000000..8e4cd53bc54 --- /dev/null +++ b/java/src/main/java/org/lance/FragmentStatistics.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; + +/** + * Per-fragment statistics of a dataset version as parallel primitive arrays: {@code ids[i]}, {@code + * rowCounts[i]} and {@code dataFileNums[i]} describe the same fragment. Returned by {@link + * Dataset#getFragmentStatistics()} as a lightweight alternative to materializing full {@link + * Fragment} objects. + */ +public final class FragmentStatistics { + private final int[] ids; + private final long[] rowCounts; + private final int[] dataFileNums; + + FragmentStatistics(int[] ids, long[] rowCounts, int[] dataFileNums) { + this.ids = ids; + this.rowCounts = rowCounts; + this.dataFileNums = dataFileNums; + } + + /** Fragment IDs in manifest order. */ + public int[] getIds() { + return ids; + } + + /** + * Row count per fragment, aligned with {@link #getIds()}. Matches {@link + * FragmentMetadata#getNumRows()}: physical rows minus deleted rows. + */ + public long[] getRowCounts() { + return rowCounts; + } + + /** Number of data files per fragment, aligned with {@link #getIds()}. */ + public int[] getDataFileNums() { + return dataFileNums; + } + + /** Number of fragments described. */ + public int size() { + return ids.length; + } +} diff --git a/java/src/main/java/org/lance/LanceConstants.java b/java/src/main/java/org/lance/LanceConstants.java new file mode 100644 index 00000000000..93d20b7e747 --- /dev/null +++ b/java/src/main/java/org/lance/LanceConstants.java @@ -0,0 +1,41 @@ +/* + * 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; + +/** Constants for the Lance SDK. */ +public final class LanceConstants { + + private LanceConstants() {} + + /** Legacy file format version (0.1). */ + public static final String FILE_FORMAT_VERSION_LEGACY = "legacy"; + + /** Stable file format version (resolves to latest stable). */ + public static final String FILE_FORMAT_VERSION_STABLE = "stable"; + + /** Next file format version (resolves to latest). */ + public static final String FILE_FORMAT_VERSION_NEXT = "next"; + + /** File format version 0.1. */ + public static final String FILE_FORMAT_VERSION_0_1 = "0.1"; + + /** File format version 2.0. */ + public static final String FILE_FORMAT_VERSION_2_0 = "2.0"; + + /** File format version 2.1. */ + public static final String FILE_FORMAT_VERSION_2_1 = "2.1"; + + /** File format version 2.2. */ + public static final String FILE_FORMAT_VERSION_2_2 = "2.2"; +} diff --git a/java/src/main/java/org/lance/LanceException.java b/java/src/main/java/org/lance/LanceException.java new file mode 100644 index 00000000000..0b5c180e319 --- /dev/null +++ b/java/src/main/java/org/lance/LanceException.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * Thrown when a Lance operation fails and its API cannot expose a checked exception. + * + *

For example, scanner I/O failures can be handled separately from programming errors: + * + *

{@code
+ * try (ArrowReader reader = scanner.scanBatches()) {
+ *   // Consume batches.
+ * } catch (LanceException e) {
+ *   // Handle the failed Lance operation.
+ * }
+ * }
+ */ +public class LanceException extends RuntimeException { + /** + * Creates an exception with a message describing the failed operation. + * + * @param message description of the failure + */ + public LanceException(String message) { + super(message); + } + + /** + * Creates an exception with a message and its underlying cause. + * + * @param message description of the failure + * @param cause underlying cause of the failure + */ + public LanceException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/java/src/main/java/org/lance/LanceTimeoutException.java b/java/src/main/java/org/lance/LanceTimeoutException.java new file mode 100644 index 00000000000..7d22e79229f --- /dev/null +++ b/java/src/main/java/org/lance/LanceTimeoutException.java @@ -0,0 +1,28 @@ +/* + * 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; + +/** + * Thrown when a Lance operation exceeds its configured timeout. + * + *

For example, a commit issued via {@link CommitBuilder} that does not complete within the + * duration set by {@link CommitBuilder#commitTimeout(java.time.Duration)} fails with this + * exception. It is unchecked so it does not change existing method signatures; catch it + * specifically to distinguish a timeout from other failures. + */ +public class LanceTimeoutException extends RuntimeException { + public LanceTimeoutException(String message) { + super(message); + } +} diff --git a/java/src/main/java/org/lance/LockManager.java b/java/src/main/java/org/lance/LockManager.java index 2097e6f385f..c171c2cfe28 100644 --- a/java/src/main/java/org/lance/LockManager.java +++ b/java/src/main/java/org/lance/LockManager.java @@ -18,20 +18,48 @@ /** The LockManager class provides a way to manage read and write locks. */ public class LockManager { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); + private final Dataset dataset; + + public LockManager() { + this(null); + } + + LockManager(Dataset dataset) { + this.dataset = dataset; + } /** * Represents a read lock for the LockManager. This lock allows multiple threads to read * concurrently, but prevents write access. */ public class ReadLock implements AutoCloseable { + private final boolean callbackLease; + /** Acquires a read lock on the lock manager. */ public ReadLock() { - lock.readLock().lock(); + // The outer index create already pins the Dataset with a read lock. Callback re-entry uses + // that lease instead of blocking behind a writer queued on the same lock. + callbackLease = dataset != null && ContextIndexBuildProgress.isActive(dataset); + if (callbackLease) { + return; + } + if (isCallbackFromAnotherDataset()) { + // An untimed tryLock can join active readers even when a writer is queued, but never waits + // for another Dataset whose operation may itself be waiting for this callback. + if (!lock.readLock().tryLock()) { + throw new IllegalStateException( + "Dataset lifecycle read lock is busy in an index progress callback"); + } + } else { + lock.readLock().lock(); + } } @Override public void close() { - lock.readLock().unlock(); + if (!callbackLease) { + lock.readLock().unlock(); + } } } @@ -39,7 +67,17 @@ public void close() { public class WriteLock implements AutoCloseable { /** Constructs a new WriteLock and acquires the write lock. */ public WriteLock() { - lock.writeLock().lock(); + if (dataset != null && ContextIndexBuildProgress.isActive(dataset)) { + throw new IllegalStateException("Dataset is busy in an index progress callback"); + } + if (isCallbackFromAnotherDataset()) { + if (!lock.writeLock().tryLock()) { + throw new IllegalStateException( + "Dataset lifecycle write lock is busy in an index progress callback"); + } + } else { + lock.writeLock().lock(); + } } @Override @@ -65,4 +103,10 @@ public ReadLock acquireReadLock() { public WriteLock acquireWriteLock() { return new WriteLock(); } + + private boolean isCallbackFromAnotherDataset() { + return dataset != null + && ContextIndexBuildProgress.isCallbackActive() + && !ContextIndexBuildProgress.isActive(dataset); + } } diff --git a/java/src/main/java/org/lance/ManifestLocation.java b/java/src/main/java/org/lance/ManifestLocation.java new file mode 100644 index 00000000000..37a5273abd0 --- /dev/null +++ b/java/src/main/java/org/lance/ManifestLocation.java @@ -0,0 +1,58 @@ +/* + * 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 java.util.Optional; + +/** Metadata describing the location of a dataset manifest. */ +public final class ManifestLocation { + private final long version; + private final String path; + private final long sizeBytes; + private final ManifestNamingScheme namingScheme; + private final String eTag; + + ManifestLocation(long version, String path, long sizeBytes, String namingScheme, String eTag) { + this.version = version; + this.path = path; + this.sizeBytes = sizeBytes; + this.namingScheme = ManifestNamingScheme.valueOf(namingScheme); + this.eTag = eTag; + } + + /** Dataset version represented by the manifest. */ + public long getVersion() { + return version; + } + + /** Manifest path relative to the object-store namespace or root. */ + public String getPath() { + return path; + } + + /** Manifest object size in bytes. */ + public long getSizeBytes() { + return sizeBytes; + } + + /** Naming scheme used by the manifest path. */ + public ManifestNamingScheme getNamingScheme() { + return namingScheme; + } + + /** Object-store entity tag, when available. */ + public Optional getETag() { + return Optional.ofNullable(eTag); + } +} diff --git a/java/src/main/java/org/lance/ManifestNamingScheme.java b/java/src/main/java/org/lance/ManifestNamingScheme.java new file mode 100644 index 00000000000..ec6b371af63 --- /dev/null +++ b/java/src/main/java/org/lance/ManifestNamingScheme.java @@ -0,0 +1,22 @@ +/* + * 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; + +/** Naming scheme used by a Lance manifest path. */ +public enum ManifestNamingScheme { + /** Manifest names based directly on the dataset version. */ + V1, + /** Zero-padded, inverted version names optimized for latest-version lookup. */ + V2 +} diff --git a/java/src/main/java/org/lance/OpenDatasetBuilder.java b/java/src/main/java/org/lance/OpenDatasetBuilder.java index fc350fb0fcf..f3e0ccabf73 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,9 +54,11 @@ 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 Map> baseStoreParams = new HashMap<>(); + private Session session; /** Creates a new builder instance. Package-private, use Dataset.open() instead. */ OpenDatasetBuilder() {} @@ -78,7 +79,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 @@ -89,24 +90,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 @@ -124,38 +125,73 @@ public OpenDatasetBuilder tableId(List tableId) { */ public OpenDatasetBuilder readOptions(ReadOptions options) { this.options = options; + this.baseStoreParams = options.getBaseStoreParams(); + return this; + } + + /** + * Sets runtime-only object store parameters for registered base paths. + * + *

Entries are keyed by the exact {@link BasePath#getPath()} value persisted in the manifest. + * Each value is used as-is for that base. These params are not persisted in the manifest. If a + * base has no explicit entry, the read options storage options are used as a fallback. + * + * @param baseStoreParams object store parameters keyed by base path URI + * @return this builder instance + */ + public OpenDatasetBuilder baseStoreParams(Map> baseStoreParams) { + this.baseStoreParams = new HashMap<>(baseStoreParams); + return this; + } + + /** + * Sets the session to share caches between multiple datasets. + * + *

When a session is provided, the index and metadata caches from the session will be used + * instead of creating new caches. This can improve cache hit rates when opening multiple related + * datasets. + * + *

Note: When a session is provided, the indexCacheSizeBytes and metadataCacheSizeBytes + * settings in ReadOptions are ignored because the session's caches are used instead. + * + * @param session The session to use + * @return this builder instance + */ + public OpenDatasetBuilder session(Session session) { + this.session = session; return this; } /** * 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."); } } @@ -167,29 +203,32 @@ 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); + return Dataset.open(allocator, selfManagedAllocator, uri, options, baseStoreParams, 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))); + // Do not set the dataset version here. Some namespace implementations only support describing + // the latest table metadata; the requested version is applied when opening the dataset below. - 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 client manages versioning (commits go through namespace client API) + Boolean namespaceClientManagedVersioning = response.getManagedVersioning(); + Map namespaceStorageOptions = response.getStorageOptions(); ReadOptions.Builder optionsBuilder = @@ -197,15 +236,11 @@ 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.getRef().ifPresent(optionsBuilder::setRef); options.getBlockSize().ifPresent(optionsBuilder::setBlockSize); options.getSerializedManifest().ifPresent(optionsBuilder::setSerializedManifest); + optionsBuilder.setBaseStoreParams(baseStoreParams); Map storageOptions = new HashMap<>(options.getStorageOptions()); if (namespaceStorageOptions != null) { @@ -213,7 +248,20 @@ private Dataset buildFromNamespace() { } optionsBuilder.setStorageOptions(storageOptions); - // Open dataset with regular open method - return Dataset.open(allocator, selfManagedAllocator, location, optionsBuilder.build()); + // 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(), + baseStoreParams, + 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 ae7ec4a8e67..ec7ebf0e94b 100644 --- a/java/src/main/java/org/lance/ReadOptions.java +++ b/java/src/main/java/org/lance/ReadOptions.java @@ -13,9 +13,8 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; - import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; import java.nio.ByteBuffer; import java.util.HashMap; @@ -26,27 +25,40 @@ public class ReadOptions { private final Optional version; + private final Optional ref; private final Optional blockSize; private final long indexCacheSizeBytes; private final long metadataCacheSizeBytes; private final Optional serializedManifest; private final Map storageOptions; - private final Optional storageOptionsProvider; + private final Map> baseStoreParams; + private final Optional session; private ReadOptions(Builder builder) { this.version = builder.version; + this.ref = builder.ref; this.blockSize = builder.blockSize; this.indexCacheSizeBytes = builder.indexCacheSizeBytes; this.metadataCacheSizeBytes = builder.metadataCacheSizeBytes; this.storageOptions = builder.storageOptions; + this.baseStoreParams = builder.baseStoreParams; this.serializedManifest = builder.serializedManifest; - this.storageOptionsProvider = builder.storageOptionsProvider; + this.session = builder.session; } public Optional getVersion() { return version; } + /** + * Get the reference (a version, a branch or a tag) the dataset is opened at. + * + * @return the reference, or empty if none was specified + */ + public Optional getRef() { + return ref; + } + public Optional getBlockSize() { return blockSize; } @@ -63,18 +75,28 @@ public Map getStorageOptions() { return storageOptions; } + public Map> getBaseStoreParams() { + return baseStoreParams; + } + public Optional getSerializedManifest() { return serializedManifest; } - public Optional getStorageOptionsProvider() { - return storageOptionsProvider; + /** + * Get the session to use for opening the dataset. + * + * @return the session, or empty if no session was specified + */ + public Optional getSession() { + return session; } @Override public String toString() { return MoreObjects.toStringHelper(this) .add("version", version.orElse(null)) + .add("ref", ref.orElse(null)) .add("blockSize", blockSize.orElse(null)) .add("indexCacheSizeBytes", indexCacheSizeBytes) .add("metadataCacheSizeBytes", metadataCacheSizeBytes) @@ -88,15 +110,21 @@ public String toString() { public static class Builder { private Optional version = Optional.empty(); + private Optional ref = Optional.empty(); private Optional blockSize = Optional.empty(); - private long indexCacheSizeBytes = 6 * 1024 * 1024 * 1024; // Default to 6 GiB like Rust - private long metadataCacheSizeBytes = 1024 * 1024 * 1024; // Default to 1 GiB like Rust + private long indexCacheSizeBytes = 6L * 1024 * 1024 * 1024; // Default to 6 GiB like Rust + private long metadataCacheSizeBytes = 1024L * 1024 * 1024; // Default to 1 GiB like Rust private Map storageOptions = new HashMap<>(); + private Map> baseStoreParams = new HashMap<>(); private Optional serializedManifest = Optional.empty(); - private Optional storageOptionsProvider = Optional.empty(); + private Optional session = Optional.empty(); /** - * Set the version of the dataset to read. If not set, read from latest version. + * Set the version of the dataset to read. If neither a version nor a {@link #setRef(Ref) ref} + * is set, read from latest version. + * + *

The version is looked up on the branch the dataset URI points at. Use {@link #setRef(Ref)} + * to open a version on another branch. Cannot be combined with {@link #setRef(Ref)}. * * @param version the version of the dataset * @return this builder @@ -106,6 +134,44 @@ public Builder setVersion(long version) { return this; } + /** + * Set the reference to open the dataset at. It accepts the same references as {@link + * Dataset#checkout(Ref)}: + * + *

    + *
  • {@link Ref#ofMain(long)}: a version on the main branch, when the URI is the dataset + * root. + *
  • {@link Ref#ofBranch(String)}: the latest version of a branch. + *
  • {@link Ref#ofBranch(String, long)}: a version on a branch. + *
  • {@link Ref#ofTag(String)}: the version a tag points at, on the branch that version + * belongs to. + *
+ * + *

Open the dataset root: references to the default branch ({@link Ref#ofMain()}, {@link + * Ref#ofMain(long)}, and {@code "main"} in {@link Ref#ofBranch(String)}) are resolved on the + * chain the URI points at, like {@link #setVersion(long)}. From a branch directory they read + * that branch, while {@link Dataset#checkout(Ref)} reads the default branch. + * + *

{@code
+     * Dataset dev =
+     *     Dataset.open()
+     *         .allocator(allocator)
+     *         .uri("s3://bucket/table.lance")
+     *         .readOptions(new ReadOptions.Builder().setRef(Ref.ofBranch("dev", 2)).build())
+     *         .build();
+     * }
+ * + *

Cannot be combined with {@link #setVersion(long)} or {@link + * #setSerializedManifest(ByteBuffer)}. + * + * @param ref the reference to open + * @return this builder + */ + public Builder setRef(Ref ref) { + this.ref = Optional.of(Preconditions.checkNotNull(ref, "ref must not be null")); + return this; + } + /** * Block size in bytes. Provide a hint for the size of the minimal I/O request. Recommended to * be set to value bigger than 4KB. @@ -131,15 +197,14 @@ public Builder setIndexCacheSizeBytes(long indexCacheSizeBytes) { } /** - * Index cache size. Index cache is a LRU cache with TTL. This number specifies the number of - * index pages, for example, IVF partitions, to be cached in the host memory. Roughly, for an - * IVF_PQ partition with n rows, the size of each index page equals the combination of the pq - * code (nd.array([n,pq], dtype=uint8)) and the row ids (nd.array([n], dtype=uint64)). - * Approximately, n = Total Rows / number of IVF partitions. pq = number of PQ sub-vectors. - * Default is 256. - * - * @param indexCacheSize the index cache size + * Set the index cache size as a count of index entries, each assumed to be ~20 MB. The count is + * converted to a byte budget ({@code indexCacheSize * 20 MB}) and stored the same way as {@link + * #setIndexCacheSizeBytes(long)}. Index cache is an LRU cache with TTL. + * + * @param indexCacheSize the number of ~20 MB index entries to cache * @return this builder + * @deprecated Use {@link #setIndexCacheSizeBytes(long)} to set the cache size in bytes + * directly. When neither is set, the default cache size is 6 GiB. */ @Deprecated public Builder setIndexCacheSize(int indexCacheSize) { @@ -179,6 +244,13 @@ public Builder setMetadataCacheSize(int metadataCacheSize) { * Set storage options. Extra options that make sense for a particular storage connection. This * is used to store connection parameters like credentials, endpoint, etc. * + *

For datasets with additional registered base paths, a key of the form {@code + * base_.} applies {@code } only to the base path with that manifest id, + * overriding the unscoped options that every base inherits. For example {@code + * base_1.account_key = abc} makes base 1 use {@code account_key = abc} while all other options + * are shared. Exact per-base bindings set via {@link #setBaseStoreParams(Map)} take precedence + * over base-scoped keys. + * * @param storageOptions the storage options * @return this builder */ @@ -187,9 +259,25 @@ public Builder setStorageOptions(Map storageOptions) { return this; } + /** + * Set runtime-only object store parameters for registered base paths. + * + *

Entries are keyed by the exact {@link BasePath#getPath()} value persisted in the manifest. + * Each value is the storage options map used as-is for that base. These params are not + * persisted in the manifest. If a base has no explicit entry, {@link #setStorageOptions(Map)} + * remains the fallback. + * + * @param baseStoreParams object store parameters keyed by base path URI + * @return this builder + */ + public Builder setBaseStoreParams(Map> baseStoreParams) { + this.baseStoreParams = baseStoreParams; + return this; + } + /** * Use a serialized manifest instead of loading it from the object store. This is common when - * transferring a dataset across IPC boundaries. + * transferring a dataset across IPC boundaries. Cannot be combined with {@link #setRef(Ref)}. * * @param serializedManifest the serialized manifest as a ByteBuffer * @return this builder @@ -200,21 +288,38 @@ public Builder setSerializedManifest(ByteBuffer serializedManifest) { } /** - * Set a custom storage options provider for automatic storage options refresh. + * Set a session to share caches between multiple datasets. + * + *

When a session is provided, the index and metadata caches from the session will be used + * instead of creating new caches. This can improve cache hit rates when opening multiple + * related datasets. * - *

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. + *

Note: When a session is provided, the indexCacheSizeBytes and metadataCacheSizeBytes + * settings are ignored because the session's caches are used instead. * - * @param storageOptionsProvider the storage options provider implementation + * @param session the session to use * @return this builder */ - public Builder setStorageOptionsProvider(StorageOptionsProvider storageOptionsProvider) { - this.storageOptionsProvider = Optional.of(storageOptionsProvider); + public Builder setSession(Session session) { + this.session = Optional.of(session); return this; } public ReadOptions build() { + if (version.isPresent() && ref.isPresent()) { + throw new IllegalArgumentException( + String.format( + "Cannot set both version (%d) and ref (%s); use Ref.ofMain(version) or" + + " Ref.ofBranch(branch, version) to open a version by reference", + version.get(), ref.get())); + } + if (serializedManifest.isPresent() && ref.isPresent()) { + throw new IllegalArgumentException( + String.format( + "Cannot set both a serialized manifest and ref (%s); the manifest already" + + " determines the version to open", + ref.get())); + } return new ReadOptions(this); } } diff --git a/java/src/main/java/org/lance/Ref.java b/java/src/main/java/org/lance/Ref.java index 111a1edd6d3..69e0e3fb8f3 100644 --- a/java/src/main/java/org/lance/Ref.java +++ b/java/src/main/java/org/lance/Ref.java @@ -41,21 +41,33 @@ public Optional getTagName() { return tagName; } + /** Creates a reference to a specific version on the default branch. */ public static Ref ofMain(long versionNumber) { Preconditions.checkArgument(versionNumber > 0, "versionNumber must be greater than 0"); return new Ref(Optional.of(versionNumber), Optional.empty(), Optional.empty()); } + /** Creates a reference to the latest version on the default branch. */ public static Ref ofMain() { return new Ref(Optional.empty(), Optional.empty(), Optional.empty()); } + /** + * Creates a reference to the latest version on a branch. + * + *

In reference contexts, {@code "main"} is an alias for the default branch. + */ public static Ref ofBranch(String branchName) { Preconditions.checkArgument( branchName != null && !branchName.isEmpty(), "branchName must not be empty"); return new Ref(Optional.empty(), Optional.of(branchName), Optional.empty()); } + /** + * Creates a reference to a specific version on a branch. + * + *

In reference contexts, {@code "main"} is an alias for the default branch. + */ public static Ref ofBranch(String branchName, long versionNumber) { Preconditions.checkArgument( branchName != null && !branchName.isEmpty(), "branchName must not be empty"); diff --git a/java/src/main/java/org/lance/Session.java b/java/src/main/java/org/lance/Session.java new file mode 100644 index 00000000000..95a92cc8b57 --- /dev/null +++ b/java/src/main/java/org/lance/Session.java @@ -0,0 +1,377 @@ +/* + * 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.apache.arrow.util.Preconditions; + +import java.io.Closeable; +import java.util.Collections; +import java.util.Map; + +/** + * A user session that holds runtime state for Lance datasets. + * + *

A session can be shared between multiple datasets to share caches (index cache and metadata + * cache), increasing cache hit rates and reducing memory usage. + * + *

Example usage: + * + *

{@code
+ * // Create a shared session with default cache sizes
+ * Session session = Session.builder().build();
+ *
+ * // Create a session with custom cache sizes
+ * Session customSession = Session.builder()
+ *     .indexCacheSizeBytes(2L * 1024 * 1024 * 1024)  // 2 GiB
+ *     .metadataCacheSizeBytes(512L * 1024 * 1024)    // 512 MiB
+ *     .build();
+ *
+ * // Select registered cache backends using URIs or structured configuration
+ * Session backendSession = Session.builder()
+ *     .indexCacheBackend("moka://?capacity=1073741824")
+ *     .metadataCacheBackend(CacheBackendConfig.builder("moka")
+ *         .option("capacity", "268435456")
+ *         .build())
+ *     .build();
+ *
+ * // Open multiple datasets with shared session
+ * Dataset ds1 = Dataset.open()
+ *     .uri("s3://bucket/table1.lance")
+ *     .session(session)
+ *     .build();
+ *
+ * Dataset ds2 = Dataset.open()
+ *     .uri("s3://bucket/table2.lance")
+ *     .session(session)
+ *     .build();
+ *
+ * // Verify session sharing
+ * assert ds1.session().isSameAs(ds2.session());
+ *
+ * // Clean up - session must be closed separately
+ * ds1.close();
+ * ds2.close();
+ * session.close();
+ * }
+ */ +public class Session implements Closeable { + static { + JniLoader.ensureLoaded(); + } + + /** Default index cache size: 6 GiB */ + public static final long DEFAULT_INDEX_CACHE_SIZE_BYTES = 6L * 1024 * 1024 * 1024; + + /** Default metadata cache size: 1 GiB */ + public static final long DEFAULT_METADATA_CACHE_SIZE_BYTES = 1L * 1024 * 1024 * 1024; + + private long nativeSessionHandle; + + private Session(long handle) { + this.nativeSessionHandle = handle; + } + + /** + * Creates a new builder for configuring a Session. + * + * @return a new Builder instance + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates a new session with default cache sizes. + * + * @return a new Session instance + * @deprecated Use {@link #builder()} instead + */ + @Deprecated + public static Session create() { + return builder().build(); + } + + /** + * Creates a new session with custom cache sizes. + * + * @param indexCacheSizeBytes the size of the index cache in bytes + * @param metadataCacheSizeBytes the size of the metadata cache in bytes + * @return a new Session instance + * @deprecated Use {@link #builder()} instead + */ + @Deprecated + public static Session create(long indexCacheSizeBytes, long metadataCacheSizeBytes) { + return builder() + .indexCacheSizeBytes(indexCacheSizeBytes) + .metadataCacheSizeBytes(metadataCacheSizeBytes) + .build(); + } + + /** Builder for creating Session instances with custom configuration. */ + public static class Builder { + private Long indexCacheSizeBytes; + private Long metadataCacheSizeBytes; + private String indexCacheBackendUri; + private CacheBackendConfig indexCacheBackendConfig; + private String metadataCacheBackendUri; + private CacheBackendConfig metadataCacheBackendConfig; + + private Builder() {} + + /** + * Sets the size of the index cache in bytes. + * + * @param indexCacheSizeBytes the size of the index cache in bytes (must be non-negative) + * @return this builder instance + */ + public Builder indexCacheSizeBytes(long indexCacheSizeBytes) { + Preconditions.checkArgument(indexCacheSizeBytes >= 0, "indexCacheSizeBytes must be >= 0"); + this.indexCacheSizeBytes = indexCacheSizeBytes; + return this; + } + + /** + * Selects a registered index cache backend using a backend URI. + * + *

For example, {@code moka://?capacity=1048576}. This option is mutually exclusive with + * {@link #indexCacheSizeBytes(long)}. + * + * @param backendUri backend URI whose scheme identifies the registered backend + * @return this builder instance + */ + public Builder indexCacheBackend(String backendUri) { + Preconditions.checkNotNull(backendUri, "backendUri must not be null"); + this.indexCacheBackendUri = backendUri; + this.indexCacheBackendConfig = null; + return this; + } + + /** + * Selects a registered index cache backend using structured configuration. + * + *

This option is mutually exclusive with {@link #indexCacheSizeBytes(long)}. + * + * @param backendConfig backend kind and backend-specific options + * @return this builder instance + */ + public Builder indexCacheBackend(CacheBackendConfig backendConfig) { + Preconditions.checkNotNull(backendConfig, "backendConfig must not be null"); + this.indexCacheBackendConfig = backendConfig; + this.indexCacheBackendUri = null; + return this; + } + + /** + * Sets the size of the metadata cache in bytes. + * + * @param metadataCacheSizeBytes the size of the metadata cache in bytes (must be non-negative) + * @return this builder instance + */ + public Builder metadataCacheSizeBytes(long metadataCacheSizeBytes) { + Preconditions.checkArgument( + metadataCacheSizeBytes >= 0, "metadataCacheSizeBytes must be >= 0"); + this.metadataCacheSizeBytes = metadataCacheSizeBytes; + return this; + } + + /** + * Selects a registered metadata cache backend using a backend URI. + * + *

For example, {@code moka://?capacity=1048576}. This option is mutually exclusive with + * {@link #metadataCacheSizeBytes(long)}. + * + * @param backendUri backend URI whose scheme identifies the registered backend + * @return this builder instance + */ + public Builder metadataCacheBackend(String backendUri) { + Preconditions.checkNotNull(backendUri, "backendUri must not be null"); + this.metadataCacheBackendUri = backendUri; + this.metadataCacheBackendConfig = null; + return this; + } + + /** + * Selects a registered metadata cache backend using structured configuration. + * + *

This option is mutually exclusive with {@link #metadataCacheSizeBytes(long)}. + * + * @param backendConfig backend kind and backend-specific options + * @return this builder instance + */ + public Builder metadataCacheBackend(CacheBackendConfig backendConfig) { + Preconditions.checkNotNull(backendConfig, "backendConfig must not be null"); + this.metadataCacheBackendConfig = backendConfig; + this.metadataCacheBackendUri = null; + return this; + } + + /** + * Builds the Session with the configured settings. + * + * @return a new Session instance + */ + public Session build() { + validateCacheConfiguration(); + long handle = + createNative( + indexCacheSizeBytes == null ? -1 : indexCacheSizeBytes, + metadataCacheSizeBytes == null ? -1 : metadataCacheSizeBytes, + indexCacheBackendUri, + backendKind(indexCacheBackendConfig), + backendOptions(indexCacheBackendConfig), + metadataCacheBackendUri, + backendKind(metadataCacheBackendConfig), + backendOptions(metadataCacheBackendConfig)); + return new Session(handle); + } + + private void validateCacheConfiguration() { + Preconditions.checkArgument( + indexCacheSizeBytes == null + || (indexCacheBackendUri == null && indexCacheBackendConfig == null), + "indexCacheSizeBytes and indexCacheBackend are mutually exclusive; set one or the other"); + Preconditions.checkArgument( + metadataCacheSizeBytes == null + || (metadataCacheBackendUri == null && metadataCacheBackendConfig == null), + "metadataCacheSizeBytes and metadataCacheBackend are mutually exclusive; " + + "set one or the other"); + } + + private static String backendKind(CacheBackendConfig config) { + return config == null ? null : config.getKind(); + } + + private static Map backendOptions(CacheBackendConfig config) { + return config == null ? Collections.emptyMap() : config.getOptions(); + } + } + + /** + * Creates a Session from an existing native handle. This is used internally when retrieving the + * session from a dataset. + * + * @param handle the native session handle + * @return a new Session instance wrapping the handle + */ + static Session fromHandle(long handle) { + Preconditions.checkArgument(handle != 0, "Invalid session handle"); + return new Session(handle); + } + + /** + * Returns the current size of the session in bytes. + * + *

This includes the size of both index and metadata caches. Note that computing this is not + * trivial as it walks the caches. + * + * @return the size of the session in bytes + */ + public long sizeBytes() { + Preconditions.checkArgument(nativeSessionHandle != 0, "Session is closed"); + return sizeBytesNative(); + } + + /** + * Returns statistics for the metadata cache of this session. + * + *

The returned statistics are a snapshot; call this method periodically to observe how hits + * and misses evolve over time. + * + * @return the metadata cache statistics + */ + public CacheStats metadataCacheStats() { + Preconditions.checkArgument(nativeSessionHandle != 0, "Session is closed"); + return metadataCacheStatsNative(); + } + + /** + * Returns whether the other session is the same as this one. + * + *

Two sessions are considered the same if they share the same underlying native session. This + * comparison uses the underlying Arc pointer equality, so sessions obtained from different + * sources (e.g., directly created vs obtained from a dataset) will be correctly identified as the + * same if they share the same underlying session. + * + * @param other the other session to compare + * @return true if both sessions share the same underlying session + */ + public boolean isSameAs(Session other) { + if (other == null) { + return false; + } + if (this.nativeSessionHandle == 0 || other.nativeSessionHandle == 0) { + return false; + } + return isSameAsNative(this.nativeSessionHandle, other.nativeSessionHandle); + } + + /** + * Returns the native session handle. Used internally for passing to JNI methods. + * + * @return the native session handle + */ + long getNativeHandle() { + return nativeSessionHandle; + } + + /** + * Checks if this session is closed. + * + * @return true if the session is closed, false otherwise + */ + public boolean isClosed() { + return nativeSessionHandle == 0; + } + + /** + * Closes this session and releases any resources associated with it. + * + *

After calling this method, the session should not be used. Datasets that were opened with + * this session will continue to work until they are closed, as they hold their own reference to + * the underlying native session. + */ + @Override + public void close() { + if (nativeSessionHandle != 0) { + releaseNative(nativeSessionHandle); + nativeSessionHandle = 0; + } + } + + @Override + public String toString() { + if (nativeSessionHandle == 0) { + return "Session(closed)"; + } + return String.format("Session(sizeBytes=%d)", sizeBytes()); + } + + private static native long createNative( + long indexCacheSizeBytes, + long metadataCacheSizeBytes, + String indexCacheBackendUri, + String indexCacheBackendKind, + Map indexCacheBackendOptions, + String metadataCacheBackendUri, + String metadataCacheBackendKind, + Map metadataCacheBackendOptions); + + private native long sizeBytesNative(); + + private native CacheStats metadataCacheStatsNative(); + + private static native void releaseNative(long handle); + + private static native boolean isSameAsNative(long handle1, long handle2); +} diff --git a/java/src/main/java/org/lance/SourcedTransaction.java b/java/src/main/java/org/lance/SourcedTransaction.java new file mode 100644 index 00000000000..d833c417752 --- /dev/null +++ b/java/src/main/java/org/lance/SourcedTransaction.java @@ -0,0 +1,168 @@ +/* + * 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.operation.Operation; + +import org.apache.arrow.util.Preconditions; + +import java.util.Map; +import java.util.Optional; + +/** + * A convenience wrapper that pairs a {@link Transaction} with a {@link Dataset}, providing a simple + * commit workflow. + * + *

This replaces the old {@code Transaction} class's "sourced" role where the transaction held a + * reference to the dataset it was built from. + * + *

Example usage: + * + *

{@code
+ * try (SourcedTransaction txn = dataset.newTransactionBuilder()
+ *     .operation(Append.builder().fragments(fragments).build())
+ *     .build();
+ *     Dataset committed = txn.commit()) {
+ *     // use committed dataset
+ * }
+ * }
+ */ +public class SourcedTransaction implements AutoCloseable { + + private final Transaction transaction; + private final Dataset dataset; + + private SourcedTransaction(Transaction transaction, Dataset dataset) { + this.transaction = transaction; + this.dataset = dataset; + } + + /** Returns the underlying {@link Transaction}. */ + public Transaction transaction() { + return transaction; + } + + /** Delegates to {@link Transaction#readVersion()}. */ + public long readVersion() { + return transaction.readVersion(); + } + + /** Delegates to {@link Transaction#uuid()}. */ + public String uuid() { + return transaction.uuid(); + } + + /** Delegates to {@link Transaction#operation()}. */ + public Operation operation() { + return transaction.operation(); + } + + /** Delegates to {@link Transaction#tag()}. */ + public Optional tag() { + return transaction.tag(); + } + + /** Delegates to {@link Transaction#transactionProperties()}. */ + public Optional> transactionProperties() { + return transaction.transactionProperties(); + } + + /** + * Commit this transaction against the source dataset. + * + * @return a new Dataset at the committed version + */ + public Dataset commit() { + return dataset.commitTransaction(transaction); + } + + /** + * Commit this transaction against the source dataset with additional options. + * + * @param detached if true, the commit will not be part of the main dataset lineage + * @param enableV2ManifestPaths if true, and this is a new dataset, uses the new V2 manifest paths + * @return a new Dataset at the committed version + */ + public Dataset commit(boolean detached, boolean enableV2ManifestPaths) { + return dataset.commitTransaction(transaction, detached, enableV2ManifestPaths); + } + + /** Release native resources held by the underlying transaction's operation. */ + @Override + public void close() { + transaction.close(); + } + + /** Builder for constructing {@link SourcedTransaction} instances from a {@link Dataset}. */ + public static class Builder { + private final Dataset dataset; + private long readVersion; + private Operation operation; + private String tag; + private Map transactionProperties; + + /** + * Create a builder for committing against an existing dataset. The read version defaults to the + * dataset's current version. + * + * @param dataset the existing dataset to commit against + */ + public Builder(Dataset dataset) { + this.dataset = dataset; + this.readVersion = dataset.version(); + } + + public Builder readVersion(long readVersion) { + this.readVersion = readVersion; + return this; + } + + public Builder operation(Operation operation) { + if (this.operation != null) { + throw new IllegalStateException( + String.format("Operation %s has been set", this.operation.name())); + } + this.operation = operation; + return this; + } + + /** + * Set an optional tag for the transaction. + * + * @param tag the tag string + * @return this builder instance + */ + public Builder tag(String tag) { + this.tag = tag; + return this; + } + + public Builder transactionProperties(Map properties) { + this.transactionProperties = properties; + return this; + } + + public SourcedTransaction build() { + Preconditions.checkState(operation != null, "TransactionBuilder has no operations"); + Transaction transaction = + new Transaction.Builder() + .readVersion(readVersion) + .operation(operation) + .tag(tag) + .transactionProperties(transactionProperties) + .build(); + return new SourcedTransaction(transaction, dataset); + } + } +} diff --git a/java/src/main/java/org/lance/SqlQuery.java b/java/src/main/java/org/lance/SqlQuery.java index cce6d939222..cb149a7fa6f 100644 --- a/java/src/main/java/org/lance/SqlQuery.java +++ b/java/src/main/java/org/lance/SqlQuery.java @@ -51,7 +51,8 @@ public SqlQuery withRowAddr(boolean withAddr) { } public ArrowReader intoBatchRecords() throws IOException { - try (ArrowArrayStream s = ArrowArrayStream.allocateNew(dataset.allocator())) { + try (LockManager.ReadLock readLock = dataset.acquireReadLock(); + ArrowArrayStream s = ArrowArrayStream.allocateNew(dataset.allocator())) { intoBatchRecords( dataset, sql, Optional.ofNullable(table), withRowId, withRowAddr, s.memoryAddress()); return Data.importArrayStream(dataset.allocator(), s); diff --git a/java/src/main/java/org/lance/Tag.java b/java/src/main/java/org/lance/Tag.java index f7ce7be83cc..d09185d38c0 100644 --- a/java/src/main/java/org/lance/Tag.java +++ b/java/src/main/java/org/lance/Tag.java @@ -15,6 +15,10 @@ import com.google.common.base.MoreObjects; +import java.time.Instant; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -23,12 +27,34 @@ public class Tag { private final Optional branch; private final long version; private final int manifestSize; + private final Optional createdAt; + private final Optional updatedAt; + private final Map metadata; public Tag(String name, String branch, long version, int manifestSize) { + this(name, branch, version, manifestSize, null, null, Collections.emptyMap()); + } + + public Tag( + String name, String branch, long version, int manifestSize, Map metadata) { + this(name, branch, version, manifestSize, null, null, metadata); + } + + public Tag( + String name, + String branch, + long version, + int manifestSize, + Instant createdAt, + Instant updatedAt, + Map metadata) { this.name = name; this.branch = Optional.ofNullable(branch); this.version = version; this.manifestSize = manifestSize; + this.createdAt = Optional.ofNullable(createdAt); + this.updatedAt = Optional.ofNullable(updatedAt); + this.metadata = Collections.unmodifiableMap(new HashMap<>(metadata)); } public String getName() { @@ -47,6 +73,18 @@ public int getManifestSize() { return manifestSize; } + public Optional getCreatedAt() { + return createdAt; + } + + public Optional getUpdatedAt() { + return updatedAt; + } + + public Map getMetadata() { + return metadata; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -54,6 +92,9 @@ public String toString() { .add("branch", branch) .add("version", version) .add("manifestSize", manifestSize) + .add("createdAt", createdAt) + .add("updatedAt", updatedAt) + .add("metadata", metadata) .toString(); } @@ -69,11 +110,14 @@ public boolean equals(Object o) { return version == tag.version && Objects.equals(branch, tag.branch) && manifestSize == tag.manifestSize + && Objects.equals(createdAt, tag.createdAt) + && Objects.equals(updatedAt, tag.updatedAt) + && Objects.equals(metadata, tag.metadata) && Objects.equals(name, tag.name); } @Override public int hashCode() { - return Objects.hash(name, branch, version, manifestSize); + return Objects.hash(name, branch, version, manifestSize, createdAt, updatedAt, metadata); } } diff --git a/java/src/main/java/org/lance/Transaction.java b/java/src/main/java/org/lance/Transaction.java index 2d565c73258..0ac6a310d81 100644 --- a/java/src/main/java/org/lance/Transaction.java +++ b/java/src/main/java/org/lance/Transaction.java @@ -18,39 +18,61 @@ import com.google.common.base.MoreObjects; import org.apache.arrow.util.Preconditions; -import java.util.HashMap; import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; /** - * Align with the Transaction struct in rust. The transaction won't commit the status to original - * dataset. It will return a new dataset after committed. + * A pure data container representing a Lance transaction. + * + *

A Transaction holds the read version, a unique identifier, the operation to perform, and + * optional transaction properties. It does not contain commit configuration or execution logic. + * + *

To commit a transaction, use {@link CommitBuilder} or {@link SourcedTransaction}. */ -public class Transaction { +public class Transaction implements AutoCloseable { private final long readVersion; private final String uuid; - private final Map writeParams; - private final Optional> transactionProperties; - // Mainly for JNI usage - private final Dataset dataset; private final Operation operation; + private final Optional tag; + private final Optional> transactionProperties; + /** + * Constructor used by JNI when reading transactions from native code. + * + * @param readVersion the version that was read when creating this transaction + * @param uuid the unique identifier for this transaction + * @param operation the operation to perform + * @param tag optional tag for the transaction + * @param transactionProperties optional transaction properties + */ private Transaction( - Dataset dataset, long readVersion, String uuid, Operation operation, - Map writeParams, + String tag, Map transactionProperties) { - this.dataset = dataset; this.readVersion = readVersion; this.uuid = uuid; this.operation = operation; - this.writeParams = writeParams != null ? writeParams : new HashMap<>(); - this.transactionProperties = Optional.ofNullable(transactionProperties); + this.tag = tag == null || tag.isEmpty() ? Optional.empty() : Optional.of(tag); + this.transactionProperties = + transactionProperties == null || transactionProperties.isEmpty() + ? Optional.empty() + : Optional.of(transactionProperties); + } + + /** + * Create a transaction with the given read version and operation. A random UUID is generated + * automatically. + * + * @param readVersion the version that was read when creating this transaction + * @param operation the operation to perform + */ + public Transaction(long readVersion, Operation operation) { + this(readVersion, UUID.randomUUID().toString(), operation, null, null); } public long readVersion() { @@ -65,22 +87,19 @@ public Operation operation() { return operation; } - public Map writeParams() { - return writeParams; + /** Returns the optional tag for this transaction; null and empty tags are both absent. */ + public Optional tag() { + return tag; } + /** Returns transaction properties; null and empty maps are both absent. */ public Optional> transactionProperties() { return transactionProperties; } - public Dataset commit() { - if (dataset == null) { - throw new UnsupportedOperationException("Transaction doesn't support create new dataset yet"); - } - return dataset.commitTransaction(this); - } - - public void release() { + /** Release native resources held by the operation (e.g. Arrow C schemas). */ + @Override + public void close() { operation.release(); } @@ -90,7 +109,7 @@ public String toString() { .add("readVersion", readVersion) .add("uuid", uuid) .add("operation", operation) - .add("writeParams", writeParams) + .add("tag", tag) .add("transactionProperties", transactionProperties) .toString(); } @@ -107,20 +126,24 @@ public boolean equals(Object o) { return readVersion == that.readVersion && uuid.equals(that.uuid) && Objects.equals(operation, that.operation) - && Objects.equals(writeParams, that.writeParams) + && Objects.equals(tag, that.tag) && Objects.equals(transactionProperties, that.transactionProperties); } + @Override + public int hashCode() { + return Objects.hash(readVersion, uuid, operation, tag, transactionProperties); + } + + /** Builder for constructing {@link Transaction} instances. */ public static class Builder { - private final String uuid; - private final Dataset dataset; + private String uuid; private long readVersion; private Operation operation; - private Map writeParams; + private String tag; private Map transactionProperties; - public Builder(Dataset dataset) { - this.dataset = dataset; + public Builder() { this.uuid = UUID.randomUUID().toString(); } @@ -129,34 +152,40 @@ public Builder readVersion(long readVersion) { return this; } - public Builder transactionProperties(Map properties) { - this.transactionProperties = properties; + public Builder uuid(String uuid) { + this.uuid = uuid; return this; } - public Builder writeParams(Map writeParams) { - this.writeParams = writeParams; + public Builder operation(Operation operation) { + if (this.operation != null) { + throw new IllegalStateException( + String.format("Operation %s has been set", this.operation.name())); + } + this.operation = operation; return this; } - public Builder operation(Operation operation) { - validateState(); - this.operation = operation; + /** + * Set an optional tag for the transaction. + * + * @param tag the tag string; null or empty means absent + * @return this builder instance + */ + public Builder tag(String tag) { + this.tag = tag; return this; } - private void validateState() { - if (operation != null) { - throw new IllegalStateException( - String.format("Operation %s has been set", operation.name())); - } + /** Set transaction properties; null or an empty map means absent. */ + public Builder transactionProperties(Map properties) { + this.transactionProperties = properties; + return this; } public Transaction build() { Preconditions.checkState(operation != null, "TransactionBuilder has no operations"); - - return new Transaction( - dataset, readVersion, uuid, operation, writeParams, transactionProperties); + return new Transaction(readVersion, uuid, operation, tag, transactionProperties); } } } diff --git a/java/src/main/java/org/lance/WriteDatasetBuilder.java b/java/src/main/java/org/lance/WriteDatasetBuilder.java index 8a8c33163da..9515ea562cb 100644 --- a/java/src/main/java/org/lance/WriteDatasetBuilder.java +++ b/java/src/main/java/org/lance/WriteDatasetBuilder.java @@ -13,11 +13,8 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; +import org.lance.file.FileWriteOptions; import org.lance.namespace.LanceNamespace; -import org.lance.namespace.LanceNamespaceStorageOptionsProvider; -import org.lance.namespace.model.CreateEmptyTableRequest; -import org.lance.namespace.model.CreateEmptyTableResponse; import org.lance.namespace.model.DeclareTableRequest; import org.lance.namespace.model.DeclareTableResponse; import org.lance.namespace.model.DescribeTableRequest; @@ -53,12 +50,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();
@@ -69,19 +66,25 @@ 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;
   private Map storageOptions = new HashMap<>();
+  private Map properties = new HashMap<>();
+  private Map> baseStoreParams = new HashMap<>();
   private boolean ignoreNamespaceStorageOptions = false;
   private Optional maxRowsPerFile = Optional.empty();
   private Optional maxRowsPerGroup = Optional.empty();
   private Optional maxBytesPerFile = Optional.empty();
+  private FileWriteOptions fileWriteOptions = FileWriteOptions.builder().build();
   private Optional enableStableRowIds = Optional.empty();
-  private Optional dataStorageVersion = Optional.empty();
+  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. */
   WriteDatasetBuilder() {
@@ -133,7 +136,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 @@ -144,24 +147,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 @@ -208,11 +211,47 @@ public WriteDatasetBuilder storageOptions(Map storageOptions) { } /** - * Sets whether to ignore storage options from the namespace's describeTable() or - * createEmptyTable(). + * Sets the table properties to forward to the namespace on table creation. + * + *

These are Lance-namespace properties: catalog-level key-value metadata stored by the + * namespace outside the Lance table (available even if the table manifest does not exist), as + * distinct from the manifest-stored {@code config} (read/write behavior) and {@code metadata} + * (business metadata), and from non-persisted {@code storageOptions}. They are attached to the + * underlying declareTable request via its {@code properties} field. + * + *

Only used when a namespace client is configured via namespaceClient()+tableId() and the + * write creates the table (CREATE mode). Ignored for direct-URI writes and for APPEND/OVERWRITE + * modes. + * + * @param properties Table properties to forward on declareTable + * @return this builder instance + */ + public WriteDatasetBuilder properties(Map properties) { + this.properties = new HashMap<>(properties); + return this; + } + + /** + * Sets runtime-only object store parameters for registered base paths. + * + *

Entries are keyed by the exact {@link BasePath#getPath()} value persisted in the manifest. + * Each value is used as-is for that base. These params are not persisted in the manifest. If a + * base has no explicit entry, the write-level storage options are used as a fallback. + * + * @param baseStoreParams object store parameters keyed by base path URI + * @return this builder instance + */ + public WriteDatasetBuilder baseStoreParams(Map> baseStoreParams) { + this.baseStoreParams = new HashMap<>(baseStoreParams); + return this; + } + + /** + * 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) { @@ -253,6 +292,18 @@ public WriteDatasetBuilder maxBytesPerFile(long maxBytesPerFile) { return this; } + /** + * Sets options for configuring the current-format file writer. + * + * @param fileWriteOptions file writer options + * @return this builder instance + */ + public WriteDatasetBuilder fileWriteOptions(FileWriteOptions fileWriteOptions) { + this.fileWriteOptions = + Preconditions.checkNotNull(fileWriteOptions, "fileWriteOptions must not be null"); + return this; + } + /** * Sets whether to enable stable row IDs. * @@ -267,10 +318,10 @@ public WriteDatasetBuilder enableStableRowIds(boolean enableStableRowIds) { /** * Sets the data storage version. * - * @param dataStorageVersion The Lance file version to use + * @param dataStorageVersion The Lance file version to use (e.g., "legacy", "stable", "2.0") * @return this builder instance */ - public WriteDatasetBuilder dataStorageVersion(WriteParams.LanceFileVersion dataStorageVersion) { + public WriteDatasetBuilder dataStorageVersion(String dataStorageVersion) { this.dataStorageVersion = Optional.of(dataStorageVersion); return this; } @@ -285,11 +336,51 @@ 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. + * + *

Note: For write operations, the session is currently not used during the write itself, but + * is stored for future use when the resulting dataset needs to be reopened with the same session. + * This is a placeholder for future session support in write operations. + * + * @param session The session to use + * @return this builder instance + */ + public WriteDatasetBuilder session(Session session) { + this.session = session; + return this; + } + /** * 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 @@ -300,23 +391,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."); } } @@ -336,64 +428,56 @@ 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 namespaceClientManagedVersioning = false; - // Mode-specific namespace operations + // Mode-specific namespace client operations if (mode == WriteParams.WriteMode.CREATE) { - // Try declareTable first, fall back to deprecated createEmptyTable - // for backward compatibility with older namespace implementations. - // createEmptyTable support will be removed in 3.0.0. - String location; - Map responseStorageOptions; - - try { - DeclareTableRequest declareRequest = new DeclareTableRequest(); - declareRequest.setId(tableId); - DeclareTableResponse declareResponse = namespace.declareTable(declareRequest); - location = declareResponse.getLocation(); - responseStorageOptions = declareResponse.getStorageOptions(); - } catch (UnsupportedOperationException e) { - // Fall back to deprecated createEmptyTable - CreateEmptyTableRequest fallbackRequest = new CreateEmptyTableRequest(); - fallbackRequest.setId(tableId); - CreateEmptyTableResponse fallbackResponse = namespace.createEmptyTable(fallbackRequest); - location = fallbackResponse.getLocation(); - responseStorageOptions = fallbackResponse.getStorageOptions(); + DeclareTableRequest declareRequest = new DeclareTableRequest(); + declareRequest.setId(tableId); + if (properties != null && !properties.isEmpty()) { + declareRequest.setProperties(properties); } + DeclareTableResponse declareResponse = namespaceClient.declareTable(declareRequest); - tableUri = location; + 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"); } - namespaceStorageOptions = ignoreNamespaceStorageOptions ? null : responseStorageOptions; + 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(); + 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); @@ -401,7 +485,11 @@ private Dataset executeWithNamespace() { // Build WriteParams with merged storage options WriteParams.Builder paramsBuilder = - new WriteParams.Builder().withMode(mode).withStorageOptions(mergedStorageOptions); + new WriteParams.Builder() + .withMode(mode) + .withStorageOptions(mergedStorageOptions) + .withBaseStoreParams(baseStoreParams) + .withFileWriteOptions(fileWriteOptions); maxRowsPerFile.ifPresent(paramsBuilder::withMaxRowsPerFile); maxRowsPerGroup.ifPresent(paramsBuilder::withMaxRowsPerGroup); @@ -411,22 +499,36 @@ private Dataset executeWithNamespace() { initialBases.ifPresent(paramsBuilder::withInitialBases); targetBases.ifPresent(paramsBuilder::withTargetBases); + allowExternalBlobOutsideBases.ifPresent(paramsBuilder::withAllowExternalBlobOutsideBases); + blobPackFileSizeThreshold.ifPresent(paramsBuilder::withBlobPackFileSizeThreshold); WriteParams params = paramsBuilder.build(); - // Create storage options provider for credential refresh during long-running writes - StorageOptionsProvider storageOptionsProvider = - ignoreNamespaceStorageOptions - ? null - : new LanceNamespaceStorageOptionsProvider(namespace, tableId); - - // Use Dataset.create() which handles CREATE/APPEND/OVERWRITE modes - return createDatasetWithStream(tableUri, params, storageOptionsProvider); + // 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 { + // 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); + } } private Dataset executeWithUri() { WriteParams.Builder paramsBuilder = - new WriteParams.Builder().withMode(mode).withStorageOptions(storageOptions); + new WriteParams.Builder() + .withMode(mode) + .withStorageOptions(storageOptions) + .withBaseStoreParams(baseStoreParams) + .withFileWriteOptions(fileWriteOptions); maxRowsPerFile.ifPresent(paramsBuilder::withMaxRowsPerFile); maxRowsPerGroup.ifPresent(paramsBuilder::withMaxRowsPerGroup); @@ -435,28 +537,71 @@ 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(); - return createDatasetWithStream(uri, params, null); + return createDatasetWithStream(uri, params); + } + + private Dataset createDatasetWithStream(String path, WriteParams params) { + // If stream is directly provided, use it + if (stream != null) { + 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); + } + } + + // If only schema is provided (empty table), use Dataset.create with schema + if (schema != null) { + return Dataset.create(allocator, path, schema, params); + } + + throw new IllegalStateException("No data source provided"); } - private Dataset createDatasetWithStream( - String path, WriteParams params, StorageOptionsProvider storageOptionsProvider) { + private Dataset createDatasetWithStreamAndNamespaceClient( + String path, + WriteParams params, + LanceNamespace namespaceClient, + List tableId, + boolean namespaceClientManagedVersioning) { // 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, + namespaceClient, + tableId, + namespaceClientManagedVersioning); } // 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, + namespaceClient, + tableId, + namespaceClientManagedVersioning); } } // If only schema is provided (empty table), use Dataset.create with schema + // 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 56ce06a7b0a..3c7137d1db0 100644 --- a/java/src/main/java/org/lance/WriteFragmentBuilder.java +++ b/java/src/main/java/org/lance/WriteFragmentBuilder.java @@ -13,7 +13,9 @@ */ package org.lance; -import org.lance.io.StorageOptionsProvider; +import org.lance.file.FileWriteOptions; +import org.lance.namespace.LanceNamespace; +import org.lance.schema.LanceSchema; import org.apache.arrow.c.ArrowArrayStream; import org.apache.arrow.memory.BufferAllocator; @@ -45,9 +47,12 @@ public class WriteFragmentBuilder { private BufferAllocator allocator; private VectorSchemaRoot vectorSchemaRoot; private ArrowArrayStream arrowArrayStream; + private LanceSchema schema; private WriteParams writeParams; private WriteParams.Builder writeParamsBuilder; - private StorageOptionsProvider storageOptionsProvider; + private LanceNamespace namespaceClient; + private List tableId; + private Session session; WriteFragmentBuilder() {} @@ -99,6 +104,22 @@ public WriteFragmentBuilder data(ArrowArrayStream stream) { return this; } + /** + * Set the Lance dataset schema to use when writing fragments. + * + *

This is useful for distributed writes where workers create uncommitted fragments and a + * coordinator commits them later. When this schema is supplied, lance-core does not need to open + * the existing dataset to infer the schema in APPEND mode. The schema should come from the target + * dataset so Lance field IDs are preserved. + * + * @param schema the target Lance dataset schema + * @return this builder + */ + public WriteFragmentBuilder schema(LanceSchema schema) { + this.schema = schema; + return this; + } + /** * Set the write parameters. * @@ -123,13 +144,59 @@ public WriteFragmentBuilder storageOptions(Map storageOptions) { } /** - * Set the storage options provider for dynamic credential refresh. + * Set runtime-only object store parameters for registered base paths. + * + *

Entries are keyed by the exact {@link BasePath#getPath()} value persisted in the manifest. + * Each value is the storage options map used for that base. Bases without an explicit entry use + * {@link #storageOptions(Map)} as the fallback. + * + * @param baseStoreParams object store parameters keyed by base path URI + * @return this builder + */ + public WriteFragmentBuilder baseStoreParams(Map> baseStoreParams) { + ensureWriteParamsBuilder(); + this.writeParamsBuilder.withBaseStoreParams(baseStoreParams); + return this; + } + + /** + * Set the namespace client for automatic credential refresh. + * + *

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 tableId(List tableId) { + this.tableId = tableId; + return this; + } + + /** + * Set a session to reuse across operations. + * + *

The session holds shared caches (metadata and index) and the object store registry. * - * @param provider the storage options provider + * @param session the session to share * @return this builder */ - public WriteFragmentBuilder storageOptionsProvider(StorageOptionsProvider provider) { - this.storageOptionsProvider = provider; + public WriteFragmentBuilder session(Session session) { + this.session = session; return this; } @@ -169,6 +236,18 @@ public WriteFragmentBuilder maxBytesPerFile(long maxBytesPerFile) { return this; } + /** + * Set options for configuring the current-format file writer. + * + * @param fileWriteOptions file writer options + * @return this builder + */ + public WriteFragmentBuilder fileWriteOptions(FileWriteOptions fileWriteOptions) { + ensureWriteParamsBuilder(); + this.writeParamsBuilder.withFileWriteOptions(fileWriteOptions); + return this; + } + /** * Set the write mode. * @@ -196,15 +275,39 @@ public WriteFragmentBuilder enableStableRowIds(boolean enable) { /** * Set the data storage version. * - * @param version the data storage version + * @param version the data storage version (e.g., "legacy", "stable", "2.0") * @return this builder */ - public WriteFragmentBuilder dataStorageVersion(WriteParams.LanceFileVersion version) { + public WriteFragmentBuilder dataStorageVersion(String version) { ensureWriteParamsBuilder(); this.writeParamsBuilder.withDataStorageVersion(version); return this; } + /** + * Register base paths when creating a new dataset from fragments. + * + * @param bases base paths to register + * @return this builder + */ + public WriteFragmentBuilder initialBases(List bases) { + ensureWriteParamsBuilder(); + this.writeParamsBuilder.withInitialBases(bases); + return this; + } + + /** + * Set base names or paths where new fragment files should be written. + * + * @param targetBases base names or exact paths + * @return this builder + */ + public WriteFragmentBuilder targetBases(List targetBases) { + ensureWriteParamsBuilder(); + this.writeParamsBuilder.withTargetBases(targetBases); + return this; + } + /** * Execute the fragment write operation. * @@ -213,15 +316,31 @@ public WriteFragmentBuilder dataStorageVersion(WriteParams.LanceFileVersion vers 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, + schema, + session); } else { return Fragment.create( - datasetUri, arrowArrayStream, finalWriteParams, storageOptionsProvider); + datasetUri, + allocator, + arrowArrayStream, + finalWriteParams, + namespaceClient, + tableId, + schema, + session); } } @@ -252,8 +371,14 @@ private void validate() { Preconditions.checkState( vectorSchemaRoot == null || allocator != null, "allocator is required when using VectorSchemaRoot"); + Preconditions.checkState( + schema == null || allocator != null, "allocator is required with schema"); 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/WriteParams.java b/java/src/main/java/org/lance/WriteParams.java index c095009bc67..d4084e7202d 100644 --- a/java/src/main/java/org/lance/WriteParams.java +++ b/java/src/main/java/org/lance/WriteParams.java @@ -13,7 +13,10 @@ */ package org.lance; +import org.lance.file.FileWriteOptions; + import com.google.common.base.MoreObjects; +import org.apache.arrow.util.Preconditions; import java.util.HashMap; import java.util.List; @@ -30,58 +33,50 @@ public enum WriteMode { OVERWRITE } - public enum LanceFileVersion { - LEGACY("legacy"), - V0_1("0.1"), - V2_0("2.0"), - STABLE("stable"), - V2_1("2.1"), - NEXT("next"), - V2_2("2.2"); - - private final String versionString; - - LanceFileVersion(String versionString) { - this.versionString = versionString; - } - - public String getVersionString() { - return versionString; - } - } - private final Optional maxRowsPerFile; private final Optional maxRowsPerGroup; private final Optional maxBytesPerFile; + private final FileWriteOptions fileWriteOptions; private final Optional mode; private final Optional enableStableRowIds; - private final Optional dataStorageVersion; + private final Optional dataStorageVersion; private final Optional enableV2ManifestPaths; private Map storageOptions = new HashMap<>(); + private Map> baseStoreParams = new HashMap<>(); private final Optional> initialBases; private final Optional> targetBases; + private final Optional allowExternalBlobOutsideBases; + private final Optional blobPackFileSizeThreshold; private WriteParams( Optional maxRowsPerFile, Optional maxRowsPerGroup, Optional maxBytesPerFile, + FileWriteOptions fileWriteOptions, Optional mode, Optional enableStableRowIds, - Optional dataStorageVersion, + Optional dataStorageVersion, Optional enableV2ManifestPaths, Map storageOptions, + Map> baseStoreParams, Optional> initialBases, - Optional> targetBases) { + Optional> targetBases, + Optional allowExternalBlobOutsideBases, + Optional blobPackFileSizeThreshold) { this.maxRowsPerFile = maxRowsPerFile; this.maxRowsPerGroup = maxRowsPerGroup; this.maxBytesPerFile = maxBytesPerFile; + this.fileWriteOptions = fileWriteOptions; this.mode = mode; this.enableStableRowIds = enableStableRowIds; this.dataStorageVersion = dataStorageVersion; this.enableV2ManifestPaths = enableV2ManifestPaths; this.storageOptions = storageOptions; + this.baseStoreParams = baseStoreParams; this.initialBases = initialBases; this.targetBases = targetBases; + this.allowExternalBlobOutsideBases = allowExternalBlobOutsideBases; + this.blobPackFileSizeThreshold = blobPackFileSizeThreshold; } public Optional getMaxRowsPerFile() { @@ -96,6 +91,10 @@ public Optional getMaxBytesPerFile() { return maxBytesPerFile; } + public FileWriteOptions getFileWriteOptions() { + return fileWriteOptions; + } + /** * Get Mode with name. * @@ -110,7 +109,7 @@ public Optional getEnableStableRowIds() { } public Optional getDataStorageVersion() { - return dataStorageVersion.map(LanceFileVersion::getVersionString); + return dataStorageVersion; } public Optional getEnableV2ManifestPaths() { @@ -121,6 +120,10 @@ public Map getStorageOptions() { return storageOptions; } + public Map> getBaseStoreParams() { + return baseStoreParams; + } + public Optional> getInitialBases() { return initialBases; } @@ -129,12 +132,37 @@ 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; + } + + /** + * 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) .add("maxRowsPerFile", maxRowsPerFile.orElse(null)) .add("maxRowsPerGroup", maxRowsPerGroup.orElse(null)) .add("maxBytesPerFile", maxBytesPerFile.orElse(null)) + .add("dataCacheBytes", fileWriteOptions.getDataCacheBytes().orElse(null)) + .add("maxPageBytes", fileWriteOptions.getMaxPageBytes().orElse(null)) .add("mode", mode.orElse(null)) .add("dataStorageVersion", dataStorageVersion.orElse(null)) .toString(); @@ -145,13 +173,17 @@ public static class Builder { private Optional maxRowsPerFile = Optional.empty(); private Optional maxRowsPerGroup = Optional.empty(); private Optional maxBytesPerFile = Optional.empty(); + private FileWriteOptions fileWriteOptions = FileWriteOptions.builder().build(); private Optional mode = Optional.empty(); private Optional enableStableRowIds = Optional.empty(); - private Optional dataStorageVersion = Optional.empty(); + private Optional dataStorageVersion = Optional.empty(); private Optional enableV2ManifestPaths; private Map storageOptions = new HashMap<>(); + private Map> baseStoreParams = new HashMap<>(); 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); @@ -168,21 +200,60 @@ public Builder withMaxBytesPerFile(long maxBytesPerFile) { return this; } + /** + * Set options for configuring the current-format file writer. + * + * @param fileWriteOptions file writer options + * @return this builder + */ + public Builder withFileWriteOptions(FileWriteOptions fileWriteOptions) { + this.fileWriteOptions = + Preconditions.checkNotNull(fileWriteOptions, "fileWriteOptions must not be null"); + return this; + } + public Builder withMode(WriteMode mode) { this.mode = Optional.of(mode); return this; } - public Builder withDataStorageVersion(LanceFileVersion dataStorageVersion) { + public Builder withDataStorageVersion(String dataStorageVersion) { this.dataStorageVersion = Optional.of(dataStorageVersion); return this; } + /** + * Set storage options for the write. + * + *

For writes involving additional registered base paths, a key of the form {@code + * base_.} applies {@code } only to the base path with that id, overriding the + * unscoped options that every base inherits. Exact per-base bindings set via {@link + * #withBaseStoreParams(Map)} take precedence over base-scoped keys. + * + * @param storageOptions the storage options + * @return this builder + */ public Builder withStorageOptions(Map storageOptions) { this.storageOptions = storageOptions; return this; } + /** + * Set runtime-only object store parameters for registered base paths. + * + *

Entries are keyed by the exact {@link BasePath#getPath()} value persisted in the manifest. + * Each value is the storage options map used as-is for that base. These params are not + * persisted in the manifest. If a base has no explicit entry, {@link #withStorageOptions(Map)} + * remains the fallback. + * + * @param baseStoreParams object store parameters keyed by base path URI + * @return this builder + */ + public Builder withBaseStoreParams(Map> baseStoreParams) { + this.baseStoreParams = baseStoreParams; + return this; + } + public Builder withEnableStableRowIds(boolean enableStableRowIds) { this.enableStableRowIds = Optional.of(enableStableRowIds); return this; @@ -203,18 +274,50 @@ 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; + } + + /** + * 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, maxRowsPerGroup, maxBytesPerFile, + fileWriteOptions, mode, enableStableRowIds, dataStorageVersion, enableV2ManifestPaths, storageOptions, + baseStoreParams, initialBases, - targetBases); + targetBases, + allowExternalBlobOutsideBases, + blobPackFileSizeThreshold); } } } diff --git a/java/src/main/java/org/lance/WriterVersion.java b/java/src/main/java/org/lance/WriterVersion.java new file mode 100644 index 00000000000..7e4fda48cf1 --- /dev/null +++ b/java/src/main/java/org/lance/WriterVersion.java @@ -0,0 +1,57 @@ +/* + * 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 java.util.Optional; + +/** Version metadata for the library that wrote a dataset manifest. */ +public final class WriterVersion { + private final String library; + private final String version; + private final String prerelease; + private final String buildMetadata; + + WriterVersion(String library, String version, String prerelease, String buildMetadata) { + this.library = library; + this.version = version; + this.prerelease = prerelease; + this.buildMetadata = buildMetadata; + } + + /** Name of the writer library, such as {@code lance}. */ + public String getLibrary() { + return library; + } + + /** + * Version string reported by the writer library. + * + *

This value is opaque because writer libraries are not required to use semantic versioning. + * When a writer does use semantic versioning, newer writers store the core version here and + * expose prerelease and build metadata separately. + */ + public String getVersion() { + return version; + } + + /** Optional semantic-version prerelease component, when supplied by the writer. */ + public Optional getPrerelease() { + return Optional.ofNullable(prerelease); + } + + /** Optional semantic-version build metadata component, when supplied by the writer. */ + public Optional getBuildMetadata() { + return Optional.ofNullable(buildMetadata); + } +} diff --git a/java/src/main/java/org/lance/cleanup/CleanupCandidateFile.java b/java/src/main/java/org/lance/cleanup/CleanupCandidateFile.java new file mode 100644 index 00000000000..a7925a746af --- /dev/null +++ b/java/src/main/java/org/lance/cleanup/CleanupCandidateFile.java @@ -0,0 +1,50 @@ +/* + * 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.cleanup; + +/** A file that cleanup identified as removable. */ +public class CleanupCandidateFile { + private final String path; + private final CleanupFileKind kind; + private final boolean unverified; + private final long sizeBytes; + + public CleanupCandidateFile(String path, String kind, boolean unverified, long sizeBytes) { + this(path, CleanupFileKind.fromRustString(kind), unverified, sizeBytes); + } + + public CleanupCandidateFile( + String path, CleanupFileKind kind, boolean unverified, long sizeBytes) { + this.path = path; + this.kind = kind; + this.unverified = unverified; + this.sizeBytes = sizeBytes; + } + + public String getPath() { + return path; + } + + public CleanupFileKind getKind() { + return kind; + } + + public boolean isUnverified() { + return unverified; + } + + public long getSizeBytes() { + return sizeBytes; + } +} diff --git a/java/src/main/java/org/lance/cleanup/CleanupExplanation.java b/java/src/main/java/org/lance/cleanup/CleanupExplanation.java new file mode 100644 index 00000000000..320d555c3b5 --- /dev/null +++ b/java/src/main/java/org/lance/cleanup/CleanupExplanation.java @@ -0,0 +1,72 @@ +/* + * 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.cleanup; + +import java.util.List; + +/** Read-only explanation of what cleanup would remove. */ +public class CleanupExplanation { + private final long readVersion; + private final RemovalStats stats; + private final List candidateFiles; + private final boolean candidateFilesTruncated; + private final long candidateFileLimit; + private final List referencedBranches; + private final List warnings; + + public CleanupExplanation( + long readVersion, + RemovalStats stats, + List candidateFiles, + boolean candidateFilesTruncated, + long candidateFileLimit, + List referencedBranches, + List warnings) { + this.readVersion = readVersion; + this.stats = stats; + this.candidateFiles = candidateFiles; + this.candidateFilesTruncated = candidateFilesTruncated; + this.candidateFileLimit = candidateFileLimit; + this.referencedBranches = referencedBranches; + this.warnings = warnings; + } + + public long getReadVersion() { + return readVersion; + } + + public RemovalStats getStats() { + return stats; + } + + public List getCandidateFiles() { + return candidateFiles; + } + + public boolean isCandidateFilesTruncated() { + return candidateFilesTruncated; + } + + public long getCandidateFileLimit() { + return candidateFileLimit; + } + + public List getReferencedBranches() { + return referencedBranches; + } + + public List getWarnings() { + return warnings; + } +} diff --git a/java/src/main/java/org/lance/cleanup/CleanupFileKind.java b/java/src/main/java/org/lance/cleanup/CleanupFileKind.java new file mode 100644 index 00000000000..695e778fc76 --- /dev/null +++ b/java/src/main/java/org/lance/cleanup/CleanupFileKind.java @@ -0,0 +1,43 @@ +/* + * 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.cleanup; + +/** Kind of file identified by cleanup. */ +public enum CleanupFileKind { + MANIFEST, + DATA, + TRANSACTION, + INDEX, + DELETION, + TEMPORARY_MANIFEST; + + public static CleanupFileKind fromRustString(String value) { + switch (value) { + case "manifest": + return MANIFEST; + case "data": + return DATA; + case "transaction": + return TRANSACTION; + case "index": + return INDEX; + case "deletion": + return DELETION; + case "temporary_manifest": + return TEMPORARY_MANIFEST; + default: + throw new IllegalArgumentException("Unknown cleanup file kind: " + value); + } + } +} diff --git a/java/src/main/java/org/lance/cleanup/CleanupPolicy.java b/java/src/main/java/org/lance/cleanup/CleanupPolicy.java index 075fef724aa..25da8763747 100644 --- a/java/src/main/java/org/lance/cleanup/CleanupPolicy.java +++ b/java/src/main/java/org/lance/cleanup/CleanupPolicy.java @@ -13,6 +13,9 @@ */ package org.lance.cleanup; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; import java.util.Optional; /** @@ -24,18 +27,27 @@ public class CleanupPolicy { private final Optional beforeTimestampMillis; private final Optional beforeVersion; + private final Optional> versions; private final Optional deleteUnverified; private final Optional errorIfTaggedOldVersions; + private final Optional cleanReferencedBranches; + private final Optional deleteRateLimit; private CleanupPolicy( Optional beforeTimestampMillis, Optional beforeVersion, + Optional> versions, Optional deleteUnverified, - Optional errorIfTaggedOldVersions) { + Optional errorIfTaggedOldVersions, + Optional cleanReferencedBranches, + Optional deleteRateLimit) { this.beforeTimestampMillis = beforeTimestampMillis; this.beforeVersion = beforeVersion; + this.versions = versions; this.deleteUnverified = deleteUnverified; this.errorIfTaggedOldVersions = errorIfTaggedOldVersions; + this.cleanReferencedBranches = cleanReferencedBranches; + this.deleteRateLimit = deleteRateLimit; } public static Builder builder() { @@ -50,6 +62,10 @@ public Optional getBeforeVersion() { return beforeVersion; } + public Optional> getVersions() { + return versions; + } + public Optional getDeleteUnverified() { return deleteUnverified; } @@ -58,12 +74,23 @@ public Optional getErrorIfTaggedOldVersions() { return errorIfTaggedOldVersions; } + public Optional getCleanReferencedBranches() { + return cleanReferencedBranches; + } + + public Optional getDeleteRateLimit() { + return deleteRateLimit; + } + /** Builder for CleanupPolicy. */ public static class Builder { private Optional beforeTimestampMillis = Optional.empty(); private Optional beforeVersion = Optional.empty(); + private Optional> versions = Optional.empty(); private Optional deleteUnverified = Optional.empty(); private Optional errorIfTaggedOldVersions = Optional.empty(); + private Optional cleanReferencedBranches = Optional.empty(); + private Optional deleteRateLimit = Optional.empty(); private Builder() {} @@ -79,6 +106,12 @@ public Builder withBeforeVersion(long beforeVersion) { return this; } + /** Set the exact dataset versions to clean. */ + public Builder withVersions(List versions) { + this.versions = Optional.of(Collections.unmodifiableList(new ArrayList<>(versions))); + return this; + } + /** If true, delete unverified data files even if they are recent. */ public Builder withDeleteUnverified(boolean deleteUnverified) { this.deleteUnverified = Optional.of(deleteUnverified); @@ -91,9 +124,27 @@ public Builder withErrorIfTaggedOldVersions(boolean errorIfTaggedOldVersions) { return this; } + /** If true, clean referenced branches before clean the current branch. */ + public Builder withCleanReferencedBranches(boolean cleanReferencedBranches) { + this.cleanReferencedBranches = Optional.of(cleanReferencedBranches); + return this; + } + + /** Set the maximum number of delete operations per second. */ + public Builder withDeleteRateLimit(long deleteRateLimit) { + this.deleteRateLimit = Optional.of(deleteRateLimit); + return this; + } + public CleanupPolicy build() { return new CleanupPolicy( - beforeTimestampMillis, beforeVersion, deleteUnverified, errorIfTaggedOldVersions); + beforeTimestampMillis, + beforeVersion, + versions, + deleteUnverified, + errorIfTaggedOldVersions, + cleanReferencedBranches, + deleteRateLimit); } } } diff --git a/java/src/main/java/org/lance/cleanup/CleanupReferencedBranch.java b/java/src/main/java/org/lance/cleanup/CleanupReferencedBranch.java new file mode 100644 index 00000000000..47fae25cd5a --- /dev/null +++ b/java/src/main/java/org/lance/cleanup/CleanupReferencedBranch.java @@ -0,0 +1,39 @@ +/* + * 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.cleanup; + +/** A branch that references the current branch lineage. */ +public class CleanupReferencedBranch { + private final String name; + private final long referencedVersion; + private final boolean cleanupCandidate; + + public CleanupReferencedBranch(String name, long referencedVersion, boolean cleanupCandidate) { + this.name = name; + this.referencedVersion = referencedVersion; + this.cleanupCandidate = cleanupCandidate; + } + + public String getName() { + return name; + } + + public long getReferencedVersion() { + return referencedVersion; + } + + public boolean isCleanupCandidate() { + return cleanupCandidate; + } +} diff --git a/java/src/main/java/org/lance/cleanup/RemovalStats.java b/java/src/main/java/org/lance/cleanup/RemovalStats.java index 1d88324f907..ebd4571b56d 100644 --- a/java/src/main/java/org/lance/cleanup/RemovalStats.java +++ b/java/src/main/java/org/lance/cleanup/RemovalStats.java @@ -17,10 +17,48 @@ public class RemovalStats { private final long bytesRemoved; private final long oldVersions; + private final long dataFilesRemoved; + private final long transactionFilesRemoved; + private final long indexFilesRemoved; + private final long deletionFilesRemoved; + private final long failedDeletes; public RemovalStats(long bytesRemoved, long oldVersions) { + this(bytesRemoved, oldVersions, 0L, 0L, 0L, 0L, 0L); + } + + public RemovalStats( + long bytesRemoved, + long oldVersions, + long dataFilesRemoved, + long transactionFilesRemoved, + long indexFilesRemoved, + long deletionFilesRemoved) { + this( + bytesRemoved, + oldVersions, + dataFilesRemoved, + transactionFilesRemoved, + indexFilesRemoved, + deletionFilesRemoved, + 0L); + } + + public RemovalStats( + long bytesRemoved, + long oldVersions, + long dataFilesRemoved, + long transactionFilesRemoved, + long indexFilesRemoved, + long deletionFilesRemoved, + long failedDeletes) { this.bytesRemoved = bytesRemoved; this.oldVersions = oldVersions; + this.dataFilesRemoved = dataFilesRemoved; + this.transactionFilesRemoved = transactionFilesRemoved; + this.indexFilesRemoved = indexFilesRemoved; + this.deletionFilesRemoved = deletionFilesRemoved; + this.failedDeletes = failedDeletes; } public long getBytesRemoved() { @@ -30,4 +68,32 @@ public long getBytesRemoved() { public long getOldVersions() { return oldVersions; } + + public long getDataFilesRemoved() { + return dataFilesRemoved; + } + + public long getTransactionFilesRemoved() { + return transactionFilesRemoved; + } + + public long getIndexFilesRemoved() { + return indexFilesRemoved; + } + + public long getDeletionFilesRemoved() { + return deletionFilesRemoved; + } + + /** + * Number of objects cleanup tried and failed to delete. + * + *

Cleanup is best effort: a failed delete is counted here and the sweep continues, so a + * non-zero value means the cleanup completed but did not remove everything it identified. The + * files it could not remove are still unreferenced and a later cleanup will retry them. Callers + * that treat cleanup as all-or-nothing should check this rather than rely on the call throwing. + */ + public long getFailedDeletes() { + return failedDeletes; + } } diff --git a/java/src/main/java/org/lance/compaction/Compaction.java b/java/src/main/java/org/lance/compaction/Compaction.java index cd294428678..e739d09dac8 100644 --- a/java/src/main/java/org/lance/compaction/Compaction.java +++ b/java/src/main/java/org/lance/compaction/Compaction.java @@ -15,6 +15,7 @@ import org.lance.Dataset; import org.lance.JniLoader; +import org.lance.LockManager; import com.google.common.base.Preconditions; @@ -32,16 +33,25 @@ public static CompactionPlan planCompaction( Preconditions.checkNotNull(dataset); Preconditions.checkNotNull(compactionOptions); - return nativePlanCompaction( - dataset, - compactionOptions.getTargetRowsPerFragment(), - compactionOptions.getMaxRowsPerGroup(), - compactionOptions.getMaxBytesPerFile(), - compactionOptions.getMaterializeDeletions(), - compactionOptions.getMaterializeDeletionsThreshold(), - compactionOptions.getNumThreads(), - compactionOptions.getBatchSize(), - compactionOptions.getDeferIndexRemap()); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativePlanCompaction( + dataset, + compactionOptions.getTargetRowsPerFragment(), + compactionOptions.getMaxRowsPerGroup(), + compactionOptions.getMaxBytesPerFile(), + compactionOptions.getMaterializeDeletions(), + compactionOptions.getMaterializeDeletionsThreshold(), + compactionOptions.getNumThreads(), + compactionOptions.getBatchSize(), + compactionOptions.getDeferIndexRemap(), + compactionOptions.getCompactionMode(), + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments(), + compactionOptions.getMaxSourceRows(), + compactionOptions.getMaxSourceBytes(), + compactionOptions.getExcludedFragmentIds(), + compactionOptions.getDataStorageVersion()); + } } public static CompactionMetrics commitCompaction( @@ -59,10 +69,59 @@ public static CompactionMetrics commitCompaction( compactionOptions.getMaterializeDeletionsThreshold(), compactionOptions.getNumThreads(), compactionOptions.getBatchSize(), - compactionOptions.getDeferIndexRemap()); + compactionOptions.getDeferIndexRemap(), + compactionOptions.getCompactionMode(), + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments(), + compactionOptions.getMaxSourceRows(), + compactionOptions.getMaxSourceBytes(), + compactionOptions.getExcludedFragmentIds()); + } + + /** + * Java wrapper around the raw commit-compaction JNI call. It acquires the dataset read lock so + * the native call cannot race with {@link Dataset#close()}; keep the raw native method private so + * no caller can bypass this lock. + */ + public static CompactionMetrics nativeCommitCompaction( + Dataset dataset, + List rewriteResults, + Optional targetRowsPerFragment, + Optional maxRowsPerGroup, + Optional maxBytesPerFile, + Optional materializeDeletions, + Optional materializeDeletionsThreshold, + Optional numThreads, + Optional batchSize, + Optional deferIndexRemap, + Optional compactionMode, + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments, + Optional maxSourceRows, + Optional maxSourceBytes, + List excludedFragmentIds) { + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return commitCompactionNative( + dataset, + rewriteResults, + targetRowsPerFragment, + maxRowsPerGroup, + maxBytesPerFile, + materializeDeletions, + materializeDeletionsThreshold, + numThreads, + batchSize, + deferIndexRemap, + compactionMode, + binaryCopyReadBatchBytes, + maxSourceFragments, + maxSourceRows, + maxSourceBytes, + excludedFragmentIds); + } } - public static native CompactionMetrics nativeCommitCompaction( + private static native CompactionMetrics commitCompactionNative( Dataset dataset, List rewriteResults, Optional targetRowsPerFragment, @@ -72,7 +131,13 @@ public static native CompactionMetrics nativeCommitCompaction( Optional materializeDeletionsThreshold, Optional numThreads, Optional batchSize, - Optional deferIndexRemap); + Optional deferIndexRemap, + Optional compactionMode, + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments, + Optional maxSourceRows, + Optional maxSourceBytes, + List excludedFragmentIds); private static native CompactionPlan nativePlanCompaction( Dataset dataset, @@ -83,5 +148,12 @@ private static native CompactionPlan nativePlanCompaction( Optional materializeDeletionsThreshold, Optional numThreads, Optional batchSize, - Optional deferIndexRemap); + Optional deferIndexRemap, + Optional compactionMode, + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments, + Optional maxSourceRows, + Optional maxSourceBytes, + List excludedFragmentIds, + Optional dataStorageVersion); } diff --git a/java/src/main/java/org/lance/compaction/CompactionMode.java b/java/src/main/java/org/lance/compaction/CompactionMode.java new file mode 100644 index 00000000000..d0a24f875d5 --- /dev/null +++ b/java/src/main/java/org/lance/compaction/CompactionMode.java @@ -0,0 +1,34 @@ +/* + * 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.compaction; + +/** Controls how data is rewritten during compaction. */ +public enum CompactionMode { + /** Decode and re-encode data (default). */ + REENCODE("reencode"), + /** Try binary copy if fragments are compatible, fall back to reencode otherwise. */ + TRY_BINARY_COPY("try_binary_copy"), + /** Use binary copy or fail if fragments are not compatible. */ + FORCE_BINARY_COPY("force_binary_copy"); + + private final String value; + + CompactionMode(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/java/src/main/java/org/lance/compaction/CompactionOptions.java b/java/src/main/java/org/lance/compaction/CompactionOptions.java index d5db1be817c..d83e618da5b 100644 --- a/java/src/main/java/org/lance/compaction/CompactionOptions.java +++ b/java/src/main/java/org/lance/compaction/CompactionOptions.java @@ -13,12 +13,18 @@ */ package org.lance.compaction; +import org.lance.DataStorageVersion; + import com.google.common.base.MoreObjects; import java.io.IOException; import java.io.ObjectInputStream; import java.io.ObjectOutputStream; +import java.io.OptionalDataException; import java.io.Serializable; +import java.util.Collections; +import java.util.List; +import java.util.Objects; import java.util.Optional; /** @@ -28,6 +34,10 @@ * default values. */ public class CompactionOptions implements Serializable { + // Pinned to the UID generated before maxSourceRows/maxSourceBytes were added, so that + // CompactionTask streams queued by older workers still deserialize during a rolling upgrade. + private static final long serialVersionUID = 3114922060085417942L; + // these fields are effectively final, but not marked as final for de/ser private Optional targetRowsPerFragment; private Optional maxRowsPerGroup; @@ -37,6 +47,13 @@ public class CompactionOptions implements Serializable { private Optional numThreads; private Optional batchSize; private Optional deferIndexRemap; + private Optional compactionMode; + private Optional binaryCopyReadBatchBytes; + private Optional maxSourceFragments; + private Optional maxSourceRows; + private Optional maxSourceBytes; + private List excludedFragmentIds; + private Optional dataStorageVersion; private CompactionOptions( Optional targetRowsPerFragment, @@ -46,7 +63,14 @@ private CompactionOptions( Optional materializeDeletionsThreshold, Optional numThreads, Optional batchSize, - Optional deferIndexRemap) { + Optional deferIndexRemap, + Optional compactionMode, + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments, + Optional maxSourceRows, + Optional maxSourceBytes, + List excludedFragmentIds, + Optional dataStorageVersion) { this.targetRowsPerFragment = targetRowsPerFragment; this.maxRowsPerGroup = maxRowsPerGroup; this.maxBytesPerFile = maxBytesPerFile; @@ -55,12 +79,49 @@ private CompactionOptions( this.numThreads = numThreads; this.batchSize = batchSize; this.deferIndexRemap = deferIndexRemap; + this.compactionMode = compactionMode; + this.binaryCopyReadBatchBytes = binaryCopyReadBatchBytes; + this.maxSourceFragments = maxSourceFragments; + this.maxSourceRows = maxSourceRows; + this.maxSourceBytes = maxSourceBytes; + this.excludedFragmentIds = List.copyOf(excludedFragmentIds); + this.dataStorageVersion = dataStorageVersion.map(DataStorageVersion::fromRustString); } public Optional getDeferIndexRemap() { return deferIndexRemap; } + /** Returns the compaction mode as its string value for the native layer. */ + public Optional getCompactionMode() { + return compactionMode.map(CompactionMode::getValue); + } + + public Optional getBinaryCopyReadBatchBytes() { + return binaryCopyReadBatchBytes; + } + + public Optional getMaxSourceFragments() { + return maxSourceFragments; + } + + public Optional getMaxSourceRows() { + return maxSourceRows; + } + + public Optional getMaxSourceBytes() { + return maxSourceBytes; + } + + public List getExcludedFragmentIds() { + return excludedFragmentIds; + } + + /** Returns the version selector as its string value for the native layer. */ + public Optional getDataStorageVersion() { + return dataStorageVersion.map(DataStorageVersion::toRustString); + } + public Optional getMaterializeDeletions() { return materializeDeletions; } @@ -104,6 +165,13 @@ public String toString() { .add("numThreads", numThreads.orElse(null)) .add("batchSize", batchSize.orElse(null)) .add("deferIndexRemap", deferIndexRemap.orElse(null)) + .add("compactionMode", compactionMode.orElse(null)) + .add("binaryCopyReadBatchBytes", binaryCopyReadBatchBytes.orElse(null)) + .add("maxSourceFragments", maxSourceFragments.orElse(null)) + .add("maxSourceRows", maxSourceRows.orElse(null)) + .add("maxSourceBytes", maxSourceBytes.orElse(null)) + .add("excludedFragmentIds", excludedFragmentIds) + .add("dataStorageVersion", dataStorageVersion.orElse(null)) .toString(); } @@ -116,6 +184,13 @@ private void writeObject(ObjectOutputStream output) throws IOException { output.writeObject(numThreads.orElse(null)); output.writeObject(batchSize.orElse(null)); output.writeObject(deferIndexRemap.orElse(null)); + output.writeObject(compactionMode.map(CompactionMode::getValue).orElse(null)); + output.writeObject(binaryCopyReadBatchBytes.orElse(null)); + output.writeObject(maxSourceFragments.orElse(null)); + output.writeObject(maxSourceRows.orElse(null)); + output.writeObject(maxSourceBytes.orElse(null)); + output.writeObject(excludedFragmentIds); + output.writeObject(getDataStorageVersion().orElse(null)); } private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { @@ -127,6 +202,65 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou this.numThreads = Optional.ofNullable((Long) input.readObject()); this.batchSize = Optional.ofNullable((Long) input.readObject()); this.deferIndexRemap = Optional.ofNullable((Boolean) input.readObject()); + String modeStr = (String) input.readObject(); + this.compactionMode = Optional.empty(); + if (modeStr != null) { + for (CompactionMode m : CompactionMode.values()) { + if (m.getValue().equals(modeStr)) { + this.compactionMode = Optional.of(m); + break; + } + } + } + this.binaryCopyReadBatchBytes = Optional.ofNullable((Long) input.readObject()); + this.maxSourceFragments = Optional.ofNullable((Long) input.readObject()); + this.maxSourceRows = readTrailingLong(input); + this.maxSourceBytes = readTrailingLong(input); + this.excludedFragmentIds = readTrailingLongList(input); + this.dataStorageVersion = readTrailingString(input).map(DataStorageVersion::fromRustString); + } + + /** + * Reads a trailing Long field that older writers did not emit. Streams written before the field + * was added end here, which surfaces as an {@link OptionalDataException} with {@code eof} set; in + * that case the field is treated as unset. + */ + private static Optional readTrailingLong(ObjectInputStream input) + throws IOException, ClassNotFoundException { + try { + return Optional.ofNullable((Long) input.readObject()); + } catch (OptionalDataException e) { + if (!e.eof) { + throw e; + } + return Optional.empty(); + } + } + + @SuppressWarnings("unchecked") + private static List readTrailingLongList(ObjectInputStream input) + throws IOException, ClassNotFoundException { + try { + List fragmentIds = (List) input.readObject(); + return fragmentIds == null ? Collections.emptyList() : List.copyOf(fragmentIds); + } catch (OptionalDataException e) { + if (!e.eof) { + throw e; + } + return Collections.emptyList(); + } + } + + private static Optional readTrailingString(ObjectInputStream input) + throws IOException, ClassNotFoundException { + try { + return Optional.ofNullable((String) input.readObject()); + } catch (OptionalDataException e) { + if (!e.eof) { + throw e; + } + return Optional.empty(); + } } /** Builder for CompactionOptions. */ @@ -139,6 +273,13 @@ public static class Builder { private Optional numThreads = Optional.empty(); private Optional batchSize = Optional.empty(); private Optional deferIndexRemap = Optional.empty(); + private Optional compactionMode = Optional.empty(); + private Optional binaryCopyReadBatchBytes = Optional.empty(); + private Optional maxSourceFragments = Optional.empty(); + private Optional maxSourceRows = Optional.empty(); + private Optional maxSourceBytes = Optional.empty(); + private List excludedFragmentIds = Collections.emptyList(); + private Optional dataStorageVersion = Optional.empty(); private Builder() {} @@ -182,6 +323,98 @@ public Builder withDeferIndexRemap(boolean deferIndexRemap) { return this; } + public Builder withCompactionMode(CompactionMode compactionMode) { + this.compactionMode = Optional.of(compactionMode); + return this; + } + + public Builder withBinaryCopyReadBatchBytes(long binaryCopyReadBatchBytes) { + this.binaryCopyReadBatchBytes = Optional.of(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. + * + * @throws IllegalArgumentException if {@code maxSourceFragments} is not positive + */ + public Builder withMaxSourceFragments(long maxSourceFragments) { + this.maxSourceFragments = + Optional.of(positiveBudget("maxSourceFragments", maxSourceFragments)); + return this; + } + + /** + * Maximum number of source rows to compact in a single run. Rows are counted as live rows + * (physical rows minus soft-deleted rows). Tasks are included until adding the next task would + * exceed this limit. + * + * @throws IllegalArgumentException if {@code maxSourceRows} is not positive + */ + public Builder withMaxSourceRows(long maxSourceRows) { + this.maxSourceRows = Optional.of(positiveBudget("maxSourceRows", maxSourceRows)); + return this; + } + + /** + * Maximum number of source bytes to compact in a single run, measured as the total size of the + * source fragments' data and overlay files. Tasks are included until adding the next task would + * exceed this limit. Blob v2 payloads live in separate blob files and are not counted, so this + * is not a cap on total compaction I/O for datasets with blob columns. + * + * @throws IllegalArgumentException if {@code maxSourceBytes} is not positive + */ + public Builder withMaxSourceBytes(long maxSourceBytes) { + this.maxSourceBytes = Optional.of(positiveBudget("maxSourceBytes", maxSourceBytes)); + return this; + } + + /** + * Select the output data file version without changing the dataset's default write version. + * Omission uses the compaction config target when set, otherwise the dataset default. The + * planner resolves release selectors before distributing tasks. V1/V2 cross-family targets are + * rejected by the engine. + */ + public Builder withDataStorageVersion(DataStorageVersion version) { + this.dataStorageVersion = Optional.of(Objects.requireNonNull(version, "version")); + return this; + } + + /** + * Fragment IDs to exclude from compaction planning. Excluded fragments remain unchanged and act + * as boundaries, so fragments on opposite sides are not combined into the same task. Duplicate + * and unknown IDs are ignored. + * + * @throws IllegalArgumentException if an ID is negative or exceeds the unsigned 32-bit range + */ + public Builder withExcludedFragmentIds(List excludedFragmentIds) { + Objects.requireNonNull(excludedFragmentIds, "excludedFragmentIds"); + for (Long fragmentId : excludedFragmentIds) { + if (fragmentId == null || fragmentId < 0 || fragmentId > 0xFFFF_FFFFL) { + throw new IllegalArgumentException( + "excludedFragmentIds must contain values between 0 and 4294967295, got " + + fragmentId); + } + } + this.excludedFragmentIds = List.copyOf(excludedFragmentIds); + return this; + } + + /** + * A max source budget of zero admits no work and a negative value would wrap around to an + * effectively unlimited budget on the Rust side, so both are rejected here. Leave the option + * unset for no limit. + */ + private static long positiveBudget(String name, long value) { + if (value <= 0) { + throw new IllegalArgumentException( + name + " must be greater than 0, got " + value + " (leave unset for no limit)"); + } + return value; + } + public CompactionOptions build() { return new CompactionOptions( targetRowsPerFragment, @@ -191,7 +424,14 @@ public CompactionOptions build() { materializeDeletionsThreshold, numThreads, batchSize, - deferIndexRemap); + deferIndexRemap, + compactionMode, + binaryCopyReadBatchBytes, + maxSourceFragments, + maxSourceRows, + maxSourceBytes, + excludedFragmentIds, + dataStorageVersion.map(DataStorageVersion::toRustString)); } } } diff --git a/java/src/main/java/org/lance/compaction/CompactionTask.java b/java/src/main/java/org/lance/compaction/CompactionTask.java index 2e288baa959..ab4f9c7c7a2 100644 --- a/java/src/main/java/org/lance/compaction/CompactionTask.java +++ b/java/src/main/java/org/lance/compaction/CompactionTask.java @@ -14,10 +14,12 @@ package org.lance.compaction; import org.lance.Dataset; +import org.lance.LockManager; import com.google.common.base.MoreObjects; import java.io.Serializable; +import java.util.List; import java.util.Optional; /** The compaction task which can be sent across network and executed individually. */ @@ -42,18 +44,27 @@ public String toString() { } public RewriteResult execute(Dataset dataset) { - return nativeExecute( - dataset, - taskData, - readVersion, - compactionOptions.getTargetRowsPerFragment(), - compactionOptions.getMaxRowsPerGroup(), - compactionOptions.getMaxBytesPerFile(), - compactionOptions.getMaterializeDeletions(), - compactionOptions.getMaterializeDeletionsThreshold(), - compactionOptions.getNumThreads(), - compactionOptions.getBatchSize(), - compactionOptions.getDeferIndexRemap()); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeExecute( + dataset, + taskData, + readVersion, + compactionOptions.getTargetRowsPerFragment(), + compactionOptions.getMaxRowsPerGroup(), + compactionOptions.getMaxBytesPerFile(), + compactionOptions.getMaterializeDeletions(), + compactionOptions.getMaterializeDeletionsThreshold(), + compactionOptions.getNumThreads(), + compactionOptions.getBatchSize(), + compactionOptions.getDeferIndexRemap(), + compactionOptions.getCompactionMode(), + compactionOptions.getBinaryCopyReadBatchBytes(), + compactionOptions.getMaxSourceFragments(), + compactionOptions.getMaxSourceRows(), + compactionOptions.getMaxSourceBytes(), + compactionOptions.getExcludedFragmentIds(), + compactionOptions.getDataStorageVersion()); + } } private native RewriteResult nativeExecute( @@ -67,7 +78,14 @@ private native RewriteResult nativeExecute( Optional materializeDeletionsThreshold, Optional numThreads, Optional batchSize, - Optional deferIndexRemap); + Optional deferIndexRemap, + Optional compactionMode, + Optional binaryCopyReadBatchBytes, + Optional maxSourceFragments, + Optional maxSourceRows, + Optional maxSourceBytes, + List excludedFragmentIds, + Optional dataStorageVersion); public CompactionOptions getCompactionOptions() { return compactionOptions; diff --git a/java/src/main/java/org/lance/compaction/RewriteResult.java b/java/src/main/java/org/lance/compaction/RewriteResult.java index adba77e89da..c4c5d816c2c 100644 --- a/java/src/main/java/org/lance/compaction/RewriteResult.java +++ b/java/src/main/java/org/lance/compaction/RewriteResult.java @@ -19,7 +19,6 @@ import java.io.Serializable; import java.util.List; -import java.util.Map; /** * Rewrite Result of a single compaction task. It will be passed across different workers and be @@ -31,25 +30,21 @@ public class RewriteResult implements Serializable { private final List originalFragments; private final long readVersion; - // null if index remap is deferred after compaction - @Nullable private final Map rowIdMap; - - // null if index remap is part of compaction - @Nullable private final byte[] changedRowAddrs; + // Serialized RoaringTreemap of row addresses read from the original fragments. + // null for stable row IDs. + @Nullable private final byte[] rowAddrs; public RewriteResult( CompactionMetrics metrics, List newFragments, List originalFragments, long readVersion, - Map rowIdMap, - byte[] changedRowAddrs) { + byte[] rowAddrs) { this.metrics = metrics; this.newFragments = newFragments; this.originalFragments = originalFragments; this.readVersion = readVersion; - this.rowIdMap = rowIdMap; - this.changedRowAddrs = changedRowAddrs; + this.rowAddrs = rowAddrs; } public long getReadVersion() { @@ -61,8 +56,8 @@ public CompactionMetrics getMetrics() { } @Nullable - public byte[] getChangedRowAddrs() { - return changedRowAddrs; + public byte[] getRowAddrs() { + return rowAddrs; } public List getNewFragments() { @@ -72,9 +67,4 @@ public List getNewFragments() { public List getOriginalFragments() { return originalFragments; } - - @Nullable - public Map getRowIdMap() { - return rowIdMap; - } } diff --git a/java/src/main/java/org/lance/delta/DatasetDelta.java b/java/src/main/java/org/lance/delta/DatasetDelta.java index 1c0eb4e9a73..c02e4e90e47 100755 --- a/java/src/main/java/org/lance/delta/DatasetDelta.java +++ b/java/src/main/java/org/lance/delta/DatasetDelta.java @@ -91,6 +91,24 @@ public ArrowReader getUpdatedRows() throws IOException { private native void nativeGetUpdatedRows(long streamAddress) throws IOException; + /** + * Return a streaming ArrowReader of the row ids deleted in the range. + * + *

The batches carry a single {@code _rowid} column. Requires stable row ids. + */ + public ArrowReader getDeletedRowIds() throws IOException { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeDeltaHandle != 0, "DatasetDelta is closed"); + BufferAllocator allocator = dataset.allocator(); + try (ArrowArrayStream s = ArrowArrayStream.allocateNew(allocator)) { + nativeGetDeletedRowIds(s.memoryAddress()); + return Data.importArrayStream(allocator, s); + } + } + } + + private native void nativeGetDeletedRowIds(long streamAddress) throws IOException; + @Override public void close() { try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { diff --git a/java/src/main/java/org/lance/delta/DatasetDeltaBuilder.java b/java/src/main/java/org/lance/delta/DatasetDeltaBuilder.java index 9084da2ab9c..9813b8aeba1 100755 --- a/java/src/main/java/org/lance/delta/DatasetDeltaBuilder.java +++ b/java/src/main/java/org/lance/delta/DatasetDeltaBuilder.java @@ -15,6 +15,7 @@ import org.lance.Dataset; import org.lance.JniLoader; +import org.lance.LockManager; import java.util.Optional; @@ -71,7 +72,9 @@ public DatasetDeltaBuilder withEndVersion(long version) { /** Build the DatasetDelta after validating builder state. */ public DatasetDelta build() { - return nativeBuild(dataset, comparedAgainst, beginVersion, endVersion); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeBuild(dataset, comparedAgainst, beginVersion, endVersion); + } } private static native DatasetDelta nativeBuild( diff --git a/java/src/main/java/org/lance/file/BlobReadMode.java b/java/src/main/java/org/lance/file/BlobReadMode.java new file mode 100644 index 00000000000..d7be0381fbf --- /dev/null +++ b/java/src/main/java/org/lance/file/BlobReadMode.java @@ -0,0 +1,41 @@ +/* + * 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.file; + +/** + * Controls how blob-encoded columns are returned when reading a Lance file. + * + *

Blob columns can be read in two modes: + * + *

    + *
  • {@link #CONTENT} — materializes the full binary content (default) + *
  • {@link #DESCRIPTOR} — returns a struct with {@code position} and {@code size} fields + *
+ */ +public enum BlobReadMode { + /** Return blob columns as materialized binary content (default). */ + CONTENT(0), + /** Return blob columns as descriptors (struct with position and size). */ + DESCRIPTOR(1); + + private final int value; + + BlobReadMode(int value) { + this.value = value; + } + + public int getValue() { + return value; + } +} diff --git a/java/src/main/java/org/lance/file/FileReadOptions.java b/java/src/main/java/org/lance/file/FileReadOptions.java new file mode 100644 index 00000000000..3d813c78eec --- /dev/null +++ b/java/src/main/java/org/lance/file/FileReadOptions.java @@ -0,0 +1,59 @@ +/* + * 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.file; + +/** + * Options for reading a Lance file. + * + *

Use {@link #builder()} to create an instance. New options can be added here in the future + * without breaking existing callers. + */ +public class FileReadOptions { + private final BlobReadMode blobReadMode; + + private FileReadOptions(Builder builder) { + this.blobReadMode = builder.blobReadMode; + } + + /** Returns the blob read mode. Defaults to {@link BlobReadMode#CONTENT}. */ + public BlobReadMode getBlobReadMode() { + return blobReadMode; + } + + /** Creates a new builder with default options. */ + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private BlobReadMode blobReadMode = BlobReadMode.CONTENT; + + private Builder() {} + + /** + * Sets how blob-encoded columns are returned. + * + * @param blobReadMode {@link BlobReadMode#CONTENT} to materialize binary content, or {@link + * BlobReadMode#DESCRIPTOR} to return position/size descriptors + */ + public Builder blobReadMode(BlobReadMode blobReadMode) { + this.blobReadMode = blobReadMode; + return this; + } + + public FileReadOptions build() { + return new FileReadOptions(this); + } + } +} diff --git a/java/src/main/java/org/lance/file/FileWriteOptions.java b/java/src/main/java/org/lance/file/FileWriteOptions.java new file mode 100644 index 00000000000..94d8e7eb608 --- /dev/null +++ b/java/src/main/java/org/lance/file/FileWriteOptions.java @@ -0,0 +1,80 @@ +/* + * 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.file; + +import java.util.Optional; + +/** + * Options for configuring a current-format Lance file writer. + * + *

These options are ignored for legacy V1 files. + */ +public class FileWriteOptions { + private final Optional dataCacheBytes; + private final Optional maxPageBytes; + + private FileWriteOptions(Builder builder) { + this.dataCacheBytes = builder.dataCacheBytes; + this.maxPageBytes = builder.maxPageBytes; + } + + /** + * Returns the total column-data buffering budget. + * + *

The budget is divided evenly across top-level columns. An empty value uses 8 MiB per column. + */ + public Optional getDataCacheBytes() { + return dataCacheBytes; + } + + /** Returns the best-effort maximum page size, or an empty value to use the writer default. */ + public Optional getMaxPageBytes() { + return maxPageBytes; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private Optional dataCacheBytes = Optional.empty(); + private Optional maxPageBytes = Optional.empty(); + + private Builder() {} + + /** + * Sets the total column-data buffering budget. + * + * @param dataCacheBytes buffer budget in bytes + */ + public Builder dataCacheBytes(long dataCacheBytes) { + this.dataCacheBytes = Optional.of(dataCacheBytes); + return this; + } + + /** + * Sets the best-effort maximum page size. + * + * @param maxPageBytes positive page size in bytes + */ + public Builder maxPageBytes(long maxPageBytes) { + this.maxPageBytes = Optional.of(maxPageBytes); + return this; + } + + public FileWriteOptions build() { + return new FileWriteOptions(this); + } + } +} diff --git a/java/src/main/java/org/lance/file/LanceFileReader.java b/java/src/main/java/org/lance/file/LanceFileReader.java index 9777e529f48..e3962eb539a 100644 --- a/java/src/main/java/org/lance/file/LanceFileReader.java +++ b/java/src/main/java/org/lance/file/LanceFileReader.java @@ -54,7 +54,8 @@ private native void readAllNative( int batchSize, @Nullable List projectedNames, @Nullable List ranges, - long streamMemoryAddress) + long streamMemoryAddress, + int blobReadMode) throws IOException; private LanceFileReader() {} @@ -124,18 +125,45 @@ private Schema load_schema() throws IOException { } /** - * Read all rows from the Lance file + * Read all rows from the Lance file. + * + *

Blob-encoded columns are returned as materialized binary content. Use {@link #readAll(List, + * List, int, FileReadOptions)} to control blob output format. * - * @param batchSize the maximum number of rows to read in a single batch * @param projectedNames optional list of column names to project; if null, all columns are read * @param ranges optional array of ranges to read; if null, all rows are read. + * @param batchSize the maximum number of rows to read in a single batch * @return an ArrowReader for the Lance file */ public ArrowReader readAll( @Nullable List projectedNames, @Nullable List ranges, int batchSize) throws IOException { + return readAll(projectedNames, ranges, batchSize, FileReadOptions.builder().build()); + } + + /** + * Read all rows from the Lance file with additional read options. + * + * @param projectedNames optional list of column names to project; if null, all columns are read + * @param ranges optional array of ranges to read; if null, all rows are read. + * @param batchSize the maximum number of rows to read in a single batch + * @param options file read options controlling output format (e.g. blob handling) + * @return an ArrowReader for the Lance file + * @see FileReadOptions + */ + public ArrowReader readAll( + @Nullable List projectedNames, + @Nullable List ranges, + int batchSize, + FileReadOptions options) + throws IOException { try (ArrowArrayStream ffiArrowArrayStream = ArrowArrayStream.allocateNew(allocator)) { - readAllNative(batchSize, projectedNames, ranges, ffiArrowArrayStream.memoryAddress()); + readAllNative( + batchSize, + projectedNames, + ranges, + ffiArrowArrayStream.memoryAddress(), + options.getBlobReadMode().getValue()); return Data.importArrayStream(allocator, ffiArrowArrayStream); } } diff --git a/java/src/main/java/org/lance/file/LanceFileWriter.java b/java/src/main/java/org/lance/file/LanceFileWriter.java index 0143daee1ff..7b03fe2efc0 100644 --- a/java/src/main/java/org/lance/file/LanceFileWriter.java +++ b/java/src/main/java/org/lance/file/LanceFileWriter.java @@ -14,12 +14,12 @@ package org.lance.file; import org.lance.JniLoader; -import org.lance.WriteParams; import org.apache.arrow.c.ArrowArray; 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.VectorSchemaRoot; import org.apache.arrow.vector.dictionary.DictionaryProvider; @@ -39,7 +39,10 @@ public class LanceFileWriter implements AutoCloseable { private DictionaryProvider dictionaryProvider; private static native LanceFileWriter openNative( - String fileUri, Optional dataStorageVersion, Map storageOptions) + String fileUri, + Optional dataStorageVersion, + Map storageOptions, + FileWriteOptions writeOptions) throws IOException; private native void closeNative(long nativeLanceFileReaderHandle) throws IOException; @@ -87,19 +90,47 @@ public static LanceFileWriter open( * @param path the URI of the file to write to * @param allocator the BufferAllocator to use for the writer * @param dictionaryProvider the DictionaryProvider to use for the writer - * @param dataStorageVersion the version of the data storage format to use + * @param dataStorageVersion the version of the data storage format to use (e.g., "legacy", + * "stable", "2.0") * @return a new LanceFileWriter */ public static LanceFileWriter open( String path, BufferAllocator allocator, DictionaryProvider dictionaryProvider, - Optional dataStorageVersion, + Optional dataStorageVersion, Map storageOptions) throws IOException { - Optional dataStorageVersionStr = - dataStorageVersion.map(WriteParams.LanceFileVersion::getVersionString); - LanceFileWriter writer = openNative(path, dataStorageVersionStr, storageOptions); + return open( + path, + allocator, + dictionaryProvider, + dataStorageVersion, + storageOptions, + FileWriteOptions.builder().build()); + } + + /** + * Open a LanceFileWriter with explicit file write options. + * + * @param path the URI of the file to write to + * @param allocator the BufferAllocator to use for the writer + * @param dictionaryProvider the DictionaryProvider to use for the writer + * @param dataStorageVersion the version of the data storage format to use + * @param storageOptions additional storage options for the writer + * @param writeOptions options for configuring the current-format file writer + * @return a new LanceFileWriter + */ + public static LanceFileWriter open( + String path, + BufferAllocator allocator, + DictionaryProvider dictionaryProvider, + Optional dataStorageVersion, + Map storageOptions, + FileWriteOptions writeOptions) + throws IOException { + Preconditions.checkNotNull(writeOptions, "writeOptions must not be null"); + LanceFileWriter writer = openNative(path, dataStorageVersion, storageOptions, writeOptions); writer.allocator = allocator; writer.dictionaryProvider = dictionaryProvider; return writer; diff --git a/java/src/main/java/org/lance/fragment/DataFile.java b/java/src/main/java/org/lance/fragment/DataFile.java index 1120a5286c1..9755c271d67 100644 --- a/java/src/main/java/org/lance/fragment/DataFile.java +++ b/java/src/main/java/org/lance/fragment/DataFile.java @@ -85,7 +85,16 @@ public boolean equals(Object o) { && Objects.equals(path, that.path) && Arrays.equals(fields, that.fields) && Arrays.equals(columnIndices, that.columnIndices) - && Objects.equals(fileSizeBytes, that.fileSizeBytes); + && Objects.equals(fileSizeBytes, that.fileSizeBytes) + && Objects.equals(baseId, that.baseId); + } + + @Override + public int hashCode() { + int result = Objects.hash(path, fileMajorVersion, fileMinorVersion, fileSizeBytes, baseId); + result = 31 * result + Arrays.hashCode(fields); + result = 31 * result + Arrays.hashCode(columnIndices); + return result; } @Override diff --git a/java/src/main/java/org/lance/fragment/DeletionFile.java b/java/src/main/java/org/lance/fragment/DeletionFile.java index 18e17140389..2eed2fafb0f 100644 --- a/java/src/main/java/org/lance/fragment/DeletionFile.java +++ b/java/src/main/java/org/lance/fragment/DeletionFile.java @@ -57,6 +57,36 @@ public Optional getBaseId() { return Optional.ofNullable(baseId); } + /** + * Returns the deletion file path relative to its dataset-root base. + * + * @param fragmentId fragment that owns this deletion file + */ + public String getRelativePath(long fragmentId) { + if (fragmentId < 0) { + throw new IllegalArgumentException("fragmentId must be non-negative"); + } + String suffix; + switch (fileType) { + case ARRAY: + suffix = "arrow"; + break; + case BITMAP: + suffix = "bin"; + break; + default: + throw new IllegalStateException("Unsupported deletion file type: " + fileType); + } + return "_deletions/" + + fragmentId + + "-" + + readVersion + + "-" + + Long.toUnsignedString(id) + + "." + + suffix; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -65,7 +95,13 @@ public boolean equals(Object o) { return id == that.id && readVersion == that.readVersion && fileType == that.fileType - && Objects.equals(numDeletedRows, that.numDeletedRows); + && Objects.equals(numDeletedRows, that.numDeletedRows) + && Objects.equals(baseId, that.baseId); + } + + @Override + public int hashCode() { + return Objects.hash(id, readVersion, numDeletedRows, fileType, baseId); } @Override diff --git a/java/src/main/java/org/lance/fragment/FragmentUpdateResult.java b/java/src/main/java/org/lance/fragment/FragmentUpdateResult.java index 47718119371..34dbbe3908c 100644 --- a/java/src/main/java/org/lance/fragment/FragmentUpdateResult.java +++ b/java/src/main/java/org/lance/fragment/FragmentUpdateResult.java @@ -14,6 +14,7 @@ package org.lance.fragment; import org.lance.FragmentMetadata; +import org.lance.JniLoader; import com.google.common.base.MoreObjects; import org.apache.arrow.c.ArrowArrayStream; @@ -23,12 +24,51 @@ * Fragment.updateColumns()}. */ public class FragmentUpdateResult { + static { + JniLoader.ensureLoaded(); + } + private final FragmentMetadata updatedFragment; private final long[] fieldsModified; + /** + * Matched physical row offsets within the fragment, serialized as portable RoaringBitmap bytes + * (little-endian, same format as {@link org.lance.operation.Update#updatedFragmentOffsets()}). + */ + private final byte[] updatedRowOffsetBytes; + + /** Primary public API for constructing a result with portable RoaringBitmap offset bytes. */ + public static FragmentUpdateResult create( + FragmentMetadata updatedFragment, long[] updatedFieldIds, byte[] updatedRowOffsetBytes) { + return new FragmentUpdateResult(updatedFragment, updatedFieldIds, updatedRowOffsetBytes); + } + + /** Two-argument form for callers that do not track per-row offsets; offsets default to empty. */ public FragmentUpdateResult(FragmentMetadata updatedFragment, long[] updatedFieldIds) { + this(updatedFragment, updatedFieldIds, new byte[0]); + } + + private FragmentUpdateResult( + FragmentMetadata updatedFragment, long[] updatedFieldIds, byte[] updatedRowOffsetBytes) { this.updatedFragment = updatedFragment; this.fieldsModified = updatedFieldIds; + this.updatedRowOffsetBytes = + updatedRowOffsetBytes != null ? updatedRowOffsetBytes : new byte[0]; + } + + /** + * @deprecated Use {@link #create(FragmentMetadata, long[], byte[])} instead. This constructor + * encodes the expanded {@code long[]} offsets into portable RoaringBitmap bytes via JNI and + * is retained for backward compatibility with callers compiled against the prior long[]-based + * API. + */ + @Deprecated + public FragmentUpdateResult( + FragmentMetadata updatedFragment, long[] updatedFieldIds, long[] updatedRowOffsets) { + this( + updatedFragment, + updatedFieldIds, + encodeRowOffsetsToBytes(updatedRowOffsets != null ? updatedRowOffsets : new long[0])); } public FragmentMetadata getUpdatedFragment() { @@ -39,11 +79,34 @@ public long[] getFieldsModified() { return fieldsModified; } + /** + * Physical row offsets (0-based within the fragment) whose columns were rewritten, as portable + * RoaringBitmap bytes. + */ + public byte[] getUpdatedRowOffsetBytes() { + return updatedRowOffsetBytes; + } + + /** + * Physical row offsets (0-based within the fragment) whose columns were rewritten. + * + * @deprecated Use {@link #getUpdatedRowOffsetBytes()} instead. + */ + @Deprecated + public long[] getUpdatedRowOffsets() { + return expandRowOffsetsFromBytes(updatedRowOffsetBytes); + } + + private static native byte[] encodeRowOffsetsToBytes(long[] rowOffsets); + + private static native long[] expandRowOffsetsFromBytes(byte[] rowOffsetBytes); + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("fragmentMetadata", updatedFragment) .add("updatedFieldIds", fieldsModified) + .add("updatedRowOffsetBytesLength", updatedRowOffsetBytes.length) .toString(); } } diff --git a/java/src/main/java/org/lance/fragment/RowIdMeta.java b/java/src/main/java/org/lance/fragment/RowIdMeta.java index f9c2fa8b4ac..49e41a3192b 100644 --- a/java/src/main/java/org/lance/fragment/RowIdMeta.java +++ b/java/src/main/java/org/lance/fragment/RowIdMeta.java @@ -13,6 +13,8 @@ */ package org.lance.fragment; +import org.lance.JniLoader; + import com.google.common.base.MoreObjects; import java.io.Serializable; @@ -21,12 +23,30 @@ public class RowIdMeta implements Serializable { private static final long serialVersionUID = -6532828695072614148L; + static { + JniLoader.ensureLoaded(); + } + private final String metadata; public RowIdMeta(String metadata) { this.metadata = metadata; } + /** + * Creates a RowIdMeta from an array of stable row IDs by delegating to the Rust {@code + * write_row_ids} encoder via JNI. The returned metadata is a JSON string wrapping the + * protobuf-encoded RowIdSequence, matching the format expected by lance-core. + * + * @param rowIds stable row IDs to encode + * @return RowIdMeta containing the serialized inline representation + */ + public static RowIdMeta fromRowIds(long[] rowIds) { + return new RowIdMeta(nativeEncodeRowIds(rowIds)); + } + + private static native String nativeEncodeRowIds(long[] rowIds); + public String getMetadata() { return metadata; } @@ -43,6 +63,11 @@ public boolean equals(Object obj) { return Objects.equals(metadata, that.metadata); } + @Override + public int hashCode() { + return Objects.hash(metadata); + } + @Override public String toString() { return MoreObjects.toStringHelper(this).add("metadata", metadata).toString(); diff --git a/java/src/main/java/org/lance/fragment/VersionMeta.java b/java/src/main/java/org/lance/fragment/VersionMeta.java new file mode 100644 index 00000000000..53f5a5468e3 --- /dev/null +++ b/java/src/main/java/org/lance/fragment/VersionMeta.java @@ -0,0 +1,62 @@ +/* + * 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.fragment; + +import com.google.common.base.MoreObjects; + +import java.io.Serializable; +import java.util.Objects; + +/** + * Metadata for per-row dataset version sequences (created_at / last_updated_at). Wraps the + * JSON-serialized Rust RowDatasetVersionMeta enum. + * + *

Structurally identical to {@link RowIdMeta} — kept separate because the two map to distinct + * Rust types with different serialization formats and evolution paths. + */ +public class VersionMeta implements Serializable { + private static final long serialVersionUID = 1L; + + private final String metadata; + + public VersionMeta(String metadata) { + this.metadata = metadata; + } + + public String getMetadata() { + return metadata; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (obj == null || getClass() != obj.getClass()) { + return false; + } + VersionMeta that = (VersionMeta) obj; + return Objects.equals(metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(metadata); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("metadata", metadata).toString(); + } +} diff --git a/java/src/main/java/org/lance/index/Index.java b/java/src/main/java/org/lance/index/Index.java index 955835496ed..795f176a96d 100644 --- a/java/src/main/java/org/lance/index/Index.java +++ b/java/src/main/java/org/lance/index/Index.java @@ -17,6 +17,7 @@ import java.time.Instant; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Objects; import java.util.Optional; @@ -29,6 +30,7 @@ public class Index { private final UUID uuid; private final List fields; + private final List coveringFields; private final String name; private final long datasetVersion; private final List fragments; @@ -36,11 +38,14 @@ public class Index { private final int indexVersion; private final Instant createdAt; private final Integer baseId; + private final Long sizeBytes; + private final List files; private final IndexType indexType; private Index( UUID uuid, List fields, + List coveringFields, String name, long datasetVersion, List fragments, @@ -48,9 +53,12 @@ private Index( int indexVersion, Instant createdAt, Integer baseId, + Long sizeBytes, + List files, IndexType indexType) { this.uuid = uuid; this.fields = fields; + this.coveringFields = coveringFields; this.name = name; this.datasetVersion = datasetVersion; this.fragments = fragments; @@ -58,6 +66,22 @@ private Index( this.indexVersion = indexVersion; this.createdAt = createdAt; this.baseId = baseId; + boolean hasFiles = files != null && !files.isEmpty(); + if (hasFiles && sizeBytes == null) { + long derivedSizeBytes = 0; + try { + for (IndexFile file : files) { + derivedSizeBytes = Math.addExact(derivedSizeBytes, file.getSizeBytes()); + } + } catch (ArithmeticException e) { + throw new IllegalArgumentException( + "index file sizes cannot be represented by Java long", e); + } + this.sizeBytes = derivedSizeBytes; + } else { + this.sizeBytes = !hasFiles && Long.valueOf(0).equals(sizeBytes) ? null : sizeBytes; + } + this.files = hasFiles ? files : null; this.indexType = indexType; } @@ -74,6 +98,20 @@ public List fields() { return fields; } + /** + * Fields whose values this index carries but is not keyed on. Always a suffix of {@link + * #fields()}, and never all of it, so the first entry of {@code fields()} is always a column the + * index is keyed on. Empty for an index that carries no extra columns. + * + *

These ids also appear in {@link #fields()} — that is deliberate, so that every consumer + * reading {@code fields()} as the index's dependency set also covers them with no change. + * + * @return the covering field IDs + */ + public List coveringFields() { + return coveringFields; + } + /** * Human readable index name * @@ -104,6 +142,22 @@ public Optional baseId() { return Optional.ofNullable(baseId); } + /** + * Get the total size of all files in this physical index segment. + * + *

The size is unavailable for indices created before index file sizes were tracked. + * + * @return the segment size in bytes, or empty if unavailable + */ + public Optional getSizeBytes() { + return Optional.ofNullable(sizeBytes); + } + + /** Files and sizes for this index segment, or empty when older writers did not record them. */ + public Optional> getFiles() { + return Optional.ofNullable(files); + } + /** * Get the index version. * @@ -140,11 +194,14 @@ public boolean equals(Object o) { && indexVersion == index.indexVersion && Objects.equals(uuid, index.uuid) && Objects.equals(fields, index.fields) + && Objects.equals(coveringFields, index.coveringFields) && Objects.equals(name, index.name) && Objects.equals(fragments, index.fragments) && Arrays.equals(indexDetails, index.indexDetails) && Objects.equals(createdAt, index.createdAt) && Objects.equals(baseId, index.baseId) + && Objects.equals(sizeBytes, index.sizeBytes) + && Objects.equals(files, index.files) && indexType == index.indexType; } @@ -154,11 +211,14 @@ public int hashCode() { Objects.hash( uuid, fields, + coveringFields, name, datasetVersion, indexVersion, createdAt, baseId, + sizeBytes, + files, fragments, indexType); result = 31 * result + Arrays.hashCode(indexDetails); @@ -170,12 +230,15 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("uuid", uuid) .add("fields", fields) + .add("coveringFields", coveringFields) .add("name", name) .add("datasetVersion", datasetVersion) .add("indexVersion", indexVersion) .add("indexType", indexType) .add("createdAt", createdAt) .add("baseId", baseId) + .add("sizeBytes", sizeBytes) + .add("files", files) .toString(); } @@ -192,6 +255,7 @@ public static class Builder { private UUID uuid; private List fields; + private List coveringFields = Collections.emptyList(); private String name; private long datasetVersion; private List fragments; @@ -199,6 +263,8 @@ public static class Builder { private int indexVersion; private Instant createdAt; private Integer baseId; + private Long sizeBytes; + private List files; private IndexType indexType; private Builder() {} @@ -213,6 +279,11 @@ public Builder fields(List fields) { return this; } + public Builder coveringFields(List coveringFields) { + this.coveringFields = coveringFields; + return this; + } + public Builder name(String name) { this.name = name; return this; @@ -248,6 +319,16 @@ public Builder baseId(Integer baseId) { return this; } + public Builder sizeBytes(Long sizeBytes) { + this.sizeBytes = sizeBytes; + return this; + } + + public Builder files(List files) { + this.files = files; + return this; + } + public Builder indexType(IndexType indexType) { this.indexType = indexType; return this; @@ -257,6 +338,7 @@ public Index build() { return new Index( uuid, fields, + coveringFields, name, datasetVersion, fragments, @@ -264,6 +346,8 @@ public Index build() { indexVersion, createdAt, baseId, + sizeBytes, + files, indexType); } } diff --git a/java/src/main/java/org/lance/index/IndexBuildProgress.java b/java/src/main/java/org/lance/index/IndexBuildProgress.java new file mode 100644 index 00000000000..822b0fefb4d --- /dev/null +++ b/java/src/main/java/org/lance/index/IndexBuildProgress.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.index; + +import java.util.Optional; + +/** Receives stage-level progress while Lance builds or finalizes an index. */ +public interface IndexBuildProgress { + + /** + * Reports that a stage has started. + * + *

Implementations must be thread-safe. Lance may invoke callbacks concurrently from native + * runtime threads. Callbacks may re-enter read-only methods on the same {@code Dataset} through + * JNI. Conflicting write re-entry from a callback is rejected; unrelated concurrent callers keep + * their normal wait behavior. Concurrent create/merge index builds on the same Dataset handle are + * serialized. An exception thrown by this method terminates the index operation. + * + * @param stage stable, index-type-specific stage name + * @param total number of work units, or empty when the total is unknown + * @param unit description of the work unit, such as {@code files} or {@code partitions} + */ + void stageStart(String stage, Optional total, String unit); + + /** + * Reports completed work within a stage. + * + *

An exception thrown by this method terminates the index operation. + * + * @param stage stage name previously reported to {@link #stageStart} + * @param completed number of completed work units + */ + void stageProgress(String stage, long completed); + + /** + * Reports that a stage has completed. + * + *

The stage work has already succeeded when this method is called. Lance therefore logs and + * ignores exceptions thrown by this callback instead of failing the completed operation. + * + * @param stage completed stage name + */ + void stageComplete(String stage); +} diff --git a/java/src/main/java/org/lance/index/IndexDescription.java b/java/src/main/java/org/lance/index/IndexDescription.java index d17782eb531..b1e2689a00c 100755 --- a/java/src/main/java/org/lance/index/IndexDescription.java +++ b/java/src/main/java/org/lance/index/IndexDescription.java @@ -15,6 +15,7 @@ import java.util.List; import java.util.Objects; +import java.util.Optional; /** * High-level description of an index, aggregating metadata across all segments. @@ -31,6 +32,7 @@ public final class IndexDescription { private final long rowsIndexed; private final List metadata; private final String detailsJson; + private final Long totalSizeBytes; public IndexDescription( String name, @@ -40,6 +42,18 @@ public IndexDescription( long rowsIndexed, List metadata, String detailsJson) { + this(name, fieldIds, typeUrl, indexType, rowsIndexed, metadata, detailsJson, null); + } + + public IndexDescription( + String name, + List fieldIds, + String typeUrl, + String indexType, + long rowsIndexed, + List metadata, + String detailsJson, + Long totalSizeBytes) { this.name = Objects.requireNonNull(name, "name must not be null"); this.fieldIds = Objects.requireNonNull(fieldIds, "fieldIds must not be null"); this.typeUrl = Objects.requireNonNull(typeUrl, "typeUrl must not be null"); @@ -47,6 +61,7 @@ public IndexDescription( this.rowsIndexed = rowsIndexed; this.metadata = Objects.requireNonNull(metadata, "metadata must not be null"); this.detailsJson = detailsJson; + this.totalSizeBytes = totalSizeBytes; } /** The logical name of the index. */ @@ -83,6 +98,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. * @@ -91,4 +115,15 @@ public List getMetadata() { public String getDetailsJson() { return detailsJson; } + + /** + * Total size of all files across all physical index segments. + * + *

The size is unavailable if any segment predates index file size tracking. + * + * @return the logical index size in bytes, or empty if unavailable + */ + public Optional getTotalSizeBytes() { + return Optional.ofNullable(totalSizeBytes); + } } diff --git a/java/src/main/java/org/lance/index/IndexFile.java b/java/src/main/java/org/lance/index/IndexFile.java new file mode 100644 index 00000000000..bb6fdc9cd3f --- /dev/null +++ b/java/src/main/java/org/lance/index/IndexFile.java @@ -0,0 +1,61 @@ +/* + * 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 com.google.common.base.MoreObjects; + +import java.util.Objects; + +/** Metadata for a file within an index segment. */ +public final class IndexFile { + private final String path; + private final long sizeBytes; + + public IndexFile(String path, long sizeBytes) { + this.path = Objects.requireNonNull(path, "path must not be null"); + if (sizeBytes < 0) { + throw new IllegalArgumentException("sizeBytes must be non-negative"); + } + this.sizeBytes = sizeBytes; + } + + public String getPath() { + return path; + } + + public long getSizeBytes() { + return sizeBytes; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + IndexFile indexFile = (IndexFile) o; + return sizeBytes == indexFile.sizeBytes && Objects.equals(path, indexFile.path); + } + + @Override + public int hashCode() { + return Objects.hash(path, sizeBytes); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("path", path) + .add("sizeBytes", sizeBytes) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/index/IndexType.java b/java/src/main/java/org/lance/index/IndexType.java index 5ea900b82d0..1fff86fc7e0 100644 --- a/java/src/main/java/org/lance/index/IndexType.java +++ b/java/src/main/java/org/lance/index/IndexType.java @@ -24,13 +24,15 @@ public enum IndexType { MEM_WAL(7), ZONEMAP(8), BLOOM_FILTER(9), + RTREE(10), VECTOR(100), IVF_FLAT(101), IVF_SQ(102), IVF_PQ(103), IVF_HNSW_SQ(104), IVF_HNSW_PQ(105), - IVF_HNSW_FLAT(106); + IVF_HNSW_FLAT(106), + IVF_RQ(107); private final int value; diff --git a/java/src/main/java/org/lance/index/scalar/BTreeIndexParams.java b/java/src/main/java/org/lance/index/scalar/BTreeIndexParams.java index d72d5936d97..a7be46d2a85 100755 --- a/java/src/main/java/org/lance/index/scalar/BTreeIndexParams.java +++ b/java/src/main/java/org/lance/index/scalar/BTreeIndexParams.java @@ -59,7 +59,14 @@ public Builder zoneSize(long zoneSize) { * @param rangeId non-negative range identifier * @return this builder * @throws IllegalArgumentException + * @deprecated {@code rangeId} is no longer needed and is now ignored at build time (a warning + * is logged if set). A pre-sorted data stream is still supported — just pass the reader + * without a {@code rangeId}. Each build produces one canonical segment; for distributed + * builds, build one segment per worker and commit them with {@code + * commitExistingIndexSegments(...)}, optionally consolidating with {@code + * mergeExistingIndexSegments(...)}. This parameter will be removed in a future release. */ + @Deprecated public Builder rangeId(int rangeId) { if (rangeId < 0) { throw new IllegalArgumentException("rangeId must be non-negative"); diff --git a/java/src/main/java/org/lance/index/scalar/BitmapIndexParams.java b/java/src/main/java/org/lance/index/scalar/BitmapIndexParams.java index b5e18be507a..67a0f2afc7e 100644 --- a/java/src/main/java/org/lance/index/scalar/BitmapIndexParams.java +++ b/java/src/main/java/org/lance/index/scalar/BitmapIndexParams.java @@ -13,6 +13,11 @@ */ package org.lance.index.scalar; +import org.lance.util.JsonUtils; + +import java.util.HashMap; +import java.util.Map; + /** Builder-style configuration for Bitmap scalar index parameters. */ public final class BitmapIndexParams { private static final String INDEX_TYPE = "bitmap"; @@ -25,9 +30,36 @@ public static Builder builder() { } public static final class Builder { + private Integer shardId; + + /** + * Configure an explicit shard ID for distributed bitmap builds spanning multiple fragments. + * + * @param shardId non-negative shard identifier + * @return this builder + * @throws IllegalArgumentException + */ + public Builder shardId(int shardId) { + if (shardId < 0) { + throw new IllegalArgumentException("shardId must be non-negative"); + } + this.shardId = shardId; + return this; + } + /** Build a {@link ScalarIndexParams} instance for a Bitmap index. */ public ScalarIndexParams build() { - return ScalarIndexParams.create(INDEX_TYPE); + Map params = new HashMap<>(); + if (shardId != null) { + params.put("shard_id", shardId); + } + + if (params.isEmpty()) { + return ScalarIndexParams.create(INDEX_TYPE); + } + + String json = JsonUtils.toJson(params); + return ScalarIndexParams.create(INDEX_TYPE, json); } } } diff --git a/java/src/main/java/org/lance/index/scalar/InvertedIndexParams.java b/java/src/main/java/org/lance/index/scalar/InvertedIndexParams.java index ca0a7a46c70..6597fdc0841 100755 --- a/java/src/main/java/org/lance/index/scalar/InvertedIndexParams.java +++ b/java/src/main/java/org/lance/index/scalar/InvertedIndexParams.java @@ -13,6 +13,7 @@ */ package org.lance.index.scalar; +import org.lance.DocumentGranularity; import org.lance.util.JsonUtils; import com.google.common.base.Preconditions; @@ -41,10 +42,12 @@ public static Builder builder() { /** Builder for inverted scalar index parameters. */ public static final class Builder { + private String analyzer; + private String lanceTokenizer; private String baseTokenizer; private String language; private Boolean withPosition; - private Integer maxTokenLength; + private Integer maxTokenLength = 40; private Boolean lowerCase; private Boolean stem; private Boolean removeStopWords; @@ -53,7 +56,52 @@ public static final class Builder { private Integer minNgramLength; private Integer maxNgramLength; private Boolean prefixOnly; - private Boolean skipMerge; + private Integer blockSize = 128; + private Boolean splitIdentifiers; + private Boolean splitOnNumerics; + private Boolean preserveOriginal; + private Boolean indexOperators; + private Long memoryLimit; + private Integer numWorkers; + private Integer formatVersion; + private DocumentGranularity documentGranularity = DocumentGranularity.ROW; + + /** + * Configure the analyzer preset. + * + *

Supported values are {@code "text"} and {@code "code"}. The code analyzer selects the code + * tokenizer defaults and requires FTS format v3. If unset, the analyzer is inferred from {@link + * #baseTokenizer(String)}. + * + * @param analyzer analyzer preset + * @return this builder + */ + public Builder analyzer(String analyzer) { + Objects.requireNonNull(analyzer, "analyzer must not be null"); + if (analyzer.isEmpty()) { + throw new IllegalArgumentException("analyzer must not be empty"); + } + this.analyzer = analyzer; + return this; + } + + /** + * Configure the document-level tokenizer used before lexical tokenization. + * + *

Supported values are {@code "text"} for plain strings and {@code "json"} for JSON strings. + * If unset, Lance infers the document tokenizer from the Arrow field type. + * + * @param lanceTokenizer document-level tokenizer + * @return this builder + */ + public Builder lanceTokenizer(String lanceTokenizer) { + Objects.requireNonNull(lanceTokenizer, "lanceTokenizer must not be null"); + if (lanceTokenizer.isEmpty()) { + throw new IllegalArgumentException("lanceTokenizer must not be empty"); + } + this.lanceTokenizer = lanceTokenizer; + return this; + } /** * Configure the base tokenizer. @@ -65,10 +113,18 @@ public static final class Builder { *

  • {@code "whitespace"}: splits tokens on whitespace *
  • {@code "raw"}: no tokenization *
  • {@code "ngram"}: N-Gram tokenizer + *
  • {@code "code"}: code-aware tokenizer + *
  • {@code "icu"}: ICU dictionary-based Unicode word segmentation + *
  • {@code "icu/split"}: ICU segmentation with simple-style delimiter splitting *
  • {@code "lindera/*"}: Lindera tokenizer *
  • {@code "jieba/*"}: Jieba tokenizer * * + *

    Lindera and Jieba tokenizers load their language models from the directory configured by + * {@code LANCE_LANGUAGE_MODEL_HOME}, or from Lance's platform-specific default language model + * directory. The tokenizer suffix selects a model directory, for example {@code jieba/default}. + * The {@code code} tokenizer requires FTS format v3. + * * @param baseTokenizer tokenizer identifier string * @return this builder * @throws IllegalArgumentException @@ -112,12 +168,16 @@ public Builder withPosition(boolean withPosition) { /** * Configure the maximum token length. * - * @param maxTokenLength maximum token length, must be positive + *

    The default is {@code 40}. Set this to {@code null} to disable the maximum token length + * filter. + * + * @param maxTokenLength maximum token length, or {@code null} for no limit; non-null values + * must be positive * @return this builder - * @throws IllegalArgumentException + * @throws IllegalArgumentException if {@code maxTokenLength} is not null and is not positive */ public Builder maxTokenLength(Integer maxTokenLength) { - if (maxTokenLength == null || maxTokenLength <= 0) { + if (maxTokenLength != null && maxTokenLength <= 0) { throw new IllegalArgumentException("maxTokenLength must be positive when specified"); } this.maxTokenLength = maxTokenLength; @@ -224,21 +284,181 @@ public Builder prefixOnly(boolean prefixOnly) { } /** - * Configure whether to skip the partition merge stage after indexing. If true, skip the - * partition merge stage after indexing. This can be useful for distributed indexing where merge - * is handled separately. + * Configure the number of documents in each compressed posting block. + * + *

    Supported values are {@code 128} and {@code 256}. New indexes default to {@code 128} when + * this is not set. + * + *

    {@code blockSize = 256} requires FTS format v3. Format v3 also supports the default {@code + * blockSize = 128}. + * + * @param blockSize posting block size + * @return this builder + * @throws IllegalArgumentException if {@code blockSize} is unsupported + */ + public Builder blockSize(int blockSize) { + if (blockSize != 128 && blockSize != 256) { + throw new IllegalArgumentException("blockSize must be one of 128 or 256"); + } + this.blockSize = blockSize; + return this; + } + + /** + * Configure whether code identifiers are split into subwords. + * + *

    This option is valid only with the {@code code} analyzer. + * + * @param splitIdentifiers whether to split identifiers + * @return this builder + */ + public Builder splitIdentifiers(boolean splitIdentifiers) { + this.splitIdentifiers = splitIdentifiers; + return this; + } + + /** + * Configure whether code identifier subwords are split at letter-number boundaries. + * + *

    This option is valid only with the {@code code} analyzer. + * + * @param splitOnNumerics whether to split at numeric boundaries + * @return this builder + */ + public Builder splitOnNumerics(boolean splitOnNumerics) { + this.splitOnNumerics = splitOnNumerics; + return this; + } + + /** + * Configure whether complete code identifiers are indexed alongside their subwords. + * + *

    This option is valid only with the {@code code} analyzer. + * + * @param preserveOriginal whether to preserve complete identifiers + * @return this builder + */ + public Builder preserveOriginal(boolean preserveOriginal) { + this.preserveOriginal = preserveOriginal; + return this; + } + + /** + * Configure whether code operators such as {@code ::}, {@code ->}, and {@code !=} are indexed. + * + *

    This option is valid only with the {@code code} analyzer. + * + * @param indexOperators whether to index operators + * @return this builder + */ + public Builder indexOperators(boolean indexOperators) { + this.indexOperators = indexOperators; + return this; + } + + /** + * Configure the total memory limit in MiB for the build stage. + * + *

    The limit is split evenly across FTS workers and is not persisted with the index. If + * unset, each worker uses a 2 GiB build-time limit. + * + *

    A value of {@code 0} is passed through to Rust. + * + * @param memoryLimit total memory limit in MiB, must be non-negative + * @return this builder + * @throws IllegalArgumentException if {@code memoryLimit} is negative + */ + public Builder memoryLimit(long memoryLimit) { + if (memoryLimit < 0) { + throw new IllegalArgumentException("memoryLimit must be non-negative"); + } + this.memoryLimit = memoryLimit; + return this; + } + + /** + * Configure the number of workers used for the build stage. + * + *

    The effective value is capped at the available compute-intensive CPU count and is not + * persisted with the index. Rust clamps a value of {@code 0} to one worker. + * + * @param numWorkers requested worker count, must be non-negative + * @return this builder + * @throws IllegalArgumentException if {@code numWorkers} is negative + */ + public Builder numWorkers(int numWorkers) { + if (numWorkers < 0) { + throw new IllegalArgumentException("numWorkers must be non-negative"); + } + this.numWorkers = numWorkers; + return this; + } + + /** + * This option has no effect because the Rust inverted-index builder does not support skipping + * the partition merge stage. * * @param skipMerge whether to skip partition merge * @return this builder + * @deprecated this option has no effect and will be removed in a future release */ + @Deprecated public Builder skipMerge(boolean skipMerge) { - this.skipMerge = skipMerge; + return this; + } + + /** + * Configure the on-disk FTS format version to write when creating a new index. + * + *

    If unset, Lance uses {@code LANCE_FTS_FORMAT_VERSION} when present and otherwise selects + * v3 for the code analyzer, {@code baseTokenizer = "code"}, or {@code blockSize = 256}, and v2 + * for other indexes. Format v3 supports both posting block sizes. Formats v1 and v2 support + * only {@code blockSize = 128} and cannot be used with the code analyzer or code base + * tokenizer. + * + * @param formatVersion FTS format version, must be 1, 2, or 3 + * @return this builder + * @throws IllegalArgumentException + */ + public Builder formatVersion(int formatVersion) { + if (formatVersion != 1 && formatVersion != 2 && formatVersion != 3) { + throw new IllegalArgumentException("formatVersion must be 1, 2, or 3"); + } + this.formatVersion = formatVersion; + return this; + } + + /** + * Configure the unit treated as one FTS document. + * + *

    {@link DocumentGranularity#LIST_ELEMENT} uses each element of the deepest list on the + * indexed field path as one document. The default is {@link DocumentGranularity#ROW}. + * + * @param documentGranularity document boundary semantics + * @return this builder + */ + public Builder documentGranularity(DocumentGranularity documentGranularity) { + this.documentGranularity = + Objects.requireNonNull(documentGranularity, "documentGranularity must not be null"); return this; } /** Build a {@link ScalarIndexParams} instance for an inverted index. */ public ScalarIndexParams build() { + if (formatVersion != null) { + Preconditions.checkArgument( + formatVersion == 3 || blockSize == 128, "formatVersion 1 and 2 require blockSize 128"); + Preconditions.checkArgument( + (!"code".equals(analyzer) && !"code".equals(baseTokenizer)) || formatVersion == 3, + "code analyzer and baseTokenizer 'code' require formatVersion 3"); + } Map params = new HashMap<>(); + if (analyzer != null) { + params.put("analyzer", analyzer); + } + if (lanceTokenizer != null) { + params.put("lance_tokenizer", lanceTokenizer); + } if (baseTokenizer != null) { params.put("base_tokenizer", baseTokenizer); } @@ -248,9 +468,7 @@ public ScalarIndexParams build() { if (withPosition != null) { params.put("with_position", withPosition); } - if (maxTokenLength != null) { - params.put("max_token_length", maxTokenLength); - } + params.put("max_token_length", maxTokenLength); if (lowerCase != null) { params.put("lower_case", lowerCase); } @@ -280,9 +498,31 @@ public ScalarIndexParams build() { if (prefixOnly != null) { params.put("prefix_only", prefixOnly); } - if (skipMerge != null) { - params.put("skip_merge", skipMerge); + if (blockSize != null) { + params.put("block_size", blockSize); + } + if (splitIdentifiers != null) { + params.put("split_identifiers", splitIdentifiers); + } + if (splitOnNumerics != null) { + params.put("split_on_numerics", splitOnNumerics); + } + if (preserveOriginal != null) { + params.put("preserve_original", preserveOriginal); + } + if (indexOperators != null) { + params.put("index_operators", indexOperators); + } + if (memoryLimit != null) { + params.put("memory_limit", memoryLimit); + } + if (numWorkers != null) { + params.put("num_workers", numWorkers); + } + if (formatVersion != null) { + params.put("format_version", formatVersion); } + params.put("document_granularity", documentGranularity.toRustString()); String json = JsonUtils.toJson(params); return ScalarIndexParams.create(INDEX_TYPE, json); diff --git a/java/src/main/java/org/lance/index/scalar/ScalarIndexParams.java b/java/src/main/java/org/lance/index/scalar/ScalarIndexParams.java index 345a55f20b2..b3408e2d68d 100644 --- a/java/src/main/java/org/lance/index/scalar/ScalarIndexParams.java +++ b/java/src/main/java/org/lance/index/scalar/ScalarIndexParams.java @@ -31,7 +31,7 @@ private ScalarIndexParams(Builder builder) { * Create a new ScalarIndexParams with the given index type and no parameters. * * @param indexType the index type (e.g., "btree", "zonemap", "bitmap", "inverted", "labellist", - * "ngram") + * "ngram", "rtree") * @return ScalarIndexParams */ public static ScalarIndexParams create(String indexType) { @@ -42,7 +42,7 @@ public static ScalarIndexParams create(String indexType) { * Create a new ScalarIndexParams with the given index type and JSON parameters. * * @param indexType the index type (e.g., "btree", "zonemap", "bitmap", "inverted", "labellist", - * "ngram") + * "ngram", "rtree") * @param jsonParams JSON string containing index-specific parameters * @return ScalarIndexParams */ @@ -58,7 +58,7 @@ public static class Builder { * Create a new builder for scalar index parameters. * * @param indexType the index type (e.g., "btree", "zonemap", "bitmap", "inverted", "labellist", - * "ngram") + * "ngram", "rtree") */ public Builder(String indexType) { this.indexType = indexType; 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/main/java/org/lance/index/vector/RQBuildParams.java b/java/src/main/java/org/lance/index/vector/RQBuildParams.java new file mode 100755 index 00000000000..49db5d9567e --- /dev/null +++ b/java/src/main/java/org/lance/index/vector/RQBuildParams.java @@ -0,0 +1,53 @@ +/* + * 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.vector; + +import com.google.common.base.MoreObjects; + +/** Parameters for building a Rabit Quantizer (RQ) index stage. Defaults to 5 bits per dimension. */ +public class RQBuildParams { + private final byte numBits; + + private RQBuildParams(Builder builder) { + this.numBits = builder.numBits; + } + + public static class Builder { + private byte numBits = 5; + + public Builder() {} + + /** + * @param numBits number of bits per dimension used by Rabit quantization. + * @return Builder + */ + public Builder setNumBits(byte numBits) { + this.numBits = numBits; + return this; + } + + public RQBuildParams build() { + return new RQBuildParams(this); + } + } + + public byte getNumBits() { + return numBits; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("numBits", numBits).toString(); + } +} diff --git a/java/src/main/java/org/lance/index/vector/VectorIndexParams.java b/java/src/main/java/org/lance/index/vector/VectorIndexParams.java index 07159cdd048..e8928943b48 100644 --- a/java/src/main/java/org/lance/index/vector/VectorIndexParams.java +++ b/java/src/main/java/org/lance/index/vector/VectorIndexParams.java @@ -26,6 +26,7 @@ public class VectorIndexParams { private final Optional pqParams; private final Optional hnswParams; private final Optional sqParams; + private final Optional rqParams; private VectorIndexParams(Builder builder) { this.distanceType = builder.distanceType; @@ -33,12 +34,16 @@ private VectorIndexParams(Builder builder) { this.pqParams = builder.pqParams; this.hnswParams = builder.hnswParams; this.sqParams = builder.sqParams; + this.rqParams = builder.rqParams; validate(); } private void validate() { - if (pqParams.isPresent() && sqParams.isPresent()) { - throw new IllegalArgumentException("PQ and SQ cannot coexist"); + if ((pqParams.isPresent() ? 1 : 0) + + (sqParams.isPresent() ? 1 : 0) + + (rqParams.isPresent() ? 1 : 0) + > 1) { + throw new IllegalArgumentException("Only one of PQ, SQ, or RQ can be specified at a time."); } if (hnswParams.isPresent() && !pqParams.isPresent() && !sqParams.isPresent()) { throw new IllegalArgumentException("HNSW must be combined with either PQ or SQ"); @@ -100,6 +105,35 @@ public static VectorIndexParams withIvfPqParams( return new Builder(ivf).setDistanceType(distanceType).setPqParams(pq).build(); } + /** + * Create a new IVF index with RQ quantizer. + * + * @param numPartitions the number of partitions of IVF (Inverted File Index) + * @param numBits number of bits per dimension used by Rabit quantization + * @param distanceType the distance type for calculating the distance between vectors + * @return the VectorIndexParams + */ + public static VectorIndexParams ivfRq( + int numPartitions, byte numBits, DistanceType distanceType) { + IvfBuildParams ivfParams = new IvfBuildParams.Builder().setNumPartitions(numPartitions).build(); + RQBuildParams rqParams = new RQBuildParams.Builder().setNumBits(numBits).build(); + + return new Builder(ivfParams).setDistanceType(distanceType).setRqParams(rqParams).build(); + } + + /** + * Create a new IVF index with RQ quantizer. + * + * @param distanceType the distance type for calculating the distance between vectors + * @param ivf the IVF build parameters + * @param rq the RQ build parameters + * @return the VectorIndexParams + */ + public static VectorIndexParams withIvfRqParams( + DistanceType distanceType, IvfBuildParams ivf, RQBuildParams rq) { + return new Builder(ivf).setDistanceType(distanceType).setRqParams(rq).build(); + } + /** * Create a new IVF HNSW index with PQ quantizer. The dataset is partitioned into IVF partitions, * and each partition builds an HNSW graph. @@ -144,6 +178,7 @@ public static class Builder { private Optional pqParams = Optional.empty(); private Optional hnswParams = Optional.empty(); private Optional sqParams = Optional.empty(); + private Optional rqParams = Optional.empty(); /** * Create a new builder to create a vector index. @@ -191,6 +226,15 @@ public Builder setSqParams(SQBuildParams sqParams) { return this; } + /** + * @param rqParams the RQ quantizer build parameters + * @return Builder + */ + public Builder setRqParams(RQBuildParams rqParams) { + this.rqParams = Optional.of(rqParams); + return this; + } + public VectorIndexParams build() { return new VectorIndexParams(this); } @@ -220,6 +264,10 @@ public Optional getSqParams() { return sqParams; } + public Optional getRqParams() { + return rqParams; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -228,6 +276,7 @@ public String toString() { .add("pqParams", pqParams.orElse(null)) .add("hnswParams", hnswParams.orElse(null)) .add("sqParams", sqParams.orElse(null)) + .add("rqParams", rqParams.orElse(null)) .toString(); } } diff --git a/java/src/main/java/org/lance/index/vector/VectorTrainer.java b/java/src/main/java/org/lance/index/vector/VectorTrainer.java index 03081176bf1..640398ffcf6 100755 --- a/java/src/main/java/org/lance/index/vector/VectorTrainer.java +++ b/java/src/main/java/org/lance/index/vector/VectorTrainer.java @@ -15,6 +15,8 @@ import org.lance.Dataset; import org.lance.JniLoader; +import org.lance.LockManager; +import org.lance.index.DistanceType; import org.apache.arrow.util.Preconditions; @@ -33,41 +35,80 @@ public final class VectorTrainer { private VectorTrainer() {} + /** + * Train IVF centroids for the given dataset column with {@link DistanceType#L2}. + * + *

    Equivalent to {@link #trainIvfCentroids(Dataset, String, IvfBuildParams, DistanceType)} with + * {@code distanceType = DistanceType.L2}. + */ + public static float[] trainIvfCentroids(Dataset dataset, String column, IvfBuildParams params) { + return trainIvfCentroids(dataset, column, params, DistanceType.L2); + } + /** * Train IVF centroids for the given dataset column. * + *

    The {@code distanceType} controls the geometry used to cluster centroids and must match the + * distance type later passed to per-fragment index builds. If they disagree the centroids are + * clustered on a different geometry than the encoded data and recall will silently degrade. + * * @param dataset the dataset to sample training data from * @param column the vector column name * @param params IVF build parameters (numPartitions, sampleRate, etc.) + * @param distanceType distance metric used during clustering * @return a flattened array of centroids laid out as [numPartitions][dimension] */ - public static float[] trainIvfCentroids(Dataset dataset, String column, IvfBuildParams params) { + public static float[] trainIvfCentroids( + Dataset dataset, String column, IvfBuildParams params, DistanceType distanceType) { Preconditions.checkArgument(dataset != null, "dataset cannot be null"); Preconditions.checkArgument( column != null && !column.isEmpty(), "column cannot be null or empty"); Preconditions.checkArgument(params != null, "params cannot be null"); - return nativeTrainIvfCentroids(dataset, column, params); + Preconditions.checkArgument(distanceType != null, "distanceType cannot be null"); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeTrainIvfCentroids(dataset, column, params, distanceType.toString()); + } + } + + /** + * Train a PQ codebook for the given dataset column with {@link DistanceType#L2}. + * + *

    Equivalent to {@link #trainPqCodebook(Dataset, String, PQBuildParams, DistanceType)} with + * {@code distanceType = DistanceType.L2}. + */ + public static float[] trainPqCodebook(Dataset dataset, String column, PQBuildParams params) { + return trainPqCodebook(dataset, column, params, DistanceType.L2); } /** * Train a PQ codebook for the given dataset column. * + *

    The {@code distanceType} controls the geometry used to fit the codebook (notably, Cosine + * normalizes training data before k-means) and must match the distance type later passed to + * per-fragment index builds. If they disagree the codebook is shaped for one geometry while + * encoding happens against another and recall will silently degrade. + * * @param dataset the dataset to sample training data from * @param column the vector column name * @param params PQ build parameters (numSubVectors, numBits, sampleRate, etc.) + * @param distanceType distance metric used during codebook training * @return a flattened array of codebook entries laid out as [num_centroids][dimension] */ - public static float[] trainPqCodebook(Dataset dataset, String column, PQBuildParams params) { + public static float[] trainPqCodebook( + Dataset dataset, String column, PQBuildParams params, DistanceType distanceType) { Preconditions.checkArgument(dataset != null, "dataset cannot be null"); Preconditions.checkArgument( column != null && !column.isEmpty(), "column cannot be null or empty"); Preconditions.checkArgument(params != null, "params cannot be null"); - return nativeTrainPqCodebook(dataset, column, params); + Preconditions.checkArgument(distanceType != null, "distanceType cannot be null"); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + return nativeTrainPqCodebook(dataset, column, params, distanceType.toString()); + } } private static native float[] nativeTrainIvfCentroids( - Dataset dataset, String column, IvfBuildParams params); + Dataset dataset, String column, IvfBuildParams params, String distanceType); private static native float[] nativeTrainPqCodebook( - Dataset dataset, String column, PQBuildParams params); + Dataset dataset, String column, PQBuildParams params, String distanceType); } 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/ipc/ApproxMode.java b/java/src/main/java/org/lance/ipc/ApproxMode.java new file mode 100644 index 00000000000..e6aa7fc3552 --- /dev/null +++ b/java/src/main/java/org/lance/ipc/ApproxMode.java @@ -0,0 +1,42 @@ +/* + * 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; + +/** + * Controls the speed / accuracy tradeoff for approximate vector search. + * + *

    This setting currently only affects RQ-quantized vector indexes, such as IVF_RQ. Other index + * types ignore this setting. + */ +public enum ApproxMode { + /** Prefer faster approximate scoring when supported by the RQ index. */ + FAST("fast"), + + /** Use the index's default approximation behavior. */ + NORMAL("normal"), + + /** Prefer more accurate approximate scoring when supported by the RQ index. */ + ACCURATE("accurate"); + + private final String value; + + ApproxMode(String value) { + this.value = value; + } + + /** Returns the lowercase value passed across the JNI boundary. */ + public String toRustString() { + return value; + } +} 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..050a23a81d3 --- /dev/null +++ b/java/src/main/java/org/lance/ipc/AsyncScanner.java @@ -0,0 +1,233 @@ +/* + * 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.LanceException; +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.UUID; +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; + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + scanner = + createAsyncScanner( + dataset, + options.getFragmentIds(), + options.getIndexSegments(), + options.getColumns(), + options.getSubstraitFilter(), + options.getFilter(), + options.getBatchSize(), + options.getBatchSizeBytes(), + options.getIoBufferSize(), + options.getLimit(), + options.getOffset(), + options.getNearest(), + options.getFullTextQuery(), + options.isPrefilter(), + options.isWithRowId(), + options.isWithRowAddress(), + options.getBatchReadahead(), + options.getFragmentReadahead(), + options.isScanInOrder(), + options.getLateMaterialization(), + options.getColumnOrderings(), + options.isUseScalarIndex(), + options.isFastSearch(), + options.getSubstraitAggregate(), + options.isIncludeDeletedRows(), + options.isStrictBatchSize(), + options.isDisableScoringAutoprojection()); + } + scanner.allocator = allocator; + return scanner; + } + + static native AsyncScanner createAsyncScanner( + Dataset dataset, + Optional> fragmentIds, + Optional> indexSegments, + Optional> columns, + Optional substraitFilter, + Optional filter, + Optional batchSize, + Optional batchSizeBytes, + Optional ioBufferSize, + Optional limit, + Optional offset, + Optional query, + Optional fullTextQuery, + boolean prefilter, + boolean withRowId, + boolean withRowAddress, + int batchReadahead, + Optional fragmentReadahead, + boolean scanInOrder, + Optional lateMaterialization, + Optional> columnOrderings, + boolean useScalarIndex, + boolean fastSearch, + Optional substraitAggregate, + boolean includeDeletedRows, + boolean strictBatchSize, + boolean disableScoringAutoprojection); + + /** + * 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 LanceException("Scan failed", error); + } + + if (streamPtr < 0) { + throw new LanceException("Native scan returned an invalid stream pointer"); + } + + try { + ArrowArrayStream stream = ArrowArrayStream.wrap(streamPtr); + return Data.importArrayStream(allocator, stream); + } catch (Exception e) { + throw new LanceException("Failed to import scan stream", 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 LanceException(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/main/java/org/lance/ipc/FullTextQuery.java b/java/src/main/java/org/lance/ipc/FullTextQuery.java index e28e12c2189..1cad64026c0 100755 --- a/java/src/main/java/org/lance/ipc/FullTextQuery.java +++ b/java/src/main/java/org/lance/ipc/FullTextQuery.java @@ -13,6 +13,8 @@ */ package org.lance.ipc; +import org.lance.DocumentGranularity; + import com.google.common.base.MoreObjects; import org.apache.arrow.util.Preconditions; @@ -21,13 +23,20 @@ import java.util.Objects; import java.util.Optional; -/** Base type for full text search queries used by Lance scanner. */ +/** + * Base type for full text search queries used by Lance scanner. + * + *

    Match and phrase overloads without a {@link DocumentGranularity} infer the unique indexed + * granularity for the field. They use row documents when no index exists and fail as ambiguous when + * row and list-element indexes coexist. + */ public abstract class FullTextQuery { public enum Type { MATCH, MATCH_PHRASE, BOOST, MULTI_MATCH, + COMBINED_FIELDS, BOOLEAN } @@ -37,8 +46,11 @@ public enum Operator { } public enum Occur { + /** The clause may match and contributes its score when it does. */ SHOULD, + /** The clause must match and contributes its score. */ MUST, + /** The clause must not match and never contributes to the score. */ MUST_NOT } @@ -63,7 +75,14 @@ public FullTextQuery getQuery() { public abstract Type getType(); public static FullTextQuery match(String queryText, String column) { - return match(queryText, column, 1.0f, Optional.empty(), 50, Operator.OR, 0); + return new MatchQuery( + queryText, column, 1.0f, Optional.empty(), 50, Operator.OR, 0, Optional.empty()); + } + + public static FullTextQuery match( + String queryText, String column, DocumentGranularity documentGranularity) { + return match( + queryText, column, 1.0f, Optional.empty(), 50, Operator.OR, 0, documentGranularity); } public static FullTextQuery match( @@ -75,15 +94,58 @@ public static FullTextQuery match( Operator operator, int prefixLength) { return new MatchQuery( - queryText, column, boost, fuzziness, maxExpansions, operator, prefixLength); + queryText, + column, + boost, + fuzziness, + maxExpansions, + operator, + prefixLength, + Optional.empty()); + } + + public static FullTextQuery match( + String queryText, + String column, + float boost, + Optional fuzziness, + int maxExpansions, + Operator operator, + int prefixLength, + DocumentGranularity documentGranularity) { + return new MatchQuery( + queryText, + column, + boost, + fuzziness, + maxExpansions, + operator, + prefixLength, + Optional.of( + Objects.requireNonNull(documentGranularity, "documentGranularity must not be null"))); } public static FullTextQuery phrase(String queryText, String column) { - return phrase(queryText, column, 0); + return new PhraseQuery(queryText, column, 0, Optional.empty()); + } + + public static FullTextQuery phrase( + String queryText, String column, DocumentGranularity documentGranularity) { + return phrase(queryText, column, 0, documentGranularity); } public static FullTextQuery phrase(String queryText, String column, int slop) { - return new PhraseQuery(queryText, column, slop); + return new PhraseQuery(queryText, column, slop, Optional.empty()); + } + + public static FullTextQuery phrase( + String queryText, String column, int slop, DocumentGranularity documentGranularity) { + return new PhraseQuery( + queryText, + column, + slop, + Optional.of( + Objects.requireNonNull(documentGranularity, "documentGranularity must not be null"))); } public static FullTextQuery multiMatch(String queryText, List columns) { @@ -95,6 +157,15 @@ public static FullTextQuery multiMatch( return new MultiMatchQuery(queryText, columns, boosts, operator); } + public static FullTextQuery combinedFields(String queryText, List columns) { + return combinedFields(queryText, columns, null, Operator.OR); + } + + public static FullTextQuery combinedFields( + String queryText, List columns, List boosts, Operator operator) { + return new CombinedFieldsQuery(queryText, columns, boosts, operator); + } + public static FullTextQuery boost(FullTextQuery positive, FullTextQuery negative) { return boost(positive, negative, 0.5f); } @@ -117,6 +188,7 @@ public static final class MatchQuery extends FullTextQuery { private final int maxExpansions; private final Operator operator; private final int prefixLength; + private final Optional documentGranularity; MatchQuery( String queryText, @@ -125,7 +197,8 @@ public static final class MatchQuery extends FullTextQuery { Optional fuzziness, int maxExpansions, Operator operator, - int prefixLength) { + int prefixLength, + Optional documentGranularity) { Preconditions.checkArgument( queryText != null && !queryText.isEmpty(), "queryText must not be null or empty"); Preconditions.checkArgument( @@ -140,6 +213,7 @@ public static final class MatchQuery extends FullTextQuery { this.maxExpansions = maxExpansions; this.operator = operator == null ? Operator.OR : operator; this.prefixLength = prefixLength; + this.documentGranularity = Objects.requireNonNull(documentGranularity); } @Override @@ -175,6 +249,39 @@ public int getPrefixLength() { return prefixLength; } + /** Returns the explicit granularity, or empty when query planning should infer it. */ + public Optional getDocumentGranularity() { + return documentGranularity; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MatchQuery)) return false; + MatchQuery other = (MatchQuery) o; + return Float.compare(boost, other.boost) == 0 + && maxExpansions == other.maxExpansions + && prefixLength == other.prefixLength + && operator == other.operator + && Objects.equals(documentGranularity, other.documentGranularity) + && Objects.equals(queryText, other.queryText) + && Objects.equals(column, other.column) + && Objects.equals(fuzziness, other.fuzziness); + } + + @Override + public int hashCode() { + return Objects.hash( + queryText, + column, + boost, + fuzziness, + maxExpansions, + operator, + prefixLength, + documentGranularity); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -186,6 +293,7 @@ public String toString() { .add("maxExpansions", maxExpansions) .add("operator", operator) .add("prefixLength", prefixLength) + .add("documentGranularity", documentGranularity) .toString(); } } @@ -195,8 +303,13 @@ public static final class PhraseQuery extends FullTextQuery { private final String queryText; private final String column; private final int slop; + private final Optional documentGranularity; - PhraseQuery(String queryText, String column, int slop) { + PhraseQuery( + String queryText, + String column, + int slop, + Optional documentGranularity) { Preconditions.checkArgument( queryText != null && !queryText.isEmpty(), "queryText must not be null or empty"); Preconditions.checkArgument( @@ -206,6 +319,7 @@ public static final class PhraseQuery extends FullTextQuery { this.queryText = queryText; this.column = column; this.slop = slop; + this.documentGranularity = Objects.requireNonNull(documentGranularity); } @Override @@ -225,6 +339,27 @@ public int getSlop() { return slop; } + /** Returns the explicit granularity, or empty when query planning should infer it. */ + public Optional getDocumentGranularity() { + return documentGranularity; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof PhraseQuery)) return false; + PhraseQuery other = (PhraseQuery) o; + return slop == other.slop + && Objects.equals(documentGranularity, other.documentGranularity) + && Objects.equals(queryText, other.queryText) + && Objects.equals(column, other.column); + } + + @Override + public int hashCode() { + return Objects.hash(queryText, column, slop, documentGranularity); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -232,6 +367,7 @@ public String toString() { .add("queryText", queryText) .add("column", column) .add("slop", slop) + .add("documentGranularity", documentGranularity) .toString(); } } @@ -277,6 +413,118 @@ public Operator getOperator() { return operator; } + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof MultiMatchQuery)) return false; + MultiMatchQuery other = (MultiMatchQuery) o; + return operator == other.operator + && Objects.equals(queryText, other.queryText) + && Objects.equals(columns, other.columns) + && Objects.equals(boosts, other.boosts); + } + + @Override + public int hashCode() { + return Objects.hash(queryText, columns, operator, boosts); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("type", getType()) + .add("queryText", queryText) + .add("columns", columns) + .add("boosts", boosts) + .add("operator", operator) + .toString(); + } + } + + /** + * Combined-fields (BM25F) query across multiple columns. + * + *

    Unlike {@link MultiMatchQuery}, which scores each column independently and keeps the best + * field, this query treats the target columns as a single virtual field so that term statistics + * are blended across fields (Lucene {@code CombinedFieldQuery}). A term that is rare in one field + * but common in another then scores consistently, and a single query term can match across fields + * (for example a first name in one column and a last name in another). + * + *

    Target columns must be unique and share the same tokenizer/index configuration. When {@code + * boosts} is given, its length must equal the number of columns and each per-column weight must + * be a finite value in {@code [1, 2^20]} (fractional weights allowed); when {@code null}, every + * column defaults to {@code 1.0}. A {@code null} operator defaults to {@link Operator#OR}. These + * constraints are validated in the Rust core and surface as an {@link IllegalArgumentException} + * from {@code Dataset.newScan}, which builds the query when the scanner is created. + * + *

    At least one target column needs an FTS index. Columns without an index, and rows added + * since the last index build, are read from the data and scored together with the indexed rows. + * + *

    BM25F adds up each column's contribution for a whole row, so a column indexed only with + * {@link DocumentGranularity#LIST_ELEMENT} granularity is rejected, because its element + * coordinates have no counterpart in the other columns. A column that has both indexes uses the + * row one. + */ + public static final class CombinedFieldsQuery extends FullTextQuery { + private final String queryText; + private final List columns; + private final Optional> boosts; + private final Operator operator; + + CombinedFieldsQuery( + String queryText, List columns, List boosts, Operator operator) { + Preconditions.checkArgument( + queryText != null && !queryText.isEmpty(), "queryText must not be null or empty"); + Preconditions.checkArgument( + columns != null && !columns.isEmpty(), "columns must not be null or empty"); + + this.queryText = queryText; + this.columns = + Collections.unmodifiableList(new java.util.ArrayList<>(Objects.requireNonNull(columns))); + this.boosts = + boosts == null + ? Optional.empty() + : Optional.of(Collections.unmodifiableList(new java.util.ArrayList<>(boosts))); + this.operator = operator == null ? Operator.OR : operator; + } + + @Override + public Type getType() { + return Type.COMBINED_FIELDS; + } + + public String getQueryText() { + return queryText; + } + + public List getColumns() { + return columns; + } + + public Optional> getBoosts() { + return boosts; + } + + public Operator getOperator() { + return operator; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof CombinedFieldsQuery)) return false; + CombinedFieldsQuery other = (CombinedFieldsQuery) o; + return operator == other.operator + && Objects.equals(queryText, other.queryText) + && Objects.equals(columns, other.columns) + && Objects.equals(boosts, other.boosts); + } + + @Override + public int hashCode() { + return Objects.hash(queryText, columns, operator, boosts); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/java/src/main/java/org/lance/ipc/LanceScanner.java b/java/src/main/java/org/lance/ipc/LanceScanner.java index 804b7ea22f3..058a6e1057a 100644 --- a/java/src/main/java/org/lance/ipc/LanceScanner.java +++ b/java/src/main/java/org/lance/ipc/LanceScanner.java @@ -14,6 +14,7 @@ package org.lance.ipc; import org.lance.Dataset; +import org.lance.LanceException; import org.lance.LockManager; import org.apache.arrow.c.ArrowArrayStream; @@ -29,6 +30,7 @@ import java.nio.ByteBuffer; import java.util.List; import java.util.Optional; +import java.util.UUID; /** Scanner over a Fragment. */ public class LanceScanner implements org.apache.arrow.dataset.scanner.Scanner { @@ -57,22 +59,39 @@ public static LanceScanner create( Preconditions.checkNotNull(dataset); Preconditions.checkNotNull(options); Preconditions.checkNotNull(allocator); - LanceScanner scanner = - createScanner( - dataset, - options.getFragmentIds(), - options.getColumns(), - options.getSubstraitFilter(), - options.getFilter(), - options.getBatchSize(), - options.getLimit(), - options.getOffset(), - options.getNearest(), - options.getFullTextQuery(), - options.isWithRowId(), - options.isWithRowAddress(), - options.getBatchReadahead(), - options.getColumnOrderings()); + LanceScanner scanner; + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + scanner = + createScanner( + dataset, + options.getFragmentIds(), + options.getIndexSegments(), + options.getColumns(), + options.getSubstraitFilter(), + options.getFilter(), + options.getBatchSize(), + options.getBatchSizeBytes(), + options.getIoBufferSize(), + options.getLimit(), + options.getOffset(), + options.getNearest(), + options.getFullTextQuery(), + options.isPrefilter(), + options.isWithRowId(), + options.isWithRowAddress(), + options.getBatchReadahead(), + options.getFragmentReadahead(), + options.isScanInOrder(), + options.getLateMaterialization(), + options.getColumnOrderings(), + options.isUseScalarIndex(), + options.isFastSearch(), + options.getSubstraitAggregate(), + options.isCollectStats(), + options.isIncludeDeletedRows(), + options.isStrictBatchSize(), + options.isDisableScoringAutoprojection()); + } scanner.allocator = allocator; scanner.dataset = dataset; scanner.options = options; @@ -82,18 +101,32 @@ public static LanceScanner create( static native LanceScanner createScanner( Dataset dataset, Optional> fragmentIds, + Optional> indexSegments, Optional> columns, Optional substraitFilter, Optional filter, Optional batchSize, + Optional batchSizeBytes, + Optional ioBufferSize, Optional limit, Optional offset, Optional query, Optional fullTextQuery, + boolean prefilter, boolean withRowId, boolean withRowAddress, int batchReadahead, - Optional> columnOrderings); + Optional fragmentReadahead, + boolean scanInOrder, + Optional lateMaterialization, + Optional> columnOrderings, + boolean useScalarIndex, + boolean fastSearch, + Optional substraitAggregate, + boolean collectStats, + boolean includeDeletedRows, + boolean strictBatchSize, + boolean disableScoringAutoprojection); /** * Closes this scanner and releases any system resources associated with it. If the scanner is @@ -124,12 +157,71 @@ public ArrowReader scanBatches() { openStream(s.memoryAddress()); return Data.importArrayStream(allocator, s); } catch (IOException e) { - // TODO: handle IO exception? - throw new RuntimeException(e); + throw new LanceException("Failed to open scan stream", e); } } } + /** + * Export this scan's results into a caller-owned Arrow C stream identified by its memory address, + * using the Arrow C Data Interface release callback to transfer ownership. + * + *

    This method intentionally takes a raw {@code streamAddress} (an {@code ArrowArrayStream} + * memory address) rather than a Java {@link ArrowArrayStream} object. A typed parameter would be + * an {@code org.apache.arrow.c.ArrowArrayStream} loaded by Lance's classloader / Arrow + * version; a caller running a different Arrow version (or under a different classloader, e.g. + * Spark + a native engine bundling its own Arrow) cannot construct that exact type and would hit + * a {@code ClassCastException}/{@code NoSuchMethodError} at the very boundary this method exists + * to cross. The C Data Interface ABI is stable across Arrow versions, so passing the C struct's + * address keeps the two sides fully decoupled: the caller allocates the stream with its + * own Arrow runtime and only the {@code long} address crosses into Lance. See gluten#12263 + * for the cross-Arrow-version integration that motivated this. + * + *

    Unlike {@link #scanBatches()}, no Java Arrow {@link ArrowReader} is created on Lance's side: + * Lance writes the C struct directly at {@code streamAddress} and the caller drives the read loop + * with its own Arrow runtime. + * + *

    The {@code streamAddress} must point to a freshly-allocated, empty {@code ArrowArrayStream} + * (its {@code release} callback must be null). Exporting into a stream that already holds a + * producer is rejected with an {@link IllegalArgumentException}, because overwriting the struct + * would drop the existing {@code release} callback and leak the first producer. The caller owns + * the stream and is responsible for closing it; the release callback installed by this call + * routes back through Lance's native side. + * + *

    The provided stream must not be shared across concurrent exports. An {@code + * ArrowArrayStream} is a plain C struct in caller-owned memory with no internal synchronization, + * so a single stream must be exported into, then drained, by one thread at a time. The + * already-populated check above guards the sequential "export twice" mistake, but it cannot make + * two concurrent exports into the same struct safe — that is a caller-side data race on + * caller-owned memory, the same contract as Arrow's C Data Interface itself. Use a separate + * stream per concurrent export. + * + *

    Example (caller on its own Arrow version / allocator): + * + *

    {@code
    +   * try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(callerAllocator)) {
    +   *   scanner.exportArrowStream(stream.memoryAddress());
    +   *   try (ArrowReader reader = Data.importArrayStream(callerAllocator, stream)) {
    +   *     while (reader.loadNextBatch()) {
    +   *       VectorSchemaRoot batch = reader.getVectorSchemaRoot();
    +   *       // ...
    +   *     }
    +   *   }
    +   * }
    +   * }
    + * + * @param streamAddress the memory address of a freshly-allocated, empty {@code ArrowArrayStream} + * to populate + * @throws IllegalArgumentException if the scanner is closed or the stream is already populated + * @throws IOException if the native scan fails to start + */ + public void exportArrowStream(long streamAddress) throws IOException { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeScannerHandle != 0, "Scanner is closed"); + openStream(streamAddress); + } + } + private native void openStream(long streamAddress) throws IOException; @Override @@ -163,5 +255,23 @@ public long countRows() { } } + /** + * Get scan execution statistics for the most recently executed scan. + * + *

    Statistics are only collected when {@link ScanOptions.Builder#collectStats(boolean)} is set + * to true. The values are populated when the scan stream is fully consumed and closed. + * + *

    If statistics collection is disabled, or the scan has not finished yet, this method returns + * {@link Optional#empty()}. + */ + public Optional getStats() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeScannerHandle != 0, "Scanner is closed"); + return Optional.ofNullable(nativeGetStats()); + } + } + + private native ScanStats nativeGetStats(); + private native long nativeCountRows(); } diff --git a/java/src/main/java/org/lance/ipc/MaterializationStyle.java b/java/src/main/java/org/lance/ipc/MaterializationStyle.java new file mode 100644 index 00000000000..e0ab095e06c --- /dev/null +++ b/java/src/main/java/org/lance/ipc/MaterializationStyle.java @@ -0,0 +1,117 @@ +/* + * 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.apache.arrow.util.Preconditions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Controls whether projected columns are fetched before or after filtering. + * + *

    For example, a selective filter can fetch a large payload column only for matching rows: + * + *

    {@code
    + * ScanOptions options = new ScanOptions.Builder()
    + *     .filter("id = 42")
    + *     .lateMaterialization(MaterializationStyle.allEarlyExcept(
    + *         Collections.singletonList("payload")))
    + *     .build();
    + * }
    + * + *

    This policy only affects projected columns that are not used by a filter in a regular scan. + * Vector search and full-text search always use late materialization. + */ +public final class MaterializationStyle { + /** Available materialization policies. */ + public enum Mode { + /** Let Lance choose based on the storage system and column size. */ + HEURISTIC("heuristic"), + /** Fetch all eligible projected columns after filtering. */ + ALL_LATE("all_late"), + /** Fetch all projected columns before filtering. */ + ALL_EARLY("all_early"), + /** Fetch only the specified projected columns after filtering. */ + ALL_EARLY_EXCEPT("all_early_except"); + + private final String rustValue; + + Mode(String rustValue) { + this.rustValue = rustValue; + } + + /** Returns the explicit value expected by the Rust binding. */ + public String toRustString() { + return rustValue; + } + } + + private final Mode mode; + private final List columns; + + private MaterializationStyle(Mode mode, List columns) { + this.mode = mode; + this.columns = Collections.unmodifiableList(new ArrayList<>(columns)); + } + + /** Use Lance's storage-aware heuristic. This is the default when no style is specified. */ + public static MaterializationStyle heuristic() { + return new MaterializationStyle(Mode.HEURISTIC, Collections.emptyList()); + } + + /** Fetch all eligible projected columns after filtering. */ + public static MaterializationStyle allLate() { + return new MaterializationStyle(Mode.ALL_LATE, Collections.emptyList()); + } + + /** Fetch all projected columns before filtering. */ + public static MaterializationStyle allEarly() { + return new MaterializationStyle(Mode.ALL_EARLY, Collections.emptyList()); + } + + /** + * Fetch only the specified projected columns after filtering; all other columns are fetched + * before filtering. An empty list is equivalent to {@link #allEarly()}. + */ + public static MaterializationStyle allEarlyExcept(List lateColumns) { + Preconditions.checkNotNull(lateColumns, "lateColumns must not be null"); + for (String column : lateColumns) { + Preconditions.checkArgument( + column != null && !column.isEmpty(), "lateColumns must not contain null or empty names"); + } + return new MaterializationStyle(Mode.ALL_EARLY_EXCEPT, lateColumns); + } + + /** Returns the selected materialization mode. */ + public Mode getMode() { + return mode; + } + + /** Returns the explicit value expected by the Rust binding. */ + public String toRustString() { + return mode.toRustString(); + } + + /** Columns to fetch after filtering when using {@link Mode#ALL_EARLY_EXCEPT}. */ + public List getColumns() { + return columns; + } + + @Override + public String toString() { + return "MaterializationStyle{" + "mode=" + mode + ", columns=" + columns + '}'; + } +} diff --git a/java/src/main/java/org/lance/ipc/Query.java b/java/src/main/java/org/lance/ipc/Query.java index 9bd2dc03b90..d517b70f6a0 100644 --- a/java/src/main/java/org/lance/ipc/Query.java +++ b/java/src/main/java/org/lance/ipc/Query.java @@ -24,6 +24,7 @@ public class Query { private final String column; private final float[] key; + private final int queryVectorDim; private final int k; private final int minimumNprobes; private final Optional maximumNprobes; @@ -31,11 +32,21 @@ public class Query { private final Optional refineFactor; private final Optional distanceType; private final boolean useIndex; + private final int queryParallelism; + private final ApproxMode approxMode; private Query(Builder builder) { this.column = Preconditions.checkNotNull(builder.column, "Columns must be set"); Preconditions.checkArgument(!builder.column.isEmpty(), "Column must not be empty"); this.key = Preconditions.checkNotNull(builder.key, "Key must be set"); + Preconditions.checkArgument( + builder.queryVectorDim >= 0, "Query vector dimension must not be negative"); + if (builder.queryVectorDim > 0) { + Preconditions.checkArgument( + builder.key.length > 0 && builder.key.length % builder.queryVectorDim == 0, + "Batch query buffer length must be a positive multiple of the query vector dimension"); + } + this.queryVectorDim = builder.queryVectorDim; Preconditions.checkArgument(builder.k > 0, "K must be greater than 0"); Preconditions.checkArgument( builder.minimumNprobes > 0, "Minimum Nprobes must be greater than 0"); @@ -50,6 +61,8 @@ private Query(Builder builder) { this.refineFactor = builder.refineFactor; this.distanceType = builder.distanceType; this.useIndex = builder.useIndex; + this.queryParallelism = builder.queryParallelism; + this.approxMode = builder.approxMode; } public String getColumn() { @@ -60,6 +73,16 @@ public float[] getKey() { return key; } + /** + * Returns the length of each query vector when {@link #getKey()} packs a batch of query vectors, + * or {@code 0} for a single-vector query. + * + * @return The per-vector dimension of a batch query, or {@code 0} for a single-vector query. + */ + public int getQueryVectorDim() { + return queryVectorDim; + } + public int getK() { return k; } @@ -92,11 +115,24 @@ public boolean isUseIndex() { return useIndex; } + public int getQueryParallelism() { + return queryParallelism; + } + + public ApproxMode getApproxMode() { + return approxMode; + } + + public String getApproxModeString() { + return approxMode.toRustString(); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("column", column) .add("key", key) + .add("queryVectorDim", queryVectorDim) .add("k", k) .add("minimumNprobes", minimumNprobes) .add("maximumNprobes", maximumNprobes.orElse(null)) @@ -104,12 +140,15 @@ public String toString() { .add("refineFactor", refineFactor.orElse(null)) .add("distanceType", distanceType.orElse(null)) .add("useIndex", useIndex) + .add("queryParallelism", queryParallelism) + .add("approxMode", approxMode) .toString(); } public static class Builder { private String column; private float[] key; + private int queryVectorDim = 0; private int k = 10; private int minimumNprobes = 1; private Optional maximumNprobes = Optional.empty(); @@ -117,6 +156,8 @@ public static class Builder { private Optional refineFactor = Optional.empty(); private Optional distanceType = Optional.empty(); private boolean useIndex = true; + private int queryParallelism = 0; + private ApproxMode approxMode = ApproxMode.NORMAL; /** * Sets the column to be searched. @@ -132,11 +173,59 @@ public Builder setColumn(String column) { /** * Sets the vector to be searched. * + *

    This API accepts a single query vector. The array length must match the target vector + * column dimension. To search multiple query vectors in one scan, use {@link + * #setKeys(float[][])}. + * * @param key The search vector. * @return The Builder instance for method chaining. */ public Builder setKey(float[] key) { this.key = key; + this.queryVectorDim = 0; + return this; + } + + /** + * Sets multiple query vectors for a batch nearest-neighbor search. + * + *

    Every row must be non-null and share the same length, which must match the target vector + * column dimension. The rows are flattened row-major into a single query buffer. + * + *

    Unlike {@link #setKey(float[])}, a batch query prepends a non-nullable {@code query_index} + * column holding the zero-based offset of the query vector that produced each row, and returns + * up to {@code k} rows per query vector (results are grouped by {@code query_index}, ordered by + * distance within each group). This column is added even when a single query vector is + * supplied. The scan fails if the dataset already contains a column named {@code query_index}. + * + *

    Scan-level {@code limit} / {@code offset} apply to the combined result across all query + * vectors, not per query vector. Batch search is not supported on multivector columns. + * + * @param keys The search vectors, one per row. + * @return The Builder instance for method chaining. + */ + public Builder setKeys(float[][] keys) { + Preconditions.checkNotNull(keys, "Keys must not be null"); + Preconditions.checkArgument(keys.length > 0, "Keys must not be empty"); + Preconditions.checkNotNull(keys[0], "Query vector must not be null"); + int dim = keys[0].length; + Preconditions.checkArgument(dim > 0, "Query vector dimension must be greater than 0"); + long totalLength = (long) keys.length * dim; + Preconditions.checkArgument( + totalLength <= Integer.MAX_VALUE, + "Batch query of %s vectors x %s dimensions exceeds the maximum buffer length %s", + keys.length, + dim, + Integer.MAX_VALUE); + float[] flattened = new float[(int) totalLength]; + for (int i = 0; i < keys.length; i++) { + Preconditions.checkNotNull(keys[i], "Query vector must not be null"); + Preconditions.checkArgument( + keys[i].length == dim, "All query vectors must have the same dimension"); + System.arraycopy(keys[i], 0, flattened, i * dim, dim); + } + this.key = flattened; + this.queryVectorDim = dim; return this; } @@ -154,8 +243,7 @@ public Builder setK(int k) { /** * Sets the number of probes to load and search. * - *

    This is a convenience method that sets both the minimum and maximum number of probes to - * the same value. + *

    This sets both the minimum and maximum number of probes to the same value. * * @param nprobes The number of probes. * @return The Builder instance for method chaining. @@ -245,6 +333,38 @@ public Builder setUseIndex(boolean useIndex) { return this; } + /** + * Sets vector partition search concurrency for each query. + * + *

    The default is 0. Value 0 uses the automatic policy, which currently maps to the + * single-worker sequential path. Value -1 uses the CPU pool size. Value 1 uses the + * single-worker sequential path. Values greater than or equal to 2 use the partition-parallel + * path and are clamped to the CPU pool size. + * + * @param queryParallelism The partition search concurrency policy. + * @return The Builder instance for method chaining. + */ + public Builder setQueryParallelism(int queryParallelism) { + Preconditions.checkArgument( + queryParallelism >= -1, "Query parallelism must be greater than or equal to -1"); + this.queryParallelism = queryParallelism; + return this; + } + + /** + * Sets the speed / accuracy tradeoff for approximate vector search. + * + *

    This setting currently only affects RQ-quantized vector indexes, such as IVF_RQ. Other + * index types ignore this setting. + * + * @param approxMode The approximate search mode to use for the query. + * @return The Builder instance for method chaining. + */ + public Builder setApproxMode(ApproxMode approxMode) { + this.approxMode = Preconditions.checkNotNull(approxMode, "ApproxMode must not be null"); + return this; + } + /** * Builds the Query object. * diff --git a/java/src/main/java/org/lance/ipc/ScanOptions.java b/java/src/main/java/org/lance/ipc/ScanOptions.java index b73f9e8104e..f73fb027cb2 100644 --- a/java/src/main/java/org/lance/ipc/ScanOptions.java +++ b/java/src/main/java/org/lance/ipc/ScanOptions.java @@ -19,11 +19,15 @@ import java.nio.ByteBuffer; import java.util.List; import java.util.Optional; +import java.util.UUID; /** Lance scan options. */ public class ScanOptions { private final Optional> fragmentIds; + private final Optional> indexSegments; private final Optional batchSize; + private final Optional batchSizeBytes; + private final Optional ioBufferSize; private final Optional> columns; private final Optional filter; private final Optional substraitFilter; @@ -31,31 +35,227 @@ public class ScanOptions { private final Optional offset; private final Optional nearest; private final Optional fullTextQuery; + private final boolean prefilter; private final boolean withRowId; private final boolean withRowAddress; private final int batchReadahead; + private final Optional fragmentReadahead; + private final boolean scanInOrder; + private final Optional lateMaterialization; private final Optional> columnOrderings; + private final boolean useScalarIndex; + private final Optional substraitAggregate; + private final boolean collectStats; + private final boolean fastSearch; + private final boolean includeDeletedRows; + private final boolean strictBatchSize; + private final boolean disableScoringAutoprojection; + + public ScanOptions( + Optional> fragmentIds, + Optional batchSize, + Optional> columns, + Optional filter, + Optional substraitFilter, + Optional limit, + Optional offset, + Optional nearest, + Optional fullTextQuery, + boolean prefilter, + boolean withRowId, + boolean withRowAddress, + int batchReadahead, + Optional> columnOrderings, + boolean useScalarIndex, + Optional substraitAggregate, + boolean collectStats) { + this( + fragmentIds, + Optional.empty(), + batchSize, + columns, + filter, + substraitFilter, + limit, + offset, + nearest, + fullTextQuery, + prefilter, + withRowId, + withRowAddress, + batchReadahead, + columnOrderings, + useScalarIndex, + substraitAggregate, + collectStats, + false, + false, + false, + false); + } + + /** + * Constructor for LanceScanOptions. + * + * @param fragmentIds the id of the fragments to scan + * @param batchSize Maximum row number of each returned ArrowRecordBatch. Optional, use + * Optional.empty() if unspecified. + * @param columns (Optional) Projected columns. Optional.empty() for scanning all columns. + * Otherwise, only columns present in the List will be scanned. + * @param filter (Optional) Filter expression. Optional.empty() for no filter. + * @param substraitFilter (Optional) Substrait filter expression. + * @param filter (Optional) Filter expression. Optional.empty() for no filter. + * @param limit (Optional) Maximum number of rows to return. + * @param offset (Optional) Number of rows to skip before returning results. + * @param withRowId Whether to include the row ID in the results. + * @param withRowAddress Whether to include the row address in the results. + * @param nearest (Optional) Nearest neighbor query. + * @param batchReadahead Number of batches to read ahead. + * @param columnOrderings (Optional) Column orderings for result sorting. + * @param useScalarIndex Whether to use scalar indices for the scan. Default is true. + * @param substraitAggregate (Optional) Substrait aggregate expression for aggregate pushdown. + * @param collectStats Whether to collect scan execution statistics. Default is false. + * @param fastSearch Whether to only search indexed fragments. Default is false. + * @deprecated Use the overload that adds {@code indexSegments} for vector index segment + * selection. + */ + @Deprecated + public ScanOptions( + Optional> fragmentIds, + Optional batchSize, + Optional> columns, + Optional filter, + Optional substraitFilter, + Optional limit, + Optional offset, + Optional nearest, + Optional fullTextQuery, + boolean prefilter, + boolean withRowId, + boolean withRowAddress, + int batchReadahead, + Optional> columnOrderings, + boolean useScalarIndex, + Optional substraitAggregate, + boolean collectStats, + boolean fastSearch, + boolean includeDeletedRows, + boolean strictBatchSize, + boolean disableScoringAutoprojection) { + this( + fragmentIds, + Optional.empty(), + batchSize, + columns, + filter, + substraitFilter, + limit, + offset, + nearest, + fullTextQuery, + prefilter, + withRowId, + withRowAddress, + batchReadahead, + columnOrderings, + useScalarIndex, + substraitAggregate, + collectStats, + fastSearch, + includeDeletedRows, + strictBatchSize, + disableScoringAutoprojection); + } /** * Constructor for LanceScanOptions. * * @param fragmentIds the id of the fragments to scan + * @param indexSegments (Optional) Vector index segment UUIDs to restrict the search to. Only + * valid for nearest-neighbor search. Empty list is rejected by the engine. When combined with + * {@code fragmentIds}, fragments outside the selected segments still fall back to flat KNN. * @param batchSize Maximum row number of each returned ArrowRecordBatch. Optional, use * Optional.empty() if unspecified. * @param columns (Optional) Projected columns. Optional.empty() for scanning all columns. * Otherwise, only columns present in the List will be scanned. * @param filter (Optional) Filter expression. Optional.empty() for no filter. * @param substraitFilter (Optional) Substrait filter expression. + * @param filter (Optional) Filter expression. Optional.empty() for no filter. * @param limit (Optional) Maximum number of rows to return. * @param offset (Optional) Number of rows to skip before returning results. * @param withRowId Whether to include the row ID in the results. * @param withRowAddress Whether to include the row address in the results. * @param nearest (Optional) Nearest neighbor query. * @param batchReadahead Number of batches to read ahead. + * @param columnOrderings (Optional) Column orderings for result sorting. + * @param useScalarIndex Whether to use scalar indices for the scan. Default is true. + * @param substraitAggregate (Optional) Substrait aggregate expression for aggregate pushdown. + * @param collectStats Whether to collect scan execution statistics. Default is false. + * @param fastSearch Whether to only search indexed fragments. Default is false. + * @param includeDeletedRows Whether to include deleted rows in scan results. Default is false. + * @param strictBatchSize Whether to enforce strict batch sizing. Default is false. + * @param disableScoringAutoprojection Whether to disable scoring column autoprojection. Default + * is false. */ public ScanOptions( Optional> fragmentIds, + Optional> indexSegments, + Optional batchSize, + Optional> columns, + Optional filter, + Optional substraitFilter, + Optional limit, + Optional offset, + Optional nearest, + Optional fullTextQuery, + boolean prefilter, + boolean withRowId, + boolean withRowAddress, + int batchReadahead, + Optional> columnOrderings, + boolean useScalarIndex, + Optional substraitAggregate, + boolean collectStats, + boolean fastSearch, + boolean includeDeletedRows, + boolean strictBatchSize, + boolean disableScoringAutoprojection) { + this( + fragmentIds, + indexSegments, + batchSize, + Optional.empty(), + Optional.empty(), + columns, + filter, + substraitFilter, + limit, + offset, + nearest, + fullTextQuery, + prefilter, + withRowId, + withRowAddress, + batchReadahead, + Optional.empty(), + true, + Optional.empty(), + columnOrderings, + useScalarIndex, + substraitAggregate, + collectStats, + fastSearch, + includeDeletedRows, + strictBatchSize, + disableScoringAutoprojection); + } + + private ScanOptions( + Optional> fragmentIds, + Optional> indexSegments, Optional batchSize, + Optional batchSizeBytes, + Optional ioBufferSize, Optional> columns, Optional filter, Optional substraitFilter, @@ -63,15 +263,46 @@ public ScanOptions( Optional offset, Optional nearest, Optional fullTextQuery, + boolean prefilter, boolean withRowId, boolean withRowAddress, int batchReadahead, - Optional> columnOrderings) { + Optional fragmentReadahead, + boolean scanInOrder, + Optional lateMaterialization, + Optional> columnOrderings, + boolean useScalarIndex, + Optional substraitAggregate, + boolean collectStats, + boolean fastSearch, + boolean includeDeletedRows, + boolean strictBatchSize, + boolean disableScoringAutoprojection) { Preconditions.checkArgument( !(filter.isPresent() && substraitFilter.isPresent()), "cannot set both substrait filter and string filter"); + Preconditions.checkArgument( + batchReadahead > 0, "batchReadahead must be greater than 0, got %s", batchReadahead); + batchSizeBytes.ifPresent( + value -> + Preconditions.checkArgument( + value > 0, "batchSizeBytes must be greater than 0, got %s", value)); + ioBufferSize.ifPresent( + value -> + Preconditions.checkArgument( + value > 0, "ioBufferSize must be greater than 0, got %s", value)); + fragmentReadahead.ifPresent( + value -> + Preconditions.checkArgument( + value > 0, "fragmentReadahead must be greater than 0, got %s", value)); + Preconditions.checkArgument( + !(strictBatchSize && batchSizeBytes.isPresent()), + "strictBatchSize=true cannot be combined with batchSizeBytes"); this.fragmentIds = fragmentIds; + this.indexSegments = indexSegments; this.batchSize = batchSize; + this.batchSizeBytes = batchSizeBytes; + this.ioBufferSize = ioBufferSize; this.columns = columns; this.filter = filter; this.substraitFilter = substraitFilter; @@ -79,10 +310,21 @@ public ScanOptions( this.offset = offset; this.nearest = nearest; this.fullTextQuery = fullTextQuery; + this.prefilter = prefilter; this.withRowId = withRowId; this.withRowAddress = withRowAddress; this.batchReadahead = batchReadahead; + this.fragmentReadahead = fragmentReadahead; + this.scanInOrder = scanInOrder; + this.lateMaterialization = lateMaterialization; this.columnOrderings = columnOrderings; + this.useScalarIndex = useScalarIndex; + this.substraitAggregate = substraitAggregate; + this.collectStats = collectStats; + this.fastSearch = fastSearch; + this.includeDeletedRows = includeDeletedRows; + this.strictBatchSize = strictBatchSize; + this.disableScoringAutoprojection = disableScoringAutoprojection; } /** @@ -94,6 +336,15 @@ public Optional> getFragmentIds() { return fragmentIds; } + /** + * Get the index segment UUIDs. + * + * @return Optional containing the index segment UUIDs if specified, otherwise empty. + */ + public Optional> getIndexSegments() { + return indexSegments; + } + /** * Get the batch size. * @@ -103,6 +354,24 @@ public Optional getBatchSize() { return batchSize; } + /** + * Get the target batch size in bytes. + * + * @return Optional containing the target batch size in bytes if specified, otherwise empty. + */ + public Optional getBatchSizeBytes() { + return batchSizeBytes; + } + + /** + * Get the I/O buffer size in bytes. + * + * @return Optional containing the I/O buffer size if specified, otherwise empty. + */ + public Optional getIoBufferSize() { + return ioBufferSize; + } + /** * Get the columns. * @@ -166,6 +435,15 @@ public Optional getFullTextQuery() { return fullTextQuery; } + /** + * Get whether to prefilter before nearest neighbor search. + * + * @return true if prefilter should be applied, false otherwise. + */ + public boolean isPrefilter() { + return prefilter; + } + /** * Get whether to include the row ID. * @@ -193,15 +471,103 @@ public int getBatchReadahead() { return batchReadahead; } + /** + * Get the number of fragments to read ahead concurrently. + * + * @return Optional containing the fragment readahead if specified, otherwise empty. + */ + public Optional getFragmentReadahead() { + return fragmentReadahead; + } + + /** + * Get whether batches should be returned in fragment and batch order. + * + * @return true to preserve scan order, false to return batches as soon as they are ready. + */ + public boolean isScanInOrder() { + return scanInOrder; + } + + /** + * Get the materialization policy for projected columns. + * + * @return Optional containing the materialization policy if specified, otherwise empty. + */ + public Optional getLateMaterialization() { + return lateMaterialization; + } + public Optional> getColumnOrderings() { return columnOrderings; } + /** + * Get whether to use scalar indices for the scan. + * + * @return true if scalar indices should be used, false otherwise. + */ + public boolean isUseScalarIndex() { + return useScalarIndex; + } + + /** + * Get whether to only search indexed fragments. + * + * @return true if unindexed fragments should be skipped, false otherwise. + */ + public boolean isFastSearch() { + return fastSearch; + } + + /** + * Get the substrait aggregate expression. + * + * @return Optional containing the substrait aggregate if specified, otherwise empty. + */ + public Optional getSubstraitAggregate() { + return substraitAggregate; + } + + public boolean isCollectStats() { + return collectStats; + } + + /** + * Get whether to include deleted rows in scan results. + * + * @return true if deleted rows should be included, false otherwise. + */ + public boolean isIncludeDeletedRows() { + return includeDeletedRows; + } + + /** + * Get whether to enforce strict batch sizing. + * + * @return true if batch sizes must be strictly enforced, false otherwise. + */ + public boolean isStrictBatchSize() { + return strictBatchSize; + } + + /** + * Get whether to disable scoring autoprojection. + * + * @return true if scoring column autoprojection is disabled, false otherwise. + */ + public boolean isDisableScoringAutoprojection() { + return disableScoringAutoprojection; + } + @Override public String toString() { return MoreObjects.toStringHelper(this) .add("fragmentIds", fragmentIds.orElse(null)) + .add("indexSegments", indexSegments.orElse(null)) .add("batchSize", batchSize.orElse(null)) + .add("batchSizeBytes", batchSizeBytes.orElse(null)) + .add("ioBufferSize", ioBufferSize.orElse(null)) .add("columns", columns.orElse(null)) .add("filter", filter.orElse(null)) .add( @@ -211,17 +577,33 @@ public String toString() { .add("offset", offset.orElse(null)) .add("nearest", nearest.orElse(null)) .add("fullTextQuery", fullTextQuery.orElse(null)) + .add("prefilter", prefilter) .add("withRowId", withRowId) .add("WithRowAddress", withRowAddress) .add("batchReadahead", batchReadahead) + .add("fragmentReadahead", fragmentReadahead.orElse(null)) + .add("scanInOrder", scanInOrder) + .add("lateMaterialization", lateMaterialization.orElse(null)) .add("columnOrdering", columnOrderings) + .add("useScalarIndex", useScalarIndex) + .add("fastSearch", fastSearch) + .add( + "substraitAggregate", + substraitAggregate.map(buf -> "ByteBuffer[" + buf.remaining() + " bytes]").orElse(null)) + .add("collectStats", collectStats) + .add("includeDeletedRows", includeDeletedRows) + .add("strictBatchSize", strictBatchSize) + .add("disableScoringAutoprojection", disableScoringAutoprojection) .toString(); } /** Builder for constructing LanceScanOptions. */ public static class Builder { private Optional> fragmentIds = Optional.empty(); + private Optional> indexSegments = Optional.empty(); private Optional batchSize = Optional.empty(); + private Optional batchSizeBytes = Optional.empty(); + private Optional ioBufferSize = Optional.empty(); private Optional> columns = Optional.empty(); private Optional filter = Optional.empty(); private Optional substraitFilter = Optional.empty(); @@ -229,10 +611,21 @@ public static class Builder { private Optional offset = Optional.empty(); private Optional nearest = Optional.empty(); private Optional fullTextQuery = Optional.empty(); + private boolean prefilter = false; private boolean withRowId = false; private boolean withRowAddress = false; private int batchReadahead = 16; + private Optional fragmentReadahead = Optional.empty(); + private boolean scanInOrder = true; + private Optional lateMaterialization = Optional.empty(); private Optional> columnOrderings = Optional.empty(); + private boolean useScalarIndex = true; + private boolean fastSearch = false; + private Optional substraitAggregate = Optional.empty(); + private boolean collectStats = false; + private boolean includeDeletedRows = false; + private boolean strictBatchSize = false; + private boolean disableScoringAutoprojection = false; public Builder() {} @@ -243,7 +636,10 @@ public Builder() {} */ public Builder(ScanOptions options) { this.fragmentIds = options.getFragmentIds(); + this.indexSegments = options.getIndexSegments(); this.batchSize = options.getBatchSize(); + this.batchSizeBytes = options.getBatchSizeBytes(); + this.ioBufferSize = options.getIoBufferSize(); this.columns = options.getColumns(); this.filter = options.getFilter(); this.substraitFilter = options.getSubstraitFilter(); @@ -251,10 +647,21 @@ public Builder(ScanOptions options) { this.offset = options.getOffset(); this.nearest = options.getNearest(); this.fullTextQuery = options.getFullTextQuery(); + this.prefilter = options.isPrefilter(); this.withRowId = options.isWithRowId(); this.withRowAddress = options.isWithRowAddress(); this.batchReadahead = options.getBatchReadahead(); + this.fragmentReadahead = options.getFragmentReadahead(); + this.scanInOrder = options.isScanInOrder(); + this.lateMaterialization = options.getLateMaterialization(); this.columnOrderings = options.getColumnOrderings(); + this.useScalarIndex = options.isUseScalarIndex(); + this.fastSearch = options.isFastSearch(); + this.substraitAggregate = options.getSubstraitAggregate(); + this.collectStats = options.isCollectStats(); + this.includeDeletedRows = options.isIncludeDeletedRows(); + this.strictBatchSize = options.isStrictBatchSize(); + this.disableScoringAutoprojection = options.isDisableScoringAutoprojection(); } /** @@ -268,6 +675,29 @@ public Builder fragmentIds(List fragmentIds) { return this; } + /** + * Restrict vector search to the specified index segment UUIDs from a single logical index. + * + *

    By default, no segment restriction is applied. The engine rejects empty lists, unknown + * segments, and use without a nearest-neighbor query. When {@link #fragmentIds(List)} is also + * set, selected fragments outside these segments are searched with flat KNN; otherwise, + * fragments outside the selected segments are excluded. + * + *

    {@code
    +     * ScanOptions options = new ScanOptions.Builder()
    +     *     .nearest(query)
    +     *     .indexSegments(List.of(segment.uuid()))
    +     *     .build();
    +     * }
    + * + * @param indexSegments the index segment UUIDs to use + * @return Builder instance for method chaining. + */ + public Builder indexSegments(List indexSegments) { + this.indexSegments = Optional.of(indexSegments); + return this; + } + /** * Set the batch size. * @@ -279,6 +709,34 @@ public Builder batchSize(long batchSize) { return this; } + /** + * Set the approximate target size of each returned Arrow record batch in bytes. + * + *

    If {@link #batchSize(long)} is also set, the limit reached first determines the batch + * size. This option cannot be combined with {@link #strictBatchSize(boolean)}. + * + * @param batchSizeBytes target batch size in bytes; must be greater than zero. + * @return Builder instance for method chaining. + */ + public Builder batchSizeBytes(long batchSizeBytes) { + this.batchSizeBytes = Optional.of(batchSizeBytes); + return this; + } + + /** + * Set the amount of memory reserved for buffering storage I/O. + * + *

    This controls scanner backpressure but is not a hard upper bound on total scanner memory. + * It currently applies only to v2 files. + * + * @param ioBufferSize I/O buffer size in bytes; must be greater than zero. + * @return Builder instance for method chaining. + */ + public Builder ioBufferSize(long ioBufferSize) { + this.ioBufferSize = Optional.of(ioBufferSize); + return this; + } + /** * Set the columns. * @@ -356,6 +814,17 @@ public Builder fullTextQuery(FullTextQuery fullTextQuery) { return this; } + /** + * Set whether to prefilter during nearest neighbor search. + * + * @param prefilter true to apply prefilter, false otherwise. + * @return Builder instance for method chaining. + */ + public Builder prefilter(boolean prefilter) { + this.prefilter = prefilter; + return this; + } + /** * Set whether to include the row ID. * @@ -389,11 +858,139 @@ public Builder batchReadahead(int batchReadahead) { return this; } + /** + * Set the number of fragments to read ahead concurrently. + * + * @param fragmentReadahead number of fragments to read ahead; must be greater than zero. + * @return Builder instance for method chaining. + */ + public Builder fragmentReadahead(int fragmentReadahead) { + this.fragmentReadahead = Optional.of(fragmentReadahead); + return this; + } + + /** + * Set whether batches are returned in fragment and batch order. + * + *

    Disabling ordering can increase scan concurrency at the cost of additional memory. This + * option is ignored for v2 files, which always scan in order. It is also ignored when a column + * ordering or nearest-neighbor query is configured; the query determines the result order. + * + * @param scanInOrder true to preserve scan order, false to return ready batches immediately. + * @return Builder instance for method chaining. + */ + public Builder scanInOrder(boolean scanInOrder) { + this.scanInOrder = scanInOrder; + return this; + } + + /** + * Set when projected columns that are not used by the filter should be materialized. + * + *

    This option only affects regular scans. Vector search and full-text search always use late + * materialization. + * + * @param lateMaterialization materialization policy. + * @return Builder instance for method chaining. + */ + public Builder lateMaterialization(MaterializationStyle lateMaterialization) { + this.lateMaterialization = Optional.of(lateMaterialization); + return this; + } + public Builder setColumnOrderings(List columnOrderings) { this.columnOrderings = Optional.of(columnOrderings); return this; } + /** + * Set whether to use scalar indices for the scan. + * + *

    Scans will use scalar indices, when available, to optimize queries with filters. However, + * in some corner cases, scalar indices may make performance worse. This parameter allows users + * to disable scalar indices in these cases. + * + * @param useScalarIndex true to use scalar indices, false otherwise. Default is true. + * @return Builder instance for method chaining. + */ + public Builder useScalarIndex(boolean useScalarIndex) { + this.useScalarIndex = useScalarIndex; + return this; + } + + /** + * Set whether to only search indexed fragments. + * + *

    This is a weak-consistency mode for vector search, full text search, and scalar-indexed + * filters. It can reduce latency by skipping recently appended fragments that are not covered + * by the relevant index. + * + * @param fastSearch true to skip unindexed fragments, false otherwise. Default is false. + * @return Builder instance for method chaining. + */ + public Builder fastSearch(boolean fastSearch) { + this.fastSearch = fastSearch; + return this; + } + + /** + * Set the substrait aggregate expression. + * + * @param substraitAggregate Substrait aggregate expression. + * @return Builder instance for method chaining. + */ + public Builder substraitAggregate(ByteBuffer substraitAggregate) { + this.substraitAggregate = Optional.of(substraitAggregate); + return this; + } + + /** + * Enable or disable scan execution statistics collection. + * + *

    When enabled, the native scanner will collect statistics (see {@link ScanStats}) for the + * scan and make them available via {@link LanceScanner#getStats()} after the scan stream is + * fully consumed and closed. + * + *

    Default is false. + */ + public Builder collectStats(boolean collectStats) { + this.collectStats = collectStats; + return this; + } + + /** + * Set whether to include deleted rows in scan results. Default is false. + * + * @param includeDeletedRows whether to include deleted rows + * @return Builder instance for method chaining. + */ + public Builder includeDeletedRows(boolean includeDeletedRows) { + this.includeDeletedRows = includeDeletedRows; + return this; + } + + /** + * Set whether to enforce strict batch sizing. Default is false. + * + * @param strictBatchSize whether to enforce strict batch sizing + * @return Builder instance for method chaining. + */ + public Builder strictBatchSize(boolean strictBatchSize) { + this.strictBatchSize = strictBatchSize; + return this; + } + + /** + * Set whether to disable scoring column autoprojection. Default is false. + * + * @param disableScoringAutoprojection whether to disable autoprojection + * @return Builder instance for method chaining. + */ + public Builder disableScoringAutoprojection(boolean disableScoringAutoprojection) { + this.disableScoringAutoprojection = disableScoringAutoprojection; + return this; + } + /** * Build the LanceScanOptions instance. * @@ -402,7 +999,10 @@ public Builder setColumnOrderings(List columnOrderings) { public ScanOptions build() { return new ScanOptions( fragmentIds, + indexSegments, batchSize, + batchSizeBytes, + ioBufferSize, columns, filter, substraitFilter, @@ -410,10 +1010,21 @@ public ScanOptions build() { offset, nearest, fullTextQuery, + prefilter, withRowId, withRowAddress, batchReadahead, - columnOrderings); + fragmentReadahead, + scanInOrder, + lateMaterialization, + columnOrderings, + useScalarIndex, + substraitAggregate, + collectStats, + fastSearch, + includeDeletedRows, + strictBatchSize, + disableScoringAutoprojection); } } } diff --git a/java/src/main/java/org/lance/ipc/ScanStats.java b/java/src/main/java/org/lance/ipc/ScanStats.java new file mode 100755 index 00000000000..e2160d0f371 --- /dev/null +++ b/java/src/main/java/org/lance/ipc/ScanStats.java @@ -0,0 +1,231 @@ +/* + * 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 java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Summary counts collected after executing a scan plan. + * + *

    These statistics are populated when the scan stream is fully consumed and closed. + */ +public final class ScanStats { + private final long iops; + private final long requests; + private final long bytesRead; + private final long indicesLoaded; + private final long partsLoaded; + private final long indexComparisons; + + /** Number of index cache page lookups served from memory in this scan. */ + private final long indexCacheHits; + + /** Number of index cache page lookups that had to load from storage in this scan. */ + private final long indexCacheMisses; + + private final Map allCounts; + private final Map allTimes; + + public ScanStats( + long iops, + long requests, + long bytesRead, + long indicesLoaded, + long partsLoaded, + long indexComparisons, + long indexCacheHits, + long indexCacheMisses, + Map allCounts, + Map allTimes) { + this.iops = iops; + this.requests = requests; + this.bytesRead = bytesRead; + this.indicesLoaded = indicesLoaded; + this.partsLoaded = partsLoaded; + this.indexComparisons = indexComparisons; + this.indexCacheHits = indexCacheHits; + this.indexCacheMisses = indexCacheMisses; + this.allCounts = freezeMap(allCounts); + this.allTimes = freezeMap(allTimes); + } + + /** + * Backwards-compatible constructor kept for existing callers that predate the addition of + * per-query index cache statistics. New code should use the 10-argument constructor that also + * accepts {@code indexCacheHits} and {@code indexCacheMisses}. + * + * @deprecated Use {@link #ScanStats(long, long, long, long, long, long, long, long, Map, Map)}. + */ + @Deprecated + public ScanStats( + long iops, + long requests, + long bytesRead, + long indicesLoaded, + long partsLoaded, + long indexComparisons, + Map allCounts, + Map allTimes) { + this( + iops, + requests, + bytesRead, + indicesLoaded, + partsLoaded, + indexComparisons, + 0L, + 0L, + allCounts, + allTimes); + } + + private static Map freezeMap(Map map) { + if (map == null || map.isEmpty()) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(new HashMap<>(map)); + } + + public long getIops() { + return iops; + } + + public long getRequests() { + return requests; + } + + public long getBytesRead() { + return bytesRead; + } + + public long getIndicesLoaded() { + return indicesLoaded; + } + + public long getPartsLoaded() { + return partsLoaded; + } + + public long getIndexComparisons() { + return indexComparisons; + } + + /** + * Number of index cache page lookups where the loader was not executed in this scan. + * + *

    Counts both true cache hits on already-populated entries and coalesced concurrent loads (a + * follower attached to another caller's in-flight load). + * + *

    Instrumented boundaries in this release: BTree, IVF v2 (write-cache scan path), inverted + * posting list (grouped and per-token) and its per-token metadata, inverted phrase positions, + * bitmap (Equals / Range / IsIn), ngram, rtree. + * + *

    Caveats: + * + *

      + *
    • IVF v2 streaming scans and legacy v1 IVF partitions bypass the cache by design and are + * therefore reported as a miss on every call. + *
    • A cold posting-list lookup on the grouped inverted layout can record up to two misses + * (group + per-token metadata) for a single term. + *
    + * + *

    Uninstrumented paths (HNSW graph pages, quantizer codebooks) do not contribute to either + * counter. See the sibling {@link #getIndexCacheMisses()} for the paired counter. + */ + public long getIndexCacheHits() { + return indexCacheHits; + } + + /** + * Number of index cache page lookups where the loader ran in this scan (the page was not resident + * and had to be materialised, typically from storage). See {@link #getIndexCacheHits()} for the + * paired counter and the list of instrumented boundaries. + */ + public long getIndexCacheMisses() { + return indexCacheMisses; + } + + public Map getAllCounts() { + return allCounts; + } + + public Map getAllTimes() { + return allTimes; + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (!(o instanceof ScanStats)) { + return false; + } + ScanStats that = (ScanStats) o; + return iops == that.iops + && requests == that.requests + && bytesRead == that.bytesRead + && indicesLoaded == that.indicesLoaded + && partsLoaded == that.partsLoaded + && indexComparisons == that.indexComparisons + && indexCacheHits == that.indexCacheHits + && indexCacheMisses == that.indexCacheMisses + && Objects.equals(allCounts, that.allCounts) + && Objects.equals(allTimes, that.allTimes); + } + + @Override + public int hashCode() { + return Objects.hash( + iops, + requests, + bytesRead, + indicesLoaded, + partsLoaded, + indexComparisons, + indexCacheHits, + indexCacheMisses, + allCounts, + allTimes); + } + + @Override + public String toString() { + return "ScanStats{" + + "iops=" + + iops + + ", requests=" + + requests + + ", bytesRead=" + + bytesRead + + ", indicesLoaded=" + + indicesLoaded + + ", partsLoaded=" + + partsLoaded + + ", indexComparisons=" + + indexComparisons + + ", indexCacheHits=" + + indexCacheHits + + ", indexCacheMisses=" + + indexCacheMisses + + ", allCounts=" + + allCounts + + ", allTimes=" + + allTimes + + '}'; + } +} diff --git a/java/src/main/java/org/lance/memwal/CompactedSsTable.java b/java/src/main/java/org/lance/memwal/CompactedSsTable.java new file mode 100644 index 00000000000..cb703b42641 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/CompactedSsTable.java @@ -0,0 +1,56 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; + +/** + * Points to an SSTable compacted into the base table. + * + *

    Pass a list of these to {@link org.lance.merge.MergeInsertParams#markSstablesAsCompacted} so + * Lance can record compaction progress. + */ +public class CompactedSsTable { + private final String shardId; + private final long generation; + + /** + * @param shardId UUID string for the write shard + * @param generation generation number from {@link ShardSnapshot#sstables()} + */ + public CompactedSsTable(String shardId, long generation) { + Preconditions.checkNotNull(shardId, "shardId must not be null"); + this.shardId = shardId; + this.generation = generation; + } + + /** UUID string for the write shard. */ + public String getShardId() { + return shardId; + } + + /** The compacted SSTable's generation number. */ + public long getGeneration() { + return generation; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("shardId", shardId) + .add("generation", generation) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/ExecutionPlan.java b/java/src/main/java/org/lance/memwal/ExecutionPlan.java new file mode 100644 index 00000000000..34fe2017647 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ExecutionPlan.java @@ -0,0 +1,108 @@ +/* + * 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.memwal; + +import org.lance.JniLoader; +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; + +/** + * An executable physical plan produced by a MemWAL planner. + * + *

    Planner classes only build plans; execution happens through this class. Obtain instances from + * {@link LsmPointLookupPlanner#planLookup} or {@link LsmVectorSearchPlanner#planSearch}. + */ +public class ExecutionPlan implements AutoCloseable { + static { + JniLoader.ensureLoaded(); + } + + private long nativeExecutionPlanHandle; + BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + + private ExecutionPlan() {} + + /** Output schema of this physical plan. */ + public Schema schema() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeExecutionPlanHandle != 0, "ExecutionPlan is closed"); + try (ArrowSchema ffiSchema = ArrowSchema.allocateNew(allocator)) { + nativeImportSchema(ffiSchema.memoryAddress()); + return Data.importSchema(allocator, ffiSchema, null); + } + } + } + + private native void nativeImportSchema(long schemaAddress); + + /** Schema of the base dataset this plan was constructed from. */ + public Schema datasetSchema() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeExecutionPlanHandle != 0, "ExecutionPlan is closed"); + try (ArrowSchema ffiSchema = ArrowSchema.allocateNew(allocator)) { + nativeImportDatasetSchema(ffiSchema.memoryAddress()); + return Data.importSchema(allocator, ffiSchema, null); + } + } + } + + private native void nativeImportDatasetSchema(long schemaAddress); + + /** Return the physical plan rendered as an indented string. */ + public String explain() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeExecutionPlanHandle != 0, "ExecutionPlan is closed"); + return nativeExplain(); + } + } + + private native String nativeExplain(); + + /** Execute the plan and return a streaming reader over the results. */ + public ArrowReader toReader() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeExecutionPlanHandle != 0, "ExecutionPlan is closed"); + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + nativeOpenStream(stream.memoryAddress()); + return Data.importArrayStream(allocator, stream); + } + } + } + + private native void nativeOpenStream(long streamAddress); + + /** + * Close the plan and release native resources. If the plan is already closed, invoking this + * method has no effect. + */ + @Override + public void close() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeExecutionPlanHandle != 0) { + releaseNativeExecutionPlan(nativeExecutionPlanHandle); + nativeExecutionPlanHandle = 0; + } + } + } + + private native void releaseNativeExecutionPlan(long handle); +} diff --git a/java/src/main/java/org/lance/memwal/InitializeMemWalParams.java b/java/src/main/java/org/lance/memwal/InitializeMemWalParams.java new file mode 100644 index 00000000000..a8f00c03abd --- /dev/null +++ b/java/src/main/java/org/lance/memwal/InitializeMemWalParams.java @@ -0,0 +1,101 @@ +/* + * 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.memwal; + +import com.google.common.base.Preconditions; + +import java.util.List; +import java.util.Optional; + +/** + * Parameters for {@link org.lance.Dataset#initializeMemWal}. + * + *

    At most one sharding mode may be selected: bucket sharding ({@link #withBucketSharding}), + * identity sharding ({@link #withIdentitySharding}), or {@link #withUnsharded()}. With none + * selected, shards are managed manually by passing shard IDs to {@link + * org.lance.Dataset#memWalWriter}. + */ +public class InitializeMemWalParams { + private Optional> maintainedIndexes = Optional.empty(); + private Optional bucketColumn = Optional.empty(); + private Optional numBuckets = Optional.empty(); + private Optional identityColumn = Optional.empty(); + private boolean unsharded = false; + private Optional writerConfigDefaults = Optional.empty(); + + /** + * Names of the indexes to maintain through the MemWAL. Must already exist on the dataset, and an + * empty list maintains none. + * + *

    Left unset, the MemWAL maintains every index the table has, including ones created later. + */ + public InitializeMemWalParams withMaintainedIndexes(List maintainedIndexes) { + Preconditions.checkNotNull(maintainedIndexes, "maintainedIndexes must not be null"); + this.maintainedIndexes = Optional.of(maintainedIndexes); + return this; + } + + /** Use bucket sharding, deriving shard IDs by hashing {@code column} into {@code numBuckets}. */ + public InitializeMemWalParams withBucketSharding(String column, int numBuckets) { + Preconditions.checkNotNull(column, "column must not be null"); + this.bucketColumn = Optional.of(column); + this.numBuckets = Optional.of(numBuckets); + return this; + } + + /** Use identity sharding, deriving shard IDs directly from {@code column}. */ + public InitializeMemWalParams withIdentitySharding(String column) { + Preconditions.checkNotNull(column, "column must not be null"); + this.identityColumn = Optional.of(column); + return this; + } + + /** Use a single unsharded shard for all writes. */ + public InitializeMemWalParams withUnsharded() { + this.unsharded = true; + return this; + } + + /** Default {@link ShardWriterConfig} recorded in the MemWAL index for all writers. */ + public InitializeMemWalParams withWriterConfigDefaults(ShardWriterConfig writerConfigDefaults) { + Preconditions.checkNotNull(writerConfigDefaults, "writerConfigDefaults must not be null"); + this.writerConfigDefaults = Optional.of(writerConfigDefaults); + return this; + } + + /** Empty when every index the table has is maintained. */ + public Optional> maintainedIndexes() { + return maintainedIndexes; + } + + public Optional bucketColumn() { + return bucketColumn; + } + + public Optional numBuckets() { + return numBuckets; + } + + public Optional identityColumn() { + return identityColumn; + } + + public boolean unsharded() { + return unsharded; + } + + public Optional writerConfigDefaults() { + return writerConfigDefaults; + } +} diff --git a/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java b/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java new file mode 100644 index 00000000000..941a68a1606 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/LsmPointLookupPlanner.java @@ -0,0 +1,119 @@ +/* + * 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.memwal; + +import org.lance.Dataset; +import org.lance.JniLoader; +import org.lance.LockManager; + +import org.apache.arrow.c.ArrowArray; +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.FieldVector; + +import java.util.List; +import java.util.Optional; + +/** + * Plans primary-key point lookups across all MemWAL LSM levels. + * + *

    More efficient than {@link LsmScanner} for known-primary-key lookups thanks to bloom filter + * optimizations and short-circuit evaluation. + */ +public class LsmPointLookupPlanner implements AutoCloseable { + static { + JniLoader.ensureLoaded(); + } + + private long nativeLookupPlannerHandle; + private final BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + + /** + * @param dataset the base dataset + * @param shardSnapshots shard snapshots specifying the SSTables to include + */ + public LsmPointLookupPlanner(Dataset dataset, List shardSnapshots) { + this(dataset, shardSnapshots, null); + } + + /** + * @param dataset the base dataset + * @param shardSnapshots shard snapshots specifying the SSTables to include + * @param pkColumns primary key column names; inferred from schema metadata when {@code null} + */ + public LsmPointLookupPlanner( + Dataset dataset, List shardSnapshots, List pkColumns) { + Preconditions.checkNotNull(dataset, "dataset must not be null"); + Preconditions.checkNotNull(shardSnapshots, "shardSnapshots must not be null"); + this.allocator = dataset.allocator(); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + nativeCreate(dataset, shardSnapshots, Optional.ofNullable(pkColumns)); + } + } + + private native void nativeCreate( + Dataset dataset, List shardSnapshots, Optional> pkColumns); + + /** + * Plan a point lookup by primary key value. + * + * @param pkValue for a single-column primary key, a vector with exactly one element; for a + * composite primary key, a {@code StructVector} with one row and one child per key column + * @param columns columns to project; pass {@code null} to return all columns + * @return an executable plan + */ + public ExecutionPlan planLookup(FieldVector pkValue, List columns) { + Preconditions.checkNotNull(pkValue, "pkValue must not be null"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument( + nativeLookupPlannerHandle != 0, "LsmPointLookupPlanner is closed"); + try (ArrowArray array = ArrowArray.allocateNew(allocator); + ArrowSchema schema = ArrowSchema.allocateNew(allocator)) { + Data.exportVector(allocator, pkValue, null, array, schema); + ExecutionPlan plan = + nativePlanLookup( + array.memoryAddress(), schema.memoryAddress(), Optional.ofNullable(columns)); + plan.allocator = allocator; + return plan; + } + } + } + + /** Plan a point lookup by primary key value, returning all columns. */ + public ExecutionPlan planLookup(FieldVector pkValue) { + return planLookup(pkValue, null); + } + + private native ExecutionPlan nativePlanLookup( + long arrayAddress, long schemaAddress, Optional> columns); + + /** + * Close the planner and release native resources. If the planner is already closed, invoking this + * method has no effect. + */ + @Override + public void close() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeLookupPlannerHandle != 0) { + releaseNativeLookupPlanner(nativeLookupPlannerHandle); + nativeLookupPlannerHandle = 0; + } + } + } + + private native void releaseNativeLookupPlanner(long handle); +} diff --git a/java/src/main/java/org/lance/memwal/LsmScanner.java b/java/src/main/java/org/lance/memwal/LsmScanner.java new file mode 100644 index 00000000000..308d8009d71 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/LsmScanner.java @@ -0,0 +1,192 @@ +/* + * 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.memwal; + +import org.lance.Dataset; +import org.lance.JniLoader; +import org.lance.LockManager; + +import org.apache.arrow.c.ArrowArrayStream; +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 java.io.Closeable; +import java.util.List; +import java.util.Optional; + +/** + * LSM-aware scanner covering all MemWAL data levels. + * + *

    Results are deduplicated by primary key, always returning the newest version of each row + * across the base table, SSTables, and (when created from a {@link ShardWriter}) the active + * MemTable. + * + *

    The builder methods ({@link #project}, {@link #filter}, {@link #limit}, {@link + * #withRowAddress}, {@link #withMemtableGen}) mutate this scanner and return it for chaining. + */ +public class LsmScanner implements Closeable { + static { + JniLoader.ensureLoaded(); + } + + private long nativeLsmScannerHandle; + BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + + private LsmScanner() {} + + /** + * Create a scanner from a dataset and shard snapshots. + * + *

    The scanner does not include any active MemTable; use {@link ShardWriter#lsmScanner} for + * read-your-writes consistency. + * + * @param dataset the base dataset to scan + * @param shardSnapshots shard snapshots specifying the SSTables to include + * @return an LSM scanner + */ + public static LsmScanner fromSnapshots(Dataset dataset, List shardSnapshots) { + Preconditions.checkNotNull(dataset, "dataset must not be null"); + Preconditions.checkNotNull(shardSnapshots, "shardSnapshots must not be null"); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + LsmScanner scanner = createFromSnapshots(dataset, shardSnapshots); + scanner.allocator = dataset.allocator(); + return scanner; + } + } + + static native LsmScanner createFromSnapshots(Dataset dataset, List shardSnapshots); + + /** Select specific columns to return. */ + public LsmScanner project(List columns) { + Preconditions.checkNotNull(columns, "columns must not be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + nativeProject(columns); + return this; + } + } + + private native void nativeProject(List columns); + + /** + * Set a SQL filter expression, for example {@code "value > 0.5"}. + * + *

    If {@code expr} fails to parse, an {@link IllegalArgumentException} is thrown and this + * scanner becomes unusable — build a new scanner rather than retrying on this instance. + * + * @param expr a SQL filter expression + * @return this scanner + */ + public LsmScanner filter(String expr) { + Preconditions.checkNotNull(expr, "expr must not be null"); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + nativeFilter(expr); + return this; + } + } + + private native void nativeFilter(String expr); + + /** Limit the number of rows returned, optionally skipping {@code offset} rows first. */ + public LsmScanner limit(Optional limit, Optional offset) { + Preconditions.checkNotNull(limit, "limit must not be null"); + Preconditions.checkNotNull(offset, "offset must not be null"); + limit.ifPresent( + l -> Preconditions.checkArgument(l >= 0, "limit must not be negative, got %s", l)); + offset.ifPresent( + o -> Preconditions.checkArgument(o >= 0, "offset must not be negative, got %s", o)); + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + nativeLimit(limit, offset); + return this; + } + } + + /** Limit the number of rows returned, optionally skipping {@code offset} rows first. */ + public LsmScanner limit(long limit, Optional offset) { + return limit(Optional.of(limit), offset); + } + + /** Limit the number of rows returned. */ + public LsmScanner limit(long limit) { + return limit(limit, Optional.empty()); + } + + private native void nativeLimit(Optional limit, Optional offset); + + /** Include the {@code _rowaddr} internal column in results. */ + public LsmScanner withRowAddress() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + nativeWithRowAddress(); + return this; + } + } + + private native void nativeWithRowAddress(); + + /** Include the {@code _memtable_gen} internal column in results. */ + public LsmScanner withMemtableGen() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + nativeWithMemtableGen(); + return this; + } + } + + private native void nativeWithMemtableGen(); + + /** Execute the scan and return a streaming reader over the merged results. */ + public ArrowReader scanBatches() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + nativeOpenStream(stream.memoryAddress()); + return Data.importArrayStream(allocator, stream); + } + } + } + + private native void nativeOpenStream(long streamAddress); + + /** Return the row count without materializing all column data. */ + public long countRows() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeLsmScannerHandle != 0, "LsmScanner is closed"); + return nativeCountRows(); + } + } + + private native long nativeCountRows(); + + /** + * Close the scanner and release native resources. If the scanner is already closed, invoking this + * method has no effect. + */ + @Override + public void close() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeLsmScannerHandle != 0) { + releaseNativeLsmScanner(nativeLsmScannerHandle); + nativeLsmScannerHandle = 0; + } + } + } + + private native void releaseNativeLsmScanner(long handle); +} diff --git a/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java b/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java new file mode 100644 index 00000000000..b91e5ae4a7d --- /dev/null +++ b/java/src/main/java/org/lance/memwal/LsmVectorSearchPlanner.java @@ -0,0 +1,231 @@ +/* + * 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.memwal; + +import org.lance.Dataset; +import org.lance.JniLoader; +import org.lance.LockManager; + +import org.apache.arrow.c.ArrowArray; +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.Float4Vector; + +import java.util.List; +import java.util.Optional; + +/** + * Plans IVF-PQ vector KNN search across all MemWAL LSM levels. + * + *

    Results include staleness filtering so only the latest version of each row is returned. The + * output schema includes a {@code _distance} column. + */ +public class LsmVectorSearchPlanner implements AutoCloseable { + static { + JniLoader.ensureLoaded(); + } + + private long nativeVectorPlannerHandle; + private final BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + + /** + * @param dataset the base dataset + * @param shardSnapshots shard snapshots specifying the SSTables to include + * @param vectorColumn name of the {@code FixedSizeList} vector column + */ + public LsmVectorSearchPlanner( + Dataset dataset, List shardSnapshots, String vectorColumn) { + this(dataset, shardSnapshots, vectorColumn, null, null, null); + } + + /** + * @param dataset the base dataset + * @param shardSnapshots shard snapshots specifying the SSTables to include + * @param vectorColumn name of the {@code FixedSizeList} vector column + * @param pkColumns primary key column names; inferred from schema metadata when {@code null} + * @param distanceType distance metric, one of {@code "l2"}, {@code "cosine"}, {@code "dot"}, + * {@code "hamming"}; defaults to {@code "l2"} when {@code null} + */ + public LsmVectorSearchPlanner( + Dataset dataset, + List shardSnapshots, + String vectorColumn, + List pkColumns, + String distanceType) { + this(dataset, shardSnapshots, vectorColumn, pkColumns, distanceType, null); + } + + /** + * @param dataset the base dataset + * @param shardSnapshots shard snapshots specifying the SSTables to include + * @param vectorColumn name of the {@code FixedSizeList} vector column + * @param pkColumns primary key column names; inferred from schema metadata when {@code null} + * @param distanceType distance metric, one of {@code "l2"}, {@code "cosine"}, {@code "dot"}, + * {@code "hamming"}; defaults to {@code "l2"} when {@code null} + * @param filter SQL predicate applied as a prefilter to every LSM source before vector candidate + * selection; pass {@code null} for no predicate + */ + public LsmVectorSearchPlanner( + Dataset dataset, + List shardSnapshots, + String vectorColumn, + List pkColumns, + String distanceType, + String filter) { + Preconditions.checkNotNull(dataset, "dataset must not be null"); + Preconditions.checkNotNull(shardSnapshots, "shardSnapshots must not be null"); + Preconditions.checkNotNull(vectorColumn, "vectorColumn must not be null"); + this.allocator = dataset.allocator(); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + nativeCreate( + dataset, + shardSnapshots, + vectorColumn, + Optional.ofNullable(pkColumns), + Optional.ofNullable(distanceType), + Optional.ofNullable(filter)); + } + } + + private native void nativeCreate( + Dataset dataset, + List shardSnapshots, + String vectorColumn, + Optional> pkColumns, + Optional distanceType, + Optional filter); + + /** + * Plan a KNN vector search. + * + * @param query a flat float32 vector of length {@code vectorDim} + * @param k number of nearest neighbours to return + * @param nprobes number of IVF partitions to probe + * @param columns columns to project; pass {@code null} to return all columns plus {@code + * _distance} + * @param refineBaseTable when true, the base-table arm re-ranks candidates with exact distances + * (refine factor 1). Useful when the base table uses an approximate index (IVF-PQ). + * Auto-enabled whenever stale filtering is on (see {@code overfetchFactor}). + * @param overfetchFactor over-fetch multiple for sources with rows superseded by newer + * generations. Must be at least {@code 1.0}: + *

      + *
    • {@code == 1.0}: no over-fetch — an affected source fetches exactly {@code k} + * candidates and may return fewer than {@code k} live rows after stale rows are + * dropped. + *
    • {@code > 1.0}: with over-fetch — such a source fetches {@code ceil(k * + * overfetchFactor)} candidates so dropping stale rows is less likely to under-fill the + * result. + *
    + * + * @return an executable plan + */ + public ExecutionPlan planSearch( + Float4Vector query, + int k, + int nprobes, + List columns, + boolean refineBaseTable, + double overfetchFactor) { + Preconditions.checkNotNull(query, "query must not be null"); + Preconditions.checkArgument(k > 0, "k must be positive, got %s", k); + Preconditions.checkArgument(nprobes > 0, "nprobes must be positive, got %s", nprobes); + Preconditions.checkArgument( + Double.isFinite(overfetchFactor) && overfetchFactor >= 1.0, + "overfetchFactor must be finite and >= 1.0, got %s", + overfetchFactor); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument( + nativeVectorPlannerHandle != 0, "LsmVectorSearchPlanner is closed"); + try (ArrowArray array = ArrowArray.allocateNew(allocator); + ArrowSchema schema = ArrowSchema.allocateNew(allocator)) { + Data.exportVector(allocator, query, null, array, schema); + ExecutionPlan plan = + nativePlanSearch( + array.memoryAddress(), + schema.memoryAddress(), + k, + nprobes, + Optional.ofNullable(columns), + refineBaseTable, + overfetchFactor); + plan.allocator = allocator; + return plan; + } + } + } + + /** + * Plan a KNN vector search with stale filtering on and no over-fetch ({@code overfetchFactor = + * 1.0}). + * + * @param query a flat float32 vector of length {@code vectorDim} + * @param k number of nearest neighbours to return + * @param nprobes number of IVF partitions to probe + * @param columns columns to project; pass {@code null} to return all columns plus {@code + * _distance} + * @param refineBaseTable when true, the base-table arm re-ranks candidates with exact distances. + * @return an executable plan + */ + public ExecutionPlan planSearch( + Float4Vector query, int k, int nprobes, List columns, boolean refineBaseTable) { + return planSearch(query, k, nprobes, columns, refineBaseTable, 1.0); + } + + /** + * Plan a KNN vector search with stale filtering on (no refine, no over-fetch). + * + * @param query a flat float32 vector of length {@code vectorDim} + * @param k number of nearest neighbours to return + * @param nprobes number of IVF partitions to probe + * @param columns columns to project; pass {@code null} to return all columns plus {@code + * _distance} + * @return an executable plan + */ + public ExecutionPlan planSearch(Float4Vector query, int k, int nprobes, List columns) { + return planSearch(query, k, nprobes, columns, false, 1.0); + } + + /** Plan a KNN vector search with default {@code nprobes} of 20. */ + public ExecutionPlan planSearch(Float4Vector query, int k) { + return planSearch(query, k, 20, null, false, 1.0); + } + + private native ExecutionPlan nativePlanSearch( + long arrayAddress, + long schemaAddress, + int k, + int nprobes, + Optional> columns, + boolean refineBaseTable, + double overfetchFactor); + + /** + * Close the planner and release native resources. If the planner is already closed, invoking this + * method has no effect. + */ + @Override + public void close() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeVectorPlannerHandle != 0) { + releaseNativeVectorPlanner(nativeVectorPlannerHandle); + nativeVectorPlannerHandle = 0; + } + } + } + + private native void releaseNativeVectorPlanner(long handle); +} diff --git a/java/src/main/java/org/lance/memwal/MemTableStats.java b/java/src/main/java/org/lance/memwal/MemTableStats.java new file mode 100644 index 00000000000..55370ad101a --- /dev/null +++ b/java/src/main/java/org/lance/memwal/MemTableStats.java @@ -0,0 +1,182 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +import java.util.Optional; + +/** Statistics of the active MemTable held by a {@link ShardWriter}. */ +public class MemTableStats { + private final long rowCount; + private final long batchCount; + private final long estimatedSizeBytes; + private final long generation; + private final Optional maxBufferedBatchPosition; + private final long durableBatchCount; + private final long globalOffset; + private final Optional pendingWalStartBatchPosition; + private final Optional pendingWalEndBatchPosition; + private final long pendingWalBatchCount; + private final long pendingWalRowCount; + private final long pendingWalEstimatedBytes; + private final long indexBytes; + private final long graceBytes; + private final long retainedBytes; + + public MemTableStats( + long rowCount, + long batchCount, + long estimatedSizeBytes, + long generation, + Long maxBufferedBatchPosition, + long durableBatchCount, + long globalOffset, + Long pendingWalStartBatchPosition, + Long pendingWalEndBatchPosition, + long pendingWalBatchCount, + long pendingWalRowCount, + long pendingWalEstimatedBytes, + long indexBytes, + long graceBytes, + long retainedBytes) { + this.rowCount = rowCount; + this.batchCount = batchCount; + this.estimatedSizeBytes = estimatedSizeBytes; + this.generation = generation; + this.maxBufferedBatchPosition = Optional.ofNullable(maxBufferedBatchPosition); + this.durableBatchCount = durableBatchCount; + this.globalOffset = globalOffset; + this.pendingWalStartBatchPosition = Optional.ofNullable(pendingWalStartBatchPosition); + this.pendingWalEndBatchPosition = Optional.ofNullable(pendingWalEndBatchPosition); + this.pendingWalBatchCount = pendingWalBatchCount; + this.pendingWalRowCount = pendingWalRowCount; + this.pendingWalEstimatedBytes = pendingWalEstimatedBytes; + this.indexBytes = indexBytes; + this.graceBytes = graceBytes; + this.retainedBytes = retainedBytes; + } + + /** Number of rows currently buffered in the active MemTable. */ + public long rowCount() { + return rowCount; + } + + /** Number of record batches currently buffered in the active MemTable. */ + public long batchCount() { + return batchCount; + } + + /** + * Row-data bytes of the active MemTable: the unit the flush trigger measures. Its in-memory + * indexes are reported separately by {@link #indexBytes()} and are not included here. + */ + public long estimatedSizeBytes() { + return estimatedSizeBytes; + } + + /** Generation number of the active MemTable. */ + public long generation() { + return generation; + } + + /** Highest WAL batch position buffered into the MemTable, if any. */ + public Optional maxBufferedBatchPosition() { + return maxBufferedBatchPosition; + } + + /** + * Writer-global count of WAL-durable batches (exclusive; 0 means none). Compare against {@code + * globalOffset() + batchCount()} to see what this MemTable still owes the WAL. + */ + public long durableBatchCount() { + return durableBatchCount; + } + + /** Writer-global coordinate of this MemTable's batch 0. */ + public long globalOffset() { + return globalOffset; + } + + /** First WAL batch position pending flush, if any. */ + public Optional pendingWalStartBatchPosition() { + return pendingWalStartBatchPosition; + } + + /** Last WAL batch position pending flush, if any. */ + public Optional pendingWalEndBatchPosition() { + return pendingWalEndBatchPosition; + } + + /** Number of WAL batches pending flush. */ + public long pendingWalBatchCount() { + return pendingWalBatchCount; + } + + /** Number of rows in WAL batches pending flush. */ + public long pendingWalRowCount() { + return pendingWalRowCount; + } + + /** Estimated bytes of WAL batches pending flush. */ + public long pendingWalEstimatedBytes() { + return pendingWalEstimatedBytes; + } + + /** + * Bytes held by the active MemTable's in-memory indexes, its primary-key bloom filter included. + * Usually what explains a shard near its ceiling with few rows in it: an HNSW graph is + * pre-allocated in full from the configured row capacity. + */ + public long indexBytes() { + return indexBytes; + } + + /** + * Bytes held by generations that have flushed but are lingering out the configured + * frozen-MemTable grace. Resident, but no flush reclaims them — the sweeper does, on a timer. + */ + public long graceBytes() { + return graceBytes; + } + + /** + * Every resident byte this shard holds. The figure a process-wide budget meters, as opposed to + * what a flush can still give back. + */ + public long retainedBytes() { + return retainedBytes; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("rowCount", rowCount) + .add("batchCount", batchCount) + .add("estimatedSizeBytes", estimatedSizeBytes) + .add("generation", generation) + .add("maxBufferedBatchPosition", maxBufferedBatchPosition.orElse(null)) + .add("durableBatchCount", durableBatchCount) + .add("globalOffset", globalOffset) + .add("pendingWalStartBatchPosition", pendingWalStartBatchPosition.orElse(null)) + .add("pendingWalEndBatchPosition", pendingWalEndBatchPosition.orElse(null)) + .add("pendingWalBatchCount", pendingWalBatchCount) + .add("pendingWalRowCount", pendingWalRowCount) + .add("pendingWalEstimatedBytes", pendingWalEstimatedBytes) + .add("indexBytes", indexBytes) + .add("graceBytes", graceBytes) + .add("retainedBytes", retainedBytes) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/MemWalHnswParams.java b/java/src/main/java/org/lance/memwal/MemWalHnswParams.java new file mode 100644 index 00000000000..260f1add9a2 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/MemWalHnswParams.java @@ -0,0 +1,78 @@ +/* + * 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.memwal; + +import com.google.common.base.Preconditions; + +/** + * HNSW build parameters for a maintained vector index, used with {@link + * ShardWriterConfig#withHnswParams}. + * + *

    Defaults match the Lance defaults; override only the fields you need. {@code numEdges} is the + * graph degree (level 0 retains {@code 2 * numEdges}), equivalent to FAISS's {@code M}. + */ +public class MemWalHnswParams { + private final String indexName; + private int numEdges = 20; + private int efConstruction = 150; + private int maxLevel = 7; + + /** + * @param indexName name of the maintained vector index these parameters apply to. + */ + public MemWalHnswParams(String indexName) { + Preconditions.checkNotNull(indexName, "indexName must not be null"); + this.indexName = indexName; + } + + /** + * HNSW graph degree: max neighbors retained per node on upper levels (level 0 retains 2 * + * numEdges). + */ + public MemWalHnswParams withNumEdges(int numEdges) { + Preconditions.checkArgument(numEdges > 0, "numEdges must be greater than 0"); + this.numEdges = numEdges; + return this; + } + + /** Beam width (candidate list size) used while inserting nodes at build time. */ + public MemWalHnswParams withEfConstruction(int efConstruction) { + Preconditions.checkArgument(efConstruction > 0, "efConstruction must be greater than 0"); + this.efConstruction = efConstruction; + return this; + } + + /** Maximum number of graph levels. */ + public MemWalHnswParams withMaxLevel(int maxLevel) { + Preconditions.checkArgument(maxLevel > 0, "maxLevel must be greater than 0"); + this.maxLevel = maxLevel; + return this; + } + + public String indexName() { + return indexName; + } + + public int numEdges() { + return numEdges; + } + + public int efConstruction() { + return efConstruction; + } + + public int maxLevel() { + return maxLevel; + } +} diff --git a/java/src/main/java/org/lance/memwal/MemWalIndexDetails.java b/java/src/main/java/org/lance/memwal/MemWalIndexDetails.java new file mode 100644 index 00000000000..4e421aaaa13 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/MemWalIndexDetails.java @@ -0,0 +1,88 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** Details of the MemWAL index attached to a dataset. */ +public class MemWalIndexDetails { + private final long numShards; + private final List maintainedIndexes; + private final boolean maintainAllIndexes; + private final Map writerConfigDefaults; + private final List shardingSpecs; + + public MemWalIndexDetails( + long numShards, + List maintainedIndexes, + boolean maintainAllIndexes, + Map writerConfigDefaults, + List shardingSpecs) { + this.numShards = numShards; + this.maintainedIndexes = + maintainedIndexes == null ? Collections.emptyList() : maintainedIndexes; + this.maintainAllIndexes = maintainAllIndexes; + this.writerConfigDefaults = + writerConfigDefaults == null ? Collections.emptyMap() : writerConfigDefaults; + this.shardingSpecs = shardingSpecs == null ? Collections.emptyList() : shardingSpecs; + } + + /** Number of shards tracked by the MemWAL index. */ + public long numShards() { + return numShards; + } + + /** + * Names of the indexes maintained through the MemWAL. Empty when {@link #maintainAllIndexes()} is + * set, which names no index because it covers every one. + */ + public List maintainedIndexes() { + return maintainedIndexes; + } + + /** + * Whether every index the table has is maintained, including ones created later. + * + *

    This is what separates maintaining everything from maintaining nothing: both leave {@link + * #maintainedIndexes()} empty. + */ + public boolean maintainAllIndexes() { + return maintainAllIndexes; + } + + /** Default {@link ShardWriterConfig} values persisted in the MemWAL index. */ + public Map writerConfigDefaults() { + return writerConfigDefaults; + } + + /** The sharding specifications recorded in the MemWAL index. */ + public List shardingSpecs() { + return shardingSpecs; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("numShards", numShards) + .add("maintainedIndexes", maintainedIndexes) + .add("maintainAllIndexes", maintainAllIndexes) + .add("writerConfigDefaults", writerConfigDefaults) + .add("shardingSpecs", shardingSpecs) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/ShardSnapshot.java b/java/src/main/java/org/lance/memwal/ShardSnapshot.java new file mode 100644 index 00000000000..42e20bb5177 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardSnapshot.java @@ -0,0 +1,91 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +/** + * Snapshot of a MemWAL shard's state, used when constructing scanners and planners. + * + *

    The builder methods ({@link #withSpecId}, {@link #withCurrentGeneration}, {@link + * #withSsTable}) mutate this instance and return it for chaining. + */ +public class ShardSnapshot { + private final String shardId; + private int specId = 0; + private long currentGeneration = 0; + private final List sstables = new ArrayList<>(); + + /** + * @param shardId UUID string for the write shard + */ + public ShardSnapshot(String shardId) { + Preconditions.checkNotNull(shardId, "shardId must not be null"); + this.shardId = shardId; + } + + /** Set the {@link ShardingSpec} ID for this snapshot. */ + public ShardSnapshot withSpecId(int specId) { + this.specId = specId; + return this; + } + + /** Set the current (active) generation number. */ + public ShardSnapshot withCurrentGeneration(long currentGeneration) { + this.currentGeneration = currentGeneration; + return this; + } + + /** Add an SSTable with its storage path. */ + public ShardSnapshot withSsTable(long generation, String path) { + Preconditions.checkNotNull(path, "path must not be null"); + this.sstables.add(new SsTable(generation, path)); + return this; + } + + /** UUID string for this shard. */ + public String shardId() { + return shardId; + } + + /** The {@link ShardingSpec} ID for this snapshot. */ + public int specId() { + return specId; + } + + /** The current (active) generation number. */ + public long currentGeneration() { + return currentGeneration; + } + + /** The SSTables included in this snapshot. */ + public List sstables() { + return Collections.unmodifiableList(sstables); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("shardId", shardId) + .add("specId", specId) + .add("currentGeneration", currentGeneration) + .add("sstables", sstables) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/ShardWriter.java b/java/src/main/java/org/lance/memwal/ShardWriter.java new file mode 100644 index 00000000000..290b4c8f3c2 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardWriter.java @@ -0,0 +1,194 @@ +/* + * 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.memwal; + +import org.lance.Dataset; +import org.lance.JniLoader; +import org.lance.LockManager; + +import org.apache.arrow.c.ArrowArrayStream; +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 java.io.Closeable; +import java.util.Collections; +import java.util.List; + +/** + * Stateful writer for one MemWAL shard. + * + *

    Obtain an instance via {@link Dataset#memWalWriter}. The writer must be closed when no longer + * needed; using it inside a try-with-resources block is recommended: + * + *

    {@code
    + * try (ShardWriter writer = dataset.memWalWriter(shardId)) {
    + *   writer.put(reader);
    + *   writer.delete(keys);
    + * }
    + * }
    + * + *

    {@link #close()} flushes pending data and releases native resources. Statistics must be read + * before the writer is closed. + */ +public class ShardWriter implements Closeable { + static { + JniLoader.ensureLoaded(); + } + + private long nativeShardWriterHandle; + private BufferAllocator allocator; + private final LockManager lockManager = new LockManager(); + + private ShardWriter() {} + + /** + * Create a writer for {@code shardId} on the given dataset. + * + * @param dataset the dataset MemWAL has been initialized on + * @param shardId UUID string identifying the write shard + * @param config writer configuration; pass {@code null} to use the Lance default configuration + * @return a new ShardWriter + */ + public static ShardWriter create(Dataset dataset, String shardId, ShardWriterConfig config) { + Preconditions.checkNotNull(dataset, "dataset must not be null"); + Preconditions.checkNotNull(shardId, "shardId must not be null"); + try (LockManager.ReadLock readLock = dataset.acquireReadLock()) { + ShardWriter writer = createNative(dataset, shardId, config); + writer.allocator = dataset.allocator(); + return writer; + } + } + + static native ShardWriter createNative(Dataset dataset, String shardId, ShardWriterConfig config); + + /** UUID string for this writer's shard. */ + public String shardId() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + return nativeShardId(); + } + } + + private native String nativeShardId(); + + /** + * Write data to the MemWAL. + * + * @param reader the source data; consumed fully by this call + */ + public void put(ArrowReader reader) { + Preconditions.checkNotNull(reader, "reader must not be null"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + nativePut(stream.memoryAddress()); + } + } + } + + private native void nativePut(long streamAddress); + + /** + * Delete rows from the MemWAL by primary key. + * + *

    Each batch in {@code reader} must carry this shard's primary key column(s); other columns + * are ignored. Lance builds a tombstone row per key — the primary key plus {@code _tombstone = + * true} and null in every other column — and appends it like an ordinary write. The tombstone is + * the newest value for its key: it wins newest-per-PK resolution (suppressing the older real row) + * and is then dropped from query results. + * + *

    Only supported in memtable mode. Because a tombstone nulls every non-PK column, those + * columns must be nullable in the base schema; deleting against a schema with a non-nullable + * non-PK column errors. Deleting on a shard with no primary key columns also errors. + * + * @param reader the keys to delete; consumed fully by this call + */ + public void delete(ArrowReader reader) { + Preconditions.checkNotNull(reader, "reader must not be null"); + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + nativeDelete(stream.memoryAddress()); + } + } + } + + private native void nativeDelete(long streamAddress); + + /** Return a snapshot of cumulative write statistics. */ + public WriteStats stats() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + return nativeStats(); + } + } + + private native WriteStats nativeStats(); + + /** Return current statistics of the active MemTable. */ + public MemTableStats memtableStats() { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + return nativeMemtableStats(); + } + } + + private native MemTableStats nativeMemtableStats(); + + /** + * Create an LSM scanner that includes this writer's active MemTable. + * + *

    The scanner covers the base table, the given SSTables, and the current active MemTable, + * providing read-your-writes consistency. This writer's own shard is included automatically. + * + * @param shardSnapshots snapshots of other shards to include + * @return an LSM scanner + */ + public LsmScanner lsmScanner(List shardSnapshots) { + try (LockManager.ReadLock readLock = lockManager.acquireReadLock()) { + Preconditions.checkArgument(nativeShardWriterHandle != 0, "ShardWriter is closed"); + LsmScanner scanner = + nativeLsmScanner(shardSnapshots == null ? Collections.emptyList() : shardSnapshots); + scanner.allocator = allocator; + return scanner; + } + } + + /** Create an LSM scanner that includes this writer's active MemTable. */ + public LsmScanner lsmScanner() { + return lsmScanner(Collections.emptyList()); + } + + private native LsmScanner nativeLsmScanner(List shardSnapshots); + + /** + * Flush pending data and close the writer, releasing native resources. If the writer is already + * closed, invoking this method has no effect. + */ + @Override + public void close() { + try (LockManager.WriteLock writeLock = lockManager.acquireWriteLock()) { + if (nativeShardWriterHandle != 0) { + releaseNativeShardWriter(nativeShardWriterHandle); + nativeShardWriterHandle = 0; + } + } + } + + private native void releaseNativeShardWriter(long handle); +} diff --git a/java/src/main/java/org/lance/memwal/ShardWriterConfig.java b/java/src/main/java/org/lance/memwal/ShardWriterConfig.java new file mode 100644 index 00000000000..d6bf2aeb383 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardWriterConfig.java @@ -0,0 +1,185 @@ +/* + * 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.memwal; + +import com.google.common.base.Preconditions; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +/** + * Optional configuration for a {@link ShardWriter}. + * + *

    Every value is optional; unset values fall back to the Lance default writer configuration. Use + * the {@code with*} methods to build a configuration, then pass it to {@link + * org.lance.Dataset#memWalWriter} or {@link InitializeMemWalParams#withWriterConfigDefaults}. + */ +public class ShardWriterConfig { + private Optional durableWrite = Optional.empty(); + private Optional maxWalBufferSize = Optional.empty(); + private Optional maxWalFlushIntervalMs = Optional.empty(); + private Optional maxMemtableSize = Optional.empty(); + private Optional maxMemtableRows = Optional.empty(); + private Optional maxMemtableBatches = Optional.empty(); + private Optional maxUnflushedMemtableBytes = Optional.empty(); + private Optional manifestScanBatchSize = Optional.empty(); + private Optional backpressureLogIntervalMs = Optional.empty(); + private Optional statsLogIntervalMs = Optional.empty(); + private List hnswParams = Collections.emptyList(); + + /** Whether each {@code put} is durably persisted to the WAL before returning. */ + public ShardWriterConfig withDurableWrite(boolean durableWrite) { + this.durableWrite = Optional.of(durableWrite); + return this; + } + + /** Maximum size of the in-memory WAL buffer, in bytes. */ + public ShardWriterConfig withMaxWalBufferSize(long maxWalBufferSize) { + Preconditions.checkArgument( + maxWalBufferSize >= 0, "maxWalBufferSize must not be negative, got %s", maxWalBufferSize); + this.maxWalBufferSize = Optional.of(maxWalBufferSize); + return this; + } + + /** Maximum interval between WAL flushes, in milliseconds. */ + public ShardWriterConfig withMaxWalFlushIntervalMs(long maxWalFlushIntervalMs) { + Preconditions.checkArgument( + maxWalFlushIntervalMs >= 0, + "maxWalFlushIntervalMs must not be negative, got %s", + maxWalFlushIntervalMs); + this.maxWalFlushIntervalMs = Optional.of(maxWalFlushIntervalMs); + return this; + } + + /** Maximum size of a MemTable before it is flushed, in bytes. */ + public ShardWriterConfig withMaxMemtableSize(long maxMemtableSize) { + Preconditions.checkArgument( + maxMemtableSize >= 0, "maxMemtableSize must not be negative, got %s", maxMemtableSize); + this.maxMemtableSize = Optional.of(maxMemtableSize); + return this; + } + + /** Maximum number of rows in a MemTable before it is flushed. */ + public ShardWriterConfig withMaxMemtableRows(long maxMemtableRows) { + Preconditions.checkArgument( + maxMemtableRows >= 0, "maxMemtableRows must not be negative, got %s", maxMemtableRows); + this.maxMemtableRows = Optional.of(maxMemtableRows); + return this; + } + + /** Maximum number of record batches in a MemTable before it is flushed. */ + public ShardWriterConfig withMaxMemtableBatches(long maxMemtableBatches) { + Preconditions.checkArgument( + maxMemtableBatches >= 0, + "maxMemtableBatches must not be negative, got %s", + maxMemtableBatches); + this.maxMemtableBatches = Optional.of(maxMemtableBatches); + return this; + } + + /** Maximum unflushed MemTable bytes allowed before applying backpressure. */ + public ShardWriterConfig withMaxUnflushedMemtableBytes(long maxUnflushedMemtableBytes) { + Preconditions.checkArgument( + maxUnflushedMemtableBytes >= 0, + "maxUnflushedMemtableBytes must not be negative, got %s", + maxUnflushedMemtableBytes); + this.maxUnflushedMemtableBytes = Optional.of(maxUnflushedMemtableBytes); + return this; + } + + /** Batch size used when scanning the WAL manifest. */ + public ShardWriterConfig withManifestScanBatchSize(long manifestScanBatchSize) { + Preconditions.checkArgument( + manifestScanBatchSize >= 0, + "manifestScanBatchSize must not be negative, got %s", + manifestScanBatchSize); + this.manifestScanBatchSize = Optional.of(manifestScanBatchSize); + return this; + } + + /** Interval between backpressure log messages, in milliseconds. */ + public ShardWriterConfig withBackpressureLogIntervalMs(long backpressureLogIntervalMs) { + Preconditions.checkArgument( + backpressureLogIntervalMs >= 0, + "backpressureLogIntervalMs must not be negative, got %s", + backpressureLogIntervalMs); + this.backpressureLogIntervalMs = Optional.of(backpressureLogIntervalMs); + return this; + } + + /** Interval between statistics log messages, in milliseconds; {@code 0} disables logging. */ + public ShardWriterConfig withStatsLogIntervalMs(long statsLogIntervalMs) { + Preconditions.checkArgument( + statsLogIntervalMs >= 0, + "statsLogIntervalMs must not be negative, got %s", + statsLogIntervalMs); + this.statsLogIntervalMs = Optional.of(statsLogIntervalMs); + return this; + } + + /** + * Per-index HNSW build-parameter overrides for the MemTable this writer builds. Each {@link + * MemWalHnswParams#indexName} should reference a maintained vector index. + */ + public ShardWriterConfig withHnswParams(List hnswParams) { + Preconditions.checkNotNull(hnswParams, "hnswParams must not be null"); + this.hnswParams = hnswParams; + return this; + } + + public Optional durableWrite() { + return durableWrite; + } + + public Optional maxWalBufferSize() { + return maxWalBufferSize; + } + + public Optional maxWalFlushIntervalMs() { + return maxWalFlushIntervalMs; + } + + public Optional maxMemtableSize() { + return maxMemtableSize; + } + + public Optional maxMemtableRows() { + return maxMemtableRows; + } + + public Optional maxMemtableBatches() { + return maxMemtableBatches; + } + + public Optional maxUnflushedMemtableBytes() { + return maxUnflushedMemtableBytes; + } + + public Optional manifestScanBatchSize() { + return manifestScanBatchSize; + } + + public Optional backpressureLogIntervalMs() { + return backpressureLogIntervalMs; + } + + public Optional statsLogIntervalMs() { + return statsLogIntervalMs; + } + + public List hnswParams() { + return hnswParams; + } +} diff --git a/java/src/main/java/org/lance/memwal/ShardingEvaluator.java b/java/src/main/java/org/lance/memwal/ShardingEvaluator.java new file mode 100644 index 00000000000..50e6cdb1ea3 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardingEvaluator.java @@ -0,0 +1,124 @@ +/* + * 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.memwal; + +import org.lance.JniLoader; +import org.lance.schema.LanceField; +import org.lance.schema.LanceSchema; + +import org.apache.arrow.c.ArrowArray; +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.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +/** Evaluates MemWAL sharding specs against Arrow record batches. */ +public final class ShardingEvaluator { + static { + JniLoader.ensureLoaded(); + } + + private ShardingEvaluator() {} + + /** + * Evaluate {@code spec} against {@code root}. + * + * @param allocator allocator used for Arrow C data interface structs + * @param root input record batch + * @param spec MemWAL sharding spec to evaluate + * @param schema Lance table schema used to resolve spec source field IDs to input column names + * @return an Arrow reader containing one result batch with the derived sharding fields + */ + public static ArrowReader evaluate( + BufferAllocator allocator, VectorSchemaRoot root, ShardingSpec spec, LanceSchema schema) { + Preconditions.checkNotNull(allocator, "allocator must not be null"); + Preconditions.checkNotNull(root, "root must not be null"); + Preconditions.checkNotNull(spec, "spec must not be null"); + Preconditions.checkNotNull(schema, "schema must not be null"); + + try (ArrowSchema arrowSchema = ArrowSchema.allocateNew(allocator); + ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchema); + nativeEvaluate( + arrowArray.memoryAddress(), + arrowSchema.memoryAddress(), + spec, + sourceIdToColumnMap(schema), + stream.memoryAddress()); + return Data.importArrayStream(allocator, stream); + } + } + + /** Evaluate {@code spec} against {@code root} when the spec embeds enough column information. */ + public static ArrowReader evaluate( + BufferAllocator allocator, VectorSchemaRoot root, ShardingSpec spec) { + return evaluate(allocator, root, spec, Collections.emptyMap()); + } + + private static ArrowReader evaluate( + BufferAllocator allocator, + VectorSchemaRoot root, + ShardingSpec spec, + Map sourceIdToColumn) { + Preconditions.checkNotNull(allocator, "allocator must not be null"); + Preconditions.checkNotNull(root, "root must not be null"); + Preconditions.checkNotNull(spec, "spec must not be null"); + Preconditions.checkNotNull(sourceIdToColumn, "sourceIdToColumn must not be null"); + + try (ArrowSchema arrowSchema = ArrowSchema.allocateNew(allocator); + ArrowArray arrowArray = ArrowArray.allocateNew(allocator); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportVectorSchemaRoot(allocator, root, null, arrowArray, arrowSchema); + nativeEvaluate( + arrowArray.memoryAddress(), + arrowSchema.memoryAddress(), + spec, + sourceIdToColumn, + stream.memoryAddress()); + return Data.importArrayStream(allocator, stream); + } + } + + private static Map sourceIdToColumnMap(LanceSchema schema) { + Map result = new HashMap<>(); + for (LanceField field : schema.fields()) { + collectFieldIds(field, "", result); + } + return result; + } + + private static void collectFieldIds( + LanceField field, String prefix, Map result) { + String fullName = prefix.isEmpty() ? field.getName() : prefix + "." + field.getName(); + result.put(field.getId(), fullName); + for (LanceField child : field.getChildren()) { + collectFieldIds(child, fullName, result); + } + } + + private static native void nativeEvaluate( + long arrowArrayAddress, + long arrowSchemaAddress, + ShardingSpec spec, + Map sourceIdToColumn, + long streamAddress); +} diff --git a/java/src/main/java/org/lance/memwal/ShardingField.java b/java/src/main/java/org/lance/memwal/ShardingField.java new file mode 100644 index 00000000000..7bf2b7ec6d3 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardingField.java @@ -0,0 +1,96 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** One derived field used in a MemWAL shard partitioning specification. */ +public class ShardingField { + private final String fieldId; + private final List sourceIds; + private final Optional transform; + private final Optional expression; + private final String resultType; + private final Map parameters; + + /** + * @param fieldId identifier for the derived field + * @param sourceIds source field IDs used as inputs + * @param transform optional transform name applied to the source fields, may be {@code null} + * @param expression optional expression used to derive the field value, may be {@code null} + * @param resultType output type name for the derived field + * @param parameters extra transform parameters + */ + public ShardingField( + String fieldId, + List sourceIds, + String transform, + String expression, + String resultType, + Map parameters) { + this.fieldId = fieldId; + this.sourceIds = sourceIds == null ? Collections.emptyList() : sourceIds; + this.transform = Optional.ofNullable(transform); + this.expression = Optional.ofNullable(expression); + this.resultType = resultType; + this.parameters = parameters == null ? Collections.emptyMap() : parameters; + } + + /** Identifier for the derived field. */ + public String fieldId() { + return fieldId; + } + + /** Source field IDs used as inputs. */ + public List sourceIds() { + return sourceIds; + } + + /** Optional transform name applied to the source fields. */ + public Optional transform() { + return transform; + } + + /** Optional expression used to derive the field value. */ + public Optional expression() { + return expression; + } + + /** Output type name for the derived field. */ + public String resultType() { + return resultType; + } + + /** Extra transform parameters. */ + public Map parameters() { + return parameters; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("fieldId", fieldId) + .add("sourceIds", sourceIds) + .add("transform", transform.orElse(null)) + .add("expression", expression.orElse(null)) + .add("resultType", resultType) + .add("parameters", parameters) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/ShardingSpec.java b/java/src/main/java/org/lance/memwal/ShardingSpec.java new file mode 100644 index 00000000000..3465d18f780 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/ShardingSpec.java @@ -0,0 +1,45 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +import java.util.Collections; +import java.util.List; + +/** Partitioning specification for deriving MemWAL shard IDs. */ +public class ShardingSpec { + private final int specId; + private final List fields; + + public ShardingSpec(int specId, List fields) { + this.specId = specId; + this.fields = fields == null ? Collections.emptyList() : fields; + } + + /** Identifier for this shard specification. */ + public int specId() { + return specId; + } + + /** The derived fields that make up this specification. */ + public List fields() { + return fields; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("specId", specId).add("fields", fields).toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/SsTable.java b/java/src/main/java/org/lance/memwal/SsTable.java new file mode 100644 index 00000000000..55cb2e63f77 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/SsTable.java @@ -0,0 +1,45 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +/** An SSTable and the storage path of its Lance files. */ +public class SsTable { + private final long generation; + private final String path; + + public SsTable(long generation, String path) { + this.generation = generation; + this.path = path; + } + + /** The generation number of this SSTable. */ + public long generation() { + return generation; + } + + /** The storage path of the SSTable Lance files. */ + public String path() { + return path; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("generation", generation) + .add("path", path) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/memwal/WriteStats.java b/java/src/main/java/org/lance/memwal/WriteStats.java new file mode 100644 index 00000000000..07811695bc3 --- /dev/null +++ b/java/src/main/java/org/lance/memwal/WriteStats.java @@ -0,0 +1,101 @@ +/* + * 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.memwal; + +import com.google.common.base.MoreObjects; + +/** A snapshot of cumulative write statistics for a {@link ShardWriter}. */ +public class WriteStats { + private final long putCount; + private final long putTimeMs; + private final long walFlushCount; + private final long walFlushBytes; + private final long walFlushTimeMs; + private final long memtableFlushCount; + private final long memtableFlushRows; + private final long memtableFlushTimeMs; + + public WriteStats( + long putCount, + long putTimeMs, + long walFlushCount, + long walFlushBytes, + long walFlushTimeMs, + long memtableFlushCount, + long memtableFlushRows, + long memtableFlushTimeMs) { + this.putCount = putCount; + this.putTimeMs = putTimeMs; + this.walFlushCount = walFlushCount; + this.walFlushBytes = walFlushBytes; + this.walFlushTimeMs = walFlushTimeMs; + this.memtableFlushCount = memtableFlushCount; + this.memtableFlushRows = memtableFlushRows; + this.memtableFlushTimeMs = memtableFlushTimeMs; + } + + /** Number of {@link ShardWriter#put} calls. */ + public long putCount() { + return putCount; + } + + /** Total time spent in {@code put}, in milliseconds. */ + public long putTimeMs() { + return putTimeMs; + } + + /** Number of WAL flushes. */ + public long walFlushCount() { + return walFlushCount; + } + + /** Total bytes flushed to the WAL. */ + public long walFlushBytes() { + return walFlushBytes; + } + + /** Total time spent flushing the WAL, in milliseconds. */ + public long walFlushTimeMs() { + return walFlushTimeMs; + } + + /** Number of MemTable flushes. */ + public long memtableFlushCount() { + return memtableFlushCount; + } + + /** Total rows flushed from MemTables. */ + public long memtableFlushRows() { + return memtableFlushRows; + } + + /** Total time spent flushing MemTables, in milliseconds. */ + public long memtableFlushTimeMs() { + return memtableFlushTimeMs; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("putCount", putCount) + .add("putTimeMs", putTimeMs) + .add("walFlushCount", walFlushCount) + .add("walFlushBytes", walFlushBytes) + .add("walFlushTimeMs", walFlushTimeMs) + .add("memtableFlushCount", memtableFlushCount) + .add("memtableFlushRows", memtableFlushRows) + .add("memtableFlushTimeMs", memtableFlushTimeMs) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/merge/MergeInsertParams.java b/java/src/main/java/org/lance/merge/MergeInsertParams.java index a1759455248..4d8e10b8011 100644 --- a/java/src/main/java/org/lance/merge/MergeInsertParams.java +++ b/java/src/main/java/org/lance/merge/MergeInsertParams.java @@ -13,10 +13,14 @@ */ package org.lance.merge; +import org.lance.DataStorageVersion; +import org.lance.memwal.CompactedSsTable; + import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; import java.nio.ByteBuffer; +import java.util.Collections; import java.util.List; import java.util.Optional; @@ -35,6 +39,10 @@ public class MergeInsertParams { private int conflictRetries = 10; private long retryTimeoutMs = 30 * 1000; private boolean skipAutoCleanup = false; + private boolean useIndex = true; + private Optional dataStorageVersion = Optional.empty(); + private MergeWriteMode writeMode = MergeWriteMode.Auto; + private List compactedSstables = Collections.emptyList(); public MergeInsertParams(List on) { this.on = on; @@ -223,10 +231,83 @@ public MergeInsertParams withSkipAutoCleanup(boolean skipAutoCleanup) { return this; } + /** + * Controls whether to use indices for the merge operation. + * + *

    When set to false, forces a full table scan even if an index exists on the join key. This + * can be useful for benchmarking or when the optimizer chooses a suboptimal path. + * + *

    Default is true (use index if available). + * + * @param useIndex Whether to use indices for the merge join + * @return This MergeInsertParams instance + */ + public MergeInsertParams withUseIndex(boolean useIndex) { + this.useIndex = useIndex; + return this; + } + + /** + * Set the exact data storage version for files written by this operation. + * + *

    If omitted, the dataset's default write version is used without changing it. Release + * selectors are resolved by the engine. V1/V2 cross-family targets are rejected. + */ + public MergeInsertParams withDataStorageVersion(DataStorageVersion version) { + this.dataStorageVersion = Optional.of(Preconditions.checkNotNull(version)); + return this; + } + + /** + * Selects how the merged rows are written. + * + *

    Only has an effect on a partial-schema update (the source omits some dataset columns), where + * {@link MergeWriteMode#RewriteColumns} writes fewer bytes than {@link + * MergeWriteMode#RewriteRows} once the fraction of rows matched exceeds roughly the fraction of + * each row's bytes the source columns occupy: nearly always for a KB-scale update of a MB-per-row + * table, possibly never for a table of narrow columns. + * + *

    Default is {@link MergeWriteMode#Auto}: the write mode is chosen by the engine, typically + * rewriting whole rows. + * + * @param writeMode How the merged rows are written. + * @return This MergeInsertParams instance + */ + public MergeInsertParams withWriteMode(MergeWriteMode writeMode) { + Preconditions.checkNotNull(writeMode, "writeMode must not be null"); + this.writeMode = writeMode; + return this; + } + + /** + * Mark MemWAL SSTables as compacted into the base table. + * + *

    Use this when merge insert compacts MemWAL SSTables. It updates MemWAL compaction progress + * to prevent the same SSTables from being compacted again, in the same commit as the data. + * + *

    For multi-pass compaction, call this only on the final successful data-changing pass. + * Intermediate passes must not carry compaction progress. Lance cannot tell whether a caller has + * another pass planned, so it cannot enforce this: if a delete pass carried the marker and the + * process then died before the matching upsert, the recorded progress would claim rows were + * copied in that never were. + * + * @param sstables the SSTables being compacted + * @return This MergeInsertParams instance + */ + public MergeInsertParams markSstablesAsCompacted(List sstables) { + Preconditions.checkNotNull(sstables, "sstables must not be null"); + this.compactedSstables = sstables; + return this; + } + public List on() { return on; } + public List getCompactedSstables() { + return compactedSstables; + } + public WhenMatched whenMatched() { return whenMatched; } @@ -275,6 +356,23 @@ public boolean skipAutoCleanup() { return skipAutoCleanup; } + public boolean useIndex() { + return useIndex; + } + + /** Returns the version selector as its string value for the native layer. */ + public Optional getDataStorageVersion() { + return dataStorageVersion.map(DataStorageVersion::toRustString); + } + + public MergeWriteMode writeMode() { + return writeMode; + } + + public String writeModeValue() { + return writeMode.name(); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) @@ -292,9 +390,48 @@ public String toString() { .add("conflictRetries", conflictRetries) .add("retryTimeoutMs", retryTimeoutMs) .add("skipAutoCleanup", skipAutoCleanup) + .add("useIndex", useIndex) + .add("dataStorageVersion", dataStorageVersion.orElse(null)) + .add("writeMode", writeMode) .toString(); } + /** + * How a merge insert writes the merged rows. + * + *

    The caller chooses rather than the engine because which mode writes fewer bytes depends on + * the fraction of each fragment's rows the source matches, which is only known once the join has + * run. + */ + public enum MergeWriteMode { + /** + * Let the engine choose, typically rewriting whole rows. A partial-schema update whose join key + * carries a scalar index may instead patch columns on the indexed path that predates this enum. + */ + Auto, + + /** + * Delete the matched rows and write whole rows into new fragments. Cost scales with the number + * of matched rows. + * + *

    For a partial-schema update this gives up the scalar-index probe on the join key, because + * the indexed path only ever patches columns. + */ + RewriteRows, + + /** + * Attach new data files holding the source columns to the fragments that already hold the + * matched rows, and tombstone the old versions of those columns. The omitted columns are + * neither read nor written, but the replacement column file covers every row of each fragment + * it touches, so the bytes written barely fall as fewer rows match. + * + *

    Errors if the merge cannot be expressed this way: it must update matched rows only (no + * inserts, no matched deletes, no delete-by-source) with a source that omits at least one + * dataset column, carries at least one column besides the join key, and carries no blob column. + */ + RewriteColumns, + } + public enum WhenMatched { /** * The row is deleted from the target table and a new row is inserted based on the source table. diff --git a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java index 3ffe2b82f01..e9e8618c763 100644 --- a/java/src/main/java/org/lance/namespace/DirectoryNamespace.java +++ b/java/src/main/java/org/lance/namespace/DirectoryNamespace.java @@ -14,10 +14,98 @@ 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.CountTableRowsResponse; +import org.lance.namespace.model.CreateMaterializedViewRequest; +import org.lance.namespace.model.CreateMaterializedViewResponse; +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.NamespaceExistsResponse; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.QueryTableResponse; +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.TableExistsResponse; +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; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.arrow.memory.BufferAllocator; import java.io.Closeable; @@ -89,6 +177,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): @@ -142,7 +240,14 @@ public class DirectoryNamespace implements LanceNamespace, Closeable { JniLoader.ensureLoaded(); } - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = createObjectMapper(); + + private static ObjectMapper createObjectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + return mapper; + } private long nativeDirectoryNamespaceHandle; private BufferAllocator allocator; @@ -230,10 +335,11 @@ public DropNamespaceResponse dropNamespace(DropNamespaceRequest request) { } @Override - public void namespaceExists(NamespaceExistsRequest request) { + public NamespaceExistsResponse namespaceExists(NamespaceExistsRequest request) { ensureInitialized(); String requestJson = toJson(request); namespaceExistsNative(nativeDirectoryNamespaceHandle, requestJson); + return new NamespaceExistsResponse(); } @Override @@ -261,10 +367,11 @@ public RegisterTableResponse registerTable(RegisterTableRequest request) { } @Override - public void tableExists(TableExistsRequest request) { + public TableExistsResponse tableExists(TableExistsRequest request) { ensureInitialized(); String requestJson = toJson(request); tableExistsNative(nativeDirectoryNamespaceHandle, requestJson); + return new TableExistsResponse(); } @Override @@ -284,10 +391,11 @@ public DeregisterTableResponse deregisterTable(DeregisterTableRequest request) { } @Override - public Long countTableRows(CountTableRowsRequest request) { + public CountTableRowsResponse countTableRows(CountTableRowsRequest request) { ensureInitialized(); String requestJson = toJson(request); - return countTableRowsNative(nativeDirectoryNamespaceHandle, requestJson); + Long count = countTableRowsNative(nativeDirectoryNamespaceHandle, requestJson); + return new CountTableRowsResponse().count(count); } @Override @@ -300,19 +408,19 @@ public CreateTableResponse createTable(CreateTableRequest request, byte[] reques } @Override - public CreateEmptyTableResponse createEmptyTable(CreateEmptyTableRequest request) { + public DeclareTableResponse declareTable(DeclareTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - String responseJson = createEmptyTableNative(nativeDirectoryNamespaceHandle, requestJson); - return fromJson(responseJson, CreateEmptyTableResponse.class); + String responseJson = declareTableNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, DeclareTableResponse.class); } @Override - public DeclareTableResponse declareTable(DeclareTableRequest request) { + public RenameTableResponse renameTable(RenameTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - String responseJson = declareTableNative(nativeDirectoryNamespaceHandle, requestJson); - return fromJson(responseJson, DeclareTableResponse.class); + String responseJson = renameTableNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, RenameTableResponse.class); } @Override @@ -352,10 +460,11 @@ public DeleteFromTableResponse deleteFromTable(DeleteFromTableRequest request) { } @Override - public byte[] queryTable(QueryTableRequest request) { + public QueryTableResponse queryTable(QueryTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - return queryTableNative(nativeDirectoryNamespaceHandle, requestJson); + byte[] data = queryTableNative(nativeDirectoryNamespaceHandle, requestJson); + return new QueryTableResponse().data(data); } @Override @@ -400,6 +509,180 @@ public AlterTransactionResponse alterTransaction(AlterTransactionRequest request return fromJson(responseJson, AlterTransactionResponse.class); } + // Table version operations + + @Override + public ListTableVersionsResponse listTableVersions(ListTableVersionsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listTableVersionsNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, ListTableVersionsResponse.class); + } + + @Override + public CreateTableVersionResponse createTableVersion(CreateTableVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableVersionNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableVersionResponse.class); + } + + @Override + public DescribeTableVersionResponse describeTableVersion(DescribeTableVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = describeTableVersionNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, DescribeTableVersionResponse.class); + } + + @Override + public BatchDeleteTableVersionsResponse batchDeleteTableVersions( + BatchDeleteTableVersionsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = + batchDeleteTableVersionsNative(nativeDirectoryNamespaceHandle, requestJson); + 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 CreateMaterializedViewResponse createMaterializedView( + CreateMaterializedViewRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createMaterializedViewNative(nativeDirectoryNamespaceHandle, requestJson); + return fromJson(responseJson, CreateMaterializedViewResponse.class); + } + @Override public void close() { if (nativeDirectoryNamespaceHandle != 0) { @@ -408,6 +691,42 @@ public void close() { } } + /** + * Returns the native handle for this namespace. Used internally for passing to Dataset.open() for + * namespace commit handler support. + */ + public long getNativeHandle() { + ensureInitialized(); + 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( @@ -467,10 +786,10 @@ private native long createNativeWithProvider( private native String createTableNative(long handle, String requestJson, byte[] requestData); - private native String createEmptyTableNative(long handle, String requestJson); - 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( @@ -492,6 +811,52 @@ private native String mergeInsertIntoTableNative( private native String alterTransactionNative(long handle, String requestJson); + private native String listTableVersionsNative(long handle, String requestJson); + + private native String createTableVersionNative(long handle, String requestJson); + + private native String describeTableVersionNative(long handle, String requestJson); + + 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 String createMaterializedViewNative(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 840e9f3d690..9a14c50af72 100644 --- a/java/src/main/java/org/lance/namespace/RestNamespace.java +++ b/java/src/main/java/org/lance/namespace/RestNamespace.java @@ -14,10 +14,97 @@ 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.CountTableRowsResponse; +import org.lance.namespace.model.CreateMaterializedViewRequest; +import org.lance.namespace.model.CreateMaterializedViewResponse; +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.NamespaceExistsResponse; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.QueryTableResponse; +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.TableExistsResponse; +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; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.arrow.memory.BufferAllocator; import java.io.Closeable; @@ -42,6 +129,8 @@ *

  • tls.key_file (optional): Path to client key file *
  • tls.ssl_ca_cert (optional): Path to CA certificate file *
  • tls.assert_hostname (optional): "true" or "false" (default: true) + *
  • tls.reload_interval_seconds (optional): How often the TLS files above are checked for + * rotation (default: 300, "0" disables reloading) * * *

    Example usage: @@ -67,7 +156,8 @@ public class RestNamespace implements LanceNamespace, Closeable { JniLoader.ensureLoaded(); } - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final ObjectMapper OBJECT_MAPPER = + new ObjectMapper().registerModule(new JavaTimeModule()); private long nativeRestNamespaceHandle; private BufferAllocator allocator; @@ -159,10 +249,11 @@ public DropNamespaceResponse dropNamespace(DropNamespaceRequest request) { } @Override - public void namespaceExists(NamespaceExistsRequest request) { + public NamespaceExistsResponse namespaceExists(NamespaceExistsRequest request) { ensureInitialized(); String requestJson = toJson(request); namespaceExistsNative(nativeRestNamespaceHandle, requestJson); + return new NamespaceExistsResponse(); } @Override @@ -190,10 +281,11 @@ public RegisterTableResponse registerTable(RegisterTableRequest request) { } @Override - public void tableExists(TableExistsRequest request) { + public TableExistsResponse tableExists(TableExistsRequest request) { ensureInitialized(); String requestJson = toJson(request); tableExistsNative(nativeRestNamespaceHandle, requestJson); + return new TableExistsResponse(); } @Override @@ -213,10 +305,11 @@ public DeregisterTableResponse deregisterTable(DeregisterTableRequest request) { } @Override - public Long countTableRows(CountTableRowsRequest request) { + public CountTableRowsResponse countTableRows(CountTableRowsRequest request) { ensureInitialized(); String requestJson = toJson(request); - return countTableRowsNative(nativeRestNamespaceHandle, requestJson); + Long count = countTableRowsNative(nativeRestNamespaceHandle, requestJson); + return new CountTableRowsResponse().count(count); } @Override @@ -228,19 +321,19 @@ public CreateTableResponse createTable(CreateTableRequest request, byte[] reques } @Override - public CreateEmptyTableResponse createEmptyTable(CreateEmptyTableRequest request) { + public DeclareTableResponse declareTable(DeclareTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - String responseJson = createEmptyTableNative(nativeRestNamespaceHandle, requestJson); - return fromJson(responseJson, CreateEmptyTableResponse.class); + String responseJson = declareTableNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, DeclareTableResponse.class); } @Override - public DeclareTableResponse declareTable(DeclareTableRequest request) { + public RenameTableResponse renameTable(RenameTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - String responseJson = declareTableNative(nativeRestNamespaceHandle, requestJson); - return fromJson(responseJson, DeclareTableResponse.class); + String responseJson = renameTableNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, RenameTableResponse.class); } @Override @@ -280,10 +373,11 @@ public DeleteFromTableResponse deleteFromTable(DeleteFromTableRequest request) { } @Override - public byte[] queryTable(QueryTableRequest request) { + public QueryTableResponse queryTable(QueryTableRequest request) { ensureInitialized(); String requestJson = toJson(request); - return queryTableNative(nativeRestNamespaceHandle, requestJson); + byte[] data = queryTableNative(nativeRestNamespaceHandle, requestJson); + return new QueryTableResponse().data(data); } @Override @@ -327,6 +421,176 @@ public AlterTransactionResponse alterTransaction(AlterTransactionRequest request return fromJson(responseJson, AlterTransactionResponse.class); } + @Override + public ListTableVersionsResponse listTableVersions(ListTableVersionsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = listTableVersionsNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, ListTableVersionsResponse.class); + } + + @Override + public CreateTableVersionResponse createTableVersion(CreateTableVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createTableVersionNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, CreateTableVersionResponse.class); + } + + @Override + public DescribeTableVersionResponse describeTableVersion(DescribeTableVersionRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = describeTableVersionNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, DescribeTableVersionResponse.class); + } + + @Override + public BatchDeleteTableVersionsResponse batchDeleteTableVersions( + BatchDeleteTableVersionsRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = batchDeleteTableVersionsNative(nativeRestNamespaceHandle, requestJson); + 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 CreateMaterializedViewResponse createMaterializedView( + CreateMaterializedViewRequest request) { + ensureInitialized(); + String requestJson = toJson(request); + String responseJson = createMaterializedViewNative(nativeRestNamespaceHandle, requestJson); + return fromJson(responseJson, CreateMaterializedViewResponse.class); + } + @Override public void close() { if (nativeRestNamespaceHandle != 0) { @@ -335,6 +599,42 @@ public void close() { } } + /** + * Returns the native handle for this namespace. Used internally for passing to Dataset.open() for + * namespace commit handler support. + */ + public long getNativeHandle() { + ensureInitialized(); + 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."); @@ -393,10 +693,10 @@ private native long createNativeWithProvider( private native String createTableNative(long handle, String requestJson, byte[] requestData); - private native String createEmptyTableNative(long handle, String requestJson); - 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( @@ -418,6 +718,52 @@ private native String mergeInsertIntoTableNative( private native String alterTransactionNative(long handle, String requestJson); + private native String listTableVersionsNative(long handle, String requestJson); + + private native String createTableVersionNative(long handle, String requestJson); + + private native String describeTableVersionNative(long handle, String requestJson); + + 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 String createMaterializedViewNative(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/operation/Clone.java b/java/src/main/java/org/lance/operation/Clone.java new file mode 100644 index 00000000000..7a6e86c0346 --- /dev/null +++ b/java/src/main/java/org/lance/operation/Clone.java @@ -0,0 +1,136 @@ +/* + * 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.operation; + +import com.google.common.base.MoreObjects; + +import java.util.Objects; +import java.util.Optional; + +/** + * Clone a dataset version into a new dataset. + * + *

    Only a shallow clone can be committed directly. A deep clone must copy the source files first, + * so committing one throws {@link IllegalArgumentException}. + */ +public final class Clone implements Operation { + private final boolean shallow; + private final Optional refName; + private final long refVersion; + private final String refPath; + private final Optional branchName; + + private Clone( + boolean shallow, String refName, long refVersion, String refPath, String branchName) { + this.shallow = shallow; + this.refName = Optional.ofNullable(refName); + this.refVersion = refVersion; + this.refPath = Objects.requireNonNull(refPath, "refPath must not be null"); + this.branchName = Optional.ofNullable(branchName); + } + + public boolean isShallow() { + return shallow; + } + + public Optional getRefName() { + return refName; + } + + public long getRefVersion() { + return refVersion; + } + + public String getRefPath() { + return refPath; + } + + public Optional getBranchName() { + return branchName; + } + + @Override + public String name() { + return "Clone"; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("shallow", shallow) + .add("refName", refName) + .add("refVersion", refVersion) + .add("refPath", refPath) + .add("branchName", branchName) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + Clone clone = (Clone) o; + return shallow == clone.shallow + && refVersion == clone.refVersion + && Objects.equals(refName, clone.refName) + && Objects.equals(refPath, clone.refPath) + && Objects.equals(branchName, clone.branchName); + } + + @Override + public int hashCode() { + return Objects.hash(shallow, refName, refVersion, refPath, branchName); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private boolean shallow; + private String refName; + private long refVersion; + private String refPath; + private String branchName; + + public Builder shallow(boolean shallow) { + this.shallow = shallow; + return this; + } + + public Builder refName(String refName) { + this.refName = refName; + return this; + } + + public Builder refVersion(long refVersion) { + this.refVersion = refVersion; + return this; + } + + public Builder refPath(String refPath) { + this.refPath = refPath; + return this; + } + + public Builder branchName(String branchName) { + this.branchName = branchName; + return this; + } + + public Clone build() { + return new Clone(shallow, refName, refVersion, refPath, branchName); + } + } +} diff --git a/java/src/main/java/org/lance/operation/DataOverlay.java b/java/src/main/java/org/lance/operation/DataOverlay.java new file mode 100644 index 00000000000..57f5ed83f55 --- /dev/null +++ b/java/src/main/java/org/lance/operation/DataOverlay.java @@ -0,0 +1,219 @@ +/* + * 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.operation; + +import org.lance.fragment.DataFile; + +import com.google.common.base.MoreObjects; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Attach overlay files that update selected cells without rewriting fragment base files. */ +public final class DataOverlay implements Operation { + private final List groups; + + private DataOverlay(List groups) { + this.groups = Objects.requireNonNull(groups); + } + + public List getGroups() { + return groups; + } + + @Override + public String name() { + return "DataOverlay"; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("groups", groups).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DataOverlay that = (DataOverlay) o; + return Objects.equals(groups, that.groups); + } + + @Override + public int hashCode() { + return Objects.hash(groups); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private List groups = Collections.emptyList(); + + public Builder groups(List groups) { + this.groups = groups; + return this; + } + + public DataOverlay build() { + return new DataOverlay(groups); + } + } + + /** Overlay files appended to a single fragment, ordered oldest to newest. */ + public static final class DataOverlayGroup { + private final long fragmentId; + private final List overlays; + + public DataOverlayGroup(long fragmentId, List overlays) { + this.fragmentId = fragmentId; + this.overlays = Objects.requireNonNull(overlays); + } + + public long getFragmentId() { + return fragmentId; + } + + public List getOverlays() { + return overlays; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DataOverlayGroup that = (DataOverlayGroup) o; + return fragmentId == that.fragmentId && Objects.equals(overlays, that.overlays); + } + + @Override + public int hashCode() { + return Objects.hash(fragmentId, overlays); + } + } + + /** + * A data file together with the physical-row coverage it supplies. + * + *

    When this object is used to commit an overlay, Lance ignores the supplied committed version + * and stamps the overlay stored in the manifest with the new dataset version (including after a + * retry). The value remains part of the transaction model so transactions read from native code + * can represent it exactly. + */ + public static final class DataOverlayFile { + private final DataFile dataFile; + private final OverlayCoverage coverage; + private final long committedVersion; + + public DataOverlayFile(DataFile dataFile, OverlayCoverage coverage, long committedVersion) { + this.dataFile = Objects.requireNonNull(dataFile); + this.coverage = Objects.requireNonNull(coverage); + if (!coverage.isShared() && coverage.getBitmaps().size() != dataFile.getFields().length) { + throw new IllegalArgumentException( + String.format( + "per-field overlay coverage for %s has %d bitmaps but the data file has %d fields", + dataFile.getPath(), coverage.getBitmaps().size(), dataFile.getFields().length)); + } + this.committedVersion = committedVersion; + } + + /** Creates an overlay for commit; Lance stamps its manifest version during the commit. */ + public static DataOverlayFile forCommit(DataFile dataFile, OverlayCoverage coverage) { + return new DataOverlayFile(dataFile, coverage, 0); + } + + public DataFile getDataFile() { + return dataFile; + } + + public OverlayCoverage getCoverage() { + return coverage; + } + + public long getCommittedVersion() { + return committedVersion; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + DataOverlayFile that = (DataOverlayFile) o; + return committedVersion == that.committedVersion + && Objects.equals(dataFile, that.dataFile) + && Objects.equals(coverage, that.coverage); + } + + @Override + public int hashCode() { + return Objects.hash(dataFile, coverage, committedVersion); + } + } + + /** + * Portable RoaringBitmap bytes for shared or per-field overlay coverage. Per-field bitmaps are + * ordered to match the overlay data file's fields. + */ + public static final class OverlayCoverage { + private final boolean shared; + private final List bitmaps; + + private OverlayCoverage(boolean shared, List bitmaps) { + this.shared = shared; + this.bitmaps = Objects.requireNonNull(bitmaps); + if (shared && bitmaps.size() != 1) { + throw new IllegalArgumentException("shared overlay coverage requires exactly one bitmap"); + } + } + + public static OverlayCoverage shared(byte[] bitmap) { + return new OverlayCoverage(true, Collections.singletonList(bitmap)); + } + + public static OverlayCoverage perField(List bitmaps) { + return new OverlayCoverage(false, new ArrayList<>(bitmaps)); + } + + public boolean isShared() { + return shared; + } + + public List getBitmaps() { + return bitmaps; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + OverlayCoverage that = (OverlayCoverage) o; + if (shared != that.shared || bitmaps.size() != that.bitmaps.size()) return false; + for (int i = 0; i < bitmaps.size(); i++) { + if (!Arrays.equals(bitmaps.get(i), that.bitmaps.get(i))) return false; + } + return true; + } + + @Override + public int hashCode() { + int result = Boolean.hashCode(shared); + for (byte[] bitmap : bitmaps) result = 31 * result + Arrays.hashCode(bitmap); + return result; + } + } +} diff --git a/java/src/main/java/org/lance/operation/KeyExistenceFilter.java b/java/src/main/java/org/lance/operation/KeyExistenceFilter.java new file mode 100644 index 00000000000..e5a525c01e1 --- /dev/null +++ b/java/src/main/java/org/lance/operation/KeyExistenceFilter.java @@ -0,0 +1,125 @@ +/* + * 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.operation; + +import java.util.Arrays; +import java.util.Objects; + +/** Inserted-key conflict filter carried by an {@link Update} operation. */ +public final class KeyExistenceFilter { + public enum Type { + EXACT, + BLOOM + } + + private final int[] fieldIds; + private final Type type; + private final long[] exactKeyHashes; + private final byte[] bloomBitmap; + private final int bloomNumBits; + private final long bloomNumberOfItems; + private final double bloomProbability; + + private KeyExistenceFilter( + int[] fieldIds, + Type type, + long[] exactKeyHashes, + byte[] bloomBitmap, + int bloomNumBits, + long bloomNumberOfItems, + double bloomProbability) { + this.fieldIds = Objects.requireNonNull(fieldIds); + this.type = Objects.requireNonNull(type); + this.exactKeyHashes = exactKeyHashes; + this.bloomBitmap = bloomBitmap; + this.bloomNumBits = bloomNumBits; + this.bloomNumberOfItems = bloomNumberOfItems; + this.bloomProbability = bloomProbability; + } + + public static KeyExistenceFilter exact(int[] fieldIds, long[] keyHashes) { + long[] canonicalHashes = Objects.requireNonNull(keyHashes).clone(); + Arrays.sort(canonicalHashes); + return new KeyExistenceFilter(fieldIds, Type.EXACT, canonicalHashes, null, 0, 0, 0); + } + + public static KeyExistenceFilter bloom( + int[] fieldIds, byte[] bitmap, int numBits, long numberOfItems, double probability) { + Objects.requireNonNull(bitmap, "bitmap must not be null"); + if (numBits <= 0 || numberOfItems <= 0) { + throw new IllegalArgumentException("numBits and numberOfItems must be positive"); + } + if (numBits != bitmap.length * Byte.SIZE) { + throw new IllegalArgumentException( + String.format( + "numBits (%d) must equal the bitmap length in bits (%d)", + numBits, bitmap.length * Byte.SIZE)); + } + if (!Double.isFinite(probability) || probability <= 0.0 || probability >= 1.0) { + throw new IllegalArgumentException("probability must be finite and between 0 and 1"); + } + return new KeyExistenceFilter( + fieldIds, Type.BLOOM, null, bitmap, numBits, numberOfItems, probability); + } + + public int[] getFieldIds() { + return fieldIds; + } + + public Type getType() { + return type; + } + + public long[] getExactKeyHashes() { + return exactKeyHashes; + } + + public byte[] getBloomBitmap() { + return bloomBitmap; + } + + public int getBloomNumBits() { + return bloomNumBits; + } + + public long getBloomNumberOfItems() { + return bloomNumberOfItems; + } + + public double getBloomProbability() { + return bloomProbability; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + KeyExistenceFilter that = (KeyExistenceFilter) o; + return bloomNumBits == that.bloomNumBits + && bloomNumberOfItems == that.bloomNumberOfItems + && Double.compare(bloomProbability, that.bloomProbability) == 0 + && Arrays.equals(fieldIds, that.fieldIds) + && type == that.type + && Arrays.equals(exactKeyHashes, that.exactKeyHashes) + && Arrays.equals(bloomBitmap, that.bloomBitmap); + } + + @Override + public int hashCode() { + int result = Objects.hash(type, bloomNumBits, bloomNumberOfItems, bloomProbability); + result = 31 * result + Arrays.hashCode(fieldIds); + result = 31 * result + Arrays.hashCode(exactKeyHashes); + return 31 * result + Arrays.hashCode(bloomBitmap); + } +} diff --git a/java/src/main/java/org/lance/operation/Merge.java b/java/src/main/java/org/lance/operation/Merge.java index bd83657384b..07ecb880d90 100644 --- a/java/src/main/java/org/lance/operation/Merge.java +++ b/java/src/main/java/org/lance/operation/Merge.java @@ -27,16 +27,26 @@ */ public class Merge extends SchemaOperation { private final List fragments; + // True when this merge makes no nullability-affecting schema change: it + // introduces no field that data staged against an earlier schema could not + // safely omit. Without the assertion the merge conservatively conflicts with + // concurrent appends, whose fragments would omit new columns and read as null. + private final boolean preservesNullability; - protected Merge(List fragments, Schema schema) { + protected Merge(List fragments, Schema schema, boolean preservesNullability) { super(schema); this.fragments = fragments; + this.preservesNullability = preservesNullability; } public List fragments() { return fragments; } + public boolean preservesNullability() { + return preservesNullability; + } + @Override public String name() { return "Merge"; @@ -47,6 +57,7 @@ public String toString() { return MoreObjects.toStringHelper(this) .add("fragments", fragments) .add("schema", schema()) + .add("preservesNullability", preservesNullability) .toString(); } @@ -56,12 +67,13 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; Merge that = (Merge) o; - return Objects.equals(fragments, that.fragments); + return Objects.equals(fragments, that.fragments) + && preservesNullability == that.preservesNullability; } @Override public int hashCode() { - return Objects.hash(super.hashCode(), fragments); + return Objects.hash(super.hashCode(), fragments, preservesNullability); } public static Builder builder() { @@ -71,6 +83,8 @@ public static Builder builder() { public static class Builder { private List fragments; private Schema schema; + // No assertion by default, which conservatively conflicts. + private boolean preservesNullability = false; private Builder() {} @@ -84,8 +98,13 @@ public Builder schema(Schema schema) { return this; } + public Builder preservesNullability(boolean preservesNullability) { + this.preservesNullability = preservesNullability; + return this; + } + public Merge build() { - return new Merge(fragments, schema); + return new Merge(fragments, schema, preservesNullability); } } } diff --git a/java/src/main/java/org/lance/operation/Overwrite.java b/java/src/main/java/org/lance/operation/Overwrite.java index 5459e9b33af..b3e564ebb95 100644 --- a/java/src/main/java/org/lance/operation/Overwrite.java +++ b/java/src/main/java/org/lance/operation/Overwrite.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.BasePath; import org.lance.FragmentMetadata; import com.google.common.base.MoreObjects; @@ -31,12 +32,25 @@ public class Overwrite extends SchemaOperation { private final List fragments; private final Optional> configUpsertValues; + private final Optional> initialBases; protected Overwrite( List fragments, Schema schema, Map configUpsertValues) { + this(fragments, schema, configUpsertValues, null); + } + + protected Overwrite( + List fragments, + Schema schema, + Map configUpsertValues, + List initialBases) { super(schema); this.fragments = fragments; - this.configUpsertValues = Optional.ofNullable(configUpsertValues); + this.configUpsertValues = + configUpsertValues == null || configUpsertValues.isEmpty() + ? Optional.empty() + : Optional.of(configUpsertValues); + this.initialBases = Optional.ofNullable(initialBases); } public List fragments() { @@ -47,6 +61,18 @@ public Optional> configUpsertValues() { return configUpsertValues; } + /** + * Base paths used when creating a dataset; absent for a regular overwrite. An explicit empty list + * is retained because Rust distinguishes it from an absent value. + */ + public Optional> initialBases() { + return initialBases; + } + + public Optional> getInitialBases() { + return initialBases; + } + public static Builder builder() { return new Builder(); } @@ -62,6 +88,7 @@ public String toString() { .add("fragments", fragments) .add("schema", schema()) .add("configUpsertValues", configUpsertValues) + .add("initialBases", initialBases) .toString(); } @@ -71,13 +98,35 @@ public boolean equals(Object o) { if (o == null || getClass() != o.getClass()) return false; if (!super.equals(o)) return false; Overwrite that = (Overwrite) o; - return Objects.equals(fragments, that.fragments); + return Objects.equals(fragments, that.fragments) + && Objects.equals(configUpsertValues, that.configUpsertValues) + && initialBasesEqual(initialBases, that.initialBases); + } + + private static boolean initialBasesEqual( + Optional> left, Optional> right) { + if (left.isEmpty() || right.isEmpty()) return left.isEmpty() && right.isEmpty(); + List leftBases = left.orElseThrow(); + List rightBases = right.orElseThrow(); + if (leftBases.size() != rightBases.size()) return false; + for (int i = 0; i < leftBases.size(); i++) { + BasePath a = leftBases.get(i); + BasePath b = rightBases.get(i); + if (a.getId() != b.getId() + || a.isDatasetRoot() != b.isDatasetRoot() + || !Objects.equals(a.getName(), b.getName()) + || !Objects.equals(a.getPath(), b.getPath())) { + return false; + } + } + return true; } public static class Builder { private List fragments; private Schema schema; private Map configUpsertValues; + private List initialBases; private Builder() {} @@ -96,8 +145,13 @@ public Builder configUpsertValues(Map configUpsertValues) { return this; } + public Builder initialBases(List initialBases) { + this.initialBases = initialBases; + return this; + } + public Overwrite build() { - return new Overwrite(fragments, schema, configUpsertValues); + return new Overwrite(fragments, schema, configUpsertValues, initialBases); } } } diff --git a/java/src/main/java/org/lance/operation/Project.java b/java/src/main/java/org/lance/operation/Project.java index a4c718c4c4b..8c79431a7d1 100644 --- a/java/src/main/java/org/lance/operation/Project.java +++ b/java/src/main/java/org/lance/operation/Project.java @@ -16,14 +16,26 @@ import com.google.common.base.MoreObjects; import org.apache.arrow.vector.types.pojo.Schema; +import java.util.Objects; + /** * Project to a new schema. This Operation only changes the schema, not the data. Note: 1. For * removing columns. The data will be removed after compaction. 2. Project will modify column * positions, not ids(a.k.a. field id) */ public class Project extends SchemaOperation { - private Project(Schema schema) { + // True when this projection makes no nullability-affecting schema change, + // as a rename or a drop does not. Without the assertion the projection + // conservatively conflicts with concurrent value writes. + private final boolean preservesNullability; + + private Project(Schema schema, boolean preservesNullability) { super(schema); + this.preservesNullability = preservesNullability; + } + + public boolean preservesNullability() { + return preservesNullability; } @Override @@ -33,7 +45,23 @@ public String name() { @Override public String toString() { - return MoreObjects.toStringHelper(this).add("schema", schema()).toString(); + return MoreObjects.toStringHelper(this) + .add("schema", schema()) + .add("preservesNullability", preservesNullability) + .toString(); + } + + @Override + public boolean equals(Object o) { + if (!super.equals(o)) { + return false; + } + return preservesNullability == ((Project) o).preservesNullability; + } + + @Override + public int hashCode() { + return Objects.hash(schema(), preservesNullability); } public static Builder builder() { @@ -42,6 +70,7 @@ public static Builder builder() { public static class Builder { private Schema schema; + private boolean preservesNullability; public Builder() {} @@ -50,8 +79,13 @@ public Builder schema(Schema schema) { return this; } + public Builder preservesNullability(boolean preservesNullability) { + this.preservesNullability = preservesNullability; + return this; + } + public Project build() { - return new Project(schema); + return new Project(schema, preservesNullability); } } } diff --git a/java/src/main/java/org/lance/operation/RewrittenIndex.java b/java/src/main/java/org/lance/operation/RewrittenIndex.java index 2899991129a..7961bd8f78b 100644 --- a/java/src/main/java/org/lance/operation/RewrittenIndex.java +++ b/java/src/main/java/org/lance/operation/RewrittenIndex.java @@ -13,10 +13,14 @@ */ package org.lance.operation; +import org.lance.index.IndexFile; + import com.google.common.base.MoreObjects; import java.util.Arrays; +import java.util.List; import java.util.Objects; +import java.util.Optional; import java.util.UUID; /** @@ -30,13 +34,15 @@ public class RewrittenIndex { private final String newIndexDetailsTypeUrl; private final byte[] newIndexDetailsValue; private final int newIndexVersion; + private final Optional> newIndexFiles; private RewrittenIndex( UUID oldId, UUID newId, String newIndexDetailsTypeUrl, byte[] newIndexDetailsValue, - int newIndexVersion) { + int newIndexVersion, + List newIndexFiles) { if (oldId == null || newId == null || newIndexDetailsTypeUrl == null @@ -50,6 +56,10 @@ private RewrittenIndex( this.newIndexDetailsTypeUrl = newIndexDetailsTypeUrl; this.newIndexDetailsValue = newIndexDetailsValue; this.newIndexVersion = newIndexVersion; + this.newIndexFiles = + newIndexFiles == null || newIndexFiles.isEmpty() + ? Optional.empty() + : Optional.of(newIndexFiles); } public UUID getOldId() { @@ -72,6 +82,11 @@ public int getNewIndexVersion() { return newIndexVersion; } + /** Files in the rewritten index, or empty when older writers did not record them. */ + public Optional> getNewIndexFiles() { + return newIndexFiles; + } + @Override public boolean equals(Object o) { if (this == o) { @@ -85,7 +100,8 @@ public boolean equals(Object o) { && Objects.equals(newId, that.newId) && Objects.equals(newIndexDetailsTypeUrl, that.newIndexDetailsTypeUrl) && Arrays.equals(newIndexDetailsValue, that.newIndexDetailsValue) - && newIndexVersion == that.newIndexVersion; + && newIndexVersion == that.newIndexVersion + && Objects.equals(newIndexFiles, that.newIndexFiles); } @Override @@ -95,7 +111,8 @@ public int hashCode() { newId, newIndexDetailsTypeUrl, Arrays.hashCode(newIndexDetailsValue), - newIndexVersion); + newIndexVersion, + newIndexFiles); } @Override @@ -106,6 +123,7 @@ public String toString() { .add("newIndexDetailsTypeUrl", newIndexDetailsTypeUrl) .add("newIndexDetailsValue", newIndexDetailsValue) .add("newIndexVersion", newIndexVersion) + .add("newIndexFiles", newIndexFiles) .toString(); } @@ -119,6 +137,7 @@ public static class Builder { private String newIndexDetailsTypeUrl; private byte[] newIndexDetailsValue; private int newIndexVersion; + private List newIndexFiles; private Builder() {} @@ -147,9 +166,19 @@ public Builder newIndexVersion(int newIndexVersion) { return this; } + public Builder newIndexFiles(List newIndexFiles) { + this.newIndexFiles = newIndexFiles; + return this; + } + public RewrittenIndex build() { return new RewrittenIndex( - oldId, newId, newIndexDetailsTypeUrl, newIndexDetailsValue, newIndexVersion); + oldId, + newId, + newIndexDetailsTypeUrl, + newIndexDetailsValue, + newIndexVersion, + newIndexFiles); } } } diff --git a/java/src/main/java/org/lance/operation/SchemaOperation.java b/java/src/main/java/org/lance/operation/SchemaOperation.java index 3fbf6f502db..509492c852d 100644 --- a/java/src/main/java/org/lance/operation/SchemaOperation.java +++ b/java/src/main/java/org/lance/operation/SchemaOperation.java @@ -20,7 +20,17 @@ import java.util.Objects; -/** Schema related base operation. */ +/** + * Schema related base operation. + * + *

    Each field will be assigned a field id when transaction commits, in the following order: + * + *

      + *
    1. Parse from field metadata with key {@code lance:field_id}. + *
    2. Otherwise, set field id from txn read version dataset's schema field (with the same name). + *
    3. Otherwise, allocate based on the max field id of the dataset. + *
    + */ public abstract class SchemaOperation implements Operation { private final Schema schema; private ArrowSchema cSchema; diff --git a/java/src/main/java/org/lance/operation/Update.java b/java/src/main/java/org/lance/operation/Update.java index f886942b4b9..137c29049c5 100644 --- a/java/src/main/java/org/lance/operation/Update.java +++ b/java/src/main/java/org/lance/operation/Update.java @@ -14,12 +14,14 @@ package org.lance.operation; import org.lance.FragmentMetadata; +import org.lance.memwal.CompactedSsTable; import com.google.common.base.MoreObjects; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; @@ -30,6 +32,17 @@ public class Update implements Operation { private final long[] fieldsModified; private final long[] fieldsForPreservingFragBitmap; private final Optional updateMode; + private final List compactedSstables; + private final Optional insertedRowsFilter; + + /** + * Per-fragment matched row offsets serialized as portable RoaringBitmap bytes (little-endian, + * spec-compliant). Keys are fragment ids; values are the serialized bitmap for the local physical + * row offsets (0-based) within the fragment whose columns were rewritten. Empty map means the + * caller did not supply offsets and the partial last_updated refresh in build_manifest will not + * activate. + */ + private final Map updatedFragmentOffsets; private Update( List removedFragmentIds, @@ -37,13 +50,20 @@ private Update( List newFragments, long[] fieldsModified, long[] fieldsForPreservingFragBitmap, - Optional updateMode) { + Optional updateMode, + Map updatedFragmentOffsets, + List compactedSstables, + KeyExistenceFilter insertedRowsFilter) { this.removedFragmentIds = removedFragmentIds; this.updatedFragments = updatedFragments; this.newFragments = newFragments; this.fieldsModified = fieldsModified; this.fieldsForPreservingFragBitmap = fieldsForPreservingFragBitmap; + Objects.requireNonNull(updateMode); this.updateMode = updateMode; + this.updatedFragmentOffsets = updatedFragmentOffsets; + this.compactedSstables = Objects.requireNonNull(compactedSstables); + this.insertedRowsFilter = Optional.ofNullable(insertedRowsFilter); } public static Builder builder() { @@ -70,10 +90,37 @@ public long[] fieldsForPreservingFragBitmap() { return fieldsForPreservingFragBitmap; } + /** + * The update strategy. + * + *

    The Rust transaction model permits an absent mode, but the current transaction protobuf + * cannot persist that state distinctly from {@link UpdateMode#RewriteRows}. JNI therefore rejects + * commits with an empty mode instead of silently changing their semantics. + */ public Optional updateMode() { return updateMode; } + public Map updatedFragmentOffsets() { + return updatedFragmentOffsets; + } + + public List compactedSstables() { + return compactedSstables; + } + + public List getCompactedSstables() { + return compactedSstables; + } + + public Optional insertedRowsFilter() { + return insertedRowsFilter; + } + + public Optional getInsertedRowsFilter() { + return insertedRowsFilter; + } + @Override public String name() { return "Update"; @@ -87,6 +134,9 @@ public String toString() { .add("fieldsModified", fieldsModified) .add("fieldsForPreservingFragBitmap", fieldsForPreservingFragBitmap) .add("updateMode", updateMode) + .add("updatedFragmentOffsets", updatedFragmentOffsets) + .add("compactedSstables", compactedSstables) + .add("insertedRowsFilter", insertedRowsFilter) .toString(); } @@ -100,7 +150,53 @@ public boolean equals(Object o) { && Objects.equals(newFragments, that.newFragments) && Arrays.equals(fieldsModified, that.fieldsModified) && Arrays.equals(fieldsForPreservingFragBitmap, that.fieldsForPreservingFragBitmap) - && Objects.equals(updateMode, that.updateMode); + && Objects.equals(updateMode, that.updateMode) + && compactedSstablesEqual(compactedSstables, that.compactedSstables) + && Objects.equals(insertedRowsFilter, that.insertedRowsFilter) + && offsetMapsEqual(updatedFragmentOffsets, that.updatedFragmentOffsets); + } + + /** Deep-equality for {@code Map}: keys by value, arrays by content. */ + private static boolean offsetMapsEqual(Map a, Map b) { + if (a == b) return true; + if (a.size() != b.size()) return false; + for (Map.Entry entry : a.entrySet()) { + if (!Arrays.equals(entry.getValue(), b.get(entry.getKey()))) return false; + } + return true; + } + + private static boolean compactedSstablesEqual( + List left, List right) { + if (left.size() != right.size()) return false; + for (int i = 0; i < left.size(); i++) { + CompactedSsTable a = left.get(i); + CompactedSsTable b = right.get(i); + if (a.getGeneration() != b.getGeneration() + || !Objects.equals(a.getShardId(), b.getShardId())) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int h = + Objects.hash( + removedFragmentIds, updatedFragments, newFragments, updateMode, insertedRowsFilter); + for (CompactedSsTable sstable : compactedSstables) { + h = 31 * h + Objects.hash(sstable.getShardId(), sstable.getGeneration()); + } + h = 31 * h + Arrays.hashCode(fieldsModified); + h = 31 * h + Arrays.hashCode(fieldsForPreservingFragBitmap); + // Sum entry hashes (XOR key ^ array-content hash) so result is insertion-order-independent. + int mapHash = 0; + for (Map.Entry entry : updatedFragmentOffsets.entrySet()) { + mapHash += Long.hashCode(entry.getKey()) ^ Arrays.hashCode(entry.getValue()); + } + h = 31 * h + mapHash; + return h; } public enum UpdateMode { @@ -115,6 +211,9 @@ public static class Builder { private long[] fieldsModified = new long[0]; private long[] fieldsForPreservingFragBitmap = new long[0]; private Optional updateMode = Optional.empty(); + private Map updatedFragmentOffsets = Collections.emptyMap(); + private List compactedSstables = Collections.emptyList(); + private KeyExistenceFilter insertedRowsFilter; private Builder() {} @@ -143,11 +242,39 @@ public Builder fieldsForPreservingFragBitmap(long[] fieldsForPreservingFragBitma return this; } + /** + * Set the update strategy. A mode must be present when the operation is committed because the + * transaction format cannot persist an absent mode losslessly. + */ public Builder updateMode(Optional updateMode) { this.updateMode = updateMode; return this; } + /** + * Set the per-fragment matched row offsets for a RewriteColumns commit. + * + *

    Keys are fragment ids; values are portable RoaringBitmap bytes (little-endian, + * spec-compliant serialization) encoding the local physical row offsets (0-based) within the + * fragment that matched the update_columns hash join. When non-empty and update mode is + * RewriteColumns with stable row IDs enabled, build_manifest will call the partial last_updated + * refresh for those offsets only. + */ + public Builder updatedFragmentOffsets(Map updatedFragmentOffsets) { + this.updatedFragmentOffsets = updatedFragmentOffsets; + return this; + } + + public Builder compactedSstables(List compactedSstables) { + this.compactedSstables = compactedSstables; + return this; + } + + public Builder insertedRowsFilter(KeyExistenceFilter insertedRowsFilter) { + this.insertedRowsFilter = insertedRowsFilter; + return this; + } + public Update build() { return new Update( removedFragmentIds, @@ -155,7 +282,10 @@ public Update build() { newFragments, fieldsModified, fieldsForPreservingFragBitmap, - updateMode); + updateMode, + updatedFragmentOffsets, + compactedSstables, + insertedRowsFilter); } } } diff --git a/java/src/main/java/org/lance/operation/UpdateBases.java b/java/src/main/java/org/lance/operation/UpdateBases.java new file mode 100644 index 00000000000..e0bff689bd1 --- /dev/null +++ b/java/src/main/java/org/lance/operation/UpdateBases.java @@ -0,0 +1,92 @@ +/* + * 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.operation; + +import org.lance.BasePath; + +import com.google.common.base.MoreObjects; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Add base paths to the dataset manifest. */ +public final class UpdateBases implements Operation { + private final List newBases; + + private UpdateBases(List newBases) { + this.newBases = Objects.requireNonNull(newBases); + } + + public List getNewBases() { + return newBases; + } + + @Override + public String name() { + return "UpdateBases"; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("newBases", newBases).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UpdateBases that = (UpdateBases) o; + if (newBases.size() != that.newBases.size()) return false; + for (int i = 0; i < newBases.size(); i++) { + if (!basePathEquals(newBases.get(i), that.newBases.get(i))) return false; + } + return true; + } + + @Override + public int hashCode() { + int result = 1; + for (BasePath base : newBases) { + result = + 31 * result + + Objects.hash(base.getId(), base.getName(), base.getPath(), base.isDatasetRoot()); + } + return result; + } + + private static boolean basePathEquals(BasePath left, BasePath right) { + return left.getId() == right.getId() + && left.isDatasetRoot() == right.isDatasetRoot() + && Objects.equals(left.getName(), right.getName()) + && Objects.equals(left.getPath(), right.getPath()); + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private List newBases = Collections.emptyList(); + + public Builder newBases(List newBases) { + this.newBases = newBases; + return this; + } + + public UpdateBases build() { + return new UpdateBases(newBases); + } + } +} diff --git a/java/src/main/java/org/lance/operation/UpdateMemWalState.java b/java/src/main/java/org/lance/operation/UpdateMemWalState.java new file mode 100644 index 00000000000..b0d6a5f8122 --- /dev/null +++ b/java/src/main/java/org/lance/operation/UpdateMemWalState.java @@ -0,0 +1,88 @@ +/* + * 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.operation; + +import org.lance.memwal.CompactedSsTable; + +import com.google.common.base.MoreObjects; + +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** Update SSTable compaction progress in the MemWAL index. */ +public final class UpdateMemWalState implements Operation { + private final List compactedSstables; + + private UpdateMemWalState(List compactedSstables) { + this.compactedSstables = Objects.requireNonNull(compactedSstables); + } + + public List getCompactedSstables() { + return compactedSstables; + } + + @Override + public String name() { + return "UpdateMemWalState"; + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("compactedSstables", compactedSstables).toString(); + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + UpdateMemWalState that = (UpdateMemWalState) o; + if (compactedSstables.size() != that.compactedSstables.size()) return false; + for (int i = 0; i < compactedSstables.size(); i++) { + CompactedSsTable left = compactedSstables.get(i); + CompactedSsTable right = that.compactedSstables.get(i); + if (left.getGeneration() != right.getGeneration() + || !Objects.equals(left.getShardId(), right.getShardId())) { + return false; + } + } + return true; + } + + @Override + public int hashCode() { + int result = 1; + for (CompactedSsTable sstable : compactedSstables) { + result = 31 * result + Objects.hash(sstable.getShardId(), sstable.getGeneration()); + } + return result; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + private List compactedSstables = Collections.emptyList(); + + public Builder compactedSstables(List compactedSstables) { + this.compactedSstables = compactedSstables; + return this; + } + + public UpdateMemWalState build() { + return new UpdateMemWalState(compactedSstables); + } + } +} diff --git a/java/src/main/java/org/lance/otel/LanceMetrics.java b/java/src/main/java/org/lance/otel/LanceMetrics.java new file mode 100644 index 00000000000..07b8595ff62 --- /dev/null +++ b/java/src/main/java/org/lance/otel/LanceMetrics.java @@ -0,0 +1,283 @@ +/* + * 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.otel; + +import org.lance.JniLoader; + +import io.opentelemetry.api.GlobalOpenTelemetry; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.api.common.AttributesBuilder; +import io.opentelemetry.api.metrics.BatchCallback; +import io.opentelemetry.api.metrics.Meter; +import io.opentelemetry.api.metrics.MeterProvider; +import io.opentelemetry.api.metrics.ObservableDoubleMeasurement; +import io.opentelemetry.api.metrics.ObservableMeasurement; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.logging.Logger; + +/** Bridges Lance's internal Rust metrics into OpenTelemetry observable instruments. */ +public final class LanceMetrics { + private static final Logger LOGGER = Logger.getLogger(LanceMetrics.class.getName()); + private static final String METER_NAME = "lance"; + private static final List INSTRUMENTS = new ArrayList<>(); + private static boolean instrumented; + private static MeterProvider instrumentedProvider; + + private LanceMetrics() {} + + /** + * Register Lance metrics on the global OpenTelemetry {@link MeterProvider}. + * + * @return true if the recorder is installed and instruments are registered, false if a different + * Rust metrics recorder is already installed in this process + */ + public static synchronized boolean instrument() { + return instrument(GlobalOpenTelemetry.get().getMeterProvider()); + } + + /** + * Register Lance metrics as OpenTelemetry observable instruments. + * + *

    This installs a process-global Rust metrics recorder. If a different Rust metrics recorder + * is already installed, this returns false and does not create instruments. Repeated successful + * calls with the same provider are safe and return true without creating duplicate instruments. + * Calls with a different provider close the existing instruments and register new callbacks on + * the supplied provider. + * + * @param meterProvider the OpenTelemetry meter provider that owns the instruments + * @return true if the recorder is installed and instruments are registered, false otherwise + */ + public static synchronized boolean instrument(MeterProvider meterProvider) { + Objects.requireNonNull(meterProvider, "meterProvider"); + JniLoader.ensureLoaded(); + + if (!registerLanceMetricsRecorderNative()) { + LOGGER.warning( + "Could not install the Lance metrics recorder: another Rust metrics recorder is already" + + " installed in this process. Lance metrics will not be exported via" + + " OpenTelemetry."); + return false; + } + + if (instrumented) { + if (instrumentedProvider == meterProvider) { + return true; + } + closeInstruments(); + } + + Meter meter = meterProvider.meterBuilder(METER_NAME).build(); + Map registeredMetrics = new HashMap<>(); + List measurements = new ArrayList<>(); + + for (MetricDescription desc : supportedMetrics(catalog())) { + String unit = desc.getUnit() == null ? "" : desc.getUnit(); + String description = desc.getDescription(); + switch (desc.getKind()) { + case "counter": + ObservableDoubleMeasurement counter = + meter + .counterBuilder(desc.getName()) + .ofDoubles() + .setUnit(unit) + .setDescription(description) + .buildObserver(); + registeredMetrics.put(desc.getName(), RegisteredMetric.scalar(counter)); + measurements.add(counter); + break; + case "gauge": + ObservableDoubleMeasurement gauge = + meter + .gaugeBuilder(desc.getName()) + .setUnit(unit) + .setDescription(description) + .buildObserver(); + registeredMetrics.put(desc.getName(), RegisteredMetric.scalar(gauge)); + measurements.add(gauge); + break; + case "histogram": + ObservableDoubleMeasurement buckets = + meter + .counterBuilder(desc.getName() + "_bucket") + .ofDoubles() + .setDescription(description + " (cumulative buckets)") + .buildObserver(); + ObservableDoubleMeasurement count = + meter + .counterBuilder(desc.getName() + "_count") + .ofDoubles() + .setDescription(description + " (count)") + .buildObserver(); + ObservableDoubleMeasurement sum = + meter + .counterBuilder(desc.getName() + "_sum") + .ofDoubles() + .setUnit(unit) + .setDescription(description + " (sum)") + .buildObserver(); + registeredMetrics.put(desc.getName(), RegisteredMetric.histogram(buckets, count, sum)); + measurements.add(buckets); + measurements.add(count); + measurements.add(sum); + break; + default: + LOGGER.warning( + "Skipping Lance metric " + desc.getName() + " with unknown kind: " + desc.getKind()); + continue; + } + } + + if (!measurements.isEmpty()) { + ObservableMeasurement first = measurements.get(0); + ObservableMeasurement[] rest = + measurements.subList(1, measurements.size()).toArray(new ObservableMeasurement[0]); + BatchCallback callback = + meter.batchCallback(() -> recordSnapshot(registeredMetrics), first, rest); + INSTRUMENTS.add(callback); + } + + instrumented = true; + instrumentedProvider = meterProvider; + return true; + } + + /** Close registered OpenTelemetry callbacks. Rust metric state remains process-global. */ + public static synchronized void close() { + closeInstruments(); + } + + /** Return the catalog of Lance metrics described by the native recorder. */ + public static List catalog() { + JniLoader.ensureLoaded(); + return Collections.unmodifiableList(lanceMetricsCatalogNative()); + } + + /** Return a point-in-time snapshot of all recorded Lance metric series. */ + public static List snapshot() { + JniLoader.ensureLoaded(); + return Collections.unmodifiableList(snapshotLanceMetricsNative()); + } + + static List supportedMetrics(List catalog) { + List supported = new ArrayList<>(); + for (MetricDescription desc : catalog) { + switch (desc.getKind()) { + case "counter": + case "gauge": + case "histogram": + supported.add(desc); + break; + default: + LOGGER.warning( + "Skipping Lance metric " + desc.getName() + " with unknown kind: " + desc.getKind()); + } + } + return supported; + } + + private static void recordSnapshot(Map registeredMetrics) { + for (MetricPoint point : snapshot()) { + RegisteredMetric metric = registeredMetrics.get(point.getName()); + if (metric == null) { + continue; + } + metric.record(point); + } + } + + private static void closeInstruments() { + for (AutoCloseable instrument : INSTRUMENTS) { + try { + instrument.close(); + } catch (Exception e) { + LOGGER.warning("Failed to close Lance OpenTelemetry instrument: " + e.getMessage()); + } + } + INSTRUMENTS.clear(); + instrumented = false; + instrumentedProvider = null; + } + + private static AttributesBuilder attributesBuilder(Map values) { + AttributesBuilder builder = Attributes.builder(); + for (Map.Entry entry : values.entrySet()) { + builder.put(entry.getKey(), entry.getValue()); + } + return builder; + } + + private static Attributes attributes(Map values) { + return attributesBuilder(values).build(); + } + + private static final class RegisteredMetric { + private final ObservableDoubleMeasurement scalar; + private final ObservableDoubleMeasurement buckets; + private final ObservableDoubleMeasurement count; + private final ObservableDoubleMeasurement sum; + + private RegisteredMetric( + ObservableDoubleMeasurement scalar, + ObservableDoubleMeasurement buckets, + ObservableDoubleMeasurement count, + ObservableDoubleMeasurement sum) { + this.scalar = scalar; + this.buckets = buckets; + this.count = count; + this.sum = sum; + } + + private static RegisteredMetric scalar(ObservableDoubleMeasurement measurement) { + return new RegisteredMetric(measurement, null, null, null); + } + + private static RegisteredMetric histogram( + ObservableDoubleMeasurement buckets, + ObservableDoubleMeasurement count, + ObservableDoubleMeasurement sum) { + return new RegisteredMetric(null, buckets, count, sum); + } + + private void record(MetricPoint point) { + if (scalar != null && point.getValue() != null) { + scalar.record(point.getValue(), attributes(point.getAttributes())); + } + if (buckets != null && point.getBuckets() != null) { + for (MetricBucket bucket : point.getBuckets()) { + AttributesBuilder attributes = attributesBuilder(point.getAttributes()); + attributes.put("le", bucket.getLe()); + buckets.record(bucket.getCumulativeCount(), attributes.build()); + } + } + if (count != null && point.getCount() != null) { + count.record(point.getCount().doubleValue(), attributes(point.getAttributes())); + } + if (sum != null && point.getSum() != null) { + sum.record(point.getSum(), attributes(point.getAttributes())); + } + } + } + + private static native boolean registerLanceMetricsRecorderNative(); + + private static native List lanceMetricsCatalogNative(); + + private static native List snapshotLanceMetricsNative(); +} diff --git a/java/src/main/java/org/lance/otel/MetricBucket.java b/java/src/main/java/org/lance/otel/MetricBucket.java new file mode 100644 index 00000000000..4efa1058a30 --- /dev/null +++ b/java/src/main/java/org/lance/otel/MetricBucket.java @@ -0,0 +1,33 @@ +/* + * 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.otel; + +/** A cumulative histogram bucket exported with Prometheus-style {@code le} semantics. */ +public final class MetricBucket { + private final String le; + private final long cumulativeCount; + + public MetricBucket(String le, long cumulativeCount) { + this.le = le; + this.cumulativeCount = cumulativeCount; + } + + public String getLe() { + return le; + } + + public long getCumulativeCount() { + return cumulativeCount; + } +} diff --git a/java/src/main/java/org/lance/otel/MetricDescription.java b/java/src/main/java/org/lance/otel/MetricDescription.java new file mode 100644 index 00000000000..a350e2be963 --- /dev/null +++ b/java/src/main/java/org/lance/otel/MetricDescription.java @@ -0,0 +1,45 @@ +/* + * 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.otel; + +/** Metadata for one Lance metric available through the OpenTelemetry bridge. */ +public final class MetricDescription { + private final String name; + private final String kind; + private final String unit; + private final String description; + + public MetricDescription(String name, String kind, String unit, String description) { + this.name = name; + this.kind = kind; + this.unit = unit; + this.description = description; + } + + public String getName() { + return name; + } + + public String getKind() { + return kind; + } + + public String getUnit() { + return unit; + } + + public String getDescription() { + return description; + } +} diff --git a/java/src/main/java/org/lance/otel/MetricPoint.java b/java/src/main/java/org/lance/otel/MetricPoint.java new file mode 100644 index 00000000000..f9a44b95f20 --- /dev/null +++ b/java/src/main/java/org/lance/otel/MetricPoint.java @@ -0,0 +1,75 @@ +/* + * 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.otel; + +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** A cumulative Lance metric data point captured by the native metrics recorder. */ +public final class MetricPoint { + private final String name; + private final String kind; + private final Map attributes; + private final Double value; + private final List buckets; + private final Long count; + private final Double sum; + + public MetricPoint( + String name, + String kind, + Map attributes, + Double value, + List buckets, + Long count, + Double sum) { + this.name = name; + this.kind = kind; + this.attributes = + attributes == null ? Collections.emptyMap() : Collections.unmodifiableMap(attributes); + this.value = value; + this.buckets = buckets == null ? null : Collections.unmodifiableList(buckets); + this.count = count; + this.sum = sum; + } + + public String getName() { + return name; + } + + public String getKind() { + return kind; + } + + public Map getAttributes() { + return attributes; + } + + public Double getValue() { + return value; + } + + public List getBuckets() { + return buckets; + } + + public Long getCount() { + return count; + } + + public Double getSum() { + return sum; + } +} diff --git a/java/src/main/java/org/lance/schema/LanceField.java b/java/src/main/java/org/lance/schema/LanceField.java index f1d3185b68e..4dbb3a0ea38 100644 --- a/java/src/main/java/org/lance/schema/LanceField.java +++ b/java/src/main/java/org/lance/schema/LanceField.java @@ -25,6 +25,7 @@ import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -43,6 +44,8 @@ public class LanceField { private final List children; private final boolean isUnenforcedPrimaryKey; private final int unenforcedPrimaryKeyPosition; + private final boolean isUnenforcedClusteringKey; + private final int unenforcedClusteringKeyPosition; LanceField( int id, @@ -55,7 +58,9 @@ public class LanceField { Map metadata, List children, boolean isUnenforcedPrimaryKey, - int unenforcedPrimaryKeyPosition) { + int unenforcedPrimaryKeyPosition, + boolean isUnenforcedClusteringKey, + int unenforcedClusteringKeyPosition) { this.id = id; this.parentId = parentId; this.name = name; @@ -67,6 +72,8 @@ public class LanceField { this.children = children; this.isUnenforcedPrimaryKey = isUnenforcedPrimaryKey; this.unenforcedPrimaryKeyPosition = unenforcedPrimaryKeyPosition; + this.isUnenforcedClusteringKey = isUnenforcedClusteringKey; + this.unenforcedClusteringKeyPosition = unenforcedClusteringKeyPosition; } public int getId() { @@ -121,6 +128,23 @@ public OptionalInt getUnenforcedPrimaryKeyPosition() { return OptionalInt.empty(); } + /** Whether this field is part of the clustering key. */ + public boolean isUnenforcedClusteringKey() { + return isUnenforcedClusteringKey; + } + + /** + * Get the position of this field within a composite clustering key. + * + * @return the 1-based position if explicitly set, or empty if using schema field id ordering + */ + public OptionalInt getUnenforcedClusteringKeyPosition() { + if (unenforcedClusteringKeyPosition > 0) { + return OptionalInt.of(unenforcedClusteringKeyPosition); + } + return OptionalInt.empty(); + } + public Field asArrowField() { List arrowChildren = children.stream().map(LanceField::asArrowField).collect(Collectors.toList()); @@ -133,6 +157,25 @@ public Field asArrowField() { name, new FieldType(nullable, type, dictionaryEncoding, metadata), arrowChildren); } + Field asArrowFieldWithFieldIds() { + List arrowChildren = + children.stream().map(LanceField::asArrowFieldWithFieldIds).collect(Collectors.toList()); + + if (type instanceof ArrowType.FixedSizeList) { + arrowChildren.addAll(childrenForFixedSizeList()); + } + + if (id < 0) { + throw new IllegalStateException("Lance field id is required for schema override: " + name); + } + Map metadataWithFieldId = new HashMap<>(metadata); + metadataWithFieldId.put(LanceSchema.LANCE_FIELD_ID_KEY, Integer.toString(id)); + return new Field( + name, + new FieldType(nullable, type, dictionaryEncoding, metadataWithFieldId), + arrowChildren); + } + private List childrenForFixedSizeList() { if (logicalType == null || logicalType.isEmpty()) { return Collections.emptyList(); @@ -257,6 +300,7 @@ public String toString() { .add("children", children) .add("isUnenforcedPrimaryKey", isUnenforcedPrimaryKey) .add("unenforcedPrimaryKeyPosition", unenforcedPrimaryKeyPosition) + .add("unenforcedClusteringKeyPosition", unenforcedClusteringKeyPosition) .add("metadata", metadata) .toString(); } diff --git a/java/src/main/java/org/lance/schema/LanceSchema.java b/java/src/main/java/org/lance/schema/LanceSchema.java index 9df2e072b51..50a48e578af 100644 --- a/java/src/main/java/org/lance/schema/LanceSchema.java +++ b/java/src/main/java/org/lance/schema/LanceSchema.java @@ -17,11 +17,13 @@ import org.apache.arrow.vector.types.pojo.Schema; import java.util.Collections; +import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class LanceSchema { + static final String LANCE_FIELD_ID_KEY = "lance:field_id"; private final List fields; private final Map metadata; @@ -34,6 +36,30 @@ public List fields() { return fields; } + /** + * Get the unenforced primary key fields, ordered by position. + * + * @return primary key fields sorted by their position value + */ + public List getUnenforcedPrimaryKey() { + return fields.stream() + .filter(LanceField::isUnenforcedPrimaryKey) + .sorted(Comparator.comparingInt(f -> f.getUnenforcedPrimaryKeyPosition().orElse(0))) + .collect(Collectors.toList()); + } + + /** + * Get the unenforced clustering key fields, ordered by position. + * + * @return clustering key fields sorted by their position value + */ + public List getUnenforcedClusteringKey() { + return fields.stream() + .filter(LanceField::isUnenforcedClusteringKey) + .sorted(Comparator.comparingInt(f -> f.getUnenforcedClusteringKeyPosition().orElse(0))) + .collect(Collectors.toList()); + } + public Map metadata() { return Collections.unmodifiableMap(metadata); } @@ -43,6 +69,12 @@ public Schema asArrowSchema() { fields.stream().map(LanceField::asArrowField).collect(Collectors.toList()), metadata); } + public Schema asArrowSchemaWithFieldIds() { + return new Schema( + fields.stream().map(LanceField::asArrowFieldWithFieldIds).collect(Collectors.toList()), + metadata); + } + @Override public String toString() { return MoreObjects.toStringHelper(this) diff --git a/java/src/main/java/org/lance/update/UpdateParams.java b/java/src/main/java/org/lance/update/UpdateParams.java new file mode 100644 index 00000000000..f82f893140b --- /dev/null +++ b/java/src/main/java/org/lance/update/UpdateParams.java @@ -0,0 +1,154 @@ +/* + * 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.update; + +import org.lance.DataStorageVersion; + +import com.google.common.base.MoreObjects; +import com.google.common.base.Preconditions; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * Parameters describing an update operation. + * + *

    An update operation is similar to SQL's {@code UPDATE} statement: each entry in {@link + * #updates()} maps a target column name to a SQL expression evaluated for every row that matches + * the optional {@link #whereClause()}. + * + *

    The {@code where} clause accepts the dataset columns as well as the system columns {@code + * _rowid}, {@code _rowaddr}, and {@code _rowoffset}, which is useful for targeting specific rows by + * stable row id. + * + *

    Important: {@code _rowid} is only a stable identifier when the + * dataset was created with {@code WriteParams.Builder().withEnableStableRowIds(true)}. Without + * stable row ids, {@code _rowid} is a positional id that can shift across compaction, deletion, or + * re-insertion. Targeting rows by an unstable {@code _rowid} in {@link #withWhere(String)} can + * silently update the wrong rows. + */ +public class UpdateParams { + // Defaults are kept in sync with the Rust core defaults declared on + // `lance::dataset::UpdateBuilder` (`conflict_retries = 10`, + // `retry_timeout = 30s`). When the Rust defaults change, update both sides. + private static final int DEFAULT_CONFLICT_RETRIES = 10; + private static final long DEFAULT_RETRY_TIMEOUT_MS = 30_000L; + + private final Map updates; + private Optional whereClause = Optional.empty(); + private Optional dataStorageVersion = Optional.empty(); + private int conflictRetries = DEFAULT_CONFLICT_RETRIES; + private long retryTimeoutMs = DEFAULT_RETRY_TIMEOUT_MS; + + public UpdateParams(Map updates) { + Preconditions.checkNotNull(updates, "updates must not be null"); + Preconditions.checkArgument(!updates.isEmpty(), "updates must not be empty"); + this.updates = new HashMap<>(updates); + } + + /** + * Restrict the update to rows matching the given SQL predicate. + * + *

    The predicate is evaluated against the dataset schema augmented with the system columns + * {@code _rowid}, {@code _rowaddr}, and {@code _rowoffset}. + * + *

    When matching rows by {@code _rowid}, the dataset must have been created with stable row ids + * enabled (see the class-level Javadoc); otherwise the predicate may silently target the wrong + * rows. + * + * @param whereClause SQL predicate, e.g. {@code "id = 1"} or {@code "_rowid IN (1, 2, 3)"}. + * @return This UpdateParams instance. + */ + public UpdateParams withWhere(String whereClause) { + Preconditions.checkNotNull(whereClause, "whereClause must not be null"); + this.whereClause = Optional.of(whereClause); + return this; + } + + /** + * Set number of times to retry the operation if there is contention. + * + *

    Default mirrors the Rust core default ({@value #DEFAULT_CONFLICT_RETRIES}). + * + * @param retries Number of times to retry the operation if there is contention. + * @return This UpdateParams instance. + */ + public UpdateParams withConflictRetries(int retries) { + Preconditions.checkArgument(retries >= 0, "retries must be non-negative"); + this.conflictRetries = retries; + return this; + } + + /** + * Set the timeout in milliseconds used to limit retries. + * + *

    This is the maximum time to spend on the operation before giving up. At least one attempt + * will be made, regardless of how long it takes to complete. Default mirrors the Rust core + * default ({@value #DEFAULT_RETRY_TIMEOUT_MS} ms). + * + * @param timeoutMs Timeout in milliseconds used to limit retries. + * @return This UpdateParams instance. + */ + public UpdateParams withRetryTimeoutMs(long timeoutMs) { + Preconditions.checkArgument(timeoutMs >= 0, "timeoutMs must be non-negative"); + this.retryTimeoutMs = timeoutMs; + return this; + } + + /** + * Set the exact data storage version for files written by this operation. + * + *

    If omitted, the dataset's default write version is used without changing it. Release + * selectors are resolved by the engine. V1/V2 cross-family targets are rejected. + */ + public UpdateParams withDataStorageVersion(DataStorageVersion version) { + this.dataStorageVersion = Optional.of(Preconditions.checkNotNull(version)); + return this; + } + + /** Returns an unmodifiable view of the update expressions. */ + public Map updates() { + return Collections.unmodifiableMap(updates); + } + + public Optional whereClause() { + return whereClause; + } + + public int conflictRetries() { + return conflictRetries; + } + + public long retryTimeoutMs() { + return retryTimeoutMs; + } + + /** Returns the version selector as its string value for the native layer. */ + public Optional getDataStorageVersion() { + return dataStorageVersion.map(DataStorageVersion::toRustString); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("updates", updates) + .add("whereClause", whereClause.orElse(null)) + .add("conflictRetries", conflictRetries) + .add("retryTimeoutMs", retryTimeoutMs) + .add("dataStorageVersion", dataStorageVersion.orElse(null)) + .toString(); + } +} diff --git a/java/src/main/java/org/lance/update/UpdateResult.java b/java/src/main/java/org/lance/update/UpdateResult.java new file mode 100644 index 00000000000..b6122e69285 --- /dev/null +++ b/java/src/main/java/org/lance/update/UpdateResult.java @@ -0,0 +1,59 @@ +/* + * 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.update; + +import org.lance.Dataset; + +import com.google.common.base.MoreObjects; + +/** + * Result of {@link org.lance.Dataset#update(UpdateParams)}. + * + *

    Contains the new {@link Dataset} produced by the commit and the number of rows affected. + */ +public final class UpdateResult { + private final Dataset dataset; + private final long numRowsUpdated; + // Snapshot the dataset identity at construction time so toString() stays safe to call after the + // caller has closed the dataset (closed datasets reject native uri()/version() calls). + private final String datasetUri; + private final long datasetVersion; + + public UpdateResult(Dataset dataset, long numRowsUpdated) { + this.dataset = dataset; + this.numRowsUpdated = numRowsUpdated; + this.datasetUri = dataset.uri(); + this.datasetVersion = dataset.version(); + } + + /** Returns the new dataset reflecting the committed update. */ + public Dataset getDataset() { + return dataset; + } + + /** Returns the number of rows that matched the predicate and were updated. */ + public long getNumRowsUpdated() { + return numRowsUpdated; + } + + @Override + public String toString() { + // Use the snapshot taken at construction time; the caller may have already closed `dataset`. + return MoreObjects.toStringHelper(this) + .add("datasetUri", datasetUri) + .add("datasetVersion", datasetVersion) + .add("numRowsUpdated", numRowsUpdated) + .toString(); + } +} 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..5b75a22cee2 --- /dev/null +++ b/java/src/test/java/org/lance/AsyncScannerTest.java @@ -0,0 +1,727 @@ +/* + * 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.index.IndexOptions; +import org.lance.index.IndexParams; +import org.lance.index.IndexType; +import org.lance.index.scalar.ScalarIndexParams; +import org.lance.ipc.AsyncScanner; +import org.lance.ipc.MaterializationStyle; +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.io.BufferedReader; +import java.io.InputStreamReader; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +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(); + } + } + + @Test + @SuppressWarnings("unchecked") + void testNativeScanFailureUsesLanceException() throws Exception { + Constructor constructor = AsyncScanner.class.getDeclaredConstructor(); + constructor.setAccessible(true); + AsyncScanner scanner = constructor.newInstance(); + + Field pendingTasksField = AsyncScanner.class.getDeclaredField("pendingTasks"); + pendingTasksField.setAccessible(true); + ConcurrentHashMap> pendingTasks = + (ConcurrentHashMap>) pendingTasksField.get(scanner); + CompletableFuture pendingTask = new CompletableFuture<>(); + pendingTasks.put(1L, pendingTask); + + Method failTask = AsyncScanner.class.getDeclaredMethod("failTask", long.class, String.class); + failTask.setAccessible(true); + failTask.invoke(scanner, 1L, "scan I/O failed"); + + CompletionException failure = assertThrows(CompletionException.class, pendingTask::join); + assertEquals(LanceException.class, failure.getCause().getClass()); + assertEquals("scan I/O failed", failure.getCause().getMessage()); + } + + /** + * 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(); + } + } + } + } + + @Test + void testAsyncScanWithPerformanceOptions(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_performance_options").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() + .filter("id < 20") + .batchSize(10) + .batchSizeBytes(1024) + .ioBufferSize(1024 * 1024) + .batchReadahead(2) + .fragmentReadahead(2) + .scanInOrder(false) + .lateMaterialization(MaterializationStyle.allEarly()) + .build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator); + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS)) { + assertEquals(20, countRows(reader)); + } + } + } + } + + @Test + void testFastSearchSkipsUnindexedFragments(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_fast_search_scalar_index").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 100)) { + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + IndexOptions indexOptions = + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("id_btree_index") + .replace(true) + .build(); + dataset.createIndex(indexOptions); + + FragmentMetadata metadata = testDataset.createNewFragment(10); + FragmentOperation.Append appendOp = + new FragmentOperation.Append(Collections.singletonList(metadata)); + try (Dataset appended = + Dataset.commit(allocator, datasetPath, appendOp, Optional.of(dataset.version()))) { + ScanOptions normalOptions = new ScanOptions.Builder().filter("id < 5").build(); + try (AsyncScanner scanner = AsyncScanner.create(appended, normalOptions, allocator)) { + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS); + assertEquals(10, countRows(reader)); + reader.close(); + } + + ScanOptions fastOptions = + new ScanOptions.Builder().filter("id < 5").fastSearch(true).build(); + try (AsyncScanner scanner = AsyncScanner.create(appended, fastOptions, allocator)) { + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS); + assertEquals(5, countRows(reader)); + reader.close(); + } + } + } + } + } + + private static int countRows(ArrowReader reader) throws Exception { + int rowCount = 0; + while (reader.loadNextBatch()) { + rowCount += reader.getVectorSchemaRoot().getRowCount(); + } + return rowCount; + } + + @Test + void testIncludeDeletedRowsAsync(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_include_deleted_rows").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + assertEquals(10, dataset.countRows()); + + // Delete half the rows + dataset.delete("id >= 5"); + assertEquals(5, dataset.countRows()); + + // Async scan without includeDeletedRows — should only see live rows + ScanOptions defaultOptions = new ScanOptions.Builder().batchSize(20L).build(); + try (AsyncScanner scanner = AsyncScanner.create(dataset, defaultOptions, allocator)) { + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS); + assertEquals(5, countRows(reader), "default async scan: should exclude deleted rows"); + reader.close(); + } + + // Async scan with includeDeletedRows=true — should see all rows + ScanOptions includeDeletedOptions = + new ScanOptions.Builder() + .batchSize(20L) + .withRowId(true) // required by includeDeletedRows + .includeDeletedRows(true) + .build(); + try (AsyncScanner scanner = + AsyncScanner.create(dataset, includeDeletedOptions, allocator)) { + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS); + assertEquals( + 10, countRows(reader), "includeDeletedRows async: should include deleted rows"); + reader.close(); + } + } + } + } + + @Test + void testStrictBatchSizeAsync(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_strict_batch_size").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 25)) { + int batchSize = 10; + + ScanOptions strictOptions = + new ScanOptions.Builder().batchSize(batchSize).strictBatchSize(true).build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, strictOptions, allocator)) { + ArrowReader reader = scanner.scanBatchesAsync().get(10, TimeUnit.SECONDS); + int totalRows = 0; + while (reader.loadNextBatch()) { + int rows = reader.getVectorSchemaRoot().getRowCount(); + assertTrue( + rows <= batchSize, "strict async: batch " + rows + " should be <= " + batchSize); + totalRows += rows; + } + assertEquals(25, totalRows, "strictBatchSize async: should read all 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(); + } + } + } + } + + /** + * Regression test for classloader isolation. + * + *

    Verifies that AsyncScanner works correctly when the calling thread has an isolated + * (non-system) context classloader, simulating environments like Spark executors, web containers, + * or shaded JARs where application classes are loaded by a custom classloader. + * + *

    The fix under test moved class resolution from the JNI dispatcher thread (which only has the + * system classloader after attach_current_thread_permanently()) to JNI_OnLoad (which has the + * correct application classloader), passing a GlobalRef to the dispatcher. + */ + @Test + void testAsyncScanWithIsolatedClassloader(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("async_scanner_classloader").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)) { + ScanOptions options = new ScanOptions.Builder().batchSize(20L).build(); + + // Use a classloader that delegates only to the bootstrap classloader, + // making it unable to find any org.lance.* classes on its own. + ClassLoader originalCl = Thread.currentThread().getContextClassLoader(); + ClassLoader restrictedCl = new ClassLoader(null) { + // Intentionally empty: parent is null (bootstrap only) + }; + + // --- Part 1: Swap context classloader on the current thread --- + try { + Thread.currentThread().setContextClassLoader(restrictedCl); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + CompletableFuture future = scanner.scanBatchesAsync(); + ArrowReader reader = future.get(10, TimeUnit.SECONDS); + assertNotNull(reader); + + int rowCount = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + rowCount += root.getRowCount(); + } + + assertEquals( + totalRows, rowCount, "Async scan should succeed despite restrictive classloader"); + reader.close(); + } + } finally { + Thread.currentThread().setContextClassLoader(originalCl); + } + + // --- Part 2: Run from a thread with an isolated classloader --- + // Simulates Spark executor threads that have a non-system classloader. + ExecutorService isolatedExecutor = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "isolated-classloader-thread"); + t.setContextClassLoader(restrictedCl); + return t; + }); + try { + CompletableFuture result = + CompletableFuture.supplyAsync( + () -> { + 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()) { + rowCount += reader.getVectorSchemaRoot().getRowCount(); + } + reader.close(); + return rowCount; + } catch (Exception e) { + throw new RuntimeException(e); + } + }, + isolatedExecutor); + + assertEquals( + totalRows, + result.get(15, TimeUnit.SECONDS), + "Async scan from isolated classloader thread should succeed"); + } finally { + isolatedExecutor.shutdown(); + } + } + } + } + + /** + * 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"); + } + } + } + } + + /** + * Regression test: reproduce the JNI classloader bug via a forked JVM. + * + *

    Launches a child JVM where only {@code target/test-classes/} is on the system classpath. All + * lance classes are loaded by an isolated {@link URLClassLoader} with a null parent (bootstrap + * classloader only). This means JNI's {@code FindClass} on a native thread attached via {@code + * attach_current_thread_permanently()} will use the system classloader, which cannot find + * {@code org.lance.ipc.AsyncScanner}. + * + *

    With the fix, {@code JNI_OnLoad} pre-resolves the class on the loading thread (which has the + * correct classloader) and passes a {@code GlobalRef} to the dispatcher — so the dispatcher never + * calls {@code FindClass}. + */ + @Test + void testClassloaderIsolationWithForkedJvm(@TempDir Path tempDir) throws Exception { + // --- Collect full classpath from the current classloader hierarchy --- + List classpathEntries = new ArrayList<>(); + ClassLoader cl = Thread.currentThread().getContextClassLoader(); + if (cl == null) { + cl = AsyncScannerTest.class.getClassLoader(); + } + while (cl != null) { + if (cl instanceof URLClassLoader) { + for (URL url : ((URLClassLoader) cl).getURLs()) { + classpathEntries.add(url.getPath()); + } + } + cl = cl.getParent(); + } + + // Fallback: if no URLClassLoader found (Java 9+ AppClassLoader), use java.class.path + if (classpathEntries.isEmpty()) { + String cp = System.getProperty("java.class.path"); + if (cp != null) { + for (String entry : cp.split(java.io.File.pathSeparator)) { + classpathEntries.add(entry); + } + } + } + + // Find the test-classes directory + String testClassesDir = null; + for (String entry : classpathEntries) { + if (entry.contains("test-classes")) { + testClassesDir = entry; + break; + } + } + assertNotNull(testClassesDir, "Could not find test-classes directory in classpath"); + + // Full classpath for the isolated URLClassLoader inside the forked JVM + String fullClasspath = String.join(java.io.File.pathSeparator, classpathEntries); + + // --- Build the forked JVM command --- + String javaHome = System.getProperty("java.home"); + String javaBin = javaHome + java.io.File.separator + "bin" + java.io.File.separator + "java"; + + List command = new ArrayList<>(); + command.add(javaBin); + + // Add --add-opens flags matching surefire config (required by Arrow/Netty) + command.add("--add-opens=java.base/java.lang=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.lang.invoke=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.lang.reflect=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.io=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.net=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.nio=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.util=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.util.concurrent=ALL-UNNAMED"); + command.add("--add-opens=java.base/java.util.concurrent.atomic=ALL-UNNAMED"); + command.add("--add-opens=java.base/jdk.internal.ref=ALL-UNNAMED"); + command.add("--add-opens=java.base/sun.nio.ch=ALL-UNNAMED"); + command.add("--add-opens=java.base/sun.nio.cs=ALL-UNNAMED"); + command.add("--add-opens=java.base/sun.security.action=ALL-UNNAMED"); + command.add("--add-opens=java.base/sun.util.calendar=ALL-UNNAMED"); + command.add("--add-opens=java.security.jgss/sun.security.krb5=ALL-UNNAMED"); + command.add("-XX:+IgnoreUnrecognizedVMOptions"); + command.add("-Dio.netty.tryReflectionSetAccessible=true"); + + // System classpath: ONLY test-classes (no lance main classes) + command.add("-cp"); + command.add(testClassesDir); + + command.add("org.lance.ClassloaderBugBootstrap"); + command.add(fullClasspath); + command.add(tempDir.toString()); + + ProcessBuilder pb = new ProcessBuilder(command); + pb.redirectErrorStream(false); + + Process process = pb.start(); + + // Read stdout and stderr concurrently to avoid deadlock when buffers fill + CompletableFuture stderrFuture = + CompletableFuture.supplyAsync( + () -> { + try (BufferedReader errReader = + new BufferedReader(new InputStreamReader(process.getErrorStream()))) { + return errReader.lines().collect(Collectors.joining("\n")); + } catch (Exception e) { + return "Failed to read stderr: " + e.getMessage(); + } + }); + + String stdout; + try (BufferedReader outReader = + new BufferedReader(new InputStreamReader(process.getInputStream()))) { + stdout = outReader.lines().collect(Collectors.joining("\n")); + } + + String stderr = stderrFuture.get(60, TimeUnit.SECONDS); + + boolean exited = process.waitFor(60, TimeUnit.SECONDS); + assertTrue(exited, "Forked JVM did not exit within 60 seconds"); + + int exitCode = process.exitValue(); + assertEquals( + 0, + exitCode, + "Forked JVM exited with code " + + exitCode + + "\n--- stdout ---\n" + + stdout + + "\n--- stderr ---\n" + + stderr); + assertTrue( + stdout.contains("SUCCESS"), + "Expected SUCCESS in stdout but got:\n--- stdout ---\n" + + stdout + + "\n--- stderr ---\n" + + stderr); + } +} diff --git a/java/src/test/java/org/lance/ClassloaderBugBootstrap.java b/java/src/test/java/org/lance/ClassloaderBugBootstrap.java new file mode 100644 index 00000000000..7eef8cb6115 --- /dev/null +++ b/java/src/test/java/org/lance/ClassloaderBugBootstrap.java @@ -0,0 +1,74 @@ +/* + * 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 java.io.File; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; + +/** + * Standalone bootstrap for the forked-JVM classloader bug test. + * + *

    This class has zero lance imports. It lives on the forked JVM's system classpath + * ({@code target/test-classes/} only). All lance classes are loaded through an isolated {@link + * URLClassLoader} whose parent is {@code null} (bootstrap classloader), so the JVM's system + * classloader cannot see them. + * + *

    Usage: {@code java -cp target/test-classes org.lance.ClassloaderBugBootstrap + * } + * + *

    Exit code 0 + "SUCCESS" on stdout = pass; non-zero = failure. + */ +public class ClassloaderBugBootstrap { + + public static void main(String[] args) { + if (args.length < 2) { + System.err.println("Usage: ClassloaderBugBootstrap "); + System.exit(2); + } + + String classpath = args[0]; + String tempDir = args[1]; + + try { + // Build URL array from the full classpath + String[] entries = classpath.split(File.pathSeparator); + URL[] urls = new URL[entries.length]; + for (int i = 0; i < entries.length; i++) { + urls[i] = new File(entries[i]).toURI().toURL(); + } + + // Parent is null => only bootstrap classloader is the parent. + // The system classloader (which only has target/test-classes/) is bypassed. + URLClassLoader isolatedCl = new URLClassLoader(urls, null); + + // Set as context classloader for consistency + Thread.currentThread().setContextClassLoader(isolatedCl); + + // Load and invoke the helper class through the isolated classloader + Class helperClass = Class.forName("org.lance.ClassloaderBugHelper", true, isolatedCl); + Method runMethod = helperClass.getMethod("run", String.class); + runMethod.invoke(null, tempDir); + + // Force exit: the JNI dispatcher thread is non-daemon and would keep the JVM alive + System.exit(0); + + } catch (Throwable t) { + System.err.println("ClassloaderBugBootstrap FAILED:"); + t.printStackTrace(System.err); + System.exit(1); + } + } +} diff --git a/java/src/test/java/org/lance/ClassloaderBugHelper.java b/java/src/test/java/org/lance/ClassloaderBugHelper.java new file mode 100644 index 00000000000..1e57a5afa45 --- /dev/null +++ b/java/src/test/java/org/lance/ClassloaderBugHelper.java @@ -0,0 +1,82 @@ +/* + * 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.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; + +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; + +/** + * Helper loaded by the isolated {@link java.net.URLClassLoader} in the forked JVM. + * + *

    This class has normal lance imports. It is not on the system classpath of the forked + * JVM — the system classloader cannot find it (or any other {@code org.lance.*} class). Only the + * isolated classloader can. + * + *

    If the JNI dispatcher thread tries to {@code FindClass("org/lance/ipc/AsyncScanner")} after + * {@code attach_current_thread_permanently()}, it will use the system classloader and fail — + * reproducing the bug. With the fix, the class is pre-resolved in {@code JNI_OnLoad} (which runs on + * a thread with the correct classloader) and passed as a {@code GlobalRef}. + */ +public class ClassloaderBugHelper { + + /** + * Entry point invoked reflectively from {@link ClassloaderBugBootstrap}. + * + * @param tempDir temporary directory for the lance dataset + */ + public static void run(String tempDir) throws Exception { + String datasetPath = tempDir + "/classloader_bug_test"; + + 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)) { + ScanOptions options = new ScanOptions.Builder().batchSize(20L).build(); + + try (AsyncScanner scanner = AsyncScanner.create(dataset, options, allocator)) { + CompletableFuture future = scanner.scanBatchesAsync(); + + // Use a generous timeout — the bug causes the dispatcher thread to panic, + // so the future will never complete. + ArrowReader reader = future.get(30, TimeUnit.SECONDS); + + int rowCount = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + rowCount += root.getRowCount(); + } + reader.close(); + + if (rowCount != totalRows) { + throw new AssertionError("Expected " + totalRows + " rows but got " + rowCount); + } + } + } + } + + // Signal success to the parent process + System.out.println("SUCCESS"); + } +} diff --git a/java/src/test/java/org/lance/CleanupTest.java b/java/src/test/java/org/lance/CleanupTest.java index 56955122ca0..f3f3638fc51 100644 --- a/java/src/test/java/org/lance/CleanupTest.java +++ b/java/src/test/java/org/lance/CleanupTest.java @@ -13,17 +13,27 @@ */ package org.lance; +import org.lance.cleanup.CleanupExplanation; import org.lance.cleanup.CleanupPolicy; import org.lance.cleanup.RemovalStats; import org.apache.arrow.memory.RootAllocator; import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.time.Duration; +import java.util.EnumSet; +import java.util.List; +import java.util.Set; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; public class CleanupTest { @Test @@ -42,6 +52,68 @@ public void testCleanupBeforeVersion(@TempDir Path tempDir) { RemovalStats stats = dataset.cleanupWithPolicy(CleanupPolicy.builder().withBeforeVersion(3L).build()); assertEquals(2L, stats.getOldVersions()); + assertEquals(0L, stats.getDataFilesRemoved()); + assertEquals(2L, stats.getTransactionFilesRemoved()); + assertEquals(0L, stats.getIndexFilesRemoved()); + assertEquals(0L, stats.getDeletionFilesRemoved()); + } + } + } + + @Test + public void testCleanupSpecificVersions(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("test_dataset_for_cleanup").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + testDataset.createEmptyDataset().close(); + + testDataset.write(1, 10).close(); + testDataset.write(2, 10).close(); + + try (Dataset dataset = testDataset.write(3, 10)) { + assertEquals(4, dataset.listVersions().size()); + + RemovalStats stats = + dataset.cleanupWithPolicy(CleanupPolicy.builder().withVersions(List.of(2L)).build()); + + assertEquals(1L, stats.getOldVersions()); + assertEquals(3, dataset.listVersions().size()); + assertTrue(dataset.listVersions().stream().noneMatch(version -> version.getId() == 2L)); + } + } + } + + @Test + public void testExplainCleanupBeforeVersion(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("test_dataset_for_cleanup").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + testDataset.createEmptyDataset().close(); + + testDataset.write(1, 10).close(); + testDataset.write(2, 10).close(); + + try (Dataset dataset = testDataset.write(3, 10)) { + CleanupPolicy policy = CleanupPolicy.builder().withBeforeVersion(3L).build(); + CleanupOperation cleanup = dataset.cleanup(policy); + CleanupExplanation explanation = cleanup.explain(); + + assertEquals(2L, explanation.getStats().getOldVersions()); + assertEquals(2L, explanation.getStats().getTransactionFilesRemoved()); + assertTrue(explanation.getStats().getBytesRemoved() > 0); + assertTrue(explanation.getReadVersion() > 0); + assertTrue(explanation.getCandidateFiles().size() > 0); + assertTrue(explanation.getReferencedBranches().isEmpty()); + + List versions = dataset.listVersions(); + assertEquals(4, versions.size()); + + RemovalStats stats = cleanup.execute(); + assertEquals(explanation.getStats().getOldVersions(), stats.getOldVersions()); } } } @@ -110,4 +182,138 @@ public void testCleanupTaggedVersion(@TempDir Path tempDir) throws Exception { } } } + + @Test + public void testExplainCleanupWithMaxCandidateFiles(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("test_dataset_for_cleanup").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + testDataset.createEmptyDataset().close(); + + testDataset.write(1, 10).close(); + testDataset.write(2, 10).close(); + + try (Dataset dataset = testDataset.write(3, 10)) { + CleanupPolicy policy = CleanupPolicy.builder().withBeforeVersion(3L).build(); + CleanupExplanation full = dataset.cleanup(policy).explain(); + assertTrue(full.getCandidateFiles().size() > 1); + assertEquals(1000L, full.getCandidateFileLimit()); + + CleanupExplanation truncated = dataset.cleanup(policy).withMaxCandidateFiles(1L).explain(); + assertEquals(1L, truncated.getCandidateFileLimit()); + assertEquals(1, truncated.getCandidateFiles().size()); + assertTrue(truncated.isCandidateFilesTruncated()); + assertTrue(!truncated.getWarnings().isEmpty()); + // Aggregate stats stay accurate even when the per-file list is truncated. + assertEquals(full.getStats().getOldVersions(), truncated.getStats().getOldVersions()); + + Assertions.assertThrows( + IllegalArgumentException.class, + () -> dataset.cleanup(policy).withMaxCandidateFiles(0L)); + } + } + } + + @Test + public void testCleanupWithRateLimit(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("test_dataset_for_cleanup").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + testDataset.createEmptyDataset().close(); + testDataset.write(1, 100).close(); + testDataset.write(2, 100).close(); + try (Dataset dataset = testDataset.write(3, 100)) { + List versions = dataset.listVersions(); + assertEquals(4, versions.size()); + long beforeTimestampMillis = + versions.get(versions.size() - 1).getDataTime().toInstant().toEpochMilli() + 1; + long start = System.nanoTime(); + RemovalStats stats = + dataset.cleanupWithPolicy( + CleanupPolicy.builder() + .withBeforeTimestampMillis(beforeTimestampMillis) + .withDeleteRateLimit(1L) + .build()); + long elapsed = System.nanoTime() - start; + + assertEquals(3L, stats.getOldVersions()); + assertTrue(stats.getBytesRemoved() > 0); + assertTrue(elapsed >= Duration.ofSeconds(2).toNanos()); + } + } + } + + @Test + public void testFailedDeletesReachesJavaAcrossJni(@TempDir Path tempDir) throws Exception { + // Best-effort deletion makes failedDeletes the only programmatic signal that cleanup + // returned normally without removing everything it identified. Asserting it is zero on + // a successful run would prove nothing: the six-argument constructor defaults it to + // zero, so a JNI projection that dropped the field would still pass. The value has to + // be non-zero, which means a delete has to genuinely fail. + // + // Removing a file needs write permission on its parent, so making _transactions + // read-only fails exactly those deletes while the manifest deletes still succeed. + String datasetPath = tempDir.resolve("test_dataset_for_failed_deletes").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + testDataset.write(2, 10).close(); + + Path transactions = Path.of(datasetPath, "_transactions"); + assertTrue(Files.isDirectory(transactions), "expected a _transactions directory"); + Set original = Files.getPosixFilePermissions(transactions); + + // Every write lands a transaction file, so the directory stays writable until the + // last version is committed and only then becomes read-only. + try (Dataset dataset = testDataset.write(3, 10)) { + Files.setPosixFilePermissions( + transactions, + EnumSet.of(PosixFilePermission.OWNER_READ, PosixFilePermission.OWNER_EXECUTE)); + try { + // Root ignores these bits, so confirm deletion is really blocked before relying + // on it; otherwise this asserts nothing and should skip rather than fail. + Path probe = transactions.resolve("permission-probe"); + boolean blocked; + try { + Files.createFile(probe); + Files.deleteIfExists(probe); + blocked = false; + } catch (IOException expected) { + blocked = true; + } + Assumptions.assumeTrue(blocked, "filesystem permissions do not block deletion here"); + + RemovalStats stats = + dataset.cleanupWithPolicy(CleanupPolicy.builder().withBeforeVersion(3L).build()); + + // The call returns normally rather than throwing, and reports what it could not + // remove. Both halves matter: the old behaviour discarded the whole sweep. + assertTrue( + stats.getFailedDeletes() > 0, + "expected the blocked transaction deletes to be counted, got " + + stats.getFailedDeletes()); + assertTrue(stats.getOldVersions() > 0, "the sweep must continue past a failed delete"); + } finally { + Files.setPosixFilePermissions(transactions, original); + } + } + } + } + + @Test + public void testRemovalStatsCarriesFailedDeletes() { + RemovalStats stats = new RemovalStats(1L, 2L, 3L, 4L, 5L, 6L, 7L); + assertEquals(7L, stats.getFailedDeletes()); + + // The pre-existing six-argument constructor stays source compatible and defaults to + // zero, so callers that predate best-effort deletion keep compiling. + assertEquals(0L, new RemovalStats(1L, 2L, 3L, 4L, 5L, 6L).getFailedDeletes()); + } } diff --git a/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java b/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java new file mode 100644 index 00000000000..06c8a3e1abe --- /dev/null +++ b/java/src/test/java/org/lance/CommitBuilderStorageFormatTest.java @@ -0,0 +1,135 @@ +/* + * 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.operation.Append; +import org.lance.operation.Delete; +import org.lance.operation.OperationTestBase; + +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CommitBuilderStorageFormatTest extends OperationTestBase { + + /** + * Append to a freshly created (2.2) dataset with the given storage format and return the + * committed dataset's format version. + */ + private String commitWithStorageFormat(String datasetPath, String storageFormat) + throws Exception { + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + try (Dataset committed = + new CommitBuilder(dataset).storageFormat(storageFormat).execute(txn)) { + assertEquals(2, committed.version()); + return committed.getLanceFileFormatVersion(); + } + } + } + } + + /** + * The numeric versions are what {@link Dataset#getLanceFileFormatVersion()} returns and what + * {@link WriteParams.Builder#withDataStorageVersion(String)} accepts, so they must work here too + * — a caller that encodes fragments as "2.2" has to be able to commit them as "2.2". + */ + @Test + void testCanonicalVersionAccepted(@TempDir Path tempDir) throws Exception { + assertEquals( + LanceConstants.FILE_FORMAT_VERSION_2_2, + commitWithStorageFormat( + tempDir.resolve("canonical").toString(), LanceConstants.FILE_FORMAT_VERSION_2_2)); + } + + /** The "v"-prefixed spelling shipped in this method's Javadoc and stays accepted. */ + @Test + void testDeprecatedAliasAccepted(@TempDir Path tempDir) throws Exception { + assertEquals( + LanceConstants.FILE_FORMAT_VERSION_2_2, + commitWithStorageFormat(tempDir.resolve("alias").toString(), "v2_2")); + } + + /** Row-level operations preserve the dataset default regardless of the supplied format. */ + @ParameterizedTest + @ValueSource(strings = {"2.1", "2.2"}) + void testRowLevelOperationPreservesDefault(String storageFormat, @TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("mismatch").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + dataset = new CommitBuilder(dataset).execute(appendTxn); + } + assertEquals(LanceConstants.FILE_FORMAT_VERSION_2_2, dataset.getLanceFileFormatVersion()); + + List fragmentIds = + dataset.getFragments().stream() + .map(f -> Long.valueOf(f.getId())) + .collect(Collectors.toList()); + + try (Transaction deleteTxn = deleteAll(fragmentIds)) { + try (Dataset deleted = + new CommitBuilder(dataset).storageFormat(storageFormat).execute(deleteTxn)) { + assertEquals(0, deleted.countRows()); + assertEquals(LanceConstants.FILE_FORMAT_VERSION_2_2, deleted.getLanceFileFormatVersion()); + assertEquals(10, dataset.countRows()); + } + } + } + } + + private Transaction deleteAll(List fragmentIds) { + return new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Delete.builder().deletedFragmentIds(fragmentIds).predicate("1=1").build()) + .build(); + } + + @Test + void testUnknownFormatRejected(@TempDir Path tempDir) { + assertThrows( + IllegalArgumentException.class, + () -> commitWithStorageFormat(tempDir.resolve("bogus").toString(), "bogus")); + // The alias set is frozen at what shipped, so it does not extend to newer versions. + assertThrows( + IllegalArgumentException.class, + () -> commitWithStorageFormat(tempDir.resolve("v23").toString(), "v2_3")); + } +} diff --git a/java/src/test/java/org/lance/CommitBuilderTimeoutTest.java b/java/src/test/java/org/lance/CommitBuilderTimeoutTest.java new file mode 100644 index 00000000000..3f78d91ec8c --- /dev/null +++ b/java/src/test/java/org/lance/CommitBuilderTimeoutTest.java @@ -0,0 +1,93 @@ +/* + * 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.operation.Append; +import org.lance.operation.OperationTestBase; + +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.time.Duration; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +public class CommitBuilderTimeoutTest extends OperationTestBase { + + @Test + void testZeroTimeoutRejected(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("zeroTimeout").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).commitTimeout(Duration.ZERO)); + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).commitTimeout(Duration.ofMillis(-1))); + } + } + + @Test + void testNullDisablesTimeout(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("nullTimeout").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + try (Dataset committed = new CommitBuilder(dataset).commitTimeout(null).execute(txn)) { + assertEquals(2, committed.version()); + } + } + } + } + + @Test + void testPositiveTimeoutSucceeds(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("posTimeout").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + FragmentMetadata fragment = testDataset.createNewFragment(10); + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + try (Dataset committed = + new CommitBuilder(dataset).commitTimeout(Duration.ofMinutes(5)).execute(txn)) { + assertEquals(2, committed.version()); + } + } + } + } + + // Timeout *firing* behavior is covered by the Rust test + // `test_commit_timeout_triggers`, which uses a throttled store for a reliable + // trigger; reproducing it from Java without exposing throttling would be + // flaky on fast runners. +} diff --git a/java/src/test/java/org/lance/CompactionTest.java b/java/src/test/java/org/lance/CompactionTest.java index 9ac96804a61..4c874c4c878 100644 --- a/java/src/test/java/org/lance/CompactionTest.java +++ b/java/src/test/java/org/lance/CompactionTest.java @@ -15,6 +15,7 @@ import org.lance.compaction.Compaction; import org.lance.compaction.CompactionMetrics; +import org.lance.compaction.CompactionMode; import org.lance.compaction.CompactionOptions; import org.lance.compaction.CompactionPlan; import org.lance.compaction.CompactionTask; @@ -23,6 +24,8 @@ import org.apache.arrow.memory.RootAllocator; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -30,14 +33,21 @@ import java.io.ObjectInputStream; import java.io.ObjectOutputStream; import java.nio.file.Path; +import java.util.Arrays; +import java.util.Base64; import java.util.Collections; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** Add test for distributed compaction. */ public class CompactionTest { - @Test - public void testBasicCompaction(@TempDir Path tempDir) throws Exception { + @ParameterizedTest + @EnumSource(DataStorageVersion.class) + public void testBasicCompaction(DataStorageVersion version, @TempDir Path tempDir) + throws Exception { String datasetPath = tempDir.resolve("test_dataset_for_compaction").toString(); try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { TestUtils.SimpleTestDataset testDataset = @@ -49,9 +59,41 @@ public void testBasicCompaction(@TempDir Path tempDir) throws Exception { testDataset.write(1, 10).close(); try (Dataset dataset = testDataset.write(2, 10)) { CompactionOptions compactionOptions = - CompactionOptions.builder().withTargetRowsPerFragment(100).withNumThreads(1).build(); + CompactionOptions.builder() + .withTargetRowsPerFragment(100) + .withNumThreads(1) + .withMaxSourceRows(1000) + .withMaxSourceBytes(10L * 1024 * 1024) + .withDataStorageVersion(version) + .build(); + compactionOptions = serializeAndDeserialize(compactionOptions); + assertEquals( + Optional.of(version.toRustString()), compactionOptions.getDataStorageVersion()); + if (version == DataStorageVersion.LEGACY) { + CompactionOptions legacyOptions = compactionOptions; + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> Compaction.planCompaction(dataset, legacyOptions)); + assertTrue(error.getMessage().contains("V1 and V2")); + return; + } + String exactVersion = + version == DataStorageVersion.STABLE + ? "2.2" + : version == DataStorageVersion.NEXT ? "2.3" : version.toRustString(); CompactionPlan compactionPlan = Compaction.planCompaction(dataset, compactionOptions); + // The source budgets are loose, so the plan is unaffected and the + // options must survive the JNI round trip. + assertEquals(Optional.of(1000L), compactionPlan.getCompactionOptions().getMaxSourceRows()); + assertEquals( + Optional.of(10L * 1024 * 1024), + compactionPlan.getCompactionOptions().getMaxSourceBytes()); + assertEquals( + Optional.of(exactVersion), + compactionPlan.getCompactionOptions().getDataStorageVersion()); + // will plan to compact two fragments into one. assertEquals(1, compactionPlan.getCompactionTasks().size()); CompactionTask task = compactionPlan.getCompactionTasks().get(0); @@ -71,6 +113,19 @@ public void testBasicCompaction(@TempDir Path tempDir) throws Exception { // mock network transferring result = serializeAndDeserialize(result); + result + .getNewFragments() + .forEach( + fragment -> + fragment + .getFiles() + .forEach( + file -> + assertEquals( + exactVersion, + file.getFileMajorVersion() + + "." + + file.getFileMinorVersion()))); CompactionMetrics ignored = Compaction.commitCompaction( dataset, Collections.singletonList(result), compactionPlan.getCompactionOptions()); @@ -79,6 +134,20 @@ public void testBasicCompaction(@TempDir Path tempDir) throws Exception { dataset.checkoutLatest(); assertEquals(1, dataset.getFragments().size()); assertEquals(20, dataset.getFragments().get(0).countRows()); + dataset + .getFragments() + .forEach( + fragment -> + fragment + .metadata() + .getFiles() + .forEach( + file -> + assertEquals( + exactVersion, + file.getFileMajorVersion() + + "." + + file.getFileMinorVersion()))); } } } @@ -130,6 +199,118 @@ public void testDeletionCompaction(@TempDir Path tempDir) throws Exception { } } + @Test + public void testExcludedFragmentIds(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("test_excluded_fragment_ids").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + testDataset.write(2, 10).close(); + testDataset.write(3, 10).close(); + try (Dataset dataset = testDataset.write(4, 10)) { + CompactionOptions options = + CompactionOptions.builder() + .withTargetRowsPerFragment(100) + .withExcludedFragmentIds(Arrays.asList(1L, 1L, 999L)) + .build(); + + CompactionPlan plan = Compaction.planCompaction(dataset, options); + + assertEquals( + Arrays.asList(1L, 1L, 999L), plan.getCompactionOptions().getExcludedFragmentIds()); + assertEquals(1, plan.getCompactionTasks().size()); + assertEquals(2, plan.getCompactionTasks().get(0).getTaskData().getFragments().size()); + assertEquals( + 2, plan.getCompactionTasks().get(0).getTaskData().getFragments().get(0).getId()); + assertEquals( + 3, plan.getCompactionTasks().get(0).getTaskData().getFragments().get(1).getId()); + + CompactionTask task = serializeAndDeserialize(plan.getCompactionTasks().get(0)); + assertEquals( + Arrays.asList(1L, 1L, 999L), task.getCompactionOptions().getExcludedFragmentIds()); + } + } + } + + @ParameterizedTest + @EnumSource(CompactionMode.class) + public void testCompactionModeRoundTrip(CompactionMode mode, @TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("test_dataset_for_compaction").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + CompactionOptions compactionOptions = + CompactionOptions.builder() + .withTargetRowsPerFragment(100) + .withNumThreads(1) + .withCompactionMode(mode) + .build(); + CompactionPlan compactionPlan = Compaction.planCompaction(dataset, compactionOptions); + + // The plan's options are rebuilt by the native layer; the mode must come + // back as a CompactionMode enum, not a raw String. + assertEquals( + Optional.of(mode.getValue()), + compactionPlan.getCompactionOptions().getCompactionMode()); + + CompactionTask task = serializeAndDeserialize(compactionPlan.getCompactionTasks().get(0)); + RewriteResult result = task.execute(dataset); + assertEquals(2, result.getMetrics().getFragmentsRemoved()); + assertEquals(1, result.getMetrics().getFragmentsAdded()); + } + } + } + + /** + * A serialized CompactionOptions produced by the class as it existed before maxSourceRows and + * maxSourceBytes were added (no declared serialVersionUID, stream ends after maxSourceFragments), + * built with targetRowsPerFragment=1024, materializeDeletions=true, + * compactionMode=TRY_BINARY_COPY, maxSourceFragments=4. + */ + private static final String PRE_SOURCE_BUDGET_OPTIONS_BASE64 = + "rO0ABXNyACZvcmcubGFuY2UuY29tcGFjdGlvbi5Db21wYWN0aW9uT3B0aW9ucys6bRwua1fWAwALTAAJYmF0Y2hTaXpl" + + "dAAUTGphdmEvdXRpbC9PcHRpb25hbDtMABhiaW5hcnlDb3B5UmVhZEJhdGNoQnl0ZXNxAH4AAUwADmNvbXBhY3Rpb25N" + + "b2RlcQB+AAFMAA9kZWZlckluZGV4UmVtYXBxAH4AAUwAFG1hdGVyaWFsaXplRGVsZXRpb25zcQB+AAFMAB1tYXRlcmlh" + + "bGl6ZURlbGV0aW9uc1RocmVzaG9sZHEAfgABTAAPbWF4Qnl0ZXNQZXJGaWxlcQB+AAFMAA9tYXhSb3dzUGVyR3JvdXBx" + + "AH4AAUwAEm1heFNvdXJjZUZyYWdtZW50c3EAfgABTAAKbnVtVGhyZWFkc3EAfgABTAAVdGFyZ2V0Um93c1BlckZyYWdt" + + "ZW50cQB+AAF4cHNyAA5qYXZhLmxhbmcuTG9uZzuL5JDMjyPfAgABSgAFdmFsdWV4cgAQamF2YS5sYW5nLk51bWJlcoas" + + "lR0LlOCLAgAAeHAAAAAAAAAEAHBwc3IAEWphdmEubGFuZy5Cb29sZWFuzSBygNWc+u4CAAFaAAV2YWx1ZXhwAXBwcHB0" + + "AA90cnlfYmluYXJ5X2NvcHlwc3EAfgADAAAAAAAAAAR4"; + + @Test + public void testDeserializeOptionsFromOlderVersion() throws Exception { + byte[] serialized = Base64.getDecoder().decode(PRE_SOURCE_BUDGET_OPTIONS_BASE64); + CompactionOptions options; + try (ObjectInputStream in = new ObjectInputStream(new ByteArrayInputStream(serialized))) { + options = (CompactionOptions) in.readObject(); + } + assertEquals(Optional.of(1024L), options.getTargetRowsPerFragment()); + assertEquals(Optional.of(true), options.getMaterializeDeletions()); + assertEquals( + Optional.of(CompactionMode.TRY_BINARY_COPY.getValue()), options.getCompactionMode()); + assertEquals(Optional.of(4L), options.getMaxSourceFragments()); + // Fields absent from the old stream deserialize as unset. + assertEquals(Optional.empty(), options.getMaxSourceRows()); + assertEquals(Optional.empty(), options.getMaxSourceBytes()); + assertEquals(Collections.emptyList(), options.getExcludedFragmentIds()); + assertEquals(Optional.empty(), options.getDataStorageVersion()); + } + + @Test + public void testUnknownDataStorageVersion() { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> DataStorageVersion.fromRustString("unknown")); + assertEquals("Unknown data storage version: unknown", error.getMessage()); + } + private static T serializeAndDeserialize(T object) throws IOException, ClassNotFoundException { ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); 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/DatasetTest.java b/java/src/test/java/org/lance/DatasetTest.java index 6ea4533accb..c76a97c99c0 100644 --- a/java/src/test/java/org/lance/DatasetTest.java +++ b/java/src/test/java/org/lance/DatasetTest.java @@ -14,9 +14,11 @@ package org.lance; import org.lance.compaction.CompactionOptions; +import org.lance.file.FileWriteOptions; import org.lance.index.Index; import org.lance.index.IndexCriteria; import org.lance.index.IndexDescription; +import org.lance.index.IndexOptions; import org.lance.index.IndexParams; import org.lance.index.IndexType; import org.lance.index.OptimizeOptions; @@ -59,6 +61,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Clock; +import java.time.Instant; import java.time.ZonedDateTime; import java.util.ArrayList; import java.util.Arrays; @@ -78,6 +81,7 @@ import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Assertions.fail; @@ -117,6 +121,122 @@ void testCreateEmptyDataset(@TempDir Path tempDir) { } } + @Test + void testWriteRejectsNegativeDataCacheBytes(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("negative_data_cache_bytes").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + WriteParams params = + new WriteParams.Builder() + .withFileWriteOptions(FileWriteOptions.builder().dataCacheBytes(-1).build()) + .build(); + + assertThrows( + IllegalArgumentException.class, () -> testDataset.createDatasetWithWriteParams(params)); + } + } + + @Test + void testWriteDatasetBuilderRejectsNullFileWriteOptions() { + NullPointerException error = + assertThrows( + NullPointerException.class, () -> new WriteDatasetBuilder().fileWriteOptions(null)); + + assertEquals("fileWriteOptions must not be null", error.getMessage()); + } + + @Test + void testGetLanceFileFormatVersion(@TempDir Path tempDir) { + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + // Test default version (V2_2) + 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_2, dataset.getLanceFileFormatVersion()); + WriterVersion writerVersion = dataset.getWriterVersion().orElseThrow(AssertionError::new); + assertEquals("lance", writerVersion.getLibrary()); + assertFalse(writerVersion.getVersion().isEmpty()); + } + + // Test LEGACY version + String legacyPath = tempDir.resolve("legacy_version").toString(); + try (Dataset legacyDataset = + Dataset.write() + .allocator(allocator) + .uri(legacyPath) + .schema(testDataset.getSchema()) + .mode(WriteParams.WriteMode.CREATE) + .dataStorageVersion(LanceConstants.FILE_FORMAT_VERSION_LEGACY) + .execute()) { + assertEquals( + LanceConstants.FILE_FORMAT_VERSION_0_1, legacyDataset.getLanceFileFormatVersion()); + } + + // This dataset was written before writer_version was added to the manifest. + String historicalPath = + Path.of("..", "test_data", "v0.7.5", "with_deletions") + .toAbsolutePath() + .normalize() + .toString(); + try (Dataset historicalDataset = Dataset.open(historicalPath, allocator)) { + assertTrue(historicalDataset.getWriterVersion().isEmpty()); + } + + // This fixture was written by lance 2.0.0-beta.1. Reading it through Dataset verifies + // that the manifest's prerelease qualifier survives the Rust-to-Java JNI mapping. + String prereleasePath = + Path.of("..", "test_data", "pre_file_sizes", "index_without_file_sizes") + .toAbsolutePath() + .normalize() + .toString(); + try (Dataset prereleaseDataset = Dataset.open(prereleasePath, allocator)) { + WriterVersion writerVersion = + prereleaseDataset.getWriterVersion().orElseThrow(AssertionError::new); + assertEquals("lance", writerVersion.getLibrary()); + assertEquals("2.0.0", writerVersion.getVersion()); + assertEquals("beta.1", writerVersion.getPrerelease().orElseThrow(AssertionError::new)); + assertTrue(writerVersion.getBuildMetadata().isEmpty()); + } + } + } + + @Test + void testWriterVersionPreservesOpaqueAndOptionalFields() { + WriterVersion writerVersion = + new WriterVersion("custom-writer", "release-2026", "preview.1", "build.42"); + + assertEquals("custom-writer", writerVersion.getLibrary()); + assertEquals("release-2026", writerVersion.getVersion()); + assertEquals("preview.1", writerVersion.getPrerelease().orElseThrow(AssertionError::new)); + assertEquals("build.42", writerVersion.getBuildMetadata().orElseThrow(AssertionError::new)); + } + + @Test + void testListManifestLocations(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("manifest_locations").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 1).close(); + + List manifests = Dataset.listManifestLocations(datasetPath); + assertEquals(2, manifests.size()); + assertEquals( + Set.of(1L, 2L), + manifests.stream().map(ManifestLocation::getVersion).collect(Collectors.toSet())); + for (ManifestLocation manifest : manifests) { + assertTrue(manifest.getPath().contains("manifest_locations/_versions/")); + assertFalse(manifest.getPath().startsWith("_versions/")); + assertTrue(manifest.getPath().endsWith(".manifest")); + assertTrue(manifest.getSizeBytes() > 0); + assertNotNull(manifest.getNamingScheme()); + } + } + } + @Test void testCreateDirNotExist(@TempDir Path tempDir) throws IOException, URISyntaxException { String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); @@ -148,6 +268,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()); @@ -157,8 +278,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)); @@ -168,6 +291,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()); } @@ -176,19 +300,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()); } @@ -196,12 +324,16 @@ 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()); } List versions = dataset.listVersions(); assertEquals(3, versions.size()); + assertEquals(3, dataset.getVersionCount()); + assertEquals(3, dataset2.getVersionCount()); + assertEquals(3, dataset3.getVersionCount()); assertEquals(1, versions.get(0).getId()); assertEquals(2, versions.get(1).getId()); assertEquals(3, versions.get(2).getId()); @@ -212,6 +344,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()); @@ -277,8 +410,15 @@ void testTags(@TempDir Path tempDir) { try (Dataset dataset = testDataset.createEmptyDataset()) { assertEquals(1, dataset.version()); dataset.tags().create("tag1", Ref.ofMain()); - assertEquals(1, dataset.tags().list().size()); - assertEquals(1, dataset.tags().list().get(0).getVersion()); + dataset.tags().replaceMetadata("tag1", Map.of("description", "primary tag")); + List tags = dataset.tags().list(); + Tag tag1 = tags.get(0); + assertEquals(1, tags.size()); + assertEquals(1, tag1.getVersion()); + assertEquals(Map.of("description", "primary tag"), tag1.getMetadata()); + assertTrue(tag1.getCreatedAt().isPresent()); + assertTrue(tag1.getUpdatedAt().isPresent()); + assertEquals(tag1.getCreatedAt(), tag1.getUpdatedAt()); assertEquals(1, dataset.tags().getVersion("tag1")); } @@ -292,12 +432,67 @@ void testTags(@TempDir Path tempDir) { assertEquals(2, dataset2.tags().list().size()); assertEquals(1, dataset2.tags().getVersion("tag1")); assertEquals(2, dataset2.tags().getVersion("tag2")); - dataset2.tags().update("tag2", Ref.ofMain(1)); + dataset2.tags().replaceMetadata("tag2", Map.of("description", "rollback tag")); + Instant tag2CreatedAt = + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getCreatedAt() + .orElseThrow(); + Instant tag2UpdatedAt = + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getUpdatedAt() + .orElseThrow(); + assertEquals(tag2CreatedAt, tag2UpdatedAt); assertEquals(2, dataset2.tags().list().size()); assertEquals(1, dataset2.tags().list().get(0).getVersion()); - assertEquals(1, dataset2.tags().list().get(1).getVersion()); + assertEquals(2, dataset2.tags().list().get(1).getVersion()); assertEquals(1, dataset2.tags().getVersion("tag1")); + assertEquals(2, dataset2.tags().getVersion("tag2")); + assertEquals( + Map.of("description", "rollback tag"), + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getMetadata()); + dataset2.tags().update("tag2", Ref.ofMain(1)); + Instant updatedTag2CreatedAt = + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getCreatedAt() + .orElseThrow(); + Instant updatedTag2UpdatedAt = + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getUpdatedAt() + .orElseThrow(); assertEquals(1, dataset2.tags().getVersion("tag2")); + assertEquals(updatedTag2CreatedAt, tag2CreatedAt); + assertFalse(updatedTag2UpdatedAt.isBefore(tag2UpdatedAt)); + assertEquals( + Map.of("description", "rollback tag"), + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getMetadata()); + dataset2.tags().replaceMetadata("tag2", Collections.emptyMap()); + assertEquals( + Collections.emptyMap(), + dataset2.tags().list().stream() + .filter(tag -> tag.getName().equals("tag2")) + .findFirst() + .orElseThrow() + .getMetadata()); dataset2.tags().delete("tag2"); assertEquals(1, dataset2.tags().list().size()); assertEquals(1, dataset2.tags().list().get(0).getVersion()); @@ -317,6 +512,7 @@ void testTags(@TempDir Path tempDir) { try (Dataset branch = dataset2.createBranch("branch", Ref.ofMain(2))) { branch.tags().create("tag_on_branch", Ref.ofBranch("branch")); + branch.tags().replaceMetadata("tag_on_branch", Map.of("description", "branch tag")); assertEquals(2, dataset2.tags().getVersion("tag_on_branch")); List tags = dataset2.tags().list(); Optional tagOptional = @@ -327,6 +523,7 @@ void testTags(@TempDir Path tempDir) { assertTrue(tagOptional.isPresent()); assertEquals(2, tagOptional.get().getVersion()); assertEquals(Optional.of("branch"), tagOptional.get().getBranch()); + assertEquals(Map.of("description", "branch tag"), tagOptional.get().getMetadata()); dataset2.tags().update("tag1", Ref.ofBranch("branch")); tags = dataset2.tags().list(); @@ -423,7 +620,12 @@ void testOpenSerializedManifest(@TempDir Path tempDir) throws IOException { assertEquals(1, dataset1.version()); Path manifestPath = datasetPath.resolve("_versions"); try (Stream fileStream = Files.list(manifestPath)) { - assertEquals(1, fileStream.count()); + // Ignore the version hint file, which is not a manifest. + assertEquals( + 1, + fileStream + .filter(p -> !p.getFileName().toString().startsWith("latest_version_hint")) + .count()); ByteBuffer manifestBuffer = readManifest(manifestPath.resolve("1.manifest")); try (Dataset dataset2 = testDataset.write(1, 5)) { assertEquals(2, dataset2.version()); @@ -551,6 +753,40 @@ void testAlterColumns(@TempDir Path tempDir) { } } + @Test + void testAlterColumnsCastType(@TempDir Path tempDir) { + String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); + String datasetPath = tempDir.resolve(testMethodName).toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + + // Widen "id" from Int32 to Int64. The cast target type is a parameterized ArrowType, which + // must survive the trip to the native side; regression test for a dropped cast that left the + // stored type unchanged. + ColumnAlteration widenId = + new ColumnAlteration.Builder("id").castTo(new ArrowType.Int(64, true)).build(); + dataset.alterColumns(Collections.singletonList(widenId)); + + assertEquals(new ArrowType.Int(64, true), dataset.getSchema().findField("id").getType()); + + // A cast combined with rename must apply both. + ColumnAlteration renameAndWiden = + new ColumnAlteration.Builder("id") + .rename("id_long") + .castTo(new ArrowType.Int(64, true)) + .build(); + dataset.alterColumns(Collections.singletonList(renameAndWiden)); + + List fieldNames = + dataset.getSchema().getFields().stream().map(Field::getName).collect(Collectors.toList()); + assertFalse(fieldNames.contains("id")); + assertTrue(fieldNames.contains("id_long")); + assertEquals(new ArrowType.Int(64, true), dataset.getSchema().findField("id_long").getType()); + } + } + @Test void testAddColumnBySqlExpressions(@TempDir Path tempDir) { String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); @@ -771,6 +1007,23 @@ void testDropPath(@TempDir Path tempDir) { } } + @Test + void testDropRejectsNonDatasetPath(@TempDir Path tempDir) { + Path warehouse = tempDir.resolve("warehouse"); + Path tablePath = warehouse.resolve("table.lance"); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, tablePath.toString()); + dataset = testDataset.createEmptyDataset(); + + // Pointing at the parent of a dataset must not wipe out the whole warehouse. + assertThrows( + IllegalArgumentException.class, + () -> Dataset.drop(warehouse.toString(), new HashMap<>())); + assertTrue(Files.exists(tablePath)); + } + } + @Test void testTake(@TempDir Path tempDir) throws IOException, ClosedChannelException { String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); @@ -799,6 +1052,99 @@ void testTake(@TempDir Path tempDir) throws IOException, ClosedChannelException } } + @Test + void testTakeRows(@TempDir Path tempDir) throws IOException, ClosedChannelException { + String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); + String datasetPath = tempDir.resolve(testMethodName).toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + + try (Dataset dataset2 = testDataset.write(1, 5)) { + // For a single-fragment dataset, physical row IDs match row offsets + List rowIds = Arrays.asList(1L, 4L); + List columns = Arrays.asList("id", "name"); + try (ArrowReader reader = dataset2.takeRows(rowIds, columns)) { + while (reader.loadNextBatch()) { + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + assertNotNull(result); + assertEquals(rowIds.size(), result.getRowCount()); + + for (int i = 0; i < rowIds.size(); i++) { + assertEquals(rowIds.get(i).intValue(), result.getVector("id").getObject(i)); + assertNotNull(result.getVector("name").getObject(i)); + } + } + } + + // Verify input order is preserved: reversed input yields reversed output + List reversed = Arrays.asList(4L, 1L); + try (ArrowReader reader = dataset2.takeRows(reversed, columns)) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + assertEquals(4, result.getVector("id").getObject(0)); + assertEquals(1, result.getVector("id").getObject(1)); + } + + // Empty row IDs should be rejected + assertThrows( + IllegalArgumentException.class, + () -> { + dataset2.takeRows(Collections.emptyList(), columns); + }); + assertThrows( + IllegalArgumentException.class, + () -> { + dataset2.takeRows(null, columns); + }); + } + } + } + + @Test + void testSample(@TempDir Path tempDir) throws IOException { + String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); + String datasetPath = tempDir.resolve(testMethodName).toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + + try (Dataset dataset2 = testDataset.write(1, 20)) { + // Sample without fragment filter + List columns = Arrays.asList("id", "name"); + try (ArrowReader reader = dataset2.sample(5, columns)) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + assertNotNull(result); + assertEquals(5, result.getRowCount()); + assertEquals(2, result.getSchema().getFields().size()); + } + + // Sample with fragment filter + List fragments = dataset2.getFragments(); + assertFalse(fragments.isEmpty()); + List fragmentIds = + fragments.stream().map(f -> f.getId()).collect(Collectors.toList()); + try (ArrowReader reader = dataset2.sample(3, columns, Optional.of(fragmentIds))) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + assertNotNull(result); + assertEquals(3, result.getRowCount()); + } + + // Sample more than available rows returns all rows + try (ArrowReader reader = dataset2.sample(100, columns)) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + assertNotNull(result); + assertEquals(20, result.getRowCount()); + } + } + } + } + @Test void testCountRows(@TempDir Path tempDir) { String testMethodName = new Object() {}.getClass().getEnclosingMethod().getName(); @@ -829,7 +1175,7 @@ void testCalculateDataSize(@TempDir Path tempDir) { dataset = testDataset.createEmptyDataset(); try (Dataset dataset2 = testDataset.write(1, 5)) { - assertEquals(100, dataset2.calculateDataSize()); + assertEquals(112, dataset2.calculateDataSize()); } } } @@ -1147,8 +1493,8 @@ void testCommitTransactionDetachedTrue(@TempDir Path tempDir) { long baseRowCount = base.countRows(); FragmentMetadata fragment = suite.createNewFragment(5); Append append = Append.builder().fragments(Collections.singletonList(fragment)).build(); - Transaction transaction = base.newTransactionBuilder().operation(append).build(); - try (Dataset committed = base.commitTransaction(transaction, true, false)) { + SourcedTransaction transaction = base.newTransactionBuilder().operation(append).build(); + try (Dataset committed = base.commitTransaction(transaction.transaction(), true, false)) { // Original dataset is not refreshed to the new version. assertEquals(baseVersion, base.version()); assertEquals(baseRowCount, base.countRows()); @@ -1176,11 +1522,11 @@ void testCommitTransactionDetachedTrueOnV1ManifestThrowsUnsupported(@TempDir Pat FragmentMetadata fragment = suite.createNewFragment(3); Append append = Append.builder().fragments(Collections.singletonList(fragment)).build(); - Transaction transaction = dataset.newTransactionBuilder().operation(append).build(); + SourcedTransaction transaction = dataset.newTransactionBuilder().operation(append).build(); UnsupportedOperationException ex = assertThrows( UnsupportedOperationException.class, - () -> dataset.commitTransaction(transaction, true, false)); + () -> dataset.commitTransaction(transaction.transaction(), true, false)); // Error should indicate detached commits are not supported on v1 manifests. assertNotNull(ex.getMessage()); @@ -1210,9 +1556,10 @@ void testEnableStableRowIds(@TempDir Path tempDir) throws Exception { FragmentMetadata frag1 = testDataset.createNewFragment(10); FragmentMetadata frag2 = testDataset.createNewFragment(10); - Transaction.Builder builder = new Transaction.Builder(dataset); + SourcedTransaction.Builder builder = new SourcedTransaction.Builder(dataset); Append append = Append.builder().fragments(Arrays.asList(frag1, frag2)).build(); - Transaction transaction = builder.operation(append).readVersion(dataset.version()).build(); + SourcedTransaction transaction = + builder.operation(append).readVersion(dataset.version()).build(); // Step2: if move-stable-rowid is enabled, the rowids of new fragments should be // consecutive. @@ -1251,6 +1598,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(); @@ -1668,46 +2042,116 @@ 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(Collections.emptyMap(), branch1Meta.getMetadata()); + mainV2 + .branches() + .replaceMetadata("branch1", Map.of("description", "long-lived branch")); + branches = branch2V4.branches().list(); + b1 = branches.stream().filter(b -> b.getName().equals("branch1")).findFirst(); + assertTrue(b1.isPresent()); + assertEquals(Map.of("description", "long-lived branch"), b1.get().getMetadata()); + mainV2.branches().replaceMetadata("branch1", Collections.emptyMap()); + branches = branch2V4.branches().list(); + b1 = branches.stream().filter(b -> b.getName().equals("branch1")).findFirst(); + assertTrue(b1.isPresent()); + assertEquals(Collections.emptyMap(), b1.get().getMetadata()); 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); // Delete branch1 and verify listing - try { - mainV2.branches().delete("branch1"); - } catch (Exception ignored) { - // Some environments may report NotFound on cleanup; ignore and proceed - } - List branchListAfterDelete = mainV2.branches().list(); - assertTrue( - branchListAfterDelete.stream().noneMatch(b -> b.getName().equals("branch1")), - "branch1 should be deleted"); - - Optional branch2AfterDelete = - branchListAfterDelete.stream() - .filter(b -> b.getName().equals("branch2")) - .findFirst(); - assertTrue(branch2AfterDelete.isPresent(), "branch2 should remain"); - assertEquals(branch2Meta, branch2AfterDelete.get()); - - // Step 6. use checkout_branch to checkout branch2 - try (Dataset branch2V4New = mainV2.checkout(Ref.ofBranch("branch2"))) { - assertNotNull(branch2V4New.getSchema()); - assertEquals(4, branch2V4New.version()); - assertEquals(10, branch2V4New.countRows()); // A(5) + B(3) + C(2) - } + mainV2.branches().delete("branch2"); + assertEquals(1, mainV2.branches().list().size()); - // Step 7. use checkout reference to checkout branch2 - try (Dataset branch2V4New = mainV2.checkout(Ref.ofBranch("branch2", 3))) { + // Step 6. use checkout_branch to checkout branch1 + try (Dataset branch2V4New = mainV2.checkout(Ref.ofBranch("branch1"))) { assertEquals(3, branch2V4New.version()); assertEquals(8, branch2V4New.countRows()); // A(5) + B(3) } + + // Step 7. use checkout reference to checkout branch2 + try (Dataset branch2V4New = mainV2.checkout(Ref.ofBranch("branch1", 2))) { + assertEquals(2, branch2V4New.version()); + assertEquals(5, branch2V4New.countRows()); // A(5) + } + + // Step 8. open the dataset directly at a reference + mainV2.tags().create("main_tag", Ref.ofMain(2)); + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofBranch("branch1"))) { + assertEquals(3, opened.version()); + assertEquals(8, opened.countRows()); // A(5) + B(3) + assertTrue(opened.uri().contains("tree/branch1"), opened.uri()); + } + try (Dataset opened = + openAt(allocator, datasetPath, Ref.ofBranch("branch1", 2))) { + assertEquals(2, opened.version()); + assertEquals(5, opened.countRows()); // A(5) + assertTrue(opened.uri().contains("tree/branch1"), opened.uri()); + } + // main has no version 3, only branch1 does. + try (Dataset opened = + openAt(allocator, datasetPath, Ref.ofBranch("branch1", 3))) { + assertEquals(3, opened.version()); + assertEquals(8, opened.countRows()); // A(5) + B(3) + assertTrue(opened.uri().contains("tree/branch1"), opened.uri()); + } + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofMain(1))) { + assertEquals(1, opened.version()); + assertEquals(0, opened.countRows()); + assertFalse(opened.uri().contains("tree/"), opened.uri()); + } + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofMain())) { + assertEquals(2, opened.version()); + assertEquals(5, opened.countRows()); // A(5) + assertFalse(opened.uri().contains("tree/"), opened.uri()); + } + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofBranch("main", 1))) { + assertEquals(1, opened.version()); + assertFalse(opened.uri().contains("tree/"), opened.uri()); + } + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofTag("main_tag"))) { + assertEquals(2, opened.version()); + assertEquals(5, opened.countRows()); // A(5) + assertFalse(opened.uri().contains("tree/"), opened.uri()); + } + // "tag" points at branch1:3, so it opens branch1, not version 3 of main. + try (Dataset opened = openAt(allocator, datasetPath, Ref.ofTag("tag"))) { + assertEquals(3, opened.version()); + assertEquals(8, opened.countRows()); // A(5) + B(3) + assertTrue(opened.uri().contains("tree/branch1"), opened.uri()); + } + IOException missing = + assertThrows( + IOException.class, + () -> openAt(allocator, datasetPath, Ref.ofBranch("no_such_branch"))); + assertTrue(missing.getMessage().contains("no_such_branch"), missing.getMessage()); + RuntimeException missingTag = + assertThrows( + RuntimeException.class, + () -> openAt(allocator, datasetPath, Ref.ofTag("no_such_tag"))); + assertTrue( + missingTag.getMessage().contains("no_such_tag"), missingTag.getMessage()); + IllegalArgumentException missingVersion = + assertThrows( + IllegalArgumentException.class, + () -> openAt(allocator, datasetPath, Ref.ofBranch("branch1", 99))); + assertTrue( + missingVersion.getMessage().contains("branch1"), missingVersion.getMessage()); } } } @@ -1717,6 +2161,25 @@ void testBranches(@TempDir Path tempDir) { } } + private static Dataset openAt(BufferAllocator allocator, String path, Ref ref) { + return Dataset.open(allocator, path, new ReadOptions.Builder().setRef(ref).build()); + } + + @Test + void testReadOptionsRejectsConflictingRef() { + ReadOptions.Builder withVersion = + new ReadOptions.Builder().setVersion(1).setRef(Ref.ofBranch("branch1")); + IllegalArgumentException e = assertThrows(IllegalArgumentException.class, withVersion::build); + assertTrue(e.getMessage().contains("both version (1) and ref"), e.getMessage()); + + ReadOptions.Builder withManifest = + new ReadOptions.Builder() + .setSerializedManifest(ByteBuffer.allocateDirect(1)) + .setRef(Ref.ofBranch("branch1")); + e = assertThrows(IllegalArgumentException.class, withManifest::build); + assertTrue(e.getMessage().contains("serialized manifest and ref"), e.getMessage()); + } + @Test void testOptimizingIndices(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("optimize_scalar").toString(); @@ -1757,18 +2220,20 @@ void testOptimizingIndices(@TempDir Path tempDir) throws Exception { OptimizeOptions options = OptimizeOptions.builder().numIndicesToMerge(0).build(); dsAppended.optimizeIndices(options); - List afterIndexes = dsAppended.getIndexes(); - Index idIndexAfter = - afterIndexes.stream() + List idIndexes = + dsAppended.getIndexes().stream() .filter(idx -> "id_idx".equals(idx.name())) - .findFirst() - .orElse(null); - assertNotNull(idIndexAfter); - List afterFragments = idIndexAfter.fragments().orElse(Collections.emptyList()); - - assertTrue(afterFragments.contains(0)); - assertTrue(afterFragments.contains(1)); - assertEquals(2, afterFragments.size()); + .collect(Collectors.toList()); + assertEquals( + 2, + idIndexes.size(), + "append-only optimize must add a delta segment instead of merging"); + + Set coveredFragments = + idIndexes.stream() + .flatMap(idx -> idx.fragments().orElse(Collections.emptyList()).stream()) + .collect(Collectors.toSet()); + assertEquals(new HashSet<>(Arrays.asList(0, 1)), coveredFragments); } } } @@ -1788,6 +2253,16 @@ void testReadZeroLengthBlob(@TempDir Path tempDir) throws Exception { } } + @Test + void testTakeNullBlobPreservesSelection(@TempDir Path tempDir) throws Exception { + String base = tempDir.resolve("testTakeNullBlobPreservesSelection").toString(); + try (Dataset ds = TestUtils.createBlobDataset(base, 128, 8)) { + List blobs = ds.takeBlobsByIndices(Collections.singletonList(15L), "blobs"); + assertEquals(1, blobs.size()); + assertNull(blobs.get(0)); + } + } + @Test void testReadLargeBlobAndRanges(@TempDir Path tempDir) throws Exception { String base = tempDir.resolve("testReadLargeBlobAndRanges").toString(); @@ -1837,6 +2312,69 @@ void testReadSmallBlobSequentialIntegrity(@TempDir Path tempDir) throws Exceptio } } + @Test + void testReadUpToFillsAcrossReadAheadBoundary(@TempDir Path tempDir) throws Exception { + String base = tempDir.resolve("testReadUpToFillsAcrossReadAheadBoundary").toString(); + try (Dataset ds = TestUtils.createBlobDataset(base, 64, 4)) { + List blobs = ds.takeBlobsByIndices(Collections.singletonList(2L), "blobs", 16L); + BlobFile blobFile = blobs.get(0); + byte[] first = blobFile.readUpTo(4); + assertEquals(4, first.length); + byte[] second = blobFile.readUpTo(20); + assertEquals(20, second.length); + assertEquals(24L, blobFile.tell()); + blobFile.seek(0); + byte[] all = blobFile.read(); + assertArrayEquals(Arrays.copyOfRange(all, 0, 4), first); + assertArrayEquals(Arrays.copyOfRange(all, 4, 24), second); + blobFile.close(); + } + } + + @Test + void testSetReadBufferSizeZeroStillReadsPayload(@TempDir Path tempDir) throws Exception { + String base = tempDir.resolve("testSetReadBufferSizeZeroStillReadsPayload").toString(); + try (Dataset ds = TestUtils.createBlobDataset(base, 64, 4)) { + List blobs = ds.takeBlobsByIndices(Collections.singletonList(2L), "blobs", 0L); + BlobFile blobFile = blobs.get(0); + byte[] first = blobFile.readUpTo(64); + byte[] rest = blobFile.read(); + blobFile.seek(0); + byte[] all = blobFile.read(); + byte[] combined = new byte[first.length + rest.length]; + System.arraycopy(first, 0, combined, 0, first.length); + System.arraycopy(rest, 0, combined, first.length, rest.length); + assertArrayEquals(all, combined); + blobFile.close(); + } + } + + @Test + void testTakeBlobsRejectsNegativeBufferSizeForEmptySelection(@TempDir Path tempDir) + throws Exception { + String base = + tempDir.resolve("testTakeBlobsRejectsNegativeBufferSizeForEmptySelection").toString(); + try (Dataset ds = TestUtils.createBlobDataset(base, 64, 4)) { + assertThrows( + IllegalArgumentException.class, + () -> ds.takeBlobsByIndices(Collections.emptyList(), "blobs", -1L)); + assertThrows( + IllegalArgumentException.class, + () -> ds.takeBlobs(Collections.emptyList(), "blobs", -1L)); + } + } + + @Test + void testSetReadBufferSizeFailureClosesOpenedHandles(@TempDir Path tempDir) throws Exception { + String base = tempDir.resolve("testSetReadBufferSizeFailureClosesOpenedHandles").toString(); + try (Dataset ds = TestUtils.createBlobDataset(base, 64, 4)) { + List blobs = ds.takeBlobsByIndices(Arrays.asList(0L, 1L), "blobs"); + blobs.get(1).close(); + assertThrows(RuntimeException.class, () -> Dataset.setBlobReadBufferSize(blobs, 1024L)); + assertThrows(RuntimeException.class, () -> blobs.get(0).readUpTo(1)); + } + } + @Test public void testIndexStatistics(@TempDir Path tempDir) throws Exception { Path datasetPath = tempDir.resolve("testIndexStatistics"); @@ -1898,16 +2436,113 @@ 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()); + assertEquals( + Collections.emptyList(), + desc.getSegments().get(0).coveringFields(), + "no covering columns are declared yet"); + assertTrue( + desc.getSegments().get(0).getSizeBytes().orElse(0L) > 0, + "segment size should be positive"); + assertEquals( + desc.getSegments().get(0).getSizeBytes(), + desc.getTotalSizeBytes(), + "single-segment size should equal the logical index size"); + 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"); + assertTrue( + indexDesc.getTotalSizeBytes().orElse(0L) > 0, "total index size should be positive"); + } + } + } + } + + @Test + void testDropIndex(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("drop_index").toString(); + + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + // 1. write two fragments + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + List fragments = dataset.getFragments(); + assertEquals(2, dataset.getFragments().size()); + + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{\"zone_size\": 2048}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + + // 2. partially create index + List segments = new ArrayList<>(); + for (Fragment fragment : fragments) { + segments.add( + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("name"), IndexType.BTREE, indexParams) + .withIndexName("test_index") + .withFragmentIds(Collections.singletonList(fragment.getId())) + .build())); } + + // then no index should have been created + assertFalse( + dataset.listIndexes().contains("test_index"), + "Partially created index should not present"); + + // 3. commit the index + List committed = dataset.commitExistingIndexSegments("test_index", "name", segments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains("test_index")); + + List indexes = dataset.getIndexes(); + assertTrue(indexes.stream().anyMatch(idx -> idx.name().equals("test_index"))); + + dataset.dropIndex("test_index"); + + List indexNamesAfterDrop = dataset.listIndexes(); + assertFalse(indexNamesAfterDrop.contains("test_index")); + } + } + } + + @Test + void testDropIndexNonExistent(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("drop_index_nonexistent").toString(); + + Schema schema = + new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(32, true)), + Field.nullable("name", new ArrowType.Utf8())), + null); + + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + try (Dataset dataset = + Dataset.create(allocator, datasetPath, schema, new WriteParams.Builder().build())) { + assertEquals(1, dataset.version()); + + assertThrows(RuntimeException.class, () -> dataset.dropIndex("nonexistent")); + + assertEquals(1, dataset.version()); + assertTrue(dataset.listIndexes().isEmpty()); + assertTrue(dataset.getIndexes().isEmpty()); } } } diff --git a/java/src/test/java/org/lance/DeltaTest.java b/java/src/test/java/org/lance/DeltaTest.java index 72537207524..da4fd512466 100755 --- a/java/src/test/java/org/lance/DeltaTest.java +++ b/java/src/test/java/org/lance/DeltaTest.java @@ -28,11 +28,12 @@ import org.apache.arrow.vector.types.pojo.Schema; import org.apache.arrow.vector.util.ByteArrayReadableSeekableByteChannel; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Assumptions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.file.Path; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; @@ -41,9 +42,9 @@ public class DeltaTest { @Test - public void testInsertedRowsComparedAgainst() throws IOException { + public void testInsertedRowsComparedAgainst(@TempDir Path tempDir) throws IOException { try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { - String uri = "memory://delta_demo"; + String uri = tempDir.resolve("delta_demo").toString(); // Build initial batch (2 rows) Schema schema = new Schema( @@ -79,7 +80,11 @@ public void testInsertedRowsComparedAgainst() throws IOException { org.apache.arrow.c.ArrowArrayStream.allocateNew(allocator)) { Data.exportArrayStream(allocator, reader1, stream1); Dataset ds = - Dataset.write().stream(stream1).uri(uri).mode(WriteParams.WriteMode.CREATE).execute(); + Dataset.write().stream(stream1) + .uri(uri) + .mode(WriteParams.WriteMode.CREATE) + .enableStableRowIds(true) + .execute(); // Append one row (v2) VectorSchemaRoot root2 = VectorSchemaRoot.create(schema, allocator); @@ -107,40 +112,35 @@ public void testInsertedRowsComparedAgainst() throws IOException { Dataset.write().stream(stream2).uri(uri).mode(WriteParams.WriteMode.APPEND).execute(); DatasetDelta delta = ds2.delta(1L); - try { - try (ArrowReader inserted = delta.getInsertedRows()) { - int total = 0; - boolean foundRow = false; - - while (inserted.loadNextBatch()) { - VectorSchemaRoot outRoot = inserted.getVectorSchemaRoot(); - Schema outSchema = outRoot.getSchema(); - List names = - outSchema.getFields().stream().map(Field::getName).collect(Collectors.toList()); - Assertions.assertTrue(names.contains("_row_created_at_version")); - Assertions.assertTrue(names.contains("_row_last_updated_at_version")); - - IntVector outId = (IntVector) outRoot.getVector("id"); - VarCharVector outVal = (VarCharVector) outRoot.getVector("val"); - - for (int i = 0; i < outRoot.getRowCount(); i++) { - int id = outId.get(i); - byte[] bytes = outVal.get(i); - String val = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); - if (id == 3 && "c".equals(val)) { - foundRow = true; - } - } + try (ArrowReader inserted = delta.getInsertedRows()) { + int total = 0; + boolean foundRow = false; + + while (inserted.loadNextBatch()) { + VectorSchemaRoot outRoot = inserted.getVectorSchemaRoot(); + Schema outSchema = outRoot.getSchema(); + List names = + outSchema.getFields().stream().map(Field::getName).collect(Collectors.toList()); + Assertions.assertTrue(names.contains("_row_created_at_version")); + Assertions.assertTrue(names.contains("_row_last_updated_at_version")); + + IntVector outId = (IntVector) outRoot.getVector("id"); + VarCharVector outVal = (VarCharVector) outRoot.getVector("val"); - total += outRoot.getRowCount(); + for (int i = 0; i < outRoot.getRowCount(); i++) { + int id = outId.get(i); + byte[] bytes = outVal.get(i); + String val = new String(bytes, java.nio.charset.StandardCharsets.UTF_8); + if (id == 3 && "c".equals(val)) { + foundRow = true; + } } - Assertions.assertEquals(1, total); - Assertions.assertTrue(foundRow, "Inserted row (id=3, val=c) not found in delta"); + total += outRoot.getRowCount(); } - } catch (UnsatisfiedLinkError e) { - Assumptions.assumeTrue( - false, "JNI for DatasetDelta.getInsertedRows not available: " + e.getMessage()); + + Assertions.assertEquals(1, total); + Assertions.assertTrue(foundRow, "Inserted row (id=3, val=c) not found in delta"); } } } @@ -148,10 +148,9 @@ public void testInsertedRowsComparedAgainst() throws IOException { } @Test - public void testListTransactionsExplicitRange() { + public void testListTransactionsExplicitRange(@TempDir Path tempDir) throws IOException { try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { - String uri = "memory://delta_demo_tx"; - // v1 + String uri = tempDir.resolve("delta_demo_tx").toString(); Schema schema = new Schema( Arrays.asList( @@ -159,21 +158,110 @@ public void testListTransactionsExplicitRange() { "id", new org.apache.arrow.vector.types.pojo.ArrowType.Int(32, true)), Field.nullable( "val", org.apache.arrow.vector.types.pojo.ArrowType.Utf8.INSTANCE))); - try (Dataset ds = Dataset.create(allocator, uri, schema, new WriteParams.Builder().build())) { - // v2 - WriteParams params = - new WriteParams.Builder().withMode(WriteParams.WriteMode.APPEND).build(); - try (Dataset ds2 = Dataset.create(allocator, uri, schema, params); ) { + + // v1: create with two rows. + byte[] batch1 = writeBatch(allocator, schema, new int[] {1, 2}, new String[] {"a", "b"}); + try (ArrowStreamReader reader1 = + new ArrowStreamReader(new ByteArrayReadableSeekableByteChannel(batch1), allocator); + ArrowArrayStream stream1 = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader1, stream1); + Dataset.write().stream(stream1) + .uri(uri) + .mode(WriteParams.WriteMode.CREATE) + .execute() + .close(); + } + + // v2: append one row. + byte[] batch2 = writeBatch(allocator, schema, new int[] {3}, new String[] {"c"}); + try (ArrowStreamReader reader2 = + new ArrowStreamReader(new ByteArrayReadableSeekableByteChannel(batch2), allocator); + ArrowArrayStream stream2 = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader2, stream2); + try (Dataset ds2 = + Dataset.write().stream(stream2).uri(uri).mode(WriteParams.WriteMode.APPEND).execute()) { DatasetDelta delta = ds2.delta(1L, 2L); - try { - List txs = delta.listTransactions(); - Assertions.assertTrue(txs.size() == 1); - } catch (UnsatisfiedLinkError e) { - Assumptions.assumeTrue( - false, "JNI for DatasetDelta.listTransactions not available: " + e.getMessage()); + List txs = delta.listTransactions(); + Assertions.assertEquals(1, txs.size(), "delta v1..v2 should contain exactly one txn"); + } + } + } + } + + @Test + public void testGetDeletedRowIds(@TempDir Path tempDir) throws IOException { + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + String uri = tempDir.resolve("delta_demo_delete").toString(); + Schema schema = + new Schema( + Arrays.asList( + Field.notNullable( + "id", new org.apache.arrow.vector.types.pojo.ArrowType.Int(32, true)), + Field.nullable( + "val", org.apache.arrow.vector.types.pojo.ArrowType.Utf8.INSTANCE))); + + // v1: create with three rows, keeping stable row ids so deletions are reportable. + byte[] batch1 = + writeBatch(allocator, schema, new int[] {1, 2, 3}, new String[] {"a", "b", "c"}); + try (ArrowStreamReader reader1 = + new ArrowStreamReader(new ByteArrayReadableSeekableByteChannel(batch1), allocator); + ArrowArrayStream stream1 = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader1, stream1); + Dataset.write().stream(stream1) + .uri(uri) + .mode(WriteParams.WriteMode.CREATE) + .enableStableRowIds(true) + .execute() + .close(); + } + + // v2: delete one row. + try (Dataset ds = Dataset.open(uri, allocator)) { + ds.delete("id = 2"); + } + + try (Dataset ds2 = Dataset.open(uri, allocator)) { + DatasetDelta delta = ds2.delta(1L); + try (ArrowReader deleted = delta.getDeletedRowIds()) { + int total = 0; + while (deleted.loadNextBatch()) { + VectorSchemaRoot outRoot = deleted.getVectorSchemaRoot(); + List names = + outRoot.getSchema().getFields().stream() + .map(Field::getName) + .collect(Collectors.toList()); + Assertions.assertEquals(Arrays.asList("_rowid"), names); + total += outRoot.getRowCount(); } + Assertions.assertEquals(1, total, "exactly one row was deleted"); } } } } + + /** Helper: serialize a single Arrow batch with the given schema and (id, val) pairs. */ + private static byte[] writeBatch(RootAllocator allocator, Schema schema, int[] ids, String[] vals) + throws IOException { + Assertions.assertEquals(ids.length, vals.length, "ids and vals must align"); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator); + try { + root.allocateNew(); + IntVector idVec = (IntVector) root.getVector("id"); + VarCharVector valVec = (VarCharVector) root.getVector("val"); + for (int i = 0; i < ids.length; i++) { + idVec.setSafe(i, ids[i]); + valVec.setSafe(i, vals[i].getBytes(java.nio.charset.StandardCharsets.UTF_8)); + } + root.setRowCount(ids.length); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + return out.toByteArray(); + } finally { + root.close(); + } + } } diff --git a/java/src/test/java/org/lance/NamespaceIntegrationTest.java b/java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java similarity index 59% rename from java/src/test/java/org/lance/NamespaceIntegrationTest.java rename to java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java index 2d6f8ab1443..e6728f3e244 100644 --- a/java/src/test/java/org/lance/NamespaceIntegrationTest.java +++ b/java/src/test/java/org/lance/DirectoryNamespaceIntegrationTest.java @@ -15,26 +15,32 @@ import org.lance.namespace.DirectoryNamespace; import org.lance.namespace.LanceNamespace; -import org.lance.namespace.LanceNamespaceStorageOptionsProvider; -import org.lance.namespace.model.CreateEmptyTableRequest; -import org.lance.namespace.model.CreateEmptyTableResponse; +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; import org.lance.operation.Append; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; @@ -47,6 +53,7 @@ import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; import software.amazon.awssdk.services.s3.model.S3Object; +import java.io.ByteArrayOutputStream; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; @@ -55,22 +62,29 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; /** * 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"; @@ -79,6 +93,21 @@ public class NamespaceIntegrationTest { private static final String BUCKET_NAME = "lance-namespace-integtest-java"; private static S3Client s3Client; + private BufferAllocator testAllocator; + private String testPrefix; + + @BeforeEach + void setUpTest() { + testAllocator = new RootAllocator(Long.MAX_VALUE); + testPrefix = "test-" + UUID.randomUUID().toString().substring(0, 8); + } + + @AfterEach + void tearDownTest() { + if (testAllocator != null) { + testAllocator.close(); + } + } @BeforeAll static void setup() { @@ -129,115 +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()); - } - - // 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"); - } + protected static class TrackingNamespaceResult { + /** The namespace client to use for operations. May be wrapped. */ + public final LanceNamespace namespaceClient; - 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 - } + /** The inner DirectoryNamespace for metrics retrieval. */ + public final DirectoryNamespace innerNamespaceClient; - @Override - public String namespaceId() { - return "TrackingNamespace { inner: " + inner.namespaceId() + " }"; + public TrackingNamespaceResult( + LanceNamespace namespaceClient, DirectoryNamespace innerNamespaceClient) { + this.namespaceClient = namespaceClient; + this.innerNamespaceClient = innerNamespaceClient; } + } - /** - * Modifies storage options to add incrementing credentials with expiration timestamp. - * - * @param storageOptions Original storage options - * @param count Call count to use for credential generation - * @return Modified storage options with new credentials - */ - private Map modifyStorageOptions( - Map storageOptions, int count) { - Map modified = - storageOptions != null ? new HashMap<>(storageOptions) : 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); - - long expiresAtMillis = System.currentTimeMillis() + (credentialExpiresInSeconds * 1000L); - modified.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"); - - return modified; + /** + * 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()); } - @Override - public CreateEmptyTableResponse createEmptyTable(CreateEmptyTableRequest request) { - int count = createCallCount.incrementAndGet(); - - CreateEmptyTableResponse response = inner.createEmptyTable(request); - response.setStorageOptions(modifyStorageOptions(response.getStorageOptions(), count)); - - return response; + // 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(); + // 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)); - DeclareTableResponse response = inner.declareTable(request); - response.setStorageOptions(modifyStorageOptions(response.getStorageOptions(), count)); - - 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(modifyStorageOptions(response.getStorageOptions(), 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 @@ -252,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 @@ -311,36 +335,38 @@ 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) .execute()) { assertEquals(2, dataset.countRows()); } } - // Verify createEmptyTable was called - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable should be called once"); + // Verify declareTable was called + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); - // Open dataset through namespace WITH refresh enabled - ReadOptions readOptions = new ReadOptions.Builder().build(); + // 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, @@ -359,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, @@ -382,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 @@ -441,35 +469,37 @@ 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) .execute()) { assertEquals(2, dataset.countRows()); } } - // Verify createEmptyTable was called - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable should be called once"); + // Verify declareTable was called + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); - // Open dataset through namespace with refresh enabled - ReadOptions readOptions = new ReadOptions.Builder().build(); + // 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, @@ -480,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, @@ -499,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, @@ -523,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 @@ -582,22 +614,22 @@ 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 createEmptyTable was called - int callCountAfter = namespace.getCreateCallCount(); - assertEquals( - 1, callCountAfter - callCountBefore, "createEmptyTable should be called once"); + // Verify declareTable was called + int callCountAfter = getDeclareCallCount(innerNamespaceClient); + assertEquals(1, callCountAfter - callCountBefore, "declareTable should be called once"); // Verify dataset was created successfully assertEquals(2, dataset.countRows()); @@ -621,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(), "createEmptyTable 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 = @@ -684,29 +720,32 @@ 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) .execute()) { - // Verify createEmptyTable was called exactly ONCE + // Verify declareTable was called exactly ONCE assertEquals( - 1, namespace.getCreateCallCount(), "createEmptyTable 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 createEmptyTable response, and since credentials + // 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 createEmptyTable response and don't expire"); + + "initial credentials come from declareTable response and don't expire"); // Verify dataset was created successfully assertEquals(2, dataset.countRows()); @@ -716,33 +755,35 @@ public VectorSchemaRoot getVectorSchemaRoot() { // Verify counts after dataset is closed assertEquals( - 1, namespace.getCreateCallCount(), "createEmptyTable should still be 1 after close"); + 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) - ReadOptions readOptions = new ReadOptions.Builder().build(); + // 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()) { - // createEmptyTable should NOT be called during open (only during CREATE) + // declareTable should NOT be called during open (only during CREATE) assertEquals( 1, - namespace.getCreateCallCount(), - "createEmptyTable should still be 1 (not called during open)"); + 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 @@ -754,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: createEmptyTable = 1"); - assertEquals(1, namespace.getDescribeCallCount(), "Final: describeTable = 1"); + assertEquals(1, getDeclareCallCount(innerNamespaceClient), "Final: declareTable = 1"); + assertEquals(1, getDescribeCallCount(innerNamespaceClient), "Final: describeTable = 1"); } } @@ -776,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 = @@ -834,22 +877,24 @@ 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) .execute()) { assertEquals(2, dataset.countRows()); } - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable 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; @@ -882,18 +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, @@ -918,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 = @@ -978,15 +1026,20 @@ public VectorSchemaRoot getVectorSchemaRoot() { Dataset.write() .allocator(allocator) .reader(createReader) - .namespace(namespace) + .namespaceClient(namespaceClient) .tableId(Arrays.asList(tableName)) .mode(WriteParams.WriteMode.CREATE) + .storageOptions(storageOptions) .execute()) { assertEquals(1, dataset.countRows()); } - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable 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); @@ -1037,14 +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(), "createEmptyTable 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"); @@ -1054,12 +1109,13 @@ 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()) { assertEquals(2, ds.countRows(), "Should have 2 rows after overwrite"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); @@ -1080,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 = @@ -1089,13 +1147,15 @@ void testDistributedWriteWithNamespace() throws Exception { new Field("a", FieldType.nullable(new ArrowType.Int(32, true)), null), new Field("b", FieldType.nullable(new ArrowType.Int(32, true)), null))); - // Step 1: Create empty table via namespace - CreateEmptyTableRequest request = new CreateEmptyTableRequest(); + // Step 1: Declare table via namespace + DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - CreateEmptyTableResponse response = namespace.createEmptyTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable 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(); @@ -1106,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<>(); @@ -1131,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); } @@ -1151,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); } @@ -1169,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); } @@ -1183,14 +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"); } } } @@ -1207,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 = @@ -1216,12 +1276,13 @@ void testFragmentCreateAndCommitWithNamespace() throws Exception { new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null), new Field("value", FieldType.nullable(new ArrowType.Int(32, true)), null))); - // Create empty table via namespace - CreateEmptyTableRequest request = new CreateEmptyTableRequest(); + // Declare table via namespace + DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - CreateEmptyTableResponse response = namespace.createEmptyTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); - assertEquals(1, namespace.getCreateCallCount(), "createEmptyTable should be called once"); + assertEquals( + 1, getDeclareCallCount(innerNamespaceClient), "declareTable should be called once"); String tableUri = response.getLocation(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -1232,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"); @@ -1257,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()); @@ -1272,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()); @@ -1296,12 +1354,13 @@ 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()) { assertEquals(6, ds.countRows(), "Should have 6 rows total"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); @@ -1321,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 = @@ -1330,10 +1390,10 @@ void testTransactionCommitWithNamespace() throws Exception { new Field("id", FieldType.nullable(new ArrowType.Int(32, true)), null), new Field("name", FieldType.nullable(new ArrowType.Utf8()), null))); - // Create empty table via namespace - CreateEmptyTableRequest request = new CreateEmptyTableRequest(); + // Declare table via namespace + DeclareTableRequest request = new DeclareTableRequest(); request.setId(Arrays.asList(tableName)); - CreateEmptyTableResponse response = namespace.createEmptyTable(request); + DeclareTableResponse response = namespaceClient.declareTable(request); String tableUri = response.getLocation(); Map namespaceStorageOptions = response.getStorageOptions(); @@ -1344,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)) { @@ -1369,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 @@ -1381,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)) { @@ -1409,33 +1469,371 @@ 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(); - Transaction transaction = - new Transaction.Builder(datasetWithProvider) - .readVersion(datasetWithProvider.version()) + try (Transaction transaction = + new Transaction.Builder() + .readVersion(datasetWithNamespaceClient.version()) .operation(appendOp) - .build(); - - try (Dataset committedDataset = transaction.commit()) { - assertEquals(2, committedDataset.version()); - assertEquals(4, committedDataset.countRows()); + .build()) { + try (Dataset committedDataset = + 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()) { assertEquals(4, ds.countRows(), "Should have 4 rows total"); assertEquals(2, ds.listVersions().size(), "Should have 2 versions"); } } } + + private Map createDirectoryNamespaceS3Config() { + Map config = new HashMap<>(); + config.put("root", "s3://" + BUCKET_NAME + "/" + testPrefix); + config.put("storage.access_key_id", ACCESS_KEY); + config.put("storage.secret_access_key", SECRET_KEY); + config.put("storage.endpoint", ENDPOINT_URL); + config.put("storage.region", REGION); + config.put("storage.allow_http", "true"); + config.put("storage.virtual_hosted_style_request", "false"); + config.put("inline_optimization_enabled", "false"); + // Very high retry count to guarantee all concurrent operations succeed + config.put("commit_retries", "2147483647"); + return config; + } + + private byte[] createTestTableData() throws Exception { + 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), + new Field("age", FieldType.nullable(new ArrowType.Int(32, true)), null))); + + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, testAllocator)) { + IntVector idVector = (IntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + IntVector ageVector = (IntVector) root.getVector("age"); + + idVector.allocateNew(3); + nameVector.allocateNew(3); + ageVector.allocateNew(3); + + idVector.set(0, 1); + nameVector.set(0, "Alice".getBytes()); + ageVector.set(0, 30); + + idVector.set(1, 2); + nameVector.set(1, "Bob".getBytes()); + ageVector.set(1, 25); + + idVector.set(2, 3); + nameVector.set(2, "Charlie".getBytes()); + ageVector.set(2, 35); + + idVector.setValueCount(3); + nameVector.setValueCount(3); + ageVector.setValueCount(3); + root.setRowCount(3); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.writeBatch(); + } + return out.toByteArray(); + } + } + + @Test + void testBasicCreateAndDropOnS3() throws Exception { + DirectoryNamespace namespaceClient = new DirectoryNamespace(); + namespaceClient.initialize(createDirectoryNamespaceS3Config(), testAllocator); + + try { + String tableName = "basic_test_table"; + List tableId = Arrays.asList("test_ns", tableName); + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = new CreateTableRequest().id(tableId); + CreateTableResponse createResp = namespaceClient.createTable(createReq, tableData); + assertNotNull(createResp); + assertNotNull(createResp.getLocation()); + + DropTableRequest dropReq = new DropTableRequest().id(tableId); + DropTableResponse dropResp = namespaceClient.dropTable(dropReq); + assertNotNull(dropResp); + + TableExistsRequest existsReq = new TableExistsRequest().id(tableId); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(existsReq)); + } finally { + namespaceClient.close(); + } + } + + @Test + void testConcurrentCreateAndDropWithSingleInstanceOnS3() throws Exception { + 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")); + namespaceClient.createNamespace(createNsReq); + + int numTables = 10; + ExecutorService executor = Executors.newFixedThreadPool(numTables); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numTables); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + executor.submit( + () -> { + try { + startLatch.await(); + + String tableName = "s3_concurrent_table_" + tableIndex; + List tableId = Arrays.asList("test_ns", tableName); + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = new CreateTableRequest().id(tableId); + namespaceClient.createTable(createReq, tableData); + + DropTableRequest dropReq = new DropTableRequest().id(tableId); + namespaceClient.dropTable(dropReq); + + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(120, TimeUnit.SECONDS), "Timed out waiting for tasks to complete"); + + executor.shutdown(); + assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS)); + + assertEquals(numTables, successCount.get(), "All tasks should succeed"); + assertEquals(0, failCount.get(), "No tasks should fail"); + } finally { + namespaceClient.close(); + } + } + + @Test + void testConcurrentCreateAndDropWithMultipleInstancesOnS3() throws Exception { + Map baseConfig = createDirectoryNamespaceS3Config(); + + // Initialize namespace first with a single instance to ensure __manifest + // table is created and parent namespace exists before concurrent operations + DirectoryNamespace initNs = new DirectoryNamespace(); + initNs.initialize(new HashMap<>(baseConfig), testAllocator); + CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); + initNs.createNamespace(createNsReq); + initNs.close(); + + int numTables = 10; + ExecutorService executor = Executors.newFixedThreadPool(numTables); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numTables); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + List namespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + executor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + startLatch.await(); + + localNs = new DirectoryNamespace(); + localNs.initialize(new HashMap<>(baseConfig), testAllocator); + + synchronized (namespaces) { + namespaces.add(localNs); + } + + String tableName = "s3_multi_ns_table_" + tableIndex; + List tableId = Arrays.asList("test_ns", tableName); + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = new CreateTableRequest().id(tableId); + localNs.createTable(createReq, tableData); + + DropTableRequest dropReq = new DropTableRequest().id(tableId); + localNs.dropTable(dropReq); + + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(120, TimeUnit.SECONDS), "Timed out waiting for tasks to complete"); + + executor.shutdown(); + assertTrue(executor.awaitTermination(30, TimeUnit.SECONDS)); + + for (DirectoryNamespace ns : namespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + assertEquals(numTables, successCount.get(), "All tasks should succeed"); + assertEquals(0, failCount.get(), "No tasks should fail"); + } + + @Test + void testConcurrentCreateThenDropFromDifferentInstanceOnS3() throws Exception { + Map baseConfig = createDirectoryNamespaceS3Config(); + + // Initialize namespace first with a single instance to ensure __manifest + // table is created and parent namespace exists before concurrent operations + DirectoryNamespace initNs = new DirectoryNamespace(); + initNs.initialize(new HashMap<>(baseConfig), testAllocator); + CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); + initNs.createNamespace(createNsReq); + initNs.close(); + + int numTables = 10; + + // First, create all tables using separate namespace instances + ExecutorService createExecutor = Executors.newFixedThreadPool(numTables); + CountDownLatch createStartLatch = new CountDownLatch(1); + CountDownLatch createDoneLatch = new CountDownLatch(numTables); + AtomicInteger createSuccessCount = new AtomicInteger(0); + List createNamespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + createExecutor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + createStartLatch.await(); + + localNs = new DirectoryNamespace(); + localNs.initialize(new HashMap<>(baseConfig), testAllocator); + + synchronized (createNamespaces) { + createNamespaces.add(localNs); + } + + String tableName = "s3_cross_instance_table_" + tableIndex; + List tableId = Arrays.asList("test_ns", tableName); + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = new CreateTableRequest().id(tableId); + localNs.createTable(createReq, tableData); + + createSuccessCount.incrementAndGet(); + } catch (Exception e) { + // Ignore + } finally { + createDoneLatch.countDown(); + } + }); + } + + createStartLatch.countDown(); + assertTrue(createDoneLatch.await(120, TimeUnit.SECONDS), "Timed out waiting for creates"); + createExecutor.shutdown(); + + assertEquals(numTables, createSuccessCount.get(), "All creates should succeed"); + + // Close create namespaces + for (DirectoryNamespace ns : createNamespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + // Now drop all tables using NEW namespace instances + ExecutorService dropExecutor = Executors.newFixedThreadPool(numTables); + CountDownLatch dropStartLatch = new CountDownLatch(1); + CountDownLatch dropDoneLatch = new CountDownLatch(numTables); + AtomicInteger dropSuccessCount = new AtomicInteger(0); + AtomicInteger dropFailCount = new AtomicInteger(0); + List dropNamespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + dropExecutor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + dropStartLatch.await(); + + localNs = new DirectoryNamespace(); + localNs.initialize(new HashMap<>(baseConfig), testAllocator); + + synchronized (dropNamespaces) { + dropNamespaces.add(localNs); + } + + String tableName = "s3_cross_instance_table_" + tableIndex; + List tableId = Arrays.asList("test_ns", tableName); + + DropTableRequest dropReq = new DropTableRequest().id(tableId); + localNs.dropTable(dropReq); + + dropSuccessCount.incrementAndGet(); + } catch (Exception e) { + dropFailCount.incrementAndGet(); + } finally { + dropDoneLatch.countDown(); + } + }); + } + + dropStartLatch.countDown(); + assertTrue(dropDoneLatch.await(120, TimeUnit.SECONDS), "Timed out waiting for drops"); + dropExecutor.shutdown(); + + // Close drop namespaces + for (DirectoryNamespace ns : dropNamespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + assertEquals(numTables, dropSuccessCount.get(), "All drops should succeed"); + assertEquals(0, dropFailCount.get(), "No drops should fail"); + } } diff --git a/java/src/test/java/org/lance/FileReaderWriterTest.java b/java/src/test/java/org/lance/FileReaderWriterTest.java index c645acdcaa2..3dbe8a67882 100644 --- a/java/src/test/java/org/lance/FileReaderWriterTest.java +++ b/java/src/test/java/org/lance/FileReaderWriterTest.java @@ -13,6 +13,9 @@ */ package org.lance; +import org.lance.file.BlobReadMode; +import org.lance.file.FileReadOptions; +import org.lance.file.FileWriteOptions; import org.lance.file.LanceFileReader; import org.lance.file.LanceFileWriter; import org.lance.util.Range; @@ -20,11 +23,14 @@ import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.FieldVector; +import org.apache.arrow.vector.LargeVarBinaryVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.apache.arrow.vector.util.Text; import org.junit.jupiter.api.Assertions; @@ -37,6 +43,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.Map; +import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -204,6 +211,31 @@ void testBasicWrite(@TempDir Path tempDir) throws Exception { createSimpleFile(filePath); } + @Test + void testWriteRejectsZeroMaxPageBytes(@TempDir Path tempDir) { + String filePath = tempDir.resolve("page_options.lance").toString(); + FileWriteOptions options = FileWriteOptions.builder().maxPageBytes(0).build(); + + IllegalArgumentException error = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> { + try (BufferAllocator allocator = new RootAllocator(); + LanceFileWriter writer = + LanceFileWriter.open( + filePath, + allocator, + null, + Optional.empty(), + Collections.emptyMap(), + options); + VectorSchemaRoot batch = createBatch(allocator)) { + writer.write(batch); + } + }); + assertTrue(error.getMessage().contains("max_page_bytes must be greater than 0, got 0")); + } + @Test void testWriteNoData(@TempDir Path tempDir) throws Exception { String filePath = tempDir.resolve("no_data.lance").toString(); @@ -251,7 +283,9 @@ void testInvalidPath() { LanceFileReader.open("/tmp/does_not_exist.lance", allocator); fail("Expected LanceException to be thrown"); } catch (IOException e) { - assertTrue(e.getMessage().contains("Object at location /tmp/does_not_exist.lance not found")); + String message = e.getMessage(); + assertTrue(message.contains("/tmp/does_not_exist.lance")); + assertTrue(message.toLowerCase().contains("not found")); } try { LanceFileReader.open("", allocator); @@ -304,4 +338,94 @@ void testWriteNullSchemaMetadata(@TempDir Path tempDir) throws Exception { } } } + + private void writeBlobFile(String filePath, BufferAllocator allocator) throws Exception { + Map blobMetadata = new HashMap<>(); + blobMetadata.put("lance-encoding:blob", "true"); + + Field blobField = + new Field( + "blob_data", + new FieldType(true, ArrowType.LargeBinary.INSTANCE, null, blobMetadata), + Collections.emptyList()); + + Schema schema = new Schema(Collections.singletonList(blobField), null); + + try (LanceFileWriter writer = + LanceFileWriter.open(filePath, allocator, null, Collections.emptyMap())) { + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + root.allocateNew(); + + LargeVarBinaryVector blobVector = (LargeVarBinaryVector) root.getVector("blob_data"); + + for (int i = 0; i < 5; i++) { + byte[] data = new byte[100 * (i + 1)]; + Arrays.fill(data, (byte) i); + blobVector.setSafe(i, data); + } + + root.setRowCount(5); + writer.write(root); + } + } + } + + @Test + void testBlobDescriptorMode(@TempDir Path tempDir) throws Exception { + String filePath = tempDir.resolve("test_blob.lance").toString(); + BufferAllocator allocator = new RootAllocator(); + writeBlobFile(filePath, allocator); + + try (LanceFileReader reader = LanceFileReader.open(filePath, allocator)) { + assertTrue( + reader.schema().getFields().get(0).getMetadata().containsKey("lance-encoding:blob"), + "Blob metadata should be preserved in schema"); + + FileReadOptions options = + FileReadOptions.builder().blobReadMode(BlobReadMode.DESCRIPTOR).build(); + try (ArrowReader batch = + reader.readAll(Collections.singletonList("blob_data"), null, 10, options)) { + assertTrue(batch.loadNextBatch()); + VectorSchemaRoot root = batch.getVectorSchemaRoot(); + assertEquals(5, root.getRowCount()); + + FieldVector column = root.getVector("blob_data"); + assertTrue( + column.getField().getType() instanceof ArrowType.Struct, + "DESCRIPTOR mode should return Struct but got " + column.getField().getType()); + assertEquals( + 2, + column.getField().getChildren().size(), + "Struct should have 2 fields (position and size)"); + } + } + allocator.close(); + } + + @Test + void testBlobContentMode(@TempDir Path tempDir) throws Exception { + String filePath = tempDir.resolve("test_blob.lance").toString(); + BufferAllocator allocator = new RootAllocator(); + writeBlobFile(filePath, allocator); + + try (LanceFileReader reader = LanceFileReader.open(filePath, allocator)) { + // Default readAll (no BlobReadMode) should return materialized binary + try (ArrowReader batch = reader.readAll(Collections.singletonList("blob_data"), null, 10)) { + assertTrue(batch.loadNextBatch()); + VectorSchemaRoot root = batch.getVectorSchemaRoot(); + assertEquals(5, root.getRowCount()); + + FieldVector column = root.getVector("blob_data"); + assertTrue( + column.getField().getType() instanceof ArrowType.LargeBinary, + "CONTENT mode should return LargeBinary but got " + column.getField().getType()); + + LargeVarBinaryVector binaryVector = (LargeVarBinaryVector) column; + for (int i = 0; i < 5; i++) { + assertEquals(100 * (i + 1), binaryVector.get(i).length); + } + } + } + allocator.close(); + } } diff --git a/java/src/test/java/org/lance/FragmentTest.java b/java/src/test/java/org/lance/FragmentTest.java index c1e646f492b..40beea1589b 100644 --- a/java/src/test/java/org/lance/FragmentTest.java +++ b/java/src/test/java/org/lance/FragmentTest.java @@ -13,30 +13,52 @@ */ package org.lance; +import org.lance.file.FileWriteOptions; +import org.lance.fragment.DeletionFile; +import org.lance.fragment.DeletionFileType; import org.lance.fragment.FragmentMergeResult; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; import org.lance.operation.Merge; +import org.lance.operation.Project; import org.lance.operation.Update; +import org.lance.schema.LanceField; +import org.apache.arrow.c.ArrowArrayStream; +import org.apache.arrow.c.Data; import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.UInt8Vector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; +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.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; @@ -44,6 +66,18 @@ import static org.junit.jupiter.api.Assertions.assertTrue; public class FragmentTest { + @Test + void testDeletionFileRelativePath() { + DeletionFile bitmap = new DeletionFile(7, 11, 2L, DeletionFileType.BITMAP, null); + DeletionFile array = new DeletionFile(7, 11, 2L, DeletionFileType.ARRAY, null); + DeletionFile highBitId = new DeletionFile(-1L, 11, 2L, DeletionFileType.BITMAP, null); + + assertEquals("_deletions/5-11-7.bin", bitmap.getRelativePath(5)); + assertEquals("_deletions/5-11-7.arrow", array.getRelativePath(5)); + assertEquals("_deletions/5-11-18446744073709551615.bin", highBitId.getRelativePath(5)); + assertThrows(IllegalArgumentException.class, () -> bitmap.getRelativePath(-1)); + } + @Test void testFragmentCreateFfiArray(@TempDir Path tempDir) { String datasetPath = tempDir.resolve("new_fragment_array").toString(); @@ -55,6 +89,24 @@ void testFragmentCreateFfiArray(@TempDir Path tempDir) { } } + @Test + void testFragmentWriteRejectsZeroMaxPageBytes(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("zero_max_page_bytes").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + WriteParams params = + new WriteParams.Builder() + .withFileWriteOptions(FileWriteOptions.builder().maxPageBytes(0).build()) + .build(); + + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> testDataset.createNewFragment(3, params)); + assertTrue(error.getMessage().contains("max_page_bytes must be greater than 0, got 0")); + } + } + @Test void testFragmentCreate(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("new_fragment").toString(); @@ -64,6 +116,7 @@ void testFragmentCreate(@TempDir Path tempDir) throws Exception { testDataset.createEmptyDataset().close(); int rowCount = 21; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); + assertEquals(fragmentMeta.getFiles(), fragmentMeta.getReferencedLanceFiles()); // Commit fragment FragmentOperation.Append appendOp = new FragmentOperation.Append(Arrays.asList(fragmentMeta)); @@ -81,6 +134,128 @@ void testFragmentCreate(@TempDir Path tempDir) throws Exception { } } + @Test + void testWriteFragmentWithSchemaOverride(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("fragment_schema_override").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + List fieldList = + new ArrayList<>(testDataset.getSchema().getFields()); + Collections.reverse(fieldList); + + try (Transaction projectTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Project.builder().schema(new Schema(fieldList)).build()) + .build(); + Dataset evolvedDataset = new CommitBuilder(dataset).execute(projectTxn); + VectorSchemaRoot root = + VectorSchemaRoot.create(evolvedDataset.getSchema(), allocator)) { + root.allocateNew(); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + IntVector idVector = (IntVector) root.getVector("id"); + nameVector.setSafe(0, "Person 1".getBytes(StandardCharsets.UTF_8)); + idVector.setSafe(0, 1); + root.setRowCount(1); + + List fragments = + Fragment.write() + .datasetUri(datasetPath) + .allocator(allocator) + .data(root) + .schema(evolvedDataset.getLanceSchema()) + .mode(WriteParams.WriteMode.APPEND) + .execute(); + + assertEquals(1, fragments.size()); + assertEquals(1, fragments.get(0).getPhysicalRows()); + assertArrayEquals( + evolvedDataset.getLanceSchema().fields().stream() + .mapToInt(LanceField::getId) + .toArray(), + fragments.get(0).getFiles().get(0).getFields()); + + FragmentOperation.Append appendOp = new FragmentOperation.Append(fragments); + try (Dataset appendedDataset = + Dataset.commit( + allocator, datasetPath, appendOp, Optional.of(evolvedDataset.version())); + ArrowReader reader = appendedDataset.newScan().scanBatches()) { + assertEquals(3, appendedDataset.version()); + assertEquals(1, appendedDataset.countRows()); + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot batch = reader.getVectorSchemaRoot(); + assertEquals(1, batch.getRowCount()); + assertEquals( + "Person 1", + new String( + ((VarCharVector) batch.getVector("name")).get(0), StandardCharsets.UTF_8)); + assertEquals(1, ((IntVector) batch.getVector("id")).get(0)); + } + } + } + } + } + + @Test + void testWriteFragmentWithSession(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("fragment_with_session").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + Session session = Session.builder().build()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + long sizeBefore = session.sizeBytes(); + try (VectorSchemaRoot root = VectorSchemaRoot.create(testDataset.getSchema(), allocator)) { + root.allocateNew(); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + IntVector idVector = (IntVector) root.getVector("id"); + nameVector.setSafe(0, "Person 1".getBytes(StandardCharsets.UTF_8)); + idVector.setSafe(0, 1); + root.setRowCount(1); + + // First append in APPEND mode without an explicit schema: the + // manifest load for schema inference populates the shared session's + // metadata cache. + List firstFragments = + appendWithSession(datasetPath, allocator, root, session); + assertEquals(1, firstFragments.size()); + assertEquals(1, firstFragments.get(0).getPhysicalRows()); + assertTrue(session.sizeBytes() > sizeBefore); + long hitsAfterFirst = session.metadataCacheStats().getHits(); + + // Second append through the same session: schema inference reads the + // manifest cached by the first write, so cache hits must increase. + List secondFragments = + appendWithSession(datasetPath, allocator, root, session); + assertEquals(1, secondFragments.size()); + assertEquals(1, secondFragments.get(0).getPhysicalRows()); + assertTrue(session.metadataCacheStats().getHits() > hitsAfterFirst); + + // A closed session has a zero native handle and degrades to "no + // session", matching Dataset's behavior for closed sessions. + Session closedSession = Session.builder().build(); + closedSession.close(); + List fragmentsWithClosedSession = + appendWithSession(datasetPath, allocator, root, closedSession); + assertEquals(1, fragmentsWithClosedSession.size()); + } + } + } + + private static List appendWithSession( + String datasetPath, RootAllocator allocator, VectorSchemaRoot root, Session session) { + return Fragment.write() + .datasetUri(datasetPath) + .allocator(allocator) + .data(root) + .mode(WriteParams.WriteMode.APPEND) + .session(session) + .execute(); + } + @Test void commitWithoutVersion(@TempDir Path tempDir) { String datasetPath = tempDir.resolve("commit_without_version").toString(); @@ -209,8 +384,15 @@ void testDeleteRows(@TempDir Path tempDir) throws IOException { assertNotNull(updateFragment.getDeletionFile()); Update update = - Update.builder().updatedFragments(Collections.singletonList(updateFragment)).build(); - Dataset dataset3 = dataset2.newTransactionBuilder().operation(update).build().commit(); + Update.builder() + .updatedFragments(Collections.singletonList(updateFragment)) + .updateMode(Optional.of(Update.UpdateMode.RewriteRows)) + .build(); + Dataset dataset3; + try (Transaction txn = + new Transaction.Builder().readVersion(dataset2.version()).operation(update).build()) { + dataset3 = new CommitBuilder(dataset2).execute(txn); + } assertEquals(totalRows - deleteCount, dataset3.countRows()); @@ -225,8 +407,15 @@ void testDeleteRows(@TempDir Path tempDir) throws IOException { assertNotNull(updateFragment.getDeletionFile()); update = - Update.builder().updatedFragments(Collections.singletonList(updateFragment)).build(); - Dataset dataset4 = dataset3.newTransactionBuilder().operation(update).build().commit(); + Update.builder() + .updatedFragments(Collections.singletonList(updateFragment)) + .updateMode(Optional.of(Update.UpdateMode.RewriteRows)) + .build(); + Dataset dataset4; + try (Transaction txn = + new Transaction.Builder().readVersion(dataset3.version()).operation(update).build()) { + dataset4 = new CommitBuilder(dataset3).execute(txn); + } assertEquals(totalRows - deleteCount - deleteCount2, dataset4.countRows()); // Case 3. Test delete all rows @@ -241,8 +430,13 @@ void testDeleteRows(@TempDir Path tempDir) throws IOException { update = Update.builder() .removedFragmentIds(Collections.singletonList(Long.valueOf(fragment.getId()))) + .updateMode(Optional.of(Update.UpdateMode.RewriteRows)) .build(); - Dataset dataset5 = dataset4.newTransactionBuilder().operation(update).build().commit(); + Dataset dataset5; + try (Transaction txn = + new Transaction.Builder().readVersion(dataset4.version()).operation(update).build()) { + dataset5 = new CommitBuilder(dataset4).execute(txn); + } assertEquals(0, dataset5.countRows()); } @@ -279,7 +473,6 @@ void testMergeColumns(@TempDir Path tempDir) throws Exception { // Commit fragment FragmentOperation.Append appendOp = new FragmentOperation.Append(Arrays.asList(fragmentMeta)); - Transaction transaction; try (Dataset dataset = Dataset.commit(allocator, datasetPath, appendOp, Optional.of(1L))) { assertEquals(2, dataset.version()); assertEquals(2, dataset.latestVersion()); @@ -293,47 +486,257 @@ void testMergeColumns(@TempDir Path tempDir) throws Exception { FragmentMergeResult mergeResult = testDataset.mergeColumn(fragment, 10); - Transaction.Builder builder = new Transaction.Builder(dataset); - transaction = - builder + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Merge.builder() .fragments(Collections.singletonList(mergeResult.getFragmentMetadata())) .schema(mergeResult.getSchema().asArrowSchema()) .build()) - .readVersion(dataset.version()) - .build(); + .build()) { + try (Dataset newDs = new CommitBuilder(dataset).execute(transaction)) { + assertEquals(3, newDs.version()); + assertEquals(3, newDs.latestVersion()); + Fragment newFrag = newDs.getFragments().get(0); + try (LanceScanner scanner = newFrag.newScan()) { + Schema schemaRes = scanner.schema(); + assertTrue( + schemaRes.getFields().stream() + .anyMatch(field -> field.getName().equals("new_col1"))); + assertTrue( + schemaRes.getFields().stream() + .anyMatch(field -> field.getName().equals("new_col2"))); + + try (ArrowReader reader = scanner.scanBatches()) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + VarCharVector newCol1Vec = (VarCharVector) root.getVector("new_col1"); + VarCharVector newCol2Vec = (VarCharVector) root.getVector("new_col2"); + assertEquals(21, newCol2Vec.getValueCount()); + + // The first 10 rows are not null + assertNotNull(newCol1Vec.get(9)); + // Remaining rows are null + assertNull(newCol1Vec.get(10)); + } + } + } + } + } + } + } + + @Test + void testFragmentStatistics(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("fragment_statistics").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + // Two fragments with different row counts + FragmentMetadata frag1 = testDataset.createNewFragment(21); + FragmentMetadata frag2 = testDataset.createNewFragment(9); + FragmentOperation.Append appendOp = new FragmentOperation.Append(Arrays.asList(frag1, frag2)); + try (Dataset dataset = Dataset.commit(allocator, datasetPath, appendOp, Optional.of(1L))) { + List fragments = dataset.getFragments(); + FragmentStatistics stats = dataset.getFragmentStatistics(); + assertEquals(fragments.size(), stats.size()); + + // Parity with getFragments across all three arrays + assertArrayEquals(fragments.stream().mapToInt(Fragment::getId).toArray(), stats.getIds()); + assertArrayEquals( + fragments.stream().mapToLong(f -> f.metadata().getNumRows()).toArray(), + stats.getRowCounts()); + assertArrayEquals( + fragments.stream().mapToInt(f -> f.metadata().getFiles().size()).toArray(), + stats.getDataFileNums()); + + assertEquals(30, Arrays.stream(stats.getRowCounts()).sum()); + + dataset.delete("id < 5"); + assertArrayEquals(new long[] {16, 4}, dataset.getFragmentStatistics().getRowCounts()); + } + } + } + + @Test + void testFragmentStatisticsOnEmptyDataset(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("fragment_statistics_empty").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + assertEquals(0, dataset.getFragmentStatistics().size()); + } + } + } + + @Test + void testFragmentStatisticsPreservesLegacyMissingRowCount() { + String historicalPath = + Path.of("..", "test_data", "v0.7.5", "with_deletions") + .toAbsolutePath() + .normalize() + .toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + Dataset dataset = Dataset.open(historicalPath, allocator)) { + FragmentStatistics stats = dataset.getFragmentStatistics(); + assertArrayEquals(new int[] {0}, stats.getIds()); + assertArrayEquals(new long[] {0}, stats.getRowCounts()); + assertArrayEquals(new int[] {1}, stats.getDataFileNums()); + } + } + + @Test + void testCountRowsConcurrentWithClose(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("count_rows_close_race").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + FragmentMetadata fragmentMeta = testDataset.createNewFragment(100); + FragmentOperation.Append appendOp = new FragmentOperation.Append(Arrays.asList(fragmentMeta)); + Dataset dataset = Dataset.commit(allocator, datasetPath, appendOp, Optional.of(1L)); + Fragment fragment = dataset.getFragments().get(0); + + int threadCount = 8; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + for (int i = 0; i < threadCount; i++) { + futures.add( + executor.submit( + () -> { + start.await(); + // Hammer countRows until close() wins the race. The only acceptable + // failure is the "Dataset is closed" rejection; anything else (a native + // crash or "Null pointer in rust value from Java") means the native + // handle was released while still in use. + while (true) { + try { + fragment.countRows(); + } catch (IllegalArgumentException e) { + assertEquals("Dataset is closed", e.getMessage()); + return null; + } + } + })); + } + start.countDown(); + Thread.sleep(50); + dataset.close(); + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + } finally { + executor.shutdownNow(); + } + } + } - assertNotNull(transaction); - - try (Dataset newDs = transaction.commit()) { - assertEquals(3, newDs.version()); - assertEquals(3, newDs.latestVersion()); - Fragment newFrag = newDs.getFragments().get(0); - try (LanceScanner scanner = newFrag.newScan()) { - Schema schemaRes = scanner.schema(); - assertTrue( - schemaRes.getFields().stream() - .anyMatch(field -> field.getName().equals("new_col1"))); - assertTrue( - schemaRes.getFields().stream() - .anyMatch(field -> field.getName().equals("new_col2"))); - - try (ArrowReader reader = scanner.scanBatches()) { - assertTrue(reader.loadNextBatch()); - VectorSchemaRoot root = reader.getVectorSchemaRoot(); - VarCharVector newCol1Vec = (VarCharVector) root.getVector("new_col1"); - VarCharVector newCol2Vec = (VarCharVector) root.getVector("new_col2"); - assertEquals(21, newCol2Vec.getValueCount()); - - // The first 10 rows are not null - assertNotNull(newCol1Vec.get(9)); - // Remaining rows are null - assertNull(newCol1Vec.get(10)); + @Test + void testAddColumnsByReader(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("testAddColumnsByReader").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.MergeColumnTestDataset testDataset = + new TestUtils.MergeColumnTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + int rowCount = 21; + FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); + FragmentOperation.Append appendOp = new FragmentOperation.Append(Arrays.asList(fragmentMeta)); + try (Dataset dataset = Dataset.commit(allocator, datasetPath, appendOp, Optional.of(1L))) { + Fragment fragment = dataset.getFragments().get(0); + + // The stream carries only the new column, one value per fragment row in row order; + // several small batches exercise the positional zip across batch boundaries. A read + // batch size of 5 against stream batches of 8 forces stream batches to be sliced at + // non-zero offsets, which the nulls in the stream must survive. + FragmentMergeResult result; + try (ArrowStreamReader reader = newColumnStream(allocator, rowCount, 8); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + result = fragment.addColumns(stream, Optional.of(5L)); + } + + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Merge.builder() + .fragments(Collections.singletonList(result.getFragmentMetadata())) + .schema(result.getSchema().asArrowSchema()) + .build()) + .build()) { + try (Dataset newDs = new CommitBuilder(dataset).execute(transaction)) { + try (LanceScanner scanner = newDs.getFragments().get(0).newScan(); + ArrowReader batches = scanner.scanBatches()) { + int row = 0; + while (batches.loadNextBatch()) { + VectorSchemaRoot root = batches.getVectorSchemaRoot(); + BigIntVector val = (BigIntVector) root.getVector("val"); + for (int i = 0; i < root.getRowCount(); i++, row++) { + if (row % 3 == 0) { + assertTrue(val.isNull(i), "row " + row + " should be null"); + } else { + assertEquals(row * 2L, val.get(i)); + } + } + } + assertEquals(rowCount, row); } } } + + // The stream must cover every live row exactly once: one row short or one row long + // must fail instead of silently misaligning values. + assertAddColumnsRejected(allocator, fragment, rowCount - 1, Optional.empty()); + assertAddColumnsRejected(allocator, fragment, rowCount + 1, Optional.empty()); + // The batch size must fit a u32. + assertAddColumnsRejected(allocator, fragment, rowCount, Optional.of(-1L)); + } + } + } + + private static void assertAddColumnsRejected( + RootAllocator allocator, Fragment fragment, int rows, Optional batchSize) + throws IOException { + try (ArrowStreamReader reader = newColumnStream(allocator, rows, 8); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + assertThrows(IllegalArgumentException.class, () -> fragment.addColumns(stream, batchSize)); + } + } + + private static ArrowStreamReader newColumnStream(RootAllocator allocator, int rows, int batchSize) + throws IOException { + Schema schema = + new Schema( + Collections.singletonList(Field.nullable("val", new ArrowType.Int(64, true))), null); + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator); + ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + for (int start = 0; start < rows; start += batchSize) { + int batchRows = Math.min(batchSize, rows - start); + root.allocateNew(); + BigIntVector val = (BigIntVector) root.getVector("val"); + for (int i = 0; i < batchRows; i++) { + // Every third row is null to check nulls line up with their rows. + if ((start + i) % 3 == 0) { + val.setNull(i); + } else { + val.setSafe(i, (start + i) * 2L); + } + } + root.setRowCount(batchRows); + writer.writeBatch(); } + writer.end(); } + return new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), allocator); } } diff --git a/java/src/test/java/org/lance/JNITest.java b/java/src/test/java/org/lance/JNITest.java index 4b09de66631..2e1c77bdc37 100644 --- a/java/src/test/java/org/lance/JNITest.java +++ b/java/src/test/java/org/lance/JNITest.java @@ -18,8 +18,10 @@ import org.lance.index.vector.HnswBuildParams; 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.ipc.ApproxMode; import org.lance.ipc.Query; import org.lance.test.JniTestHelper; @@ -28,6 +30,7 @@ import java.util.Arrays; import java.util.Optional; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; public class JNITest { @@ -48,6 +51,19 @@ public void testIntsOpt() { @Test public void testQuery() { + Query defaultQuery = + new Query.Builder().setColumn("column").setKey(new float[] {1.0f, 2.0f, 3.0f}).build(); + assertEquals(ApproxMode.NORMAL, defaultQuery.getApproxMode()); + + Query nprobesQuery = + new Query.Builder() + .setColumn("column") + .setKey(new float[] {1.0f, 2.0f, 3.0f}) + .setNprobes(20) + .build(); + assertEquals(20, nprobesQuery.getMinimumNprobes()); + assertEquals(Optional.of(20), nprobesQuery.getMaximumNprobes()); + JniTestHelper.parseQuery( Optional.of( new Query.Builder() @@ -59,6 +75,8 @@ public void testQuery() { .setRefineFactor(40) .setDistanceType(DistanceType.L2) .setUseIndex(true) + .setQueryParallelism(-1) + .setApproxMode(ApproxMode.ACCURATE) .build())); } @@ -70,6 +88,11 @@ public void testIvfFlatIndexParams() { .build()); } + @Test + public void testRqBuildParamsDefaultNumBits() { + assertEquals((byte) 5, new RQBuildParams.Builder().build().getNumBits()); + } + @Test public void testIvfPqIndexParams() { JniTestHelper.parseIndexParams( diff --git a/java/src/test/java/org/lance/MergeInsertTest.java b/java/src/test/java/org/lance/MergeInsertTest.java index c36ec26b4fa..a59fcc1262b 100644 --- a/java/src/test/java/org/lance/MergeInsertTest.java +++ b/java/src/test/java/org/lance/MergeInsertTest.java @@ -13,6 +13,10 @@ */ package org.lance; +import org.lance.index.IndexOptions; +import org.lance.index.IndexParams; +import org.lance.index.IndexType; +import org.lance.index.scalar.ScalarIndexParams; import org.lance.merge.MergeInsertParams; import org.lance.merge.MergeInsertResult; @@ -25,12 +29,17 @@ import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.ipc.ArrowStreamReader; import org.apache.arrow.vector.ipc.ArrowStreamWriter; +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.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.junit.jupiter.params.provider.EnumSource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; @@ -40,10 +49,29 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.TreeMap; import java.util.UUID; public class MergeInsertTest { + private static final Schema WIDE_SCHEMA = + new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(32, true)), + Field.nullable("name", new ArrowType.Utf8()), + Field.nullable("score", new ArrowType.Int(32, true)))); + + /** {@link #WIDE_SCHEMA} minus "score", so a source over it is a partial-schema update. */ + private static final Schema PARTIAL_SOURCE_SCHEMA = + new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(32, true)), + Field.nullable("name", new ArrowType.Utf8()))); + + /** {@link #createWideDataset()} after the rows with id 1 and 4 are renamed. */ + private static final String WIDE_AFTER_UPDATE = + "{0=Person 0/0, 1=Updated 1/10, 2=Person 2/20, 3=Person 3/30, 4=Updated 4/40, 5=Person 5/50}"; + @TempDir private Path tempDir; private RootAllocator allocator; private TestUtils.SimpleTestDataset testDataset; @@ -64,15 +92,39 @@ public void tearDown() { allocator.close(); } - @Test - public void testWhenNotMatchedInsertAll() throws Exception { + @ParameterizedTest + @EnumSource(DataStorageVersion.class) + public void testWhenNotMatchedInsertAll(DataStorageVersion version) throws Exception { // Test insert all unmatched source rows try (VectorSchemaRoot source = buildSource(testDataset.getSchema(), allocator)) { try (ArrowArrayStream sourceStream = convertToStream(source, allocator)) { - MergeInsertResult result = - dataset.mergeInsert( - new MergeInsertParams(Collections.singletonList("id")), sourceStream); + MergeInsertParams params = + new MergeInsertParams(Collections.singletonList("id")).withDataStorageVersion(version); + if (version == DataStorageVersion.LEGACY) { + IllegalArgumentException error = + Assertions.assertThrows( + IllegalArgumentException.class, () -> dataset.mergeInsert(params, sourceStream)); + Assertions.assertTrue(error.getMessage().contains("V1 and V2")); + return; + } + String exactVersion = + version == DataStorageVersion.STABLE + ? "2.2" + : version == DataStorageVersion.NEXT ? "2.3" : version.toRustString(); + MergeInsertResult result = dataset.mergeInsert(params, sourceStream); + + Assertions.assertTrue( + result.dataset().getFragments().stream() + .filter( + fragment -> + fragment.metadata().getId() + != dataset.getFragments().get(0).metadata().getId()) + .flatMap(fragment -> fragment.metadata().getFiles().stream()) + .allMatch( + file -> + exactVersion.equals( + file.getFileMajorVersion() + "." + file.getFileMinorVersion()))); Assertions.assertEquals( "{0=Person 0, 1=Person 1, 2=Person 2, 3=Person 3, 4=Person 4, 7=Source 7, 8=Source 8, 9=Source 9}", @@ -275,6 +327,171 @@ private ArrowArrayStream convertToStream(VectorSchemaRoot root, RootAllocator al return stream; } + @Test + public void testMergeInsertWithoutIndex() throws Exception { + // Verify that merge insert with useIndex=false still completes and + // produces results consistent with the default (useIndex=true). + + try (VectorSchemaRoot source = buildSource(testDataset.getSchema(), allocator)) { + try (ArrowArrayStream sourceStream = convertToStream(source, allocator)) { + MergeInsertResult result = + dataset.mergeInsert( + new MergeInsertParams(Collections.singletonList("id")) + .withMatchedUpdateAll() + .withNotMatched(MergeInsertParams.WhenNotMatched.InsertAll) + .withUseIndex(false), + sourceStream); + + Assertions.assertEquals( + "{0=Source 0, 1=Source 1, 2=Source 2, 3=Person 3, 4=Person 4, 7=Source 7, 8=Source 8, 9=Source 9}", + readAll(result.dataset()).toString(), + "merge insert with useIndex=false should produce correct upsert results"); + } + } + } + + @ParameterizedTest + @CsvSource({"Auto, 2", "RewriteRows, 3"}) + public void testWriteModeOnIndexedPartialUpdate( + MergeInsertParams.MergeWriteMode writeMode, int expectedFragments) throws Exception { + try (Dataset wideDataset = createWideDataset()) { + createIdIndex(wideDataset); + + try (VectorSchemaRoot source = buildPartialSource(1, 4); + ArrowArrayStream sourceStream = convertToStream(source, allocator)) { + MergeInsertResult result = + wideDataset.mergeInsert( + new MergeInsertParams(Collections.singletonList("id")) + .withMatchedUpdateAll() + .withNotMatched(MergeInsertParams.WhenNotMatched.DoNothing) + .withWriteMode(writeMode), + sourceStream); + + Assertions.assertEquals(2, result.stats().numUpdatedRows()); + try (Dataset merged = result.dataset()) { + Assertions.assertEquals(expectedFragments, merged.getFragments().size()); + Assertions.assertEquals(WIDE_AFTER_UPDATE, readWide(merged).toString()); + } + } + } + } + + @Test + public void testWriteModeRewriteColumns() throws Exception { + try (Dataset wideDataset = createWideDataset()) { + try (VectorSchemaRoot source = buildPartialSource(1, 4); + ArrowArrayStream sourceStream = convertToStream(source, allocator)) { + MergeInsertResult result = + wideDataset.mergeInsert( + new MergeInsertParams(Collections.singletonList("id")) + .withMatchedUpdateAll() + .withNotMatched(MergeInsertParams.WhenNotMatched.DoNothing) + .withWriteMode(MergeInsertParams.MergeWriteMode.RewriteColumns), + sourceStream); + + Assertions.assertEquals(2, result.stats().numUpdatedRows()); + try (Dataset merged = result.dataset()) { + Assertions.assertEquals(2, merged.getFragments().size()); + // "score", which the source omits, is carried through untouched. + Assertions.assertEquals(WIDE_AFTER_UPDATE, readWide(merged).toString()); + } + } + } + } + + @Test + public void testWriteModeRewriteColumnsRejectsInserts() throws Exception { + + try (Dataset wideDataset = createWideDataset()) { + try (VectorSchemaRoot source = buildPartialSource(100); + ArrowArrayStream sourceStream = convertToStream(source, allocator)) { + Exception e = + Assertions.assertThrows( + Exception.class, + () -> + wideDataset.mergeInsert( + // WhenNotMatched defaults to InsertAll. + new MergeInsertParams(Collections.singletonList("id")) + .withMatchedUpdateAll() + .withWriteMode(MergeInsertParams.MergeWriteMode.RewriteColumns) + .withConflictRetries(0), + sourceStream)); + Assertions.assertTrue( + e.getMessage().contains("RewriteColumns cannot express"), + "expected the RewriteColumns rejection, got: " + e.getMessage()); + } + } + } + + /** A dataset of 6 rows over 2 fragments, whose "score" column no partial source carries. */ + private Dataset createWideDataset() { + String path = tempDir.resolve(UUID.randomUUID().toString()).toString(); + Dataset.create(allocator, path, WIDE_SCHEMA, new WriteParams.Builder().build()).close(); + + List fragments; + try (VectorSchemaRoot root = VectorSchemaRoot.create(WIDE_SCHEMA, allocator)) { + root.allocateNew(); + IntVector idVector = (IntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + IntVector scoreVector = (IntVector) root.getVector("score"); + for (int i = 0; i < 6; i++) { + idVector.setSafe(i, i); + nameVector.setSafe(i, ("Person " + i).getBytes(StandardCharsets.UTF_8)); + scoreVector.setSafe(i, i * 10); + } + root.setRowCount(6); + fragments = + Fragment.create( + path, allocator, root, new WriteParams.Builder().withMaxRowsPerFile(3).build()); + } + + Dataset wideDataset = + Dataset.commit(allocator, path, new FragmentOperation.Append(fragments), Optional.of(1L)); + Assertions.assertEquals(2, wideDataset.getFragments().size()); + return wideDataset; + } + + private void createIdIndex(Dataset dataset) { + IndexParams indexParams = + IndexParams.builder().setScalarIndexParams(ScalarIndexParams.create("btree", "{}")).build(); + dataset.createIndex( + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("id_btree") + .replace(true) + .build()); + } + + /** A source over {@link #WIDE_SCHEMA} minus "score", renaming the rows it names. */ + private VectorSchemaRoot buildPartialSource(int... ids) { + VectorSchemaRoot source = VectorSchemaRoot.create(PARTIAL_SOURCE_SCHEMA, allocator); + source.allocateNew(); + IntVector idVector = (IntVector) source.getVector("id"); + VarCharVector nameVector = (VarCharVector) source.getVector("name"); + for (int i = 0; i < ids.length; i++) { + idVector.setSafe(i, ids[i]); + nameVector.setSafe(i, ("Updated " + ids[i]).getBytes(StandardCharsets.UTF_8)); + } + source.setRowCount(ids.length); + return source; + } + + /** Reads a {@link #WIDE_SCHEMA} dataset as id -> "name/score". */ + private TreeMap readWide(Dataset dataset) throws Exception { + TreeMap rows = new TreeMap<>(); + try (ArrowReader reader = dataset.newScan().scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot batch = reader.getVectorSchemaRoot(); + IntVector ids = (IntVector) batch.getVector("id"); + VarCharVector names = (VarCharVector) batch.getVector("name"); + IntVector scores = (IntVector) batch.getVector("score"); + for (int i = 0; i < batch.getRowCount(); i++) { + rows.put(ids.get(i), new String(names.get(i)) + "/" + scores.get(i)); + } + } + } + return rows; + } + private TreeMap readAll(Dataset dataset) throws Exception { try (ArrowReader reader = dataset.newScan().scanBatches()) { TreeMap map = new TreeMap<>(); diff --git a/java/src/test/java/org/lance/MultiBaseTest.java b/java/src/test/java/org/lance/MultiBaseTest.java index 802b4a2ca31..ab745cf92d5 100644 --- a/java/src/test/java/org/lance/MultiBaseTest.java +++ b/java/src/test/java/org/lance/MultiBaseTest.java @@ -36,13 +36,18 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; public class MultiBaseTest { private BufferAllocator allocator; @@ -103,6 +108,51 @@ private ArrowStreamReader makeReader(int startId, int count) throws Exception { } } + private VectorSchemaRoot makeRoot(int startId, int count) { + List fields = + Arrays.asList( + new Field("id", FieldType.notNullable(new ArrowType.Int(32, true)), null), + new Field("value", FieldType.nullable(new ArrowType.Utf8()), null)); + Schema schema = new Schema(fields); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator); + IntVector idVec = (IntVector) root.getVector("id"); + idVec.allocateNew(count); + VarCharVector valVec = (VarCharVector) root.getVector("value"); + valVec.allocateNew(); + for (int i = 0; i < count; i++) { + int id = startId + i; + idVec.setSafe(i, id); + byte[] b = ("val_" + id).getBytes(); + valVec.setSafe(i, b, 0, b.length); + } + root.setRowCount(count); + return root; + } + + private boolean hasLanceFile(String basePath) throws Exception { + try (Stream paths = Files.walk(Path.of(basePath))) { + return paths.anyMatch(path -> path.toString().endsWith(".lance")); + } + } + + private Set baseIds(Dataset dataset) { + return dataset.getFragments().stream() + .flatMap(f -> f.metadata().getFiles().stream()) + .map(DataFile::getBaseId) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + } + + private Set baseIds(List fragments) { + return fragments.stream() + .flatMap(fragment -> fragment.getFiles().stream()) + .map(DataFile::getBaseId) + .filter(Optional::isPresent) + .map(Optional::get) + .collect(Collectors.toSet()); + } + @Test public void testCreateMode() throws Exception { ArrowStreamReader reader = makeReader(0, 500); @@ -125,6 +175,13 @@ public void testCreateMode() throws Exception { assertNotNull(ds); assertEquals(primary, ds.uri()); assertEquals(500, ds.countRows()); + assertEquals(2, ds.getBasePaths().size()); + assertEquals( + Arrays.asList("base1", "base2"), + ds.getBasePaths().stream() + .map(base -> base.getName().orElseThrow(AssertionError::new)) + .sorted() + .collect(Collectors.toList())); } @Test @@ -211,12 +268,7 @@ public void testTargetByPathUri() throws Exception { .maxRowsPerFile(50) .execute(); - Set baseIds = - ds.getFragments().stream() - .flatMap(f -> f.metadata().getFiles().stream().map(DataFile::getBaseId)) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toSet()); + Set baseIds = baseIds(ds); assertEquals(1, baseIds.size()); ArrowStreamReader append = makeReader(100, 50); @@ -231,12 +283,103 @@ public void testTargetByPathUri() throws Exception { .execute(); assertEquals(150, updated.countRows()); - baseIds = - updated.getFragments().stream() - .flatMap(f -> f.metadata().getFiles().stream().map(DataFile::getBaseId)) - .filter(Optional::isPresent) - .map(Optional::get) - .collect(Collectors.toSet()); + baseIds = baseIds(updated); assertEquals(2, baseIds.size()); } + + @Test + public void testFragmentCreateWithMultiBaseParams() throws Exception { + ArrowStreamReader reader = makeReader(0, 100); + List bases = + Arrays.asList( + new BasePath(0, Optional.of("base1"), base1, false), + new BasePath(0, Optional.of("base2"), base2, false)); + + Dataset ds = + Dataset.write() + .allocator(allocator) + .reader(reader) + .uri(primary) + .mode(WriteParams.WriteMode.CREATE) + .initialBases(bases) + .targetBases(Arrays.asList("base1")) + .maxRowsPerFile(50) + .execute(); + Set initialBaseIds = baseIds(ds); + assertEquals(1, initialBaseIds.size()); + assertTrue(hasLanceFile(base1)); + + Map> baseStoreParams = new HashMap<>(); + baseStoreParams.put(base2, new HashMap<>()); + WriteParams params = + new WriteParams.Builder() + .withTargetBases(Arrays.asList("base2")) + .withBaseStoreParams(baseStoreParams) + .withMaxRowsPerFile(25) + .build(); + + List fragments; + try (VectorSchemaRoot root = makeRoot(100, 50)) { + fragments = Fragment.create(primary, allocator, root, params); + } + + assertEquals(2, fragments.size()); + Set fragmentBaseIds = baseIds(fragments); + assertEquals(1, fragmentBaseIds.size()); + assertTrue(Collections.disjoint(initialBaseIds, fragmentBaseIds)); + assertTrue(hasLanceFile(base2)); + + FragmentOperation.Append append = new FragmentOperation.Append(fragments); + Dataset updated = Dataset.commit(allocator, primary, append, Optional.of(ds.version())); + assertEquals(150, updated.countRows()); + assertEquals(2, baseIds(updated).size()); + } + + @Test + public void testFragmentWriteWithMultiBaseParams() throws Exception { + ArrowStreamReader reader = makeReader(0, 50); + List bases = + Arrays.asList( + new BasePath(0, Optional.of("base1"), base1, false), + new BasePath(0, Optional.of("base2"), base2, false)); + + Dataset ds = + Dataset.write() + .allocator(allocator) + .reader(reader) + .uri(primary) + .mode(WriteParams.WriteMode.CREATE) + .initialBases(bases) + .targetBases(Arrays.asList("base1")) + .maxRowsPerFile(50) + .execute(); + Set initialBaseIds = baseIds(ds); + assertEquals(1, initialBaseIds.size()); + assertTrue(hasLanceFile(base1)); + + Map> baseStoreParams = new HashMap<>(); + baseStoreParams.put(base2, new HashMap<>()); + + List fragments; + try (VectorSchemaRoot root = makeRoot(50, 25)) { + fragments = + Fragment.write() + .datasetUri(primary) + .allocator(allocator) + .data(root) + .targetBases(Arrays.asList("base2")) + .baseStoreParams(baseStoreParams) + .maxRowsPerFile(25) + .execute(); + } + + Set fragmentBaseIds = baseIds(fragments); + assertEquals(1, fragmentBaseIds.size()); + assertTrue(Collections.disjoint(initialBaseIds, fragmentBaseIds)); + FragmentOperation.Append append = new FragmentOperation.Append(fragments); + Dataset updated = Dataset.commit(allocator, primary, append, Optional.of(ds.version())); + assertEquals(75, updated.countRows()); + assertEquals(2, baseIds(updated).size()); + assertTrue(hasLanceFile(base2)); + } } diff --git a/java/src/test/java/org/lance/ScannerTest.java b/java/src/test/java/org/lance/ScannerTest.java index 9fe844e9334..2856d530eba 100644 --- a/java/src/test/java/org/lance/ScannerTest.java +++ b/java/src/test/java/org/lance/ScannerTest.java @@ -13,10 +13,18 @@ */ package org.lance; +import org.lance.index.IndexOptions; +import org.lance.index.IndexParams; +import org.lance.index.IndexType; +import org.lance.index.scalar.ScalarIndexParams; import org.lance.ipc.ColumnOrdering; import org.lance.ipc.LanceScanner; +import org.lance.ipc.MaterializationStyle; import org.lance.ipc.ScanOptions; +import org.lance.ipc.ScanStats; +import org.apache.arrow.c.ArrowArrayStream; +import org.apache.arrow.c.Data; import org.apache.arrow.dataset.scanner.Scanner; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; @@ -32,16 +40,24 @@ import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Path; +import java.util.ArrayList; import java.util.Arrays; +import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.UUID; import java.util.stream.Collectors; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class ScannerTest { @@ -58,6 +74,102 @@ static void tearDown() { } } + @Test + void testIndexSegmentOptions() { + List segments = List.of(UUID.randomUUID()); + ScanOptions options = + new ScanOptions.Builder().indexSegments(segments).batchSizeBytes(1024).build(); + assertEquals(segments, options.getIndexSegments().orElseThrow()); + ScanOptions copy = new ScanOptions.Builder(options).build(); + assertEquals(segments, copy.getIndexSegments().orElseThrow()); + assertEquals(1024L, copy.getBatchSizeBytes().orElseThrow()); + assertTrue(new ScanOptions.Builder().build().getIndexSegments().isEmpty()); + } + + @Test + void testScannerPerformanceOptions() { + MaterializationStyle materialization = + MaterializationStyle.allEarlyExcept(Collections.singletonList("name")); + ScanOptions options = + new ScanOptions.Builder() + .batchSize(1024) + .batchSizeBytes(64 * 1024) + .ioBufferSize(8 * 1024 * 1024) + .batchReadahead(4) + .fragmentReadahead(2) + .scanInOrder(false) + .lateMaterialization(materialization) + .build(); + + assertEquals(1024L, options.getBatchSize().orElseThrow()); + assertEquals(64 * 1024L, options.getBatchSizeBytes().orElseThrow()); + assertEquals(8 * 1024 * 1024L, options.getIoBufferSize().orElseThrow()); + assertEquals(4, options.getBatchReadahead()); + assertEquals(2, options.getFragmentReadahead().orElseThrow()); + assertFalse(options.isScanInOrder()); + assertEquals(materialization, options.getLateMaterialization().orElseThrow()); + assertEquals("heuristic", MaterializationStyle.heuristic().toRustString()); + assertEquals("all_late", MaterializationStyle.allLate().toRustString()); + assertEquals("all_early", MaterializationStyle.allEarly().toRustString()); + assertEquals("all_early_except", materialization.toRustString()); + assertEquals(Collections.singletonList("name"), materialization.getColumns()); + + ScanOptions defaults = new ScanOptions.Builder().build(); + assertTrue(defaults.getBatchSizeBytes().isEmpty()); + assertTrue(defaults.getIoBufferSize().isEmpty()); + assertTrue(defaults.getFragmentReadahead().isEmpty()); + assertTrue(defaults.isScanInOrder()); + assertTrue(defaults.getLateMaterialization().isEmpty()); + + assertThrows( + IllegalArgumentException.class, () -> new ScanOptions.Builder().batchSizeBytes(0).build()); + assertThrows( + IllegalArgumentException.class, () -> new ScanOptions.Builder().ioBufferSize(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> new ScanOptions.Builder().fragmentReadahead(0).build()); + assertThrows( + IllegalArgumentException.class, + () -> new ScanOptions.Builder().batchSizeBytes(1024).strictBatchSize(true).build()); + assertTrue(MaterializationStyle.allEarlyExcept(Collections.emptyList()).getColumns().isEmpty()); + } + + static Stream materializationStyles() { + return Stream.of( + MaterializationStyle.heuristic(), + MaterializationStyle.allLate(), + MaterializationStyle.allEarly(), + MaterializationStyle.allEarlyExcept(Collections.emptyList())); + } + + @ParameterizedTest + @MethodSource("materializationStyles") + void testMaterializationStylesAcrossJni( + MaterializationStyle materializationStyle, @TempDir Path tempDir) throws Exception { + String datasetPath = + tempDir.resolve("materialization_" + materializationStyle.getMode().name()).toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset dataset = testDataset.write(1, 40); + LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .filter("id < 20") + .lateMaterialization(materializationStyle) + .build()); + ArrowReader reader = scanner.scanBatches()) { + int rowCount = 0; + while (reader.loadNextBatch()) { + rowCount += reader.getVectorSchemaRoot().getRowCount(); + } + assertEquals(20, rowCount); + } + } + } + @Test void testDatasetScanner(@TempDir Path tempDir) throws IOException { String datasetPath = tempDir.resolve("dataset_scanner").toString(); @@ -151,6 +263,458 @@ void testDatasetScannerSchema(@TempDir Path tempDir) throws Exception { } } + /** + * Imports a caller-owned C stream populated by {@link LanceScanner#exportArrowStream(long)} and + * returns the {@code id} values in the order the stream produced them. + * + *

    The projected schema is asserted to be exactly a single {@code id: int32} field, and the + * assertion is made on the imported reader before the first {@code loadNextBatch()} call + * so that it still runs for an empty (zero-batch) result — a regression that exported the wrong + * schema for an empty scan would otherwise slip through. See {@code + * org.apache.arrow.vector.ipc.ArrowReader#getVectorSchemaRoot()}, which exposes the schema as + * soon as the stream is imported. + * + *

    This helper intentionally makes no assertion about per-batch row counts. The + * scanner's {@code batchSize} is only a hint unless {@code strictBatchSize(true)} is set, so the + * number of batches and the rows per batch are not part of the contract being tested here; that + * dimension is covered separately by {@link #testExportArrowStreamStrictBatchSize}. Row ordering + * and exact values are asserted by the callers against the returned list. + */ + private static List drainIdStream(BufferAllocator allocator, ArrowArrayStream stream) + throws IOException { + List ids = new ArrayList<>(); + try (ArrowReader reader = Data.importArrayStream(allocator, stream)) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + List fields = root.getSchema().getFields(); + assertEquals(1, fields.size()); + Field idField = fields.get(0); + assertEquals("id", idField.getName()); + // Pin the exact type, not just the ArrowTypeID family: the projected column is a nullable + // signed int32. ArrowTypeID.Int alone also matches int8/16/64 and unsigned, and the + // (IntVector) cast below only guards the width on non-empty results — an empty scan that + // exported e.g. int64 or a non-nullable id would otherwise slip through this helper. + assertTrue(idField.isNullable()); + ArrowType.Int idType = (ArrowType.Int) idField.getType(); + assertEquals(32, idType.getBitWidth()); + assertTrue(idType.getIsSigned()); + while (reader.loadNextBatch()) { + IntVector vector = (IntVector) root.getVector("id"); + int rowsInBatch = vector.getValueCount(); + for (int i = 0; i < rowsInBatch; i++) { + ids.add(vector.get(i)); + } + } + } + return ids; + } + + /** + * Happy path: a single-fragment ordered scan exported through a caller-owned C stream returns + * every row exactly once, in scan order. The caller allocates the {@link ArrowArrayStream} from + * its own allocator and passes only the memory address; the scanner fills the C struct in place. + * This is the cross-Arrow-version / cross-classloader boundary the API exists to serve. + */ + @Test + void testExportArrowStream(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_basic").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + int totalRows = 40; + int batchRows = 20; + try (Dataset dataset = testDataset.write(1, totalRows)) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(batchRows) + .columns(Arrays.asList("id")) + .build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + // SimpleTestDataset writes id = 0..totalRows-1; an ordered scan must return them in + // exactly that sequence, so assert the exact ordering (no sort). + List ids = drainIdStream(allocator, stream); + assertEquals(totalRows, ids.size()); + for (int i = 0; i < totalRows; i++) { + assertEquals(i, ids.get(i)); + } + } + } + } + } + } + + /** + * A scan that spans multiple fragments is exported as a single C stream that concatenates the + * fragments in fragment order. {@code createNewFragment(40, 10)} produces 4 fragments of 10 rows + * (ids 0-9, 10-19, 20-29, 30-39), and an ordered scan must return 0..39 in exactly that order. + * + *

    The expected ids are asserted in stream order without sorting: sorting would mask a + * regression that returned fragments out of order, which is exactly the kind of bug this test + * exists to catch. A non-divisor batch size (7) is used so batch boundaries do not line up with + * fragment boundaries, exercising the stream's batch stitching across fragments. + */ + @Test + void testExportArrowStreamMultipleFragments(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_multi_fragment").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + int totalRows = 40; + // maxRowsPerFile < totalRows forces multiple fragments (4 fragments of 10 rows). + List fragments = testDataset.createNewFragment(totalRows, 10); + assertEquals(4, fragments.size()); + FragmentOperation.Append appendOp = new FragmentOperation.Append(fragments); + try (Dataset dataset = Dataset.commit(allocator, datasetPath, appendOp, Optional.of(1L))) { + int batchRows = 7; // deliberately not a divisor of any fragment size + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(batchRows) + .columns(Arrays.asList("id")) + .build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + List ids = drainIdStream(allocator, stream); + assertEquals(totalRows, ids.size()); + // Assert exact scan order (no sort) so out-of-order fragments would fail. + for (int i = 0; i < totalRows; i++) { + assertEquals(i, ids.get(i), "row " + i + " out of expected scan order"); + } + } + } + } + } + } + + /** + * A pushed-down filter is honored by the exported stream: only matching rows cross the C-data + * boundary. {@code id < 20} over ids 0..39 must yield exactly 0..19 in order. Asserted in scan + * order without sorting so a filter/ordering regression cannot hide behind a sort. + */ + @Test + void testExportArrowStreamWithFilter(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_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)) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(50) + .columns(Arrays.asList("id")) + .filter("id < 20") + .build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + List ids = drainIdStream(allocator, stream); + assertEquals(20, ids.size()); + for (int i = 0; i < 20; i++) { + assertEquals(i, ids.get(i)); + } + } + } + } + } + } + + /** + * Pushed-down limit and offset are honored by the exported stream. Over ids 0..39, {@code + * offset(10).limit(5)} must yield exactly [10, 11, 12, 13, 14] in order — asserted as an exact + * ordered list so both the window bounds and the ordering are checked. + */ + @Test + void testExportArrowStreamWithLimitOffset(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_limit_offset").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 40)) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(50) + .columns(Arrays.asList("id")) + .limit(5) + .offset(10) + .build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + List ids = drainIdStream(allocator, stream); + assertEquals(Arrays.asList(10, 11, 12, 13, 14), ids); + } + } + } + } + } + + /** + * Column projection is reflected in the exported stream's schema. {@code SimpleTestDataset} has + * columns {@code (id, name)}; projecting only {@code name} must produce a stream whose schema is + * exactly that one column. The schema is checked on the imported reader before draining, and the + * full row count is verified after. + */ + @Test + void testExportArrowStreamProjectsRequestedColumnsOnly(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_projection").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + // Project only "name"; the exported stream's schema must contain exactly that column. + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().columns(Arrays.asList("name")).build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + try (ArrowReader reader = Data.importArrayStream(allocator, stream)) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertEquals(1, root.getSchema().getFields().size()); + assertEquals("name", root.getSchema().getFields().get(0).getName()); + int rows = 0; + while (reader.loadNextBatch()) { + rows += root.getRowCount(); + } + assertEquals(10, rows); + } + } + } + } + } + } + + /** + * A scan that matches no rows ({@code id < 0}) still exports a valid, well-formed stream that + * yields zero rows. {@link #drainIdStream} asserts the projected schema ({@code id: int32}) on + * the imported reader before any {@code loadNextBatch()}, so this case also guards the empty-scan + * schema — a regression that exported a wrong or absent schema for zero-row results would fail + * here even though no batch is ever produced. + */ + @Test + void testExportArrowStreamEmptyResult(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_empty").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 40)) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder().columns(Arrays.asList("id")).filter("id < 0").build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + List ids = drainIdStream(allocator, stream); + assertTrue(ids.isEmpty()); + } + } + } + } + } + + /** + * Guards against the sequential "export twice into the same stream" mistake. After the first + * export installs a producer (non-null {@code release} callback), a second export into the same + * stream must be rejected with {@link IllegalArgumentException} rather than overwriting the C + * struct in place — overwriting would drop the first producer's release callback and leak it. + * + *

    The test also verifies the rejection is non-destructive: the first producer is still intact + * and fully drainable (all 40 rows) after the rejected second call. This is the single-threaded + * misuse case; concurrent exports into one caller-owned stream are the caller's responsibility, + * as documented on {@link LanceScanner#exportArrowStream(long)}. + */ + @Test + void testExportArrowStreamRejectsPopulatedStream(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_reject_populated").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 40)) { + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().columns(Arrays.asList("id")).build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + // First export populates the stream and installs a release callback. + scanner.exportArrowStream(stream.memoryAddress()); + // Exporting again into the same (already-populated) stream must be rejected rather + // than silently overwriting and leaking the first producer's release callback. + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> scanner.exportArrowStream(stream.memoryAddress())); + assertTrue(ex.getMessage().toLowerCase().contains("already populated")); + // The first producer is still intact and drainable. + try (ArrowReader reader = Data.importArrayStream(allocator, stream)) { + int rows = 0; + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + while (reader.loadNextBatch()) { + rows += root.getRowCount(); + } + assertEquals(40, rows); + } + } + } + } + } + } + + /** + * A null (0) stream address is rejected with {@link IllegalArgumentException} before any native + * dereference, so a caller mistake cannot turn into a native null-pointer write. + */ + @Test + void testExportArrowStreamRejectsNullAddress(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_reject_null").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().columns(Arrays.asList("id")).build())) { + assertThrows(IllegalArgumentException.class, () -> scanner.exportArrowStream(0L)); + } + } + } + } + + /** + * Exporting from a closed scanner is rejected with {@link IllegalArgumentException} (the native + * scanner handle is zero after {@code close()}), rather than dereferencing a freed handle. The + * scanner is closed explicitly here, so it is intentionally not in a try-with-resources. + */ + @Test + void testExportArrowStreamRejectsClosedScanner(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_reject_closed").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().columns(Arrays.asList("id")).build()); + scanner.close(); + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + assertThrows( + IllegalArgumentException.class, + () -> scanner.exportArrowStream(stream.memoryAddress())); + } + } + } + } + + /** + * Null values survive the C-data export round-trip. {@code writeSortByDataset} writes 10 rows + * (insertion order) in which {@code id} is null at rows 2 and 5 and {@code name} is null at rows + * 0 and 6. An unordered scan returns rows in insertion order, so the exported stream must + * reproduce both the non-null values and the null positions exactly — null/validity bitmaps are a + * common casualty of an incorrect C-data export, so this guards them explicitly. + */ + @Test + void testExportArrowStreamPreservesNulls(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_nulls").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.writeSortByDataset(1)) { + // Insertion order, row -> (id, name): + // 0 -> (0, null) 3 -> (2, "P2") 6 -> (3, null) 9 -> (5, "P5") + // 1 -> (1, "P0") 4 -> (2, "P3") 7 -> (4, "P4") + // 2 -> (null,"P1") 5 -> (null,"P3") 8 -> (4, "P5") + Integer[] expectedIds = {0, 1, null, 2, 2, null, 3, 4, 4, 5}; + String[] expectedNames = {null, "P0", "P1", "P2", "P3", "P3", null, "P4", "P5", "P5"}; + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder().columns(Arrays.asList("id", "name")).build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + try (ArrowReader reader = Data.importArrayStream(allocator, stream)) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertEquals(2, root.getSchema().getFields().size()); + int row = 0; + while (reader.loadNextBatch()) { + IntVector idVector = (IntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + for (int i = 0; i < root.getRowCount(); i++, row++) { + if (expectedIds[row] == null) { + assertTrue(idVector.isNull(i), "id should be null at row " + row); + } else { + assertEquals( + expectedIds[row].intValue(), idVector.get(i), "id mismatch at row " + row); + } + if (expectedNames[row] == null) { + assertTrue(nameVector.isNull(i), "name should be null at row " + row); + } else { + assertEquals( + expectedNames[row], + new String(nameVector.get(i), StandardCharsets.UTF_8), + "name mismatch at row " + row); + } + } + } + assertEquals(expectedIds.length, row); + } + } + } + } + } + } + + /** + * With {@code strictBatchSize(true)}, the exported stream must split into batches no larger than + * the requested batch size, and still reproduce every row in order. This is the one place the + * per-batch size is part of the contract; the other export tests deliberately leave batch sizing + * unasserted because it is only a hint by default. Mirrors {@link #testStrictBatchSize} but over + * the C-data export path. A batch size of 10 over 25 rows yields batches of at most 10. + */ + @Test + void testExportArrowStreamStrictBatchSize(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("export_stream_strict_batch").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + int totalRows = 25; + int batchSize = 10; + try (Dataset dataset = testDataset.write(1, totalRows)) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(batchSize) + .strictBatchSize(true) + .columns(Arrays.asList("id")) + .build())) { + try (ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + scanner.exportArrowStream(stream.memoryAddress()); + try (ArrowReader reader = Data.importArrayStream(allocator, stream)) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + List ids = new ArrayList<>(); + while (reader.loadNextBatch()) { + int rowsInBatch = root.getRowCount(); + assertTrue( + rowsInBatch <= batchSize, + "strict: batch of " + rowsInBatch + " should be <= " + batchSize); + IntVector idVector = (IntVector) root.getVector("id"); + for (int i = 0; i < rowsInBatch; i++) { + ids.add(idVector.get(i)); + } + } + assertEquals(totalRows, ids.size()); + for (int i = 0; i < totalRows; i++) { + assertEquals(i, ids.get(i)); + } + } + } + } + } + } + } + @Test void testDatasetScannerCountRows(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("dataset_scanner_count").toString(); @@ -173,6 +737,47 @@ void testDatasetScannerCountRows(@TempDir Path tempDir) throws Exception { } } + @Test + void testDatasetScannerStats(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("dataset_scanner_stats").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 40)) { + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().batchSize(20).build())) { + assertTrue(scanner.getStats().isEmpty()); + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + // Consume all batches. + } + } + assertTrue(scanner.getStats().isEmpty()); + } + + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().batchSize(20).collectStats(true).build())) { + assertTrue(scanner.getStats().isEmpty()); + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + // Consume all batches. + } + } + + Optional statsOpt = scanner.getStats(); + assertTrue(statsOpt.isPresent()); + ScanStats stats = statsOpt.get(); + assertTrue(stats.getBytesRead() > 0 || !stats.getAllCounts().isEmpty()); + // Even without an index on this dataset, the two new counters must + // still marshal through JNI and default to zero rather than throwing. + assertTrue(stats.getIndexCacheHits() >= 0); + assertTrue(stats.getIndexCacheMisses() >= 0); + } + } + } + } + @Test void testFragmentScanner(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("fragment_scanner").toString(); @@ -378,31 +983,99 @@ void testDatasetScannerBatchReadahead(@TempDir Path tempDir) throws Exception { TestUtils.SimpleTestDataset testDataset = new TestUtils.SimpleTestDataset(allocator, datasetPath); testDataset.createEmptyDataset().close(); - int totalRows = 1000; - int batchSize = 100; - int batchReadahead = 5; - try (Dataset dataset = testDataset.write(1, totalRows)) { + + int totalRows = 2000; + int maxRowsPerFile = 100; // ~20 fragments + List fragments = testDataset.createNewFragment(totalRows, maxRowsPerFile); + assertTrue(fragments.size() > 1, "expected multiple fragments, got " + fragments.size()); + + FragmentOperation.Append append = new FragmentOperation.Append(fragments); + try (Dataset dataset = Dataset.commit(allocator, datasetPath, append, Optional.of(1L))) { + int batchReadahead = 2; // far below the default (num compute CPUs) try (LanceScanner scanner = dataset.newScan( - new ScanOptions.Builder() - .batchSize(batchSize) - .batchReadahead(batchReadahead) - .build())) { - // This test is more about ensuring that the batchReadahead parameter is accepted - // and doesn't cause errors. The actual effect of batchReadahead might not be - // directly observable in this test. + new ScanOptions.Builder().batchSize(50).batchReadahead(batchReadahead).build())) { try (ArrowReader reader = scanner.scanBatches()) { int rowCount = 0; + long idSum = 0; while (reader.loadNextBatch()) { - rowCount += reader.getVectorSchemaRoot().getRowCount(); + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector ids = (IntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + idSum += ids.get(i); + } + rowCount += root.getRowCount(); } assertEquals(totalRows, rowCount); + // ids are the contiguous range [0, totalRows) + assertEquals((long) totalRows * (totalRows - 1) / 2, idSum); } } } } } + @Test + void testDatasetScannerPerformanceOptions(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("dataset_scanner_performance_options").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + int totalRows = 2000; + int matchingRows = 1000; + WriteParams writeParams = + new WriteParams.Builder() + .withMaxRowsPerFile(100) + .withDataStorageVersion(LanceConstants.FILE_FORMAT_VERSION_STABLE) + .build(); + List fragments = testDataset.createNewFragment(totalRows, writeParams); + FragmentOperation.Append append = new FragmentOperation.Append(fragments); + try (Dataset dataset = Dataset.commit(allocator, datasetPath, append, Optional.of(1L))) { + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .filter("id < " + matchingRows) + .batchSize(200) + .batchSizeBytes(256) + .ioBufferSize(1024 * 1024) + .batchReadahead(2) + .fragmentReadahead(2) + .scanInOrder(false) + .lateMaterialization(MaterializationStyle.allEarly()) + .build()); + ArrowReader reader = scanner.scanBatches()) { + int rowCount = 0; + long idSum = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertTrue( + root.getRowCount() < 200, + "batchSizeBytes should split batches before the row limit"); + IntVector ids = (IntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + idSum += ids.get(i); + } + rowCount += root.getRowCount(); + } + assertEquals(matchingRows, rowCount); + assertEquals((long) matchingRows * (matchingRows - 1) / 2, idSum); + } + + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .filter("id < " + matchingRows) + .lateMaterialization( + MaterializationStyle.allEarlyExcept(Collections.singletonList("name"))) + .build())) { + assertEquals(matchingRows, scanner.countRows()); + } + } + } + } + @Test void testDatasetScannerSortBy(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("testDatasetScannerSortBy").toString(); @@ -538,6 +1211,235 @@ void testDatasetScannerCombinedParams(@TempDir Path tempDir) throws Exception { } } + @Test + void testUseScalarIndex(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("dataset_scanner_use_scalar_index").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 a scalar index on the 'id' column + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + IndexOptions options = + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("id_btree_index") + .replace(true) + .build(); + dataset.createIndex(options); + + // Verify index was created + assertTrue( + dataset.listIndexes().contains("id_btree_index"), + "Expected 'id_btree_index' to be in the list of indexes: " + dataset.listIndexes()); + + // Test with useScalarIndex = true (default) + List resultsWithIndex = new ArrayList<>(); + try (Scanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .filter("id < 50") + .useScalarIndex(true) + .columns(Collections.singletonList("id")) + .build())) { + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector idVector = (IntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + resultsWithIndex.add(idVector.get(i)); + } + } + } + } + + // Test with useScalarIndex = false + List resultsWithoutIndex = new ArrayList<>(); + try (Scanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .filter("id < 50") + .useScalarIndex(false) + .columns(Collections.singletonList("id")) + .build())) { + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector idVector = (IntVector) root.getVector("id"); + for (int i = 0; i < root.getRowCount(); i++) { + resultsWithoutIndex.add(idVector.get(i)); + } + } + } + } + + // Results should be the same regardless of whether scalar index is used + assertEquals( + resultsWithIndex.size(), + resultsWithoutIndex.size(), + "Result count should be the same with or without scalar index"); + assertEquals(50, resultsWithIndex.size(), "Should return 50 rows (id < 50)"); + assertEquals( + resultsWithIndex, + resultsWithoutIndex, + "Results should be identical with or without scalar index"); + } + } + } + + @Test + void testFastSearchSkipsUnindexedFragments(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("dataset_scanner_fast_search_scalar_index").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 100)) { + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + IndexOptions options = + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("id_btree_index") + .replace(true) + .build(); + dataset.createIndex(options); + + FragmentMetadata metadata = testDataset.createNewFragment(10); + FragmentOperation.Append appendOp = + new FragmentOperation.Append(Collections.singletonList(metadata)); + try (Dataset appended = + Dataset.commit(allocator, datasetPath, appendOp, Optional.of(dataset.version()))) { + try (LanceScanner scanner = + appended.newScan(new ScanOptions.Builder().filter("id < 5").build())) { + assertEquals(10, scanner.countRows()); + } + + try (LanceScanner scanner = + appended.newScan( + new ScanOptions.Builder().filter("id < 5").fastSearch(true).build())) { + assertEquals(5, scanner.countRows()); + } + } + } + } + } + + @Test + void testIncludeDeletedRows(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("include_deleted_rows").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + assertEquals(10, dataset.countRows()); + + // Delete rows where id >= 5 + dataset.delete("id >= 5"); + assertEquals(5, dataset.countRows()); + + // Default scan should exclude deleted rows + try (LanceScanner scanner = + dataset.newScan(new ScanOptions.Builder().batchSize(20).build())) { + assertEquals(5, scanner.countRows(), "default scan: should exclude deleted rows"); + } + + // includeDeletedRows=true should surface deleted rows + // NOTE: includeDeletedRows requires withRowId=true + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .batchSize(20) + .withRowId(true) + .includeDeletedRows(true) + .build())) { + assertEquals(10, scanner.countRows(), "includeDeletedRows: should include deleted rows"); + } + } + } + } + + @Test + void testStrictBatchSize(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("strict_batch_size").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 25)) { + int batchSize = 10; + + // With strictBatchSize=true, no batch should exceed batchSize + try (Scanner scanner = + dataset.newScan( + new ScanOptions.Builder().batchSize(batchSize).strictBatchSize(true).build())) { + try (ArrowReader reader = scanner.scanBatches()) { + int totalRows = 0; + while (reader.loadNextBatch()) { + int rows = reader.getVectorSchemaRoot().getRowCount(); + assertTrue(rows <= batchSize, "strict: batch " + rows + " should be <= " + batchSize); + totalRows += rows; + } + assertEquals(25, totalRows); + } + } + + // strictBatchSize=false (default) — batch size may vary + try (Scanner scanner = + dataset.newScan(new ScanOptions.Builder().batchSize(batchSize).build())) { + try (ArrowReader reader = scanner.scanBatches()) { + int totalRows = 0; + while (reader.loadNextBatch()) { + totalRows += reader.getVectorSchemaRoot().getRowCount(); + } + assertEquals(25, totalRows); + } + } + } + } + } + + @Test + void testDisableScoringAutoprojection(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("disable_scoring_autoprojection").toString(); + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + // Smoke test: verify the option is accepted and scan still works + ScanOptions options = + new ScanOptions.Builder().batchSize(20).disableScoringAutoprojection(true).build(); + + try (LanceScanner scanner = dataset.newScan(options)) { + assertEquals( + 10, + scanner.countRows(), + "scan with disableScoringAutoprojection should return all rows"); + } + + // Also verify it doesn't break when combined with other options + ScanOptions combinedOptions = + new ScanOptions.Builder() + .batchSize(20) + .filter("id < 5") + .disableScoringAutoprojection(true) + .includeDeletedRows(false) + .strictBatchSize(false) + .build(); + + try (LanceScanner scanner = dataset.newScan(combinedOptions)) { + assertEquals( + 5, + scanner.countRows(), + "scan with disableScoringAutoprojection + filter should work"); + } + } + } + } + private void validScanResult(Dataset dataset, int fragmentId, int rowCount) throws Exception { try (Scanner scanner = dataset.newScan( diff --git a/java/src/test/java/org/lance/SessionTest.java b/java/src/test/java/org/lance/SessionTest.java new file mode 100644 index 00000000000..7828d79f06b --- /dev/null +++ b/java/src/test/java/org/lance/SessionTest.java @@ -0,0 +1,479 @@ +/* + * 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.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SessionTest { + + @Test + void testCreateSessionWithDefaults() { + try (Session session = Session.builder().build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + assertTrue(session.sizeBytes() >= 0); + } + } + + @Test + void testCreateSessionWithCustomCacheSizes() { + long indexCacheSize = 512L * 1024 * 1024; // 512 MiB + long metadataCacheSize = 128L * 1024 * 1024; // 128 MiB + + try (Session session = + Session.builder() + .indexCacheSizeBytes(indexCacheSize) + .metadataCacheSizeBytes(metadataCacheSize) + .build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + assertTrue(session.sizeBytes() >= 0); + } + } + + @Test + void testCreateSessionWithPartialCustomCacheSizes() { + // Only set index cache size, metadata should use default + try (Session session = Session.builder().indexCacheSizeBytes(512L * 1024 * 1024).build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + } + + // Only set metadata cache size, index should use default + try (Session session = Session.builder().metadataCacheSizeBytes(128L * 1024 * 1024).build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + } + } + + @Test + void testCreateSessionWithCacheBackendUris() { + try (Session session = + Session.builder() + .indexCacheBackend("moka://?capacity=1048576") + .metadataCacheBackend("moka://?capacity=524288") + .build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + assertEquals(0, session.metadataCacheStats().getNumEntries()); + } + } + + @Test + void testCreateSessionWithStructuredCacheBackendConfigs() { + CacheBackendConfig indexBackend = + CacheBackendConfig.builder("moka").option("capacity", "1048576").build(); + CacheBackendConfig metadataBackend = + CacheBackendConfig.builder("moka") + .options(Collections.singletonMap("capacity", "524288")) + .build(); + + assertEquals("moka", indexBackend.getKind()); + assertEquals(Collections.singletonMap("capacity", "1048576"), indexBackend.getOptions()); + assertThrows( + UnsupportedOperationException.class, () -> indexBackend.getOptions().put("capacity", "1")); + + try (Session session = + Session.builder() + .indexCacheBackend(indexBackend) + .metadataCacheBackend(metadataBackend) + .build()) { + assertNotNull(session); + assertFalse(session.isClosed()); + } + } + + @Test + void testCacheBackendReplacesPreviousDescriptorForSameTier() { + CacheBackendConfig structuredBackend = + CacheBackendConfig.builder("moka").option("capacity", "1048576").build(); + + try (Session session = + Session.builder() + .indexCacheBackend("missing://") + .indexCacheBackend(structuredBackend) + .build()) { + assertNotNull(session); + } + + try (Session session = + Session.builder() + .metadataCacheBackend(structuredBackend) + .metadataCacheBackend("moka://?capacity=1048576") + .build()) { + assertNotNull(session); + } + } + + @Test + void testCacheBackendRejectsSizeAndBackend() { + IllegalArgumentException indexError = + assertThrows( + IllegalArgumentException.class, + () -> + Session.builder() + .indexCacheSizeBytes(1024) + .indexCacheBackend("moka://?capacity=1048576") + .build()); + assertTrue( + indexError + .getMessage() + .contains("indexCacheSizeBytes and indexCacheBackend are mutually exclusive")); + + IllegalArgumentException metadataError = + assertThrows( + IllegalArgumentException.class, + () -> + Session.builder() + .metadataCacheBackend( + CacheBackendConfig.builder("moka").option("capacity", "1048576").build()) + .metadataCacheSizeBytes(1024) + .build()); + assertTrue( + metadataError + .getMessage() + .contains("metadataCacheSizeBytes and metadataCacheBackend are mutually exclusive")); + } + + @Test + void testCacheBackendRejectsUnknownKind() { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> Session.builder().indexCacheBackend("missing://").build()); + assertTrue(error.getMessage().contains("unknown cache backend kind")); + } + + @Test + void testCacheBackendRejectsInvalidMokaConfig() { + IllegalArgumentException missingCapacity = + assertThrows( + IllegalArgumentException.class, + () -> Session.builder().indexCacheBackend("moka://").build()); + assertTrue(missingCapacity.getMessage().contains("capacity is required")); + + IllegalArgumentException unknownOption = + assertThrows( + IllegalArgumentException.class, + () -> + Session.builder() + .metadataCacheBackend( + CacheBackendConfig.builder("moka").option("unknown", "value").build()) + .build()); + assertTrue(unknownOption.getMessage().contains("unknown option")); + } + + @Test + void testCacheBackendConfigValidatesInputs() { + assertThrows(NullPointerException.class, () -> CacheBackendConfig.builder(null)); + assertThrows(IllegalArgumentException.class, () -> CacheBackendConfig.builder("")); + assertThrows( + NullPointerException.class, + () -> CacheBackendConfig.builder("moka").options((Map) null)); + assertThrows( + NullPointerException.class, + () -> CacheBackendConfig.builder("moka").option("capacity", null)); + } + + @Test + void testSessionClose() { + Session session = Session.builder().build(); + assertFalse(session.isClosed()); + + session.close(); + assertTrue(session.isClosed()); + + // Calling close again should be safe + session.close(); + assertTrue(session.isClosed()); + } + + @Test + void testSessionSizeBytesAfterClose() { + Session session = Session.builder().build(); + session.close(); + + assertThrows(IllegalArgumentException.class, session::sizeBytes); + } + + @Test + void testSessionIsSameAs() { + try (Session session1 = Session.builder().build(); + Session session2 = Session.builder().build()) { + // Same session should be equal to itself + assertTrue(session1.isSameAs(session1)); + assertTrue(session2.isSameAs(session2)); + + // Different sessions should not be equal + assertFalse(session1.isSameAs(session2)); + assertFalse(session2.isSameAs(session1)); + + // Null comparison + assertFalse(session1.isSameAs(null)); + } + } + + @Test + void testDatasetSharesSession(@TempDir Path tempDir) { + String datasetPath1 = tempDir.resolve("dataset1").toString(); + String datasetPath2 = tempDir.resolve("dataset2").toString(); + + try (BufferAllocator allocator = new RootAllocator(); + Session session = Session.builder().build()) { + // Create first dataset with session + TestUtils.SimpleTestDataset testDataset1 = + new TestUtils.SimpleTestDataset(allocator, datasetPath1); + try (Dataset ds1 = testDataset1.createEmptyDataset()) { + // Now reopen with shared session + try (Dataset ds1WithSession = + Dataset.open().allocator(allocator).uri(datasetPath1).session(session).build()) { + + // Create second dataset + TestUtils.SimpleTestDataset testDataset2 = + new TestUtils.SimpleTestDataset(allocator, datasetPath2); + try (Dataset ds2 = testDataset2.createEmptyDataset()) { + // Reopen with shared session + try (Dataset ds2WithSession = + Dataset.open().allocator(allocator).uri(datasetPath2).session(session).build()) { + + // Both datasets should share the same session + Session session1 = ds1WithSession.session(); + Session session2 = ds2WithSession.session(); + + assertNotNull(session1); + assertNotNull(session2); + assertTrue(session1.isSameAs(session2)); + assertTrue(session1.isSameAs(session)); + } + } + } + } + } + } + + @Test + void testDatasetSessionFromReadOptions(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_session_options").toString(); + + try (BufferAllocator allocator = new RootAllocator(); + Session session = Session.builder().build()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset ds = testDataset.createEmptyDataset()) { + // Reopen with session in ReadOptions + ReadOptions options = new ReadOptions.Builder().setSession(session).build(); + + try (Dataset dsWithSession = + Dataset.open().allocator(allocator).uri(datasetPath).readOptions(options).build()) { + + Session datasetSession = dsWithSession.session(); + assertNotNull(datasetSession); + assertTrue(datasetSession.isSameAs(session)); + } + } + } + } + + @Test + void testSessionPersistsAfterDatasetClose(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_session_persist").toString(); + + try (BufferAllocator allocator = new RootAllocator(); + Session session = Session.builder().build()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + // Open and close dataset with session + Dataset ds = Dataset.open().allocator(allocator).uri(datasetPath).session(session).build(); + ds.close(); + + // Session should still be open and usable + assertFalse(session.isClosed()); + assertTrue(session.sizeBytes() >= 0); + + // Can open another dataset with the same session + try (Dataset ds2 = + Dataset.open().allocator(allocator).uri(datasetPath).session(session).build()) { + assertNotNull(ds2.session()); + assertTrue(ds2.session().isSameAs(session)); + } + } + } + + @Test + void testInternalSessionClosedWithDataset(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_internal_session").toString(); + + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + // Open dataset WITHOUT providing a session - internal session will be created + Dataset ds = Dataset.open().allocator(allocator).uri(datasetPath).build(); + + // Get the internal session + Session internalSession = ds.session(); + assertNotNull(internalSession); + assertFalse(internalSession.isClosed()); + + // Close the dataset - internal session should be closed too + ds.close(); + + // The internal session should now be closed + assertTrue(internalSession.isClosed()); + } + } + + @Test + void testUserProvidedSessionNotClosedWithDataset(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_user_session").toString(); + + try (BufferAllocator allocator = new RootAllocator(); + Session userSession = Session.builder().build()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + // Open dataset WITH user-provided session + Dataset ds = + Dataset.open().allocator(allocator).uri(datasetPath).session(userSession).build(); + + // Get the session from dataset - should be the same as user-provided + Session datasetSession = ds.session(); + assertTrue(datasetSession.isSameAs(userSession)); + + // Close the dataset + ds.close(); + + // User-provided session should NOT be closed + assertFalse(userSession.isClosed()); + assertTrue(userSession.sizeBytes() >= 0); + } + } + + @Test + void testCheckedOutDatasetsShareInternalSession(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_checkout_session").toString(); + + try (BufferAllocator allocator = new RootAllocator()) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + try (Dataset source = Dataset.open().allocator(allocator).uri(datasetPath).build()) { + source.tags().create("version-one", Ref.ofMain(1)); + Session sourceSession = source.session(); + + try (Dataset byVersion = source.checkoutVersion(1); + Dataset byTag = source.checkoutTag("version-one"); + Dataset byRef = source.checkout(Ref.ofMain(1))) { + assertNotNull(byVersion.session()); + assertNotNull(byTag.session()); + assertNotNull(byRef.session()); + assertTrue(byVersion.session().isSameAs(sourceSession)); + assertTrue(byTag.session().isSameAs(sourceSession)); + assertTrue(byRef.session().isSameAs(sourceSession)); + + source.close(); + assertTrue(sourceSession.isClosed()); + assertFalse(byVersion.session().isClosed()); + assertFalse(byTag.session().isClosed()); + assertFalse(byRef.session().isClosed()); + } + } + } + } + + @Test + void testSessionToString() { + try (Session session = Session.builder().build()) { + String str = session.toString(); + assertNotNull(str); + assertTrue(str.startsWith("Session(")); + } + + Session closedSession = Session.builder().build(); + closedSession.close(); + assertEquals("Session(closed)", closedSession.toString()); + } + + @Test + void testMetadataCacheStats(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("dataset_metadata_cache_stats").toString(); + + try (BufferAllocator allocator = new RootAllocator(); + Session session = Session.builder().build()) { + CacheStats initialStats = session.metadataCacheStats(); + assertEquals(0, initialStats.getHits()); + assertEquals(0, initialStats.getMisses()); + assertEquals(0, initialStats.getNumEntries()); + assertEquals(0, initialStats.getSizeBytes()); + assertEquals(0.0, initialStats.getHitRatio()); + + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + + // First open populates the metadata cache, producing misses + try (Dataset ds = + Dataset.open().allocator(allocator).uri(datasetPath).session(session).build()) { + ds.countRows(); + } + CacheStats statsAfterFirstOpen = session.metadataCacheStats(); + assertTrue(statsAfterFirstOpen.getMisses() > 0); + assertTrue(statsAfterFirstOpen.getNumEntries() > 0); + assertTrue(statsAfterFirstOpen.getSizeBytes() > 0); + + // Reopening the same dataset should hit the shared metadata cache + try (Dataset ds = + Dataset.open().allocator(allocator).uri(datasetPath).session(session).build()) { + ds.countRows(); + } + CacheStats statsAfterSecondOpen = session.metadataCacheStats(); + assertTrue(statsAfterSecondOpen.getHits() > statsAfterFirstOpen.getHits()); + assertTrue(statsAfterSecondOpen.getHitRatio() > 0.0); + + // Stats are not accessible once the session is closed (close is idempotent, + // so the implicit close from try-with-resources remains safe) + session.close(); + assertThrows(IllegalArgumentException.class, session::metadataCacheStats); + } + } + + @Test + void testInvalidCacheSizes() { + assertThrows( + IllegalArgumentException.class, () -> Session.builder().indexCacheSizeBytes(-1).build()); + assertThrows( + IllegalArgumentException.class, () -> Session.builder().metadataCacheSizeBytes(-1).build()); + assertThrows( + IllegalArgumentException.class, + () -> Session.builder().indexCacheSizeBytes(-1).metadataCacheSizeBytes(-1).build()); + } +} diff --git a/java/src/test/java/org/lance/SourcedTransactionTest.java b/java/src/test/java/org/lance/SourcedTransactionTest.java new file mode 100644 index 00000000000..ae2be91dd33 --- /dev/null +++ b/java/src/test/java/org/lance/SourcedTransactionTest.java @@ -0,0 +1,124 @@ +/* + * 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.operation.Append; + +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class SourcedTransactionTest { + + @Test + public void testSourcedTransaction(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testSourcedTransaction").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(20); + + Map properties = new HashMap<>(); + properties.put("transactionType", "APPEND"); + properties.put("createdBy", "testUser"); + try (SourcedTransaction appendTxn = + dataset + .newTransactionBuilder() + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .transactionProperties(properties) + .build()) { + try (Dataset committedDataset = appendTxn.commit()) { + assertEquals(2, committedDataset.version()); + assertEquals(2, committedDataset.latestVersion()); + assertEquals(20, committedDataset.countRows()); + assertEquals(dataset.version(), appendTxn.readVersion()); + assertNotNull(appendTxn.uuid()); + + // Verify transaction properties + Map txnProps = + appendTxn.transactionProperties().orElse(new HashMap<>()); + assertEquals("APPEND", txnProps.get("transactionType")); + assertEquals("testUser", txnProps.get("createdBy")); + } + } + } + } + } + + @Test + public void testTag(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testTag").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + + try (SourcedTransaction txn = + dataset + .newTransactionBuilder() + .tag("release-v2") + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + assertEquals("release-v2", txn.tag().orElse(null)); + assertEquals("release-v2", txn.transaction().tag().orElse(null)); + + try (Dataset committed = txn.commit()) { + Transaction readTx = committed.readTransaction().orElse(null); + assertNotNull(readTx); + assertEquals("release-v2", readTx.tag().orElse(null)); + } + } + } + } + } + + @Test + public void testReadVersionDefaultsToDatasetVersion(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testReadVersionDefault").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + + // Do not set readVersion explicitly — it should default to dataset.version() + try (SourcedTransaction txn = + dataset + .newTransactionBuilder() + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + assertEquals(dataset.version(), txn.readVersion()); + + try (Dataset committed = txn.commit()) { + assertTrue(committed.version() > dataset.version()); + } + } + } + } + } +} diff --git a/java/src/test/java/org/lance/TestUtils.java b/java/src/test/java/org/lance/TestUtils.java index 4a884696e54..af0cc797784 100644 --- a/java/src/test/java/org/lance/TestUtils.java +++ b/java/src/test/java/org/lance/TestUtils.java @@ -111,6 +111,11 @@ public FragmentMetadata createNewFragment(int rowCount) { } public List createNewFragment(int rowCount, int maxRowsPerFile) { + return createNewFragment( + rowCount, new WriteParams.Builder().withMaxRowsPerFile(maxRowsPerFile).build()); + } + + public List createNewFragment(int rowCount, WriteParams writeParams) { List fragmentMetas; try (VectorSchemaRoot root = VectorSchemaRoot.create(getSchema(), allocator)) { root.allocateNew(); @@ -124,12 +129,7 @@ public List createNewFragment(int rowCount, int maxRowsPerFile } root.setRowCount(rowCount); - fragmentMetas = - Fragment.create( - datasetPath, - allocator, - root, - new WriteParams.Builder().withMaxRowsPerFile(maxRowsPerFile).build()); + fragmentMetas = Fragment.create(datasetPath, allocator, root, writeParams); } return fragmentMetas; } @@ -684,6 +684,7 @@ public Dataset createEmptyDataset() { new WriteParams.Builder() // Enable stable row ids to simplify test assertions across fragments .withEnableStableRowIds(true) + .withDataStorageVersion(LanceConstants.FILE_FORMAT_VERSION_2_1) .withMode(WriteParams.WriteMode.CREATE) .build(); Dataset ds = Dataset.create(allocator, datasetPath, getSchema(), params); @@ -694,7 +695,8 @@ public Dataset createEmptyDataset() { /** * Create a single fragment with given row count and return its metadata. The fragment contains * deterministic blob payloads: - Every 16th row starting at 0 has zero-length blob - Every 16th - * row starting at 1 has a ~1 MiB payload - Others have small variable blobs (128..383 bytes) + * row starting at 1 has a ~1 MiB payload - Every 16th row starting at 15 is null - Others have + * small variable blobs (128..383 bytes) */ public FragmentMetadata createBlobFragment(int rowCount, int maxRowsPerFile) { Preconditions.checkArgument(rowCount >= 0, "rowCount must be non-negative"); @@ -716,6 +718,8 @@ public FragmentMetadata createBlobFragment(int rowCount, int maxRowsPerFile) { byte[] big = new byte[1024 * 1024]; Arrays.fill(big, (byte) 0xAB); blobsVec.setSafe(i, big); + } else if (i % 16 == 15) { + blobsVec.setNull(i); } else { // small variable blob int sz = 128 + (i % 256); @@ -731,6 +735,7 @@ public FragmentMetadata createBlobFragment(int rowCount, int maxRowsPerFile) { .withMaxRowsPerFile(maxRowsPerFile) .withMode(WriteParams.WriteMode.APPEND) .withEnableStableRowIds(true) + .withDataStorageVersion(LanceConstants.FILE_FORMAT_VERSION_2_1) .build(); List metas = Fragment.create(datasetPath, allocator, root, params); @@ -760,14 +765,16 @@ public Dataset createAndAppendRows(int totalRows, int batches) { fragments.add(createBlobFragment(batchRows, Integer.MAX_VALUE)); } - Transaction txn = - ds.newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(ds.version()) .operation(org.lance.operation.Append.builder().fragments(fragments).build()) - .build(); - Dataset newDs = txn.commit(); - Preconditions.checkArgument( - newDs.countRows() == totalRows, "dataset row count mismatch after append"); - return newDs; + .build()) { + Dataset newDs = new CommitBuilder(ds).execute(txn); + Preconditions.checkArgument( + newDs.countRows() == totalRows, "dataset row count mismatch after append"); + return newDs; + } } } } 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/TransactionTest.java b/java/src/test/java/org/lance/TransactionTest.java index c9d0e937263..02276cee122 100644 --- a/java/src/test/java/org/lance/TransactionTest.java +++ b/java/src/test/java/org/lance/TransactionTest.java @@ -13,64 +13,58 @@ */ package org.lance; +import org.lance.index.Index; +import org.lance.index.IndexFile; import org.lance.index.IndexOptions; import org.lance.index.IndexParams; import org.lance.index.IndexType; import org.lance.index.scalar.ScalarIndexParams; +import org.lance.memwal.CompactedSsTable; +import org.lance.memwal.InitializeMemWalParams; import org.lance.operation.Append; +import org.lance.operation.Clone; import org.lance.operation.CreateIndex; +import org.lance.operation.DataOverlay; +import org.lance.operation.DataReplacement; +import org.lance.operation.Delete; +import org.lance.operation.KeyExistenceFilter; +import org.lance.operation.Operation; +import org.lance.operation.Overwrite; +import org.lance.operation.ReserveFragments; +import org.lance.operation.Restore; +import org.lance.operation.Rewrite; +import org.lance.operation.RewrittenIndex; +import org.lance.operation.Update; +import org.lance.operation.UpdateBases; +import org.lance.operation.UpdateMemWalState; import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import java.beans.Introspector; import java.nio.file.Path; +import java.util.Arrays; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class TransactionTest { - @Test - public void testTransaction(@TempDir Path tempDir) { - String datasetPath = tempDir.resolve("testTransaction").toString(); - try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { - TestUtils.SimpleTestDataset testDataset = - new TestUtils.SimpleTestDataset(allocator, datasetPath); - try (Dataset dataset = testDataset.createEmptyDataset()) { - FragmentMetadata fragmentMeta = testDataset.createNewFragment(20); - - Map properties = new HashMap<>(); - properties.put("transactionType", "APPEND"); - properties.put("createdBy", "testUser"); - Transaction appendTxn = - dataset - .newTransactionBuilder() - .operation( - Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) - .transactionProperties(properties) - .build(); - try (Dataset committedDataset = appendTxn.commit()) { - assertEquals(2, committedDataset.version()); - assertEquals(2, committedDataset.latestVersion()); - assertEquals(20, committedDataset.countRows()); - assertEquals(dataset.version(), appendTxn.readVersion()); - assertNotNull(appendTxn.uuid()); - - // Verify transaction properties - Map txnProps = appendTxn.transactionProperties().orElse(new HashMap<>()); - assertEquals("APPEND", txnProps.get("transactionType")); - assertEquals("testUser", txnProps.get("createdBy")); - } - } - } - } + private static final byte[] OFFSETS_1_3_5 = + new byte[] {0x3A, 0x30, 0, 0, 1, 0, 0, 0, 0, 0, 2, 0, 0x10, 0, 0, 0, 1, 0, 3, 0, 5, 0}; @Test public void testReadTransactionCreateIndex(@TempDir Path tempDir) { @@ -106,6 +100,896 @@ public void testReadTransactionCreateIndex(@TempDir Path tempDir) { assertTrue( op.getRemovedIndices().isEmpty(), "removedIndices should be empty for CreateIndex"); assertEquals("btree_id_index", (op.getNewIndices().get(0).name())); + Index readIndex = op.getNewIndices().get(0); + assertTrue(readIndex.getFiles().isPresent()); + assertEquals( + readIndex.getSizeBytes().orElseThrow().longValue(), + readIndex.getFiles().orElseThrow().stream().mapToLong(IndexFile::getSizeBytes).sum()); + } + } + } + + @Test + public void testCommitToUri(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testCommitToUri").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + Schema schema = testDataset.getSchema(); + + // Create fragments at the dataset path + FragmentMetadata fragmentMeta = testDataset.createNewFragment(20); + + // Build a transaction targeting a URI (no existing dataset) + try (Transaction txn = + new Transaction.Builder() + .operation( + Overwrite.builder() + .fragments(Collections.singletonList(fragmentMeta)) + .schema(schema) + .build()) + .build()) { + try (Dataset committedDataset = new CommitBuilder(datasetPath, allocator).execute(txn)) { + assertEquals(1, committedDataset.version()); + assertEquals(20, committedDataset.countRows()); + try (Transaction read = committedDataset.readTransaction().orElseThrow()) { + Overwrite operation = assertInstanceOf(Overwrite.class, read.operation()); + assertTrue(operation.getInitialBases().isEmpty()); + assertTrue(operation.configUpsertValues().isEmpty()); + } + } + } + } + } + + @Test + public void testTagRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testTagRoundTrip").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .tag("v1.0") + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + assertEquals("v1.0", txn.tag().orElse(null)); + + try (Dataset committed = new CommitBuilder(dataset).execute(txn)) { + Transaction readTx = committed.readTransaction().orElse(null); + assertNotNull(readTx); + assertEquals("v1.0", readTx.tag().orElse(null)); + } + } + } + } + } + + @Test + public void testEmptyTransactionMetadataCanonicalization(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("empty_transaction_metadata").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + FragmentMetadata fragment = testDataset.createNewFragment(1); + try (Dataset dataset = testDataset.createEmptyDataset(); + Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .tag("") + .transactionProperties(Collections.emptyMap()) + .operation( + Append.builder().fragments(Collections.singletonList(fragment)).build()) + .build()) { + assertTrue(transaction.tag().isEmpty()); + assertTrue(transaction.transactionProperties().isEmpty()); + try (Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + assertTrue(read.tag().isEmpty()); + assertTrue(read.transactionProperties().isEmpty()); + } + } + } + } + + @Test + public void testTransactionPropertiesRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testTransactionPropertiesRoundTrip").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + + Map properties = new HashMap<>(); + properties.put("source", "ingestion-pipeline"); + properties.put("batchId", "42"); + + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .transactionProperties(properties) + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + try (Dataset committed = new CommitBuilder(dataset).execute(txn)) { + Transaction readTx = committed.readTransaction().orElse(null); + assertNotNull(readTx); + Map readProps = readTx.transactionProperties().orElse(null); + assertNotNull(readProps); + assertEquals("ingestion-pipeline", readProps.get("source")); + assertEquals("42", readProps.get("batchId")); + } + } + } + } + } + + @Test + public void testCustomUuid(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testCustomUuid").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + + String customUuid = "custom-uuid-12345"; + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .uuid(customUuid) + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + assertEquals(customUuid, txn.uuid()); + + try (Dataset committed = new CommitBuilder(dataset).execute(txn)) { + Transaction readTx = committed.readTransaction().orElse(null); + assertNotNull(readTx); + assertEquals(customUuid, readTx.uuid()); + } + } + } + } + } + + @Test + public void testCloneRoundTrip(@TempDir Path tempDir) { + String sourcePath = tempDir.resolve("clone_source").toString(); + String targetPath = tempDir.resolve("clone_target").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset sourceDataset = + new TestUtils.SimpleTestDataset(allocator, sourcePath); + sourceDataset.createEmptyDataset().close(); + try (Dataset source = sourceDataset.write(1, 10); + Transaction transaction = + new Transaction.Builder() + .readVersion(source.version()) + .operation( + Clone.builder() + .shallow(true) + .refVersion(source.version()) + .refPath(sourcePath) + .build()) + .build(); + Dataset cloned = new CommitBuilder(targetPath, allocator).execute(transaction); + Transaction read = cloned.readTransaction().orElseThrow()) { + assertInstanceOf(Clone.class, read.operation()); + Clone clone = (Clone) read.operation(); + assertEquals( + Clone.builder().shallow(true).refVersion(source.version()).refPath(sourcePath).build(), + clone); + assertTrue(clone.isShallow()); + assertEquals(sourcePath, clone.getRefPath()); + assertEquals(source.version(), clone.getRefVersion()); + assertTrue(clone.getRefName().isEmpty()); + assertTrue(clone.getBranchName().isEmpty()); + } + } + } + + @Test + public void testDeepCloneCommitRejected(@TempDir Path tempDir) { + String sourcePath = tempDir.resolve("clone_source").toString(); + String targetPath = tempDir.resolve("clone_target").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset sourceDataset = + new TestUtils.SimpleTestDataset(allocator, sourcePath); + sourceDataset.createEmptyDataset().close(); + try (Dataset source = sourceDataset.write(1, 10); + Transaction transaction = + new Transaction.Builder() + .readVersion(source.version()) + .operation( + Clone.builder() + .shallow(false) + .refVersion(source.version()) + .refPath(sourcePath) + .build()) + .build()) { + // Committed directly, a deep clone would reference files never copied to the target. + IllegalArgumentException e = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(targetPath, allocator).execute(transaction)); + assertTrue(e.getMessage().contains("deep Clone cannot be committed directly")); + } + } + } + + @Test + public void testUpdateBasesRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("update_bases").toString(); + String basePath = tempDir.resolve("external_base").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset(); + Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + UpdateBases.builder() + .newBases( + Collections.singletonList( + new BasePath(0, Optional.of("external"), basePath, false))) + .build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + UpdateBases operation = assertInstanceOf(UpdateBases.class, read.operation()); + assertEquals( + UpdateBases.builder() + .newBases( + Collections.singletonList( + new BasePath(0, Optional.of("external"), basePath, false))) + .build(), + operation); + assertEquals(1, operation.getNewBases().size()); + assertEquals("external", operation.getNewBases().get(0).getName().orElseThrow()); + assertEquals(basePath, operation.getNewBases().get(0).getPath()); + } + } + } + + @Test + public void testUpdateMemWalStateRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("update_memwal").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + dataset.initializeMemWal(new InitializeMemWalParams().withUnsharded()); + CompactedSsTable sstable = new CompactedSsTable(UUID.randomUUID().toString(), 7); + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + UpdateMemWalState.builder() + .compactedSstables(Collections.singletonList(sstable)) + .build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + UpdateMemWalState operation = assertInstanceOf(UpdateMemWalState.class, read.operation()); + assertEquals( + UpdateMemWalState.builder() + .compactedSstables(Collections.singletonList(sstable)) + .build(), + operation); + assertEquals(1, operation.getCompactedSstables().size()); + assertEquals(sstable.getShardId(), operation.getCompactedSstables().get(0).getShardId()); + assertEquals(7, operation.getCompactedSstables().get(0).getGeneration()); + } + } + } + } + + @Test + public void testDataOverlayRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("data_overlay").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + FragmentMetadata fragment = dataset.getFragments().get(0).metadata(); + DataOverlay.DataOverlayFile overlay = + DataOverlay.DataOverlayFile.forCommit( + fragment.getFiles().get(0), DataOverlay.OverlayCoverage.shared(OFFSETS_1_3_5)); + DataOverlay.DataOverlayGroup group = + new DataOverlay.DataOverlayGroup(fragment.getId(), Collections.singletonList(overlay)); + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + DataOverlay.builder().groups(Collections.singletonList(group)).build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + DataOverlay operation = assertInstanceOf(DataOverlay.class, read.operation()); + assertEquals( + DataOverlay.builder().groups(Collections.singletonList(group)).build(), operation); + DataOverlay.DataOverlayFile readOverlay = + operation.getGroups().get(0).getOverlays().get(0); + assertEquals(fragment.getId(), operation.getGroups().get(0).getFragmentId()); + assertTrue(readOverlay.getCoverage().isShared()); + assertArrayEquals(OFFSETS_1_3_5, readOverlay.getCoverage().getBitmaps().get(0)); + assertEquals(0, readOverlay.getCommittedVersion()); + } + } + } + } + + @Test + public void testDataOverlayPerFieldCoverageValidation(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("data_overlay_per_field").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + FragmentMetadata fragment = dataset.getFragments().get(0).metadata(); + org.lance.fragment.DataFile dataFile = fragment.getFiles().get(0); + int fieldCount = dataFile.getFields().length; + assertTrue(fieldCount > 0); + + assertThrows( + IllegalArgumentException.class, + () -> + new DataOverlay.DataOverlayFile( + dataFile, DataOverlay.OverlayCoverage.perField(Collections.emptyList()), 0)); + assertThrows( + IllegalArgumentException.class, + () -> + new DataOverlay.DataOverlayFile( + dataFile, + DataOverlay.OverlayCoverage.perField( + Collections.nCopies(fieldCount + 1, OFFSETS_1_3_5)), + 0)); + + DataOverlay.DataOverlayFile overlay = + new DataOverlay.DataOverlayFile( + dataFile, + DataOverlay.OverlayCoverage.perField( + Collections.nCopies(fieldCount, OFFSETS_1_3_5)), + 0); + DataOverlay.DataOverlayGroup group = + new DataOverlay.DataOverlayGroup(fragment.getId(), Collections.singletonList(overlay)); + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + DataOverlay.builder().groups(Collections.singletonList(group)).build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + DataOverlay readOperation = assertInstanceOf(DataOverlay.class, read.operation()); + assertEquals( + fieldCount, + readOperation + .getGroups() + .get(0) + .getOverlays() + .get(0) + .getCoverage() + .getBitmaps() + .size()); + } + } + } + } + + @Test + public void testOverwriteInitialBasesAndUpdateFilterRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("overwrite_initial_bases").toString(); + String basePath = tempDir.resolve("initial_base").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Transaction overwrite = + new Transaction.Builder() + .operation( + Overwrite.builder() + .fragments(Collections.emptyList()) + .schema(testDataset.getSchema()) + .initialBases( + Collections.singletonList( + new BasePath(0, Optional.of("initial"), basePath, false))) + .build()) + .build(); + Dataset dataset = new CommitBuilder(datasetPath, allocator).execute(overwrite); + Transaction readOverwrite = dataset.readTransaction().orElseThrow()) { + Overwrite operation = assertInstanceOf(Overwrite.class, readOverwrite.operation()); + BasePath expectedBasePath = new BasePath(0, Optional.of("initial"), basePath, false); + BasePath readBasePath = operation.getInitialBases().orElseThrow().get(0); + assertEquals(expectedBasePath.getId(), readBasePath.getId()); + assertEquals(expectedBasePath.getName(), readBasePath.getName()); + assertEquals(expectedBasePath.getPath(), readBasePath.getPath()); + assertEquals(expectedBasePath.isDatasetRoot(), readBasePath.isDatasetRoot()); + + dataset.initializeMemWal(new InitializeMemWalParams().withUnsharded()); + KeyExistenceFilter filter = KeyExistenceFilter.exact(new int[] {0}, new long[] {42}); + CompactedSsTable compacted = new CompactedSsTable(UUID.randomUUID().toString(), 8); + try (Transaction update = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Update.builder() + .compactedSstables(Collections.singletonList(compacted)) + .insertedRowsFilter(filter) + .updateMode(Optional.of(Update.UpdateMode.RewriteRows)) + .build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(update); + Transaction readUpdate = committed.readTransaction().orElseThrow()) { + Update updateOperation = assertInstanceOf(Update.class, readUpdate.operation()); + KeyExistenceFilter readFilter = updateOperation.getInsertedRowsFilter().orElseThrow(); + assertArrayEquals(new int[] {0}, readFilter.getFieldIds()); + assertArrayEquals(new long[] {42}, readFilter.getExactKeyHashes()); + assertEquals(Update.UpdateMode.RewriteRows, updateOperation.updateMode().orElseThrow()); + CompactedSsTable readCompacted = updateOperation.getCompactedSstables().get(0); + assertEquals(compacted.getShardId(), readCompacted.getShardId()); + assertEquals(compacted.getGeneration(), readCompacted.getGeneration()); + } + } + } + } + + @Test + public void testBloomFilterRoundTripAndValidation(@TempDir Path tempDir) { + byte[] bitmap = new byte[32]; + bitmap[0] = 1; + KeyExistenceFilter filter = + KeyExistenceFilter.bloom(new int[] {0}, bitmap, bitmap.length * Byte.SIZE, 8192, 0.00057); + + assertThrows( + IllegalArgumentException.class, + () -> KeyExistenceFilter.bloom(new int[] {0}, bitmap, 0, 8192, 0.00057)); + assertThrows( + IllegalArgumentException.class, + () -> KeyExistenceFilter.bloom(new int[] {0}, bitmap, 8, 8192, 0.00057)); + assertThrows( + IllegalArgumentException.class, + () -> + KeyExistenceFilter.bloom(new int[] {0}, bitmap, bitmap.length * Byte.SIZE, 0, 0.00057)); + for (double probability : Arrays.asList(0.0, 1.0, Double.NaN, Double.POSITIVE_INFINITY)) { + assertThrows( + IllegalArgumentException.class, + () -> + KeyExistenceFilter.bloom( + new int[] {0}, bitmap, bitmap.length * Byte.SIZE, 8192, probability)); + } + + String datasetPath = tempDir.resolve("bloom_filter").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset(); + Transaction update = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Update.builder() + .insertedRowsFilter(filter) + .updateMode(Optional.of(Update.UpdateMode.RewriteRows)) + .build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(update); + Transaction read = committed.readTransaction().orElseThrow()) { + assertEquals( + filter, + assertInstanceOf(Update.class, read.operation()).getInsertedRowsFilter().orElseThrow()); + } + } + } + + @Test + public void testCompatibilityAccessorsAndConstructor() throws Exception { + Overwrite.class.getDeclaredConstructor(List.class, Schema.class, Map.class); + assertTrue( + Arrays.stream(Introspector.getBeanInfo(Overwrite.class).getPropertyDescriptors()) + .anyMatch(property -> property.getName().equals("initialBases"))); + assertTrue( + Arrays.stream(Introspector.getBeanInfo(Update.class).getPropertyDescriptors()) + .anyMatch(property -> property.getName().equals("compactedSstables"))); + assertTrue( + Arrays.stream(Introspector.getBeanInfo(Update.class).getPropertyDescriptors()) + .anyMatch(property -> property.getName().equals("insertedRowsFilter"))); + assertTrue(Update.builder().build().updateMode().isEmpty()); + assertThrows( + NullPointerException.class, () -> Update.builder().compactedSstables(null).build()); + Overwrite emptyOverwrite = + Overwrite.builder() + .fragments(Collections.emptyList()) + .schema(new Schema(Collections.emptyList())) + .configUpsertValues(Collections.emptyMap()) + .initialBases(Collections.emptyList()) + .build(); + assertTrue(emptyOverwrite.configUpsertValues().isEmpty()); + assertTrue(emptyOverwrite.getInitialBases().isPresent()); + assertTrue(emptyOverwrite.getInitialBases().orElseThrow().isEmpty()); + } + + @Test + public void testUnrepresentableEmptyUpdateModeAndExplicitEmptyInitialBasesAreRejected( + @TempDir Path tempDir) { + String datasetPath = tempDir.resolve("unrepresentable_optionals").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + assertInvalidOperation( + dataset, Update.builder().build(), "update.updateMode must be specified"); + + Overwrite overwrite = + Overwrite.builder() + .fragments(Collections.emptyList()) + .schema(testDataset.getSchema()) + .initialBases(Collections.emptyList()) + .build(); + assertTrue(overwrite.getInitialBases().isPresent()); + try (Transaction transaction = + new Transaction.Builder().readVersion(dataset.version()).operation(overwrite).build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("register new bases")); + } + } + } + } + + @Test + public void testIndexSizeMustBeRepresentable(@TempDir Path tempDir) { + Index empty = indexMetadata("empty", 0L, Collections.emptyList()); + assertTrue(empty.getSizeBytes().isEmpty()); + assertTrue(empty.getFiles().isEmpty()); + Index consistent = + indexMetadata( + "consistent", + 30L, + Arrays.asList(new IndexFile("a.idx", 10), new IndexFile("b.idx", 20))); + assertEquals(30L, consistent.getSizeBytes().orElseThrow()); + assertEquals(2, consistent.getFiles().orElseThrow().size()); + Index derived = + indexMetadata( + "derived", + null, + Arrays.asList(new IndexFile("derived-a.idx", 10), new IndexFile("derived-b.idx", 20))); + assertEquals(30L, derived.getSizeBytes().orElseThrow()); + + String datasetPath = tempDir.resolve("index_files").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset(); + Transaction missingFiles = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + CreateIndex.builder() + .withNewIndices( + Collections.singletonList(indexMetadata("missing", 10L, null))) + .build()) + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(missingFiles)); + assertTrue(error.getMessage().contains("without files")); + } + + try (Dataset dataset = Dataset.open(datasetPath, allocator); + Transaction mismatch = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + CreateIndex.builder() + .withNewIndices( + Collections.singletonList( + indexMetadata( + "mismatch", + 11L, + Collections.singletonList(new IndexFile("c.idx", 10))))) + .build()) + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> new CommitBuilder(dataset).execute(mismatch)); + assertTrue(error.getMessage().contains("does not match")); + } + + try (Dataset dataset = Dataset.open(datasetPath, allocator)) { + for (List files : + Arrays.asList( + Arrays.asList( + new IndexFile("large-a.idx", Long.MAX_VALUE), new IndexFile("large-b.idx", 1)), + Arrays.asList( + new IndexFile("overflow-a.idx", Long.MAX_VALUE), + new IndexFile("overflow-b.idx", Long.MAX_VALUE), + new IndexFile("overflow-c.idx", Long.MAX_VALUE)))) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, () -> indexMetadata("too-large", null, files)); + assertTrue(error.getMessage().contains("cannot be represented")); + } + } + } + } + + private static Index indexMetadata(String name, Long sizeBytes, List files) { + return Index.builder() + .uuid(UUID.randomUUID()) + .fields(Collections.singletonList(0)) + .coveringFields(Collections.emptyList()) + .name(name) + .datasetVersion(1) + .indexVersion(0) + .sizeBytes(sizeBytes) + .files(files) + .indexType(IndexType.BTREE) + .build(); + } + + @Test + public void testInvalidUnsignedTransactionFieldsAreRejected(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("invalid_unsigned_fields").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + try (Transaction transaction = + new Transaction.Builder().readVersion(-1).operation(Update.builder().build()).build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("transaction.readVersion")); + } + + for (long invalid : new long[] {-1, 1L << 32}) { + for (boolean preserving : new boolean[] {false, true}) { + Update.Builder update = Update.builder(); + if (preserving) { + update.fieldsForPreservingFragBitmap(new long[] {invalid}); + } else { + update.fieldsModified(new long[] {invalid}); + } + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(update.build()) + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("[0]")); + } + } + } + + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + UpdateBases.builder() + .newBases( + Collections.singletonList( + new BasePath(-1, Optional.empty(), "invalid", false))) + .build()) + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("non-negative")); + } + + org.lance.fragment.DataFile dataFile = + new org.lance.fragment.DataFile("overlay", new int[0], new int[0], 2, 1, 0L, null); + DataOverlay.DataOverlayGroup invalidGroup = + new DataOverlay.DataOverlayGroup( + -1, + Collections.singletonList( + new DataOverlay.DataOverlayFile( + dataFile, DataOverlay.OverlayCoverage.shared(OFFSETS_1_3_5), 0))); + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + DataOverlay.builder().groups(Collections.singletonList(invalidGroup)).build()) + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("dataOverlay.fragmentId")); + } + + assertInvalidOperation( + dataset, + Delete.builder().deletedFragmentIds(Collections.singletonList(-1L)).build(), + "delete.deletedFragmentIds[0]"); + assertInvalidOperation( + dataset, + Update.builder().removedFragmentIds(Collections.singletonList(-1L)).build(), + "update.removedFragmentIds[0]"); + assertInvalidOperation( + dataset, + Update.builder() + .updatedFragmentOffsets(Collections.singletonMap(-1L, OFFSETS_1_3_5)) + .updateMode(Optional.of(Update.UpdateMode.RewriteColumns)) + .build(), + "updatedFragmentOffsets.fragmentId"); + assertInvalidOperation(dataset, Restore.builder().version(-1).build(), "restore.version"); + assertInvalidOperation( + dataset, + ReserveFragments.builder().numFragments(-1).build(), + "reserveFragments.numFragments"); + assertInvalidOperation( + dataset, + DataReplacement.builder() + .replacements( + Collections.singletonList( + new DataReplacement.DataReplacementGroup(-1, dataFile))) + .build(), + "dataReplacement.fragmentId"); + assertInvalidOperation( + dataset, + CreateIndex.builder() + .withNewIndices( + Collections.singletonList( + Index.builder() + .uuid(UUID.randomUUID()) + .fields(Collections.singletonList(0)) + .coveringFields(Collections.emptyList()) + .name("negative-version") + .datasetVersion(-1) + .indexVersion(0) + .indexType(IndexType.BTREE) + .build())) + .build(), + "index.datasetVersion"); + assertInvalidOperation( + dataset, + CreateIndex.builder() + .withNewIndices( + Collections.singletonList( + Index.builder() + .uuid(UUID.randomUUID()) + .fields(Collections.singletonList(0)) + .coveringFields(Collections.emptyList()) + .name("negative-fragment") + .datasetVersion(1) + .fragments(Collections.singletonList(-1)) + .indexVersion(0) + .indexType(IndexType.BTREE) + .build())) + .build(), + "index.fragments[0]"); + } + } + } + + private static void assertInvalidOperation( + Dataset dataset, Operation operation, String expectedMessage) { + try (Transaction transaction = + new Transaction.Builder().readVersion(dataset.version()).operation(operation).build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains(expectedMessage), error.getMessage()); + } + } + + @Test + public void testUnknownOperationReturnsDiagnosticError(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("unknown_operation").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset(); + Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(() -> "FutureOperation") + .build()) { + IllegalArgumentException error = + assertThrows( + IllegalArgumentException.class, + () -> new CommitBuilder(dataset).execute(transaction)); + assertTrue(error.getMessage().contains("FutureOperation")); + } + } + } + + @Test + public void testRewrittenIndexFilesModelPreservesAbsentAndEmpty() { + UUID oldId = UUID.randomUUID(); + UUID newId = UUID.randomUUID(); + RewrittenIndex absent = + RewrittenIndex.builder() + .oldId(oldId) + .newId(newId) + .newIndexDetailsTypeUrl("type.googleapis.com/lance.index.BTreeIndexDetails") + .newIndexDetailsValue(new byte[0]) + .newIndexVersion(1) + .build(); + RewrittenIndex empty = + RewrittenIndex.builder() + .oldId(oldId) + .newId(newId) + .newIndexDetailsTypeUrl("type.googleapis.com/lance.index.BTreeIndexDetails") + .newIndexDetailsValue(new byte[0]) + .newIndexVersion(1) + .newIndexFiles(Collections.emptyList()) + .build(); + RewrittenIndex populated = + RewrittenIndex.builder() + .oldId(oldId) + .newId(newId) + .newIndexDetailsTypeUrl("type.googleapis.com/lance.index.BTreeIndexDetails") + .newIndexDetailsValue(new byte[0]) + .newIndexVersion(1) + .newIndexFiles(Collections.singletonList(new IndexFile("index.idx", 123))) + .build(); + + assertTrue(absent.getNewIndexFiles().isEmpty()); + assertTrue(empty.getNewIndexFiles().isEmpty()); + assertEquals(123, populated.getNewIndexFiles().orElseThrow().get(0).getSizeBytes()); + } + + @Test + public void testRewrittenIndexFilesRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("rewritten_index_files").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 10)) { + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + dataset.createIndex( + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("btree_id") + .build()); + Index oldIndex = dataset.getIndexes().get(0); + IndexFile indexFile = new IndexFile("index.idx", 123); + RewrittenIndex rewrittenIndex = + RewrittenIndex.builder() + .oldId(oldIndex.uuid()) + .newId(UUID.randomUUID()) + .newIndexDetailsTypeUrl("type.googleapis.com/lance.index.BTreeIndexDetails") + .newIndexDetailsValue(new byte[0]) + .newIndexVersion(oldIndex.indexVersion()) + .newIndexFiles(Collections.singletonList(indexFile)) + .build(); + try (Transaction transaction = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Rewrite.builder() + .rewrittenIndices(Collections.singletonList(rewrittenIndex)) + .build()) + .build(); + Dataset committed = new CommitBuilder(dataset).execute(transaction); + Transaction read = committed.readTransaction().orElseThrow()) { + Rewrite operation = assertInstanceOf(Rewrite.class, read.operation()); + IndexFile readFile = + operation.rewrittenIndices().get(0).getNewIndexFiles().orElseThrow().get(0); + assertEquals("index.idx", readFile.getPath()); + assertEquals(123, readFile.getSizeBytes()); + } } } } diff --git a/java/src/test/java/org/lance/UpdateTest.java b/java/src/test/java/org/lance/UpdateTest.java new file mode 100644 index 00000000000..17f17a34613 --- /dev/null +++ b/java/src/test/java/org/lance/UpdateTest.java @@ -0,0 +1,309 @@ +/* + * 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.LanceScanner; +import org.lance.ipc.ScanOptions; +import org.lance.operation.Append; +import org.lance.update.UpdateParams; +import org.lance.update.UpdateResult; + +import com.google.common.collect.ImmutableMap; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.UInt8Vector; +import org.apache.arrow.vector.VarCharVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.UUID; + +public class UpdateTest { + private static final int ROW_COUNT = 5; + private static final String ROW_ID_COLUMN = "_rowid"; + + @TempDir private Path tempDir; + private RootAllocator allocator; + private TestUtils.SimpleTestDataset testDataset; + private Dataset dataset; + + @BeforeEach + public void setup() { + String datasetPath = tempDir.resolve(UUID.randomUUID().toString()).toString(); + allocator = new RootAllocator(Long.MAX_VALUE); + testDataset = new TestUtils.SimpleTestDataset(allocator, datasetPath); + + // Enable stable row ids so that `_rowid` is a stable u64 identifier and can be + // used as an update predicate (the core feature exercised by the rowid tests). + Dataset empty = + testDataset.createDatasetWithWriteParams( + new WriteParams.Builder().withEnableStableRowIds(true).build()); + + FragmentMetadata fragment = testDataset.createNewFragment(ROW_COUNT); + SourcedTransaction transaction = + new SourcedTransaction.Builder(empty) + .operation(Append.builder().fragments(Arrays.asList(fragment)).build()) + .readVersion(empty.version()) + .build(); + dataset = transaction.commit(); + empty.close(); + } + + @AfterEach + public void tearDown() { + if (dataset != null) { + dataset.close(); + } + allocator.close(); + } + + @ParameterizedTest + @EnumSource(DataStorageVersion.class) + public void testUpdateAllRows(DataStorageVersion version) { + if (version == DataStorageVersion.LEGACY) { + IllegalArgumentException error = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'updated'")) + .withDataStorageVersion(version))); + Assertions.assertTrue(error.getMessage().contains("V1 and V2")); + return; + } + String exactVersion = + version == DataStorageVersion.STABLE + ? "2.2" + : version == DataStorageVersion.NEXT ? "2.3" : version.toRustString(); + UpdateResult result = + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'updated'")).withDataStorageVersion(version)); + + Assertions.assertEquals(ROW_COUNT, result.getNumRowsUpdated()); + try (Dataset newDataset = result.getDataset()) { + Assertions.assertTrue( + newDataset.getFragments().stream() + .flatMap(fragment -> fragment.metadata().getFiles().stream()) + .allMatch( + file -> + exactVersion.equals( + file.getFileMajorVersion() + "." + file.getFileMinorVersion()))); + List names = readNames(newDataset); + Assertions.assertEquals(ROW_COUNT, names.size()); + for (String name : names) { + Assertions.assertEquals("updated", name); + } + } + } + + @Test + public void testUpdateWithWhere() { + UpdateResult result = + dataset.update(new UpdateParams(ImmutableMap.of("name", "'updated'")).withWhere("id = 2")); + + Assertions.assertEquals(1, result.getNumRowsUpdated()); + try (Dataset newDataset = result.getDataset()) { + assertNamesById( + newDataset, id -> id == 2 ? "updated" : "Person " + id, /* expectedRows= */ ROW_COUNT); + } + } + + @Test + public void testUpdateByRowId() throws Exception { + List sample = readRowIdsAndIds(dataset); + long targetRowId = sample.get(0)[2]; + int targetId = (int) sample.get(1)[2]; + + UpdateResult result = + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'updated'")) + .withWhere(ROW_ID_COLUMN + " = " + targetRowId)); + + Assertions.assertEquals(1, result.getNumRowsUpdated()); + try (Dataset newDataset = result.getDataset()) { + assertNamesById( + newDataset, + id -> id == targetId ? "updated" : "Person " + id, + /* expectedRows= */ ROW_COUNT); + } + } + + @Test + public void testUpdateByRowIdInList() throws Exception { + List sample = readRowIdsAndIds(dataset); + long[] rowIds = sample.get(0); + long[] ids = sample.get(1); + int[] indices = new int[] {0, 2, 4}; + + StringBuilder inList = new StringBuilder(); + List targetIds = new ArrayList<>(); + for (int i = 0; i < indices.length; i++) { + if (i > 0) { + inList.append(", "); + } + inList.append(rowIds[indices[i]]); + targetIds.add((int) ids[indices[i]]); + } + + UpdateResult result = + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'updated'")) + .withWhere(ROW_ID_COLUMN + " IN (" + inList + ")")); + + Assertions.assertEquals(targetIds.size(), result.getNumRowsUpdated()); + try (Dataset newDataset = result.getDataset()) { + assertNamesById( + newDataset, + id -> targetIds.contains(id) ? "updated" : "Person " + id, + /* expectedRows= */ ROW_COUNT); + } + } + + @Test + public void testUpdateWithRetryParameters() { + // Ensure builder-style retry knobs round-trip through the JNI layer. + UpdateResult result = + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'retried'")) + .withConflictRetries(3) + .withRetryTimeoutMs(60_000)); + + Assertions.assertEquals(ROW_COUNT, result.getNumRowsUpdated()); + try (Dataset newDataset = result.getDataset()) { + for (String name : readNames(newDataset)) { + Assertions.assertEquals("retried", name); + } + } + } + + @Test + public void testUpdateRejectsEmptyUpdates() { + Assertions.assertThrows( + IllegalArgumentException.class, () -> new UpdateParams(Collections.emptyMap())); + } + + @Test + public void testUpdateRejectsNegativeRetryParameters() { + UpdateParams params = new UpdateParams(ImmutableMap.of("name", "'x'")); + Assertions.assertThrows(IllegalArgumentException.class, () -> params.withConflictRetries(-1)); + Assertions.assertThrows(IllegalArgumentException.class, () -> params.withRetryTimeoutMs(-1)); + } + + @Test + public void testUpdatePropagatesInvalidWhereClause() { + // Unknown column should surface as an exception from the Rust core, not a silent no-op. + Assertions.assertThrows( + Exception.class, + () -> + dataset.update( + new UpdateParams(ImmutableMap.of("name", "'x'")) + .withWhere("nonexistent_column = 1"))); + } + + @Test + public void testUpdatePropagatesInvalidUpdateExpression() { + // Malformed SQL expression should surface as an exception, not a silent no-op. + Assertions.assertThrows( + Exception.class, + () -> dataset.update(new UpdateParams(ImmutableMap.of("name", "this is not sql")))); + } + + /** + * Returns a 2-element list: index 0 contains the {@code _rowid} values, index 1 contains the + * {@code id} values. Both arrays are sized to {@link #ROW_COUNT}. + */ + private List readRowIdsAndIds(Dataset ds) throws Exception { + long[] rowIds = new long[ROW_COUNT]; + long[] ids = new long[ROW_COUNT]; + try (LanceScanner scanner = + ds.newScan( + new ScanOptions.Builder().columns(Arrays.asList("id")).withRowId(true).build())) { + try (ArrowReader reader = scanner.scanBatches()) { + int row = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot batch = reader.getVectorSchemaRoot(); + // Lance stable `_rowid` is an Arrow uint64; Arrow Java represents uint64 as + // `UInt8Vector` (the "8" refers to byte width, not bit width). + UInt8Vector rowIdVector = (UInt8Vector) batch.getVector(ROW_ID_COLUMN); + IntVector idVector = (IntVector) batch.getVector("id"); + for (int i = 0; i < batch.getRowCount(); i++) { + rowIds[row] = rowIdVector.get(i); + ids[row] = idVector.get(i); + row++; + } + } + Assertions.assertEquals(ROW_COUNT, row); + } + } + return Arrays.asList(rowIds, ids); + } + + private interface IntToString { + String apply(int id); + } + + private void assertNamesById(Dataset ds, IntToString expected, int expectedRows) { + try (LanceScanner scanner = + ds.newScan(new ScanOptions.Builder().columns(Arrays.asList("id", "name")).build())) { + try (ArrowReader reader = scanner.scanBatches()) { + int seen = 0; + while (reader.loadNextBatch()) { + VectorSchemaRoot batch = reader.getVectorSchemaRoot(); + IntVector idVector = (IntVector) batch.getVector("id"); + VarCharVector nameVector = (VarCharVector) batch.getVector("name"); + for (int i = 0; i < batch.getRowCount(); i++) { + int id = idVector.get(i); + String name = new String(nameVector.get(i)); + Assertions.assertEquals(expected.apply(id), name); + seen++; + } + } + Assertions.assertEquals(expectedRows, seen); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private List readNames(Dataset ds) { + List names = new ArrayList<>(); + try (LanceScanner scanner = + ds.newScan(new ScanOptions.Builder().columns(Arrays.asList("name")).build())) { + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot batch = reader.getVectorSchemaRoot(); + VarCharVector nameVector = (VarCharVector) batch.getVector("name"); + for (int i = 0; i < batch.getRowCount(); i++) { + names.add(new String(nameVector.get(i))); + } + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + return names; + } +} diff --git a/java/src/test/java/org/lance/VectorSearchTest.java b/java/src/test/java/org/lance/VectorSearchTest.java index 6476f4277cd..bf358cb24c4 100644 --- a/java/src/test/java/org/lance/VectorSearchTest.java +++ b/java/src/test/java/org/lance/VectorSearchTest.java @@ -13,7 +13,34 @@ */ package org.lance; +import org.lance.ipc.Query; +import org.lance.ipc.ScanOptions; + +import org.apache.arrow.dataset.scanner.Scanner; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.Float4Vector; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.nio.file.Path; +import java.util.ArrayList; +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.*; @@ -27,190 +54,472 @@ // // An IVF-PQ index with 2 partitions is trained on this data public class VectorSearchTest { + @TempDir Path tempDir; // TODO: fix in https://github.com/lancedb/lance/issues/2956 - // @Test - // void test_create_index() throws Exception { - // try (TestVectorDataset testVectorDataset = new - // TestVectorDataset(tempDir.resolve("test_create_index"))) { - // try (Dataset dataset = testVectorDataset.create()) { - // testVectorDataset.createIndex(dataset); - // List indexes = dataset.listIndexes(); - // assertEquals(1, indexes.size()); - // assertEquals(TestVectorDataset.indexName, indexes.get(0)); - // } - // } - // } - - // rust/lance-linalg/src/distance/l2.rs:256:5: - // 5assertion `left == right` failed - // Directly panic instead of throwing an exception - // @Test - // void search_invalid_vector() throws Exception { - // try (TestVectorDataset testVectorDataset = new - // TestVectorDataset(tempDir.resolve("test_create_index"))) { - // try (Dataset dataset = testVectorDataset.create()) { - // float[] key = new float[30]; - // for (int i = 0; i < 30; i++) { - // key[i] = (float) (i + 30); - // } - // ScanOptions options = new ScanOptions.Builder() - // .nearest(new Query.Builder() - // .setColumn(TestVectorDataset.vectorColumnName) - // .setKey(key) - // .setK(5) - // .setUseIndex(false) - // .build()) - // .build(); - // assertThrows(IllegalArgumentException.class, () -> { - // try (Scanner scanner = dataset.newScan(options)) { - // try (ArrowReader reader = scanner.scanBatches()) { - // } - // } - // }); - // } - // } - // } - - // @ParameterizedTest - // @ValueSource(booleans = { false, true }) - // void test_knn(boolean createVectorIndex) throws Exception { - // try (TestVectorDataset testVectorDataset = new - // TestVectorDataset(tempDir.resolve("test_knn"))) { - // try (Dataset dataset = testVectorDataset.create()) { - - // if (createVectorIndex) { - // testVectorDataset.createIndex(dataset); - // } - // float[] key = new float[32]; - // for (int i = 0; i < 32; i++) { - // key[i] = (float) (i + 32); - // } - // ScanOptions options = new ScanOptions.Builder() - // .nearest(new Query.Builder() - // .setColumn(TestVectorDataset.vectorColumnName) - // .setKey(key) - // .setK(5) - // .setUseIndex(false) - // .build()) - // .build(); - // try (Scanner scanner = dataset.newScan(options)) { - // try (ArrowReader reader = scanner.scanBatches()) { - // VectorSchemaRoot root = reader.getVectorSchemaRoot(); - // System.out.println("Schema:"); - // assertTrue(reader.loadNextBatch(), "Expected at least one batch"); - - // assertEquals(5, root.getRowCount(), "Expected 5 results"); - - // assertEquals(4, root.getSchema().getFields().size(), "Expected 4 columns"); - // assertEquals("i", root.getSchema().getFields().get(0).getName()); - // assertEquals("s", root.getSchema().getFields().get(1).getName()); - // assertEquals(TestVectorDataset.vectorColumnName, - // root.getSchema().getFields().get(2).getName()); - // assertEquals("_distance", root.getSchema().getFields().get(3).getName()); - - // IntVector iVector = (IntVector) root.getVector("i"); - // Set expectedI = new HashSet<>(Arrays.asList(1, 81, 161, 241, 321)); - // Set actualI = new HashSet<>(); - // for (int i = 0; i < iVector.getValueCount(); i++) { - // actualI.add(iVector.get(i)); - // } - // assertEquals(expectedI, actualI, "Unexpected values in 'i' column"); - - // Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); - // float prevDistance = Float.NEGATIVE_INFINITY; - // for (int i = 0; i < distanceVector.getValueCount(); i++) { - // float distance = distanceVector.get(i); - // assertTrue(distance >= prevDistance, "Distances should be in ascending order"); - // prevDistance = distance; - // } - - // assertFalse(reader.loadNextBatch(), "Expected only one batch"); - // } - // } - // } - // } - // } - - // @Test - // void test_knn_with_new_data() throws Exception { - // try (TestVectorDataset testVectorDataset = new - // TestVectorDataset(tempDir.resolve("test_knn_with_new_data"))) { - // try (Dataset dataset = testVectorDataset.create()) { - // testVectorDataset.createIndex(dataset); - // } - - // float[] key = new float[32]; - // Arrays.fill(key, 0.0f); - // // Set k larger than the number of new rows - // int k = 20; - - // List cases = new ArrayList<>(); - // List> filters = Arrays.asList(Optional.empty(), Optional.of("i > 100")); - // List> limits = Arrays.asList(Optional.empty(), Optional.of(10)); - - // for (Optional filter : filters) { - // for (Optional limit : limits) { - // for (boolean useIndex : new boolean[] { true, false }) { - // cases.add(new TestCase(filter, limit, useIndex)); - // } - // } - // } - - // // Validate all cases - // try (Dataset dataset = testVectorDataset.appendNewData()) { - // for (TestCase testCase : cases) { - // ScanOptions.Builder optionsBuilder = new ScanOptions.Builder() - // .nearest(new Query.Builder() - // .setColumn(TestVectorDataset.vectorColumnName) - // .setKey(key) - // .setK(k) - // .setUseIndex(testCase.useIndex) - // .build()); - - // testCase.filter.ifPresent(optionsBuilder::filter); - // testCase.limit.ifPresent(optionsBuilder::limit); - - // ScanOptions options = optionsBuilder.build(); - - // try (Scanner scanner = dataset.newScan(options)) { - // try (ArrowReader reader = scanner.scanBatches()) { - // VectorSchemaRoot root = reader.getVectorSchemaRoot(); - // assertTrue(reader.loadNextBatch(), "Expected at least one batch"); - - // if (testCase.filter.isPresent()) { - // int resultRows = root.getRowCount(); - // int expectedRows = testCase.limit.orElse(k); - // assertTrue(resultRows <= expectedRows, - // "Expected less than or equal to " + expectedRows + " rows, got " + - // resultRows); - // } else { - // assertEquals(testCase.limit.orElse(k), root.getRowCount(), - // "Unexpected number of rows"); - // } - - // // Top one should be the first value of new data - // IntVector iVector = (IntVector) root.getVector("i"); - // assertEquals(400, iVector.get(0), "First result should be the first value of new - // data"); - - // // Check if distances are in ascending order - // Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); - // float prevDistance = Float.NEGATIVE_INFINITY; - // for (int i = 0; i < distanceVector.getValueCount(); i++) { - // float distance = distanceVector.get(i); - // assertTrue(distance >= prevDistance, "Distances should be in ascending order"); - // prevDistance = distance; - // } - - // assertFalse(reader.loadNextBatch(), "Expected only one batch"); - // } - // } - // } - // } - // } - // } + @Test + void test_create_index() throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("test_create_index"))) { + try (Dataset dataset = testVectorDataset.create()) { + testVectorDataset.createIndex(dataset); + List indexes = dataset.listIndexes(); + assertEquals(1, indexes.size()); + assertEquals(TestVectorDataset.indexName, indexes.get(0)); + } + } + } + + @Test + void search_invalid_vector() throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("search_invalid_vector"))) { + try (Dataset dataset = testVectorDataset.create()) { + float[] key = new float[30]; + for (int i = 0; i < 30; i++) { + key[i] = (float) (i + 30); + } + ScanOptions options = + new ScanOptions.Builder() + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(5) + .setUseIndex(false) + .build()) + .build(); + assertThrows( + IllegalArgumentException.class, + () -> { + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + reader.loadNextBatch(); + } + } + }); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void test_knn(boolean createVectorIndex) throws Exception { + try (TestVectorDataset testVectorDataset = new TestVectorDataset(tempDir.resolve("test_knn"))) { + try (Dataset dataset = testVectorDataset.create()) { + + if (createVectorIndex) { + testVectorDataset.createIndex(dataset); + } + float[] key = new float[32]; + for (int i = 0; i < 32; i++) { + key[i] = (float) (i + 32); + } + ScanOptions options = + new ScanOptions.Builder() + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(5) + .setUseIndex(createVectorIndex) + .build()) + .build(); + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + System.out.println("Schema:"); + assertTrue(reader.loadNextBatch(), "Expected at least one batch"); + + assertEquals(5, root.getRowCount(), "Expected 5 results"); + + assertEquals(4, root.getSchema().getFields().size(), "Expected 4 columns"); + assertEquals("i", root.getSchema().getFields().get(0).getName()); + assertEquals("s", root.getSchema().getFields().get(1).getName()); + assertEquals( + TestVectorDataset.vectorColumnName, root.getSchema().getFields().get(2).getName()); + assertEquals("_distance", root.getSchema().getFields().get(3).getName()); + + IntVector iVector = (IntVector) root.getVector("i"); + Set expectedI = new HashSet<>(Arrays.asList(1, 81, 161, 241, 321)); + Set actualI = new HashSet<>(); + for (int i = 0; i < iVector.getValueCount(); i++) { + actualI.add(iVector.get(i)); + } + assertEquals(expectedI, actualI, "Unexpected values in 'i' column"); + + Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); + float prevDistance = Float.NEGATIVE_INFINITY; + for (int i = 0; i < distanceVector.getValueCount(); i++) { + float distance = distanceVector.get(i); + assertTrue(distance >= prevDistance, "Distances should be in ascending order"); + prevDistance = distance; + } + + assertFalse(reader.loadNextBatch(), "Expected only one batch"); + } + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void test_batch_knn(boolean createVectorIndex) throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("test_batch_knn"))) { + try (Dataset dataset = testVectorDataset.create()) { + if (createVectorIndex) { + testVectorDataset.createIndex(dataset); + } + + // Two query vectors, each an exact match for a distinct set of rows. Every + // fragment repeats the same per-row vectors, so each query has exactly five + // distance-0 matches across the dataset. + float[] key0 = new float[32]; + float[] key1 = new float[32]; + for (int i = 0; i < 32; i++) { + key0[i] = (float) (i + 32); // matches rows with i in {1, 81, 161, 241, 321} + key1[i] = (float) i; // matches rows with i in {0, 80, 160, 240, 320} + } + int k = 5; + ScanOptions options = + new ScanOptions.Builder() + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKeys(new float[][] {key0, key1}) + .setK(k) + .setUseIndex(createVectorIndex) + .build()) + .build(); + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertTrue(reader.loadNextBatch(), "Expected at least one batch"); + + // A batch query prepends a non-nullable query_index column. + assertEquals(5, root.getSchema().getFields().size(), "Expected 5 columns"); + assertEquals("query_index", root.getSchema().getFields().get(0).getName()); + assertEquals("i", root.getSchema().getFields().get(1).getName()); + assertEquals("s", root.getSchema().getFields().get(2).getName()); + assertEquals( + TestVectorDataset.vectorColumnName, root.getSchema().getFields().get(3).getName()); + assertEquals("_distance", root.getSchema().getFields().get(4).getName()); + + // N query vectors, up to k results each. + assertEquals(2 * k, root.getRowCount(), "Expected N * k results"); + + IntVector queryIndexVector = (IntVector) root.getVector("query_index"); + IntVector iVector = (IntVector) root.getVector("i"); + Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); + + Set query0I = new HashSet<>(); + Set query1I = new HashSet<>(); + float prevDistance = Float.NEGATIVE_INFINITY; + int prevQueryIndex = 0; + for (int row = 0; row < root.getRowCount(); row++) { + int queryIndex = queryIndexVector.get(row); + assertTrue(queryIndex == 0 || queryIndex == 1, "Unexpected query_index"); + // Rows are grouped by query_index; distance ascends within each group. + assertTrue(queryIndex >= prevQueryIndex, "Rows should be grouped by query_index"); + if (queryIndex != prevQueryIndex) { + prevDistance = Float.NEGATIVE_INFINITY; + } + float distance = distanceVector.get(row); + assertTrue(distance >= prevDistance, "Distances should ascend within a query group"); + prevDistance = distance; + prevQueryIndex = queryIndex; + + if (queryIndex == 0) { + query0I.add(iVector.get(row)); + } else { + query1I.add(iVector.get(row)); + } + } + + assertEquals( + new HashSet<>(Arrays.asList(1, 81, 161, 241, 321)), + query0I, + "Unexpected matches for query 0"); + assertEquals( + new HashSet<>(Arrays.asList(0, 80, 160, 240, 320)), + query1I, + "Unexpected matches for query 1"); + + assertFalse(reader.loadNextBatch(), "Expected only one batch"); + } + } + } + } + } + + @Test + void test_batch_knn_rejects_invalid_keys() { + Query.Builder builder = new Query.Builder().setColumn(TestVectorDataset.vectorColumnName); + // An empty batch has no query vectors. + assertThrows(IllegalArgumentException.class, () -> builder.setKeys(new float[][] {})); + // Ragged rows: query vectors must all share one dimension. + assertThrows( + IllegalArgumentException.class, + () -> builder.setKeys(new float[][] {{1.0f, 2.0f}, {1.0f, 2.0f, 3.0f}})); + // vectors x dimensions overflowing the flattened int-indexed buffer is rejected up front. + // Every row aliases one array, so this costs well under 1 MB. + float[] row = new float[1 << 15]; + float[][] oversized = new float[1 << 16][]; + Arrays.fill(oversized, row); + assertThrows(IllegalArgumentException.class, () -> builder.setKeys(oversized)); + } + + @Test + void test_batch_knn_rejects_multivector_column() { + // A list-shaped query against a List column is one multivector query in + // the core, which cannot honor the setKeys batch contract (per-query results with + // query_index), so the binding must reject it rather than silently change semantics. + Field vectorItem = + new Field( + "item", + FieldType.nullable(new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)), + null); + Field vector = + new Field( + "item", + FieldType.nullable(new ArrowType.FixedSizeList(4)), + Collections.singletonList(vectorItem)); + Field multivector = + new Field( + "mv", FieldType.nullable(new ArrowType.List()), Collections.singletonList(vector)); + Schema schema = new Schema(Collections.singletonList(multivector)); + String datasetPath = tempDir.resolve("test_batch_knn_multivector").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = + Dataset.create(allocator, datasetPath, schema, new WriteParams.Builder().build())) { + ScanOptions options = + new ScanOptions.Builder() + .nearest( + new Query.Builder() + .setColumn("mv") + .setKeys(new float[][] {{1f, 2f, 3f, 4f}, {5f, 6f, 7f, 8f}}) + .setK(1) + .build()) + .build(); + IllegalArgumentException error = + assertThrows(IllegalArgumentException.class, () -> dataset.newScan(options)); + assertTrue( + error.getMessage().contains("not supported on multivector column 'mv'"), + "Unexpected error message: " + error.getMessage()); + } + } + + @Test + void test_knn_with_new_data() throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("test_knn_with_new_data"))) { + try (Dataset dataset = testVectorDataset.create()) { + testVectorDataset.createIndex(dataset); + } + + float[] key = new float[32]; + Arrays.fill(key, 0.0f); + // Set k larger than the number of new rows + int k = 20; + + List cases = new ArrayList<>(); + List> filters = Arrays.asList(Optional.empty(), Optional.of("i > 100")); + List> limits = Arrays.asList(Optional.empty(), Optional.of(10)); + + for (Optional filter : filters) { + for (Optional limit : limits) { + for (boolean useIndex : new boolean[] {true, false}) { + cases.add(new TestCase(filter, limit, useIndex)); + } + } + } + + // Validate all cases + try (Dataset dataset = testVectorDataset.appendNewData()) { + for (TestCase testCase : cases) { + ScanOptions.Builder optionsBuilder = + new ScanOptions.Builder() + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(k) + .setUseIndex(testCase.useIndex) + .build()); + + testCase.filter.ifPresent(optionsBuilder::filter); + testCase.limit.ifPresent(optionsBuilder::limit); + + ScanOptions options = optionsBuilder.build(); + + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + assertTrue(reader.loadNextBatch(), "Expected at least one batch"); + + if (testCase.filter.isPresent()) { + int resultRows = root.getRowCount(); + int expectedRows = testCase.limit.orElse(k); + assertTrue( + resultRows <= expectedRows, + "Expected less than or equal to " + expectedRows + " rows, got " + resultRows); + } else { + assertEquals( + testCase.limit.orElse(k), root.getRowCount(), "Unexpected number of rows"); + } + + // Top one should be the first value of new data + IntVector iVector = (IntVector) root.getVector("i"); + assertEquals( + 400, iVector.get(0), "First result should be the first value of new data"); + + // Check if distances are in ascending order + Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); + float prevDistance = Float.NEGATIVE_INFINITY; + for (int i = 0; i < distanceVector.getValueCount(); i++) { + float distance = distanceVector.get(i); + assertTrue(distance >= prevDistance, "Distances should be in ascending order"); + prevDistance = distance; + } + + assertFalse(reader.loadNextBatch(), "Expected only one batch"); + } + } + } + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + void test_knn_with_fragment(boolean createVectorIndex) throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("test_knn_with_fragment"))) { + try (Dataset dataset = testVectorDataset.create()) { + + if (createVectorIndex) { + testVectorDataset.createIndex(dataset); + } + List fragmentIds = new ArrayList<>(Arrays.asList(3, 4)); + float[] key = new float[32]; + for (int i = 0; i < 32; i++) { + key[i] = (float) (i + 32); + } + ScanOptions options = + new ScanOptions.Builder() + .fragmentIds(fragmentIds) + .prefilter(true) + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(6) + .setUseIndex(createVectorIndex) + .build()) + .build(); + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + System.out.println("Schema:"); + assertTrue(reader.loadNextBatch(), "Expected at least one batch"); + + assertEquals(6, root.getRowCount(), "Expected 6 results"); + + assertEquals(4, root.getSchema().getFields().size(), "Expected 4 columns"); + assertEquals("i", root.getSchema().getFields().get(0).getName()); + assertEquals("s", root.getSchema().getFields().get(1).getName()); + assertEquals( + TestVectorDataset.vectorColumnName, root.getSchema().getFields().get(2).getName()); + assertEquals("_distance", root.getSchema().getFields().get(3).getName()); + + IntVector iVector = (IntVector) root.getVector("i"); + Set expectedI = new HashSet<>(Arrays.asList(240, 320, 241, 321, 242, 322)); + Set actualI = new HashSet<>(); + for (int i = 0; i < iVector.getValueCount(); i++) { + actualI.add(iVector.get(i)); + } + assertEquals(expectedI, actualI, "Unexpected values in 'i' column"); + + Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); + float prevDistance = Float.NEGATIVE_INFINITY; + for (int i = 0; i < distanceVector.getValueCount(); i++) { + float distance = distanceVector.get(i); + assertTrue(distance >= prevDistance, "Distances should be in ascending order"); + prevDistance = distance; + } + + assertFalse(reader.loadNextBatch(), "Expected only one batch"); + } + } + } + } + } + + @Test + void test_knn_with_new_data_with_fragment() throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("test_knn_with_new_data_with_fragment"))) { + try (Dataset dataset = testVectorDataset.create()) { + testVectorDataset.createIndex(dataset); + } + List fragmentIds = new ArrayList<>(Arrays.asList(3, 4)); + float[] key = new float[32]; + for (int i = 0; i < 32; i++) { + key[i] = (float) (i + 32); + } + ScanOptions options = + new ScanOptions.Builder() + .fragmentIds(fragmentIds) + .prefilter(true) + .nearest( + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(6) + .setUseIndex(true) + .build()) + .build(); + try (Dataset dataset = testVectorDataset.appendNewData()) { + try (Scanner scanner = dataset.newScan(options)) { + try (ArrowReader reader = scanner.scanBatches()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + System.out.println("Schema:"); + assertTrue(reader.loadNextBatch(), "Expected at least one batch"); + + assertEquals(6, root.getRowCount(), "Expected 6 results"); + + assertEquals(4, root.getSchema().getFields().size(), "Expected 4 columns"); + assertEquals("i", root.getSchema().getFields().get(0).getName()); + assertEquals("s", root.getSchema().getFields().get(1).getName()); + assertEquals( + TestVectorDataset.vectorColumnName, root.getSchema().getFields().get(2).getName()); + assertEquals("_distance", root.getSchema().getFields().get(3).getName()); + + IntVector iVector = (IntVector) root.getVector("i"); + Set expectedI = new HashSet<>(Arrays.asList(240, 320, 241, 321, 242, 322)); + Set actualI = new HashSet<>(); + for (int i = 0; i < iVector.getValueCount(); i++) { + actualI.add(iVector.get(i)); + } + assertEquals(expectedI, actualI, "Unexpected values in 'i' column"); + + Float4Vector distanceVector = (Float4Vector) root.getVector("_distance"); + float prevDistance = Float.NEGATIVE_INFINITY; + for (int i = 0; i < distanceVector.getValueCount(); i++) { + float distance = distanceVector.get(i); + assertTrue(distance >= prevDistance, "Distances should be in ascending order"); + prevDistance = distance; + } + + assertFalse(reader.loadNextBatch(), "Expected only one batch"); + } + } + } + } + } private static class TestCase { final Optional filter; diff --git a/java/src/test/java/org/lance/fragment/DataFileEqualityTest.java b/java/src/test/java/org/lance/fragment/DataFileEqualityTest.java new file mode 100644 index 00000000000..1726cda1727 --- /dev/null +++ b/java/src/test/java/org/lance/fragment/DataFileEqualityTest.java @@ -0,0 +1,73 @@ +/* + * 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.fragment; + +import org.lance.FragmentMetadata; + +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashSet; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** Equality contract of fragment metadata value classes. */ +public class DataFileEqualityTest { + + private static DataFile dataFile(Integer baseId) { + return new DataFile("data/a.lance", new int[] {0, 1}, new int[] {0, 1}, 2, 1, 1024L, baseId); + } + + private static DeletionFile deletionFile(Integer baseId) { + return new DeletionFile(7L, 3L, 5L, DeletionFileType.BITMAP, baseId); + } + + @Test + void testDataFileEqualsAndHashCodeIncludeBaseId() { + for (Integer baseId : new Integer[] {null, 1}) { + assertEquals(dataFile(baseId), dataFile(baseId)); + assertEquals(dataFile(baseId).hashCode(), dataFile(baseId).hashCode()); + } + // Same relative path under different bases refers to different physical files. + assertNotEquals(dataFile(1), dataFile(2)); + assertNotEquals(dataFile(null), dataFile(1)); + } + + @Test + void testDeletionFileEqualsAndHashCodeIncludeBaseId() { + for (Integer baseId : new Integer[] {null, 1}) { + assertEquals(deletionFile(baseId), deletionFile(baseId)); + assertEquals(deletionFile(baseId).hashCode(), deletionFile(baseId).hashCode()); + } + assertNotEquals(deletionFile(1), deletionFile(2)); + assertNotEquals(deletionFile(null), deletionFile(1)); + } + + @Test + void testEqualFragmentMetadataWorksInHashSet() { + FragmentMetadata a = + new FragmentMetadata( + 0, Collections.singletonList(dataFile(null)), 100L, deletionFile(null), null); + FragmentMetadata b = + new FragmentMetadata( + 0, Collections.singletonList(dataFile(null)), 100L, deletionFile(null), null); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + Set set = new HashSet<>(Collections.singletonList(a)); + assertTrue(set.contains(b)); + } +} diff --git a/java/src/test/java/org/lance/fragment/FragmentUpdateResultTest.java b/java/src/test/java/org/lance/fragment/FragmentUpdateResultTest.java new file mode 100644 index 00000000000..1161f18b415 --- /dev/null +++ b/java/src/test/java/org/lance/fragment/FragmentUpdateResultTest.java @@ -0,0 +1,106 @@ +/* + * 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.fragment; + +import org.lance.CommitBuilder; +import org.lance.Dataset; +import org.lance.Fragment; +import org.lance.FragmentMetadata; +import org.lance.TestUtils; +import org.lance.Transaction; +import org.lance.operation.Append; + +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.Collections; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class FragmentUpdateResultTest { + + /** Portable RoaringBitmap bytes for offsets {1, 3, 5} (see UpdateTest round-trip fixture). */ + private static final byte[] PORTABLE_ROARING_BYTES_135 = + new byte[] { + (byte) 0x3A, + (byte) 0x30, + (byte) 0x00, + (byte) 0x00, + (byte) 0x01, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, + (byte) 0x02, + (byte) 0x00, + (byte) 0x10, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, + (byte) 0x01, + (byte) 0x00, + (byte) 0x03, + (byte) 0x00, + (byte) 0x05, + (byte) 0x00 + }; + + @Test + void testGetUpdatedRowOffsetBytesRoundTripViaDeprecatedGetter() { + FragmentUpdateResult result = + FragmentUpdateResult.create(null, new long[0], PORTABLE_ROARING_BYTES_135); + assertArrayEquals(PORTABLE_ROARING_BYTES_135, result.getUpdatedRowOffsetBytes()); + assertArrayEquals(new long[] {1, 3, 5}, result.getUpdatedRowOffsets()); + } + + @Test + void testDeprecatedLongArrayConstructorEncodesToBytes() { + FragmentUpdateResult result = new FragmentUpdateResult(null, new long[0], new long[] {1, 3, 5}); + assertArrayEquals(new long[] {1, 3, 5}, result.getUpdatedRowOffsets()); + + // Stored bytes from the deprecated long[] constructor decode to the same offsets. + FragmentUpdateResult fromEncodedBytes = + FragmentUpdateResult.create(null, new long[0], result.getUpdatedRowOffsetBytes()); + assertArrayEquals(new long[] {1, 3, 5}, fromEncodedBytes.getUpdatedRowOffsets()); + } + + @Test + void testUpdateColumnsReturnsMatchedRowOffsetBytes(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("testUpdateColumnsRowOffsetBytes").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.UpdateColumnTestDataset testDataset = + new TestUtils.UpdateColumnTestDataset(allocator, datasetPath); + try (Dataset dataset = testDataset.createEmptyDataset()) { + FragmentMetadata fragmentMeta = testDataset.createNewFragment(6); + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + try (Dataset appended = new CommitBuilder(dataset).execute(appendTxn)) { + Fragment fragment = appended.getFragments().get(0); + FragmentUpdateResult updateResult = testDataset.updateColumn(fragment, 4); + assertTrue(updateResult.getUpdatedRowOffsetBytes().length > 0); + assertArrayEquals(new long[] {0, 1, 2, 3}, updateResult.getUpdatedRowOffsets()); + } + } + } + } + } +} diff --git a/java/src/test/java/org/lance/fragment/RowIdMetaTest.java b/java/src/test/java/org/lance/fragment/RowIdMetaTest.java new file mode 100644 index 00000000000..ddedd706341 --- /dev/null +++ b/java/src/test/java/org/lance/fragment/RowIdMetaTest.java @@ -0,0 +1,91 @@ +/* + * 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.fragment; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +class RowIdMetaTest { + + @Test + void testFromRowIdsSingleRow() { + RowIdMeta meta = RowIdMeta.fromRowIds(new long[] {42}); + String json = meta.getMetadata(); + assertTrue(json.startsWith("{\"Inline\":[")); + assertTrue(json.endsWith("]}")); + } + + @Test + void testFromRowIdsMultipleRows() { + RowIdMeta meta = RowIdMeta.fromRowIds(new long[] {0, 1, 2, 100, Long.MAX_VALUE}); + assertNotNull(meta); + String json = meta.getMetadata(); + assertFalse(json.isEmpty()); + assertTrue(json.startsWith("{\"Inline\":[")); + assertTrue(json.endsWith("]}")); + } + + @Test + void testFromRowIdsEmpty() { + RowIdMeta meta = RowIdMeta.fromRowIds(new long[] {}); + String json = meta.getMetadata(); + assertTrue(json.startsWith("{\"Inline\":[")); + assertTrue(json.endsWith("]}")); + } + + @Test + void testFromRowIdsRoundTrip() { + long[] ids = {10, 20, 30}; + RowIdMeta first = RowIdMeta.fromRowIds(ids); + RowIdMeta second = RowIdMeta.fromRowIds(ids); + assertEquals(first, second); + } + + @Test + void testFromRowIdsDeterministic() { + long[] ids = {10, 20, 30}; + String a = RowIdMeta.fromRowIds(ids).getMetadata(); + String b = RowIdMeta.fromRowIds(ids).getMetadata(); + assertEquals(a, b); + } + + @Test + void testEquals() { + RowIdMeta a = new RowIdMeta("test"); + RowIdMeta b = new RowIdMeta("test"); + RowIdMeta c = new RowIdMeta("other"); + + assertEquals(a, b); + assertNotEquals(a, c); + assertNotEquals(a, null); + assertNotEquals(a, "test"); + assertEquals(a, a); + } + + @Test + void testHashCodeConsistency() { + RowIdMeta a = new RowIdMeta("test"); + RowIdMeta b = new RowIdMeta("test"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testToString() { + RowIdMeta meta = new RowIdMeta("someMetadata"); + String str = meta.toString(); + assertTrue(str.contains("RowIdMeta")); + assertTrue(str.contains("someMetadata")); + } +} diff --git a/java/src/test/java/org/lance/fragment/VersionMetaTest.java b/java/src/test/java/org/lance/fragment/VersionMetaTest.java new file mode 100644 index 00000000000..038d24980d2 --- /dev/null +++ b/java/src/test/java/org/lance/fragment/VersionMetaTest.java @@ -0,0 +1,80 @@ +/* + * 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.fragment; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class VersionMetaTest { + + @Test + void testConstructorAndGetter() { + VersionMeta meta = new VersionMeta("test"); + assertEquals("test", meta.getMetadata()); + } + + @Test + void testNullMetadata() { + VersionMeta meta = new VersionMeta(null); + assertNull(meta.getMetadata()); + } + + @Test + void testEquals() { + VersionMeta a = new VersionMeta("x"); + VersionMeta b = new VersionMeta("x"); + assertEquals(a, b); + assertEquals(b, a); + + VersionMeta c = new VersionMeta("y"); + assertNotEquals(a, c); + + assertFalse(a.equals(null)); + + assertFalse(a.equals("x")); + } + + @Test + void testHashCode() { + VersionMeta a = new VersionMeta("same"); + VersionMeta b = new VersionMeta("same"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testHashCodeWithNull() { + VersionMeta meta = new VersionMeta(null); + meta.hashCode(); + } + + @Test + void testToString() { + VersionMeta meta = new VersionMeta("hello"); + String s = meta.toString(); + assertTrue(s.contains("VersionMeta")); + assertTrue(s.contains("hello")); + } + + @Test + void testJsonMetadataPreservation() { + String json = "{\"Inline\":[10,20,30]}"; + VersionMeta meta = new VersionMeta(json); + assertEquals(json, meta.getMetadata()); + } +} diff --git a/java/src/test/java/org/lance/index/ScalarIndexTest.java b/java/src/test/java/org/lance/index/ScalarIndexTest.java index adb5fa83468..35feb1b031d 100644 --- a/java/src/test/java/org/lance/index/ScalarIndexTest.java +++ b/java/src/test/java/org/lance/index/ScalarIndexTest.java @@ -15,54 +15,668 @@ import org.lance.Dataset; import org.lance.Fragment; +import org.lance.LockManager; import org.lance.TestUtils; -import org.lance.Transaction; import org.lance.WriteParams; import org.lance.index.scalar.ScalarIndexParams; import org.lance.ipc.LanceScanner; import org.lance.ipc.ScanOptions; -import org.lance.operation.CreateIndex; import org.apache.arrow.c.ArrowArrayStream; import org.apache.arrow.c.Data; import org.apache.arrow.memory.BufferAllocator; import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.Float8Vector; import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.UInt8Vector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.complex.StructVector; import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.ipc.ArrowStreamReader; import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.FloatingPointPrecision; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; public class ScalarIndexTest { - @TempDir Path tempDir; + private static final class RecordingIndexBuildProgress implements IndexBuildProgress { + private final List events = Collections.synchronizedList(new ArrayList<>()); + + @Override + public void stageStart(String stage, Optional total, String unit) { + events.add( + "start:" + stage + ":" + total.map(String::valueOf).orElse("unknown") + ":" + unit); + } + + @Override + public void stageProgress(String stage, long completed) { + events.add("progress:" + stage + ":" + completed); + } + + @Override + public void stageComplete(String stage) { + events.add("complete:" + stage); + } + + private List snapshot() { + synchronized (events) { + return new ArrayList<>(events); + } + } + } + + private static final class FailingProgressIndexBuildProgress implements IndexBuildProgress { + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + throw new IllegalStateException("progress callback failure"); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + } + } + + private static final class FailingCompleteIndexBuildProgress implements IndexBuildProgress { + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + throw new IllegalStateException("complete callback failure"); + } + } + + /** + * Progress callback that re-enters the same Dataset via JNI. Without releasing the native field + * lock before an index operation starts, these calls would deadlock. + */ + private static final class ReentrantDatasetIndexBuildProgress implements IndexBuildProgress { + private final Dataset dataset; + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + private final AtomicInteger reentries = new AtomicInteger(); + + private ReentrantDatasetIndexBuildProgress(Dataset dataset) { + this.dataset = dataset; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + touchDataset(); + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + touchDataset(); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + touchDataset(); + } + + private void touchDataset() { + assertNotNull(dataset.uri()); + assertTrue(dataset.version() > 0); + assertTrue(dataset.latestVersion() > 0); + assertTrue(dataset.countRows() > 0); + assertFalse(dataset.getFragments().isEmpty()); + assertFalse(dataset.memWalIndexDetails().isPresent()); + reentries.incrementAndGet(); + } + } + + private static final class WriteReentrantIndexBuildProgress implements IndexBuildProgress { + private final Dataset dataset; + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + private final AtomicInteger reentries = new AtomicInteger(); + private final AtomicReference writeFailure = new AtomicReference<>(); + + private WriteReentrantIndexBuildProgress(Dataset dataset) { + this.dataset = dataset; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + if (reentries.getAndIncrement() == 0) { + try { + dataset.checkoutLatest(); + } catch (RuntimeException failure) { + writeFailure.set(failure); + } + } + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + } + } + + private enum DeprecatedConfigMutation { + UPDATE, + DELETE + } + + private static final class ConfigWriteReentrantIndexBuildProgress implements IndexBuildProgress { + private static final String CONFIG_KEY = "callback_config_key"; + + private final Dataset dataset; + private final DeprecatedConfigMutation mutation; + private final AtomicBoolean attempted = new AtomicBoolean(); + private final AtomicReference writeFailure = new AtomicReference<>(); + + private ConfigWriteReentrantIndexBuildProgress( + Dataset dataset, DeprecatedConfigMutation mutation) { + this.dataset = dataset; + this.mutation = mutation; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (attempted.compareAndSet(false, true)) { + try { + if (mutation == DeprecatedConfigMutation.UPDATE) { + dataset.updateConfig(Map.of(CONFIG_KEY, "updated")); + } else { + dataset.deleteConfigKeys(Set.of(CONFIG_KEY)); + } + } catch (RuntimeException failure) { + writeFailure.set(failure); + } + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class NestedCreateIndexBuildProgress implements IndexBuildProgress { + private final Dataset dataset; + private final IndexOptions segmentOptions; + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + private final AtomicReference nestedFailure = new AtomicReference<>(); + private boolean attempted; + + private NestedCreateIndexBuildProgress(Dataset dataset, IndexOptions segmentOptions) { + this.dataset = dataset; + this.segmentOptions = segmentOptions; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + if (!attempted) { + attempted = true; + try { + dataset.createIndex(segmentOptions); + } catch (RuntimeException failure) { + nestedFailure.set(failure); + } + } + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + } + } + + private static final class BlockingIndexBuildProgress implements IndexBuildProgress { + private final CountDownLatch started = new CountDownLatch(1); + private final CountDownLatch release = new CountDownLatch(1); + private final RecordingIndexBuildProgress recorder = new RecordingIndexBuildProgress(); + + @Override + public void stageStart(String stage, Optional total, String unit) { + recorder.stageStart(stage, total, unit); + started.countDown(); + try { + assertTrue(release.await(5, TimeUnit.SECONDS), "Callback release latch timed out"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while blocking progress callback", e); + } + } + + @Override + public void stageProgress(String stage, long completed) { + recorder.stageProgress(stage, completed); + } + + @Override + public void stageComplete(String stage) { + recorder.stageComplete(stage); + } + } + + private static final class QueuedWriterProgress implements IndexBuildProgress { + private final Dataset dataset; + private final AtomicReference writer = new AtomicReference<>(); + private final AtomicReference writerFailure = new AtomicReference<>(); + private final AtomicBoolean readCompleted = new AtomicBoolean(); + private boolean attempted; + + private QueuedWriterProgress(Dataset dataset) { + this.dataset = dataset; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (!attempted) { + attempted = true; + Thread queuedWriter = + new Thread( + () -> { + try { + dataset.checkoutLatest(); + } catch (RuntimeException failure) { + writerFailure.set(failure); + } + }); + writer.set(queuedWriter); + queuedWriter.start(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for queued writer", e); + } + assertTrue(queuedWriter.isAlive(), "Expected writer to queue behind outer create"); + assertTrue(dataset.version() > 0); + assertTrue(dataset.countRows() > 0); + readCompleted.set(true); + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetNestedProgress implements IndexBuildProgress { + private final Dataset outerDataset; + private final Dataset nestedDataset; + private final AtomicBoolean readCompleted = new AtomicBoolean(); + + private CrossDatasetNestedProgress(Dataset outerDataset, Dataset nestedDataset) { + this.outerDataset = outerDataset; + this.nestedDataset = nestedDataset; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + // Intentionally empty: the cross-worker handoff is covered in stageProgress. + } + + @Override + public void stageProgress(String stage, long completed) { + // stageProgress may resume on a different Tokio worker than stageStart. Reading the still- + // active outer Dataset here requires inherited callback context propagation. + assertTrue(outerDataset.version() > 0); + assertTrue(nestedDataset.version() > 0); + readCompleted.set(true); + } + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetOuterProgress implements IndexBuildProgress { + private final Dataset outerDataset; + private final Dataset nestedDataset; + private final IndexOptions nestedOptions; + private final CrossDatasetNestedProgress nestedProgress; + private final AtomicReference writer = new AtomicReference<>(); + private final AtomicReference writerFailure = new AtomicReference<>(); + private final AtomicBoolean nestedCompleted = new AtomicBoolean(); + private boolean attempted; + + private CrossDatasetOuterProgress( + Dataset outerDataset, + Dataset nestedDataset, + IndexOptions nestedOptions, + CrossDatasetNestedProgress nestedProgress) { + this.outerDataset = outerDataset; + this.nestedDataset = nestedDataset; + this.nestedOptions = nestedOptions; + this.nestedProgress = nestedProgress; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (!attempted) { + attempted = true; + Thread queuedWriter = + new Thread( + () -> { + try { + outerDataset.checkoutLatest(); + } catch (RuntimeException failure) { + writerFailure.set(failure); + } + }); + writer.set(queuedWriter); + queuedWriter.start(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for queued writer", e); + } + assertTrue(queuedWriter.isAlive(), "Expected writer to queue behind outer create"); + nestedDataset.createIndex(nestedOptions, nestedProgress); + nestedCompleted.set(true); + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetLockCycleProgress implements IndexBuildProgress { + private final Dataset targetDataset; + private final IndexOptions targetOptions; + private final CountDownLatch callbacksReady; + private final CountDownLatch attemptsFinished; + private final AtomicBoolean attempted = new AtomicBoolean(); + private final AtomicReference nestedFailure = new AtomicReference<>(); + + private CrossDatasetLockCycleProgress( + Dataset targetDataset, + IndexOptions targetOptions, + CountDownLatch callbacksReady, + CountDownLatch attemptsFinished) { + this.targetDataset = targetDataset; + this.targetOptions = targetOptions; + this.callbacksReady = callbacksReady; + this.attemptsFinished = attemptsFinished; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (attempted.compareAndSet(false, true)) { + callbacksReady.countDown(); + awaitLatch(callbacksReady, "Cross-Dataset callbacks did not become ready"); + try { + targetDataset.createIndex(targetOptions); + } catch (RuntimeException failure) { + nestedFailure.set(failure); + } finally { + attemptsFinished.countDown(); + awaitLatch(attemptsFinished, "Cross-Dataset build attempts did not finish"); + } + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetWriteCycleProgress implements IndexBuildProgress { + private final Dataset targetDataset; + private final CountDownLatch callbacksReady; + private final CountDownLatch attemptsFinished; + private final AtomicBoolean attempted = new AtomicBoolean(); + private final AtomicReference writeFailure = new AtomicReference<>(); + + private CrossDatasetWriteCycleProgress( + Dataset targetDataset, CountDownLatch callbacksReady, CountDownLatch attemptsFinished) { + this.targetDataset = targetDataset; + this.callbacksReady = callbacksReady; + this.attemptsFinished = attemptsFinished; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (attempted.compareAndSet(false, true)) { + callbacksReady.countDown(); + awaitLatch(callbacksReady, "Cross-Dataset write callbacks did not become ready"); + try { + targetDataset.checkoutLatest(); + } catch (RuntimeException failure) { + writeFailure.set(failure); + } finally { + attemptsFinished.countDown(); + awaitLatch(attemptsFinished, "Cross-Dataset write attempts did not finish"); + } + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetReadWithQueuedCloseProgress implements IndexBuildProgress { + private final Dataset outerDataset; + private final Dataset targetDataset; + private final CountDownLatch callbacksReady; + private final CountDownLatch attemptsFinished; + private final AtomicBoolean attempted = new AtomicBoolean(); + private final AtomicReference closeThread = new AtomicReference<>(); + private final AtomicReference closeFailure = new AtomicReference<>(); + private final AtomicReference readFailure = new AtomicReference<>(); + private final AtomicBoolean readCompleted = new AtomicBoolean(); + + private CrossDatasetReadWithQueuedCloseProgress( + Dataset outerDataset, + Dataset targetDataset, + CountDownLatch callbacksReady, + CountDownLatch attemptsFinished) { + this.outerDataset = outerDataset; + this.targetDataset = targetDataset; + this.callbacksReady = callbacksReady; + this.attemptsFinished = attemptsFinished; + } + + @Override + public void stageStart(String stage, Optional total, String unit) { + if (attempted.compareAndSet(false, true)) { + Thread queuedClose = + new Thread( + () -> { + try { + outerDataset.close(); + } catch (RuntimeException failure) { + closeFailure.set(failure); + } + }, + "cross-dataset-queued-close"); + queuedClose.setDaemon(true); + closeThread.set(queuedClose); + queuedClose.start(); + try { + awaitThreadBlocked(queuedClose, "Close did not queue behind the outer index build"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("Interrupted while waiting for queued close", e); + } + + callbacksReady.countDown(); + awaitLatch(callbacksReady, "Cross-Dataset read callbacks did not become ready"); + try { + assertTrue(targetDataset.version() > 0); + readCompleted.set(true); + } catch (RuntimeException failure) { + readFailure.set(failure); + } finally { + attemptsFinished.countDown(); + awaitLatch(attemptsFinished, "Cross-Dataset read attempts did not finish"); + } + } + } + + @Override + public void stageProgress(String stage, long completed) {} + + @Override + public void stageComplete(String stage) {} + } + + private static final class CrossDatasetBuildPair { + private final RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + private final Dataset datasetA; + private final Dataset datasetB; + private final IndexOptions optionsA; + private final IndexOptions optionsB; + private final AtomicReference resultA = new AtomicReference<>(); + private final AtomicReference resultB = new AtomicReference<>(); + private final AtomicReference failureA = new AtomicReference<>(); + private final AtomicReference failureB = new AtomicReference<>(); + private Thread createA; + private Thread createB; + + private CrossDatasetBuildPair(Path tempDir, String pathPrefix) throws Exception { + TestUtils.SimpleTestDataset testDatasetA = + new TestUtils.SimpleTestDataset(allocator, tempDir.resolve(pathPrefix + "_a").toString()); + TestUtils.SimpleTestDataset testDatasetB = + new TestUtils.SimpleTestDataset(allocator, tempDir.resolve(pathPrefix + "_b").toString()); + testDatasetA.createEmptyDataset().close(); + testDatasetB.createEmptyDataset().close(); + datasetA = testDatasetA.write(1, 20); + datasetB = testDatasetB.write(1, 20); + optionsA = createInvertedSegmentOptions(datasetA.getFragments().get(0).getId()); + optionsB = createInvertedSegmentOptions(datasetB.getFragments().get(0).getId()); + } + + private void runCreates(IndexBuildProgress progressA, IndexBuildProgress progressB) + throws InterruptedException { + createA = + new Thread( + () -> { + try { + resultA.set(datasetA.createIndex(optionsA, progressA)); + } catch (Throwable failure) { + failureA.set(failure); + } + }, + "cross-dataset-create-a"); + createB = + new Thread( + () -> { + try { + resultB.set(datasetB.createIndex(optionsB, progressB)); + } catch (Throwable failure) { + failureB.set(failure); + } + }, + "cross-dataset-create-b"); + createA.setDaemon(true); + createB.setDaemon(true); + createA.start(); + createB.start(); + + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(5); + joinUntil(createA, deadlineNanos); + joinUntil(createB, deadlineNanos); + + assertFalse(createA.isAlive(), "Dataset A create deadlocked"); + assertFalse(createB.isAlive(), "Dataset B create deadlocked"); + assertNull(failureA.get(), "Dataset A outer create failed"); + assertNull(failureB.get(), "Dataset B outer create failed"); + assertNotNull(resultA.get(), "Dataset A outer create did not return an index"); + assertNotNull(resultB.get(), "Dataset B outer create did not return an index"); + } + + private void closeIfWorkersStopped(Thread... additionalWorkers) { + boolean workersStopped = + (createA == null || !createA.isAlive()) && (createB == null || !createB.isAlive()); + for (Thread worker : additionalWorkers) { + workersStopped &= worker == null || !worker.isAlive(); + } + if (workersStopped) { + datasetA.close(); + datasetB.close(); + allocator.close(); + } + } + } @Test - public void testCreateBTreeIndex() throws Exception { + public void testCreateBTreeIndex(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("btree_test").toString(); Schema schema = new Schema( @@ -108,7 +722,7 @@ public void testCreateBTreeIndex() throws Exception { } @Test - public void testCreateBTreeIndexDistributively() throws Exception { + public void testCreateBTreeIndexDistributively(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("build_index_distributedly").toString(); try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { TestUtils.SimpleTestDataset testDataset = @@ -118,171 +732,396 @@ public void testCreateBTreeIndexDistributively() throws Exception { testDataset.write(1, 10).close(); try (Dataset dataset = testDataset.write(2, 10)) { List fragments = dataset.getFragments(); - assertEquals(2, dataset.getFragments().size()); + assertEquals(2, fragments.size()); ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{\"zone_size\": 2048}"); IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); - UUID uuid = UUID.randomUUID(); + String indexName = "test_index"; - // 2. partially create index - dataset.createIndex( - IndexOptions.builder(Collections.singletonList("name"), IndexType.BTREE, indexParams) - .withIndexName("test_index") - .withIndexUUID(uuid.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) - .build()); - dataset.createIndex( - IndexOptions.builder(Collections.singletonList("name"), IndexType.BTREE, indexParams) - .withIndexName("test_index") - .withIndexUUID(uuid.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) - .build()); + List segments = new ArrayList<>(); + for (Fragment fragment : fragments) { + segments.add( + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("name"), IndexType.BTREE, indexParams) + .withIndexName(indexName) + .withFragmentIds(Collections.singletonList(fragment.getId())) + .build())); + } - // then no index should have been created assertFalse( - dataset.listIndexes().contains("test_index"), + dataset.listIndexes().contains(indexName), "Partially created index should not present"); - // 3. merge metadata, which will still not be committed - dataset.mergeIndexMetadata(uuid.toString(), IndexType.BTREE, Optional.empty()); + List committed = dataset.commitExistingIndexSegments(indexName, "name", segments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(indexName)); + + assertEquals(2, dataset.countIndexedRows(indexName, "name = 'Person 5'", Optional.empty())); + assertEquals( + 10, + dataset.countIndexedRows( + indexName, "name >= 'Person 3' AND name < 'Person 8'", Optional.empty())); + } + } + } - // 4. commit the index - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals("name")) - .findAny() - .orElseThrow(() -> new RuntimeException("Cannot find 'name' field for TestDataset")) - .getId(); + @Test + public void testCountIndexedRowsSelectsPhysicalSegments(@TempDir Path tempDir) throws Exception { + assertPhysicalSegmentSelection(tempDir.resolve("segments"), false); + } - long datasetVersion = dataset.version(); + @Test + public void testCountIndexedRowsStableRowIdsExcludeDeletions(@TempDir Path tempDir) + throws Exception { + assertPhysicalSegmentSelection(tempDir.resolve("stable-segments"), true); + } - Index index = - Index.builder() - .uuid(uuid) - .name("test_index") - .fields(Collections.singletonList(fieldId)) - .datasetVersion(datasetVersion) - .indexVersion(0) - .fragments(fragments.stream().map(Fragment::getId).collect(Collectors.toList())) - .build(); + @Test + public void testCountIndexedRowsDoesNotOpenUnselectedSegment(@TempDir Path tempDir) + throws Exception { + String filter = "name >= 'Person 0'"; + Path datasetPath = tempDir.resolve("unopened"); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + UUID segmentA; + UUID segmentB; + String indexName; + try (NamedSegments segments = createNamedSegments(allocator, datasetPath, false, 10, 4)) { + segmentA = segments.segmentA.uuid(); + segmentB = segments.segmentB.uuid(); + indexName = segments.indexName; + } + // Reopen after deleting B so a session cache cannot satisfy the query. + deleteIndexSegment(datasetPath, segmentB); + try (Dataset dataset = Dataset.open(datasetPath.toString(), allocator)) { + assertEquals( + 10, + dataset.countIndexedRows( + indexName, filter, Collections.singletonList(segmentA), Optional.empty())); + Exception missingSegment = + Assertions.assertThrows( + Exception.class, + () -> + dataset.countIndexedRows( + indexName, filter, Collections.singletonList(segmentB), Optional.empty())); + assertTrue( + missingSegment.getMessage() != null && !missingSegment.getMessage().isEmpty(), + "opening the deleted segment should fail, got: " + missingSegment); + } + } + } - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); + private void assertPhysicalSegmentSelection(Path datasetPath, boolean stableRowIds) + throws Exception { + String filter = "name >= 'Person 0'"; + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + NamedSegments segments = createNamedSegments(allocator, datasetPath, stableRowIds, 10, 4)) { + Dataset dataset = segments.dataset; + List segmentA = Collections.singletonList(segments.segmentA.uuid()); + List segmentB = Collections.singletonList(segments.segmentB.uuid()); + List both = Arrays.asList(segments.segmentB.uuid(), segments.segmentA.uuid()); + int fragmentA = fragmentId(segments.segmentA); + int fragmentB = fragmentId(segments.segmentB); - Transaction createIndexTx = - dataset.newTransactionBuilder().operation(createIndexOp).build(); + assertEquals( + 10, dataset.countIndexedRows(segments.indexName, filter, segmentA, Optional.empty())); + assertEquals( + 4, dataset.countIndexedRows(segments.indexName, filter, segmentB, Optional.empty())); + assertEquals( + 14, dataset.countIndexedRows(segments.indexName, filter, both, Optional.empty())); + assertEquals(14, dataset.countIndexedRows(segments.indexName, filter, Optional.empty())); + assertEquals( + 14, + dataset.countIndexedRows( + segments.indexName, filter, both, Optional.of(Arrays.asList(fragmentB, fragmentA)))); + assertEquals( + 10, + dataset.countIndexedRows( + segments.indexName, filter, Optional.of(Collections.singletonList(fragmentA)))); - try (Dataset newDataset = createIndexTx.commit()) { - // new dataset should contain that index - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains("test_index")); - } + Exception unindexedFilter = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> dataset.countIndexedRows(segments.indexName, "id = 0", Optional.empty())); + assertTrue( + unindexedFilter.getMessage().contains(segments.indexName), unindexedFilter.getMessage()); + assertTrue( + unindexedFilter.getMessage().contains("cannot be planned"), unindexedFilter.getMessage()); + + Exception coverageMismatch = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + segmentA, + Optional.of(Collections.singletonList(fragmentB)))); + assertTrue( + coverageMismatch.getMessage().contains("do not match selected segment coverage"), + coverageMismatch.getMessage()); + assertTrue( + coverageMismatch.getMessage().contains(Integer.toString(fragmentA)), + coverageMismatch.getMessage()); + assertTrue( + coverageMismatch.getMessage().contains(Integer.toString(fragmentB)), + coverageMismatch.getMessage()); + + Exception unknownFragment = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + segmentA, + Optional.of(Collections.singletonList(99)))); + assertTrue( + unknownFragment.getMessage().contains("unknown fragment IDs"), + unknownFragment.getMessage()); + + Exception duplicateFragments = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + segmentA, + Optional.of(Arrays.asList(fragmentA, fragmentA)))); + assertTrue( + duplicateFragments.getMessage().contains("duplicate fragment IDs"), + duplicateFragments.getMessage()); + + Exception unknownSegment = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + Collections.singletonList(UUID.randomUUID()), + Optional.empty())); + assertTrue( + unknownSegment.getMessage().contains("does not exist"), unknownSegment.getMessage()); + + Index otherIndex = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("id"), IndexType.BTREE, btreeIndexParams()) + .withIndexName("other_index") + .build()); + Exception wrongIndex = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + Collections.singletonList(otherIndex.uuid()), + Optional.empty())); + assertTrue(wrongIndex.getMessage().contains("other_index"), wrongIndex.getMessage()); + assertTrue(wrongIndex.getMessage().contains(segments.indexName), wrongIndex.getMessage()); + + Exception duplicateSegments = + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, + filter, + Arrays.asList(segments.segmentA.uuid(), segments.segmentA.uuid()), + Optional.empty())); + assertTrue( + duplicateSegments.getMessage().contains("duplicate UUIDs"), + duplicateSegments.getMessage()); + Assertions.assertThrows( + IllegalArgumentException.class, + () -> + dataset.countIndexedRows( + segments.indexName, filter, Collections.emptyList(), Optional.empty())); + + dataset.delete("name = 'Person 0'"); + assertEquals( + 9, dataset.countIndexedRows(segments.indexName, filter, segmentA, Optional.empty())); + assertEquals( + 3, dataset.countIndexedRows(segments.indexName, filter, segmentB, Optional.empty())); + assertEquals( + 12, dataset.countIndexedRows(segments.indexName, filter, both, Optional.empty())); + assertEquals(12, dataset.countRows(filter)); + } + } + + private NamedSegments createNamedSegments( + BufferAllocator allocator, Path datasetPath, boolean stableRowIds, int rowsA, int rowsB) + throws Exception { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath.toString()); + WriteParams.Builder createParams = new WriteParams.Builder(); + if (stableRowIds) { + createParams.withEnableStableRowIds(true); + } + testDataset.createDatasetWithWriteParams(createParams.build()).close(); + testDataset.write(1, rowsA).close(); + Dataset dataset = testDataset.write(2, rowsB); + List fragments = dataset.getFragments(); + assertEquals(2, fragments.size()); + Fragment fragmentA = null; + Fragment fragmentB = null; + for (Fragment fragment : fragments) { + long physicalRows = fragment.metadata().getPhysicalRows(); + if (physicalRows == rowsA) { + fragmentA = fragment; + } else if (physicalRows == rowsB) { + fragmentB = fragment; } } + assertNotNull(fragmentA); + assertNotNull(fragmentB); + + String indexName = "name_idx"; + List committed = + commitBtreeSegments(dataset, indexName, "name", Arrays.asList(fragmentA, fragmentB)); + return new NamedSegments( + dataset, + indexName, + indexCovering(committed, fragmentA.getId()), + indexCovering(committed, fragmentB.getId())); + } + + private static IndexParams btreeIndexParams() { + return IndexParams.builder() + .setScalarIndexParams(ScalarIndexParams.create("btree", "{\"zone_size\": 2048}")) + .build(); + } + + private static List commitBtreeSegments( + Dataset dataset, String indexName, String column, List fragments) { + List segments = new ArrayList<>(); + for (Fragment fragment : fragments) { + segments.add( + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList(column), IndexType.BTREE, btreeIndexParams()) + .withIndexName(indexName) + .withFragmentIds(Collections.singletonList(fragment.getId())) + .build())); + } + List committed = dataset.commitExistingIndexSegments(indexName, column, segments); + assertEquals(fragments.size(), committed.size()); + return committed; + } + + private static Index indexCovering(List indexes, int fragmentId) { + for (Index index : indexes) { + if (index.fragments().orElse(Collections.emptyList()).contains(fragmentId)) { + return index; + } + } + throw new AssertionError("no committed segment covers fragment " + fragmentId); + } + + private static int fragmentId(Index segment) { + List fragments = + segment + .fragments() + .orElseThrow( + () -> + new AssertionError("segment " + segment.uuid() + " has no fragment coverage")); + assertEquals(1, fragments.size(), "expected one fragment in " + segment.uuid()); + return fragments.get(0); + } + + private static void deleteIndexSegment(Path datasetPath, UUID uuid) throws IOException { + Path indexDir = datasetPath.resolve("_indices").resolve(uuid.toString()); + assertTrue(Files.isDirectory(indexDir), "expected index directory " + indexDir); + try (Stream walk = Files.walk(indexDir)) { + walk.sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.delete(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + private static final class NamedSegments implements AutoCloseable { + private final Dataset dataset; + private final String indexName; + private final Index segmentA; + private final Index segmentB; + + private NamedSegments(Dataset dataset, String indexName, Index segmentA, Index segmentB) { + this.dataset = dataset; + this.indexName = indexName; + this.segmentA = segmentA; + this.segmentB = segmentB; + } + + @Override + public void close() { + dataset.close(); + } } @Test - public void testRangedBTreeIndex() throws Exception { + public void testRangedBTreeIndex(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("ranged_btree_map").toString(); - UUID indexUUID = UUID.randomUUID(); try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { TestUtils.SimpleTestDataset testDataset = new TestUtils.SimpleTestDataset(allocator, datasetPath); testDataset.createEmptyDataset().close(); - // 1. write some data - try (Dataset dataset = testDataset.write(1, 200)) { - - // 2. scan data out - List data = new ArrayList<>(); - try (LanceScanner scanner = - dataset.newScan( - new ScanOptions.Builder() - .withRowId(true) - .columns(Collections.singletonList("id")) - .build()); - ArrowReader arrowReader = scanner.scanBatches(); ) { - while (arrowReader.loadNextBatch()) { - VectorSchemaRoot root = arrowReader.getVectorSchemaRoot(); - UInt8Vector rowIdVec = (UInt8Vector) root.getVector("_rowid"); - IntVector idVec = (IntVector) root.getVector("id"); - for (int i = 0; i < root.getRowCount(); i++) { - data.add(new long[] {idVec.get(i), rowIdVec.get(i)}); - } - } - } - - // 3. sort data globally (This will be done by computing engines in production) - data.sort((d1, d2) -> (int) (d1[0] - d2[0])); - int mid = data.size() / 2; - - // 4. divide sorted data into ranges and build index for each range - createBtreeIndexForRange(dataset, data.subList(0, mid), 1, allocator, indexUUID); - createBtreeIndexForRange(dataset, data.subList(mid, data.size()), 2, allocator, indexUUID); - - // 5. merge index. - dataset.mergeIndexMetadata(indexUUID.toString(), IndexType.BTREE, Optional.empty()); - - // 6. commit index - long datasetVersion = dataset.version(); - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals("id")) - .findAny() - .orElseThrow(() -> new RuntimeException("Cannot find 'id' field for TestDataset")) - .getId(); - Index index = - Index.builder() - .uuid(indexUUID) - .name("test_index") - .fields(Collections.singletonList(fieldId)) - .datasetVersion(datasetVersion) - .indexVersion(0) - .fragments( - dataset.getFragments().stream() - .map(Fragment::getId) - .collect(Collectors.toList())) - .build(); - - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - Transaction createIndexTx = - dataset.newTransactionBuilder().operation(createIndexOp).build(); - - try (Dataset newDataset = createIndexTx.commit()) { - // new dataset should contain that index - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains("test_index")); + testDataset.write(1, 100).close(); + try (Dataset dataset = testDataset.write(2, 100)) { + List fragments = dataset.getFragments(); + assertEquals(2, fragments.size()); - // 7. compare results - // force use index should get the right value - ScanOptions scanOptions = - new ScanOptions.Builder().withRowId(true).filter("id in (10, 20, 30)").build(); - try (LanceScanner scanner = newDataset.newScan(scanOptions); + List segments = new ArrayList<>(); + for (Fragment fragment : fragments) { + List data = new ArrayList<>(); + try (LanceScanner scanner = + dataset.newScan( + new ScanOptions.Builder() + .fragmentIds(Collections.singletonList(fragment.getId())) + .withRowId(true) + .columns(Collections.singletonList("id")) + .build()); ArrowReader arrowReader = scanner.scanBatches(); ) { - List ids = new ArrayList<>(); while (arrowReader.loadNextBatch()) { VectorSchemaRoot root = arrowReader.getVectorSchemaRoot(); + UInt8Vector rowIdVec = (UInt8Vector) root.getVector("_rowid"); IntVector idVec = (IntVector) root.getVector("id"); - for (int i = 0; i < idVec.getValueCount(); i++) { - ids.add(idVec.get(i)); + for (int i = 0; i < root.getRowCount(); i++) { + data.add(new long[] {idVec.get(i), rowIdVec.get(i)}); } } - Collections.sort(ids); - Assertions.assertIterableEquals(Arrays.asList(10, 20, 30), ids); } + + data.sort((d1, d2) -> Long.compare(d1[0], d2[0])); + segments.add(createBtreeIndexFromPreprocessedData(dataset, data, fragment, allocator)); } + + String indexName = "test_index"; + List committed = dataset.commitExistingIndexSegments(indexName, "id", segments); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(indexName)); + + assertEquals( + 6, dataset.countIndexedRows(indexName, "id in (10, 20, 30)", Optional.empty())); + assertEquals( + 20, dataset.countIndexedRows(indexName, "id >= 50 AND id < 60", Optional.empty())); } } } - private void createBtreeIndexForRange( + private Index createBtreeIndexFromPreprocessedData( Dataset dataset, List preprocessedData, - int rangeId, - BufferAllocator allocator, - UUID indexUUID) { - // Note that the indexing column is called 'value' in btree. + Fragment fragment, + BufferAllocator allocator) { Schema schema = new Schema( Arrays.asList( @@ -295,7 +1134,7 @@ private void createBtreeIndexForRange( UInt8Vector rowIdVec = (UInt8Vector) root.getVector("_rowid"); for (int i = 0; i < preprocessedData.size(); i++) { long[] dataPair = preprocessedData.get(i); - idVec.set(i, (int) dataPair[0]); + idVec.setSafe(i, (int) dataPair[0]); rowIdVec.setSafe(i, dataPair[1]); } root.setRowCount(preprocessedData.size()); @@ -316,12 +1155,12 @@ private void createBtreeIndexForRange( ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { Data.exportArrayStream(allocator, reader, stream); - ScalarIndexParams scalarParams = - ScalarIndexParams.create("btree", String.format("{\"range_id\": %s}", rangeId)); + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{\"zone_size\": 64}"); IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); - dataset.createIndex( + return dataset.createIndex( IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) - .withIndexUUID(indexUUID.toString()) + .withIndexName("test_index") + .withFragmentIds(Collections.singletonList(fragment.getId())) .withPreprocessedData(stream) .build()); } catch (Exception e) { @@ -331,20 +1170,972 @@ private void createBtreeIndexForRange( } @Test - public void testCreateZonemapIndex() throws Exception { - String datasetPath = tempDir.resolve("zonemap_test").toString(); - Schema schema = - new Schema( - Arrays.asList( - Field.nullable("id", new ArrowType.Int(32, true)), - Field.nullable("value", new ArrowType.Utf8())), - null); + public void testBtreeMergeIndexMetadataSoftBreak(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("btree_merge_metadata_soft_break").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + Exception ex = + Assertions.assertThrows( + Exception.class, + () -> + dataset.mergeIndexMetadata( + UUID.randomUUID().toString(), IndexType.BTREE, Optional.empty())); + assertTrue( + ex.getMessage() != null + && ex.getMessage().contains("no longer supports merge_index_metadata"), + "expected BTree merge_index_metadata soft-break error, got: " + ex.getMessage()); + } + } + } - try (BufferAllocator allocator = new RootAllocator()) { - try (Dataset dataset = - Dataset.create(allocator, datasetPath, schema, new WriteParams.Builder().build())) { + @Test + public void testCreateBTreeIndexReportsProgress(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("btree_create_progress").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + ScalarIndexParams scalarParams = ScalarIndexParams.create("btree", "{\"zone_size\": 2048}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); + RecordingIndexBuildProgress progress = new RecordingIndexBuildProgress(); - // Create Zonemap scalar index parameters with rows_per_zone setting + Index index = + dataset.createIndex( + IndexOptions.builder(Collections.singletonList("id"), IndexType.BTREE, indexParams) + .withIndexName("btree_progress_idx") + .replace(true) + .build(), + progress); + + assertEquals("btree_progress_idx", index.name()); + assertTrue(dataset.listIndexes().contains("btree_progress_idx")); + List events = progress.snapshot(); + assertFalse(events.isEmpty(), "Expected BTree create to report progress events"); + assertTrue( + events.stream().anyMatch(event -> event.startsWith("start:")), + "Expected at least one stageStart event, got: " + events); + assertTrue( + events.stream().anyMatch(event -> event.startsWith("complete:")), + "Expected at least one stageComplete event, got: " + events); + } + } + } + + @Test + public void testCreateInvertedIndexReportsProgress(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("inverted_create_progress").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + RecordingIndexBuildProgress progress = new RecordingIndexBuildProgress(); + + Index segment = + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + + assertNotNull(segment); + List events = progress.snapshot(); + assertEventsInOrder( + events, + "start:load_data:unknown:rows", + "complete:load_data", + "start:tokenize_docs:unknown:rows", + "progress:tokenize_docs:", + "complete:tokenize_docs", + "start:copy_partitions:", + "complete:copy_partitions", + "start:write_metadata:", + "progress:write_metadata:", + "complete:write_metadata"); + assertTrue( + events.stream() + .anyMatch(event -> event.matches("start:copy_partitions:[0-9]+:partitions")), + "Expected a known partition total, got: " + events); + assertTrue( + events.stream().anyMatch(event -> event.matches("start:write_metadata:[0-9]+:files")), + "Expected a known file total, got: " + events); + } + } + } + + @Test + public void testCreateInvertedIndexPropagatesProgressFailure(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_progress_failure").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + + RuntimeException failure = + Assertions.assertThrows( + RuntimeException.class, + () -> + dataset.createIndex( + createInvertedSegmentOptions(fragment.getId()), + new FailingProgressIndexBuildProgress())); + + assertFalse( + failure instanceof IllegalArgumentException, + "Progress callback failures should not be reported as invalid input: " + failure); + assertTrue( + causeChainContains(failure, "stageProgress") + && causeChainContains(failure, "java.lang.IllegalStateException") + && causeChainContains(failure, "progress callback failure"), + "Expected callback context and original Java exception details, got: " + failure); + } + } + } + + @Test + public void testCreateInvertedIndexIgnoresCompleteFailure(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_complete_failure").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + FailingCompleteIndexBuildProgress progress = new FailingCompleteIndexBuildProgress(); + + Index segment = + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + + assertNotNull(segment); + assertTrue( + progress.recorder.snapshot().contains("complete:write_metadata"), + "Expected create to continue after stageComplete callback failures"); + } + } + } + + @Test + @Timeout(value = 60, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexAllowsReentrantDatasetAccess(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_reentrant_dataset").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + ReentrantDatasetIndexBuildProgress progress = + new ReentrantDatasetIndexBuildProgress(dataset); + + Index segment = + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + + assertNotNull(segment); + assertTrue( + progress.reentries.get() > 0, + "Expected progress callbacks to re-enter Dataset JNI methods"); + assertTrue( + progress.recorder.snapshot().contains("complete:write_metadata"), + "Expected create to finish after re-entrant Dataset access, got: " + + progress.recorder.snapshot()); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexRejectsWriteReentryPromptly(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_write_reentry").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + WriteReentrantIndexBuildProgress progress = new WriteReentrantIndexBuildProgress(dataset); + + Index segment = + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + + assertNotNull(segment); + RuntimeException failure = progress.writeFailure.get(); + assertNotNull(failure, "Expected write re-entry to be rejected"); + assertTrue( + failure.getMessage().contains("busy in an index progress callback"), + "Unexpected write re-entry failure: " + failure.getMessage()); + assertTrue(progress.reentries.get() > 0, "Expected callback to attempt a write lock"); + } + } + } + + @ParameterizedTest + @EnumSource(DeprecatedConfigMutation.class) + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexRejectsConfigWriteBeforeCommit( + DeprecatedConfigMutation mutation, @TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("inverted_create_config_write_reentry").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + dataset.updateConfig(Map.of(ConfigWriteReentrantIndexBuildProgress.CONFIG_KEY, "original")); + long versionBeforeCreate = dataset.latestVersion(); + Fragment fragment = dataset.getFragments().get(0); + ConfigWriteReentrantIndexBuildProgress progress = + new ConfigWriteReentrantIndexBuildProgress(dataset, mutation); + + Index segment = + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + + assertNotNull(segment); + RuntimeException failure = progress.writeFailure.get(); + assertNotNull(failure, "Expected config write re-entry to be rejected"); + assertTrue( + failure.getMessage().contains("busy in an index progress callback"), + "Unexpected config write re-entry failure: " + failure.getMessage()); + assertEquals(versionBeforeCreate, dataset.version()); + assertEquals(versionBeforeCreate, dataset.latestVersion()); + assertEquals( + "original", dataset.getConfig().get(ConfigWriteReentrantIndexBuildProgress.CONFIG_KEY)); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexRejectsNestedCreatePromptly(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_nested_reentry").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + IndexOptions segmentOptions = createInvertedSegmentOptions(fragment.getId()); + NestedCreateIndexBuildProgress progress = + new NestedCreateIndexBuildProgress(dataset, segmentOptions); + + Index segment = dataset.createIndex(segmentOptions, progress); + + assertNotNull(segment); + RuntimeException failure = progress.nestedFailure.get(); + assertNotNull(failure, "Expected nested create to be rejected"); + assertTrue( + failure.getMessage().contains("busy in an index progress callback"), + "Unexpected nested create failure: " + failure.getMessage()); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexAllowsConcurrentCreate(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_concurrent_create").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + IndexOptions segmentOptions = createInvertedSegmentOptions(fragment.getId()); + BlockingIndexBuildProgress progress = new BlockingIndexBuildProgress(); + AtomicReference concurrentResult = new AtomicReference<>(); + AtomicReference outerFailure = new AtomicReference<>(); + + Thread outerCreate = + new Thread( + () -> { + try { + dataset.createIndex(segmentOptions, progress); + } catch (RuntimeException failure) { + outerFailure.set(failure); + } + }); + outerCreate.start(); + assertTrue(progress.started.await(5, TimeUnit.SECONDS), "Progress callback never started"); + + Thread concurrentCreate = + new Thread(() -> concurrentResult.set(dataset.createIndex(segmentOptions))); + concurrentCreate.start(); + concurrentCreate.join(100); + assertTrue(concurrentCreate.isAlive(), "Concurrent create should wait for active create"); + + progress.release.countDown(); + outerCreate.join(5000); + concurrentCreate.join(5000); + assertFalse(outerCreate.isAlive(), "Outer create timed out"); + assertFalse(concurrentCreate.isAlive(), "Concurrent create timed out"); + assertNull(outerFailure.get(), "Outer create failed"); + assertNotNull(concurrentResult.get(), "Concurrent create did not return an index"); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexAllowsConcurrentClose(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("inverted_concurrent_close").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + BlockingIndexBuildProgress progress = new BlockingIndexBuildProgress(); + AtomicReference closeFailure = new AtomicReference<>(); + AtomicReference outerFailure = new AtomicReference<>(); + + Thread outerCreate = + new Thread( + () -> { + try { + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + } catch (RuntimeException failure) { + outerFailure.set(failure); + } + }); + outerCreate.start(); + assertTrue(progress.started.await(5, TimeUnit.SECONDS), "Progress callback never started"); + + Thread concurrentClose = + new Thread( + () -> { + try { + dataset.close(); + } catch (RuntimeException failure) { + closeFailure.set(failure); + } + }); + concurrentClose.start(); + concurrentClose.join(100); + assertTrue(concurrentClose.isAlive(), "Concurrent close should wait for active create"); + + progress.release.countDown(); + outerCreate.join(5000); + concurrentClose.join(5000); + assertFalse(outerCreate.isAlive(), "Outer create timed out"); + assertFalse(concurrentClose.isAlive(), "Concurrent close timed out"); + assertNull(outerFailure.get(), "Outer create failed"); + assertNull(closeFailure.get(), "Concurrent close should not fail"); + } + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + public void testCreateIndexReadOwnerDoesNotDeadlockBehindQueuedClose(@TempDir Path tempDir) + throws Exception { + RootAllocator allocator = new RootAllocator(Long.MAX_VALUE); + Dataset dataset = null; + Thread readOwner = null; + Thread concurrentClose = null; + Thread queuedCreate = null; + CountDownLatch startOwnerCreate = new CountDownLatch(1); + try { + String datasetPath = tempDir.resolve("index_read_owner_queued_close").toString(); + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + dataset = testDataset.write(1, 20); + Dataset activeDataset = dataset; + Fragment fragment = activeDataset.getFragments().get(0); + IndexOptions segmentOptions = createInvertedSegmentOptions(fragment.getId()); + + CountDownLatch outerReadAcquired = new CountDownLatch(1); + AtomicReference ownerResult = new AtomicReference<>(); + AtomicReference ownerFailure = new AtomicReference<>(); + AtomicReference closeFailure = new AtomicReference<>(); + AtomicReference queuedCreateFailure = new AtomicReference<>(); + + readOwner = + new Thread( + () -> { + try (LockManager.ReadLock ignored = activeDataset.acquireReadLock()) { + outerReadAcquired.countDown(); + if (!startOwnerCreate.await(5, TimeUnit.SECONDS)) { + throw new IllegalStateException("Timed out before reentrant create"); + } + ownerResult.set(activeDataset.createIndex(segmentOptions)); + } catch (Throwable failure) { + ownerFailure.set(failure); + } + }, + "index-read-owner"); + readOwner.setDaemon(true); + readOwner.start(); + assertTrue(outerReadAcquired.await(2, TimeUnit.SECONDS), "Outer read lock was not acquired"); + + // Establish read owner -> queued close -> queued create before the read owner re-enters. + concurrentClose = + new Thread( + () -> { + try { + activeDataset.close(); + } catch (Throwable failure) { + closeFailure.set(failure); + } + }, + "queued-dataset-close"); + concurrentClose.setDaemon(true); + concurrentClose.start(); + awaitThreadBlocked(concurrentClose, "Concurrent close did not wait for the outer read lock"); + + queuedCreate = + new Thread( + () -> { + try { + activeDataset.createIndex(segmentOptions); + } catch (Throwable failure) { + queuedCreateFailure.set(failure); + } + }, + "queued-index-create"); + queuedCreate.setDaemon(true); + queuedCreate.start(); + awaitThreadBlocked(queuedCreate, "Concurrent create did not wait behind queued close"); + + startOwnerCreate.countDown(); + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + joinUntil(readOwner, deadlineNanos); + joinUntil(concurrentClose, deadlineNanos); + joinUntil(queuedCreate, deadlineNanos); + + assertFalse(readOwner.isAlive(), "Read owner deadlocked while re-entering createIndex"); + assertFalse(concurrentClose.isAlive(), "Concurrent close deadlocked"); + assertFalse(queuedCreate.isAlive(), "Queued create deadlocked"); + assertNull(ownerFailure.get(), "Read owner's createIndex failed"); + assertNotNull(ownerResult.get(), "Read owner's createIndex did not return an index"); + assertNull(closeFailure.get(), "Concurrent close failed"); + assertTrue( + queuedCreateFailure.get() instanceof IllegalArgumentException, + "Create queued behind close should observe the closed Dataset: " + + queuedCreateFailure.get()); + } finally { + startOwnerCreate.countDown(); + boolean workersStopped = + (readOwner == null || !readOwner.isAlive()) + && (concurrentClose == null || !concurrentClose.isAlive()) + && (queuedCreate == null || !queuedCreate.isAlive()); + if (workersStopped) { + if (dataset != null) { + dataset.close(); + } + allocator.close(); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateInvertedIndexAllowsReadWithQueuedWriter(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_read_with_queued_writer").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + Fragment fragment = dataset.getFragments().get(0); + QueuedWriterProgress progress = new QueuedWriterProgress(dataset); + AtomicReference createFailure = new AtomicReference<>(); + + Thread outerCreate = + new Thread( + () -> { + try { + dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), progress); + } catch (RuntimeException failure) { + createFailure.set(failure); + } + }); + outerCreate.start(); + outerCreate.join(5000); + + assertFalse(outerCreate.isAlive(), "Outer create timed out"); + assertNull(createFailure.get(), "Outer create failed"); + assertTrue(progress.readCompleted.get(), "Callback read did not complete"); + + Thread queuedWriter = progress.writer.get(); + assertNotNull(queuedWriter, "Queued writer did not start"); + queuedWriter.join(5000); + assertFalse(queuedWriter.isAlive(), "Queued writer timed out"); + assertNull(progress.writerFailure.get(), "Queued writer failed"); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testCreateIndexPreservesOuterDatasetCallbackContextAcrossDatasets( + @TempDir Path tempDir) throws Exception { + String outerPath = tempDir.resolve("inverted_cross_dataset_outer").toString(); + String nestedPath = tempDir.resolve("inverted_cross_dataset_nested").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset outerTestDataset = + new TestUtils.SimpleTestDataset(allocator, outerPath); + TestUtils.SimpleTestDataset nestedTestDataset = + new TestUtils.SimpleTestDataset(allocator, nestedPath); + outerTestDataset.createEmptyDataset().close(); + nestedTestDataset.createEmptyDataset().close(); + try (Dataset outerDataset = outerTestDataset.write(1, 20); + Dataset nestedDataset = nestedTestDataset.write(1, 20)) { + Fragment outerFragment = outerDataset.getFragments().get(0); + Fragment nestedFragment = nestedDataset.getFragments().get(0); + CrossDatasetNestedProgress nestedProgress = + new CrossDatasetNestedProgress(outerDataset, nestedDataset); + CrossDatasetOuterProgress outerProgress = + new CrossDatasetOuterProgress( + outerDataset, + nestedDataset, + createInvertedSegmentOptions(nestedFragment.getId()), + nestedProgress); + AtomicReference createFailure = new AtomicReference<>(); + + Thread outerCreate = + new Thread( + () -> { + try { + outerDataset.createIndex( + createInvertedSegmentOptions(outerFragment.getId()), outerProgress); + } catch (RuntimeException failure) { + createFailure.set(failure); + } + }); + outerCreate.start(); + outerCreate.join(5000); + + assertFalse(outerCreate.isAlive(), "Outer create timed out"); + assertNull(createFailure.get(), "Outer create failed"); + assertTrue(outerProgress.nestedCompleted.get(), "Nested create did not complete"); + assertTrue( + nestedProgress.readCompleted.get(), + "Nested stageProgress callback read did not complete"); + + Thread queuedWriter = outerProgress.writer.get(); + assertNotNull(queuedWriter, "Queued writer did not start"); + queuedWriter.join(5000); + assertFalse(queuedWriter.isAlive(), "Queued writer timed out"); + assertNull(outerProgress.writerFailure.get(), "Queued writer failed"); + } + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + public void testCreateIndexRejectsCrossDatasetCallbackLockCycle(@TempDir Path tempDir) + throws Exception { + CrossDatasetBuildPair pair = new CrossDatasetBuildPair(tempDir, "inverted_lock_cycle"); + try { + CountDownLatch callbacksReady = new CountDownLatch(2); + CountDownLatch attemptsFinished = new CountDownLatch(2); + CrossDatasetLockCycleProgress progressA = + new CrossDatasetLockCycleProgress( + pair.datasetB, pair.optionsB, callbacksReady, attemptsFinished); + CrossDatasetLockCycleProgress progressB = + new CrossDatasetLockCycleProgress( + pair.datasetA, pair.optionsA, callbacksReady, attemptsFinished); + + pair.runCreates(progressA, progressB); + assertBusyIndexBuildFailure(progressA.nestedFailure.get()); + assertBusyIndexBuildFailure(progressB.nestedFailure.get()); + } finally { + pair.closeIfWorkersStopped(); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + public void testCreateIndexRejectsCrossDatasetCallbackWriteCycle(@TempDir Path tempDir) + throws Exception { + CrossDatasetBuildPair pair = new CrossDatasetBuildPair(tempDir, "inverted_write_cycle"); + try { + CountDownLatch callbacksReady = new CountDownLatch(2); + CountDownLatch attemptsFinished = new CountDownLatch(2); + CrossDatasetWriteCycleProgress progressA = + new CrossDatasetWriteCycleProgress(pair.datasetB, callbacksReady, attemptsFinished); + CrossDatasetWriteCycleProgress progressB = + new CrossDatasetWriteCycleProgress(pair.datasetA, callbacksReady, attemptsFinished); + + pair.runCreates(progressA, progressB); + assertBusyLifecycleWriteFailure(progressA.writeFailure.get()); + assertBusyLifecycleWriteFailure(progressB.writeFailure.get()); + } finally { + pair.closeIfWorkersStopped(); + } + } + + @Test + @Timeout(value = 10, unit = TimeUnit.SECONDS) + public void testCreateIndexAllowsCrossDatasetCallbackReadWithQueuedClose(@TempDir Path tempDir) + throws Exception { + CrossDatasetBuildPair pair = new CrossDatasetBuildPair(tempDir, "inverted_queued_close_read"); + CrossDatasetReadWithQueuedCloseProgress progressA = null; + CrossDatasetReadWithQueuedCloseProgress progressB = null; + try { + CountDownLatch callbacksReady = new CountDownLatch(2); + CountDownLatch attemptsFinished = new CountDownLatch(2); + progressA = + new CrossDatasetReadWithQueuedCloseProgress( + pair.datasetA, pair.datasetB, callbacksReady, attemptsFinished); + progressB = + new CrossDatasetReadWithQueuedCloseProgress( + pair.datasetB, pair.datasetA, callbacksReady, attemptsFinished); + + pair.runCreates(progressA, progressB); + assertNull(progressA.readFailure.get(), "Dataset A callback read failed"); + assertNull(progressB.readFailure.get(), "Dataset B callback read failed"); + assertTrue(progressA.readCompleted.get(), "Dataset A callback did not read Dataset B"); + assertTrue(progressB.readCompleted.get(), "Dataset B callback did not read Dataset A"); + + Thread closeA = progressA.closeThread.get(); + Thread closeB = progressB.closeThread.get(); + assertNotNull(closeA, "Dataset A close did not start"); + assertNotNull(closeB, "Dataset B close did not start"); + closeA.join(5000); + closeB.join(5000); + assertFalse(closeA.isAlive(), "Dataset A close timed out"); + assertFalse(closeB.isAlive(), "Dataset B close timed out"); + assertNull(progressA.closeFailure.get(), "Dataset A close failed"); + assertNull(progressB.closeFailure.get(), "Dataset B close failed"); + } finally { + Thread closeA = progressA == null ? null : progressA.closeThread.get(); + Thread closeB = progressB == null ? null : progressB.closeThread.get(); + pair.closeIfWorkersStopped(closeA, closeB); + } + } + + @Test + public void testCreateInvertedIndexWithProgressUpdatesDatasetState(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_create_committed_progress").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 20)) { + long previousVersion = dataset.version(); + RecordingIndexBuildProgress progress = new RecordingIndexBuildProgress(); + IndexOptions options = + IndexOptions.builder( + Collections.singletonList("name"), + IndexType.INVERTED, + createInvertedIndexParams()) + .withIndexName("committed_inverted_progress_idx") + .replace(true) + .build(); + + Index index = dataset.createIndex(options, progress); + + assertEquals(previousVersion + 1, dataset.version()); + assertEquals("committed_inverted_progress_idx", index.name()); + assertTrue(dataset.listIndexes().contains("committed_inverted_progress_idx")); + assertTrue( + progress.snapshot().stream().anyMatch(event -> event.startsWith("progress:")), + "Expected committed create to report progress"); + } + } + } + + @Test + public void testCreateIndexRejectsNullProgress(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("create_null_progress").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + try (Dataset dataset = testDataset.write(1, 1)) { + Fragment fragment = dataset.getFragments().get(0); + + NullPointerException failure = + Assertions.assertThrows( + NullPointerException.class, + () -> dataset.createIndex(createInvertedSegmentOptions(fragment.getId()), null)); + + assertTrue(failure.getMessage().contains("progress cannot be null")); + } + } + } + + @Test + public void testMergeInvertedIndexMetadataReportsProgress(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_merge_progress").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + String indexUuid = createDistributedInvertedIndex(dataset); + RecordingIndexBuildProgress progress = new RecordingIndexBuildProgress(); + + dataset.mergeIndexMetadata(indexUuid, IndexType.INVERTED, Optional.empty(), progress); + + List events = progress.snapshot(); + assertEventsInOrder( + events, + "start:read_partition_metadata:", + "complete:read_partition_metadata", + "start:remap_partition_files:", + "complete:remap_partition_files", + "start:write_merged_metadata:", + "complete:write_merged_metadata"); + assertTrue( + events.contains("progress:read_partition_metadata:2"), + "Expected metadata progress to reach both fragments, got: " + events); + assertTrue( + events.stream().anyMatch(event -> event.startsWith("progress:remap_partition_files:")), + "Expected remap progress, got: " + events); + assertTrue( + events.contains("progress:write_merged_metadata:1"), + "Expected merged metadata write progress, got: " + events); + } + } + } + + @Test + public void testMergeInvertedIndexMetadataPropagatesProgressFailure(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_merge_progress_failure").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + String indexUuid = createDistributedInvertedIndex(dataset); + + RuntimeException failure = + Assertions.assertThrows( + RuntimeException.class, + () -> + dataset.mergeIndexMetadata( + indexUuid, + IndexType.INVERTED, + Optional.empty(), + new FailingProgressIndexBuildProgress())); + + assertFalse( + failure instanceof IllegalArgumentException, + "Progress callback failures should not be reported as invalid input: " + failure); + assertTrue( + causeChainContains(failure, "stageProgress") + && causeChainContains(failure, "read_partition_metadata") + && causeChainContains(failure, "java.lang.IllegalStateException") + && causeChainContains(failure, "progress callback failure"), + "Expected callback context and original Java exception details, got: " + failure); + } + } + } + + @Test + public void testMergeInvertedIndexMetadataIgnoresCompleteFailure(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_merge_complete_failure").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + String indexUuid = createDistributedInvertedIndex(dataset); + FailingCompleteIndexBuildProgress progress = new FailingCompleteIndexBuildProgress(); + + dataset.mergeIndexMetadata(indexUuid, IndexType.INVERTED, Optional.empty(), progress); + + assertTrue( + progress.recorder.snapshot().contains("complete:write_merged_metadata"), + "Expected merge to continue after stageComplete callback failures"); + } + } + } + + @Test + @Timeout(value = 60, unit = TimeUnit.SECONDS) + public void testMergeInvertedIndexMetadataAllowsReentrantDatasetAccess(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_merge_reentrant_dataset").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + String indexUuid = createDistributedInvertedIndex(dataset); + ReentrantDatasetIndexBuildProgress progress = + new ReentrantDatasetIndexBuildProgress(dataset); + + dataset.mergeIndexMetadata(indexUuid, IndexType.INVERTED, Optional.empty(), progress); + + assertTrue( + progress.reentries.get() > 0, + "Expected progress callbacks to re-enter Dataset JNI methods"); + assertTrue( + progress.recorder.snapshot().contains("complete:write_merged_metadata"), + "Expected merge to finish after re-entrant Dataset access, got: " + + progress.recorder.snapshot()); + } + } + } + + @Test + @Timeout(value = 5, unit = TimeUnit.SECONDS) + public void testMergeInvertedIndexMetadataRejectsWriteReentryPromptly(@TempDir Path tempDir) + throws Exception { + String datasetPath = tempDir.resolve("inverted_merge_write_reentry").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + testDataset.createEmptyDataset().close(); + testDataset.write(1, 10).close(); + try (Dataset dataset = testDataset.write(2, 10)) { + String indexUuid = createDistributedInvertedIndex(dataset); + WriteReentrantIndexBuildProgress progress = new WriteReentrantIndexBuildProgress(dataset); + + dataset.mergeIndexMetadata(indexUuid, IndexType.INVERTED, Optional.empty(), progress); + + RuntimeException failure = progress.writeFailure.get(); + assertNotNull(failure, "Expected write re-entry to be rejected"); + assertTrue( + failure.getMessage().contains("busy in an index progress callback"), + "Unexpected write re-entry failure: " + failure.getMessage()); + assertTrue(progress.reentries.get() > 0, "Expected callback to attempt a write lock"); + assertTrue( + progress.recorder.snapshot().contains("complete:write_merged_metadata"), + "Expected merge to finish after rejected write re-entry, got: " + + progress.recorder.snapshot()); + } + } + } + + private static String createDistributedInvertedIndex(Dataset dataset) { + String indexUuid = UUID.randomUUID().toString(); + for (Fragment fragment : dataset.getFragments()) { + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("name"), + IndexType.INVERTED, + createInvertedIndexParams()) + .replace(true) + .withIndexName("inverted_progress_idx") + .withIndexUUID(indexUuid) + .withFragmentIds(Collections.singletonList(fragment.getId())) + .build()); + } + return indexUuid; + } + + private static IndexOptions createInvertedSegmentOptions(int fragmentId) { + return IndexOptions.builder( + Collections.singletonList("name"), IndexType.INVERTED, createInvertedIndexParams()) + .withFragmentIds(Collections.singletonList(fragmentId)) + .build(); + } + + private static IndexParams createInvertedIndexParams() { + ScalarIndexParams scalarParams = + ScalarIndexParams.create( + "inverted", + "{\"base_tokenizer\":\"simple\",\"language\":\"English\"," + + "\"max_token_length\":40,\"lower_case\":true,\"stem\":false," + + "\"remove_stop_words\":false}"); + return IndexParams.builder().setScalarIndexParams(scalarParams).build(); + } + + private static void assertEventsInOrder(List events, String... prefixes) { + int previous = -1; + for (String prefix : prefixes) { + int current = -1; + for (int i = previous + 1; i < events.size(); i++) { + if (events.get(i).startsWith(prefix)) { + current = i; + break; + } + } + assertTrue( + current >= 0, + "Missing event '" + prefix + "' after position " + previous + ": " + events); + previous = current; + } + } + + private static boolean causeChainContains(Throwable failure, String expected) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(expected)) { + return true; + } + } + return false; + } + + private static void assertBusyIndexBuildFailure(RuntimeException failure) { + assertNotNull(failure, "Expected cross-Dataset nested create to be rejected"); + assertTrue( + failure.getMessage().contains("busy with an index build"), + "Unexpected nested create failure: " + failure.getMessage()); + } + + private static void assertBusyLifecycleWriteFailure(RuntimeException failure) { + assertNotNull(failure, "Expected cross-Dataset write to be rejected"); + assertTrue( + failure.getMessage().contains("lifecycle write lock is busy"), + "Unexpected cross-Dataset write failure: " + failure.getMessage()); + } + + private static void awaitLatch(CountDownLatch latch, String message) { + try { + assertTrue(latch.await(5, TimeUnit.SECONDS), message); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(message, e); + } + } + + private static void awaitThreadBlocked(Thread thread, String message) + throws InterruptedException { + long deadlineNanos = System.nanoTime() + TimeUnit.SECONDS.toNanos(2); + while (System.nanoTime() < deadlineNanos) { + Thread.State state = thread.getState(); + if (state == Thread.State.BLOCKED || state == Thread.State.WAITING) { + return; + } + Thread.sleep(10); + } + Assertions.fail(message + "; state=" + thread.getState()); + } + + private static void joinUntil(Thread thread, long deadlineNanos) throws InterruptedException { + long remainingNanos = deadlineNanos - System.nanoTime(); + if (remainingNanos > 0) { + thread.join(Math.max(1, TimeUnit.NANOSECONDS.toMillis(remainingNanos))); + } + } + + @Test + public void testCreateZonemapIndex(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("zonemap_test").toString(); + Schema schema = + new Schema( + Arrays.asList( + Field.nullable("id", new ArrowType.Int(32, true)), + Field.nullable("value", new ArrowType.Utf8())), + null); + + try (BufferAllocator allocator = new RootAllocator()) { + try (Dataset dataset = + Dataset.create(allocator, datasetPath, schema, new WriteParams.Builder().build())) { + + // Create Zonemap scalar index parameters with rows_per_zone setting ScalarIndexParams scalarParams = ScalarIndexParams.create("zonemap", "{\"rows_per_zone\": 1024}"); @@ -376,4 +2167,78 @@ public void testCreateZonemapIndex() throws Exception { } } } + + @Test + public void testCreateRTreeIndex(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("rtree_test").toString(); + ArrowType f64 = new ArrowType.FloatingPoint(FloatingPointPrecision.DOUBLE); + Field geometryField = + new Field( + "geometry", + new FieldType( + true, + new ArrowType.Struct(), + null, + Collections.singletonMap("ARROW:extension:name", "geoarrow.point")), + Arrays.asList(Field.notNullable("x", f64), Field.notNullable("y", f64))); + Schema schema = new Schema(Collections.singletonList(geometryField), null); + + int rowCount = 3; + try (RootAllocator allocator = new RootAllocator(); + VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + root.allocateNew(); + StructVector geometry = (StructVector) root.getVector("geometry"); + Float8Vector x = (Float8Vector) geometry.getChild("x"); + Float8Vector y = (Float8Vector) geometry.getChild("y"); + for (int i = 0; i < rowCount; i++) { + geometry.setIndexDefined(i); + x.setSafe(i, (double) i); + y.setSafe(i, i * 2.0); + } + geometry.setValueCount(rowCount); + root.setRowCount(rowCount); + + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + + try (ArrowStreamReader reader = + new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), allocator); + Dataset dataset = + Dataset.write() + .reader(reader) + .uri(datasetPath) + .allocator(allocator) + .mode(WriteParams.WriteMode.CREATE) + .execute()) { + // The point data round-trips through Lance. + assertEquals(rowCount, dataset.countRows()); + try (ArrowReader scan = dataset.newScan(new ScanOptions.Builder().build()).scanBatches()) { + assertTrue(scan.loadNextBatch()); + StructVector readGeometry = + (StructVector) scan.getVectorSchemaRoot().getVector("geometry"); + assertEquals(2.0, ((Float8Vector) readGeometry.getChild("x")).get(2)); + assertEquals(4.0, ((Float8Vector) readGeometry.getChild("y")).get(2)); + } + + // Creating and listing an RTree index via the typed IndexType works end-to-end. + Index index = + dataset.createIndex( + Collections.singletonList("geometry"), + IndexType.RTREE, + Optional.of("rtree_geometry_index"), + IndexParams.builder() + .setScalarIndexParams(ScalarIndexParams.create("rtree")) + .build(), + true); + assertEquals(IndexType.RTREE, index.indexType()); + assertTrue( + dataset.listIndexes().contains("rtree_geometry_index"), + "Expected 'rtree_geometry_index' in: " + dataset.listIndexes()); + } + } + } } diff --git a/java/src/test/java/org/lance/index/VectorIndexTest.java b/java/src/test/java/org/lance/index/VectorIndexTest.java index 9d4b9791949..6e5a7e8562d 100755 --- a/java/src/test/java/org/lance/index/VectorIndexTest.java +++ b/java/src/test/java/org/lance/index/VectorIndexTest.java @@ -16,34 +16,132 @@ 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.lance.ipc.AsyncScanner; +import org.lance.ipc.LanceScanner; +import org.lance.ipc.Query; +import org.lance.ipc.ScanOptions; + +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.IntVector; +import org.apache.arrow.vector.ipc.ArrowReader; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.ValueSource; import java.nio.file.Path; import java.util.Collections; +import java.util.HashSet; import java.util.List; import java.util.Optional; +import java.util.Set; import java.util.UUID; -import java.util.stream.Collectors; +import java.util.concurrent.TimeUnit; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; public class VectorIndexTest { - @TempDir Path tempDir; + @ParameterizedTest + @EnumSource( + value = IndexType.class, + names = {"IVF_FLAT", "IVF_PQ"}) + @SuppressWarnings("deprecation") + public void testCreateIndexWithConcreteVectorType(IndexType indexType, @TempDir Path tempDir) + throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve(indexType.name()))) { + try (Dataset dataset = testVectorDataset.create()) { + VectorIndexParams vectorIndexParams = + indexType == IndexType.IVF_FLAT + ? VectorIndexParams.ivfFlat(2, DistanceType.L2) + : VectorIndexParams.ivfPq(2, 8, 2, DistanceType.L2, 2); + IndexParams indexParams = + IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); + + Index index = + dataset.createIndex( + Collections.singletonList(TestVectorDataset.vectorColumnName), + indexType, + Optional.empty(), + indexParams, + false); + + assertNotNull(index); + assertTrue(dataset.listIndexes().contains(index.name())); + } + } + } @Test - public void testCreateIvfFlatIndexDistributively() throws Exception { + public void testCreateIvfFlatIndexPropagatesProgressFailure(@TempDir Path tempDir) + throws Exception { + String indexName = "ivf_progress_failure_idx"; + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("ivf_progress_failure"))) { + try (Dataset dataset = testVectorDataset.create()) { + long initialVersion = dataset.version(); + IndexParams indexParams = + IndexParams.builder() + .setVectorIndexParams(VectorIndexParams.ivfFlat(2, DistanceType.L2)) + .build(); + IndexOptions options = + IndexOptions.builder( + Collections.singletonList(TestVectorDataset.vectorColumnName), + IndexType.IVF_FLAT, + indexParams) + .withIndexName(indexName) + .build(); + IndexBuildProgress progress = + new IndexBuildProgress() { + @Override + public void stageStart(String stage, Optional total, String unit) {} + + @Override + public void stageProgress(String stage, long completed) { + if (stage.equals("train_ivf")) { + throw new IllegalStateException("vector progress callback failure"); + } + } + + @Override + public void stageComplete(String stage) {} + }; + + RuntimeException failure = + assertThrows(RuntimeException.class, () -> dataset.createIndex(options, progress)); + + assertFalse( + failure instanceof IllegalArgumentException, + "Progress callback failures should not be reported as invalid input: " + failure); + assertTrue( + causeChainContains(failure, "stageProgress") + && causeChainContains(failure, "train_ivf") + && causeChainContains(failure, "java.lang.IllegalStateException") + && causeChainContains(failure, "vector progress callback failure"), + "Expected callback context and original Java exception details, got: " + failure); + assertEquals(initialVersion, dataset.version(), "Failed index build must not commit"); + assertFalse( + dataset.listIndexes().contains(indexName), "Failed index build must not publish index"); + } + } + } + + @ParameterizedTest + @ValueSource(booleans = {false, true}) + public void testCreateIvfFlatIndexDistributively(boolean async, @TempDir Path tempDir) + throws Exception { try (TestVectorDataset testVectorDataset = new TestVectorDataset(tempDir.resolve("merge_ivfflat_index_metadata"))) { try (Dataset dataset = testVectorDataset.create()) { @@ -74,75 +172,118 @@ public void testCreateIvfFlatIndexDistributively() throws Exception { 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())) + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); + + float[] key = new float[32]; + for (int i = 0; i < key.length; i++) { + key[i] = 32 + i; + } + Query query = + new Query.Builder() + .setColumn(TestVectorDataset.vectorColumnName) + .setKey(key) + .setK(5) + .setNprobes(2) .build(); + // Each fragment repeats the vectors but has distinct row IDs. Selecting a segment + // must exclude equally close rows in the other segments and unindexed fragments. + for (int segment = 0; segment < 2; segment++) { + ScanOptions options = + new ScanOptions.Builder() + .nearest(query) + .indexSegments(List.of(segment == 0 ? firstSegment.uuid() : secondSegment.uuid())) + .build(); + try (RootAllocator allocator = new RootAllocator(); + AutoCloseable scanner = + async + ? AsyncScanner.create(dataset, options, allocator) + : dataset.newScan(options); + ArrowReader reader = + async + ? ((AsyncScanner) scanner).scanBatchesAsync().get(10, TimeUnit.SECONDS) + : ((LanceScanner) scanner).scanBatches()) { + Set actual = new HashSet<>(); + while (reader.loadNextBatch()) { + IntVector ids = (IntVector) reader.getVectorSchemaRoot().getVector("i"); + for (int row = 0; row < ids.getValueCount(); row++) { + actual.add(ids.get(row)); + } + } + int base = segment * 80; + assertEquals(Set.of(base, base + 1, base + 2, base + 3, base + 4), actual); + } + } - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - Transaction createIndexTx = - dataset.newTransactionBuilder().operation(createIndexOp).build(); - - try (Dataset newDataset = createIndexTx.commit()) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); + List invalidOptions = + List.of( + new ScanOptions.Builder().nearest(query).indexSegments(List.of()).build(), + new ScanOptions.Builder().indexSegments(List.of(firstSegment.uuid())).build(), + new ScanOptions.Builder() + .nearest(query) + .indexSegments(List.of(UUID.randomUUID())) + .build()); + List errors = + List.of( + "empty segment list", "only supported for vector search", "unknown index segments"); + for (int i = 0; i < invalidOptions.size(); i++) { + ScanOptions options = invalidOptions.get(i); + Exception failure = + assertThrows( + Exception.class, + () -> { + try (RootAllocator allocator = new RootAllocator(); + AutoCloseable scanner = + async + ? AsyncScanner.create(dataset, options, allocator) + : dataset.newScan(options); + ArrowReader reader = + async + ? ((AsyncScanner) scanner) + .scanBatchesAsync() + .get(10, TimeUnit.SECONDS) + : ((LanceScanner) scanner).scanBatches()) { + reader.loadNextBatch(); + } + }); + assertTrue(causeChainContains(failure, errors.get(i)), failure.toString()); } } } } @Test - public void testCreateIvfPqIndexDistributively() throws Exception { + public void testCreateIvfPqIndexDistributively(@TempDir Path tempDir) throws Exception { try (TestVectorDataset testVectorDataset = new TestVectorDataset(tempDir.resolve("merge_ivfpq_index_metadata"))) { try (Dataset dataset = testVectorDataset.create()) { @@ -196,71 +337,43 @@ public void testCreateIvfPqIndexDistributively() throws Exception { 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(); - - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); - - Transaction createIndexTx = - dataset.newTransactionBuilder().operation(createIndexOp).build(); - - try (Dataset newDataset = createIndexTx.commit()) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); - } + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); } } } @Test - public void testCreateIvfSqIndexDistributively() throws Exception { + public void testCreateIvfSqIndexDistributively(@TempDir Path tempDir) throws Exception { try (TestVectorDataset testVectorDataset = new TestVectorDataset(tempDir.resolve("merge_ivfsq_index_metadata"))) { try (Dataset dataset = testVectorDataset.create()) { @@ -298,66 +411,174 @@ public void testCreateIvfSqIndexDistributively() throws Exception { IndexParams indexParams = IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); - UUID indexUUID = UUID.randomUUID(); + 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()); - 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()); + assertFalse( + dataset.listIndexes().contains(TestVectorDataset.indexName), + "Partially created IVF_SQ index should not present before commit"); + + List committed = + dataset.commitExistingIndexSegments( + TestVectorDataset.indexName, + TestVectorDataset.vectorColumnName, + List.of(firstSegment, secondSegment)); + assertEquals(2, committed.size()); + assertTrue(dataset.listIndexes().contains(TestVectorDataset.indexName)); + } + } + } + + @Test + public void testCreateIvfRqIndex(@TempDir Path tempDir) throws Exception { + Path datasetPath = tempDir.resolve("ivf_rq_index"); + + try (TestVectorDataset testVectorDataset = new TestVectorDataset(datasetPath)) { + try (Dataset dataset = testVectorDataset.create()) { + IvfBuildParams ivf = new IvfBuildParams.Builder().setNumPartitions(2).build(); + RQBuildParams rq = new RQBuildParams.Builder().setNumBits((byte) 1).build(); + + VectorIndexParams vectorIndexParams = + VectorIndexParams.withIvfRqParams(DistanceType.L2, ivf, rq); + IndexParams indexParams = + IndexParams.builder().setVectorIndexParams(vectorIndexParams).build(); dataset.createIndex( IndexOptions.builder( Collections.singletonList(TestVectorDataset.vectorColumnName), - IndexType.IVF_SQ, + IndexType.IVF_RQ, indexParams) .withIndexName(TestVectorDataset.indexName) - .withIndexUUID(indexUUID.toString()) - .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) .build()); - assertFalse( - dataset.listIndexes().contains(TestVectorDataset.indexName), - "Partially created IVF_SQ index should not present before commit"); + List indexes = dataset.getIndexes(); + Index rqIndex = + indexes.stream() + .filter(idx -> TestVectorDataset.indexName.equals(idx.name())) + .findFirst() + .orElse(null); - dataset.mergeIndexMetadata(indexUUID.toString(), IndexType.IVF_SQ, Optional.empty()); + assertNotNull(rqIndex, "Expected IVF_RQ index to be present"); - int fieldId = - dataset.getLanceSchema().fields().stream() - .filter(f -> f.getName().equals(TestVectorDataset.vectorColumnName)) - .findAny() - .orElseThrow( - () -> new RuntimeException("Cannot find vector field for TestVectorDataset")) - .getId(); + IndexType indexType = rqIndex.indexType(); + assertNotNull(indexType, "IndexType should be set for IVF_RQ index"); - long datasetVersion = dataset.version(); + // Today all vector indices share the same VectorIndexDetails type and map to VECTOR. + // This assertion allows both VECTOR and IVF_RQ so it remains valid if the mapping + // is refined in the future. + assertTrue( + indexType == IndexType.VECTOR || indexType == IndexType.IVF_RQ, + "IndexType for IVF_RQ index should be VECTOR or IVF_RQ but was " + indexType); + } + } + } - 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())) + /** + * Regression test for the metric_type passthrough in the JNI VectorTrainer. + * + *

    Before this fix, {@code trainIvfCentroids} hardcoded {@code MetricType::L2} on the Rust + * side, so users requesting a non-L2 metric got centroids clustered on L2 geometry while + * per-fragment encoders later quantized using the requested metric — silently degraded recall. + * + *

    This test trains the same dataset twice with L2 and Cosine and asserts the centroid arrays + * differ. With the bug present, the two arrays were identical because both paths fell through to + * L2. + */ + @Test + public void testTrainIvfCentroidsHonorsDistanceType(@TempDir Path tempDir) throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("ivf_centroids_metric"))) { + try (Dataset dataset = testVectorDataset.create()) { + IvfBuildParams params = + new IvfBuildParams.Builder().setNumPartitions(2).setMaxIters(10).build(); + + float[] l2Centroids = + VectorTrainer.trainIvfCentroids( + dataset, TestVectorDataset.vectorColumnName, params, DistanceType.L2); + float[] cosineCentroids = + VectorTrainer.trainIvfCentroids( + dataset, TestVectorDataset.vectorColumnName, params, DistanceType.Cosine); + + assertEquals(l2Centroids.length, cosineCentroids.length); + assertFalse( + arraysApproximatelyEqual(l2Centroids, cosineCentroids), + "L2 and Cosine centroids should differ — Cosine normalizes input before clustering." + + " If they are equal, the metric is not being threaded into the trainer."); + } + } + } + + /** + * Regression test for the metric_type passthrough in PQ codebook training. + * + *

    Cosine training normalizes the input vectors before k-means; L2 does not. So the resulting + * codebooks must differ when the metric is honored. + */ + @Test + public void testTrainPqCodebookHonorsDistanceType(@TempDir Path tempDir) throws Exception { + try (TestVectorDataset testVectorDataset = + new TestVectorDataset(tempDir.resolve("pq_codebook_metric"))) { + try (Dataset dataset = testVectorDataset.create()) { + PQBuildParams params = + new PQBuildParams.Builder() + .setNumSubVectors(2) + .setNumBits(4) + .setMaxIters(5) + .setSampleRate(64) .build(); - CreateIndex createIndexOp = - CreateIndex.builder().withNewIndices(Collections.singletonList(index)).build(); + float[] l2Codebook = + VectorTrainer.trainPqCodebook( + dataset, TestVectorDataset.vectorColumnName, params, DistanceType.L2); + float[] cosineCodebook = + VectorTrainer.trainPqCodebook( + dataset, TestVectorDataset.vectorColumnName, params, DistanceType.Cosine); + + assertEquals(l2Codebook.length, cosineCodebook.length); + assertFalse( + arraysApproximatelyEqual(l2Codebook, cosineCodebook), + "L2 and Cosine PQ codebooks should differ — Cosine normalizes the training data." + + " If they are equal, the metric is not being threaded into the trainer."); + } + } + } - Transaction createIndexTx = - dataset.newTransactionBuilder().operation(createIndexOp).build(); + private static boolean arraysApproximatelyEqual(float[] a, float[] b) { + if (a.length != b.length) { + return false; + } + final float epsilon = 1e-6f; + for (int i = 0; i < a.length; i++) { + if (Math.abs(a[i] - b[i]) > epsilon) { + return false; + } + } + return true; + } - try (Dataset newDataset = createIndexTx.commit()) { - assertEquals(datasetVersion + 1, newDataset.version()); - assertTrue(newDataset.listIndexes().contains(TestVectorDataset.indexName)); - } + private static boolean causeChainContains(Throwable failure, String expected) { + for (Throwable current = failure; current != null; current = current.getCause()) { + if (current.getMessage() != null && current.getMessage().contains(expected)) { + return true; } } + return false; } } 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..5d738b01eb5 --- /dev/null +++ b/java/src/test/java/org/lance/index/ZonemapStatsTest.java @@ -0,0 +1,318 @@ +/* + * 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 testGetZonemapStatsCommittedSegments(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("segmented_zonemap_stats").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)) { + List fragments = dataset.getFragments(); + assertEquals(2, fragments.size()); + + ScalarIndexParams params = ScalarIndexParams.create("zonemap", "{}"); + IndexParams indexParams = IndexParams.builder().setScalarIndexParams(params).build(); + + Index firstSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("value"), IndexType.ZONEMAP, indexParams) + .withIndexName("value_zm") + .withFragmentIds(Collections.singletonList(fragments.get(0).getId())) + .build()); + Index secondSegment = + dataset.createIndex( + IndexOptions.builder( + Collections.singletonList("value"), IndexType.ZONEMAP, indexParams) + .withIndexName("value_zm") + .withFragmentIds(Collections.singletonList(fragments.get(1).getId())) + .build()); + + dataset.commitExistingIndexSegments( + "value_zm", "value", List.of(firstSegment, secondSegment)); + + 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 committed segments"); + } + } + } + + @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("")); + } + } + } +} diff --git a/java/src/test/java/org/lance/index/scalar/InvertedIndexParamsTest.java b/java/src/test/java/org/lance/index/scalar/InvertedIndexParamsTest.java new file mode 100644 index 00000000000..59ef98db9e1 --- /dev/null +++ b/java/src/test/java/org/lance/index/scalar/InvertedIndexParamsTest.java @@ -0,0 +1,165 @@ +/* + * 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 org.lance.DocumentGranularity; +import org.lance.util.JsonUtils; + +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class InvertedIndexParamsTest { + + @Test + void testIcuSplitTokenizerVariant() { + ScalarIndexParams params = InvertedIndexParams.builder().baseTokenizer("icu/split").build(); + + assertEquals("inverted", params.getIndexType()); + String jsonParams = params.getJsonParams().orElseThrow(AssertionError::new); + assertTrue(jsonParams.contains("\"base_tokenizer\":\"icu/split\"")); + } + + @Test + void defaultBlockSizeIsSerialized() { + ScalarIndexParams params = InvertedIndexParams.builder().build(); + + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals(128, ((Number) json.get("block_size")).intValue()); + assertEquals("row", json.get("document_granularity")); + assertEquals(40, ((Number) json.get("max_token_length")).intValue()); + } + + @Test + void documentGranularityIsSerialized() { + ScalarIndexParams params = + InvertedIndexParams.builder().documentGranularity(DocumentGranularity.LIST_ELEMENT).build(); + + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals("list_element", json.get("document_granularity")); + } + + @Test + void blockSizeIsSerialized() { + ScalarIndexParams params = InvertedIndexParams.builder().blockSize(128).build(); + + assertEquals("inverted", params.getIndexType()); + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals(128, ((Number) json.get("block_size")).intValue()); + } + + @Test + void invalidBlockSizeIsRejected() { + assertThrows( + IllegalArgumentException.class, () -> InvertedIndexParams.builder().blockSize(129)); + assertThrows( + IllegalArgumentException.class, () -> InvertedIndexParams.builder().blockSize(512)); + } + + @Test + void formatVersionThreeSupportsBothBlockSizes() { + for (int blockSize : new int[] {128, 256}) { + ScalarIndexParams params = + InvertedIndexParams.builder().blockSize(blockSize).formatVersion(3).build(); + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals(blockSize, ((Number) json.get("block_size")).intValue()); + assertEquals(3, ((Number) json.get("format_version")).intValue()); + } + + assertThrows( + IllegalArgumentException.class, + () -> InvertedIndexParams.builder().blockSize(256).formatVersion(2).build()); + } + + @Test + void formatVersionFourIsRejected() { + assertThrows( + IllegalArgumentException.class, () -> InvertedIndexParams.builder().formatVersion(4)); + } + + @Test + void codeAnalyzerRequiresFormatVersionThreeWhenExplicit() { + ScalarIndexParams params = + InvertedIndexParams.builder().baseTokenizer("code").formatVersion(3).build(); + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals(3, ((Number) json.get("format_version")).intValue()); + + assertThrows( + IllegalArgumentException.class, + () -> InvertedIndexParams.builder().baseTokenizer("code").formatVersion(2).build()); + assertThrows( + IllegalArgumentException.class, + () -> InvertedIndexParams.builder().analyzer("code").formatVersion(1).build()); + assertThrows( + IllegalArgumentException.class, + () -> InvertedIndexParams.builder().analyzer("code").formatVersion(2).build()); + } + + @Test + void additionalBuildParametersAreSerialized() { + ScalarIndexParams params = + InvertedIndexParams.builder() + .analyzer("code") + .lanceTokenizer("text") + .splitIdentifiers(true) + .splitOnNumerics(false) + .preserveOriginal(true) + .indexOperators(true) + .memoryLimit(4096) + .numWorkers(4) + .formatVersion(3) + .build(); + + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals("code", json.get("analyzer")); + assertEquals("text", json.get("lance_tokenizer")); + assertEquals(true, json.get("split_identifiers")); + assertEquals(false, json.get("split_on_numerics")); + assertEquals(true, json.get("preserve_original")); + assertEquals(true, json.get("index_operators")); + assertEquals(4096L, ((Number) json.get("memory_limit")).longValue()); + assertEquals(4, ((Number) json.get("num_workers")).intValue()); + } + + @Test + void nullMaxTokenLengthIsSerializedAsNull() { + ScalarIndexParams params = InvertedIndexParams.builder().maxTokenLength(null).build(); + + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertTrue(json.containsKey("max_token_length")); + assertNull(json.get("max_token_length")); + } + + @Test + void zeroResourceParametersAreSerialized() { + ScalarIndexParams params = InvertedIndexParams.builder().memoryLimit(0).numWorkers(0).build(); + + Map json = JsonUtils.fromJson(params.getJsonParams().orElseThrow()); + assertEquals(0L, ((Number) json.get("memory_limit")).longValue()); + assertEquals(0, ((Number) json.get("num_workers")).intValue()); + } + + @Test + void negativeResourceParametersAreRejected() { + assertThrows( + IllegalArgumentException.class, () -> InvertedIndexParams.builder().memoryLimit(-1)); + assertThrows( + IllegalArgumentException.class, () -> InvertedIndexParams.builder().numWorkers(-1)); + } +} diff --git a/java/src/test/java/org/lance/ipc/FullTextQueryTest.java b/java/src/test/java/org/lance/ipc/FullTextQueryTest.java index 595e99eccd8..7b6fe54784d 100755 --- a/java/src/test/java/org/lance/ipc/FullTextQueryTest.java +++ b/java/src/test/java/org/lance/ipc/FullTextQueryTest.java @@ -13,6 +13,8 @@ */ package org.lance.ipc; +import org.lance.DocumentGranularity; + import org.junit.jupiter.api.Test; import java.util.Arrays; @@ -39,6 +41,7 @@ void testMatchQueryDefaults() { assertEquals(50, q.getMaxExpansions()); assertEquals(FullTextQuery.Operator.OR, q.getOperator()); assertEquals(0, q.getPrefixLength()); + assertEquals(Optional.empty(), q.getDocumentGranularity()); } @Test @@ -46,7 +49,14 @@ void testMatchQueryCustomParameters() { FullTextQuery.MatchQuery q = (FullTextQuery.MatchQuery) FullTextQuery.match( - "hello", "title", 2.0f, Optional.of(1), 10, FullTextQuery.Operator.AND, 3); + "hello", + "title", + 2.0f, + Optional.of(1), + 10, + FullTextQuery.Operator.AND, + 3, + DocumentGranularity.LIST_ELEMENT); assertEquals(FullTextQuery.Type.MATCH, q.getType()); assertEquals("hello", q.getQueryText()); @@ -56,6 +66,7 @@ void testMatchQueryCustomParameters() { assertEquals(10, q.getMaxExpansions()); assertEquals(FullTextQuery.Operator.AND, q.getOperator()); assertEquals(3, q.getPrefixLength()); + assertEquals(Optional.of(DocumentGranularity.LIST_ELEMENT), q.getDocumentGranularity()); } @Test @@ -67,17 +78,20 @@ void testPhraseQueryDefaults() { assertEquals("exact match", q.getQueryText()); assertEquals("content", q.getColumn()); assertEquals(0, q.getSlop()); + assertEquals(Optional.empty(), q.getDocumentGranularity()); } @Test void testPhraseQueryCustomSlop() { FullTextQuery.PhraseQuery q = - (FullTextQuery.PhraseQuery) FullTextQuery.phrase("ordered terms", "content", 2); + (FullTextQuery.PhraseQuery) + FullTextQuery.phrase("ordered terms", "content", 2, DocumentGranularity.LIST_ELEMENT); assertEquals(FullTextQuery.Type.MATCH_PHRASE, q.getType()); assertEquals("ordered terms", q.getQueryText()); assertEquals("content", q.getColumn()); assertEquals(2, q.getSlop()); + assertEquals(Optional.of(DocumentGranularity.LIST_ELEMENT), q.getDocumentGranularity()); } @Test @@ -167,4 +181,257 @@ void testBooleanQuerySingleClause() { assertEquals(1, q.getClauses().size()); assertEquals(FullTextQuery.Occur.SHOULD, q.getClauses().get(0).getOccur()); } + + @Test + void testMatchQueryEquality() { + FullTextQuery a = FullTextQuery.match("hello", "body"); + FullTextQuery b = FullTextQuery.match("hello", "body"); + assertEquals(a, b, "Two MatchQuery instances with the same fields must be equal"); + assertEquals( + a.hashCode(), b.hashCode(), "Equal MatchQuery instances must have the same hashCode"); + } + + @Test + void testMatchQueryInequalityDifferentText() { + FullTextQuery a = FullTextQuery.match("hello", "body"); + FullTextQuery b = FullTextQuery.match("world", "body"); + assertFalse(a.equals(b), "MatchQuery instances with different queryText must not be equal"); + } + + @Test + void testMatchQueryInequalityDifferentColumn() { + FullTextQuery a = FullTextQuery.match("hello", "body"); + FullTextQuery b = FullTextQuery.match("hello", "title"); + assertFalse(a.equals(b), "MatchQuery instances with different column must not be equal"); + } + + @Test + void testMatchQueryEqualityWithOptions() { + FullTextQuery a = + FullTextQuery.match( + "hello", "body", 2.0f, Optional.of(1), 10, FullTextQuery.Operator.AND, 2); + FullTextQuery b = + FullTextQuery.match( + "hello", "body", 2.0f, Optional.of(1), 10, FullTextQuery.Operator.AND, 2); + assertEquals(a, b, "MatchQuery instances with all custom fields equal must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testMatchQueryInequalityDifferentFuzziness() { + FullTextQuery a = + FullTextQuery.match( + "hello", "body", 1.0f, Optional.of(1), 50, FullTextQuery.Operator.OR, 0); + FullTextQuery b = + FullTextQuery.match( + "hello", "body", 1.0f, Optional.of(2), 50, FullTextQuery.Operator.OR, 0); + assertFalse(a.equals(b), "MatchQuery instances with different fuzziness must not be equal"); + } + + @Test + void testPhraseQueryEquality() { + FullTextQuery a = FullTextQuery.phrase("hello world", "body"); + FullTextQuery b = FullTextQuery.phrase("hello world", "body"); + assertEquals(a, b, "Two PhraseQuery instances with the same fields must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testPhraseQueryEqualityWithSlop() { + FullTextQuery a = FullTextQuery.phrase("hello world", "body", 2); + FullTextQuery b = FullTextQuery.phrase("hello world", "body", 2); + assertEquals(a, b, "PhraseQuery instances with same slop must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testPhraseQueryInequalityDifferentSlop() { + FullTextQuery a = FullTextQuery.phrase("hello world", "body", 1); + FullTextQuery b = FullTextQuery.phrase("hello world", "body", 2); + assertFalse(a.equals(b), "PhraseQuery instances with different slop must not be equal"); + } + + @Test + void testPhraseQueryInequalityDifferentColumn() { + FullTextQuery a = FullTextQuery.phrase("hello world", "body"); + FullTextQuery b = FullTextQuery.phrase("hello world", "title"); + assertFalse(a.equals(b)); + } + + @Test + void testMultiMatchQueryEquality() { + FullTextQuery a = FullTextQuery.multiMatch("hello", Arrays.asList("title", "body")); + FullTextQuery b = FullTextQuery.multiMatch("hello", Arrays.asList("title", "body")); + assertEquals(a, b, "Two MultiMatchQuery instances with the same fields must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testMultiMatchQueryEqualityWithOperator() { + FullTextQuery a = + FullTextQuery.multiMatch( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.AND); + FullTextQuery b = + FullTextQuery.multiMatch( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.AND); + assertEquals(a, b); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testMultiMatchQueryInequalityDifferentOperator() { + FullTextQuery a = + FullTextQuery.multiMatch( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.AND); + FullTextQuery b = + FullTextQuery.multiMatch( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.OR); + assertFalse(a.equals(b), "MultiMatchQuery instances with different operator must not be equal"); + } + + @Test + void testMultiMatchQueryInequalityDifferentColumns() { + FullTextQuery a = FullTextQuery.multiMatch("hello", Arrays.asList("title", "body")); + FullTextQuery b = FullTextQuery.multiMatch("hello", Arrays.asList("title", "content")); + assertFalse(a.equals(b), "MultiMatchQuery instances with different columns must not be equal"); + } + + @Test + void testMultiMatchQueryEqualityWithBoosts() { + FullTextQuery a = + FullTextQuery.multiMatch( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(2.0f, 0.5f), + FullTextQuery.Operator.OR); + FullTextQuery b = + FullTextQuery.multiMatch( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(2.0f, 0.5f), + FullTextQuery.Operator.OR); + assertEquals(a, b, "MultiMatchQuery instances with same boosts must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testMultiMatchQueryInequalityDifferentBoosts() { + FullTextQuery a = + FullTextQuery.multiMatch( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(2.0f, 0.5f), + FullTextQuery.Operator.OR); + FullTextQuery b = + FullTextQuery.multiMatch( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(1.0f, 0.5f), + FullTextQuery.Operator.OR); + assertFalse(a.equals(b), "MultiMatchQuery instances with different boosts must not be equal"); + } + + @Test + void testCombinedFieldsWithoutBoosts() { + FullTextQuery.CombinedFieldsQuery q = + (FullTextQuery.CombinedFieldsQuery) + FullTextQuery.combinedFields("hello", Arrays.asList("title", "body")); + + assertEquals(FullTextQuery.Type.COMBINED_FIELDS, q.getType()); + assertEquals("hello", q.getQueryText()); + assertEquals(Arrays.asList("title", "body"), q.getColumns()); + assertFalse(q.getBoosts().isPresent()); + assertEquals(FullTextQuery.Operator.OR, q.getOperator()); + } + + @Test + void testCombinedFieldsWithBoosts() { + FullTextQuery.CombinedFieldsQuery q = + (FullTextQuery.CombinedFieldsQuery) + FullTextQuery.combinedFields( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(2.0f, 1.0f), + FullTextQuery.Operator.AND); + + assertEquals(FullTextQuery.Type.COMBINED_FIELDS, q.getType()); + assertTrue(q.getBoosts().isPresent()); + assertEquals(2, q.getBoosts().get().size()); + assertEquals(2.0f, q.getBoosts().get().get(0)); + assertEquals(1.0f, q.getBoosts().get().get(1)); + assertEquals(FullTextQuery.Operator.AND, q.getOperator()); + assertNotNull(q.toString()); + } + + @Test + void testCombinedFieldsQueryEquality() { + FullTextQuery a = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body")); + FullTextQuery b = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body")); + assertEquals(a, b, "Two CombinedFieldsQuery instances with the same fields must be equal"); + assertEquals(a.hashCode(), b.hashCode()); + } + + @Test + void testCombinedFieldsQueryInequalityDifferentOperator() { + FullTextQuery a = + FullTextQuery.combinedFields( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.AND); + FullTextQuery b = + FullTextQuery.combinedFields( + "hello", Arrays.asList("title", "body"), null, FullTextQuery.Operator.OR); + assertFalse( + a.equals(b), "CombinedFieldsQuery instances with different operator must not be equal"); + } + + @Test + void testCombinedFieldsQueryInequalityDifferentBoosts() { + FullTextQuery a = + FullTextQuery.combinedFields( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(2.0f, 1.0f), + FullTextQuery.Operator.OR); + FullTextQuery b = + FullTextQuery.combinedFields( + "hello", + Arrays.asList("title", "body"), + Arrays.asList(1.0f, 1.0f), + FullTextQuery.Operator.OR); + assertFalse( + a.equals(b), "CombinedFieldsQuery instances with different boosts must not be equal"); + } + + @Test + void testCombinedFieldsNotEqualMultiMatch() { + FullTextQuery combined = FullTextQuery.combinedFields("hello", Arrays.asList("title", "body")); + FullTextQuery multi = FullTextQuery.multiMatch("hello", Arrays.asList("title", "body")); + assertFalse( + combined.equals(multi), + "CombinedFieldsQuery and MultiMatchQuery must not be equal even with same fields"); + } + + @Test + void testCombinedFieldsBoostsDefensivelyCopied() { + java.util.List boosts = new java.util.ArrayList<>(Arrays.asList(2.0f, 1.0f)); + FullTextQuery.CombinedFieldsQuery q = + (FullTextQuery.CombinedFieldsQuery) + FullTextQuery.combinedFields( + "hello", Arrays.asList("title", "body"), boosts, FullTextQuery.Operator.OR); + + // Mutating the caller-provided list must not change the stored query. + boosts.set(0, 9.0f); + boosts.clear(); + + assertTrue(q.getBoosts().isPresent()); + assertEquals(Arrays.asList(2.0f, 1.0f), q.getBoosts().get()); + } + + @Test + void testDifferentSubtypesNotEqual() { + FullTextQuery match = FullTextQuery.match("hello", "body"); + FullTextQuery phrase = FullTextQuery.phrase("hello", "body"); + assertFalse( + match.equals(phrase), + "MatchQuery and PhraseQuery must not be equal even with same text/column"); + } } diff --git a/java/src/test/java/org/lance/ipc/LanceScannerFullTextSearchTest.java b/java/src/test/java/org/lance/ipc/LanceScannerFullTextSearchTest.java index 1c46b399195..fb3aafb0659 100755 --- a/java/src/test/java/org/lance/ipc/LanceScannerFullTextSearchTest.java +++ b/java/src/test/java/org/lance/ipc/LanceScannerFullTextSearchTest.java @@ -14,10 +14,12 @@ package org.lance.ipc; import org.lance.Dataset; +import org.lance.DocumentGranularity; import org.lance.WriteParams; import org.lance.index.IndexOptions; import org.lance.index.IndexParams; import org.lance.index.IndexType; +import org.lance.index.scalar.InvertedIndexParams; import org.lance.index.scalar.ScalarIndexParams; import org.apache.arrow.c.ArrowArrayStream; @@ -39,14 +41,63 @@ import java.nio.charset.StandardCharsets; 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; +import static org.junit.jupiter.api.Assertions.assertTrue; class LanceScannerFullTextSearchTest { @Test void testMatchQuery() throws Exception { - runFtsQuery("memory://fts_java_match", FullTextQuery.match("hello", "doc"), 2L); + runFtsQuery( + "memory://fts_java_match", + FullTextQuery.match("hello", "doc", DocumentGranularity.ROW), + 2L); + } + + @Test + void testJiebaTokenizer() throws Exception { + ScalarIndexParams indexParams = + InvertedIndexParams.builder() + .baseTokenizer("jieba/default") + .stem(false) + .removeStopWords(false) + .build(); + + runFtsQuery( + "memory://fts_java_jieba", + FullTextQuery.match("我们", "doc"), + 1L, + Arrays.asList("我们都有光明的�途", "光明的�途"), + indexParams); + } + + @Test + void testCodeAnalyzerBuilderCreatesIndex() throws Exception { + ScalarIndexParams indexParams = InvertedIndexParams.builder().analyzer("code").build(); + + runFtsQuery( + "memory://fts_java_code_analyzer", + FullTextQuery.match("helloWorld", "doc"), + 1L, + Arrays.asList("helloWorld", "other_value"), + indexParams); + } + + @Test + void testExplicitListElementGranularityReachesRustRouting() { + RuntimeException error = + assertThrows( + RuntimeException.class, + () -> + runFtsQuery( + "memory://fts_java_list_element_validation", + FullTextQuery.match("hello", "doc", DocumentGranularity.LIST_ELEMENT), + 0L)); + assertTrue(error.getMessage().contains("requested ListElement"), error.getMessage()); + assertTrue(error.getMessage().contains("'doc_idx' (Row)"), error.getMessage()); } @Test @@ -69,6 +120,48 @@ void testMultiMatch() throws Exception { runFtsQuery("memory://fts_java_multimatch", multiMatch, 3); } + @Test + void testCombinedFields() throws Exception { + // "hello" appears in doc or title of every row, so all 3 match. + FullTextQuery combined = FullTextQuery.combinedFields("hello", Arrays.asList("doc", "title")); + runFtsQuery("memory://fts_java_combined", combined, 3); + } + + @Test + void testCombinedFieldsAndMatchesAcrossColumns() throws Exception { + // Rows 0 and 1 have "hello" only in doc and "bye" only in title. With AND, only a query + // that treats both columns as one field can match them; multiMatch requires every term in a + // single column and finds nothing. + FullTextQuery combined = + FullTextQuery.combinedFields( + "hello bye", + Arrays.asList("doc", "title"), + Arrays.asList(2.0f, 1.0f), + FullTextQuery.Operator.AND); + runFtsQuery("memory://fts_java_combined_and", combined, 2L); + + FullTextQuery multiMatch = + FullTextQuery.multiMatch( + "hello bye", Arrays.asList("doc", "title"), null, FullTextQuery.Operator.AND); + runFtsQuery("memory://fts_java_multimatch_and", multiMatch, 0L); + } + + @Test + void testCombinedFieldsInvalidBoostPropagates() { + // Boost validation lives in the Rust core and must surface across the JNI boundary. + FullTextQuery combined = + FullTextQuery.combinedFields( + "hello", + Arrays.asList("doc", "title"), + Arrays.asList(0.5f, 1.0f), + FullTextQuery.Operator.OR); + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> runFtsQuery("memory://fts_java_combined_bad_boost", combined, 0L)); + assertTrue(ex.getMessage().contains("combined_fields boost for column 'doc'"), ex.getMessage()); + } + @Test void testBooleanQuery() throws Exception { FullTextQuery.MatchQuery shouldMatch = @@ -88,6 +181,25 @@ void testBooleanQuery() throws Exception { } private void runFtsQuery(String uri, FullTextQuery query, long expectedTotal) throws Exception { + ScalarIndexParams indexParams = + ScalarIndexParams.create( + "inverted", + "{\"base_tokenizer\":\"simple\",\"language\":\"English\",\"with_position\":true}"); + runFtsQuery( + uri, + query, + expectedTotal, + Arrays.asList("hello world", "hello lance", "other text"), + indexParams); + } + + private void runFtsQuery( + String uri, + FullTextQuery query, + long expectedTotal, + List documents, + ScalarIndexParams scalarParams) + throws Exception { Schema schema = new Schema( @@ -102,16 +214,13 @@ private void runFtsQuery(String uri, FullTextQuery query, long expectedTotal) th VarCharVector titleVector = (VarCharVector) root.getVector("title"); docVector.allocateNew(); - docVector.setSafe(0, "hello world".getBytes(StandardCharsets.UTF_8)); - docVector.setSafe(1, "hello lance".getBytes(StandardCharsets.UTF_8)); - docVector.setSafe(2, "other text".getBytes(StandardCharsets.UTF_8)); - titleVector.allocateNew(); - titleVector.setSafe(0, "bye world".getBytes(StandardCharsets.UTF_8)); - titleVector.setSafe(1, "bye lance".getBytes(StandardCharsets.UTF_8)); - titleVector.setSafe(2, "say hello".getBytes(StandardCharsets.UTF_8)); - - root.setRowCount(3); + List titles = Arrays.asList("bye world", "bye lance", "say hello"); + for (int i = 0; i < documents.size(); i++) { + docVector.setSafe(i, documents.get(i).getBytes(StandardCharsets.UTF_8)); + titleVector.setSafe(i, titles.get(i).getBytes(StandardCharsets.UTF_8)); + } + root.setRowCount(documents.size()); ByteArrayOutputStream out = new ByteArrayOutputStream(); try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, out)) { @@ -130,10 +239,6 @@ private void runFtsQuery(String uri, FullTextQuery query, long expectedTotal) th new WriteParams.Builder().withMode(WriteParams.WriteMode.CREATE).build(); try (Dataset dataset = Dataset.create(allocator, stream, uri, writeParams)) { - ScalarIndexParams scalarParams = - ScalarIndexParams.create( - "inverted", - "{\"base_tokenizer\":\"simple\",\"language\":\"English\",\"with_position\":true}"); IndexParams indexParams = IndexParams.builder().setScalarIndexParams(scalarParams).build(); diff --git a/java/src/test/java/org/lance/memwal/MemWalTest.java b/java/src/test/java/org/lance/memwal/MemWalTest.java new file mode 100644 index 00000000000..2d2c82e2f71 --- /dev/null +++ b/java/src/test/java/org/lance/memwal/MemWalTest.java @@ -0,0 +1,684 @@ +/* + * 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.memwal; + +import org.lance.Dataset; +import org.lance.index.DistanceType; +import org.lance.index.IndexParams; +import org.lance.index.IndexType; +import org.lance.index.vector.VectorIndexParams; +import org.lance.merge.MergeInsertParams; +import org.lance.merge.MergeInsertResult; + +import org.apache.arrow.c.ArrowArrayStream; +import org.apache.arrow.c.Data; +import org.apache.arrow.memory.BufferAllocator; +import org.apache.arrow.memory.RootAllocator; +import org.apache.arrow.vector.BigIntVector; +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.ipc.ArrowReader; +import org.apache.arrow.vector.ipc.ArrowStreamReader; +import org.apache.arrow.vector.ipc.ArrowStreamWriter; +import org.apache.arrow.vector.types.FloatingPointPrecision; +import org.apache.arrow.vector.types.pojo.ArrowType; +import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; +import org.apache.arrow.vector.types.pojo.Schema; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.nio.channels.Channels; +import java.nio.charset.StandardCharsets; +import java.nio.file.Path; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Integration tests for the MemWAL Java bindings, mirroring python/python/tests/test_mem_wal.py. + */ +public class MemWalTest { + private static final Map PK_META = + Collections.singletonMap("lance-schema:unenforced-primary-key", "true"); + + private static final Schema LOOKUP_SCHEMA = + new Schema( + Arrays.asList( + new Field( + "id", new FieldType(false, new ArrowType.Int(64, true), null, PK_META), null), + Field.nullable("name", new ArrowType.Utf8()))); + private static final Schema APPEND_ONLY_SCHEMA = + new Schema( + Arrays.asList( + new Field( + "id", + new FieldType(false, new ArrowType.Int(64, true), null, Collections.emptyMap()), + null), + Field.nullable("name", new ArrowType.Utf8()))); + + /** Build a single-batch root where {@code name = "{prefix}_{id}"}. */ + private static VectorSchemaRoot lookupRoot(BufferAllocator allocator, long[] ids, String prefix) { + VectorSchemaRoot root = VectorSchemaRoot.create(LOOKUP_SCHEMA, allocator); + BigIntVector idVector = (BigIntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + idVector.allocateNew(ids.length); + nameVector.allocateNew(); + for (int i = 0; i < ids.length; i++) { + idVector.set(i, ids[i]); + nameVector.setSafe(i, (prefix + "_" + ids[i]).getBytes(StandardCharsets.UTF_8)); + } + root.setRowCount(ids.length); + return root; + } + + /** Build a single-batch root carrying only the {@code id} primary key, for deletes. */ + private static VectorSchemaRoot keysRoot(BufferAllocator allocator, long[] ids) { + VectorSchemaRoot root = + VectorSchemaRoot.create( + new Schema( + Collections.singletonList(Field.nullable("id", new ArrowType.Int(64, true)))), + allocator); + BigIntVector idVector = (BigIntVector) root.getVector("id"); + idVector.allocateNew(ids.length); + for (int i = 0; i < ids.length; i++) { + idVector.set(i, ids[i]); + } + root.setRowCount(ids.length); + return root; + } + + /** Build a single-batch append-only root without primary-key metadata. */ + private static VectorSchemaRoot appendOnlyRoot( + BufferAllocator allocator, long[] ids, String prefix) { + VectorSchemaRoot root = VectorSchemaRoot.create(APPEND_ONLY_SCHEMA, allocator); + BigIntVector idVector = (BigIntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + idVector.allocateNew(ids.length); + nameVector.allocateNew(); + for (int i = 0; i < ids.length; i++) { + idVector.set(i, ids[i]); + nameVector.setSafe(i, (prefix + "_" + ids[i]).getBytes(StandardCharsets.UTF_8)); + } + root.setRowCount(ids.length); + return root; + } + + /** Wrap an in-memory root into an {@link ArrowReader} via the Arrow IPC stream format. */ + private static ArrowReader toReader(BufferAllocator allocator, VectorSchemaRoot root) + throws Exception { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (ArrowStreamWriter writer = new ArrowStreamWriter(root, null, Channels.newChannel(out))) { + writer.start(); + writer.writeBatch(); + writer.end(); + } + return new ArrowStreamReader(new ByteArrayInputStream(out.toByteArray()), allocator); + } + + /** Write a base dataset of `(id, name)` rows at {@code path}. */ + private static Dataset writeLookupDataset( + BufferAllocator allocator, String path, long[] ids, String prefix) throws Exception { + try (VectorSchemaRoot root = lookupRoot(allocator, ids, prefix); + ArrowReader reader = toReader(allocator, root)) { + return Dataset.write().allocator(allocator).reader(reader).uri(path).execute(); + } + } + + /** Write an append-only base dataset of `(id, name)` rows at {@code path}. */ + private static Dataset writeAppendOnlyDataset( + BufferAllocator allocator, String path, long[] ids, String prefix) throws Exception { + try (VectorSchemaRoot root = appendOnlyRoot(allocator, ids, prefix); + ArrowReader reader = toReader(allocator, root)) { + return Dataset.write().allocator(allocator).reader(reader).uri(path).execute(); + } + } + + /** + * Stage a faithful SSTable at {@code genPath}: the Lance dataset plus its primary-key + * dedup sidecar ({@code _pk_index/}), mirroring what production flush emits. The LSM scanner's + * cross-generation block-list opens the sidecar, so a dataset alone (no sidecar) is not a state + * production produces. Mirrors the Python {@code _write_sstable} test helper. + */ + private static void writeSsTable( + BufferAllocator allocator, String genPath, long[] ids, String prefix) throws Exception { + writeLookupDataset(allocator, genPath, ids, prefix).close(); + try (VectorSchemaRoot root = lookupRoot(allocator, ids, prefix); + ArrowReader reader = toReader(allocator, root); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + nativeWritePkSidecar(genPath, stream.memoryAddress(), Collections.singletonList("id")); + } + } + + /** + * Test-support native: write the primary-key dedup sidecar for an SSTable dataset already staged + * at {@code genPath}. See {@link #writeSsTable}. + */ + private static native void nativeWritePkSidecar( + String genPath, long streamAddress, List pkColumns); + + /** Read an LSM scanner fully into an {@code id -> name} map. */ + private static Map readByName(ArrowReader reader) throws Exception { + Map byId = new HashMap<>(); + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + BigIntVector idVector = (BigIntVector) root.getVector("id"); + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + for (int i = 0; i < root.getRowCount(); i++) { + byId.put(idVector.get(i), new String(nameVector.get(i), StandardCharsets.UTF_8)); + } + } + return byId; + } + + @Test + void testMemWalIndexDetailsNoneBeforeInit(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1, 2, 3}, "base")) { + assertFalse(dataset.memWalIndexDetails().isPresent()); + } + } + + @Test + void testInitializeMemWalUnsharded(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams().withUnsharded()); + + Optional details = dataset.memWalIndexDetails(); + assertTrue(details.isPresent()); + // Naming no index is how "every index the table has" is expressed, so the + // flag is the only thing separating it from maintaining nothing. + assertEquals(Collections.emptyList(), details.get().maintainedIndexes()); + assertTrue(details.get().maintainAllIndexes()); + } + } + + @Test + void testInitializeMemWalBucketShardingWithoutPrimaryKey(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("append_only").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeAppendOnlyDataset(allocator, path, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams().withBucketSharding("id", 4)); + + Optional details = dataset.memWalIndexDetails(); + assertTrue(details.isPresent()); + assertEquals(4L, details.get().numShards()); + ShardingField field = details.get().shardingSpecs().get(0).fields().get(0); + assertEquals("bucket", field.transform().get()); + assertEquals("4", field.parameters().get("num_buckets")); + } + } + + @Test + void testInitializeMemWalBucketShardingUsesConfiguredColumn(@TempDir Path tempDir) + throws Exception { + String path = tempDir.resolve("base").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams().withBucketSharding("name", 4)); + + MemWalIndexDetails details = dataset.memWalIndexDetails().get(); + ShardingField field = details.shardingSpecs().get(0).fields().get(0); + int nameFieldId = + dataset.getLanceSchema().fields().stream() + .filter(f -> f.getName().equals("name")) + .findFirst() + .get() + .getId(); + assertEquals("bucket", field.transform().get()); + assertEquals(nameFieldId, field.sourceIds().get(0)); + } + } + + @Test + void testShardingEvaluatorBucketAndIdentity(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("append_only").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeAppendOnlyDataset(allocator, path, new long[] {1}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams().withBucketSharding("id", 4)); + ShardingSpec bucketSpec = dataset.memWalIndexDetails().get().shardingSpecs().get(0); + ShardingField bucketField = bucketSpec.fields().get(0); + + try (VectorSchemaRoot root = appendOnlyRoot(allocator, new long[] {1, 2, 3}, "eval"); + ArrowReader reader = + ShardingEvaluator.evaluate(allocator, root, bucketSpec, dataset.getLanceSchema())) { + assertTrue(reader.loadNextBatch()); + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + IntVector buckets = (IntVector) result.getVector(bucketField.fieldId()); + assertEquals(3, result.getRowCount()); + assertEquals(0, buckets.get(0)); + assertEquals(0, buckets.get(1)); + assertEquals(3, buckets.get(2)); + assertFalse(reader.loadNextBatch()); + } + + int nameFieldId = + dataset.getLanceSchema().fields().stream() + .filter(f -> f.getName().equals("name")) + .findFirst() + .get() + .getId(); + ShardingSpec identitySpec = + new ShardingSpec( + 7, + Collections.singletonList( + new ShardingField( + "name_identity", + Collections.singletonList(nameFieldId), + "identity", + null, + "utf8", + Collections.emptyMap()))); + try (VectorSchemaRoot root = appendOnlyRoot(allocator, new long[] {1}, "eval"); + ArrowReader reader = + ShardingEvaluator.evaluate(allocator, root, identitySpec, dataset.getLanceSchema())) { + assertTrue(reader.loadNextBatch()); + VarCharVector names = + (VarCharVector) reader.getVectorSchemaRoot().getVector("name_identity"); + assertEquals("eval_1", new String(names.get(0), StandardCharsets.UTF_8)); + assertFalse(reader.loadNextBatch()); + } + + Map stringBucketParameters = new HashMap<>(); + stringBucketParameters.put("column", "key"); + stringBucketParameters.put("num_buckets", "8"); + ShardingSpec stringBucketSpec = + new ShardingSpec( + 8, + Collections.singletonList( + new ShardingField( + "key_bucket", + Collections.emptyList(), + "bucket", + null, + "int32", + stringBucketParameters))); + Schema stringSchema = + new Schema(Collections.singletonList(Field.nullable("key", new ArrowType.Utf8()))); + try (VectorSchemaRoot root = VectorSchemaRoot.create(stringSchema, allocator)) { + VarCharVector keyVector = (VarCharVector) root.getVector("key"); + keyVector.allocateNew(); + keyVector.setSafe(0, "a".getBytes(StandardCharsets.UTF_8)); + keyVector.setSafe(1, "b".getBytes(StandardCharsets.UTF_8)); + keyVector.setNull(2); + root.setRowCount(3); + try (ArrowReader reader = ShardingEvaluator.evaluate(allocator, root, stringBucketSpec)) { + assertTrue(reader.loadNextBatch()); + IntVector buckets = (IntVector) reader.getVectorSchemaRoot().getVector("key_bucket"); + assertEquals(1, buckets.get(0)); + assertEquals(5, buckets.get(1)); + assertEquals(0, buckets.get(2)); + assertFalse(reader.loadNextBatch()); + } + } + } + } + + @Test + void testInitializeMemWalRejectsConflictingSharding(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1}, "base")) { + InitializeMemWalParams params = + new InitializeMemWalParams().withUnsharded().withBucketSharding("id", 4); + assertThrows(IllegalArgumentException.class, () -> dataset.initializeMemWal(params)); + } + } + + @Test + void testShardWriterPutAndLsmScanner(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + String shardId = UUID.randomUUID().toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {0}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams()); + + ShardWriterConfig config = + new ShardWriterConfig() + .withDurableWrite(true) + .withMaxWalBufferSize(1) + .withMaxWalFlushIntervalMs(10) + .withMaxMemtableBatches(1); + + try (ShardWriter writer = dataset.memWalWriter(shardId, config)) { + assertEquals(shardId, writer.shardId()); + + try (VectorSchemaRoot root = lookupRoot(allocator, new long[] {1, 2}, "writer"); + ArrowReader reader = toReader(allocator, root)) { + writer.put(reader); + } + + Map byId = Collections.emptyMap(); + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + try (LsmScanner scanner = writer.lsmScanner(); + ArrowReader reader = scanner.scanBatches()) { + byId = readByName(reader); + } + if ("writer_1".equals(byId.get(1L)) && "writer_2".equals(byId.get(2L))) { + break; + } + Thread.sleep(50); + } + assertEquals("writer_1", byId.get(1L), "writer.lsmScanner() must see written rows"); + assertEquals("writer_2", byId.get(2L), "writer.lsmScanner() must see written rows"); + + WriteStats stats = writer.stats(); + assertEquals(1, stats.putCount()); + + MemTableStats memtableStats = writer.memtableStats(); + assertTrue(memtableStats.generation() >= 0); + } + } + } + + @Test + void testShardWriterDeleteMasksBaseRow(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + String shardId = UUID.randomUUID().toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams()); + + ShardWriterConfig config = + new ShardWriterConfig() + .withDurableWrite(true) + .withMaxWalBufferSize(1) + .withMaxWalFlushIntervalMs(10); + + try (ShardWriter writer = dataset.memWalWriter(shardId, config)) { + try (VectorSchemaRoot root = lookupRoot(allocator, new long[] {4}, "writer"); + ArrowReader reader = toReader(allocator, root)) { + writer.put(reader); + } + try (VectorSchemaRoot keys = keysRoot(allocator, new long[] {2}); + ArrowReader reader = toReader(allocator, keys)) { + writer.delete(reader); + } + + Map byId = Collections.emptyMap(); + long deadline = System.currentTimeMillis() + 10_000; + while (System.currentTimeMillis() < deadline) { + try (LsmScanner scanner = writer.lsmScanner(); + ArrowReader reader = scanner.scanBatches()) { + byId = readByName(reader); + } + if (!byId.containsKey(2L) && "writer_4".equals(byId.get(4L))) { + break; + } + Thread.sleep(50); + } + + assertEquals("base_1", byId.get(1L)); + assertFalse(byId.containsKey(2L), "deleted base row should be masked by the tombstone"); + assertEquals("base_3", byId.get(3L)); + assertEquals("writer_4", byId.get(4L)); + } + } + } + + @Test + void testLsmScannerFromSnapshots(@TempDir Path tempDir) throws Exception { + String basePath = tempDir.resolve("base").toString(); + String shardId = UUID.randomUUID().toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, basePath, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams()); + + // SSTable overwrites id=2. + String genPath = basePath + "/_mem_wal/" + shardId + "/gen_1"; + writeSsTable(allocator, genPath, new long[] {2}, "gen1"); + + ShardSnapshot snapshot = + new ShardSnapshot(shardId).withSsTable(1, "gen_1").withCurrentGeneration(2); + + try (LsmScanner scanner = + LsmScanner.fromSnapshots(dataset, Collections.singletonList(snapshot)); + ArrowReader reader = scanner.scanBatches()) { + Map byId = readByName(reader); + assertEquals(3, byId.size(), "Expected 3 deduplicated rows"); + assertEquals("base_1", byId.get(1L)); + assertEquals("gen1_2", byId.get(2L), "SSTable must win over base"); + assertEquals("base_3", byId.get(3L)); + } + + try (LsmScanner scanner = + LsmScanner.fromSnapshots(dataset, Collections.singletonList(snapshot)) + .limit(Optional.empty(), Optional.of(1L)); + ArrowReader reader = scanner.scanBatches()) { + Map byId = readByName(reader); + assertEquals(2, byId.size(), "Offset-only LSM scan should not require a limit"); + } + } + } + + @Test + void testPointLookup(@TempDir Path tempDir) throws Exception { + String basePath = tempDir.resolve("base").toString(); + String shardId = UUID.randomUUID().toString(); + try (BufferAllocator allocator = new RootAllocator(); + Dataset dataset = writeLookupDataset(allocator, basePath, new long[] {1, 2, 3}, "base")) { + dataset.initializeMemWal(new InitializeMemWalParams()); + + String genPath = basePath + "/_mem_wal/" + shardId + "/gen_1"; + writeSsTable(allocator, genPath, new long[] {2}, "gen1"); + + ShardSnapshot snapshot = + new ShardSnapshot(shardId).withSsTable(1, "gen_1").withCurrentGeneration(2); + + try (LsmPointLookupPlanner planner = + new LsmPointLookupPlanner(dataset, Collections.singletonList(snapshot))) { + // id=2 must resolve to the SSTable value. + assertEquals("gen1_2", lookup(planner, allocator, 2L)); + // id=1 only exists in the base table. + assertEquals("base_1", lookup(planner, allocator, 1L)); + // id=99 does not exist. + assertEquals(null, lookup(planner, allocator, 99L)); + } + } + } + + /** Run a single-key point lookup and return the resolved name, or {@code null} if absent. */ + private static String lookup(LsmPointLookupPlanner planner, BufferAllocator allocator, long id) + throws Exception { + try (BigIntVector pkVector = new BigIntVector("id", allocator)) { + pkVector.allocateNew(1); + pkVector.set(0, id); + pkVector.setValueCount(1); + try (ExecutionPlan plan = planner.planLookup(pkVector); + ArrowReader reader = plan.toReader()) { + Map byId = readByName(reader); + return byId.get(id); + } + } + } + + @Test + void testMergeInsertMarkSstablesAsCompacted(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("base").toString(); + String shardId = UUID.randomUUID().toString(); + try (BufferAllocator allocator = new RootAllocator()) { + Dataset dataset = writeLookupDataset(allocator, path, new long[] {1, 2, 3}, "base"); + try { + dataset.initializeMemWal(new InitializeMemWalParams()); + + MergeInsertParams params = + new MergeInsertParams(Collections.singletonList("id")) + .withMatchedUpdateAll() + .withNotMatched(MergeInsertParams.WhenNotMatched.InsertAll) + .markSstablesAsCompacted( + Collections.singletonList(new CompactedSsTable(shardId, 1))); + + try (VectorSchemaRoot root = lookupRoot(allocator, new long[] {2, 4}, "merged"); + ArrowReader reader = toReader(allocator, root); + ArrowArrayStream stream = ArrowArrayStream.allocateNew(allocator)) { + Data.exportArrayStream(allocator, reader, stream); + MergeInsertResult result = dataset.mergeInsert(params, stream); + Dataset merged = result.dataset(); + try { + assertEquals(4, merged.countRows(), "merge insert should yield 4 rows"); + } finally { + merged.close(); + } + } + } finally { + dataset.close(); + } + } + } + + private static final int VDIM = 32; + + private static Schema vectorSchema() { + Field idField = + new Field("id", new FieldType(false, new ArrowType.Int(32, true), null, PK_META), null); + Field vecField = + new Field( + "vec", + FieldType.nullable(new ArrowType.FixedSizeList(VDIM)), + Collections.singletonList( + new Field( + "item", + FieldType.nullable(new ArrowType.FloatingPoint(FloatingPointPrecision.SINGLE)), + null))); + return new Schema(Arrays.asList(idField, vecField)); + } + + @Test + void testVectorSearch(@TempDir Path tempDir) throws Exception { + String path = tempDir.resolve("vec").toString(); + int rows = 400; + try (BufferAllocator allocator = new RootAllocator()) { + Schema schema = vectorSchema(); + Dataset dataset; + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + IntVector idVector = (IntVector) root.getVector("id"); + FixedSizeListVector vecVector = (FixedSizeListVector) root.getVector("vec"); + Float4Vector items = (Float4Vector) vecVector.getDataVector(); + idVector.allocateNew(rows); + vecVector.allocateNew(); + for (int i = 0; i < rows; i++) { + idVector.set(i, i); + vecVector.setNotNull(i); + for (int d = 0; d < VDIM; d++) { + items.setSafe(i * VDIM + d, (float) (i * VDIM + d)); + } + } + root.setRowCount(rows); + try (ArrowReader reader = toReader(allocator, root)) { + dataset = Dataset.write().allocator(allocator).reader(reader).uri(path).execute(); + } + } + + try { + IndexParams params = + IndexParams.builder() + .setVectorIndexParams(VectorIndexParams.ivfPq(2, 8, 2, DistanceType.L2, 2)) + .build(); + dataset.createIndex( + Collections.singletonList("vec"), + IndexType.VECTOR, + Optional.of("vec_idx"), + params, + true); + dataset.initializeMemWal( + new InitializeMemWalParams() + .withMaintainedIndexes(Collections.singletonList("vec_idx"))); + + try (LsmVectorSearchPlanner planner = + new LsmVectorSearchPlanner(dataset, Collections.emptyList(), "vec")) { + int[] queryIds = {10, 50, 100, 200, 300, 350}; + int found = 0; + for (int queryId : queryIds) { + try (Float4Vector query = new Float4Vector("q", allocator)) { + query.allocateNew(VDIM); + for (int d = 0; d < VDIM; d++) { + query.set(d, (float) (queryId * VDIM + d)); + } + query.setValueCount(VDIM); + try (ExecutionPlan plan = planner.planSearch(query, 10); + ArrowReader reader = plan.toReader()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + IntVector ids = (IntVector) result.getVector("id"); + for (int i = 0; i < result.getRowCount(); i++) { + if (ids.get(i) == queryId) { + found++; + } + } + } + } + } + } + double recall = (double) found / queryIds.length; + assertTrue(recall >= 0.5, "vector search recall too low: " + recall); + } + + try (LsmVectorSearchPlanner planner = + new LsmVectorSearchPlanner( + dataset, Collections.emptyList(), "vec", null, null, "id >= 200"); + Float4Vector query = new Float4Vector("q", allocator)) { + int filteredQueryId = 250; + query.allocateNew(VDIM); + for (int d = 0; d < VDIM; d++) { + query.set(d, (float) (filteredQueryId * VDIM + d)); + } + query.setValueCount(VDIM); + + int filteredRows = 0; + boolean foundFilteredNearest = false; + try (ExecutionPlan plan = + planner.planSearch(query, 20, 2, Collections.singletonList("id"), false); + ArrowReader reader = plan.toReader()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot result = reader.getVectorSchemaRoot(); + IntVector ids = (IntVector) result.getVector("id"); + for (int i = 0; i < result.getRowCount(); i++) { + int id = ids.get(i); + assertTrue(id >= 200, "filtered vector search returned id " + id); + if (id == filteredQueryId) { + foundFilteredNearest = true; + } + filteredRows++; + } + } + } + assertTrue(filteredRows > 0, "filtered vector search should return rows"); + assertTrue( + foundFilteredNearest, "filtered vector search recall missed id " + filteredQueryId); + } + } finally { + dataset.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..1d353756695 --- /dev/null +++ b/java/src/test/java/org/lance/namespace/CustomNamespace.java @@ -0,0 +1,422 @@ +/* + * 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.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.CountTableRowsResponse; +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.NamespaceExistsResponse; +import org.lance.namespace.model.QueryTableRequest; +import org.lance.namespace.model.QueryTableResponse; +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.TableExistsResponse; +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; + +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 NamespaceExistsResponse namespaceExists(NamespaceExistsRequest request) { + return 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 TableExistsResponse tableExists(TableExistsRequest request) { + return 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 CountTableRowsResponse 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 + 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 QueryTableResponse 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 7d6c4741ad8..107205e88b3 100644 --- a/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java +++ b/java/src/test/java/org/lance/namespace/DirectoryNamespaceTest.java @@ -13,13 +13,61 @@ */ package org.lance.namespace; -import org.lance.namespace.model.*; +import org.lance.CommitBuilder; +import org.lance.Dataset; +import org.lance.Fragment; +import org.lance.FragmentMetadata; +import org.lance.ReadOptions; +import org.lance.Ref; +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.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; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.ipc.ArrowStreamWriter; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; @@ -32,9 +80,17 @@ import java.io.ByteArrayOutputStream; import java.nio.file.Path; +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.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import static org.junit.jupiter.api.Assertions.*; @@ -42,23 +98,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(); @@ -111,21 +184,50 @@ 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 testOpenSpecificVersionDoesNotPassVersionToDescribeTable() throws Exception { + VersionRejectingNamespace versionRejectingNamespace = + new VersionRejectingNamespace(innerNamespaceClient); + namespaceClient = versionRejectingNamespace; + List tableId = Arrays.asList("test_table"); + + namespaceClient.createTable(new CreateTableRequest().id(tableId), createTestTableData()); + namespaceClient.insertIntoTable( + new InsertIntoTableRequest().id(tableId).mode("append"), createTestTableData()); + + try (Dataset versionOne = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .readOptions(new ReadOptions.Builder().setVersion(1L).build()) + .build()) { + assertEquals(1, versionOne.version()); + assertEquals(3, versionOne.countRows()); + } + + assertTrue( + versionRejectingNamespace.getDescribeTableCallCount() > 0, + "Expected describeTable to be called when opening through namespace"); } @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")); @@ -135,12 +237,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()); } @@ -149,32 +251,35 @@ 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")); - assertThrows(RuntimeException.class, () -> namespace.namespaceExists(notExistsReq)); + LanceNamespaceException ex = + assertThrows( + LanceNamespaceException.class, () -> namespaceClient.namespaceExists(notExistsReq)); + assertEquals(ErrorCode.NAMESPACE_NOT_FOUND, ex.getErrorCode()); } @Test 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(RuntimeException.class, () -> namespace.namespaceExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.namespaceExists(existsReq)); } @Test @@ -182,13 +287,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()); @@ -201,17 +306,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()); @@ -223,18 +328,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()); @@ -246,23 +351,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(RuntimeException.class, () -> namespace.tableExists(notExistsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(notExistsReq)); } @Test @@ -270,39 +375,1156 @@ 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(RuntimeException.class, () -> namespace.tableExists(existsReq)); + assertThrows(LanceNamespaceException.class, () -> namespaceClient.tableExists(existsReq)); } @Test - void testCreateEmptyTable() { + void testDescribeTableReturnsManagedVersioningWhenTrackingEnabled() throws Exception { + // Create namespace with table_version_tracking_enabled and manifest_enabled + DirectoryNamespace trackingNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + config.put("table_version_tracking_enabled", "true"); + config.put("manifest_enabled", "true"); + trackingNs.initialize(config, allocator); + + try { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + trackingNs.createNamespace(createNsReq); + + // Create a table + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + trackingNs.createTable(createReq, tableData); + + // Describe table should return managedVersioning=true + DescribeTableRequest descReq = + new DescribeTableRequest().id(Arrays.asList("workspace", "test_table")); + DescribeTableResponse descResp = trackingNs.describeTable(descReq); + + assertNotNull(descResp); + assertNotNull(descResp.getLocation()); + assertTrue( + Boolean.TRUE.equals(descResp.getManagedVersioning()), + "Expected managedVersioning=true, got " + descResp.getManagedVersioning()); + } finally { + trackingNs.close(); + } + } + + @Test + void testDescribeTableVersion() throws Exception { + // Use multi-level table ID with manifest_enabled + DirectoryNamespace trackingNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + config.put("manifest_enabled", "true"); + trackingNs.initialize(config, allocator); + + try { + // Create parent namespace + CreateNamespaceRequest createNsReq = + new CreateNamespaceRequest().id(Arrays.asList("workspace")); + trackingNs.createNamespace(createNsReq); + + // Create a table with multi-level ID + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + trackingNs.createTable(createReq, tableData); + + // Describe table version + DescribeTableVersionRequest descReq = + new DescribeTableVersionRequest() + .id(Arrays.asList("workspace", "test_table")) + .version(1L); + DescribeTableVersionResponse descResp = trackingNs.describeTableVersion(descReq); + + assertNotNull(descResp); + assertNotNull(descResp.getVersion()); + assertEquals(Long.valueOf(1), descResp.getVersion().getVersion()); + assertNotNull(descResp.getVersion().getManifestPath()); + } finally { + trackingNs.close(); + } + } + + /** + * 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 + */ + 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; + } + + private static int getCreateTableVersionCount(DirectoryNamespace ns) { + Map metrics = ns.retrieveOpsMetrics(); + return metrics.getOrDefault("create_table_version", 0L).intValue(); + } + + private static int getDescribeTableVersionCount(DirectoryNamespace ns) { + Map metrics = ns.retrieveOpsMetrics(); + return metrics.getOrDefault("describe_table_version", 0L).intValue(); + } + + 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 and ops_metrics_enabled + DirectoryNamespace namespaceClient = + createManagedVersioningNamespace(managedVersioningTempDir); + String tableName = "test_table"; + java.util.List tableId = Arrays.asList(tableName); + + // Create schema and data + Schema schema = + new Schema( + Arrays.asList( + new Field("a", FieldType.nullable(new ArrowType.Int(32, true)), null), + new Field("b", FieldType.nullable(new ArrowType.Int(32, true)), null))); + + try (VectorSchemaRoot root = VectorSchemaRoot.create(schema, allocator)) { + IntVector aVector = (IntVector) root.getVector("a"); + IntVector bVector = (IntVector) root.getVector("b"); + + aVector.allocateNew(2); + bVector.allocateNew(2); + + aVector.set(0, 1); + bVector.set(0, 2); + aVector.set(1, 10); + bVector.set(1, 20); + + aVector.setValueCount(2); + bVector.setValueCount(2); + root.setRowCount(2); + + ArrowReader testReader = + 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; + } + }; + + // Create dataset through namespace + try (Dataset dataset = + Dataset.write() + .allocator(allocator) + .reader(testReader) + .namespaceClient(namespaceClient) + .tableId(tableId) + .mode(WriteParams.WriteMode.CREATE) + .execute()) { + assertEquals(2, dataset.countRows()); + assertEquals(1, dataset.version()); + } + } + + // Verify describe_table returns managed_versioning=true + DescribeTableRequest descReq = new DescribeTableRequest(); + descReq.setId(tableId); + DescribeTableResponse descResp = namespaceClient.describeTable(descReq); + + assertEquals( + Boolean.TRUE, + descResp.getManagedVersioning(), + "Expected managedVersioning=true when table_version_tracking_enabled"); + + // Open dataset through namespace - this should call list_table_versions for latest + int initialListCount = getListTableVersionsCount(namespaceClient); + try (Dataset dsFromNamespace = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { + + assertEquals(2, dsFromNamespace.countRows()); + assertEquals(1, dsFromNamespace.version()); + } + assertEquals( + initialListCount + 1, + 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, + getCreateTableVersionCount(namespaceClient), + "create_table_version should have been called once during CREATE"); + + try (VectorSchemaRoot appendRoot = VectorSchemaRoot.create(schema, allocator)) { + IntVector aVector = (IntVector) appendRoot.getVector("a"); + IntVector bVector = (IntVector) appendRoot.getVector("b"); + + aVector.allocateNew(2); + bVector.allocateNew(2); + + aVector.set(0, 100); + bVector.set(0, 200); + aVector.set(1, 1000); + bVector.set(1, 2000); + + aVector.setValueCount(2); + bVector.setValueCount(2); + appendRoot.setRowCount(2); + + ArrowReader appendReader = + 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 appendRoot; + } + }; + + // Append through namespace + try (Dataset dataset = + Dataset.write() + .allocator(allocator) + .reader(appendReader) + .namespaceClient(namespaceClient) + .tableId(tableId) + .mode(WriteParams.WriteMode.APPEND) + .execute()) { + assertEquals(4, dataset.countRows()); + assertEquals(2, dataset.version()); + } + } + + assertEquals( + 2, + 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 = getListTableVersionsCount(namespaceClient); + try (Dataset latestDs = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { + + assertEquals(4, latestDs.countRows()); + assertEquals(2, latestDs.version()); + } + assertEquals( + listCountBeforeLatest + 1, + 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 = getDescribeTableVersionCount(namespaceClient); + try (Dataset v1Ds = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .readOptions(new ReadOptions.Builder().setVersion(1L).build()) + .build()) { + + assertEquals(2, v1Ds.countRows()); + assertEquals(1, v1Ds.version()); + } + assertEquals( + describeCountBeforeV1 + 1, + getDescribeTableVersionCount(namespaceClient), + "describe_table_version should have been called once when opening version 1"); + + // Fork dev at main:1 and move it past main's latest version (dev gets 2 and 3), tag its + // head, then open refs through the namespace: the ref must survive the namespace open and + // resolve on the branch, including a branch version main does not have. + try (Dataset latestDs = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { + try (Dataset devDs = latestDs.createBranch("dev", Ref.ofMain(1))) { + devDs.updateConfig(Collections.singletonMap("step", "1")); + devDs.updateConfig(Collections.singletonMap("step", "2")); + assertEquals(3, devDs.version()); + } + latestDs.tags().create("first", Ref.ofMain(1)); + latestDs.tags().create("dev_head", Ref.ofBranch("dev", 3)); + } + Object[][] refCases = { + {Ref.ofBranch("dev"), 3L, true}, + {Ref.ofBranch("dev", 3), 3L, true}, + {Ref.ofTag("dev_head"), 3L, true}, + {Ref.ofTag("first"), 1L, false}, + }; + for (Object[] refCase : refCases) { + Ref ref = (Ref) refCase[0]; + try (Dataset refDs = + Dataset.open() + .allocator(allocator) + .namespaceClient(namespaceClient) + .tableId(tableId) + .readOptions(new ReadOptions.Builder().setRef(ref).build()) + .build()) { + assertEquals((long) refCase[1], refDs.version(), ref.toString()); + assertEquals(2, refDs.countRows(), ref.toString()); // main's latest has 4 rows + assertEquals(refCase[2], refDs.uri().contains("tree/dev"), refDs.uri()); + } + } + + namespaceClient.close(); + } + } + + @Test + void testDatasetBasedCommitBuilderWithNamespace(@TempDir Path managedVersioningTempDir) + throws Exception { + try (BufferAllocator allocator = new RootAllocator()) { + DirectoryNamespace namespaceClient = + createManagedVersioningNamespace(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) + .namespaceClient(namespaceClient) + .tableId(tableId) + .mode(WriteParams.WriteMode.CREATE) + .execute()) { + assertEquals(2, dataset.countRows()); + assertEquals(1, dataset.version()); + } + } + + // Verify initial create used createTableVersion once + assertEquals( + 1, + 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) + .namespaceClient(namespaceClient) + .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 = getCreateTableVersionCount(namespaceClient); + try (Transaction txn = + new Transaction.Builder() + .readVersion(existingDataset.version()) + .operation(Append.builder().fragments(fragments).build()) + .build(); + Dataset committed = + new CommitBuilder(existingDataset) + .namespaceClient(namespaceClient) + .tableId(tableId) + .execute(txn)) { + assertEquals(2, committed.version()); + assertEquals(4, committed.countRows()); + } + + // Verify createTableVersion was called for the dataset-based commit + assertEquals( + createCountBefore + 1, + 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) + .namespaceClient(namespaceClient) + .tableId(tableId) + .build()) { + assertEquals(4, latestDs.countRows()); + assertEquals(2, latestDs.version()); + } + + existingDataset.close(); + namespaceClient.close(); + } + } + + @Test + 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")); + namespaceClient.createNamespace(createNsReq); + + int numTables = 10; + ExecutorService executor = Executors.newFixedThreadPool(numTables); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numTables); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + executor.submit( + () -> { + try { + startLatch.await(); + + String tableName = "concurrent_table_" + tableIndex; + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("test_ns", tableName)); + namespaceClient.createTable(createReq, tableData); + + DropTableRequest dropReq = + new DropTableRequest().id(Arrays.asList("test_ns", tableName)); + namespaceClient.dropTable(dropReq); + + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(60, TimeUnit.SECONDS), "Timed out waiting for tasks to complete"); + + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + + assertEquals(numTables, successCount.get(), "All tasks should succeed"); + assertEquals(0, failCount.get(), "No tasks should fail"); + + ListTablesRequest listReq = new ListTablesRequest().id(Arrays.asList("test_ns")); + ListTablesResponse listResp = namespaceClient.listTables(listReq); + assertEquals(0, listResp.getTables().size(), "All tables should be dropped"); + } + + @Test + void testConcurrentCreateAndDropWithMultipleInstances() throws Exception { + // Initialize namespace first with a single instance to ensure __manifest + // table is created and parent namespace exists before concurrent operations + DirectoryNamespace initNs = new DirectoryNamespace(); + Map initConfig = new HashMap<>(); + initConfig.put("root", tempDir.toString()); + initConfig.put("inline_optimization_enabled", "false"); + initNs.initialize(initConfig, allocator); + + CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); + initNs.createNamespace(createNsReq); + initNs.close(); + + int numTables = 10; + ExecutorService executor = Executors.newFixedThreadPool(numTables); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(numTables); + AtomicInteger successCount = new AtomicInteger(0); + AtomicInteger failCount = new AtomicInteger(0); + List namespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + executor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + startLatch.await(); + + localNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + config.put("inline_optimization_enabled", "false"); + localNs.initialize(config, allocator); + + synchronized (namespaces) { + namespaces.add(localNs); + } + + String tableName = "multi_ns_table_" + tableIndex; + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("test_ns", tableName)); + localNs.createTable(createReq, tableData); + + DropTableRequest dropReq = + new DropTableRequest().id(Arrays.asList("test_ns", tableName)); + localNs.dropTable(dropReq); + + successCount.incrementAndGet(); + } catch (Exception e) { + failCount.incrementAndGet(); + } finally { + doneLatch.countDown(); + } + }); + } + + startLatch.countDown(); + assertTrue(doneLatch.await(60, TimeUnit.SECONDS), "Timed out waiting for tasks to complete"); + + executor.shutdown(); + assertTrue(executor.awaitTermination(10, TimeUnit.SECONDS)); + + // Close all namespace instances + for (DirectoryNamespace ns : namespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + assertEquals(numTables, successCount.get(), "All tasks should succeed"); + assertEquals(0, failCount.get(), "No tasks should fail"); + + // Verify with a fresh namespace + DirectoryNamespace verifyNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + verifyNs.initialize(config, allocator); + + ListTablesRequest listReq = new ListTablesRequest().id(Arrays.asList("test_ns")); + ListTablesResponse listResp = verifyNs.listTables(listReq); + assertEquals(0, listResp.getTables().size(), "All tables should be dropped"); + + verifyNs.close(); + } + + @Test + void testConcurrentCreateThenDropFromDifferentInstance() throws Exception { + // Initialize namespace first with a single instance to ensure __manifest + // table is created and parent namespace exists before concurrent operations + DirectoryNamespace initNs = new DirectoryNamespace(); + Map initConfig = new HashMap<>(); + initConfig.put("root", tempDir.toString()); + initConfig.put("inline_optimization_enabled", "false"); + initNs.initialize(initConfig, allocator); + + CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("test_ns")); + initNs.createNamespace(createNsReq); + initNs.close(); + + int numTables = 10; + + // First, create all tables using separate namespace instances + ExecutorService createExecutor = Executors.newFixedThreadPool(numTables); + CountDownLatch createStartLatch = new CountDownLatch(1); + CountDownLatch createDoneLatch = new CountDownLatch(numTables); + AtomicInteger createSuccessCount = new AtomicInteger(0); + List createNamespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + createExecutor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + createStartLatch.await(); + + localNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + config.put("inline_optimization_enabled", "false"); + localNs.initialize(config, allocator); + + synchronized (createNamespaces) { + createNamespaces.add(localNs); + } + + String tableName = "cross_instance_table_" + tableIndex; + byte[] tableData = createTestTableData(); + + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("test_ns", tableName)); + localNs.createTable(createReq, tableData); + + createSuccessCount.incrementAndGet(); + } catch (Exception e) { + // Ignore - test will fail on assertion + } finally { + createDoneLatch.countDown(); + } + }); + } + + createStartLatch.countDown(); + assertTrue(createDoneLatch.await(60, TimeUnit.SECONDS), "Timed out waiting for creates"); + createExecutor.shutdown(); + + assertEquals(numTables, createSuccessCount.get(), "All creates should succeed"); + + // Close create namespaces + for (DirectoryNamespace ns : createNamespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + // Now drop all tables using NEW namespace instances + ExecutorService dropExecutor = Executors.newFixedThreadPool(numTables); + CountDownLatch dropStartLatch = new CountDownLatch(1); + CountDownLatch dropDoneLatch = new CountDownLatch(numTables); + AtomicInteger dropSuccessCount = new AtomicInteger(0); + AtomicInteger dropFailCount = new AtomicInteger(0); + List dropNamespaces = new ArrayList<>(); + + for (int i = 0; i < numTables; i++) { + final int tableIndex = i; + dropExecutor.submit( + () -> { + DirectoryNamespace localNs = null; + try { + dropStartLatch.await(); + + localNs = new DirectoryNamespace(); + Map config = new HashMap<>(); + config.put("root", tempDir.toString()); + config.put("inline_optimization_enabled", "false"); + localNs.initialize(config, allocator); + + synchronized (dropNamespaces) { + dropNamespaces.add(localNs); + } + + String tableName = "cross_instance_table_" + tableIndex; + + DropTableRequest dropReq = + new DropTableRequest().id(Arrays.asList("test_ns", tableName)); + localNs.dropTable(dropReq); + + dropSuccessCount.incrementAndGet(); + } catch (Exception e) { + dropFailCount.incrementAndGet(); + } finally { + dropDoneLatch.countDown(); + } + }); + } + + dropStartLatch.countDown(); + assertTrue(dropDoneLatch.await(60, TimeUnit.SECONDS), "Timed out waiting for drops"); + dropExecutor.shutdown(); + + // Close drop namespaces + for (DirectoryNamespace ns : dropNamespaces) { + try { + ns.close(); + } catch (Exception e) { + // Ignore + } + } + + 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")); - namespace.createNamespace(createNsReq); + namespaceClient.createNamespace(createNsReq); - // Create empty table (metadata-only operation) - CreateEmptyTableRequest createReq = - new CreateEmptyTableRequest().id(Arrays.asList("workspace", "empty_table")); + // Create a table with 3 rows + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespaceClient.createTable(createReq, tableData); - CreateEmptyTableResponse createResp = namespace.createEmptyTable(createReq); + // Count rows + CountTableRowsRequest countReq = + new CountTableRowsRequest().id(Arrays.asList("workspace", "test_table")); + long count = namespaceClient.countTableRows(countReq).getCount(); + assertEquals(3, count); + } - assertNotNull(createResp); - assertNotNull(createResp.getLocation()); + @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).getCount(); + 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).getCount(); + 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).getData(); + 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()); + + DescribeTableRequest descReq = + new DescribeTableRequest().id(Arrays.asList("workspace", "declared_table")); + DescribeTableResponse descResp = namespaceClient.describeTable(descReq); + assertNull(descResp.getIsOnlyDeclared()); + + DescribeTableRequest checkReq = + new DescribeTableRequest() + .id(Arrays.asList("workspace", "declared_table")) + .checkDeclared(true); + DescribeTableResponse checkResp = namespaceClient.describeTable(checkReq); + assertEquals(Boolean.TRUE, checkResp.getIsOnlyDeclared()); + } + + @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(); + } + } + + private static class VersionRejectingNamespace extends CustomNamespace { + private final AtomicInteger describeTableCallCount = new AtomicInteger(); + + VersionRejectingNamespace(DirectoryNamespace inner) { + super(inner); + } + + @Override + public DescribeTableResponse describeTable(DescribeTableRequest request) { + describeTableCallCount.incrementAndGet(); + assertNull( + request.getVersion(), + "Dataset version should be passed to dataset open, not describeTable"); + return super.describeTable(request); + } + + int getDescribeTableCallCount() { + return describeTableCallCount.get(); + } } } 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 797ef5d6785..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; @@ -310,19 +327,38 @@ void testDropTable() throws Exception { } @Test - void testCreateEmptyTable() { + void testRenameTable() throws Exception { // Create parent namespace CreateNamespaceRequest createNsReq = new CreateNamespaceRequest().id(Arrays.asList("workspace")); namespace.createNamespace(createNsReq); - // Create empty table (metadata-only operation) - CreateEmptyTableRequest createReq = - new CreateEmptyTableRequest().id(Arrays.asList("workspace", "empty_table")); + // Create a table + byte[] tableData = createTestTableData(); + CreateTableRequest createReq = + new CreateTableRequest().id(Arrays.asList("workspace", "test_table")); + namespace.createTable(createReq, tableData); - CreateEmptyTableResponse createResp = namespace.createEmptyTable(createReq); + // TODO: underlying dir namespace doesn't support rename yet... - assertNotNull(createResp); - assertNotNull(createResp.getLocation()); + // // Rename the table + // RenameTableRequest renameReq = + // new RenameTableRequest() + // .id(Arrays.asList("workspace", "test_table")) + // .newNamespaceId(Arrays.asList("workspace")) + // .newTableName("test_table_renamed"); + + // RenameTableResponse renameRes = namespace.renameTable(renameReq); + // assertNotNull(renameRes); + + // // Verify table with old name no longer exists + // TableExistsRequest oldExistsReq = + // new TableExistsRequest().id(Arrays.asList("workspace", "test_table")); + // assertThrows(RuntimeException.class, () -> namespace.tableExists(oldExistsReq)); + + // // Verify table with new name exists + // TableExistsRequest existsReq = + // new TableExistsRequest().id(Arrays.asList("workspace", "test_table_renamed")); + // assertDoesNotThrow(() -> namespace.tableExists(existsReq)); } } diff --git a/java/src/test/java/org/lance/operation/AppendTest.java b/java/src/test/java/org/lance/operation/AppendTest.java index 2575d3afd00..5d62b429fe1 100644 --- a/java/src/test/java/org/lance/operation/AppendTest.java +++ b/java/src/test/java/org/lance/operation/AppendTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -62,17 +63,16 @@ void testAppendMultipleFragments(@TempDir Path tempDir) { testDataset.createNewFragment(rowCount), testDataset.createNewFragment(rowCount)); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(Append.builder().fragments(fragments).build()) - .build(); - - try (Dataset dataset = transaction.commit()) { - assertEquals(2, dataset.version()); - assertEquals(rowCount * 3, dataset.countRows()); - assertEquals(3, dataset.getFragments().size()); - assertEquals(transaction, dataset.readTransaction().orElse(null)); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(txn)) { + assertEquals(2, dataset.version()); + assertEquals(rowCount * 3, dataset.countRows()); + assertEquals(3, dataset.getFragments().size()); + } } } } @@ -88,12 +88,13 @@ void testAppendEmptyFragmentList(@TempDir Path tempDir) { assertThrows( IllegalArgumentException.class, () -> { - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(Append.builder().fragments(new ArrayList<>()).build()) - .build(); - transaction.commit().close(); + .build()) { + new CommitBuilder(dataset).execute(txn).close(); + } }); } } diff --git a/java/src/test/java/org/lance/operation/DataReplacementTest.java b/java/src/test/java/org/lance/operation/DataReplacementTest.java index 6d0586cc2b1..07ff06a6f94 100644 --- a/java/src/test/java/org/lance/operation/DataReplacementTest.java +++ b/java/src/test/java/org/lance/operation/DataReplacementTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.Fragment; import org.lance.FragmentMetadata; @@ -71,83 +72,93 @@ void testDataReplacement(@TempDir Path tempDir) throws Exception { List fragmentMetas = Fragment.create(datasetPath, allocator, idRoot, new WriteParams.Builder().build()); - Transaction appendTxn = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(Append.builder().fragments(fragmentMetas).build()) - .build(); - - try (Dataset initDataset = appendTxn.commit()) { - assertEquals(2, initDataset.version()); - assertEquals(rowCount, initDataset.countRows()); - - // step 3. use dataset.addColumn to add a new column named as address with all null values - Field addressField = Field.nullable("address", new ArrowType.Utf8()); - Schema addressSchema = new Schema(Collections.singletonList(addressField), null); - initDataset.addColumns(addressSchema); - - try (LanceScanner scanner = initDataset.newScan()) { - try (ArrowReader resultReader = scanner.scanBatches()) { - assertTrue(resultReader.loadNextBatch()); - VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); - assertEquals(rowCount, initDataset.countRows()); - assertEquals(rowCount, batch.getRowCount()); - - // verify all null values - VarCharVector resultNameVector = (VarCharVector) batch.getVector("address"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertTrue(resultNameVector.isNull(i)); + .build()) { + try (Dataset initDataset = new CommitBuilder(dataset).execute(appendTxn)) { + assertEquals(2, initDataset.version()); + assertEquals(rowCount, initDataset.countRows()); + + // step 3. use dataset.addColumn to add a new column named as address with all null + // values + Field addressField = Field.nullable("address", new ArrowType.Utf8()); + Schema addressSchema = new Schema(Collections.singletonList(addressField), null); + initDataset.addColumns(addressSchema); + + try (LanceScanner scanner = initDataset.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + assertEquals(rowCount, initDataset.countRows()); + assertEquals(rowCount, batch.getRowCount()); + + // verify all null values + VarCharVector resultNameVector = (VarCharVector) batch.getVector("address"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertTrue(resultNameVector.isNull(i)); + } } } - } - // step 4. use DataReplacement transaction to replace null values - try (VectorSchemaRoot replaceVectorRoot = - VectorSchemaRoot.create(addressSchema, allocator)) { - replaceVectorRoot.allocateNew(); - VarCharVector addressVector = (VarCharVector) replaceVectorRoot.getVector("address"); + // step 4. use DataReplacement transaction to replace null values + try (VectorSchemaRoot replaceVectorRoot = + VectorSchemaRoot.create(addressSchema, allocator)) { + replaceVectorRoot.allocateNew(); + VarCharVector addressVector = (VarCharVector) replaceVectorRoot.getVector("address"); - for (int i = 0; i < rowCount; i++) { - String name = "District " + i; - addressVector.setSafe(i, name.getBytes(StandardCharsets.UTF_8)); - } - replaceVectorRoot.setRowCount(rowCount); - - DataFile datafile = - writeLanceDataFile( - dataset.allocator(), - datasetPath, - replaceVectorRoot, - new int[] {2}, - new int[] {0}); - List replacementGroups = - Collections.singletonList( - new DataReplacement.DataReplacementGroup( - fragmentMetas.get(0).getId(), datafile)); - Transaction replaceTxn = - initDataset - .newTransactionBuilder() - .operation(DataReplacement.builder().replacements(replacementGroups).build()) - .build(); - - try (Dataset datasetWithAddress = replaceTxn.commit()) { - assertEquals(4, datasetWithAddress.version()); - assertEquals(rowCount, datasetWithAddress.countRows()); - - try (LanceScanner scanner = datasetWithAddress.newScan()) { - try (ArrowReader resultReader = scanner.scanBatches()) { - assertTrue(resultReader.loadNextBatch()); - VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + for (int i = 0; i < rowCount; i++) { + String name = "District " + i; + addressVector.setSafe(i, name.getBytes(StandardCharsets.UTF_8)); + } + replaceVectorRoot.setRowCount(rowCount); + + DataFile datafile = + writeLanceDataFile( + dataset.allocator(), + datasetPath, + replaceVectorRoot, + new int[] {2}, + new int[] {0}); + List replacementGroups = + Collections.singletonList( + new DataReplacement.DataReplacementGroup( + fragmentMetas.get(0).getId(), datafile)); + try (Transaction replaceTxn = + new Transaction.Builder() + .readVersion(initDataset.version()) + .operation(DataReplacement.builder().replacements(replacementGroups).build()) + .build()) { + try (Dataset datasetWithAddress = + new CommitBuilder(initDataset).execute(replaceTxn)) { + assertEquals(4, datasetWithAddress.version()); assertEquals(rowCount, datasetWithAddress.countRows()); - assertEquals(rowCount, batch.getRowCount()); - - // verify all address values not null - VarCharVector resultNameVector = (VarCharVector) batch.getVector("address"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertFalse(resultNameVector.isNull(i)); - String expectedName = "District " + i; - String actualName = new String(resultNameVector.get(i), StandardCharsets.UTF_8); - assertEquals(expectedName, actualName); + FragmentMetadata fragmentWithOverlay = + datasetWithAddress.getFragments().get(0).metadata(); + List referencedFiles = fragmentWithOverlay.getReferencedLanceFiles(); + assertEquals(2, referencedFiles.size()); + assertEquals(fragmentMetas.get(0).getFiles().get(0), referencedFiles.get(0)); + assertEquals(datafile.getPath(), referencedFiles.get(1).getPath()); + assertEquals(datafile.getBaseId(), referencedFiles.get(1).getBaseId()); + + try (LanceScanner scanner = datasetWithAddress.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + assertEquals(rowCount, datasetWithAddress.countRows()); + assertEquals(rowCount, batch.getRowCount()); + + // verify all address values not null + VarCharVector resultNameVector = (VarCharVector) batch.getVector("address"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertFalse(resultNameVector.isNull(i)); + String expectedName = "District " + i; + String actualName = + new String(resultNameVector.get(i), StandardCharsets.UTF_8); + assertEquals(expectedName, actualName); + } + } } } } diff --git a/java/src/test/java/org/lance/operation/DeleteTest.java b/java/src/test/java/org/lance/operation/DeleteTest.java index 2151b31f9de..4afa9ca976d 100644 --- a/java/src/test/java/org/lance/operation/DeleteTest.java +++ b/java/src/test/java/org/lance/operation/DeleteTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -42,15 +43,16 @@ void testDelete(@TempDir Path tempDir) { int rowCount = 20; FragmentMetadata fragmentMeta0 = testDataset.createNewFragment(rowCount); FragmentMetadata fragmentMeta1 = testDataset.createNewFragment(rowCount); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder().fragments(Arrays.asList(fragmentMeta0, fragmentMeta1)).build()) - .build(); - try (Dataset dataset = transaction.commit()) { - assertEquals(2, dataset.version()); - assertEquals(2, dataset.latestVersion()); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(appendTxn)) { + assertEquals(2, dataset.version()); + assertEquals(2, dataset.latestVersion()); + } } dataset = Dataset.open(datasetPath, allocator); @@ -60,17 +62,15 @@ void testDelete(@TempDir Path tempDir) { .map(t -> Long.valueOf(t.getId())) .collect(Collectors.toList()); - Transaction delete = - dataset - .newTransactionBuilder() + try (Transaction deleteTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Delete.builder().deletedFragmentIds(deletedFragmentIds).predicate("1=1").build()) - .build(); - try (Dataset dataset = delete.commit()) { - Transaction txn = dataset.readTransaction().get(); - Delete execDelete = (Delete) txn.operation(); - assertEquals(delete.operation(), execDelete); - assertEquals(0, dataset.countRows()); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(deleteTxn)) { + assertEquals(0, dataset.countRows()); + } } } } diff --git a/java/src/test/java/org/lance/operation/MergeTest.java b/java/src/test/java/org/lance/operation/MergeTest.java index 8268aa23071..1e037285217 100644 --- a/java/src/test/java/org/lance/operation/MergeTest.java +++ b/java/src/test/java/org/lance/operation/MergeTest.java @@ -13,20 +13,25 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; import org.lance.Transaction; import org.lance.fragment.DataFile; import org.lance.ipc.LanceScanner; +import org.lance.schema.LanceField; +import org.lance.schema.LanceSchema; import org.apache.arrow.memory.RootAllocator; import org.apache.arrow.vector.IntVector; import org.apache.arrow.vector.VarCharVector; import org.apache.arrow.vector.VectorSchemaRoot; +import org.apache.arrow.vector.complex.StructVector; import org.apache.arrow.vector.ipc.ArrowReader; import org.apache.arrow.vector.types.pojo.ArrowType; import org.apache.arrow.vector.types.pojo.Field; +import org.apache.arrow.vector.types.pojo.FieldType; import org.apache.arrow.vector.types.pojo.Schema; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -36,7 +41,9 @@ import java.nio.file.Path; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.List; +import java.util.Map; public class MergeTest extends OperationTestBase { @@ -90,45 +97,282 @@ void testMergeNewColumn(@TempDir Path tempDir) throws Exception { fragmentMeta.getDeletionFile(), fragmentMeta.getRowIdMeta()); - Transaction mergeTransaction = - initialDataset - .newTransactionBuilder() + try (Transaction mergeTxn = + new Transaction.Builder() + .readVersion(initialDataset.version()) .operation( Merge.builder() .fragments(Collections.singletonList(evolvedFragment)) .schema(evolvedSchema) + .preservesNullability(true) .build()) - .build(); - - try (Dataset evolvedDataset = mergeTransaction.commit()) { - Assertions.assertEquals(3, evolvedDataset.version()); - Assertions.assertEquals(rowCount, evolvedDataset.countRows()); - Assertions.assertEquals(evolvedSchema, evolvedDataset.getSchema()); - Assertions.assertEquals(3, evolvedDataset.getSchema().getFields().size()); - // Verify merged data - try (LanceScanner scanner = evolvedDataset.newScan()) { - try (ArrowReader resultReader = scanner.scanBatches()) { - Assertions.assertTrue(resultReader.loadNextBatch()); - VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); - Assertions.assertEquals(rowCount, batch.getRowCount()); - Assertions.assertEquals(3, batch.getSchema().getFields().size()); - // Verify age column - IntVector ageResultVector = (IntVector) batch.getVector("age"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertEquals(20 + i, ageResultVector.get(i)); + .build()) { + try (Dataset evolvedDataset = new CommitBuilder(initialDataset).execute(mergeTxn)) { + Assertions.assertEquals(3, evolvedDataset.version()); + // The explicit non-default assertion must survive the JNI round trip. + Transaction readBack = evolvedDataset.readTransaction().orElseThrow(); + Assertions.assertTrue(((Merge) readBack.operation()).preservesNullability()); + Assertions.assertEquals(rowCount, evolvedDataset.countRows()); + Assertions.assertEquals(evolvedSchema, evolvedDataset.getSchema()); + Assertions.assertEquals(3, evolvedDataset.getSchema().getFields().size()); + // Verify merged data + try (LanceScanner scanner = evolvedDataset.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + Assertions.assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + Assertions.assertEquals(rowCount, batch.getRowCount()); + Assertions.assertEquals(3, batch.getSchema().getFields().size()); + // Verify age column + IntVector ageResultVector = (IntVector) batch.getVector("age"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(20 + i, ageResultVector.get(i)); + } + IntVector idResultVector = (IntVector) batch.getVector("id"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(i, idResultVector.get(i)); + } } - IntVector idResultVector = (IntVector) batch.getVector("id"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertEquals(i, idResultVector.get(i)); + } + } + } + } + } + } + } + + @Test + void testPreservesNullabilityEquality() { + Schema schema = + new Schema( + Collections.singletonList(Field.nullable("id", new ArrowType.Int(32, true))), null); + // No assertion by default, and the assertion is part of the operation's identity. + Assertions.assertFalse( + Merge.builder() + .fragments(Collections.emptyList()) + .schema(schema) + .build() + .preservesNullability()); + Assertions.assertNotEquals( + Merge.builder().fragments(Collections.emptyList()).schema(schema).build(), + Merge.builder() + .fragments(Collections.emptyList()) + .schema(schema) + .preservesNullability(true) + .build()); + } + + @Test + void testMergeNewColumnWithNonContiguousFieldId(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("testMergeNewColumnWithNonContiguousFieldId").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + + int rowCount = 15; + try (Dataset initialDataset = createAndAppendRows(testDataset, rowCount)) { + LanceSchema initialLanceSchema = initialDataset.getLanceSchema(); + int idFieldId = + initialLanceSchema.fields().stream() + .filter(f -> f.getName().equals("id")) + .findFirst() + .map(LanceField::getId) + .orElseThrow(() -> new IllegalStateException("field 'id' not found")); + int nameFieldId = + initialLanceSchema.fields().stream() + .filter(f -> f.getName().equals("name")) + .findFirst() + .map(LanceField::getId) + .orElseThrow(() -> new IllegalStateException("field 'name' not found")); + int maxFieldId = + initialLanceSchema.fields().stream().mapToInt(LanceField::getId).max().orElse(-1); + + // Use Arrow field metadata to manually assign a non-contiguous field id for the new column. + // This aligns with Rust's `lance:field_id` metadata key. + int ageFieldId = maxFieldId + 10; + int addressFieldId = maxFieldId + 20; + int cityFieldId = maxFieldId + 30; + int countryFieldId = maxFieldId + 40; + + Field idField = + new Field( + "id", + new FieldType(true, new ArrowType.Int(32, true), null, fieldMeta(idFieldId)), + null); + Field nameField = + new Field( + "name", + new FieldType(true, new ArrowType.Utf8(), null, fieldMeta(nameFieldId)), + null); + Field ageField = + new Field( + "age", + new FieldType(true, new ArrowType.Int(32, true), null, fieldMeta(ageFieldId)), + null); + Field cityField = + new Field( + "city", + new FieldType(true, new ArrowType.Utf8(), null, fieldMeta(cityFieldId)), + null); + Field countryField = + new Field( + "country", + new FieldType(true, new ArrowType.Utf8(), null, fieldMeta(countryFieldId)), + null); + Field addressField = + new Field( + "address", + new FieldType(true, new ArrowType.Struct(), null, fieldMeta(addressFieldId)), + Arrays.asList(cityField, countryField)); + + Schema evolvedSchema = + new Schema(Arrays.asList(idField, nameField, ageField, addressField), null); + + // Write data files for the new columns with the manually specified field id. + VectorSchemaRoot ageRoot = null; + VectorSchemaRoot addressRoot = null; + try { + // Age data file + ageRoot = + VectorSchemaRoot.create( + new Schema(Collections.singletonList(ageField), null), allocator); + ageRoot.allocateNew(); + IntVector ageVector = (IntVector) ageRoot.getVector("age"); + + for (int i = 0; i < rowCount; i++) { + ageVector.setSafe(i, 20 + i); + } + ageRoot.setRowCount(rowCount); + + DataFile ageDataFile = + writeLanceDataFile( + dataset.allocator(), datasetPath, ageRoot, new int[] {ageFieldId}, new int[] {0}); + + // Address data file + addressRoot = + VectorSchemaRoot.create( + new Schema(Collections.singletonList(addressField), null), allocator); + + addressRoot.allocateNew(); + StructVector addressVector = (StructVector) addressRoot.getVector("address"); + VarCharVector cityVector = (VarCharVector) addressVector.getChild("city"); + VarCharVector countryVector = (VarCharVector) addressVector.getChild("country"); + + for (int i = 0; i < rowCount; i++) { + addressVector.setIndexDefined(i); + cityVector.setSafe(i, ("city_" + i).getBytes(StandardCharsets.UTF_8)); + countryVector.setSafe(i, ("country_" + i).getBytes(StandardCharsets.UTF_8)); + } + addressRoot.setRowCount(rowCount); + + // New fragments from age and address + DataFile addressDataFile = + writeLanceDataFile( + dataset.allocator(), + datasetPath, + addressRoot, + new int[] {addressFieldId, cityFieldId, countryFieldId}, + new int[] {-1, 0, 1}); + + FragmentMetadata fragmentMeta = initialDataset.getFragment(0).metadata(); + List dataFiles = fragmentMeta.getFiles(); + dataFiles.add(ageDataFile); + dataFiles.add(addressDataFile); + FragmentMetadata evolvedFragment = + new FragmentMetadata( + fragmentMeta.getId(), + dataFiles, + fragmentMeta.getPhysicalRows(), + fragmentMeta.getDeletionFile(), + fragmentMeta.getRowIdMeta()); + + // Commit Merge + try (Transaction mergeTxn = + new Transaction.Builder() + .readVersion(initialDataset.version()) + .operation( + Merge.builder() + .fragments(Collections.singletonList(evolvedFragment)) + .schema(evolvedSchema) + .build()) + .build()) { + try (Dataset evolvedDataset = new CommitBuilder(initialDataset).execute(mergeTxn)) { + Assertions.assertEquals(3, evolvedDataset.version()); + + // Verify field id. + LanceField evolvedAgeField = + findField(evolvedDataset.getLanceSchema().fields(), "age"); + Assertions.assertEquals(ageFieldId, evolvedAgeField.getId()); + + LanceField evolvedAddressField = + findField(evolvedDataset.getLanceSchema().fields(), "address"); + Assertions.assertEquals(addressFieldId, evolvedAddressField.getId()); + + LanceField evolvedCityField = findField(evolvedAddressField.getChildren(), "city"); + Assertions.assertEquals(cityFieldId, evolvedCityField.getId()); + + LanceField evolvedCountryField = + findField(evolvedAddressField.getChildren(), "country"); + Assertions.assertEquals(countryFieldId, evolvedCountryField.getId()); + + // Verify merged data + try (LanceScanner scanner = evolvedDataset.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + Assertions.assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + Assertions.assertEquals(rowCount, batch.getRowCount()); + Assertions.assertEquals(4, batch.getSchema().getFields().size()); + + IntVector ageResultVector = (IntVector) batch.getVector("age"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(20 + i, ageResultVector.get(i)); + } + + StructVector addressResultVector = (StructVector) batch.getVector("address"); + VarCharVector cityResultVector = + (VarCharVector) addressResultVector.getChild("city"); + VarCharVector countryResultVector = + (VarCharVector) addressResultVector.getChild("country"); + for (int i = 0; i < rowCount; i++) { + String city = new String(cityResultVector.get(i), StandardCharsets.UTF_8); + String country = new String(countryResultVector.get(i), StandardCharsets.UTF_8); + Assertions.assertEquals("city_" + i, city); + Assertions.assertEquals("country_" + i, country); + } + + IntVector idResultVector = (IntVector) batch.getVector("id"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(i, idResultVector.get(i)); + } } } } } + } finally { + if (ageRoot != null) { + ageRoot.close(); + } + if (addressRoot != null) { + addressRoot.close(); + } } } } } + private Map fieldMeta(int fieldId) { + Map idMeta = new HashMap<>(); + idMeta.put("lance:field_id", String.valueOf(fieldId)); + return idMeta; + } + + private LanceField findField(List fields, String fieldName) { + return fields.stream() + .filter(f -> f.getName().equals(fieldName)) + .findFirst() + .orElseThrow( + () -> new IllegalStateException(String.format("field '%s' not found", fieldName))); + } + @Test void testReplaceAsDiffColumns(@TempDir Path tempDir) throws Exception { String datasetPath = tempDir.resolve("testReplaceAsDiffColumns").toString(); @@ -156,9 +400,24 @@ void testReplaceAsDiffColumns(@TempDir Path tempDir) throws Exception { } ageRoot.setRowCount(rowCount); + LanceSchema initialLanceSchema = initialDataset.getLanceSchema(); + int idFieldId = + initialLanceSchema.fields().stream() + .filter(f -> f.getName().equals("id")) + .findFirst() + .map(LanceField::getId) + .orElseThrow(() -> new IllegalStateException("field 'id' not found")); + int maxFieldId = + initialLanceSchema.fields().stream().mapToInt(LanceField::getId).max().orElse(-1); + int ageFieldId = maxFieldId + 1; + DataFile ageDataFile = writeLanceDataFile( - dataset.allocator(), datasetPath, ageRoot, new int[] {0, 1}, new int[] {0, 1}); + dataset.allocator(), + datasetPath, + ageRoot, + new int[] {idFieldId, ageFieldId}, + new int[] {0, 1}); FragmentMetadata fragmentMeta = initialDataset.getFragment(0).metadata(); FragmentMetadata evolvedFragment = @@ -169,36 +428,36 @@ void testReplaceAsDiffColumns(@TempDir Path tempDir) throws Exception { fragmentMeta.getDeletionFile(), fragmentMeta.getRowIdMeta()); - Transaction mergeTransaction = - initialDataset - .newTransactionBuilder() + try (Transaction mergeTxn = + new Transaction.Builder() + .readVersion(initialDataset.version()) .operation( Merge.builder() .fragments(Collections.singletonList(evolvedFragment)) .schema(evolvedSchema) .build()) - .build(); - - try (Dataset evolvedDataset = mergeTransaction.commit()) { - Assertions.assertEquals(3, evolvedDataset.version()); - Assertions.assertEquals(rowCount, evolvedDataset.countRows()); - Assertions.assertEquals(evolvedSchema, evolvedDataset.getSchema()); - Assertions.assertEquals(2, evolvedDataset.getSchema().getFields().size()); - // Verify merged data - try (LanceScanner scanner = evolvedDataset.newScan()) { - try (ArrowReader resultReader = scanner.scanBatches()) { - Assertions.assertTrue(resultReader.loadNextBatch()); - VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); - Assertions.assertEquals(rowCount, batch.getRowCount()); - Assertions.assertEquals(2, batch.getSchema().getFields().size()); - // Verify age column - IntVector ageResultVector = (IntVector) batch.getVector("age"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertEquals(20 + i, ageResultVector.get(i)); - } - IntVector idResultVector = (IntVector) batch.getVector("id"); - for (int i = 0; i < rowCount; i++) { - Assertions.assertEquals(i, idResultVector.get(i)); + .build()) { + try (Dataset evolvedDataset = new CommitBuilder(initialDataset).execute(mergeTxn)) { + Assertions.assertEquals(3, evolvedDataset.version()); + Assertions.assertEquals(rowCount, evolvedDataset.countRows()); + Assertions.assertEquals(evolvedSchema, evolvedDataset.getSchema()); + Assertions.assertEquals(2, evolvedDataset.getSchema().getFields().size()); + // Verify merged data + try (LanceScanner scanner = evolvedDataset.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + Assertions.assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + Assertions.assertEquals(rowCount, batch.getRowCount()); + Assertions.assertEquals(2, batch.getSchema().getFields().size()); + // Verify age column + IntVector ageResultVector = (IntVector) batch.getVector("age"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(20 + i, ageResultVector.get(i)); + } + IntVector idResultVector = (IntVector) batch.getVector("id"); + for (int i = 0; i < rowCount; i++) { + Assertions.assertEquals(i, idResultVector.get(i)); + } } } } @@ -254,31 +513,31 @@ void testMergeExistingColumn(@TempDir Path tempDir) throws Exception { fragmentMeta.getDeletionFile(), fragmentMeta.getRowIdMeta()); - Transaction mergeTransaction = - initialDataset - .newTransactionBuilder() + try (Transaction mergeTxn = + new Transaction.Builder() + .readVersion(initialDataset.version()) .operation( Merge.builder() .fragments(Collections.singletonList(evolvedFragment)) .schema(testDataset.getSchema()) .build()) - .build(); - - try (Dataset mergedDataset = mergeTransaction.commit()) { - Assertions.assertEquals(3, mergedDataset.version()); - Assertions.assertEquals(rowCount, mergedDataset.countRows()); - - // Verify updated data - try (LanceScanner scanner = mergedDataset.newScan()) { - try (ArrowReader resultReader = scanner.scanBatches()) { - Assertions.assertTrue(resultReader.loadNextBatch()); - VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); - - VarCharVector nameResultVector = (VarCharVector) batch.getVector("name"); - for (int i = 0; i < rowCount; i++) { - String expectedName = "UpdatedName_" + i; - String actualName = new String(nameResultVector.get(i), StandardCharsets.UTF_8); - Assertions.assertEquals(expectedName, actualName); + .build()) { + try (Dataset mergedDataset = new CommitBuilder(initialDataset).execute(mergeTxn)) { + Assertions.assertEquals(3, mergedDataset.version()); + Assertions.assertEquals(rowCount, mergedDataset.countRows()); + + // Verify updated data + try (LanceScanner scanner = mergedDataset.newScan()) { + try (ArrowReader resultReader = scanner.scanBatches()) { + Assertions.assertTrue(resultReader.loadNextBatch()); + VectorSchemaRoot batch = resultReader.getVectorSchemaRoot(); + + VarCharVector nameResultVector = (VarCharVector) batch.getVector("name"); + for (int i = 0; i < rowCount; i++) { + String expectedName = "UpdatedName_" + i; + String actualName = new String(nameResultVector.get(i), StandardCharsets.UTF_8); + Assertions.assertEquals(expectedName, actualName); + } } } } diff --git a/java/src/test/java/org/lance/operation/OperationTestBase.java b/java/src/test/java/org/lance/operation/OperationTestBase.java index f89aeb6e829..df6ddb2830d 100644 --- a/java/src/test/java/org/lance/operation/OperationTestBase.java +++ b/java/src/test/java/org/lance/operation/OperationTestBase.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -34,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 = 2; protected Dataset dataset; @BeforeAll @@ -53,12 +54,13 @@ protected Dataset createAndAppendRows(TestUtils.SimpleTestDataset suite, int row dataset = suite.createEmptyDataset(); FragmentMetadata fragmentMeta = suite.createNewFragment(rowCount); - Transaction appendTxn = - dataset - .newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) - .build(); - return appendTxn.commit(); + .build()) { + return new CommitBuilder(dataset).execute(txn); + } } /** diff --git a/java/src/test/java/org/lance/operation/OverwriteTest.java b/java/src/test/java/org/lance/operation/OverwriteTest.java index f5a90de5b49..c1def711edc 100644 --- a/java/src/test/java/org/lance/operation/OverwriteTest.java +++ b/java/src/test/java/org/lance/operation/OverwriteTest.java @@ -13,22 +13,32 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.Fragment; 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; +import static org.junit.jupiter.api.Assertions.assertTrue; public class OverwriteTest extends OperationTestBase { @@ -43,33 +53,34 @@ void testOverwrite(@TempDir Path tempDir) throws Exception { // Commit fragment int rowCount = 20; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Overwrite.builder() .fragments(Collections.singletonList(fragmentMeta)) .schema(testDataset.getSchema()) .build()) - .build(); - try (Dataset dataset = transaction.commit()) { - assertEquals(2, dataset.version()); - assertEquals(2, dataset.latestVersion()); - assertEquals(rowCount, dataset.countRows()); - Fragment fragment = dataset.getFragments().get(0); - - try (LanceScanner scanner = fragment.newScan()) { - Schema schemaRes = scanner.schema(); - assertEquals(testDataset.getSchema(), schemaRes); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(txn)) { + assertEquals(2, dataset.version()); + assertEquals(2, dataset.latestVersion()); + assertEquals(rowCount, dataset.countRows()); + Fragment fragment = dataset.getFragments().get(0); + + try (LanceScanner scanner = fragment.newScan()) { + Schema schemaRes = scanner.schema(); + assertEquals(testDataset.getSchema(), schemaRes); + } } } - // Commit fragment again + // Try to commit from stale version (v1) - should fail with retryable error rowCount = 40; fragmentMeta = testDataset.createNewFragment(rowCount); - transaction = - dataset - .newTransactionBuilder() + try (Transaction staleTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Overwrite.builder() .fragments(Collections.singletonList(fragmentMeta)) @@ -77,21 +88,106 @@ void testOverwrite(@TempDir Path tempDir) throws Exception { .configUpsertValues(Collections.singletonMap("config_key", "config_value")) .build()) .transactionProperties(Collections.singletonMap("key", "value")) - .build(); - assertEquals( - "value", transaction.transactionProperties().map(m -> m.get("key")).orElse(null)); - try (Dataset dataset = transaction.commit()) { - assertEquals(3, dataset.version()); - assertEquals(3, dataset.latestVersion()); - assertEquals(rowCount, dataset.countRows()); - assertEquals("config_value", dataset.getConfig().get("config_key")); - Fragment fragment = dataset.getFragments().get(0); - - try (LanceScanner scanner = fragment.newScan()) { - Schema schemaRes = scanner.schema(); - assertEquals(testDataset.getSchema(), schemaRes); + .build()) { + assertEquals("value", staleTxn.transactionProperties().map(m -> m.get("key")).orElse(null)); + + RuntimeException ex = + assertThrows( + RuntimeException.class, () -> new CommitBuilder(dataset).execute(staleTxn).close()); + assertTrue( + ex.getMessage().contains("Retryable commit conflict"), + "Expected retryable commit conflict error, got: " + ex.getMessage()); + } + + // Checkout latest and retry - should succeed + dataset.checkoutLatest(); + try (Transaction retryTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Overwrite.builder() + .fragments(Collections.singletonList(fragmentMeta)) + .schema(testDataset.getSchema()) + .configUpsertValues(Collections.singletonMap("config_key", "config_value")) + .build()) + .transactionProperties(Collections.singletonMap("key", "value")) + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(retryTxn)) { + assertEquals(3, dataset.version()); + assertEquals(3, dataset.latestVersion()); + assertEquals(rowCount, dataset.countRows()); + assertEquals("config_value", dataset.getConfig().get("config_key")); + Fragment fragment = dataset.getFragments().get(0); + + try (LanceScanner scanner = fragment.newScan()) { + Schema schemaRes = scanner.schema(); + assertEquals(testDataset.getSchema(), schemaRes); + } + assertEquals(retryTxn, dataset.readTransaction().orElse(null)); + } + } + } + } + + @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); } - assertEquals(transaction, dataset.readTransaction().orElse(null)); } } } diff --git a/java/src/test/java/org/lance/operation/ProjectTest.java b/java/src/test/java/org/lance/operation/ProjectTest.java index a8124a672ca..bd3dd7d2960 100644 --- a/java/src/test/java/org/lance/operation/ProjectTest.java +++ b/java/src/test/java/org/lance/operation/ProjectTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.TestUtils; import org.lance.Transaction; @@ -29,6 +30,8 @@ import java.util.List; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; public class ProjectTest extends OperationTestBase { @@ -43,29 +46,59 @@ void testProjection(@TempDir Path tempDir) { assertEquals(testDataset.getSchema(), dataset.getSchema()); List fieldList = new ArrayList<>(testDataset.getSchema().getFields()); Collections.reverse(fieldList); - Transaction txn1 = - dataset - .newTransactionBuilder() + try (Transaction txn1 = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(Project.builder().schema(new Schema(fieldList)).build()) - .build(); - try (Dataset committedDataset = txn1.commit()) { - assertEquals(1, txn1.readVersion()); - assertEquals(1, dataset.version()); - assertEquals(2, committedDataset.version()); - assertEquals(new Schema(fieldList), committedDataset.getSchema()); - fieldList.remove(1); - Transaction txn2 = - committedDataset - .newTransactionBuilder() - .operation(Project.builder().schema(new Schema(fieldList)).build()) - .build(); - try (Dataset committedDataset2 = txn2.commit()) { - assertEquals(2, txn2.readVersion()); + .build()) { + try (Dataset committedDataset = new CommitBuilder(dataset).execute(txn1)) { + assertEquals(1, dataset.version()); assertEquals(2, committedDataset.version()); - assertEquals(3, committedDataset2.version()); - assertEquals(new Schema(fieldList), committedDataset2.getSchema()); - assertEquals(txn1, committedDataset.readTransaction().orElse(null)); - assertEquals(txn2, committedDataset2.readTransaction().orElse(null)); + assertEquals(new Schema(fieldList), committedDataset.getSchema()); + fieldList.remove(1); + try (Transaction txn2 = + new Transaction.Builder() + .readVersion(committedDataset.version()) + .operation(Project.builder().schema(new Schema(fieldList)).build()) + .build()) { + try (Dataset committedDataset2 = new CommitBuilder(committedDataset).execute(txn2)) { + assertEquals(2, committedDataset.version()); + assertEquals(3, committedDataset2.version()); + assertEquals(new Schema(fieldList), committedDataset2.getSchema()); + } + } + } + } + } + } + + @Test + void testPreservesNullabilityEqualityAndRoundTrip(@TempDir Path tempDir) { + String datasetPath = tempDir.resolve("testAssertsNonNull").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + Schema schema = testDataset.getSchema(); + + // The assertion is part of the operation's identity. + assertNotEquals( + Project.builder().schema(schema).preservesNullability(true).build(), + Project.builder().schema(schema).preservesNullability(false).build()); + assertEquals( + Project.builder().schema(schema).preservesNullability(true).build(), + Project.builder().schema(schema).preservesNullability(true).build()); + + // The explicit non-default assertion must survive the JNI round trip. + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Project.builder().schema(schema).preservesNullability(true).build()) + .build()) { + try (Dataset committed = new CommitBuilder(dataset).execute(txn)) { + Transaction readBack = committed.readTransaction().orElseThrow(); + Project project = (Project) readBack.operation(); + assertTrue(project.preservesNullability()); } } } diff --git a/java/src/test/java/org/lance/operation/ReserveFragmentsTest.java b/java/src/test/java/org/lance/operation/ReserveFragmentsTest.java index 14089482c08..9299fa7b1bd 100644 --- a/java/src/test/java/org/lance/operation/ReserveFragmentsTest.java +++ b/java/src/test/java/org/lance/operation/ReserveFragmentsTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.Fragment; import org.lance.FragmentMetadata; @@ -42,54 +43,55 @@ void testReserveFragments(@TempDir Path tempDir) throws Exception { // Create an initial fragment to establish a baseline fragment ID FragmentMetadata initialFragmentMeta = testDataset.createNewFragment(10); - Transaction appendTransaction = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder() .fragments(Collections.singletonList(initialFragmentMeta)) .build()) - .build(); - try (Dataset datasetWithFragment = appendTransaction.commit()) { - // Reserve fragment IDs - int numFragmentsToReserve = 5; - Transaction reserveTransaction = - datasetWithFragment - .newTransactionBuilder() - .operation( - new ReserveFragments.Builder().numFragments(numFragmentsToReserve).build()) - .build(); - try (Dataset datasetWithReservedFragments = reserveTransaction.commit()) { - // Create a new fragment and verify its ID reflects the reservation - FragmentMetadata newFragmentMeta = testDataset.createNewFragment(10); - Transaction appendTransaction2 = - datasetWithReservedFragments - .newTransactionBuilder() + .build()) { + try (Dataset datasetWithFragment = new CommitBuilder(dataset).execute(appendTxn)) { + // Reserve fragment IDs + int numFragmentsToReserve = 5; + try (Transaction reserveTxn = + new Transaction.Builder() + .readVersion(datasetWithFragment.version()) .operation( - Append.builder() - .fragments(Collections.singletonList(newFragmentMeta)) - .build()) - .build(); - try (Dataset finalDataset = appendTransaction2.commit()) { - // Verify the fragment IDs were properly reserved - // The new fragment should have an ID that's at least numFragmentsToReserve higher - // than it would have been without the reservation - List fragments = finalDataset.getFragments(); - assertEquals(2, fragments.size()); + new ReserveFragments.Builder().numFragments(numFragmentsToReserve).build()) + .build()) { + try (Dataset datasetWithReservedFragments = + new CommitBuilder(datasetWithFragment).execute(reserveTxn)) { + // Create a new fragment and verify its ID reflects the reservation + FragmentMetadata newFragmentMeta = testDataset.createNewFragment(10); + try (Transaction appendTxn2 = + new Transaction.Builder() + .readVersion(datasetWithReservedFragments.version()) + .operation( + Append.builder() + .fragments(Collections.singletonList(newFragmentMeta)) + .build()) + .build()) { + try (Dataset finalDataset = + new CommitBuilder(datasetWithReservedFragments).execute(appendTxn2)) { + // Verify the fragment IDs were properly reserved + // The new fragment should have an ID that's at least numFragmentsToReserve + // higher than it would have been without the reservation + List fragments = finalDataset.getFragments(); + assertEquals(2, fragments.size()); - // The first fragment ID is typically 0, and the second would normally be 1 - // But after reserving 5 fragments, the second fragment ID should be at least 6 - Fragment firstFragment = fragments.get(0); - Fragment secondFragment = fragments.get(1); + // The first fragment ID is typically 0, and the second would normally be 1 + // But after reserving 5 fragments, the second fragment ID should be at least 6 + Fragment firstFragment = fragments.get(0); + Fragment secondFragment = fragments.get(1); - // Check that the second fragment has a significantly higher ID than the first - // This is an indirect way to verify that fragment IDs were reserved - Assertions.assertNotEquals( - firstFragment.metadata().getId() + 1, secondFragment.getId()); - - // Verify the transaction is recorded - assertEquals( - reserveTransaction, datasetWithReservedFragments.readTransaction().orElse(null)); + // Check that the second fragment has a significantly higher ID than the first + // This is an indirect way to verify that fragment IDs were reserved + Assertions.assertNotEquals( + firstFragment.metadata().getId() + 1, secondFragment.getId()); + } + } + } } } } diff --git a/java/src/test/java/org/lance/operation/RestoreTest.java b/java/src/test/java/org/lance/operation/RestoreTest.java index 751263f4c06..98e9d779ade 100644 --- a/java/src/test/java/org/lance/operation/RestoreTest.java +++ b/java/src/test/java/org/lance/operation/RestoreTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -43,30 +44,31 @@ void testRestore(@TempDir Path tempDir) { // Append data to create a new version int rowCount = 20; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) - .build(); - try (Dataset modifiedDataset = transaction.commit()) { - // Verify the dataset was modified - long newVersion = modifiedDataset.version(); - assertEquals(initialVersion + 1, newVersion); - assertEquals(rowCount, modifiedDataset.countRows()); + .build()) { + try (Dataset modifiedDataset = new CommitBuilder(dataset).execute(appendTxn)) { + // Verify the dataset was modified + long newVersion = modifiedDataset.version(); + assertEquals(initialVersion + 1, newVersion); + assertEquals(rowCount, modifiedDataset.countRows()); - // Restore to the initial version - Transaction restoreTransaction = - modifiedDataset - .newTransactionBuilder() - .operation(new Restore.Builder().version(initialVersion).build()) - .build(); - try (Dataset restoredDataset = restoreTransaction.commit()) { - // Verify the dataset was restored to the initial version, but the version increases - assertEquals(initialVersion + 2, restoredDataset.version()); - // Initial dataset had 0 rows - assertEquals(0, restoredDataset.countRows()); - assertEquals(restoreTransaction, restoredDataset.readTransaction().orElse(null)); + // Restore to the initial version + try (Transaction restoreTxn = + new Transaction.Builder() + .readVersion(modifiedDataset.version()) + .operation(new Restore.Builder().version(initialVersion).build()) + .build()) { + try (Dataset restoredDataset = new CommitBuilder(modifiedDataset).execute(restoreTxn)) { + // Verify the dataset was restored to the initial version, but the version increases + assertEquals(initialVersion + 2, restoredDataset.version()); + // Initial dataset had 0 rows + assertEquals(0, restoredDataset.countRows()); + } + } } } } diff --git a/java/src/test/java/org/lance/operation/RewriteTest.java b/java/src/test/java/org/lance/operation/RewriteTest.java index 4a73e2807e8..f2081ab8895 100644 --- a/java/src/test/java/org/lance/operation/RewriteTest.java +++ b/java/src/test/java/org/lance/operation/RewriteTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -44,47 +45,45 @@ void testRewrite(@TempDir Path tempDir) { FragmentMetadata fragmentMeta1 = testDataset.createNewFragment(rowCount); FragmentMetadata fragmentMeta2 = testDataset.createNewFragment(rowCount); - Transaction appendTx = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder().fragments(Arrays.asList(fragmentMeta1, fragmentMeta2)).build()) - .build(); - - try (Dataset datasetWithData = appendTx.commit()) { - assertEquals(2, datasetWithData.version()); - assertEquals(rowCount * 2, datasetWithData.countRows()); - - // Now create a rewrite operation - List groups = new ArrayList<>(); - - // Create a rewrite group with old fragments and new fragments - List oldFragments = new ArrayList<>(); - oldFragments.add(fragmentMeta1); - - List newFragments = new ArrayList<>(); - FragmentMetadata newFragmentMeta = testDataset.createNewFragment(rowCount); - newFragments.add(newFragmentMeta); - - RewriteGroup group = - RewriteGroup.builder().oldFragments(oldFragments).newFragments(newFragments).build(); - - groups.add(group); - - // Create and commit the rewrite transaction - Transaction rewriteTx = - datasetWithData - .newTransactionBuilder() - .operation(Rewrite.builder().groups(groups).build()) - .build(); - - try (Dataset rewrittenDataset = rewriteTx.commit()) { - assertEquals(3, rewrittenDataset.version()); - // The row count should remain the same since we're just rewriting - assertEquals(rowCount * 2, rewrittenDataset.countRows()); - - // Verify that the transaction was recorded - assertEquals(rewriteTx, rewrittenDataset.readTransaction().orElse(null)); + .build()) { + try (Dataset datasetWithData = new CommitBuilder(dataset).execute(appendTxn)) { + assertEquals(2, datasetWithData.version()); + assertEquals(rowCount * 2, datasetWithData.countRows()); + + // Now create a rewrite operation + List groups = new ArrayList<>(); + + // Create a rewrite group with old fragments and new fragments + List oldFragments = new ArrayList<>(); + oldFragments.add(fragmentMeta1); + + List newFragments = new ArrayList<>(); + FragmentMetadata newFragmentMeta = testDataset.createNewFragment(rowCount); + newFragments.add(newFragmentMeta); + + RewriteGroup group = + RewriteGroup.builder().oldFragments(oldFragments).newFragments(newFragments).build(); + + groups.add(group); + + // Create and commit the rewrite transaction + try (Transaction rewriteTxn = + new Transaction.Builder() + .readVersion(datasetWithData.version()) + .operation(Rewrite.builder().groups(groups).build()) + .build()) { + try (Dataset rewrittenDataset = + new CommitBuilder(datasetWithData).execute(rewriteTxn)) { + assertEquals(3, rewrittenDataset.version()); + // The row count should remain the same since we're just rewriting + assertEquals(rowCount * 2, rewrittenDataset.countRows()); + } + } } } } diff --git a/java/src/test/java/org/lance/operation/TruncateTest.java b/java/src/test/java/org/lance/operation/TruncateTest.java index 93f5b689e8c..e316c969362 100644 --- a/java/src/test/java/org/lance/operation/TruncateTest.java +++ b/java/src/test/java/org/lance/operation/TruncateTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.FragmentMetadata; import org.lance.TestUtils; @@ -40,15 +41,15 @@ void testTruncateTable(@TempDir Path tempDir) throws Exception { // Append some data int rowCount = 20; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); - Transaction transaction = - dataset - .newTransactionBuilder() - .operation( - Append.builder() - .fragments(java.util.Collections.singletonList(fragmentMeta)) - .build()) - .build(); - try (Dataset ds1 = transaction.commit()) { + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Append.builder() + .fragments(java.util.Collections.singletonList(fragmentMeta)) + .build()) + .build(); + Dataset ds1 = new CommitBuilder(dataset).execute(txn)) { assertEquals(rowCount, ds1.countRows()); // Truncate to empty while preserving schema diff --git a/java/src/test/java/org/lance/operation/UpdateConfigTest.java b/java/src/test/java/org/lance/operation/UpdateConfigTest.java index 14c3ea444f0..aba003846c8 100644 --- a/java/src/test/java/org/lance/operation/UpdateConfigTest.java +++ b/java/src/test/java/org/lance/operation/UpdateConfigTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.TestUtils; import org.lance.Transaction; @@ -45,90 +46,101 @@ void testUpdateConfig(@TempDir Path tempDir) { UpdateMap configUpdates = UpdateMap.builder().updates(configValues).replace(false).build(); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation(UpdateConfig.builder().configUpdates(configUpdates).build()) - .build(); - try (Dataset updatedDataset = transaction.commit()) { - assertEquals(2, updatedDataset.version()); - assertEquals("value1", updatedDataset.getConfig().get("key1")); - assertEquals("value2", updatedDataset.getConfig().get("key2")); - - // Test 2: Delete configuration key using configUpdates with null value - Map deleteUpdates = new HashMap<>(); - deleteUpdates.put("key1", null); // null value means delete - - UpdateMap configDeleteUpdates = - UpdateMap.builder().updates(deleteUpdates).replace(false).build(); - - transaction = - updatedDataset - .newTransactionBuilder() - .operation(UpdateConfig.builder().configUpdates(configDeleteUpdates).build()) - .build(); - try (Dataset updatedDataset2 = transaction.commit()) { - assertEquals(3, updatedDataset2.version()); - assertNull(updatedDataset2.getConfig().get("key1")); - assertEquals("value2", updatedDataset2.getConfig().get("key2")); - - // Test 3: Update schema metadata using schemaMetadataUpdates - Map schemaMetadataMap = new HashMap<>(); - schemaMetadataMap.put("schema_key1", "schema_value1"); - schemaMetadataMap.put("schema_key2", "schema_value2"); - - UpdateMap schemaMetadataUpdates = - UpdateMap.builder().updates(schemaMetadataMap).replace(false).build(); - - transaction = - updatedDataset2 - .newTransactionBuilder() - .operation( - UpdateConfig.builder().schemaMetadataUpdates(schemaMetadataUpdates).build()) - .build(); - try (Dataset updatedDataset3 = transaction.commit()) { - assertEquals(4, updatedDataset3.version()); - assertEquals( - "schema_value1", updatedDataset3.getLanceSchema().metadata().get("schema_key1")); - assertEquals( - "schema_value2", updatedDataset3.getLanceSchema().metadata().get("schema_key2")); - - // Test 4: Update field metadata using fieldMetadataUpdates - Map fieldMetadataUpdates = new HashMap<>(); - - Map field0Updates = new HashMap<>(); - field0Updates.put("field0_key1", "field0_value1"); - UpdateMap field0UpdateMap = - UpdateMap.builder().updates(field0Updates).replace(false).build(); - - Map field1Updates = new HashMap<>(); - field1Updates.put("field1_key1", "field1_value1"); - field1Updates.put("field1_key2", "field1_value2"); - UpdateMap field1UpdateMap = - UpdateMap.builder().updates(field1Updates).replace(false).build(); - - fieldMetadataUpdates.put(0, field0UpdateMap); - fieldMetadataUpdates.put(1, field1UpdateMap); - - transaction = - updatedDataset3 - .newTransactionBuilder() - .operation( - UpdateConfig.builder().fieldMetadataUpdates(fieldMetadataUpdates).build()) - .build(); - try (Dataset updatedDataset4 = transaction.commit()) { - assertEquals(5, updatedDataset4.version()); - - // Verify field metadata for field 0 - Map fieldMetadata0 = - updatedDataset4.getLanceSchema().fields().get(0).getMetadata(); - assertEquals("field0_value1", fieldMetadata0.get("field0_key1")); - - // Verify field metadata for field 1 - Map field1Result = - updatedDataset4.getLanceSchema().fields().get(1).getMetadata(); - assertEquals("field1_value1", field1Result.get("field1_key1")); - assertEquals("field1_value2", field1Result.get("field1_key2")); + .build()) { + try (Dataset updatedDataset = new CommitBuilder(dataset).execute(txn)) { + assertEquals(2, updatedDataset.version()); + assertEquals("value1", updatedDataset.getConfig().get("key1")); + assertEquals("value2", updatedDataset.getConfig().get("key2")); + + // Test 2: Delete configuration key using configUpdates with null value + Map deleteUpdates = new HashMap<>(); + deleteUpdates.put("key1", null); // null value means delete + + UpdateMap configDeleteUpdates = + UpdateMap.builder().updates(deleteUpdates).replace(false).build(); + + try (Transaction txn2 = + new Transaction.Builder() + .readVersion(updatedDataset.version()) + .operation(UpdateConfig.builder().configUpdates(configDeleteUpdates).build()) + .build()) { + try (Dataset updatedDataset2 = new CommitBuilder(updatedDataset).execute(txn2)) { + assertEquals(3, updatedDataset2.version()); + assertNull(updatedDataset2.getConfig().get("key1")); + assertEquals("value2", updatedDataset2.getConfig().get("key2")); + + // Test 3: Update schema metadata using schemaMetadataUpdates + Map schemaMetadataMap = new HashMap<>(); + schemaMetadataMap.put("schema_key1", "schema_value1"); + schemaMetadataMap.put("schema_key2", "schema_value2"); + + UpdateMap schemaMetadataUpdates = + UpdateMap.builder().updates(schemaMetadataMap).replace(false).build(); + + try (Transaction txn3 = + new Transaction.Builder() + .readVersion(updatedDataset2.version()) + .operation( + UpdateConfig.builder() + .schemaMetadataUpdates(schemaMetadataUpdates) + .build()) + .build()) { + try (Dataset updatedDataset3 = new CommitBuilder(updatedDataset2).execute(txn3)) { + assertEquals(4, updatedDataset3.version()); + assertEquals( + "schema_value1", + updatedDataset3.getLanceSchema().metadata().get("schema_key1")); + assertEquals( + "schema_value2", + updatedDataset3.getLanceSchema().metadata().get("schema_key2")); + + // Test 4: Update field metadata using fieldMetadataUpdates + Map fieldMetadataUpdates = new HashMap<>(); + + Map field0Updates = new HashMap<>(); + field0Updates.put("field0_key1", "field0_value1"); + UpdateMap field0UpdateMap = + UpdateMap.builder().updates(field0Updates).replace(false).build(); + + Map field1Updates = new HashMap<>(); + field1Updates.put("field1_key1", "field1_value1"); + field1Updates.put("field1_key2", "field1_value2"); + UpdateMap field1UpdateMap = + UpdateMap.builder().updates(field1Updates).replace(false).build(); + + fieldMetadataUpdates.put(0, field0UpdateMap); + fieldMetadataUpdates.put(1, field1UpdateMap); + + try (Transaction txn4 = + new Transaction.Builder() + .readVersion(updatedDataset3.version()) + .operation( + UpdateConfig.builder() + .fieldMetadataUpdates(fieldMetadataUpdates) + .build()) + .build()) { + try (Dataset updatedDataset4 = + new CommitBuilder(updatedDataset3).execute(txn4)) { + assertEquals(5, updatedDataset4.version()); + + // Verify field metadata for field 0 + Map fieldMetadata0 = + updatedDataset4.getLanceSchema().fields().get(0).getMetadata(); + assertEquals("field0_value1", fieldMetadata0.get("field0_key1")); + + // Verify field metadata for field 1 + Map field1Result = + updatedDataset4.getLanceSchema().fields().get(1).getMetadata(); + assertEquals("field1_value1", field1Result.get("field1_key1")); + assertEquals("field1_value2", field1Result.get("field1_key2")); + } + } + } + } } } } diff --git a/java/src/test/java/org/lance/operation/UpdateTest.java b/java/src/test/java/org/lance/operation/UpdateTest.java index 0e3e2276204..7250c9681f7 100644 --- a/java/src/test/java/org/lance/operation/UpdateTest.java +++ b/java/src/test/java/org/lance/operation/UpdateTest.java @@ -13,6 +13,7 @@ */ package org.lance.operation; +import org.lance.CommitBuilder; import org.lance.Dataset; import org.lance.Fragment; import org.lance.FragmentMetadata; @@ -35,10 +36,14 @@ 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; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; public class UpdateTest extends OperationTestBase { @@ -54,35 +59,37 @@ void testUpdate(@TempDir Path tempDir) throws Exception { // Commit fragment int rowCount = 20; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); - Transaction transaction = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) - .build(); - - try (Dataset dataset = transaction.commit()) { - assertEquals(2, dataset.version()); - assertEquals(2, dataset.latestVersion()); - assertEquals(rowCount, dataset.countRows()); - assertThrows( - IllegalArgumentException.class, - () -> - dataset - .newTransactionBuilder() - .operation(Append.builder().fragments(new ArrayList<>()).build()) - .build() - .commit() - .close()); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(appendTxn)) { + assertEquals(2, dataset.version()); + assertEquals(2, dataset.latestVersion()); + assertEquals(rowCount, dataset.countRows()); + assertThrows( + IllegalArgumentException.class, + () -> { + try (Transaction txn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation(Append.builder().fragments(new ArrayList<>()).build()) + .build()) { + new CommitBuilder(dataset).execute(txn).close(); + } + }); + } } dataset = Dataset.open(datasetPath, allocator); // Update fragments rowCount = 40; FragmentMetadata newFragment = testDataset.createNewFragment(rowCount); - transaction = - dataset - .newTransactionBuilder() + try (Transaction updateTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Update.builder() .removedFragmentIds( @@ -91,15 +98,94 @@ void testUpdate(@TempDir Path tempDir) throws Exception { .newFragments(Collections.singletonList(newFragment)) .updateMode(Optional.of(UpdateMode.RewriteRows)) .build()) - .build(); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(updateTxn)) { + assertEquals(3, dataset.version()); + assertEquals(3, dataset.latestVersion()); + assertEquals(rowCount, dataset.countRows()); + } + } + } + } + + @Test + void testUpdatedFragmentOffsetsRoundTrip(@TempDir Path tempDir) throws Exception { + String datasetPath = tempDir.resolve("testUpdatedFragmentOffsetsRoundTrip").toString(); + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath); + dataset = testDataset.createEmptyDataset(); + + // Append an initial fragment so we have a real fragment id. + FragmentMetadata fragmentMeta = testDataset.createNewFragment(10); + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) + .build()) { + new CommitBuilder(dataset).execute(appendTxn).close(); + } + + dataset = Dataset.open(datasetPath, allocator); + Fragment existingFragment = dataset.getFragments().get(0); + long fragmentId = existingFragment.getId(); + // Use the committed fragment's own metadata as the updatedFragment so that + // the updatedFragmentOffsets key is valid (must match a fragment in updatedFragments). + FragmentMetadata existingFragmentMeta = existingFragment.metadata(); - try (Dataset dataset = transaction.commit()) { - assertEquals(3, dataset.version()); - assertEquals(3, dataset.latestVersion()); - assertEquals(rowCount, dataset.countRows()); + // Build Update with non-empty updatedFragmentOffsets. Values are portable RoaringBitmap + // bytes encoding {1, 3, 5}: cookie(4) + containerCount(4) + key(2) + card-1(2) + + // offset(4) + elems(6). Offset = 16 (start of container data from beginning of stream). + Map offsets = new HashMap<>(); + offsets.put( + fragmentId, + new byte[] { + (byte) 0x3A, + (byte) 0x30, + (byte) 0x00, + (byte) 0x00, // cookie = 12346 + (byte) 0x01, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, // 1 container + (byte) 0x00, + (byte) 0x00, // container key 0 + (byte) 0x02, + (byte) 0x00, // cardinality - 1 = 2 + (byte) 0x10, + (byte) 0x00, + (byte) 0x00, + (byte) 0x00, // offset = 16 + (byte) 0x01, + (byte) 0x00, // element 1 + (byte) 0x03, + (byte) 0x00, // element 3 + (byte) 0x05, + (byte) 0x00 // element 5 + }); - Transaction txn = dataset.readTransaction().orElse(null); - assertEquals(transaction, txn); + try (Transaction updateTxn = + new Transaction.Builder() + .readVersion(dataset.version()) + .operation( + Update.builder() + .updatedFragments(Collections.singletonList(existingFragmentMeta)) + .updateMode(Optional.of(UpdateMode.RewriteColumns)) + .updatedFragmentOffsets(offsets) + .build()) + .build()) { + try (Dataset committed = new CommitBuilder(dataset).execute(updateTxn)) { + // Read the committed transaction back (exercises the IntoJava JNI path). + try (Transaction readTx = committed.readTransaction().orElseThrow()) { + assertInstanceOf(Update.class, readTx.operation()); + Update readOp = (Update) readTx.operation(); + + Map readOffsets = readOp.updatedFragmentOffsets(); + assertEquals(1, readOffsets.size()); + assertArrayEquals(offsets.get(fragmentId), readOffsets.get(fragmentId)); + } + } } } } @@ -122,16 +208,17 @@ void testUpdateColumns(@TempDir Path tempDir) throws Exception { */ int rowCount = 6; FragmentMetadata fragmentMeta = testDataset.createNewFragment(rowCount); - Transaction appendTransaction = - dataset - .newTransactionBuilder() + try (Transaction appendTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Append.builder().fragments(Collections.singletonList(fragmentMeta)).build()) - .build(); - try (Dataset dataset = appendTransaction.commit()) { - assertEquals(2, dataset.version()); - assertEquals(2, dataset.latestVersion()); - assertEquals(rowCount, dataset.countRows()); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(appendTxn)) { + assertEquals(2, dataset.version()); + assertEquals(2, dataset.latestVersion()); + assertEquals(rowCount, dataset.countRows()); + } } dataset = Dataset.open(datasetPath, allocator); @@ -145,59 +232,61 @@ void testUpdateColumns(@TempDir Path tempDir) throws Exception { * 3: | null | null | */ FragmentUpdateResult updateResult = testDataset.updateColumn(targetFragment, updateRowCount); - Transaction updateTransaction = - dataset - .newTransactionBuilder() + try (Transaction updateTxn = + new Transaction.Builder() + .readVersion(dataset.version()) .operation( Update.builder() .updatedFragments( Collections.singletonList(updateResult.getUpdatedFragment())) .fieldsModified(updateResult.getFieldsModified()) + .updateMode(Optional.of(UpdateMode.RewriteColumns)) .build()) - .build(); - try (Dataset dataset = updateTransaction.commit()) { - assertEquals(3, dataset.version()); - assertEquals(3, dataset.latestVersion()); - Fragment fragment = dataset.getFragments().get(0); - try (LanceScanner scanner = fragment.newScan(rowCount)) { - List actualIds = new ArrayList<>(rowCount); - List actualNames = new ArrayList<>(rowCount); - List actualTimeStamps = new ArrayList<>(rowCount); - try (ArrowReader reader = scanner.scanBatches()) { - while (reader.loadNextBatch()) { - VectorSchemaRoot root = reader.getVectorSchemaRoot(); - IntVector idVector = (IntVector) root.getVector("id"); - for (int i = 0; i < idVector.getValueCount(); i++) { - actualIds.add(idVector.isNull(i) ? null : idVector.getObject(i)); - } - VarCharVector nameVector = (VarCharVector) root.getVector("name"); - for (int i = 0; i < nameVector.getValueCount(); i++) { - actualNames.add(nameVector.isNull(i) ? null : nameVector.getObject(i).toString()); - } - TimeStampSecTZVector timeStampVector = - (TimeStampSecTZVector) root.getVector("timeStamp"); - for (int i = 0; i < timeStampVector.getValueCount(); i++) { - actualTimeStamps.add( - timeStampVector.isNull(i) ? null : timeStampVector.getObject(i)); + .build()) { + try (Dataset dataset = new CommitBuilder(this.dataset).execute(updateTxn)) { + assertEquals(3, dataset.version()); + assertEquals(3, dataset.latestVersion()); + Fragment fragment = dataset.getFragments().get(0); + try (LanceScanner scanner = fragment.newScan(rowCount)) { + List actualIds = new ArrayList<>(rowCount); + List actualNames = new ArrayList<>(rowCount); + List actualTimeStamps = new ArrayList<>(rowCount); + try (ArrowReader reader = scanner.scanBatches()) { + while (reader.loadNextBatch()) { + VectorSchemaRoot root = reader.getVectorSchemaRoot(); + IntVector idVector = (IntVector) root.getVector("id"); + for (int i = 0; i < idVector.getValueCount(); i++) { + actualIds.add(idVector.isNull(i) ? null : idVector.getObject(i)); + } + VarCharVector nameVector = (VarCharVector) root.getVector("name"); + for (int i = 0; i < nameVector.getValueCount(); i++) { + actualNames.add(nameVector.isNull(i) ? null : nameVector.getObject(i).toString()); + } + TimeStampSecTZVector timeStampVector = + (TimeStampSecTZVector) root.getVector("timeStamp"); + for (int i = 0; i < timeStampVector.getValueCount(); i++) { + actualTimeStamps.add( + timeStampVector.isNull(i) ? null : timeStampVector.getObject(i)); + } } } + /* result dataset content + * _rowid | id | name | timeStamp | + * 0: | 100 | "Update 0" | 0 | + * 1: | null | null | null | + * 2: | 2 | "Update 2" | 2 | + * 3: | null | null | null | + * 4: | 4 | "Person 4" | 4 | + * 5: | null | null | null | + */ + List expectIds = Arrays.asList(100, null, 2, null, 4, null); + List expectNames = + Arrays.asList("Update 0", null, "Update 2", null, "Person 4", null); + List expectTimeStamps = Arrays.asList(0L, null, 2L, null, 4L, null); + assertEquals(expectIds, actualIds); + assertEquals(expectNames, actualNames); + assertEquals(expectTimeStamps, actualTimeStamps); } - /* result dataset content - * _rowid | id | name | timeStamp | - * 0: | 100 | "Update 0" | 0 | - * 1: | null | null | null | - * 2: | 2 | "Update 2" | 2 | - * 3: | null | null | null | - * 4: | 4 | "Person 4" | 4 | - * 5: | null | null | null | - */ - List expectIds = Arrays.asList(100, null, 2, null, 4, null); - List expectNames = - Arrays.asList("Update 0", null, "Update 2", null, "Person 4", null); - List expectTimeStamps = Arrays.asList(0L, null, 2L, null, 4L, null); - assertEquals(expectIds, actualIds); - assertEquals(expectNames, actualNames); - assertEquals(expectTimeStamps, actualTimeStamps); } } } diff --git a/java/src/test/java/org/lance/otel/LanceMetricsTest.java b/java/src/test/java/org/lance/otel/LanceMetricsTest.java new file mode 100644 index 00000000000..b165bec3d36 --- /dev/null +++ b/java/src/test/java/org/lance/otel/LanceMetricsTest.java @@ -0,0 +1,172 @@ +/* + * 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.otel; + +import org.lance.Dataset; +import org.lance.TestUtils; + +import io.opentelemetry.api.common.AttributeKey; +import io.opentelemetry.api.common.Attributes; +import io.opentelemetry.sdk.metrics.SdkMeterProvider; +import io.opentelemetry.sdk.metrics.data.DoublePointData; +import io.opentelemetry.sdk.metrics.data.MetricData; +import io.opentelemetry.sdk.testing.exporter.InMemoryMetricReader; +import org.apache.arrow.memory.RootAllocator; +import org.junit.jupiter.api.AfterEach; +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.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class LanceMetricsTest { + private static final String REQUESTS = "lance_object_store_requests_total"; + private static final String DURATION = "lance_object_store_request_duration_seconds"; + private static final String RETRYABLE = "lance_object_store_retryable_responses_total"; + private static final String IN_FLIGHT = "lance_object_store_in_flight_requests"; + + @AfterEach + void closeLanceMetrics() { + LanceMetrics.close(); + } + + @Test + void testInstrumentLanceMetricsExportsObjectStoreMetrics(@TempDir Path tempDir) { + InMemoryMetricReader reader = InMemoryMetricReader.create(); + SdkMeterProvider provider = SdkMeterProvider.builder().registerMetricReader(reader).build(); + + try { + assertTrue(LanceMetrics.instrument(provider)); + + Map catalog = + LanceMetrics.catalog().stream() + .collect(Collectors.toMap(MetricDescription::getName, Function.identity())); + assertTrue(catalog.containsKey(REQUESTS)); + assertEquals("histogram", catalog.get(DURATION).getKind()); + assertEquals("counter", catalog.get(RETRYABLE).getKind()); + assertEquals("gauge", catalog.get(IN_FLIGHT).getKind()); + + generateObjectStoreMetrics(tempDir.resolve("otel_metrics.lance")); + + MetricPoint requests = + LanceMetrics.snapshot().stream() + .filter(point -> REQUESTS.equals(point.getName())) + .findFirst() + .orElseThrow(() -> new AssertionError("expected object store request metric")); + assertNotNull(requests.getValue()); + assertTrue(requests.getValue() > 0); + assertTrue(requests.getAttributes().containsKey("operation")); + assertTrue(requests.getAttributes().containsKey("base")); + + Collection metrics = reader.collectAllMetrics(); + Map metricsByName = + metrics.stream().collect(Collectors.toMap(MetricData::getName, Function.identity())); + Set names = metricsByName.keySet(); + assertTrue(names.contains(REQUESTS)); + assertTrue(names.contains(DURATION + "_bucket")); + assertTrue(names.contains(DURATION + "_count")); + assertTrue(names.contains(DURATION + "_sum")); + + Collection bucketPoints = + metricsByName.get(DURATION + "_bucket").getDoubleSumData().getPoints(); + Collection countPoints = + metricsByName.get(DURATION + "_count").getDoubleSumData().getPoints(); + Collection sumPoints = + metricsByName.get(DURATION + "_sum").getDoubleSumData().getPoints(); + + assertFalse(bucketPoints.isEmpty()); + assertTrue( + bucketPoints.stream() + .allMatch(point -> point.getAttributes().get(AttributeKey.stringKey("le")) != null)); + + Map countsByAttributes = + countPoints.stream() + .collect(Collectors.toMap(DoublePointData::getAttributes, DoublePointData::getValue)); + Map infiniteBucketsByAttributes = new HashMap<>(); + for (DoublePointData bucketPoint : bucketPoints) { + if ("+Inf".equals(bucketPoint.getAttributes().get(AttributeKey.stringKey("le")))) { + Attributes attributes = + bucketPoint.getAttributes().toBuilder().remove(AttributeKey.stringKey("le")).build(); + infiniteBucketsByAttributes.put(attributes, bucketPoint.getValue()); + } + } + assertEquals(countsByAttributes, infiniteBucketsByAttributes); + assertTrue(sumPoints.stream().anyMatch(point -> point.getValue() > 0)); + } finally { + provider.close(); + } + } + + @Test + void testInstrumentReRegistersWithNewProvider(@TempDir Path tempDir) { + InMemoryMetricReader firstReader = InMemoryMetricReader.create(); + InMemoryMetricReader secondReader = InMemoryMetricReader.create(); + SdkMeterProvider firstProvider = + SdkMeterProvider.builder().registerMetricReader(firstReader).build(); + SdkMeterProvider secondProvider = + SdkMeterProvider.builder().registerMetricReader(secondReader).build(); + + try { + assertTrue(LanceMetrics.instrument(firstProvider)); + generateObjectStoreMetrics(tempDir.resolve("first_provider.lance")); + assertTrue(metricNames(firstReader).contains(REQUESTS)); + + assertTrue(LanceMetrics.instrument(secondProvider)); + assertTrue(metricNames(secondReader).contains(REQUESTS)); + assertFalse(metricNames(firstReader).contains(REQUESTS)); + } finally { + firstProvider.close(); + secondProvider.close(); + } + } + + @Test + void testSupportedMetricsSkipsUnknownKinds() { + MetricDescription counter = new MetricDescription("counter", "counter", null, "counter"); + MetricDescription unknown = new MetricDescription("unknown", "summary", null, "unknown"); + + List supported = + LanceMetrics.supportedMetrics(Arrays.asList(counter, unknown)); + + assertEquals(1, supported.size()); + assertEquals("counter", supported.get(0).getName()); + } + + private static void generateObjectStoreMetrics(Path datasetPath) { + try (RootAllocator allocator = new RootAllocator(Long.MAX_VALUE)) { + TestUtils.SimpleTestDataset testDataset = + new TestUtils.SimpleTestDataset(allocator, datasetPath.toString()); + try (Dataset dataset = testDataset.createEmptyDataset()) { + assertEquals(0, dataset.countRows()); + } + } + } + + private static Set metricNames(InMemoryMetricReader reader) { + Collection metrics = reader.collectAllMetrics(); + return metrics.stream().map(MetricData::getName).collect(Collectors.toSet()); + } +} diff --git a/java/src/test/resources/language_models/jieba/default/dict.txt b/java/src/test/resources/language_models/jieba/default/dict.txt new file mode 100644 index 00000000000..d127091108a --- /dev/null +++ b/java/src/test/resources/language_models/jieba/default/dict.txt @@ -0,0 +1,4 @@ +我们 1000 r +都有 1000 v +光明 1000 a +�途 1000 n diff --git a/memtest/Cargo.toml b/memtest/Cargo.toml index ef4cd5736ab..83b8b53523a 100644 --- a/memtest/Cargo.toml +++ b/memtest/Cargo.toml @@ -3,7 +3,7 @@ [package] name = "lance-memtest" version = "0.1.0" -edition = "2021" +edition = "2024" authors = ["Lance Developers"] description = "Memory allocation testing utilities for Python" license = "Apache-2.0" diff --git a/memtest/pyproject.toml b/memtest/pyproject.toml index 396d7c442e0..4418d0e19c8 100644 --- a/memtest/pyproject.toml +++ b/memtest/pyproject.toml @@ -7,7 +7,7 @@ name = "lance-memtest" version = "0.1.0" description = "Memory allocation testing utilities for Python test suites" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" license = { text = "Apache-2.0" } authors = [ { name = "Lance Developers" } @@ -17,7 +17,6 @@ classifiers = [ "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", diff --git a/memtest/src/allocator.rs b/memtest/src/allocator.rs index 5094207850f..659d5c66c33 100644 --- a/memtest/src/allocator.rs +++ b/memtest/src/allocator.rs @@ -5,7 +5,7 @@ use libc::{c_void, size_t}; mod sys { use super::*; - extern "C" { + unsafe extern "C" { #[link_name = "__libc_malloc"] fn libc_malloc(size: size_t) -> *mut c_void; #[link_name = "__libc_calloc"] @@ -48,7 +48,7 @@ mod sys { _private: [u8; 0], } - extern "C" { + unsafe extern "C" { fn malloc_default_zone() -> *mut malloc_zone_t; fn malloc_zone_malloc(zone: *mut malloc_zone_t, size: size_t) -> *mut c_void; fn malloc_zone_calloc( @@ -215,14 +215,14 @@ fn is_valid_posix_memalign_alignment(alignment: usize) -> bool { is_power_of_two(alignment) && alignment >= std::mem::size_of::<*mut c_void>() } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn malloc(size: size_t) -> *mut c_void { STATS.record_allocation(size); to_virtual(sys::malloc(size.saturating_add(HEADER_SIZE)), size, 0) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn calloc(size: size_t, element_size: size_t) -> *mut c_void { let Some(total_size) = size.checked_mul(element_size) else { @@ -236,7 +236,7 @@ pub unsafe extern "C" fn calloc(size: size_t, element_size: size_t) -> *mut c_vo ) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_malloc(size: size_t) -> *mut c_void { let ptr = sys::malloc(size); @@ -246,7 +246,7 @@ pub unsafe extern "C" fn memtest_malloc(size: size_t) -> *mut c_void { ptr } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_calloc(count: size_t, element_size: size_t) -> *mut c_void { let Some(_total_size) = count.checked_mul(element_size) else { @@ -259,7 +259,7 @@ pub unsafe extern "C" fn memtest_calloc(count: size_t, element_size: size_t) -> ptr } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn free(ptr: *mut c_void) { if ptr.is_null() { @@ -277,7 +277,7 @@ pub unsafe extern "C" fn free(ptr: *mut c_void) { } } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_free(ptr: *mut c_void) { if ptr.is_null() { @@ -287,7 +287,7 @@ pub unsafe extern "C" fn memtest_free(ptr: *mut c_void) { sys::free(ptr); } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn realloc(ptr: *mut c_void, size: size_t) -> *mut c_void { let (old_size, actual_ptr) = if ptr.is_null() || !is_ours(ptr) { @@ -310,7 +310,7 @@ pub unsafe extern "C" fn realloc(ptr: *mut c_void, size: size_t) -> *mut c_void to_virtual(new_ptr, size, 0) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_realloc(ptr: *mut c_void, size: size_t) -> *mut c_void { if ptr.is_null() { @@ -336,7 +336,7 @@ pub unsafe extern "C" fn memtest_realloc(ptr: *mut c_void, size: size_t) -> *mut new_ptr } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn memalign(alignment: size_t, size: size_t) -> *mut c_void { STATS.record_allocation(size); @@ -347,7 +347,7 @@ pub unsafe extern "C" fn memalign(alignment: size_t, size: size_t) -> *mut c_voi to_virtual(actual_ptr, size, alignment) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn posix_memalign( memptr: *mut *mut c_void, @@ -364,7 +364,7 @@ pub unsafe extern "C" fn posix_memalign( 0 } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn aligned_alloc(alignment: size_t, size: size_t) -> *mut c_void { STATS.record_allocation(size); @@ -373,7 +373,7 @@ pub unsafe extern "C" fn aligned_alloc(alignment: size_t, size: size_t) -> *mut to_virtual(actual_ptr, size, alignment) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn valloc(size: size_t) -> *mut c_void { STATS.record_allocation(size); @@ -383,7 +383,7 @@ pub unsafe extern "C" fn valloc(size: size_t) -> *mut c_void { to_virtual(actual_ptr, size, page_size) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_posix_memalign( memptr: *mut *mut c_void, @@ -406,7 +406,7 @@ pub unsafe extern "C" fn memtest_posix_memalign( 0 } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_aligned_alloc(alignment: size_t, size: size_t) -> *mut c_void { if !is_valid_posix_memalign_alignment(alignment as usize) { @@ -423,7 +423,7 @@ pub unsafe extern "C" fn memtest_aligned_alloc(alignment: size_t, size: size_t) ptr } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_valloc(size: size_t) -> *mut c_void { let page_size = libc::sysconf(libc::_SC_PAGESIZE) as size_t; @@ -434,7 +434,7 @@ pub unsafe extern "C" fn memtest_valloc(size: size_t) -> *mut c_void { ptr } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn reallocarray( old_ptr: *mut c_void, @@ -447,7 +447,7 @@ pub unsafe extern "C" fn reallocarray( realloc(old_ptr, size) } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "linux")] pub unsafe extern "C" fn malloc_usable_size(ptr: *mut c_void) -> size_t { if ptr.is_null() { @@ -464,7 +464,7 @@ pub unsafe extern "C" fn malloc_usable_size(ptr: *mut c_void) -> size_t { } } -#[no_mangle] +#[unsafe(no_mangle)] #[cfg(target_os = "macos")] pub unsafe extern "C" fn memtest_malloc_usable_size(ptr: *mut c_void) -> size_t { if ptr.is_null() { @@ -484,7 +484,7 @@ struct Interpose { unsafe impl Sync for Interpose {} #[cfg(target_os = "macos")] -extern "C" { +unsafe extern "C" { fn malloc(size: size_t) -> *mut c_void; fn calloc(count: size_t, element_size: size_t) -> *mut c_void; fn realloc(ptr: *mut c_void, size: size_t) -> *mut c_void; @@ -496,7 +496,7 @@ extern "C" { #[cfg(target_os = "macos")] #[used] -#[link_section = "__DATA,__interpose"] +#[unsafe(link_section = "__DATA,__interpose")] static INTERPOSE_TABLE: [Interpose; 7] = [ Interpose { replacement: memtest_malloc as *const () as *const c_void, diff --git a/memtest/src/lib.rs b/memtest/src/lib.rs index 4c869864552..e505f666e0d 100644 --- a/memtest/src/lib.rs +++ b/memtest/src/lib.rs @@ -18,11 +18,14 @@ pub struct MemtestStats { /// /// # Safety /// The `stats` pointer must be valid and properly aligned -#[no_mangle] +#[unsafe(no_mangle)] pub unsafe extern "C" fn memtest_get_stats(stats: *mut MemtestStats) { if stats.is_null() { return; } + if (stats as usize).wrapping_rem(std::mem::align_of::()) != 0 { + return; + } (*stats).total_allocations = STATS .total_allocations @@ -43,7 +46,33 @@ pub unsafe extern "C" fn memtest_get_stats(stats: *mut MemtestStats) { } /// Reset all statistics to zero -#[no_mangle] +#[unsafe(no_mangle)] pub extern "C" fn memtest_reset_stats() { STATS.reset(); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_get_stats_null() { + unsafe { + memtest_get_stats(std::ptr::null_mut()); + } + } + + #[test] + fn test_get_stats_misaligned() { + unsafe { + let align = std::mem::align_of::(); + let size = std::mem::size_of::(); + let buf_size = size.saturating_add(align).saturating_add(align); + let mut buf = vec![0u8; buf_size]; + let base = buf.as_mut_ptr() as usize; + let offset = if base.wrapping_rem(align) == 0 { 1 } else { 0 }; + let misaligned = buf.as_mut_ptr().add(offset) as *mut MemtestStats; + memtest_get_stats(misaligned); + } + } +} diff --git a/protos/AGENTS.md b/protos/AGENTS.md index 08a5f906787..18039dd2490 100644 --- a/protos/AGENTS.md +++ b/protos/AGENTS.md @@ -1,2 +1,27 @@ -All changes should be backwards compatible. Don't re-use field numbers of change -field numbers of existing fields. \ No newline at end of file +# Protobuf Guidelines + +Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. + +## Change Process + +- Changes to protobuf schemas that define a persisted Lance format require a PMC vote on the pull request, enforced by the `format-spec-vote` CI gate. See [Lance Format Specification Changes](../docs/src/community/voting.md#lance-format-specification-changes). +- Keep a persisted-format proto change in its own PR, together with the matching `docs/src/format/` change and only the library edits needed to compile. Put the implementation in a follow-up PR — voters need to read the contract, not its implementation. +- Execution-plan schemas (`ann.proto`, `filtered_read.proto`, and `table_identifier.proto`) are wire contracts, not persisted Lance formats. Changes to them belong with their implementation and do not require a format vote or a `docs/src/format/` change. + +## Compatibility + +- Protobuf schemas that are part of a stable file format or any other stable persisted contract must remain backwards compatible. Never reuse or change their existing field numbers. +- Protobuf schemas used exclusively by an unstable file format follow the root file-format stability contract: do not preserve compatibility with prior unstable revisions. Before making a breaking protobuf change, verify that the schema is not shared with a stable format or another persisted contract. +- Execution wire schemas may still cross process or version boundaries. Preserve their field-number compatibility unless all producers and consumers are upgraded atomically. + +## Schema Design + +- Use `optional` when you need to distinguish "not set" from "zero value" — `optional` enables presence tracking (`has_*` methods) and maps to `Option` in Rust. Bare proto3 fields have no presence semantics: they always hold a value (defaulting to zero), so you cannot tell if the sender explicitly set them. +- Use structured message types (e.g., `BasePath`) instead of plain scalars, and scope fields to operation-specific messages (e.g., `InsertTransaction`) rather than generic top-level ones. +- Don't duplicate data across messages — store each fact once and derive relationships. Prefer parallel sequences over maps when keys already exist in another field. + +## Documentation + +- Document the semantic meaning of both present and absent states for `optional` fields — explain when each case applies. +- Use precise domain terminology in field descriptions — avoid ambiguous abbreviations or terms that collide with domain concepts. +- Write multi-line comments as `/* text` / ` * text` / ` */` blocks so editors can fold them; keep single-line comments as `//`. The `ci/check_proto_comments.py` check (pre-commit hook and the `Protobuf lint` workflow) enforces this and can rewrite offenders with `--fix`. diff --git a/protos/CLAUDE.md b/protos/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/protos/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/protos/ann.proto b/protos/ann.proto new file mode 100644 index 00000000000..c8f0449fcc0 --- /dev/null +++ b/protos/ann.proto @@ -0,0 +1,98 @@ +// 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"; + +/* Query-time approximation mode for vector search. + * + * This currently only affects RQ-quantized vector indexes, such as IVF_RQ. + * Other index types ignore this setting. + */ +enum VectorApproxMode { + // Use all RQ bits for query-time scoring with u8-quantized lookup tables. + Normal = 0; + // Use only one RQ bit for query-time scoring, even for multi-bit indexes. + Fast = 1; + /* Use all RQ bits for query-time scoring with u16-quantized lookup tables + * to reduce estimator quantization error. + */ + Accurate = 2; +} + +/* The covering ("included") index columns a query needs materialized, as dataset + * field ids. + * + * Exists as a message rather than a bare `repeated int32` because proto3 has no + * presence tracking for repeated fields, and the empty list is a distinct, meaningful + * state here: + * + * * absent: no narrowing computed; materialize every covering column declared. + * * present and empty: materialize nothing, though the index does declare covering. + * * present and non-empty: materialize exactly these. + */ +message CoveringProjection { + repeated int32 field_ids = 1; +} + +// 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; + optional int32 query_parallelism = 13; + /* Query-time approximation mode. Currently only affects RQ-quantized vector + * indexes, such as IVF_RQ. Other index types ignore this setting. + */ + VectorApproxMode approx_mode = 14; + /* Which covering columns the index must materialize for this query. Absent means + * "not computed" -- see CoveringProjection. Carried across the wire so a remote + * executor declares the same search output schema the planner did; without it the + * executor's node is wider than the plan it came from, and the surrounding nodes + * were built against the planner's narrower schema. + */ + CoveringProjection covering_projection = 15; +} + +/* Serializable form of ANNIvfSubIndexExec — the IVF sub-index search node. + * + * The prefilter child ExecutionPlan is serialized by DataFusion's codec + * automatically via children() / with_new_children(). The prefilter_type + * field tells the decoder which PreFilterSource variant to use when + * reconstructing from the deserialized child inputs. + */ +message ANNIvfSubIndexExecProto { + enum PreFilterType { + NONE = 0; + FILTERED_ROW_IDS = 1; + SCALAR_INDEX_QUERY = 2; + } + + VectorQueryProto query = 1; + lance.datafusion.TableIdentifier table = 2; + repeated lance.table.IndexMetadata indices = 3; + PreFilterType prefilter_type = 4; +} + +// Serializable form of ANNIvfPartitionExec — the IVF centroid routing node. +message ANNIvfPartitionExecProto { + VectorQueryProto query = 1; + lance.datafusion.TableIdentifier table = 2; + repeated string index_uuids = 3; +} diff --git a/protos/encodings_v2_0.proto b/protos/encodings_v2_0.proto index acfc150875d..45776fb3400 100644 --- a/protos/encodings_v2_0.proto +++ b/protos/encodings_v2_0.proto @@ -7,38 +7,40 @@ package lance.encodings; import "google/protobuf/empty.proto"; -// This file contains a specification for encodings that can be used -// to store and load Arrow data into a Lance file for the 2.0 format. It -// has been superseded by encodings21.proto which is used for the 2.1 format. -// -// # Types -// -// This file assumes the user wants to load data into Arrow arrays and -// explains how to map Arrow arrays into Lance files. Encodings are divided -// into "array encoding" (which maps to an Arrow array and may contain multiple -// buffers) and "buffer encoding" (which encodes a single buffer of data). -// -// # Encoding Tree -// -// Most encodings are layered on top of each other. These form a tree of -// encodings with a single root node. To encode an array you will typically -// start with the root node and then take the output from that root encoding -// and feed it into child encodings. The decoding process works in reverse. -// -// # Multi-column Encodings -// -// Some Arrow arrays will map to more than one column of Lance data. For -// example, struct arrays and list arrays. This file only contains encodings -// for a single column. However, it does describe how multi-column arrays can -// be encoded. - -// A pointer to a buffer in a Lance file -// -// A writer can place a buffer in three different locations. The buffer -// can go in the data page, in the column metadata, or in the file metadata. -// The writer is free to choose whatever is most appropriate (for example, a dictionary -// that is shared across all pages in a column will probably go in the column -// metadata). This specification does not dictate where the buffer should go. +/* This file contains a specification for encodings that can be used + * to store and load Arrow data into a Lance file for the 2.0 format. It + * has been superseded by encodings21.proto which is used for the 2.1 format. + * + * # Types + * + * This file assumes the user wants to load data into Arrow arrays and + * explains how to map Arrow arrays into Lance files. Encodings are divided + * into "array encoding" (which maps to an Arrow array and may contain multiple + * buffers) and "buffer encoding" (which encodes a single buffer of data). + * + * # Encoding Tree + * + * Most encodings are layered on top of each other. These form a tree of + * encodings with a single root node. To encode an array you will typically + * start with the root node and then take the output from that root encoding + * and feed it into child encodings. The decoding process works in reverse. + * + * # Multi-column Encodings + * + * Some Arrow arrays will map to more than one column of Lance data. For + * example, struct arrays and list arrays. This file only contains encodings + * for a single column. However, it does describe how multi-column arrays can + * be encoded. + */ + +/* A pointer to a buffer in a Lance file + * + * A writer can place a buffer in three different locations. The buffer + * can go in the data page, in the column metadata, or in the file metadata. + * The writer is free to choose whatever is most appropriate (for example, a dictionary + * that is shared across all pages in a column will probably go in the column + * metadata). This specification does not dictate where the buffer should go. + */ message Buffer { // The index of the buffer in the collection of buffers uint32 buffer_index = 1; @@ -54,9 +56,10 @@ message Buffer { BufferType buffer_type = 2; } -// An encoding that adds nullability to another array encoding -// -// This can wrap any array encoding and add nullability information +/* An encoding that adds nullability to another array encoding + * + * This can wrap any array encoding and add nullability information + */ message Nullable { message NoNull { ArrayEncoding values = 1; @@ -78,74 +81,77 @@ message Nullable { // An array encoding for variable-length list fields message List { - // An array containing the offsets into an items array. - // - // This array will have num_rows items and will never - // have nulls. - // - // If the list at index i is not null then offsets[i] will - // contain `base + len(list)` where `base` is defined as: - // i == 0: 0 - // i > 0: (offsets[i-1] % null_offset_adjustment) - // - // To help understand we can consider the following example list: - // [ [A, B], null, [], [C, D, E] ] - // - // The offsets will be [2, ?, 2, 5] - // - // If the incoming list at index i IS null then offsets[i] will - // contain `base + len(list) + null_offset_adjustment` where `base` - // is defined the same as above. - // - // To complete the above example let's assume that `null_offset_adjustment` - // is 7. Then the offsets will be [2, 9, 2, 5] - // - // If there are no nulls then the offsets we write here are exactly the - // same as the offsets in an Arrow list array (except we omit the leading - // 0 which is redundant) - // - // The reason we do this is so that reading a single list at index i only - // requires us to load the indices at i and i-1. - // - // If the offset at index i is greater than `null_offset_adjustment`` - // then the list at index i is null. - // - // Otherwise the length of the list is `offsets[i] - base` where - // base is defined the same as above. - // - // Let's consider our example offsets: [2, 9, 2, 5] - // - // We can take any range of lists and determine how many list items are - // referenced by the sublist. - // - // 0..3: [_, 5] -> items 0..5 (base = 0* and end is 5) - // 0..2: [_, 2] -> items 0..2 (base = 0* and end is 2) - // 0..1: [_, 9] -> items 0..2 (base = 0* and end is 9 % 7) - // 1..3: [2, 5] -> items 2..5 (base = 2 and end is 5) - // 1..2: [2, 2] -> items 2..2 (base = 2 and end is 2) - // 2..3: [9, 5] -> items 2..5 (base = 9 % 7 and end is 5) - // - // * When the start of our range is the 0th item the base is always 0 and we only - // need to load a single index from disk to determine the range. - // - // The data type of the offsets array is flexible and does not need - // to match the data type of the destination array. Please note that the offsets - // array is very likely to be efficiently encoded by bit packing deltas. + /* An array containing the offsets into an items array. + * + * This array will have num_rows items and will never + * have nulls. + * + * If the list at index i is not null then offsets[i] will + * contain `base + len(list)` where `base` is defined as: + * i == 0: 0 + * i > 0: (offsets[i-1] % null_offset_adjustment) + * + * To help understand we can consider the following example list: + * [ [A, B], null, [], [C, D, E] ] + * + * The offsets will be [2, ?, 2, 5] + * + * If the incoming list at index i IS null then offsets[i] will + * contain `base + len(list) + null_offset_adjustment` where `base` + * is defined the same as above. + * + * To complete the above example let's assume that `null_offset_adjustment` + * is 7. Then the offsets will be [2, 9, 2, 5] + * + * If there are no nulls then the offsets we write here are exactly the + * same as the offsets in an Arrow list array (except we omit the leading + * 0 which is redundant) + * + * The reason we do this is so that reading a single list at index i only + * requires us to load the indices at i and i-1. + * + * If the offset at index i is greater than `null_offset_adjustment`` + * then the list at index i is null. + * + * Otherwise the length of the list is `offsets[i] - base` where + * base is defined the same as above. + * + * Let's consider our example offsets: [2, 9, 2, 5] + * + * We can take any range of lists and determine how many list items are + * referenced by the sublist. + * + * 0..3: [_, 5] -> items 0..5 (base = 0* and end is 5) + * 0..2: [_, 2] -> items 0..2 (base = 0* and end is 2) + * 0..1: [_, 9] -> items 0..2 (base = 0* and end is 9 % 7) + * 1..3: [2, 5] -> items 2..5 (base = 2 and end is 5) + * 1..2: [2, 2] -> items 2..2 (base = 2 and end is 2) + * 2..3: [9, 5] -> items 2..5 (base = 9 % 7 and end is 5) + * + * * When the start of our range is the 0th item the base is always 0 and we only + * need to load a single index from disk to determine the range. + * + * The data type of the offsets array is flexible and does not need + * to match the data type of the destination array. Please note that the offsets + * array is very likely to be efficiently encoded by bit packing deltas. + */ ArrayEncoding offsets = 1; - // If a list is null then we add this value to the offset - // - // This value must be greater than the length of the items so that - // (offset + null_offset_adjustment) is never used by a non-null list. - // - // Note that this value cannot be equal to the length of the items - // because then a page with a single list would store [ X ] and we - // couldn't know if that is a null list or a list with X items. - // - // Therefore, the best choice for this value is 1 + # of items. - // Choosing this will maximize the bit packing that we can apply to the offsets. + /* If a list is null then we add this value to the offset + * + * This value must be greater than the length of the items so that + * (offset + null_offset_adjustment) is never used by a non-null list. + * + * Note that this value cannot be equal to the length of the items + * because then a page with a single list would store [ X ] and we + * couldn't know if that is a null list or a list with X items. + * + * Therefore, the best choice for this value is 1 + # of items. + * Choosing this will maximize the bit packing that we can apply to the offsets. + */ uint64 null_offset_adjustment = 2; - // How many items are referenced by these offsets. This is needed in - // order to determine which items pages map to this offsets page. + /* How many items are referenced by these offsets. This is needed in + * order to determine which items pages map to this offsets page. + */ uint64 num_items = 3; } @@ -166,16 +172,18 @@ message Compression { // Fixed width items placed contiguously in a buffer message Flat { - // the number of bits per value, must be greater than 0, does - // not need to be a multiple of 8 + /* the number of bits per value, must be greater than 0, does + * not need to be a multiple of 8 + */ uint64 bits_per_value = 1; // the buffer of values Buffer buffer = 2; - // The Compression message can specify the compression scheme (e.g. zstd) and any - // other information that is needed for decompression. - // - // If this array is compressed then the bits_per_value refers to the uncompressed - // data. + /* The Compression message can specify the compression scheme (e.g. zstd) and any + * other information that is needed for decompression. + * + * If this array is compressed then the bits_per_value refers to the uncompressed + * data. + */ Compression compression = 3; } @@ -226,11 +234,12 @@ message OutOfLineBitpacking { uint64 compressed_bits_per_value = 3; } -// An array encoding for shredded structs that will never be null -// -// There is no actual data in this column. -// -// TODO: Struct validity bitmaps will be placed here. +/* An array encoding for shredded structs that will never be null + * + * There is no actual data in this column. + * + * TODO: Struct validity bitmaps will be placed here. + */ message SimpleStruct {} // An array encoding for binary fields @@ -322,16 +331,18 @@ message ArrayEncoding { } } -// Wraps a column with a zone map index that can be used -// to apply pushdown filters +/* Wraps a column with a zone map index that can be used + * to apply pushdown filters + */ message ZoneIndex { uint32 rows_per_zone = 1; Buffer zone_map_buffer = 2; ColumnEncoding inner = 3; } -// Marks a column as blob data. It will contain a packed struct -// with fields position and size (u64) +/* Marks a column as blob data. It will contain a packed struct + * with fields position and size (u64) + */ message Blob { ColumnEncoding inner = 1; } diff --git a/protos/encodings_v2_1.proto b/protos/encodings_v2_1.proto index 83c8c771227..643aa1f9da6 100644 --- a/protos/encodings_v2_1.proto +++ b/protos/encodings_v2_1.proto @@ -5,49 +5,52 @@ syntax = "proto3"; package lance.encodings21; -// This file contains a specification for encodings that can be used -// to store and load Arrow data into a Lance file for the 2.1 format. -// -// # Types -// -// This file assumes the user wants to load data into Arrow arrays and -// explains how to map Arrow arrays into Lance files. Encodings are divided -// into "structural encodings" (which are used to encode the structure of the -// data such as any list or struct layers) and "compressive encodings" (which -// are used to compress the actual data values). -// -// # Standardized Interpretation of Counting Terms -// -// When working with 2.1 encodings we have a number of different "counting terms" and it can be -// difficult to understand what we mean when we are talking about a "number of values". Here is -// a standard interpretation of these terms: -// -// To understand these definitions consider a data type FIXED_SIZE_LIST>. -// -// A "value" is an abstract term when we aren't being specific. -// -// - num_rows: This is the highest level counting term. A single row includes everything in the -// fixed size list. This is what the user asks for when they asks for a range of rows. -// - num_elements: The number of elements is the number of rows multiplied by the dimension of any -// fixed size list wrappers. This is what you get when you flatten the FSL layer and -// is the starting point for structural encoding. Note that an element can be a list -// value or a single primitive value. -// - num_items: The number of items is the number of values in the repetition and definition vectors -// after everything has been flattened. -// - num_visible_items: The number of visible items is the number of items after invisible items -// have been removed. Invisible items are rep/def levels that don't correspond to an -// actual value. - - -// # Structural Encodings -// -// The following message are used to describe the structural encoding of the -// data. In this document, we refer to these structural encodings as layouts. - -// Repetition and definition levels are described in more detail elsewhere. As we peel through -// the structure of an array we will encounter layers of struct and list. Each of these layers -// potentially adds a new level to the repetition and definition levels. This message describes -// the meaning of each layer. +/* This file contains a specification for encodings that can be used + * to store and load Arrow data into a Lance file for the 2.1 format. + * + * # Types + * + * This file assumes the user wants to load data into Arrow arrays and + * explains how to map Arrow arrays into Lance files. Encodings are divided + * into "structural encodings" (which are used to encode the structure of the + * data such as any list or struct layers) and "compressive encodings" (which + * are used to compress the actual data values). + * + * # Standardized Interpretation of Counting Terms + * + * When working with 2.1 encodings we have a number of different "counting terms" and it can be + * difficult to understand what we mean when we are talking about a "number of values". Here is + * a standard interpretation of these terms: + * + * To understand these definitions consider a data type FIXED_SIZE_LIST>. + * + * A "value" is an abstract term when we aren't being specific. + * + * - num_rows: This is the highest level counting term. A single row includes everything in the + * fixed size list. This is what the user asks for when they asks for a range of rows. + * - num_elements: The number of elements is the number of rows multiplied by the dimension of any + * fixed size list wrappers. This is what you get when you flatten the FSL layer and + * is the starting point for structural encoding. Note that an element can be a list + * value or a single primitive value. + * - num_items: The number of items is the number of values in the repetition and definition vectors + * after everything has been flattened. + * - num_visible_items: The number of visible items is the number of items after invisible items + * have been removed. Invisible items are rep/def levels that don't correspond to an + * actual value. + */ + + +/* # Structural Encodings + * + * The following message are used to describe the structural encoding of the + * data. In this document, we refer to these structural encodings as layouts. + */ + +/* Repetition and definition levels are described in more detail elsewhere. As we peel through + * the structure of an array we will encounter layers of struct and list. Each of these layers + * potentially adds a new level to the repetition and definition levels. This message describes + * the meaning of each layer. + */ enum RepDefLayer { // Should never be used, included for debugging purporses and general protobuf best practice REPDEF_UNSPECIFIED = 0; @@ -65,72 +68,81 @@ enum RepDefLayer { REPDEF_NULL_AND_EMPTY_LIST = 6; } -// A layout used for pages where the data is small -// -// In this case we can fit many values into a single disk sector and transposing buffers is -// expensive. As a result, we do not transpose the buffers but compress the data into small -// chunks (called mini blocks) which are roughly the size of a disk sector. -// -// The end result is a small amount of read amplification (since we must read an entire page -// at a time) but we have more flexibility in compression and do less work per value when -// compressing and decompressing in bulk. +/* A layout used for pages where the data is small + * + * In this case we can fit many values into a single disk sector and transposing buffers is + * expensive. As a result, we do not transpose the buffers but compress the data into small + * chunks (called mini blocks) which are roughly the size of a disk sector. + * + * The end result is a small amount of read amplification (since we must read an entire page + * at a time) but we have more flexibility in compression and do less work per value when + * compressing and decompressing in bulk. + */ message MiniBlockLayout { - // Description of the compression of repetition levels (e.g. how many bits per rep) - // - // Optional, if there is no repetition then this field is not present + /* Description of the compression of repetition levels (e.g. how many bits per rep) + * + * Optional, if there is no repetition then this field is not present + */ CompressiveEncoding rep_compression = 1; - // Description of the compression of definition levels (e.g. how many bits per def) - // - // Optional, if there is no definition then this field is not present + /* Description of the compression of definition levels (e.g. how many bits per def) + * + * Optional, if there is no definition then this field is not present + */ CompressiveEncoding def_compression = 2; // Description of the compression of values CompressiveEncoding value_compression = 3; - // Description of the compression of the dictionary data - // - // Optional, if there is no dictionary then this field is not present + /* Description of the compression of the dictionary data + * + * Optional, if there is no dictionary then this field is not present + */ CompressiveEncoding dictionary = 4; // Number of items in the dictionary uint64 num_dictionary_items = 5; // The meaning of each repdef layer, used to interpret repdef buffers correctly repeated RepDefLayer layers = 6; - // The number of buffers in each mini-block, this is determined by the compression and does - // NOT include the repetition or definition buffers (the presence of these buffers can be determined - // by looking at the rep_compression and def_compression fields) + /* The number of buffers in each mini-block, this is determined by the compression and does + * NOT include the repetition or definition buffers (the presence of these buffers can be determined + * by looking at the rep_compression and def_compression fields) + */ uint64 num_buffers = 7; - // The depth of the repetition index. - // - // If there is repetition then the depth must be at least 1. If there are many layers - // of repetition then deeper repetition indices will support deeper nested random access. For - // example, given 5 layers of repetition then the repetition index depth must be at least - // 3 to support access like `rows[50][17][3]`. - // - // We require `repetition_index_depth + 1` u64 values per mini-block to store the repetition - // index if the `repetition_index_depth` is greater than 0. The +1 is because we need to store - // the number of "leftover items" at the end of the chunk. Otherwise, we wouldn't have any way - // to know if the final item in a chunk is valid or not. + /* The depth of the repetition index. + * + * If there is repetition then the depth must be at least 1. If there are many layers + * of repetition then deeper repetition indices will support deeper nested random access. For + * example, given 5 layers of repetition then the repetition index depth must be at least + * 3 to support access like `rows[50][17][3]`. + * + * We require `repetition_index_depth + 1` u64 values per mini-block to store the repetition + * index if the `repetition_index_depth` is greater than 0. The +1 is because we need to store + * the number of "leftover items" at the end of the chunk. Otherwise, we wouldn't have any way + * to know if the final item in a chunk is valid or not. + */ uint32 repetition_index_depth = 8; - // The page already records how many rows are in the page. For mini-block we also need to know how - // many "items" are in the page. A row and an item are the same thing unless the page has lists. + /* The page already records how many rows are in the page. For mini-block we also need to know how + * many "items" are in the page. A row and an item are the same thing unless the page has lists. + */ uint64 num_items = 9; // Since Lance 2.2, miniblocks have larger chunk sizes (>= 64KB) bool has_large_chunk = 10; } -// A layout used for pages where the data is large -// -// In this case the cost of transposing the data is relatively small (compared to the cost of writing the data) -// and so we just zip the buffers together +/* A layout used for pages where the data is large + * + * In this case the cost of transposing the data is relatively small (compared to the cost of writing the data) + * and so we just zip the buffers together + */ message FullZipLayout { // The number of bits of repetition info (0 if there is no repetition) uint32 bits_rep = 1; // The number of bits of definition info (0 if there is no definition) uint32 bits_def = 2; - // The number of bits of value info - // - // Note: we use bits here (and not bytes) for consistency with other encodings. However, in practice, - // there is never a reason to use a bits per value that is not a multiple of 8. The complexity is not - // worth the small savings in space since this encoding is typically used with large values already. + /* The number of bits of value info + * + * Note: we use bits here (and not bytes) for consistency with other encodings. However, in practice, + * there is never a reason to use a bits per value that is not a multiple of 8. The complexity is not + * worth the small savings in space since this encoding is typically used with large values already. + */ oneof details { // If this is a fixed width block then we need to have a fixed number of bits per value uint32 bits_per_value = 3; @@ -147,39 +159,182 @@ message FullZipLayout { repeated RepDefLayer layers = 8; } -// A layout used for pages where all (visible) values are the same scalar value. -// -// This generalizes the prior AllNullLayout semantics for file_version >= 2.2. -// -// There may be buffers of repetition and definition information if required in order -// to interpret what kind of nulls are present / which items are visible. +/* A layout used for sparse flat or nested pages where Arrow structure is represented directly + * in layer-local slot domains instead of as dense repetition / definition events. + * + * Structural layers are ordered from outer-most to inner-most. Values remain mini-block + * compressed and are split into independently readable chunks. + */ +message SparseLayout { + // Description of the compression of values. + CompressiveEncoding value_compression = 1; + // Number of value buffers in each mini-block chunk. This does not include structural buffers. + uint64 num_buffers = 2; + /* Number of entries in the equivalent dense repetition / definition stream. This equals + * num_visible_items plus one structural placeholder for every list slot without children. + * Null leaf slots count as visible items because they still occupy positions in Arrow's + * leaf value buffer. For example, a nullable primitive with 100 slots, 30 of them null, + * has num_items = num_visible_items = 100. + */ + uint64 num_items = 3; + // Number of leaf value slots encoded in the value chunks, including null leaf slots. + uint64 num_visible_items = 4; + // If true, chunk-local value buffer sizes use u32. Otherwise they use u16. + bool has_large_chunk = 5; + /* Structural layers ordered from outer-most to inner-most. This may be empty for a flat, + * non-nullable leaf page whose scheduling domain equals num_visible_items. + */ + repeated SparseStructuralLayer structural_layers = 6; +} + +/* A domain is a layer-local integer coordinate space [0, num_slots). A slot is one + * element in that space. The outer-most domain is the page's top-level rows; each + * layer's child domain is the next layer's parent domain, and the terminal child + * domain contains num_visible_items leaf value slots. + */ +message SparseStructuralLayer { + // Exactly one layer kind is required. + oneof layer { + SparseValidityLayer validity = 1; + SparseListLayer list = 2; + SparseFixedSizeListLayer fixed_size_list = 3; + } +} + +message SparseValidityLayer { + // Number of nullable item or struct slots in this layer's parent and child domain. + uint64 num_slots = 1; + // Validity for the slots in this layer. + SparseValiditySet validity = 2; +} + +message SparseListLayer { + // Number of list, large-list, or map slots in this layer's parent domain. + uint64 num_slots = 1; + // Number of slots in this layer's child domain. + uint64 num_child_slots = 2; + // Non-empty parent slots. Valid parent slots absent from this set are empty lists. + SparsePositionSet non_empty_positions = 3; + // Positive child counts corresponding one-for-one with non_empty_positions. + SparseCountSet counts = 4; + // Validity for the parent slots in this layer. + SparseValiditySet validity = 5; +} + +message SparseFixedSizeListLayer { + // Number of fixed-size-list slots in this layer's parent domain. + uint64 num_slots = 1; + // Number of children per parent slot. The child domain has num_slots * dimension slots. + uint64 dimension = 2; + // Validity for the parent slots in this layer. + SparseValiditySet validity = 3; +} + +message SparseValiditySet { + enum Meaning { + SPARSE_VALIDITY_UNSPECIFIED = 0; + // Stored positions are null; all other positions are valid. + SPARSE_VALIDITY_NULL_POSITIONS = 1; + // Stored positions are valid; all other positions are null. + SPARSE_VALIDITY_VALID_POSITIONS = 2; + } + + Meaning meaning = 1; + SparsePositionSet positions = 2; +} + +message SparsePositionEmpty {} + +message SparsePositionAll {} + +message SparsePositionRange { + uint64 start = 1; + uint64 length = 2; +} + +message SparsePositionSet { + oneof positions { + // Delta-compressed u64 positions. Cardinality is num_positions. + CompressiveEncoding explicit = 1; + // One contiguous, non-empty range. + SparsePositionRange range = 2; + // Every position in the domain. + SparsePositionAll all = 3; + // No positions in the domain. + SparsePositionEmpty empty = 4; + } + // Semantic cardinality of this set. + uint64 num_positions = 5; +} + +message SparseCountEmpty {} + +message SparseCountConstant { + // Child count shared by every non-empty list slot. + uint64 value = 1; +} + +message SparseCountSet { + oneof counts { + // Compressed u64 child counts. Cardinality comes from the containing position set. + CompressiveEncoding explicit = 1; + // One positive child count shared by every non-empty list slot. + SparseCountConstant constant = 2; + // No counts; valid only when there are no non-empty list slots. + SparseCountEmpty empty = 3; + } +} + +/* A layout used for pages where all (visible) values are the same scalar value. + * + * This generalizes the prior AllNullLayout semantics for file_version >= 2.2. + * + * There may be buffers of repetition and definition information if required in order + * to interpret what kind of nulls are present / which items are visible. + */ message ConstantLayout { // The meaning of each repdef layer, used to interpret repdef buffers correctly repeated RepDefLayer layers = 5; - // Inline fixed-width scalar value bytes. - // - // This MUST only be used for types where a single non-null element is represented by a single - // fixed-width Arrow value buffer (i.e. no offsets buffer, no child data). - // - // Constraints: - // - MUST be absent for an all-null page - // - MUST be <= 32 bytes if present + /* Inline fixed-width scalar value bytes. + * + * This MUST only be used for types where a single non-null element is represented by a single + * fixed-width Arrow value buffer (i.e. no offsets buffer, no child data). + * + * Constraints: + * - MUST be absent for an all-null page + * - MUST be <= 32 bytes if present + */ optional bytes inline_value = 6; + + /* Optional compression algorithm used for the repetition buffer. + * If absent, repetition levels are stored as raw u16 values. + */ + CompressiveEncoding rep_compression = 7; + /* Optional compression algorithm used for the definition buffer. + * If absent, definition levels are stored as raw u16 values. + */ + CompressiveEncoding def_compression = 8; + // Number of values in repetition buffer after decompression. + uint64 num_rep_values = 9; + // Number of values in definition buffer after decompression. + uint64 num_def_values = 10; } -// A layout where large binary data is encoded externally and only -// the descriptions (position + size) are placed in the page -// -// Repdef information is stored in the descriptions. A description with a size of -// 0 and a position of 0 is an empty value. A description with a size of 0 and a -// non-zero position is a null value and the position is the repdef value. +/* A layout where large binary data is encoded externally and only + * the descriptions (position + size) are placed in the page + * + * Repdef information is stored in the descriptions. A description with a size of + * 0 and a position of 0 is an empty value. A description with a size of 0 and a + * non-zero position is a null value and the position is the repdef value. + */ message BlobLayout { // The inner layout used to store the descriptions PageLayout inner_layout = 1; - // The meaning of each repdef layer, used to interpret repdef buffers correctly - // - // The inner layout's repdef layers will always be 1 all valid item layer + /* The meaning of each repdef layer, used to interpret repdef buffers correctly + * + * The inner layout's repdef layers will always be 1 all valid item layer + */ repeated RepDefLayer layers = 2; } @@ -192,30 +347,34 @@ message PageLayout { ConstantLayout constant_layout = 2; // A layout used for pages where the data is large FullZipLayout full_zip_layout = 3; - // A layout where large binary data is encoded externally - // and only the descriptions are put in the page + /* A layout where large binary data is encoded externally + * and only the descriptions are put in the page + */ BlobLayout blob_layout = 4; + // A sparse structural layout. This variant requires file version 2.3 or later. + SparseLayout sparse_layout = 5; } } -// # Compressive Encodings -// -// These encodings describe how an array is compressed. An encoding may split an -// array into multiple buffers. The buffers can then be compressed further (and split -// into yet more buffers). The entire process forms a tree of encodings with the root -// of the tree being the initial array and the leaves being the final compressed buffers. -// -// # Data blocks and buffers -// -// Data blocks are a simplified version of arrays and represent a collection of buffers grouped -// with some kind of interpretation. Data blocks are the input and output of compressive encodings. -// There are different kinds of data blocks: -// - Fixed width data blocks (e.g. u8, u16, ...) -// - Variable width data blocks (e.g. strings, binary) -// - Struct data blocks (note: this is for packed structs, normal structs are encoded in the structural encoding) -// -// In addition, leaf encodings may output "buffers". These are fully compressed buffers of data that -// are stored in the page and no longer compressed. +/* # Compressive Encodings + * + * These encodings describe how an array is compressed. An encoding may split an + * array into multiple buffers. The buffers can then be compressed further (and split + * into yet more buffers). The entire process forms a tree of encodings with the root + * of the tree being the initial array and the leaves being the final compressed buffers. + * + * # Data blocks and buffers + * + * Data blocks are a simplified version of arrays and represent a collection of buffers grouped + * with some kind of interpretation. Data blocks are the input and output of compressive encodings. + * There are different kinds of data blocks: + * - Fixed width data blocks (e.g. u8, u16, ...) + * - Variable width data blocks (e.g. strings, binary) + * - Struct data blocks (note: this is for packed structs, normal structs are encoded in the structural encoding) + * + * In addition, leaf encodings may output "buffers". These are fully compressed buffers of data that + * are stored in the page and no longer compressed. + */ enum CompressionScheme { COMPRESSION_ALGORITHM_UNSPECIFIED = 0; @@ -223,56 +382,61 @@ enum CompressionScheme { COMPRESSION_ALGORITHM_ZSTD = 2; } -// Compression applied to a single buffer of data -// -// A buffer is the leaf of the compression tree. Unlike data blocks, which can -// be further compressed with a variety of techniques, a buffer cannot be understood -// in any particular way. -// -// A general compression scheme may be applied to a buffer. This is something like -// zstd, lz4, etc. The entire buffer is compressed as a single unit. If this happens -// then any parent encoding becomes opaque, even if it would normally be transparent. -// -// This is a leaf, no further compression is applied to the data. +/* Compression applied to a single buffer of data + * + * A buffer is the leaf of the compression tree. Unlike data blocks, which can + * be further compressed with a variety of techniques, a buffer cannot be understood + * in any particular way. + * + * A general compression scheme may be applied to a buffer. This is something like + * zstd, lz4, etc. The entire buffer is compressed as a single unit. If this happens + * then any parent encoding becomes opaque, even if it would normally be transparent. + * + * This is a leaf, no further compression is applied to the data. + */ message BufferCompression { // A general compression scheme to apply to the buffer CompressionScheme scheme = 1; - // The compression level - // - // Optional, if not present a scheme-specific default value will be used. - // - // Interpretation of this value depends on the compression scheme. Generally, larger - // values indicate more compression at the expense of more CPU time. + /* The compression level + * + * Optional, if not present a scheme-specific default value will be used. + * + * Interpretation of this value depends on the compression scheme. Generally, larger + * values indicate more compression at the expense of more CPU time. + */ optional int32 level = 2; } -// Fixed width items placed contiguously in a single buffer -// -// This is a leaf encoding, there is no compression applied to the data. -// -// This is a transparent encoding by definition. -// -// The input is a fixed-width data block. -// The output is a single buffer. +/* Fixed width items placed contiguously in a single buffer + * + * This is a leaf encoding, there is no compression applied to the data. + * + * This is a transparent encoding by definition. + * + * The input is a fixed-width data block. + * The output is a single buffer. + */ message Flat { - // the number of bits per value, must be greater than 0, does - // not need to be a multiple of 8 + /* the number of bits per value, must be greater than 0, does + * not need to be a multiple of 8 + */ uint64 bits_per_value = 1; // The compression applied to the data optional BufferCompression data = 2; } -// Variable width items have the values stored in one buffer and the -// offsets are output as a data block that may be further compressed. -// -// This is a partial leaf encoding. Values are not compressed but -// the offsets may be further compressed. -// -// This is a transparent encoding by definition. -// -// The input is a variable-width data block. -// The output is a single fixed-width data block (the offsets) and -// a single buffer (the values) +/* Variable width items have the values stored in one buffer and the + * offsets are output as a data block that may be further compressed. + * + * This is a partial leaf encoding. Values are not compressed but + * the offsets may be further compressed. + * + * This is a transparent encoding by definition. + * + * The input is a variable-width data block. + * The output is a single fixed-width data block (the offsets) and + * a single buffer (the values) + */ message Variable { // Describes how the offsets data block is compressed CompressiveEncoding offsets = 1; @@ -280,30 +444,32 @@ message Variable { optional BufferCompression values = 2; } -// Compression algorithm where all values have a constant value (encoded in the description) -// -// This is a leaf encoding, there is no compression applied to the data. -// -// The input can be any kind of data block. -// There is no output. +/* Compression algorithm where all values have a constant value (encoded in the description) + * + * This is a leaf encoding, there is no compression applied to the data. + * + * The input can be any kind of data block. + * There is no output. + */ message Constant { // The value (TODO: define encoding for literals?) optional bytes value = 1; } -// A compression scheme in which a single fixed-width block is "packed" into -// a smaller fixed-width block values where each value has fewer bits. -// -// This is typically done by throwing away the most significant bits of each value when -// those bits are all the same. -// -// In this scheme the number of bits per value is fixed across the entire buffer and stored -// in this message. -// -// This is a transparent encoding. -// -// The input is a fixed-width data block. -// The output is a single fixed-width data block. +/* A compression scheme in which a single fixed-width block is "packed" into + * a smaller fixed-width block values where each value has fewer bits. + * + * This is typically done by throwing away the most significant bits of each value when + * those bits are all the same. + * + * In this scheme the number of bits per value is fixed across the entire buffer and stored + * in this message. + * + * This is a transparent encoding. + * + * The input is a fixed-width data block. + * The output is a single fixed-width data block. + */ message OutOfLineBitpacking { // the number of bits of the uncompressed value. e.g. for a u32, this will be 32 uint64 uncompressed_bits_per_value = 1; @@ -311,15 +477,16 @@ message OutOfLineBitpacking { CompressiveEncoding values = 3; } -// Bitpacking variant where the bits per value are stored inline in the chunks themselves -// -// This variation of bitpacking allows for the number of bits per value to change throughout the -// buffer, which makes the compression more robust to outliers. -// -// This is an opaque encoding. -// -// The input is a fixed-width data block. -// The output is a single buffer. +/* Bitpacking variant where the bits per value are stored inline in the chunks themselves + * + * This variation of bitpacking allows for the number of bits per value to change throughout the + * buffer, which makes the compression more robust to outliers. + * + * This is an opaque encoding. + * + * The input is a fixed-width data block. + * The output is a single buffer. + */ message InlineBitpacking { // the number of bits of the uncompressed value. e.g. for a u32, this will be 32 uint64 uncompressed_bits_per_value = 1; @@ -327,16 +494,17 @@ message InlineBitpacking { optional BufferCompression values = 2; } -// A compression scheme for variable-width data -// -// A small dictionary (referred to as a "symbol table") is used to compress the values. -// In this scheme there is a single symbol table for the entire page and it is stored in the -// encoding description itself. -// -// This is a transparent encoding. -// -// The input is a variable-width data block. -// The output is a single variable-width data block. +/* A compression scheme for variable-width data + * + * A small dictionary (referred to as a "symbol table") is used to compress the values. + * In this scheme there is a single symbol table for the entire page and it is stored in the + * encoding description itself. + * + * This is a transparent encoding. + * + * The input is a variable-width data block. + * The output is a single variable-width data block. + */ message Fsst { // The FSST symbol table bytes symbol_table = 1; @@ -344,15 +512,16 @@ message Fsst { CompressiveEncoding values = 2; } -// A compression scheme where common values are stored in a dictionary and the values are -// encoded as indices into the dictionary. -// -// This is an opaque encoding unless the dictionary is considered metadata. -// -// The input is a any kind of data block. -// There are two outputs: -// - A data block of the same kind as the input (the dictionary) -// - A fixed-width data block containing the indices into the dictionary. +/* A compression scheme where common values are stored in a dictionary and the values are + * encoded as indices into the dictionary. + * + * This is an opaque encoding unless the dictionary is considered metadata. + * + * The input is a any kind of data block. + * There are two outputs: + * - A data block of the same kind as the input (the dictionary) + * - A fixed-width data block containing the indices into the dictionary. + */ message Dictionary { // The compression used to store the indices data block CompressiveEncoding indices = 1; @@ -362,14 +531,15 @@ message Dictionary { uint32 num_dictionary_items = 3; } -// A compression scheme where runs of common values are encoded as a single value and a count -// -// This is an opaque encoding unless the run lengths are considered metadata. -// -// The input is a single data block of any kind. -// There are two outputs: -// - A data block of the same kind as the input (the run values) -// - A fixed-width data block containing the lengths of the runs +/* A compression scheme where runs of common values are encoded as a single value and a count + * + * This is an opaque encoding unless the run lengths are considered metadata. + * + * The input is a single data block of any kind. + * There are two outputs: + * - A data block of the same kind as the input (the run values) + * - A fixed-width data block containing the lengths of the runs + */ message Rle { // The compression used to store the run values data block CompressiveEncoding values = 1; @@ -377,14 +547,15 @@ message Rle { CompressiveEncoding run_lengths = 2; } -// Converts a fixed-size-list of values into a flattened list of values -// -// This encoding does not actually compress the data, it just flattens out the FSL layers. -// -// This is a transparent encoding. -// -// The input is a single block of fixed-width data (with a wide width and few items) -// The output is a single block of fixed-width data (with a narrow width and many items) +/* Converts a fixed-size-list of values into a flattened list of values + * + * This encoding does not actually compress the data, it just flattens out the FSL layers. + * + * This is a transparent encoding. + * + * The input is a single block of fixed-width data (with a wide width and few items) + * The output is a single block of fixed-width data (with a narrow width and many items) + */ message FixedSizeList { // The number of items in this layer of FSL uint64 items_per_value = 1; @@ -394,10 +565,11 @@ message FixedSizeList { CompressiveEncoding values = 2; } -// Packs a struct containing only fixed-width children into a single fixed-width data block -// -// The children are concatenated row by row and stored as a single fixed-width buffer. This is -// the legacy packed struct representation and remains available for backwards compatibility. +/* Packs a struct containing only fixed-width children into a single fixed-width data block + * + * The children are concatenated row by row and stored as a single fixed-width buffer. This is + * the legacy packed struct representation and remains available for backwards compatibility. + */ message PackedStruct { // The number of bits contributed by each child field in the packed row repeated uint64 bits_per_value = 1; @@ -405,12 +577,13 @@ message PackedStruct { CompressiveEncoding values = 2; } -// Variable-width packed struct encoding (2.2 extension) -// -// Each child value is compressed independently before being transposed into -// a row-major layout. This preserves per-field compression boundaries at the -// cost of disabling mini-block compression. Readers must prefer this field -// when present and fall back to the legacy encoding otherwise. +/* Variable-width packed struct encoding (2.2 extension) + * + * Each child value is compressed independently before being transposed into + * a row-major layout. This preserves per-field compression boundaries at the + * cost of disabling mini-block compression. Readers must prefer this field + * when present and fall back to the legacy encoding otherwise. + */ message VariablePackedStruct { // Per-field encoding metadata in struct order repeated FieldEncoding fields = 1; @@ -428,32 +601,33 @@ message VariablePackedStruct { } } -// A compression scheme that wraps the underlying data with general compression -// -// Note: The application of wrapped compression will depend on the layout of the data. -// If we apply it to mini-block data then we compress entire mini-blocks. If we apply -// it to full-zip data then we compress each value individually. -// -// Note: Wrapped compression is somewhat unique at the moment as it is applied to the -// output of the inner encoding and not the input like all other compressive encodings. -// -// Note: General compression can usually be applied in two spots. We can apply -// it to individual buffers or we can apply it here, to the entire array. -// -// For example, let's say we are storing mini-blocks of strings and we are using -// FSST and bitpacking the offsets. We have something like this... -// -// WRAPPED(†3) -> FSST -> VARIABLE -(offsets)-> INLINE_BITPACKING -(data)-> FLAT -> BUFFER (†1) -// -(data)-> BUFFER (†2) -// -// General compression can be applied at †1, †2, or †3 (or any combination of these). -// -// If we apply it at †1 then we apply it just to the bitpacked offsets -// If we apply it at †2 then we apply it just to the FSST compressed data -// If we apply it at †3 then we apply it to the entire mini-block (both offsets and data) -// -// The input is a single data block of any kind. -// The output is a single data block of the same kind as the input. +/* A compression scheme that wraps the underlying data with general compression + * + * Note: The application of wrapped compression will depend on the layout of the data. + * If we apply it to mini-block data then we compress entire mini-blocks. If we apply + * it to full-zip data then we compress each value individually. + * + * Note: Wrapped compression is somewhat unique at the moment as it is applied to the + * output of the inner encoding and not the input like all other compressive encodings. + * + * Note: General compression can usually be applied in two spots. We can apply + * it to individual buffers or we can apply it here, to the entire array. + * + * For example, let's say we are storing mini-blocks of strings and we are using + * FSST and bitpacking the offsets. We have something like this... + * + * WRAPPED(†3) -> FSST -> VARIABLE -(offsets)-> INLINE_BITPACKING -(data)-> FLAT -> BUFFER (†1) + * -(data)-> BUFFER (†2) + * + * General compression can be applied at †1, †2, or †3 (or any combination of these). + * + * If we apply it at †1 then we apply it just to the bitpacked offsets + * If we apply it at †2 then we apply it just to the FSST compressed data + * If we apply it at †3 then we apply it to the entire mini-block (both offsets and data) + * + * The input is a single data block of any kind. + * The output is a single data block of the same kind as the input. + */ message General { // The compression to apply to the values BufferCompression compression = 1; @@ -461,20 +635,21 @@ message General { CompressiveEncoding values = 3; } -// A compression scheme where fixed-width values are transposed into a series of byte streams -// -// This is commonly used for floating point values where the upper bits (the mantissa) have a -// significantly different meaning than the lower bits. By splitting the values into byte streams -// we group the mantissa bits together and the exponent bits together. The end result is typically -// more compressible. -// -// Note that this encoding is mostly useful when combined with other encodings. It does not do any -// compression on its own. -// -// This is an opaque encoding. -// -// The input is a fixed-width data block -// The output is a single fixed-width data block +/* A compression scheme where fixed-width values are transposed into a series of byte streams + * + * This is commonly used for floating point values where the upper bits (the mantissa) have a + * significantly different meaning than the lower bits. By splitting the values into byte streams + * we group the mantissa bits together and the exponent bits together. The end result is typically + * more compressible. + * + * Note that this encoding is mostly useful when combined with other encodings. It does not do any + * compression on its own. + * + * This is an opaque encoding. + * + * The input is a fixed-width data block + * The output is a single fixed-width data block + */ message ByteStreamSplit { // The compression used to store the values CompressiveEncoding values = 1; diff --git a/protos/file.proto b/protos/file.proto index db5971fe61d..da4c7b0a72b 100644 --- a/protos/file.proto +++ b/protos/file.proto @@ -23,62 +23,69 @@ message Schema { // Metadata of one Lance file. message Metadata { - // 4 was used for StatisticsMetadata in the past, but has been moved to - // prevent a bug in older readers. + /* 4 was used for StatisticsMetadata in the past, but has been moved to + * prevent a bug in older readers. + */ reserved 4; - // Position of the manifest in the file. If it is zero, the manifest is stored - // externally. + /* Position of the manifest in the file. If it is zero, the manifest is stored + * externally. + */ uint64 manifest_position = 1; - // Logical offsets of each chunk group, i.e., number of the rows in each - // chunk. + /* Logical offsets of each chunk group, i.e., number of the rows in each + * chunk. + */ repeated int32 batch_offsets = 2; - // The file position that page table is stored. - // - // A page table is a matrix of N x M x 2, where N = num_fields, and M = - // num_batches. Each cell in the table is a pair of of the page. Both position and length are int64 values. The - // of all the pages in the same column are then - // contiguously stored. - // - // Every field that is a part of the file will have a run in the page table. - // This includes struct columns, which will have a run of length 0 since - // they don't store any actual data. - // - // For example, for the column 5 and batch 4, we have: - // ```text - // position = page_table[5][4][0]; - // length = page_table[5][4][1]; - // ``` + /* The file position that page table is stored. + * + * A page table is a matrix of N x M x 2, where N = num_fields, and M = + * num_batches. Each cell in the table is a pair of of the page. Both position and length are int64 values. The + * of all the pages in the same column are then + * contiguously stored. + * + * Every field that is a part of the file will have a run in the page table. + * This includes struct columns, which will have a run of length 0 since + * they don't store any actual data. + * + * For example, for the column 5 and batch 4, we have: + * ```text + * position = page_table[5][4][0]; + * length = page_table[5][4][1]; + * ``` + */ uint64 page_table_position = 3; message StatisticsMetadata { - // The schema of the statistics. - // - // This might be empty, meaning there are no statistics. It also might not - // contain statistics for every field. + /* The schema of the statistics. + * + * This might be empty, meaning there are no statistics. It also might not + * contain statistics for every field. + */ repeated Field schema = 1; - // The field ids of the statistics leaf fields. - // - // This plays a similar role to the `fields` field in the DataFile message. - // Each of these field ids corresponds to a field in the stats_schema. There - // is one per column in the stats page table. + /* The field ids of the statistics leaf fields. + * + * This plays a similar role to the `fields` field in the DataFile message. + * Each of these field ids corresponds to a field in the stats_schema. There + * is one per column in the stats page table. + */ repeated int32 fields = 2; - // The file position of the statistics page table - // - // The page table is a matrix of N x 2, where N = length of stats_fields. - // This is the same layout as the main page table, except there is always - // only one batch. - // - // For example, to get the stats column 5, we have: - // ```text - // position = stats_page_table[5][0]; - // length = stats_page_table[5][1]; - // ``` + /* The file position of the statistics page table + * + * The page table is a matrix of N x 2, where N = length of stats_fields. + * This is the same layout as the main page table, except there is always + * only one batch. + * + * For example, to get the stats column 5, we have: + * ```text + * position = stats_page_table[5][0]; + * length = stats_page_table[5][1]; + * ``` + */ uint64 page_table_position = 3; } @@ -101,10 +108,11 @@ enum Encoding { // Dictionary field metadata message Dictionary { - /// The file offset for storing the dictionary value. - /// It is only valid if encoding is DICTIONARY. - /// - /// The logic type presents the value type of the column, i.e., string value. + /* The file offset for storing the dictionary value. + * It is only valid if encoding is DICTIONARY. + * + * The logic type presents the value type of the column, i.e., string value. + */ int64 offset = 1; /// The length of dictionary values. @@ -122,41 +130,43 @@ message Field { // Fully qualified name. string name = 2; - /// Field Id. - /// - /// See the comment in `DataFile.fields` for how field ids are assigned. + /* Field Id. + * + * See the comment in `DataFile.fields` for how field ids are assigned. + */ int32 id = 3; /// Parent Field ID. If not set, this is a top-level column. int32 parent_id = 4; - // Logical types, support parameterized Arrow Type. - // - // PARENT types will always have logical type "struct". - // - // REPEATED types may have logical types: - // * "list" - // * "large_list" - // * "list.struct" - // * "large_list.struct" - // The final two are used if the list values are structs, and therefore the - // field is both implicitly REPEATED and PARENT. - // - // LEAF types may have logical types: - // * "null" - // * "bool" - // * "int8" / "uint8" - // * "int16" / "uint16" - // * "int32" / "uint32" - // * "int64" / "uint64" - // * "halffloat" / "float" / "double" - // * "string" / "large_string" - // * "binary" / "large_binary" - // * "date32:day" - // * "date64:ms" - // * "decimal:128:{precision}:{scale}" / "decimal:256:{precision}:{scale}" - // * "time:{unit}" / "timestamp:{unit}" / "duration:{unit}", where unit is - // "s", "ms", "us", "ns" - // * "dict:{value_type}:{index_type}:false" + /* Logical types, support parameterized Arrow Type. + * + * PARENT types will always have logical type "struct". + * + * REPEATED types may have logical types: + * * "list" + * * "large_list" + * * "list.struct" + * * "large_list.struct" + * The final two are used if the list values are structs, and therefore the + * field is both implicitly REPEATED and PARENT. + * + * LEAF types may have logical types: + * * "null" + * * "bool" + * * "int8" / "uint8" + * * "int16" / "uint16" + * * "int32" / "uint32" + * * "int64" / "uint64" + * * "halffloat" / "float" / "double" + * * "string" / "large_string" + * * "binary" / "large_binary" + * * "date32:day" + * * "date64:ms" + * * "decimal:128:{precision}:{scale}" / "decimal:256:{precision}:{scale}" + * * "time:{unit}" / "timestamp:{unit}" / "duration:{unit}", where unit is + * "s", "ms", "us", "ns" + * * "dict:{value_type}:{index_type}:false" + */ string logical_type = 5; // If this field is nullable. bool nullable = 6; @@ -166,35 +176,48 @@ message Field { bool unenforced_primary_key = 12; - // Position of this field in the primary key (1-based). - // 0 means the field is part of the primary key but uses schema field id for ordering. - // When set to a positive value, primary key fields are ordered by this position. + /* Position of this field in the primary key (1-based). + * 0 means the field is part of the primary key but uses schema field id for ordering. + * When set to a positive value, primary key fields are ordered by this position. + */ uint32 unenforced_primary_key_position = 13; + // Reserved for future use. Use unenforced_clustering_key_position instead. + bool unenforced_clustering_key = 14; + + /* Position of this field in the clustering key (1-based). + * 0 means the field is not part of the clustering key. + */ + uint32 unenforced_clustering_key_position = 15; + // DEPRECATED ---------------------------------------------------------------- - // Deprecated: Only used in V1 file format. V2 uses variable encodings defined - // per page. - // - // The global encoding to use for this field. + /* Deprecated: Only used in V1 file format. V2 uses variable encodings defined + * per page. + * + * The global encoding to use for this field. + */ Encoding encoding = 7; - // Deprecated: Only used in V1 file format. V2 dynamically chooses when to - // do dictionary encoding and keeps the dictionary in the data files. - // - // The file offset for storing the dictionary value. - // It is only valid if encoding is DICTIONARY. - // - // The logic type presents the value type of the column, i.e., string value. + /* Deprecated: Only used in V1 file format. V2 dynamically chooses when to + * do dictionary encoding and keeps the dictionary in the data files. + * + * The file offset for storing the dictionary value. + * It is only valid if encoding is DICTIONARY. + * + * The logic type presents the value type of the column, i.e., string value. + */ Dictionary dictionary = 8; - // Deprecated: optional extension type name, use metadata field - // ARROW:extension:name + /* Deprecated: optional extension type name, use metadata field + * ARROW:extension:name + */ string extension_name = 9; - // Field number 11 was previously `string storage_class`. - // Keep it reserved so older manifests remain compatible while new writers - // avoid reusing the slot. + /* Field number 11 was previously `string storage_class`. + * Keep it reserved so older manifests remain compatible while new writers + * avoid reusing the slot. + */ reserved 11; reserved "storage_class"; } diff --git a/protos/file2.proto b/protos/file2.proto index da0b1d5e96c..a92df4cc587 100644 --- a/protos/file2.proto +++ b/protos/file2.proto @@ -8,144 +8,149 @@ package lance.file.v2; import "google/protobuf/any.proto"; import "google/protobuf/empty.proto"; -// # Lance v2.X File Format -// -// The Lance file format is a barebones format for serializing columnar data -// into a file. -// -// * Each Lance file contains between 0 and 4Gi columns -// * Each column contains between 0 and 4Gi pages -// * Each page contains between 0 and 2^64 items -// * Different pages within a column can have different items counts -// * Columns may have up to 2^64 items -// * Different columns within a file can have different item counts -// -// The Lance file format does not have any notion of a type system or schemas. -// From the perspective of the file format all data is arbitrary buffers of -// bytes with an extensible metadata block to describe the data. It is up to -// the user to interpret these bytes meaningfully. -// -// Data buffers are written to the file first. These data buffers can be -// referenced from three different places in the file: -// -// * Page encodings can reference data buffers. This is the most common way -// that actual data is stored. -// * Column encodings can reference data buffers. For example, a column encoding -// may reference data buffer(s) containing statistics or dictionaries. -// * Finally, the global buffer offset table can reference data buffers. This -// is useful for storing data that is shared across multiple columns. -// This is also useful for global file metadata (e.g. a schema that describes -// the file) -// -// ## File Layout -// -// Note: the number of buffers (BN) is independent of the number of columns (CN) -// and pages. -// -// Buffers often need to be aligned. 64-byte alignment is common when -// working with SIMD operations. 4096-byte alignment is common when -// working with direct I/O. In order to ensure these buffers are aligned -// writers may need to insert padding before the buffers. -// -// If direct I/O is required then most (but not all) fields described -// below must be sector aligned. We have marked these fields with an -// asterisk for clarity. Readers should assume there will be optional -// padding inserted before these fields. -// -// All footer fields are unsigned integers written with little endian -// byte order. -// -// ├──────────────────────────────────┤ -// | Data Pages | -// | Data Buffer 0* | -// | ... | -// | Data Buffer BN* | -// ├──────────────────────────────────┤ -// | Column Metadatas | -// | |A| Column 0 Metadata* | -// | Column 1 Metadata* | -// | ... | -// | Column CN Metadata* | -// ├──────────────────────────────────┤ -// | Column Metadata Offset Table | -// | |B| Column 0 Metadata Position* | -// | Column 0 Metadata Size | -// | ... | -// | Column CN Metadata Position | -// | Column CN Metadata Size | -// ├──────────────────────────────────┤ -// | Global Buffers Offset Table | -// | |C| Global Buffer 0 Position* | -// | Global Buffer 0 Size | -// | ... | -// | Global Buffer GN Position | -// | Global Buffer GN Size | -// ├──────────────────────────────────┤ -// | Footer | -// | A u64: Offset to column meta 0 | -// | B u64: Offset to CMO table | -// | C u64: Offset to GBO table | -// | u32: Number of global bufs | -// | u32: Number of columns | -// | u16: Major version | -// | u16: Minor version | -// | "LANC" | -// ├──────────────────────────────────┤ -// -// File Layout-End -// -// ## Data Pages -// -// A lot of flexibility is provided in how data is stored. A page's buffers do -// not strictly need to be contiguous on the disk. However, it is recommended -// that buffers within a page be grouped together for best performance. -// -// Data pages should be large. The only time a page should be written to disk -// is when the writer needs to flush the page to disk because it has accumulated -// too much data. Pages are not read in sequential order and if pages are too -// small then the seek overhead (or request overhead) will be problematic. We -// generally advise that pages be at least 8MB or larger. -// -// ## Encodings -// -// Specific encodings are not part of this minimal format. They are provided -// by extensions. Readers and writers should be designed so that encodings can -// be easily added and removed. Ideally, they should allow for this without -// requiring recompilation through some kind of plugin system. +/* # Lance v2.X File Format + * + * The Lance file format is a barebones format for serializing columnar data + * into a file. + * + * * Each Lance file contains between 0 and 4Gi columns + * * Each column contains between 0 and 4Gi pages + * * Each page contains between 0 and 2^64 items + * * Different pages within a column can have different items counts + * * Columns may have up to 2^64 items + * * Different columns within a file can have different item counts + * + * The Lance file format does not have any notion of a type system or schemas. + * From the perspective of the file format all data is arbitrary buffers of + * bytes with an extensible metadata block to describe the data. It is up to + * the user to interpret these bytes meaningfully. + * + * Data buffers are written to the file first. These data buffers can be + * referenced from three different places in the file: + * + * * Page encodings can reference data buffers. This is the most common way + * that actual data is stored. + * * Column encodings can reference data buffers. For example, a column encoding + * may reference data buffer(s) containing statistics or dictionaries. + * * Finally, the global buffer offset table can reference data buffers. This + * is useful for storing data that is shared across multiple columns. + * This is also useful for global file metadata (e.g. a schema that describes + * the file) + * + * ## File Layout + * + * Note: the number of buffers (BN) is independent of the number of columns (CN) + * and pages. + * + * Buffers often need to be aligned. 64-byte alignment is common when + * working with SIMD operations. 4096-byte alignment is common when + * working with direct I/O. In order to ensure these buffers are aligned + * writers may need to insert padding before the buffers. + * + * If direct I/O is required then most (but not all) fields described + * below must be sector aligned. We have marked these fields with an + * asterisk for clarity. Readers should assume there will be optional + * padding inserted before these fields. + * + * All footer fields are unsigned integers written with little endian + * byte order. + * + * ├──────────────────────────────────┤ + * | Data Pages | + * | Data Buffer 0* | + * | ... | + * | Data Buffer BN* | + * ├──────────────────────────────────┤ + * | Column Metadatas | + * | |A| Column 0 Metadata* | + * | Column 1 Metadata* | + * | ... | + * | Column CN Metadata* | + * ├──────────────────────────────────┤ + * | Column Metadata Offset Table | + * | |B| Column 0 Metadata Position* | + * | Column 0 Metadata Size | + * | ... | + * | Column CN Metadata Position | + * | Column CN Metadata Size | + * ├──────────────────────────────────┤ + * | Global Buffers Offset Table | + * | |C| Global Buffer 0 Position* | + * | Global Buffer 0 Size | + * | ... | + * | Global Buffer GN Position | + * | Global Buffer GN Size | + * ├──────────────────────────────────┤ + * | Footer | + * | A u64: Offset to column meta 0 | + * | B u64: Offset to CMO table | + * | C u64: Offset to GBO table | + * | u32: Number of global bufs | + * | u32: Number of columns | + * | u16: Major version | + * | u16: Minor version | + * | "LANC" | + * ├──────────────────────────────────┤ + * + * File Layout-End + * + * ## Data Pages + * + * A lot of flexibility is provided in how data is stored. A page's buffers do + * not strictly need to be contiguous on the disk. However, it is recommended + * that buffers within a page be grouped together for best performance. + * + * Data pages should be large. The only time a page should be written to disk + * is when the writer needs to flush the page to disk because it has accumulated + * too much data. Pages are not read in sequential order and if pages are too + * small then the seek overhead (or request overhead) will be problematic. We + * generally advise that pages be at least 8MB or larger. + * + * ## Encodings + * + * Specific encodings are not part of this minimal format. They are provided + * by extensions. Readers and writers should be designed so that encodings can + * be easily added and removed. Ideally, they should allow for this without + * requiring recompilation through some kind of plugin system. + */ -// The deferred encoding is used to place the encoding itself in a different -// part of the file. This is most commonly used to allow encodings to be shared -// across different columns. For example, when writing a file with thousands of -// columns, where many pages have the exact same encoding, it can be useful -// to cut down on the size of the metadata by using a deferred encoding. +/* The deferred encoding is used to place the encoding itself in a different + * part of the file. This is most commonly used to allow encodings to be shared + * across different columns. For example, when writing a file with thousands of + * columns, where many pages have the exact same encoding, it can be useful + * to cut down on the size of the metadata by using a deferred encoding. + */ message DeferredEncoding { - // Location of the buffer containing the encoding. - // - // * If sharing encodings across columns then this will be in a global buffer - // * If sharing encodings across pages within a column this could be in a - // column metadata buffer. - // * This could also be a page buffer if the encoding is not shared, needs - // to be written before the file ends, and the encoding is too large to load - // unless we first determine the page needs to be read. This combination - // seems unusual. + /* Location of the buffer containing the encoding. + * + * * If sharing encodings across columns then this will be in a global buffer + * * If sharing encodings across pages within a column this could be in a + * column metadata buffer. + * * This could also be a page buffer if the encoding is not shared, needs + * to be written before the file ends, and the encoding is too large to load + * unless we first determine the page needs to be read. This combination + * seems unusual. + */ uint64 buffer_location = 1; uint64 buffer_length = 2; } // The encoding is placed directly in the metadata section message DirectEncoding { - // The bytes that make up the encoding embedded directly in the metadata - // - // This is the most common approach. + /* The bytes that make up the encoding embedded directly in the metadata + * + * This is the most common approach. + */ bytes encoding = 1; } -// An encoding stores the information needed to decode a column or page -// -// For example, it could describe if the page is using bit packing, and how many bits -// there are in each individual value. -// -// At the column level it can be used to wrap columns with dictionaries or statistics. +/* An encoding stores the information needed to decode a column or page + * + * For example, it could describe if the page is using bit packing, and how many bits + * there are in each individual value. + * + * At the column level it can be used to wrap columns with dictionaries or statistics. + */ message Encoding { oneof location { // The encoding is stored elsewhere and not part of this protobuf message @@ -159,52 +164,60 @@ message Encoding { // ## Metadata -// Each column has a metadata block that is placed at the end of the file. -// These may be read individually to allow for column projection. +/* Each column has a metadata block that is placed at the end of the file. + * These may be read individually to allow for column projection. + */ message ColumnMetadata { // This describes a page of column data. message Page { - // The file offsets for each of the page buffers - // - // The number of buffers is variable and depends on the encoding. There - // may be zero buffers (e.g. constant encoded data) in which case this - // could be empty. + /* The file offsets for each of the page buffers + * + * The number of buffers is variable and depends on the encoding. There + * may be zero buffers (e.g. constant encoded data) in which case this + * could be empty. + */ repeated uint64 buffer_offsets = 1; - // The size (in bytes) of each of the page buffers - // - // This field will have the same length as `buffer_offsets` and - // may be empty. + /* The size (in bytes) of each of the page buffers + * + * This field will have the same length as `buffer_offsets` and + * may be empty. + */ repeated uint64 buffer_sizes = 2; // Logical length (e.g. # rows) of the page uint64 length = 3; // The encoding used to encode the page Encoding encoding = 4; - // The priority of the page - // - // For tabular data this will be the top-level row number of the first row - // in the page (and top-level rows should not split across pages). + /* The priority of the page + * + * For tabular data this will be the top-level row number of the first row + * in the page (and top-level rows should not split across pages). + */ uint64 priority = 5; } - // Encoding information about the column itself. This typically describes - // how to interpret the column metadata buffers. For example, it could - // describe how statistics or dictionaries are stored in the column metadata. + /* Encoding information about the column itself. This typically describes + * how to interpret the column metadata buffers. For example, it could + * describe how statistics or dictionaries are stored in the column metadata. + */ Encoding encoding = 1; // The pages in the column repeated Page pages = 2; - // The file offsets of each of the column metadata buffers - // - // There may be zero buffers. + /* The file offsets of each of the column metadata buffers + * + * There may be zero buffers. + */ repeated uint64 buffer_offsets = 3; - // The size (in bytes) of each of the column metadata buffers - // - // This field will have the same length as `buffer_offsets` and - // may be empty. + /* The size (in bytes) of each of the column metadata buffers + * + * This field will have the same length as `buffer_offsets` and + * may be empty. + */ repeated uint64 buffer_sizes = 4; } // Metadata-End -// ## Where is the rest? -// -// This file format is extremely minimal. It is a building block for -// creating more useful readers and writers and not terribly useful by itself. -// Other protobuf files will describe how this can be extended. \ No newline at end of file +/* ## Where is the rest? + * + * This file format is extremely minimal. It is a building block for + * creating more useful readers and writers and not terribly useful by itself. + * Other protobuf files will describe how this can be extended. + */ diff --git a/protos/filtered_read.proto b/protos/filtered_read.proto new file mode 100644 index 00000000000..ad0f12e9032 --- /dev/null +++ b/protos/filtered_read.proto @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +syntax = "proto3"; + +package lance.datafusion; + +import "table_identifier.proto"; + +message U64Range { + uint64 start = 1; + uint64 end = 2; +} + +message ProjectionProto { + repeated int32 field_ids = 1; + bool with_row_id = 2; + bool with_row_addr = 3; + bool with_row_last_updated_at_version = 4; + bool with_row_created_at_version = 5; + BlobHandlingProto blob_handling = 6; +} + +message BlobHandlingProto { + oneof mode { + // All blobs read as binary + bool all_binary = 1; + // Blobs as descriptions, other binary as binary (default) + bool blobs_descriptions = 2; + // All binary columns as descriptions + bool all_descriptions = 3; + // Specific blobs read as binary, rest as descriptions (non-blob binary stays binary) + FieldIdSet some_blobs_binary = 4; + // Specific columns as binary, all other binary as descriptions + FieldIdSet some_binary = 5; + } +} + +message FieldIdSet { + repeated uint32 field_ids = 1; +} + +message FilteredReadThreadingModeProto { + oneof mode { + uint64 one_partition_multiple_threads = 1; + uint64 multiple_partitions = 2; + } +} + +// Serializable form of FilteredReadOptions. +message FilteredReadOptionsProto { + optional U64Range scan_range_before_filter = 1; + optional U64Range scan_range_after_filter = 2; + bool with_deleted_rows = 3; + optional uint32 batch_size = 4; + optional uint64 fragment_readahead = 5; + repeated uint64 fragment_ids = 6; + ProjectionProto projection = 7; + optional bytes refine_filter_substrait = 8; + optional bytes full_filter_substrait = 9; + FilteredReadThreadingModeProto threading_mode = 10; + optional uint64 io_buffer_size_bytes = 11; + // Arrow IPC schema for decoding Substrait filters (may be wider than projection). + optional bytes filter_schema_ipc = 12; + /* If present, a nonzero upper bound on bytes reserved by Blob v2 + * materialization awaiting ordered emission in one scanner execution. + * Admission follows output order; one oversized output batch may exceed the + * bound when no other batch is reserved. If absent, Blob v2 materialization + * has no independent memory bound. + */ + optional uint64 materialization_readahead_bytes = 13; + /* If present, the scanner-level byte budget for output batches. When set, + * the file reader uses it as an additional batch boundary alongside the + * row-based batch_size. This corresponds to FileReaderOptions.batch_size_bytes. + */ + optional uint64 batch_size_bytes = 14; +} + +/* Serializable form of FilteredReadPlan (planned/distributed mode). + * RowAddrTreeMap serialized via its built-in serialize_into/deserialize_from. + * Per-fragment filters are Substrait-encoded and deduplicated. + */ +message FilteredReadPlanProto { + bytes row_addr_tree_map = 1; + optional U64Range scan_range_after_filter = 2; + // Arrow IPC schema for decoding Substrait filters (matches the schema used at encode time). + optional bytes filter_schema_ipc = 3; + /* Per-fragment filter mapping. Key is fragment id, value is a list index into + * filter_expressions. Multiple fragments can share the same list index when + * they have the same filter, avoiding duplicate Substrait encoding. + */ + map fragment_filter_ids = 4; + /* Deduplicated Substrait-encoded filter expressions. Each entry is referenced + * by one or more values in fragment_filter_ids. + */ + repeated bytes filter_expressions = 5; +} + +// Top-level wrapper for FilteredReadExec serialization. +message FilteredReadExecProto { + TableIdentifier table = 1; + FilteredReadOptionsProto options = 2; + /* FilteredRead has two modes + * Plan-then-execute (distributed): The planner creates a FilteredReadPlan and sends it to a remote executor. + * Plan-and-execute (local): The executor creates the plan itself at execution time. + */ + optional FilteredReadPlanProto plan = 3; + /* Note: FilteredReadExec.index_input (child ExecutionPlan) is NOT serialized here. + * DataFusion's PhysicalExtensionCodec handles child plans automatically: it walks + * the plan tree via children() / with_new_children(), serializes each node, and + * passes deserialized children back as the `inputs` parameter in try_decode. + * This means any ExecutionPlan in the tree (including index_input) must also + * implement try_encode/try_decode in the PhysicalExtensionCodec. + * TODO: implement serialize/deserialize for lance-specific index input ExecutionPlans. + */ +} diff --git a/protos/fragment_metadata.proto b/protos/fragment_metadata.proto new file mode 100644 index 00000000000..63e2651d1e8 --- /dev/null +++ b/protos/fragment_metadata.proto @@ -0,0 +1,455 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +syntax = "proto3"; + +package lance.table; + +/* A DataFragment is a set of files which represent the different columns of the same + * rows. If column exists in the schema of a dataset, but the file for that column does + * not exist within a DataFragment of that dataset, that column consists entirely of + * nulls. + */ +message DataFragment { + // The ID of a DataFragment is unique within a dataset. + uint64 id = 1; + + repeated DataFile files = 2; + + /* Optional overlay files for this fragment, which supply new values for a + * subset of cells without rewriting the base data files. This MUST be empty + * if the data overlay files feature flag (64) is not set in the manifest. + * + * Order is significant: a later entry is newer than an earlier one. When two + * overlays cover the same (offset, field) and share a `committed_version`, the + * later entry wins. See DataOverlayFile for the full resolution rules. + */ + repeated DataOverlayFile overlays = 11; + + // File that indicates which rows, if any, should be considered deleted. + DeletionFile deletion_file = 3; + + // TODO: What's the simplest way we can allow an inline tombstone bitmap? + + /* A serialized RowIdSequence message (see rowids.proto). + * + * These are the row ids for the fragment, in order of the rows as they appear. + * That is, if a fragment has 3 rows, and the row ids are [1, 42, 3], then the + * first row is row 1, the second row is row 42, and the third row is row 3. + */ + oneof row_id_sequence { + // Current Lance writers store row ids inline regardless of encoded size. + bytes inline_row_ids = 5; + /* The row ids are a hidden column of one of this fragment's `files`: the + * entry whose `fields` carries the reserved id -3. See RowLineageColumn. + * + * A writer MUST set the spilled row lineage feature flag (2048) when any + * fragment uses this arm or either of the other column arms below. A + * reader that does not understand the flag would see an unset oneof and + * treat the fragment as having no row ids at all. + */ + RowLineageColumn column_row_ids = 12; + } // row_id_sequence + + oneof last_updated_at_version_sequence { + // Current Lance writers store last-updated versions inline regardless of encoded size. + bytes inline_last_updated_at_versions = 7; + /* The versions are a hidden column of one of this fragment's `files`: the + * entry whose `fields` carries the reserved id -5. Gated like + * `column_row_ids`. + */ + RowLineageColumn column_last_updated_at_versions = 13; + } // last_updated_at_version_sequence + + oneof created_at_version_sequence { + // Current Lance writers store created-at versions inline regardless of encoded size. + bytes inline_created_at_versions = 9; + /* The versions are a hidden column of one of this fragment's `files`: the + * entry whose `fields` carries the reserved id -4. Gated like + * `column_row_ids`. + */ + RowLineageColumn column_created_at_versions = 14; + } // created_at_version_sequence + + /* The `external_*` arms of the three sequence oneofs above: an opaque byte + * range in a file, holding the same encoding as the inline arm. No Lance + * writer ever emitted them; the column arms replace that design. Reserved so + * a reader never has to interpret them. + */ + reserved 6, 8, 10; + reserved "external_row_ids", "external_last_updated_at_versions", + "external_created_at_versions"; + + /* Number of original rows in the fragment, this includes rows that are now marked with + * deletion tombstones. To compute the current number of rows, subtract + * `deletion_file.num_deleted_rows` from this value. + */ + uint64 physical_rows = 4; +} + +message DataFile { + // Path to the root relative to the dataset's URI. + string path = 1; + /* The ids of the fields/columns in this file. + * + * When a DataFile object is created in memory, every value in fields is assigned -1 by + * default. An object with a value in fields of -1 must not be stored to disk. -2 is + * used for "tombstoned", meaning a field that is no longer in use. This is often + * because the original field id was reassigned to a different data file. + * + * Every negative value is reserved for system use and never names a field of the + * dataset schema. -3, -4 and -5 are the hidden row lineage columns (see + * RowLineageColumn); a reader projecting the dataset schema onto a file skips + * them, and any other negative value, rather than reject the file. + * + * In Lance v1 IDs are assigned based on position in the file, offset by the max + * existing field id in the table (if any already). So when a fragment is first created + * with one file of N columns, the field ids will be 1, 2, ..., N. If a second fragment + * is created with M columns, the field ids will be N+1, N+2, ..., N+M. + * + * In Lance v1 there is one field for each field in the input schema, this includes + * nested fields (both struct and list). Fixed size list fields have only a single + * field id (these are not considered nested fields in Lance v1). + * + * This allows column indices to be calculated from field IDs and the input schema. + * + * In Lance v2 the field IDs generally follow the same pattern but there is no + * way to calculate the column index from the field ID. This is because a given + * field could be encoded in many different ways, some of which occupy a different + * number of columns. For example, a struct field could be encoded into N + 1 columns + * or it could be encoded into a single packed column. To determine column indices + * the column_indices property should be used instead. + * + * In Lance v1 these ids must be sorted but might not always be contiguous. + */ + repeated int32 fields = 2; + /* The top-level column indices for each field in the file. + * + * If the data file is version 1 then this property will be empty + * + * Otherwise there must be one entry for each field in `fields`. + * + * Some fields may not correspond to a top-level column in the file. In these cases + * the index will -1. + * + * For example, consider the schema: + * + * - dimension: packed-struct (0): + * - x: u32 (1) + * - y: u32 (2) + * - path: `list` (3) + * - embedding: `fsl<768>` (4) + * - fp64 + * - borders: `fsl<4>` (5) + * - simple-struct (6) + * - margin: fp64 (7) + * - padding: fp64 (8) + * + * One possible column indices array could be: + * [0, -1, -1, 1, 3, 4, 5, 6, 7] + * + * This reflects quite a few phenomenon: + * - The packed struct is encoded into a single column and there is no top-level column + * for the x or y fields + * - The variable sized list is encoded into two columns + * - The embedding is encoded into a single column (common for FSL of primitive) and there + * is not "FSL column" + * - The borders field actually does have an "FSL column" + * + * The column indices table may not have duplicates (other than -1) + */ + repeated int32 column_indices = 3; + // The major file version used to create the file + uint32 file_major_version = 4; + /* The minor file version used to create the file + * + * If both `file_major_version` and `file_minor_version` are set to 0, + * then this is a version 0.1 or version 0.2 file. + */ + uint32 file_minor_version = 5; + + /* The known size of the file on disk in bytes. + * + * This is used to quickly find the footer of the file. + * + * When this is zero, it should be interpreted as "unknown". + */ + uint64 file_size_bytes = 6; + + /* 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 = 7; +} // DataFile + +/* An overlay file supplies new values for a subset of (row offset, field) cells + * within a fragment, without rewriting the fragment's base data files. It is + * used for efficient updates when only a small fraction of rows and/or columns + * change. + * + * On read, a cell is resolved by consulting the fragment's overlays from newest + * to oldest: the first overlay that covers that (offset, field) wins; if none + * cover it, the value falls through to the base data file. Because deletions + * take precedence over overlays, an overlay value for an offset that is also + * marked deleted is dead and is ignored. + * + * The overlay's data file does NOT store a row-offset key column. Within a value + * column, the position of a covered offset's value is the rank (0-based count of + * set bits below it) of that offset within the field's coverage bitmap. Because + * fields may cover different offset sets, the value columns of a single overlay + * data file may have different lengths (which the Lance file format permits). + */ +message DataOverlayFile { + /* The data file storing the overlay's new cell values, one value column per + * field in `data_file.fields`. No row-offset key column is stored. + */ + DataFile data_file = 1; + + // Which (offset, field) cells this overlay provides values for. + oneof coverage { + /* A single 32-bit Roaring bitmap of physical row offsets that applies to + * every field in `data_file.fields` (a "dense" / rectangular overlay). + * Every covered offset has a value for every field. This is the common case + * for a plain UPDATE, where one SET list is applied to one set of rows. + */ + bytes shared_offset_bitmap = 2; + /* Per-field coverage for a "sparse" overlay, used when different fields cover + * different offset sets (e.g. a MERGE with multiple WHEN MATCHED branches). + */ + FieldCoverage field_coverage = 4; + } + + /* The dataset version at which this overlay became effective: the version of + * the commit that introduced it, NOT the version it was read from. It is + * stamped at commit time and re-stamped if the commit is retried, in the same + * way as the created-at / last-updated-at version sequences. + * + * This drives two orderings: + * * Versus index builds: an index whose `dataset_version` >= this value + * already incorporates this overlay. Otherwise the overlay's covered cells + * are excluded from index results for the affected fields and re-evaluated + * against their current values (see the Data Overlay Files specification). + * * Versus other overlays: when two overlays cover the same (offset, field), + * the one with the higher `committed_version` wins. Overlays that share a + * `committed_version` are ordered by their position in + * `DataFragment.overlays`, where a later entry is newer and wins. + */ + uint64 committed_version = 3; +} + +// Per-field coverage for a sparse overlay. +message FieldCoverage { + /* One entry per field in the overlay's `data_file.fields`, in the same order. + * Each is a 32-bit Roaring bitmap of the physical row offsets covered for that + * field. An offset present in a field's bitmap but mapped to a NULL value + * means the cell is overridden to NULL (distinct from an offset that is absent, + * which falls through to the base data file). + */ + repeated bytes offset_bitmaps = 1; +} + +/* Deletion File + * + * The path of the deletion file is constructed as: + * {root}/_deletions/{fragment_id}-{read_version}-{id}.{extension} + * where {extension} depends on DeletionFileType. + */ +message DeletionFile { + /* Type of deletion file, intended as a way to increase efficiency of the storage of deleted row + * offsets. If there are sparsely deleted rows, then ARROW_ARRAY is the most efficient. If there + * are densely deleted rows, then BITMAP is the most efficient. + */ + enum DeletionFileType { + /* A single Int32Array of deleted row offsets, stored as an Arrow IPC file with one batch and + * one column. Has a .arrow extension. + */ + ARROW_ARRAY = 0; + // A Roaring Bitmap of deleted row offsets. Has a .bin extension. + BITMAP = 1; + } + + // Type of deletion file. + DeletionFileType file_type = 1; + // The version of the dataset this deletion file was built from. + uint64 read_version = 2; + /* An opaque id used to differentiate this file from others written by concurrent + * writers. + */ + uint64 id = 3; + // The number of rows that are marked as deleted. + uint64 num_deleted_rows = 4; + /* The base path index of the deletion file. Used when the file 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 deletion file. + */ + optional uint32 base_id = 7; +} // DeletionFile + +/* Marks a row lineage sequence that is stored as a hidden column of one of the + * fragment's `files` rather than inline: `_rowid` under the reserved field id + * -3, `_row_created_at_version` under -4, `_row_last_updated_at_version` under + * -5. Exactly one entry of `DataFragment.files` carries that id in its + * `fields`, and its `column_indices` locates the column the way it does for a + * user column, so the column may share a file with the user columns or with the + * other lineage columns, or sit in a file that holds nothing else. The column is + * a non-nullable uint64 with exactly `physical_rows` values, in physical row + * order; see the table format documentation for the schema. + * + * Carries no fields: the file is found by its field id, so the file's metadata + * lives in `files` alone. + * + * Valid only in a fragment whose data files are Lance v2 files. A legacy v1 + * data file has no `column_indices` to locate the column by, so a writer on a + * dataset that stores v1 files leaves every sequence inline. + */ +message RowLineageColumn {} + +// A byte range of a file. Used by the fragment reuse index details. +message ExternalFile { + // Path to the file, relative to the root of the table. + string path = 1; + // The byte offset in the file where the data starts. + uint64 offset = 2; + // The size of the data in the file, in bytes. + uint64 size = 3; +} + +// Fragment storage layout for this version. +message FragmentTreeMetadata { + oneof layout { + FragmentTree tree = 1; + } +} + +// Fragment state for one Version Manifest. +message FragmentTree { + oneof root { + FragmentTreeRoot inline_root = 1; + /* Exactly 16 UUID bytes in display order. Resolves under this dataset as + * _bt/root/{lowercase hyphenated UUID}.root. + */ + bytes root_uuid = 2; + } + + // Mutations not represented by the selected root. + repeated FragmentTreeMutation mutations_since_root = 3; + + // Next unused action sequence for this version. + uint64 next_action_sequence = 4; +} + +// Inline or external root state. +message FragmentTreeRoot { + repeated FragmentTreeChild children = 1; + repeated FragmentTreeMutation buffer = 2; + + // First action sequence not represented by this root. + uint64 next_action_sequence = 3; +} + +/* Children are ordered by min_key. A child owns [min_key, next min_key); + * the last child inherits its parent's upper bound. + */ +message FragmentTreeChild { + // Tree object path relative to its resolved dataset. + string path = 1; + + /* Absent inherits the containing object's dataset. Otherwise matches BasePath.id + * in Manifest.base_paths; zero is a valid ID. + */ + optional uint32 base_id = 2; + + /* Inclusive lower bound of this child's fragment-ID range. It need not + * equal an ID currently stored in the child. + */ + uint64 min_key = 3; + + // Fragment count in this subtree, including this object's buffer. + uint64 num_keys = 4; + + // Edges from this object to its leaves. Zero for a leaf. + uint32 height = 5; + + // Direct child count. Zero for a leaf. + uint32 num_children = 6; + + // Physical rows in this subtree, including this object's buffer. + uint64 total_rows = 7; + + // Stored byte length of the child object. + uint64 object_size = 8; + + /* Every mutation for this leaf's range at or below this value has been + * applied, and none remain buffered above it. Zero means none. Must be 0 + * for interiors. + */ + uint64 materialized_through_action_sequence = 9; + + // Visible rows in this subtree, including this object's buffer. + uint64 visible_rows = 10; +} + +// Interior routing node. +message FragmentTreeNode { + repeated FragmentTreeChild children = 1; + repeated FragmentTreeMutation buffer = 2; +} + +// Validated mutation with exact after-minus-before aggregate deltas. +message FragmentTreeMutation { + uint64 action_sequence = 1; + FragmentAction action = 2; + sint64 fragment_count_delta = 3; + sint64 total_rows_delta = 4; + sint64 visible_rows_delta = 5; +} + +/* upsert_fragment replaces the complete record. Use it for changes that other + * actions cannot express, including overlays, version sequences, and row IDs. + */ +message FragmentAction { + oneof action { + DataFragment upsert_fragment = 1; + uint64 remove_fragment = 2; + AddDataFile add_data_file = 3; + RemoveDataFile remove_data_file = 4; + AddDeletionFile add_deletion_file = 5; + ClearDeletionFile clear_deletion_file = 6; + ReplaceDataFile replace_data_file = 7; + } +} + +// Append to the ordered file list. +message AddDataFile { + uint64 frag_id = 1; + DataFile file = 2; +} + +// Remove every matching path, preserving order. +message RemoveDataFile { + uint64 frag_id = 1; + string path = 2; +} + +message AddDeletionFile { + uint64 frag_id = 1; + DeletionFile deletion_file = 2; +} + +message ClearDeletionFile { + uint64 frag_id = 1; +} + +/* On the first expected_path match, replace path, file_size_bytes, and base_id. + * Preserve its column mapping and file version. + */ +message ReplaceDataFile { + uint64 frag_id = 1; + string expected_path = 2; + string path = 3; + uint64 file_size_bytes = 4; + /* Absent inherits the resolved dataset of the tree object or Version Manifest + * containing this mutation. Otherwise matches BasePath.id in Manifest.base_paths. + */ + optional uint32 base_id = 5; +} diff --git a/protos/index.proto b/protos/index.proto index 1fb51f3291c..aebb2b24258 100644 --- a/protos/index.proto +++ b/protos/index.proto @@ -6,6 +6,7 @@ syntax = "proto3"; package lance.index.pb; import "google/protobuf/any.proto"; +import "index_old.proto"; // The type of an index. enum IndexType { @@ -55,9 +56,10 @@ message Tensor { // Inverted Index File Metadata. message IVF { - // Centroids of partitions. `dimension * num_partitions` of float32s. - // - // Deprecated, use centroids_tensor instead. + /* Centroids of partitions. `dimension * num_partitions` of float32s. + * + * Deprecated, use centroids_tensor instead. + */ repeated float centroids = 1; // [deprecated = true]; // File offset of each partition. @@ -171,23 +173,139 @@ message VectorIndex { // Vector dimension; uint32 dimension = 2; - // Composed vector index stages. - // - // For example, `IVF_PQ` index type can be expressed as: - // - // ```text - // let stages = vec![Ivf{}, PQ{num_bits: 8, num_sub_vectors: 16}] - // ``` + /* Composed vector index stages. + * + * For example, `IVF_PQ` index type can be expressed as: + * + * ```text + * let stages = vec![Ivf{}, PQ{num_bits: 8, num_sub_vectors: 16}] + * ``` + */ repeated VectorIndexStage stages = 3; // Vector distance metrics type VectorMetricType metric_type = 4; } +// Details for vector indexes, stored in the manifest's index_details field. +message VectorIndexDetails { + VectorMetricType metric_type = 1; + + /* The target number of vectors per partition. + * 0 means unset. + */ + uint64 target_partition_size = 2; + + // Optional HNSW index configuration. If set, the index has an HNSW layer. + optional HnswParameters hnsw_index_config = 3; + + message ProductQuantization { + uint32 num_bits = 1; + uint32 num_sub_vectors = 2; + } + message ScalarQuantization { + uint32 num_bits = 1; + } + message RabitQuantization { + enum RotationType { + FAST = 0; + MATRIX = 1; + } + uint32 num_bits = 1; + RotationType rotation_type = 2; + } + + // No quantization; vectors are stored as-is. + message FlatCompression {} + + oneof compression { + ProductQuantization pq = 4; + ScalarQuantization sq = 5; + RabitQuantization rq = 6; + FlatCompression flat = 8; + } + + /* Runtime hints: optional build preferences that don't affect index structure. + * Keys use reverse-DNS namespacing (e.g., "lance.ivf.max_iters", "lancedb.accelerator"). + * Unrecognized keys must be silently ignored by all runtimes. + */ + map runtime_hints = 9; +} + +// Hierarchical Navigable Small World (HNSW) parameters, used as an optional configuration for IVF indexes. +message HnswParameters { + /* The maximum number of outgoing edges per node in the HNSW graph. Higher values + * means more connections, better recall, but more memory and slower builds. + * Referred to as "M" in the HNSW literature. + */ + uint32 max_connections = 1; + /* "construction exploration factor": The size of the dynamic list used during + * index construction. + */ + uint32 construction_ef = 2; + // The maximum number of levels in the HNSW graph. + uint32 max_level = 3; +} + message JsonIndexDetails { string path = 1; google.protobuf.Any target_details = 2; } message BloomFilterIndexDetails {} -message RTreeIndexDetails {} \ No newline at end of file +message RTreeIndexDetails {} + +message FMIndexDetails {} + +/* Details of a MinHash LSH index (near-duplicate search over text). + * + * EXPERIMENTAL: this index is an experimental format feature. This message + * and the index files may change incompatibly without a separate vote, in + * which case the index must be rebuilt (the data is never rewritten), and + * the feature is removed if its stabilization vote does not pass. Datasets + * that do not use the index are not affected either way. + * + * The message identifies every input of signature generation: the + * parameters, the tokenizer and the version of the procedure. All segments + * of one logical index carry identical details, so their signatures and the + * query signature are comparable; both index files repeat the serialized + * message in their schema metadata so that a file which does not belong to + * the details is detected. A reader validates every field against the ranges + * below before it allocates memory or reads an index file. + */ +message MinHashLshIndexDetails { + /* Number of MinHash values per signature (k): in [1, 4096] and a multiple + * of `num_bands`. + */ + uint32 num_hashes = 1; + + /* Number of LSH bands (b), in [1, 256]. Each band covers + * `num_hashes / num_bands` consecutive signature values. + */ + uint32 num_bands = 2; + + /* Number of consecutive tokens joined into one shingle; at least 1. A text + * with fewer tokens forms one shingle of all of them. + */ + uint32 shingle_size = 3; + + /* The tokenizer, recorded as a full text search index records it and + * rebuilt from this message alone at query time. Required. Fields that only + * shape full text search postings (`with_position`, `block_size`, + * `posting_format_version`, `document_granularity`) are carried unchanged + * and ignored. The details must identify the tokenizer completely, so + * settings the message cannot record (custom stop words, document-level + * text extraction) and tokenizers that load data from outside Lance (the + * jieba and lindera dictionaries) are rejected. + */ + lance.table.InvertedIndexDetails tokenizer = 4; + + /* Version of the signature procedure: the token streams of the tokenizers + * that ship with Lance, shingling, hashing, the permutation coefficients + * and their seed, compression and band keys. Bumped whenever any of them + * changes in a way that makes old and new signatures incomparable; a reader + * rejects a version it does not implement. Independent of the index file + * format version. + */ + uint32 signature_version = 5; +} diff --git a/protos/index_old.proto b/protos/index_old.proto index 601aa2681da..01be98faeb0 100644 --- a/protos/index_old.proto +++ b/protos/index_old.proto @@ -5,29 +5,86 @@ syntax = "proto3"; package lance.table; -// NOTE: Do *NOT* add new index details here. Add them to the index.proto file instead. -// This file is in the lance.table package namespace while the index.proto file is in the -// lance.index package namespace. -// -// These are only here for forward compatibility. Older versions of Lance expect btree indexes -// to have lance.table in the package namespace. -// -// If you need to modify these messages (e.g. to add new fields to btree or bitmap) then -// it is ok to modify them here. +/* NOTE: Do *NOT* add new index details here. Add them to the index.proto file instead. + * This file is in the lance.table package namespace while the index.proto file is in the + * lance.index package namespace. + * + * These are only here for forward compatibility. Older versions of Lance expect btree indexes + * to have lance.table in the package namespace. + * + * If you need to modify these messages (e.g. to add new fields to btree or bitmap) then + * it is ok to modify them here. + */ -// Currently many of these are empty messages because all needed details are either hard-coded (e.g. -// filenames) or stored in the index itself. However, we may want to add more details in the -// future, in particular we can add details that may be useful for planning queries (e.g. don't -// force us to load the index until we know we can make use of it) +/* Currently many of these are empty messages because all needed details are either hard-coded (e.g. + * filenames) or stored in the index itself. However, we may want to add more details in the + * future, in particular we can add details that may be useful for planning queries (e.g. don't + * force us to load the index until we know we can make use of it) + */ message BTreeIndexDetails {} message BitmapIndexDetails {} message LabelListIndexDetails {} message NGramIndexDetails {} -message ZoneMapIndexDetails {} +message ZoneMapIndexDetails { + /* Number of rows per zone. Optional for backwards compatibility: absent on + * datasets written before this field was added. When absent, no seed writer + * is created for the index. + */ + optional uint64 rows_per_zone = 1; + /* Whether seed-based incremental updates are enabled for this index. + * On-disk semantics: absent means seeds are disabled (old datasets written + * before this field was added). Present false means explicitly disabled. + * Present true means seeds are enabled: the index will embed per-fragment + * seed buffers in data files and harvest them during incremental updates + * to skip full column scans. + * Creation-time default: index creation code sets this to true for + * variable-length types (strings, binary) and fixed-width types wider than + * 8 bytes, and to false for narrow fixed-width types (e.g. Int64, Float64). + */ + optional bool use_seeds = 2; + /* Whether this index tracks exact null row addresses in a separate bitmap. + * Absent or false means legacy format: null positions are not tracked and + * IS NULL searches fall back to approximate zone-level statistics. Present + * true means IS NULL is exact and IS NOT NULL can be answered without a + * full scan. + */ + optional bool has_null_bitmap = 3; +} message InvertedIndexDetails { - // Marking this field as optional as old versions of the index store blank details and we - // need to make sure we have a proper optional field to detect this. + enum DocumentGranularity { + ROW = 0; + LIST_ELEMENT = 1; + } + + message CodeTokenizerConfig { + /* Split one lexical identifier into subwords, e.g. getUserName -> + * get/user/name. + */ + bool split_identifiers = 1; + /* Split identifier subwords across letter/number boundaries, e.g. + * HTML2JSON -> html/2/json. An absent value uses the code tokenizer default; + * a present value records the explicit index-time choice. + */ + optional bool split_on_numerics = 2; + /* Keep the complete lexical identifier in addition to subwords, e.g. + * user_name plus user/name. An absent value uses the code tokenizer default; + * a present value records the explicit index-time choice. + */ + optional bool preserve_original = 3; + /* Index operator tokens such as "::", "->", and "!=". Operators are not + * indexed by default because they are often high-frequency noise. + */ + bool index_operators = 4; + } + + /* Lexical tokenizer used after document-level text extraction. This is an + * implementation component such as "simple", "icu", "ngram", or "code". + * Input-time analyzer profiles are expanded into this field and the concrete + * options below before these details are persisted. + * Marking this field as optional as old versions of the index store blank details and we + * need to make sure we have a proper optional field to detect this. + */ optional string base_tokenizer = 1; string language = 2; bool with_position = 3; @@ -39,4 +96,23 @@ message InvertedIndexDetails { uint32 min_ngram_length = 9; uint32 max_ngram_length = 10; bool prefix_only = 11; + /* Number of documents per compressed posting block. An absent value means + * the index predates this field and must use the legacy block size of 128. + * A present value records the block size used by the index; 256 is valid + * with format versions 3 and 4. + */ + optional uint32 block_size = 12; + /* Options for base_tokenizer = "code". Presence records the code tokenizer + * configuration used to build the index; absence means there is no + * code-specific configuration to apply. + */ + CodeTokenizerConfig code_config = 13; + /* The logical FTS document boundary. The protobuf default preserves the + * legacy row-document behavior when this field is absent. + */ + DocumentGranularity document_granularity = 14; + /* The posting-list payload format. This is separate from index_version, + * which identifies the overall inverted-index layout. + */ + optional uint32 posting_format_version = 15; } diff --git a/protos/rowids.proto b/protos/rowids.proto index 3039cbf2a92..86cec769980 100644 --- a/protos/rowids.proto +++ b/protos/rowids.proto @@ -4,13 +4,15 @@ syntax = "proto3"; package lance.table; -// TODO: what would it take to store this in a LanceV2 file? -// Or would flatbuffers be better for this? +/* TODO: what would it take to store this in a LanceV2 file? + * Or would flatbuffers be better for this? + */ -/// A sequence of row IDs. This is split up into one or more segments, -/// each of which can be encoded in different ways. The encodings are optimized -/// for values that are sorted, which will often be the case with row ids. -/// They also have optimized forms depending on how sparse the values are. +/* A sequence of row IDs. This is split up into one or more segments, + * each of which can be encoded in different ways. The encodings are optimized + * for values that are sorted, which will often be the case with row ids. + * They also have optimized forms depending on how sparse the values are. + */ message RowIdSequence { repeated U64Segment segments = 1; } @@ -31,11 +33,12 @@ message U64Segment { uint64 start = 1; /// The end of the range, exclusive. uint64 end = 2; - /// The holes in the range, as a sorted array of values; - /// Binary search can be used to check whether a value is a hole and should - /// be skipped. This can also be used to count the number of holes before a - /// given value, if you need to find the logical offset of a value in the - /// segment. + /* The holes in the range, as a sorted array of values; + * Binary search can be used to check whether a value is a hole and should + * be skipped. This can also be used to count the number of holes before a + * given value, if you need to find the logical offset of a value in the + * segment. + */ EncodedU64Array holes = 3; } @@ -45,13 +48,14 @@ message U64Segment { uint64 start = 1; /// The end of the range, exclusive. uint64 end = 2; - /// A bitmap of the values in the range. The bitmap is a sequence of bytes, - /// where each byte represents 8 values. The first byte represents values - /// start to start + 7, the second byte represents values start + 8 to - /// start + 15, and so on. The most significant bit of each byte represents - /// the first value in the range, and the least significant bit represents - /// the last value in the range. If the bit is set, the value is in the - /// range; if it is not set, the value is not in the range. + /* A bitmap of the values in the range. The bitmap is a sequence of bytes, + * where each byte represents 8 values. The first byte represents values + * start to start + 7, the second byte represents values start + 8 to + * start + 15, and so on. The most significant bit of each byte represents + * the first value in the range, and the least significant bit represents + * the last value in the range. If the bit is set, the value is in the + * range; if it is not set, the value is not in the range. + */ bytes bitmap = 3; } @@ -73,15 +77,17 @@ message U64Segment { message EncodedU64Array { message U16Array { uint64 base = 1; - /// The deltas are stored as 16-bit unsigned integers. - /// (protobuf doesn't support 16-bit integers, so we use bytes instead) + /* The deltas are stored as 16-bit unsigned integers. + * (protobuf doesn't support 16-bit integers, so we use bytes instead) + */ bytes offsets = 2; } message U32Array { uint64 base = 1; - /// The deltas are stored as 32-bit unsigned integers. - /// (we use bytes instead of uint32 to avoid overhead of varint encoding) + /* The deltas are stored as 32-bit unsigned integers. + * (we use bytes instead of uint32 to avoid overhead of varint encoding) + */ bytes offsets = 2; } @@ -97,9 +103,10 @@ message EncodedU64Array { } } -/// A sequence of dataset versions. Similar to RowIdSequence but tracks -/// version runs. It uses RLE (Run-Length Encoding) to efficiently -// represent consecutive rows with the same version. +/* A sequence of dataset versions. Similar to RowIdSequence but tracks + * version runs. It uses RLE (Run-Length Encoding) to efficiently + * represent consecutive rows with the same version. + */ message RowDatasetVersionSequence { repeated RowDatasetVersionRun runs = 1; } diff --git a/protos/table.proto b/protos/table.proto index e7de867e46e..1d0febbf215 100644 --- a/protos/table.proto +++ b/protos/table.proto @@ -8,6 +8,7 @@ package lance.table; import "google/protobuf/any.proto"; import "google/protobuf/timestamp.proto"; import "file.proto"; +import public "fragment_metadata.proto"; /* @@ -43,54 +44,64 @@ message Manifest { // Fragments of the dataset. repeated DataFragment fragments = 2; + /* Tree tables set this, leave fragments empty, and set bit 12 on both flag + * fields. Old readers skip unknown fields and would otherwise see an empty table. + */ + FragmentTreeMetadata fragment_tree = 22; + // Snapshot version number. uint64 version = 3; - // The file position of the version auxiliary data. - // * It is not inheritable between versions. - // * It is not loaded by default during query. + /* The file position of the version auxiliary data. + * * It is not inheritable between versions. + * * It is not loaded by default during query. + */ uint64 version_aux_data = 4; message WriterVersion { // The name of the library that created this file. string library = 1; - // The version of the library that created this file. Because we cannot assume - // that the library is semantically versioned, this is a string. However, if it - // is semantically versioned, it should be a valid semver string without any 'v' - // prefix. For example: `2.0.0`, `2.0.0-rc.1`. - // - // For forward compatibility with older readers, when writing new manifests this - // field should contain only the core version (major.minor.patch) without any - // prerelease or build metadata. The prerelease/build info should be stored in - // the separate prerelease and build_metadata fields instead. + /* The version of the library that created this file. Because we cannot assume + * that the library is semantically versioned, this is a string. However, if it + * is semantically versioned, it should be a valid semver string without any 'v' + * prefix. For example: `2.0.0`, `2.0.0-rc.1`. + * + * For forward compatibility with older readers, when writing new manifests this + * field should contain only the core version (major.minor.patch) without any + * prerelease or build metadata. The prerelease/build info should be stored in + * the separate prerelease and build_metadata fields instead. + */ string version = 2; - // Optional semver prerelease identifier. - // - // This field stores the prerelease portion of a semantic version separately - // from the core version number. For example, if the full version is "2.0.0-rc.1", - // the version field would contain "2.0.0" and prerelease would contain "rc.1". - // - // This separation ensures forward compatibility: older readers can parse the - // clean version field without errors, while newer readers can reconstruct the - // full semantic version by combining version, prerelease, and build_metadata. - // - // If absent, the version field is used as-is. + /* Optional semver prerelease identifier. + * + * This field stores the prerelease portion of a semantic version separately + * from the core version number. For example, if the full version is "2.0.0-rc.1", + * the version field would contain "2.0.0" and prerelease would contain "rc.1". + * + * This separation ensures forward compatibility: older readers can parse the + * clean version field without errors, while newer readers can reconstruct the + * full semantic version by combining version, prerelease, and build_metadata. + * + * If absent, the version field is used as-is. + */ optional string prerelease = 3; - // Optional semver build metadata. - // - // This field stores the build metadata portion of a semantic version separately - // from the core version number. For example, if the full version is - // "2.0.0-rc.1+build.123", the version field would contain "2.0.0", prerelease - // would contain "rc.1", and build_metadata would contain "build.123". - // - // If absent, no build metadata is present. + /* Optional semver build metadata. + * + * This field stores the build metadata portion of a semantic version separately + * from the core version number. For example, if the full version is + * "2.0.0-rc.1+build.123", the version field would contain "2.0.0", prerelease + * would contain "rc.1", and build_metadata would contain "build.123". + * + * If absent, no build metadata is present. + */ optional string build_metadata = 4; } - // The version of the writer that created this file. - // - // This information may be used to detect whether the file may have known bugs - // associated with that writer. + /* The version of the writer that created this file. + * + * This information may be used to detect whether the file may have known bugs + * associated with that writer. + */ WriterVersion writer_version = 13; // If present, the file position of the index metadata. @@ -102,105 +113,163 @@ message Manifest { // Optional version tag string tag = 8; - // Feature flags for readers. - // - // A bitmap of flags that indicate which features are required to be able to - // read the table. If a reader does not recognize a flag that is set, it - // should not attempt to read the dataset. - // - // Known flags: - // * 1: deletion files are present - // * 2: row ids are stable and stored as part of the fragment metadata. - // * 4: use v2 format (deprecated) - // * 8: table config is present + /* Feature flags for readers. + * + * A bitmap of flags that indicate which features are required to be able to + * read the table. If a reader does not recognize a flag that is set, it + * should not attempt to read the dataset. + * + * Known flags: + * * 1 << 0: deletion files are present + * * 1 << 1: row ids are stable and stored as part of the fragment metadata. + * * 1 << 2: use v2 format (deprecated) + * * 1 << 3: table config is present + * * 1 << 4: dataset uses multiple base paths + * * 1 << 5: transaction file writes are disabled + * * 1 << 6: data overlay files are present (see DataOverlayFile). Readers that do + * not understand overlays must refuse the dataset, since ignoring an overlay + * would silently return stale base values. + * * 1 << 7: some index declares covering columns. Without 1 << 13, + * the carried columns form the trailing suffix of IndexMetadata.fields in + * emission order (see IndexMetadata). Readers + * that do not understand covering columns must refuse the dataset, since + * selecting an index by membership of fields would answer a query on a + * merely-carried column with an index keyed on a different column. Writers + * must refuse it too: one that treats every entry of fields as keyed would + * maintain the index against the wrong dependency set. + * * 1 << 8: the dataset may reference recognized V2 data files with different + * exact versions. Readers and writers must use each DataFile's version + * instead of treating data_format.version as a snapshot-wide identity. + * This bit is paired in the reader and writer feature words and is one-way. + * * 1 << 9: the table uses stable row ids and carries a fragment reuse index. + * * 1 << 12: fragment records live in fragment_tree. The fragments list + * must be empty. Readers that do not know this bit must refuse the dataset. + * * 1 << 13: IndexMetadata.fields and covering_fields are independent + * declarations. This bit requires 1 << 7 in both the reader and writer + * flags, and the two bits are retained together: whenever this bit is set, + * 1 << 7 is also set, including manifests with no covering entries and + * manifests that drop the last covering index. Fields contains only the columns the index is keyed on, while + * covering_fields contains the columns whose values it carries. A field may + * occur in both lists. Implementations that only support the legacy suffix + * contract must refuse the dataset. Setting this bit reinterprets every + * IndexMetadata in the index section, so a writer must normalize every + * legacy-form entry in the same manifest -- or set the bit only when no + * entry declares covering fields -- and manifests derived from it must keep + * the bit set until a reverse conversion is likewise atomic. + */ uint64 reader_feature_flags = 9; - // Feature flags for writers. - // - // A bitmap of flags that indicate which features must be used when writing to the - // dataset. If a writer does not recognize a flag that is set, it should not attempt to - // write to the dataset. - // - // The flag identities are the same as for reader_feature_flags, but the values of - // reader_feature_flags and writer_feature_flags are not required to be identical. + /* Feature flags for writers. + * + * A bitmap of flags that indicate which features must be used when writing to the + * dataset. If a writer does not recognize a flag that is set, it should not attempt to + * write to the dataset. + * + * The flag identities are the same as for reader_feature_flags, but the values of + * reader_feature_flags and writer_feature_flags are not required to be identical. + */ uint64 writer_feature_flags = 10; - // The highest fragment ID that has been used so far. - // - // This ID is not guaranteed to be present in the current version, but it may - // have been used in previous versions. - // - // For a single fragment, will be zero. For no fragments, will be absent. + /* Highest fragment id ever allocated or reserved in this lineage. + * Absent only before the first allocation or reservation. Never decreases. + * u32::MAX means the fragment-id space is exhausted. + */ optional uint32 max_fragment_id = 11; - // Path to the transaction file, relative to `{root}/_transactions`. The file at that - // location contains a wire-format serialized Transaction message representing the - // transaction that created this version. - // - // This string field "transaction_file" may be empty if no transaction file was written. - // - // The path format is "{read_version}-{uuid}.txn" where {read_version} is the version of - // the table the transaction read from (serialized to decimal with no padding digits), - // and {uuid} is a hyphen-separated UUID. + /* Path to the transaction file, relative to `{root}/_transactions`. The file at that + * location contains a wire-format serialized Transaction message representing the + * transaction that created this version. + * + * This string field "transaction_file" may be empty if no transaction file was written. + * + * The path format is "{read_version}-{uuid}.txn" where {read_version} is the version of + * the table the transaction read from (serialized to decimal with no padding digits), + * and {uuid} is a hyphen-separated UUID. + */ string transaction_file = 12; - // The file position of the transaction content. None if transaction is empty - // This transaction content begins with the transaction content length as u32 - // If the transaction proto message has a length of `len`, the message ends at `len` + 4 - optional uint64 transaction_section = 21; - - // The next unused row id. If zero, then the table does not have any rows. - // - // This is only used if the "stable_row_ids" feature flag is set. + /* Deprecated location of the inline transaction section. Never written by + * current writers, but still read for versions written before field 23. + * + * Lance v1.0 through v11.0 eagerly decode the transaction at this offset when + * loading the manifest and fail if it does not decode. A transaction whose + * operation is newer than the reader would make the whole version + * unopenable, so the section moved to field 23, which those readers ignore. + */ + optional uint64 transaction_section_deprecated = 21; + + /* The file position of the transaction content. None if transaction is empty + * This transaction content begins with the transaction content length as u32 + * If the transaction proto message has a length of `len`, the message ends at `len` + 4 + * + * The offset is inert: a reader must never fail to open the manifest because + * it cannot decode the transaction it points at. + */ + optional uint64 transaction_section = 23; + + /* The next unused row id. If zero, then the table does not have any rows. + * + * This is only used if the "stable_row_ids" feature flag is set. + */ uint64 next_row_id = 14; message DataStorageFormat { // The format of the data files (e.g. "lance") string file_format = 1; - // The max format version of the data files. The format of the version can vary by - // file_format and is not required to follow semver. - // - // Every file in this version of the dataset has the same file_format version. + /* The default format version used when writing data files. Write operations + * that do not select an exact version use this value. This setting does not + * constrain index or other file types. The version syntax is defined by + * file_format and is not required to follow semver. + * + * Unless the mixed data-file-version capability is set, every referenced + * data file must use this version. When the capability is set, referenced + * data files may use other recognized exact V2 versions, and each DataFile's + * version is authoritative for decoding. + */ string version = 2; } - // The data storage format - // - // This specifies what format is used to store the data files. + /* The data storage format + * + * This specifies what format is used to store the data files. + */ DataStorageFormat data_format = 15; - // Table config. - // - // Keys with the prefix "lance." are reserved for the Lance library. Other - // libraries may wish to similarly prefix their configuration keys - // appropriately. + /* Table config. + * + * Keys with the prefix "lance." are reserved for the Lance library. Other + * libraries may wish to similarly prefix their configuration keys + * appropriately. + */ map config = 16; - // Metadata associated with the table. - // - // This is a key-value map that can be used to store arbitrary metadata - // associated with the table. - // - // This is different than configuration, which is used to tell libraries how - // to read, write, or manage the table. - // - // This is different than schema metadata, which is used to describe the - // data itself and is attached to the output schema of scans. + /* Metadata associated with the table. + * + * This is a key-value map that can be used to store arbitrary metadata + * associated with the table. + * + * This is different than configuration, which is used to tell libraries how + * to read, write, or manage the table. + * + * This is different than schema metadata, which is used to describe the + * data itself and is attached to the output schema of scans. + */ map table_metadata = 19; // Field number 17 (`blob_dataset_version`) was used for a secondary blob dataset. reserved 17; reserved "blob_dataset_version"; - // The base paths of data files. - // - // This is used to determine the base path of a data file. In common cases data file paths are under current dataset base path. - // But for shallow cloning, importing file and other multi-tier storage cases, the actual data files could be outside of the current dataset. - // This field is used with the `base_id` in `lance.file.File` and `lance.file.DeletionFile`. - // - // For example, if we have a dataset with base path `s3://bucket/dataset`, we have a DataFile with base_id 0, we get the actual data file path by: - // base_paths[id = 0] + /data/ + file.path - // the key(a.k.a index) starts from 0, increased by 1 for each new base path. + /* The base paths of data files. + * + * This is used to determine the base path of a data file. In common cases data file paths are under current dataset base path. + * But for shallow cloning, importing file and other multi-tier storage cases, the actual data files could be outside of the current dataset. + * This field is used with the `base_id` in `lance.file.File` and `lance.file.DeletionFile`. + * + * For example, if we have a dataset with base path `s3://bucket/dataset`, we have a DataFile with base_id 0, we get the actual data file path by: + * base_paths[id = 0] + /data/ + file.path + * the key(a.k.a index) starts from 0, increased by 1 for each new base path. + */ repeated BasePath base_paths = 18; // The branch of the dataset. None means main branch. @@ -210,19 +279,22 @@ message Manifest { // external dataset base path message BasePath { uint32 id = 1; - // This is an alias name of the base path, it is optional. - // When we use shallow clone and the target version is a tag, the tag name will be set here. + /* This is an alias name of the base path, it is optional. + * When we use shallow clone and the target version is a tag, the tag name will be set here. + */ optional string name = 2; - // Flag indicating whether this path is a dataset root path or file directory: - // - true: Path is a dataset root (actual files under subdirectories like `data`, '_deletions') - // - false: Path is a direct file directory (scenario like importing files) + /* Flag indicating whether this path is a dataset root path or file directory: + * - true: Path is a dataset root (actual files under subdirectories like `data`, '_deletions') + * - false: Path is a direct file directory (scenario like importing files) + */ bool is_dataset_root = 3; // Note: This absolute path will be directly used by Path:parse(), string path = 4; } -// Auxiliary Data attached to a version. -// Only load on-demand. +/* Auxiliary Data attached to a version. + * Only load on-demand. + */ message VersionAuxData { // key-value metadata. map metadata = 3; @@ -233,7 +305,15 @@ message IndexMetadata { // Unique ID of an index. It is unique across all the dataset versions. UUID uuid = 1; - // The columns to build the index. These refer to file.Field.id. + /* The columns the index is keyed on, in key order. These refer to file.Field.id. + * + * When the manifest has feature flag 1 << 13, this list contains only key + * fields. `covering_fields` independently declares the values the index + * carries. + * + * Without feature flag 1 << 13, the legacy contract applies: the carried fields + * form the trailing suffix of this list in emission order. + */ repeated int32 fields = 2; // Index name. Must be unique within one dataset version. @@ -242,226 +322,99 @@ message IndexMetadata { // The version of the dataset this index was built from. uint64 dataset_version = 4; - // A bitmap of the included fragment ids. - // - // This may by used to determine how much of the dataset is covered by the - // index. This information can be retrieved from the dataset by looking at - // the dataset at `dataset_version`. However, since the old version may be - // deleted while the index is still in use, this information is also stored - // in the index. - // - // The bitmap is stored as a 32-bit Roaring bitmap. + /* A bitmap of the included fragment ids. + * + * This may by used to determine how much of the dataset is covered by the + * index. This information can be retrieved from the dataset by looking at + * the dataset at `dataset_version`. However, since the old version may be + * deleted while the index is still in use, this information is also stored + * in the index. + * + * The bitmap is stored as a 32-bit Roaring bitmap. + */ bytes fragment_bitmap = 5; - // Details, specific to the index type, which are needed to load / interpret the index - // - // Indices should avoid putting large amounts of information in this field, as it will - // bloat the manifest. - // - // Indexes are plugins, and so the format of the details message is flexible and not fully - // defined by the table format. However, there are some conventions that should be followed: - // - // - When Lance APIs refer to indexes they will use the type URL of the index details as the - // identifier for the index type. If a user provides a simple string identifier like - // "btree" then it will be converted to "/lance.table.BTreeIndexDetails" - // - Type URLs comparisons are case-insensitive. Thereform an index must have a unique type - // URL ignoring case. + /* Details, specific to the index type, which are needed to load / interpret the index + * + * Indices should avoid putting large amounts of information in this field, as it will + * bloat the manifest. + * + * Indexes are plugins, and so the format of the details message is flexible and not fully + * defined by the table format. However, there are some conventions that should be followed: + * + * - When Lance APIs refer to indexes they will use the type URL of the index details as the + * identifier for the index type. If a user provides a simple string identifier like + * "btree" then it will be converted to "/lance.table.BTreeIndexDetails" + * - Type URLs comparisons are case-insensitive. Thereform an index must have a unique type + * URL ignoring case. + */ google.protobuf.Any index_details = 6; // The minimum lance version that this index is compatible with. optional int32 index_version = 7; - // Timestamp when the index was created (UTC timestamp in milliseconds since epoch) - // - // This field is optional for backward compatibility. For existing indices created before - // this field was added, this will be None/null. + /* Timestamp when the index was created (UTC timestamp in milliseconds since epoch) + * + * This field is optional for backward compatibility. For existing indices created before + * this field was added, this will be None/null. + */ optional uint64 created_at = 8; - // 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. + /* 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; -} - -// Index Section, containing a list of index metadata for one dataset version. -message IndexSection { - repeated IndexMetadata indices = 1; -} -// A DataFragment is a set of files which represent the different columns of the same -// rows. If column exists in the schema of a dataset, but the file for that column does -// not exist within a DataFragment of that dataset, that column consists entirely of -// nulls. -message DataFragment { - // The ID of a DataFragment is unique within a dataset. - uint64 id = 1; - - repeated DataFile files = 2; - - // File that indicates which rows, if any, should be considered deleted. - DeletionFile deletion_file = 3; - - // TODO: What's the simplest way we can allow an inline tombstone bitmap? - - // A serialized RowIdSequence message (see rowids.proto). - // - // These are the row ids for the fragment, in order of the rows as they appear. - // That is, if a fragment has 3 rows, and the row ids are [1, 42, 3], then the - // first row is row 1, the second row is row 42, and the third row is row 3. - oneof row_id_sequence { - // If small (< 200KB), the row ids are stored inline. - bytes inline_row_ids = 5; - // Otherwise, stored as part of a file. - ExternalFile external_row_ids = 6; - } // row_id_sequence - - oneof last_updated_at_version_sequence { - // If small (< 200KB), the row latest updated versions are stored inline. - bytes inline_last_updated_at_versions = 7; - // Otherwise, stored as part of a file. - ExternalFile external_last_updated_at_versions = 8; - } // last_updated_at_version_sequence - - oneof created_at_version_sequence { - // If small (< 200KB), the row created at versions are stored inline. - bytes inline_created_at_versions = 9; - // Otherwise, stored as part of a file. - ExternalFile external_created_at_versions = 10; - } // created_at_version_sequence - - // Number of original rows in the fragment, this includes rows that are now marked with - // deletion tombstones. To compute the current number of rows, subtract - // `deletion_file.num_deleted_rows` from this value. - uint64 physical_rows = 4; + /* 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; + + /* The fields whose values this index co-locates alongside its own data, so a + * query projecting only those columns can be answered from the index without + * a take against the base table. + * + * When the manifest has feature flag 1 << 13, this declaration is independent + * of `fields` and has no positional relationship to it. A field may occur in + * both lists when the index carries a value it is also keyed on. Consumers + * must use the union of `fields` and `covering_fields` as the index dependency + * set. + * + * Without feature flag 1 << 13, the legacy contract applies: the carried columns + * form the trailing suffix of `fields` in emission order. A column the + * index is both keyed on and carries appears twice, once as a key and once in + * the carried suffix. + * + * Empty for an index that carries no extra columns, which is every index + * written before this field existed. + * + * This declaration is not authoritative for what the segment can actually + * serve. The segment's own storage schema is: a reader must confirm the + * storage carries a column before answering a query from it, and fall back to + * a take against the base table otherwise. A declaration naming columns the + * storage does not hold is a legal state, not corruption -- a maintenance + * operation that cannot carry the values through a rebuild is permitted to + * withdraw the payload while leaving this declaration in place. + */ + repeated int32 covering_fields = 11; } -message DataFile { - // Path to the root relative to the dataset's URI. +// 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; - // The ids of the fields/columns in this file. - // - // When a DataFile object is created in memory, every value in fields is assigned -1 by - // default. An object with a value in fields of -1 must not be stored to disk. -2 is - // used for "tombstoned", meaning a field that is no longer in use. This is often - // because the original field id was reassigned to a different data file. - // - // In Lance v1 IDs are assigned based on position in the file, offset by the max - // existing field id in the table (if any already). So when a fragment is first created - // with one file of N columns, the field ids will be 1, 2, ..., N. If a second fragment - // is created with M columns, the field ids will be N+1, N+2, ..., N+M. - // - // In Lance v1 there is one field for each field in the input schema, this includes - // nested fields (both struct and list). Fixed size list fields have only a single - // field id (these are not considered nested fields in Lance v1). - // - // This allows column indices to be calculated from field IDs and the input schema. - // - // In Lance v2 the field IDs generally follow the same pattern but there is no - // way to calculate the column index from the field ID. This is because a given - // field could be encoded in many different ways, some of which occupy a different - // number of columns. For example, a struct field could be encoded into N + 1 columns - // or it could be encoded into a single packed column. To determine column indices - // the column_indices property should be used instead. - // - // In Lance v1 these ids must be sorted but might not always be contiguous. - repeated int32 fields = 2; - // The top-level column indices for each field in the file. - // - // If the data file is version 1 then this property will be empty - // - // Otherwise there must be one entry for each field in `fields`. - // - // Some fields may not correspond to a top-level column in the file. In these cases - // the index will -1. - // - // For example, consider the schema: - // - // - dimension: packed-struct (0): - // - x: u32 (1) - // - y: u32 (2) - // - path: `list` (3) - // - embedding: `fsl<768>` (4) - // - fp64 - // - borders: `fsl<4>` (5) - // - simple-struct (6) - // - margin: fp64 (7) - // - padding: fp64 (8) - // - // One possible column indices array could be: - // [0, -1, -1, 1, 3, 4, 5, 6, 7] - // - // This reflects quite a few phenomenon: - // - The packed struct is encoded into a single column and there is no top-level column - // for the x or y fields - // - The variable sized list is encoded into two columns - // - The embedding is encoded into a single column (common for FSL of primitive) and there - // is not "FSL column" - // - The borders field actually does have an "FSL column" - // - // The column indices table may not have duplicates (other than -1) - repeated int32 column_indices = 3; - // The major file version used to create the file - uint32 file_major_version = 4; - // The minor file version used to create the file - // - // If both `file_major_version` and `file_minor_version` are set to 0, - // then this is a version 0.1 or version 0.2 file. - uint32 file_minor_version = 5; - - // The known size of the file on disk in bytes. - // - // This is used to quickly find the footer of the file. - // - // When this is zero, it should be interpreted as "unknown". - uint64 file_size_bytes = 6; - - // 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 = 7; -} // DataFile - -// Deletion File -// -// The path of the deletion file is constructed as: -// {root}/_deletions/{fragment_id}-{read_version}-{id}.{extension} -// where {extension} depends on DeletionFileType. -message DeletionFile { - // Type of deletion file, intended as a way to increase efficiency of the storage of deleted row - // offsets. If there are sparsely deleted rows, then ARROW_ARRAY is the most efficient. If there - // are densely deleted rows, then BITMAP is the most efficient. - enum DeletionFileType { - // A single Int32Array of deleted row offsets, stored as an Arrow IPC file with one batch and - // one column. Has a .arrow extension. - ARROW_ARRAY = 0; - // A Roaring Bitmap of deleted row offsets. Has a .bin extension. - BITMAP = 1; - } + // Size of the file in bytes + uint64 size_bytes = 2; +} - // Type of deletion file. - DeletionFileType file_type = 1; - // The version of the dataset this deletion file was built from. - uint64 read_version = 2; - // An opaque id used to differentiate this file from others written by concurrent - // writers. - uint64 id = 3; - // The number of rows that are marked as deleted. - uint64 num_deleted_rows = 4; - // The base path index of the deletion file. Used when the file 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 deletion file. - optional uint32 base_id = 7; -} // DeletionFile - -message ExternalFile { - // Path to the file, relative to the root of the table. - string path = 1; - // The byte offset in the file where the data starts. - uint64 offset = 2; - // The size of the data in the file, in bytes. - uint64 size = 3; +// Index Section, containing a list of index metadata for one dataset version. +message IndexSection { + repeated IndexMetadata indices = 1; } -// Empty details messages for older indexes that don't take advantage of the details field. -message VectorIndexDetails {} +// VectorIndexDetails and HnswParameters (formerly HnswIndexDetails) moved to index.proto message FragmentReuseIndexDetails { @@ -472,7 +425,48 @@ message FragmentReuseIndexDetails { } message InlineContent { - repeated Version versions = 1; + // Existing compaction records, with the original field number and contents. + repeated Version legacy_versions = 1; + // New mappings; requires index_version = 1. + repeated Transition transitions = 2; + } + + // One transition from source fragments to destination fragments. + message Transition { + // Source fragments in scan order; counts include deleted rows. + repeated FragmentDigest sources = 1; + // Destination fragments in label order; counts are recorded at creation. + repeated FragmentDigest destinations = 2; + /* Exactly one mapping is required. + * New mapping types do not require an index_version increase if they preserve + * backward compatibility and older readers can handle them safely, including + * by falling back to a scan. Otherwise, index_version must increase. + * Other field numbers may be used for ordinary metadata. + */ + oneof mapping { + /* Concatenate surviving rows in source fragment order, preserving row order + * within each fragment, then split into destination fragments. + */ + OrderedCompaction ordered_compaction = 3; + /* Process source fragments sequentially, distributing their rows across + * destination fragments by label in source row order. + */ + StablePartition stable_partition = 4; + } + } + + message OrderedCompaction { + // RoaringTreemap of surviving source row addresses; missing rows are deleted. + bytes changed_row_addrs = 1; + } + + message StablePartition { + // UUID in _fri//stable_partition.lance; unchanged when the FRI UUID changes. + string map_id = 1; + // Size of stable_partition.lance in bytes. + uint64 map_size_bytes = 2; + // Unset: use the dataset base. Set: look up this ID in Manifest.base_paths. + optional uint32 base_id = 3; } message FragmentDigest { @@ -485,10 +479,11 @@ message FragmentReuseIndexDetails { // A summarized version of the RewriteGroup information in a Rewrite transaction message Group { - // A roaring treemap of the changed row addresses. - // When combined with the old fragment IDs and new fragment IDs, - // it can recover the full mapping of old row addresses to either new row addresses or deleted. - // this mapping can then be used to remap indexes or satisfy index queries for the new unindexed fragments. + /* A roaring treemap of the changed row addresses. + * When combined with the old fragment IDs and new fragment IDs, + * it can recover the full mapping of old row addresses to either new row addresses or deleted. + * this mapping can then be used to remap indexes or satisfy index queries for the new unindexed fragments. + */ bytes changed_row_addrs = 1; repeated FragmentDigest old_fragments = 2; @@ -504,176 +499,284 @@ message FragmentReuseIndexDetails { } } -// ============================================================================ -// MemWAL Index Types -// ============================================================================ +/* ============================================================================ + * MemWAL Index Types + * ============================================================================ + */ + +/* Lifecycle status of a WAL shard. Drives drop-table two-phase commit: + * a SEALED shard refuses new writer claims (reversible) until the drop + * commits (the shard dir is deleted) or rolls back (status -> ACTIVE). + */ +enum ShardStatus { + // Normal: the shard accepts writer claims. + ACTIVE = 0; + // A drop is in flight: claims are refused. Reversible to ACTIVE. + SEALED = 1; +} -// 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. + /* 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; - - // Writer fencing token - monotonically increasing. - // A writer must increment this when claiming the region. + /* 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; + + /* Computed shard field values as raw Arrow scalar bytes, keyed by shard + * field id. The byte encoding follows Arrow's little-endian convention: + * int32 is 4 LE bytes, utf8 is raw UTF-8 bytes, etc. The receiver looks + * up the result_type from the ShardingSpec to interpret each value. + */ + repeated ShardFieldEntry shard_field_entries = 14; + + /* Writer fencing token - monotonically increasing. + * 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. - // During recovery, replay starts from replay_after_wal_entry_position + 1. + /* The most recent WAL entry position that has been flushed to a MemTable. + * During recovery, replay starts from replay_after_wal_entry_position + 1. + * WAL positions are 1-based, so the default value 0 unambiguously means + * "no flush has ever stamped this shard" and recovery replays from 1. + */ uint64 replay_after_wal_entry_position = 3; - // The most recent WAL entry position (0-based) at the time manifest was updated. - // This is a hint, not authoritative - recovery must list files to find actual state. + /* The most recent WAL entry position observed at the time the manifest was + * updated. WAL positions are 1-based; default 0 means no entry has been + * written yet. This is a hint, not authoritative - recovery must list + * files to find actual state. + */ uint64 wal_entry_position_last_seen = 4; - // Next generation ID to create (incremented after each MemTable flush). + // Generation to assign to the next SSTable (incremented after each MemTable flush). uint64 current_generation = 6; - // Field 7 removed: merged_generation moved to MemWalIndexDetails.merged_generations - // which is the authoritative source for merge progress. + /* Field 7 removed: compaction progress lives in + * MemWalIndexDetails.compacted_sstables. + */ + + // List of SSTables created by flushing MemTables and their directory paths. + repeated SsTable sstables = 8; + + /* Lifecycle status. Default ACTIVE; SEALED marks an in-flight drop + * (drop-table 2PC). A SEALED manifest refuses claims at claim_epoch. + */ + ShardStatus status = 15; +} + +// A shard field value stored as raw Arrow scalar bytes. +message ShardFieldEntry { + // Shard field id (matches ShardingField.field_id in the ShardingSpec). + string field_id = 1; - // List of flushed MemTable generations and their directory paths. - repeated FlushedGeneration flushed_generations = 8; + /* Raw Arrow scalar value bytes in little-endian encoding. + * The data type is determined by the result_type of the matching ShardingField. + */ + bytes value = 2; } -// A flushed MemTable generation and its storage location. -message FlushedGeneration { - // Generation number. +// An SSTable: the immutable result of flushing a MemTable, stored as a Lance dataset. +message SsTable { + // Generation number identifying this SSTable. uint64 generation = 1; - // Directory name relative to the region directory. + // Directory name relative to the shard directory. string path = 2; + + /* Payload size of the rows, as the writer's MemTable accounted for them: the + sum over its buffers of the window each one holds. + + An estimate of the payload, not a bound on what reading the SSTable costs. + It excludes the per-array structure a reader materializes, so a consumer + budgeting memory from it must add its own headroom. + + Absent on an SSTable written before this field existed, so a reader must + not read a missing value as zero. The same applies to the two below. */ + optional uint64 in_memory_bytes = 3; + + /* Rows held, counting the older duplicates of a primary key that the + generation's deletion vector masks. A scan that applies the deletion vector + yields fewer. */ + optional uint64 physical_rows = 4; + + /* Total payload size of the primary-key columns, summed over every row in + `physical_rows`. Not a per-row size: a variable-length key differs from row + to row, and a consumer wanting a mean divides by the row count. + + Accounted the same way as `in_memory_bytes`, so the same caveat applies -- + it is not the size of any encoded form of the key, such as a comparable + byte-ordered encoding a reader might build for sorting or deduplication. + + Absent also on a table with no primary key. */ + optional uint64 primary_key_bytes = 5; } -// A region's merged generation, used in MemWalIndexDetails. -message MergedGeneration { - // Region identifier (UUID v4). - UUID region_id = 1; +// A pointer to the latest SSTable compacted for a shard. +message CompactedSsTable { + // Shard identifier (UUID v4). + UUID shard_id = 1; - // Last generation merged to base table for this region. + // Generation of the latest SSTable compacted into the base table for this shard. uint64 generation = 2; } -// Tracks which merged generation a base table index has been rebuilt to cover. -// Used to determine whether to read from flushed MemTable indexes or base table. +/* Tracks which compacted SSTable generation a base table index has been rebuilt to cover. + * Used to determine whether to read from SSTable indexes or base table. + */ 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). - repeated MergedGeneration caught_up_generations = 2; + /* Per-shard progress: the generation up to which this index covers. + * + * An absent shard means *unknown*: this index has recorded no catch-up for + * that shard, so its SSTables must be retained and a repair scheduled. + */ + repeated CompactedSsTable 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 -// -// 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. -// -// 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 -// version: uint64 -// region_spec_id: uint32 -// writer_epoch: uint64 -// replay_after_wal_entry_position: uint64 -// wal_entry_position_last_seen: uint64 -// current_generation: uint64 -// merged_generation: uint64 -// flushed_generations: list> +/* Index details for MemWAL Index, stored in IndexMetadata.index_details. + * This is the centralized structure for all MemWAL metadata: + * - Configuration (sharding specs, indexes to maintain) + * - SSTable compaction progress + * - Shard state snapshots + * + * Writers read this index to get configuration before writing. + * Readers may use shard snapshots in this index as a point-in-time + * optimization. Readers that need the latest shard set should list shard + * directories in storage and read each shard's latest manifest. + * A background process updates the index periodically to keep shard snapshots current. + * + * Shard snapshots are stored as a Lance file with one row per shard. + * The schema records shard discovery fields. Full mutable shard state remains + * authoritative in the shard manifest files. + * shard_id: utf8 + * shard_spec_id: uint32 + * shard_field_{field_id}: typed per the matching ShardingField.result_type + */ message MemWalIndexDetails { // Snapshot timestamp (Unix timestamp in milliseconds). int64 snapshot_ts_millis = 1; - // Number of regions in the snapshot. - // Used to determine storage format without reading the snapshot data. - uint32 num_regions = 2; + /* Number of shards in the snapshot. + * Used to determine storage format without reading the snapshot data. + */ + uint32 num_shards = 2; - // Inline region snapshots for small region counts. - // When num_regions <= threshold (implementation-defined, e.g., 100), - // snapshots are stored inline as serialized bytes. - // Format: Lance file bytes with the region snapshot schema. + /* 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 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; - - // Indexes from the base table to maintain in MemTables. - // These are index names referencing indexes defined on the base table. - // The primary key btree index is always maintained implicitly and - // should not be listed here. - // - // For vector indexes, MemTables inherit quantization parameters (PQ codebook, - // SQ params) from the base table index to ensure distance comparability. + /* Sharding specs defining how to derive shard identifiers. + * This configuration determines how rows are partitioned into shards. + */ + repeated ShardingSpec sharding_specs = 7; + + /* Indexes from the base table to maintain in MemTables. + * These are index names referencing indexes defined on the base table. + * The primary key btree index is always maintained implicitly and + * should not be listed here. + * + * For vector indexes, MemTables inherit quantization parameters (PQ codebook, + * 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. - // 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: - // 1. merged_generations is updated by mergers (atomic with data commit) - // 2. region snapshots are updated by background index builder - repeated MergedGeneration merged_generations = 9; - - // Per-index catchup progress tracking. - // When data is merged to the base table, base table indexes are rebuilt - // asynchronously. This field tracks which generation each index covers. - // - // For indexed queries, if an index's caught_up_generation < merged_generation, - // readers should use flushed MemTable indexes for the gap instead of - // scanning unindexed data in the base table. - // - // If an index is not present in this list, it is assumed to be fully caught up. + /* Maintain every index the MemTable can mirror, rather than the named set. + * + * Set, `maintained_indexes` is ignored and the set is resolved from the + * table's indexes each time a MemTable is built, so an index created later is + * picked up and a dropped one falls out. An index kind the writer cannot + * mirror is skipped, so introducing one does not make the table unwritable. + * + * Absent, `maintained_indexes` is the set verbatim and a name in it that the + * writer cannot mirror is an error: the caller asked for that index. + */ + optional bool maintain_all_indexes = 12; + + /* Latest SSTable compacted into the base table for each shard. + * This is updated atomically with merge-insert data commits, enabling + * conflict resolution when multiple compactors operate concurrently. + * + * Note: This is separate from shard snapshots because: + * 1. compacted_sstables is updated by compactors (atomic with data commit) + * 2. shard snapshots are updated by background index builder + */ + repeated CompactedSsTable compacted_sstables = 9; + + /* Per-index catchup progress tracking. + * When data is compacted into the base table, base table indexes are rebuilt + * asynchronously. This field tracks which generation each index covers. + * + * For indexed queries, if an index's caught_up_generation < compacted_generation, + * readers should use SSTable indexes for the gap instead of + * scanning unindexed data in the base table. + * + * An index absent from this list has recorded no catch-up, so the SSTables it + * would need stay live until a repair records it. Only the dedicated WAL + * index-repair path may add entries here; + * ordinary index operations have their entry removed automatically when they + * change an index, since they do not report what the new index covers. + */ repeated IndexCatchupProgress index_catchup = 10; + + /* Default ShardWriter configuration values for this MemWAL index. + * + * A free-form string map persisted so that every writer — across + * processes and restarts — starts from the same default writer + * configuration. These are defaults only: an individual writer may + * still override any value at runtime in its own ShardWriterConfig + * (which is not persisted). + */ + map writer_config_defaults = 11; } -// Region spec definition. -message RegionSpec { - // Unique identifier for this spec within the index. - // IDs are never reused. +// Sharding spec definition. +message ShardingSpec { + /* 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; + // Sharding field definitions that determine how to compute shard identifiers. + repeated ShardingField fields = 2; } -// Region field definition. -message RegionField { - // Unique string identifier for this region field. +// Sharding field definition. +message ShardingField { + // 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"). - // Mutually exclusive with expression. + /* Well-known shard transform name (e.g., "identity", "year", "bucket"). + * Mutually exclusive with expression. + */ optional string transform = 3; - // DataFusion SQL expression for custom logic. - // Mutually exclusive with transform. + /* DataFusion SQL expression for custom logic. + * 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). map parameters = 6; } - diff --git a/protos/table_identifier.proto b/protos/table_identifier.proto new file mode 100644 index 00000000000..d4271b12699 --- /dev/null +++ b/protos/table_identifier.proto @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +syntax = "proto3"; + +package lance.datafusion; + +/* Identifies a Lance dataset for remote reconstruction. + * + * Two modes: + * 1. uri + serialized_manifest (fast): remote executor skips manifest read. + * 2. uri + version + etag (lightweight): remote executor loads manifest from storage. + */ +message TableIdentifier { + string uri = 1; + uint64 version = 2; + optional string manifest_etag = 3; + optional bytes serialized_manifest = 4; + map storage_options = 5; +} diff --git a/protos/transaction.proto b/protos/transaction.proto index 17d96486736..89feab30ead 100644 --- a/protos/transaction.proto +++ b/protos/transaction.proto @@ -9,18 +9,20 @@ import "google/protobuf/any.proto"; package lance.table; -// A transaction represents the changes to a dataset. -// -// This has two purposes: -// 1. When retrying a commit, the transaction can be used to re-build an updated -// manifest. -// 2. When there's a conflict, this can be used to determine whether the other -// transaction is compatible with this one. +/* A transaction represents the changes to a dataset. + * + * This has two purposes: + * 1. When retrying a commit, the transaction can be used to re-build an updated + * manifest. + * 2. When there's a conflict, this can be used to determine whether the other + * transaction is compatible with this one. + */ message Transaction { - // The version of the dataset this transaction was built from. - // - // For example, for a delete transaction this means the version of the dataset - // that was read from while evaluating the deletion predicate. + /* The version of the dataset this transaction was built from. + * + * For example, for a delete transaction this means the version of the dataset + * that was read from while evaluating the deletion predicate. + */ uint64 read_version = 1; // The UUID that unique identifies a transaction. @@ -29,38 +31,43 @@ message Transaction { // Optional version tag. string tag = 3; - // Optional properties for the transaction - // __lance_commit_message is a reserved key + /* Optional properties for the transaction + * __lance_commit_message is a reserved key + */ map transaction_properties = 4; // Add new rows to the dataset. message Append { - // The new fragments to append. - // - // Fragment IDs are not yet assigned. + /* The new fragments to append. + * + * Fragment IDs are not yet assigned. + */ repeated DataFragment fragments = 1; } // Mark rows as deleted. message Delete { - // The fragments to update - // - // The fragment IDs will match existing fragments in the dataset. + /* The fragments to update + * + * The fragment IDs will match existing fragments in the dataset. + */ repeated DataFragment updated_fragments = 1; // The fragments to delete entirely. repeated uint64 deleted_fragment_ids = 2; - // The predicate that was evaluated - // - // This may be used to determine whether the delete would have affected - // files written by a concurrent transaction. + /* The predicate that was evaluated + * + * This may be used to determine whether the delete would have affected + * files written by a concurrent transaction. + */ string predicate = 3; } // Create or overwrite the entire dataset. message Overwrite { - // The new fragments - // - // Fragment IDs are not yet assigned. + /* The new fragments + * + * Fragment IDs are not yet assigned. + */ repeated DataFragment fragments = 1; // The new schema repeated lance.file.Field schema = 2; @@ -72,35 +79,40 @@ message Transaction { repeated BasePath initial_bases = 5; } - // Add or replace a new secondary index. - // - // This is also used to remove an index (we are replacing it with nothing) - // - // - new_indices: the modified indices, empty if dropping indices only - // - removed_indices: the indices that are being replaced + /* Add or replace a new secondary index. + * + * This is also used to remove an index (we are replacing it with nothing) + * + * - new_indices: the modified indices, empty if dropping indices only + * - removed_indices: the indices that are being replaced + */ message CreateIndex { repeated IndexMetadata new_indices = 1; repeated IndexMetadata removed_indices = 2; } - // An operation that rewrites but does not change the data in the table. These - // kinds of operations just rearrange data. + /* An operation that rewrites but does not change the data in the table. These + * kinds of operations just rearrange data. + */ message Rewrite { - // The old fragments that are being replaced - // - // DEPRECATED: use groups instead. - // - // These should all have existing fragment IDs. + /* The old fragments that are being replaced + * + * DEPRECATED: use groups instead. + * + * These should all have existing fragment IDs. + */ repeated DataFragment old_fragments = 1; - // The new fragments - // - // DEPRECATED: use groups instead. - // - // These fragments IDs are not yet assigned. + /* The new fragments + * + * DEPRECATED: use groups instead. + * + * These fragments IDs are not yet assigned. + */ repeated DataFragment new_fragments = 2; - // During a rewrite an index may be rewritten. We only serialize the UUID - // since a rewrite should not change the other index parameters. + /* During a rewrite an index may be rewritten. We only serialize the UUID + * since a rewrite should not change the other index parameters. + */ message RewrittenIndex { // The id of the index that will be replaced UUID old_id = 1; @@ -110,18 +122,24 @@ 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. message RewriteGroup { - // The old fragment that is being replaced - // - // This should have an existing fragment ID. + /* The old fragment that is being replaced + * + * This should have an existing fragment ID. + */ repeated DataFragment old_fragments = 1; - // The new fragment - // - // The ID should have been reserved by an earlier - // reserve operation + /* The new fragment + * + * The ID should have been reserved by an earlier + * reserve operation + */ repeated DataFragment new_fragments = 2; } @@ -133,20 +151,35 @@ message Transaction { // An operation that merges in a new column, altering the schema. message Merge { - // The updated fragments - // - // These should all have existing fragment IDs. + /* The updated fragments + * + * These should all have existing fragment IDs. + */ repeated DataFragment fragments = 1; // The new schema repeated lance.file.Field schema = 2; // Schema metadata. map schema_metadata = 3; + /* Set when this merge makes no nullability-affecting schema change: it + * introduces no field that data staged against an earlier schema could + * not safely omit. Without the assertion (including transactions written + * before this field existed) the merge conservatively conflicts with + * concurrent value-writes, which can only cause a retry. + */ + bool preserves_nullability = 4; } // An operation that projects a subset of columns, altering the schema. message Project { // The new schema repeated lance.file.Field schema = 1; + /* Set when this projection makes no nullability-affecting schema change, + * as a rename or a drop does not. Without the assertion (including + * transactions written before this field existed) the projection + * conservatively conflicts with concurrent value-writes, which can only + * cause a retry. A nullability tightening must not set this. + */ + bool preserves_nullability = 2; } // An operation that restores a dataset to a previous version. @@ -155,26 +188,29 @@ message Transaction { uint64 version = 1; } - // An operation that reserves fragment ids for future use in - // a rewrite operation. + /* An operation that reserves fragment ids for future use in + * a rewrite operation. + */ message ReserveFragments { uint32 num_fragments = 1; } // An operation that clones a dataset. message Clone { - // - true: Performs a metadata-only clone (copies manifest without data files). - // The cloned dataset references original data through `base_paths`, - // suitable for experimental scenarios or rapid metadata migration. - // - false: Performs a full deep clone using the underlying object storage's native - // copy API (e.g., S3 CopyObject, GCS rewrite). This leverages server-side - // bulk copy operations to bypass download/upload bottlenecks, achieving - // near-linear speedup for large datasets (typically 3-10x faster than - // manual file transfers). The operation maintains atomicity and data - // integrity guarantees provided by the storage backend. + /* - true: Performs a metadata-only clone (copies manifest without data files). + * The cloned dataset references original data through `base_paths`, + * suitable for experimental scenarios or rapid metadata migration. + * - false: Performs a full deep clone using the underlying object storage's native + * copy API (e.g., S3 CopyObject, GCS rewrite). This leverages server-side + * bulk copy operations to bypass download/upload bottlenecks, achieving + * near-linear speedup for large datasets (typically 3-10x faster than + * manual file transfers). The operation maintains atomicity and data + * integrity guarantees provided by the storage backend. + */ bool is_shallow = 1; - // the reference name in the source dataset - // in most cases it should be the branch or tag name in the source dataset + /* the reference name in the source dataset + * in most cases it should be the branch or tag name in the source dataset + */ optional string ref_name = 2; // the version of the source dataset for cloning uint64 ref_version = 3; @@ -184,34 +220,39 @@ message Transaction { optional string branch_name = 5; } - // Exact set of key hashes for conflict detection. - // Used when the number of inserted rows is small. + /* Exact set of key hashes for conflict detection. + * Used when the number of inserted rows is small. + */ message ExactKeySetFilter { // 64-bit hashes of the inserted row keys. repeated uint64 key_hashes = 1; } - // Bloom filter for key existence tests. - // Used when the number of rows is large. + /* Bloom filter for key existence tests. + * Used when the number of rows is large. + */ message BloomFilter { // Bitset backing the bloom filter (SBBF format). bytes bitmap = 1; // Number of bits in the bitmap. uint32 num_bits = 2; - // Number of items the filter was sized for. - // Used for intersection validation (filters with different sizes cannot be compared). - // Default: 8192 + /* Number of items the filter was sized for. + * Used for intersection validation (filters with different sizes cannot be compared). + * Default: 8192 + */ uint64 number_of_items = 3; - // False positive probability the filter was sized for. - // Used for intersection validation (filters with different parameters cannot be compared). - // Default: 0.00057 + /* False positive probability the filter was sized for. + * Used for intersection validation (filters with different parameters cannot be compared). + * Default: 0.00057 + */ double probability = 4; } - // A filter for checking key existence in set of rows inserted by a merge insert operation. - // Only created when the merge insert's ON columns match the schema's unenforced primary key. - // The presence of this filter indicates strict primary key conflict detection should be used. - // Can use either an exact set (for small row counts) or a Bloom filter (for large row counts). + /* A filter for checking key existence in set of rows inserted by a merge insert operation. + * Only created when the merge insert's ON columns match the schema's unenforced primary key. + * The presence of this filter indicates strict primary key conflict detection should be used. + * Can use either an exact set (for small row counts) or a Bloom filter (for large row counts). + */ message KeyExistenceFilter { // Field IDs of columns participating in the key (must match unenforced primary key). repeated int32 field_ids = 1; @@ -224,10 +265,16 @@ message Transaction { } } + // Serialized as sorted distinct local physical row offsets within the fragment (0-based). + message UInt32List { + repeated uint32 values = 1; + } + // An operation that updates rows but does not add or remove rows. message Update { - // The fragments that have been removed. These are fragments where all rows - // have been updated and moved to a new fragment. + /* The fragments that have been removed. These are fragments where all rows + * have been updated and moved to a new fragment. + */ repeated uint64 removed_fragment_ids = 1; // The fragments that have been updated. repeated DataFragment updated_fragments = 2; @@ -235,29 +282,42 @@ 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 - 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. + /// SSTables to mark as compacted after this transaction. + repeated CompactedSsTable compacted_sstables = 5; + /* The fields that used to judge whether to preserve the new frag's id into + * the frag bitmap of the specified indices. + */ repeated uint32 fields_for_preserving_frag_bitmap = 6; // The mode of update UpdateMode update_mode = 7; - // Filter for checking existence of keys in newly inserted rows, used for conflict detection. - // Only tracks keys from INSERT operations during merge insert, not updates. + /* Filter for checking existence of keys in newly inserted rows, used for conflict detection. + * Only tracks keys from INSERT operations during merge insert, not updates. + */ optional KeyExistenceFilter inserted_rows = 8; + /* Per-fragment physical row offsets that matched an update_columns hash join (RewriteColumns). + * Deprecated: use updated_fragment_offset_bitmaps (field 10) instead. + */ + map updated_fragment_offsets = 9; + /* Per-fragment matched offsets as portable RoaringBitmap bytes (replaces field 9). + * Writers emit field 10 only. Readers prefer field 10; fall back to field 9 for + * manifests written before this change. + */ + map updated_fragment_offset_bitmaps = 10; } // The mode of update operation enum UpdateMode { - /// rows are deleted in current fragments and rewritten in new fragments. - /// This is most optimal when the majority of columns are being rewritten - /// or only a few rows are being updated. + /* rows are deleted in current fragments and rewritten in new fragments. + * This is most optimal when the majority of columns are being rewritten + * or only a few rows are being updated. + */ REWRITE_ROWS = 0; - /// within each fragment, columns are fully rewritten and inserted as new data files. - /// Old versions of columns are tombstoned. This is most optimal when most rows are affected - /// but a small subset of columns are affected. + /* within each fragment, columns are fully rewritten and inserted as new data files. + * Old versions of columns are tombstoned. This is most optimal when most rows are affected + * but a small subset of columns are affected. + */ REWRITE_COLUMNS = 1; } @@ -271,13 +331,15 @@ message Transaction { message UpdateMap { repeated UpdateMapEntry update_entries = 1; - // If true, the map will be replaced entirely with the new entries. - // If false, the new entries will be merged with the existing map. + /* If true, the map will be replaced entirely with the new entries. + * If false, the new entries will be merged with the existing map. + */ bool replace = 2; } - // An operation that updates the table config, table metadata, schema metadata, - // or field metadata. + /* An operation that updates the table config, table metadata, schema metadata, + * or field metadata. + */ message UpdateConfig { UpdateMap config_updates = 6; UpdateMap table_metadata_updates = 7; @@ -305,12 +367,38 @@ message Transaction { repeated DataReplacementGroup replacements = 1; } - // Update the merged generations in MemWAL index. - // This operation is used during merge-insert to atomically record which - // generations have been merged to the base table. + /* Overlay files to append to a single fragment, in order (the last entry is + * newest). The overlays are appended to the fragment's existing `overlays` + * list; they do not replace it, so overlays written by concurrent commits are + * preserved. + */ + message DataOverlayGroup { + uint64 fragment_id = 1; + /* Each DataOverlayFile.committed_version is left 0 by the writer and stamped + * to the new dataset version at commit time (re-stamped on retry), in the + * same way as the created-at / last-updated-at version sequences. The fields + * touched are read from each overlay's `data_file.fields`. + */ + repeated DataOverlayFile overlays = 2; + } + + /* Attach overlay files to fragments, supplying new values for a subset of + * (row offset, field) cells without rewriting the fragments' base data files. + * See the DataOverlayFile message in table.proto for resolution, coverage, and + * versioning rules, and the Data Overlay Files and Transactions specifications + * for the (intentionally permissive) conflict semantics. + */ + message DataOverlay { + repeated DataOverlayGroup groups = 1; + } + + /* Update SSTable compaction progress in the MemWAL index. + * This operation is used during merge-insert to atomically record which + * SSTables have been compacted into the base table. + */ message UpdateMemWalState { - // Regions and generations being marked as merged. - repeated MergedGeneration merged_generations = 1; + // SSTables being marked as compacted. + repeated CompactedSsTable compacted_sstables = 1; } // An operation that updates base paths in the dataset. @@ -319,7 +407,12 @@ message Transaction { repeated BasePath new_bases = 1; } - // The operation of this transaction. + /* The operation of this transaction. + * + * Field numbers 100-199 are reserved for operations. A reader that sees a + * number in this range it does not recognize is looking at an operation from + * a newer writer, and must treat it as unknown rather than fail. + */ oneof operation { Append append = 100; Delete delete = 101; @@ -336,6 +429,7 @@ message Transaction { UpdateMemWalState update_mem_wal_state = 112; Clone clone = 113; UpdateBases update_bases = 114; + DataOverlay data_overlay = 115; } // Fields 200/202 (`blob_append` / `blob_overwrite`) previously represented blob dataset ops. diff --git a/python/.cargo/config.toml b/python/.cargo/config.toml index 3c7937b1bbe..5e72e0592aa 100644 --- a/python/.cargo/config.toml +++ b/python/.cargo/config.toml @@ -17,12 +17,16 @@ rustflags = [ "-Wclippy::string_add_assign", "-Wclippy::string_add", "-Wclippy::string_lit_as_bytes", - "-Wclippy::string_to_string", "-Wclippy::use_self", "-Aclippy::redundant_pub_crate", # PyO3 macros don't pass this. ] [target.x86_64-unknown-linux-gnu] +# See the note in ../../.cargo/config.toml for details on this choice. +# +# Note that pylance users cannot easily change this value (they would need to build +# pylance from source). This is an intentional choice to provide strong performance +# by default for pylance wheels. rustflags = ["-C", "target-cpu=haswell", "-C", "target-feature=+avx2,+fma,+f16c"] [target.aarch64-apple-darwin] diff --git a/python/AGENTS.md b/python/AGENTS.md index 5d0f7d5dc54..40301a4ddc7 100644 --- a/python/AGENTS.md +++ b/python/AGENTS.md @@ -1,14 +1,40 @@ -Use the makefile for most actions: +# Python Guidelines -* Build: `maturin develop` -* Test: `make test` -* Run single test: `pytest python/tests/.py::` -* Doctest: `make doctest` -* Lint: `make lint` -* Format: `make format` +Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. +## Commands -If you want to run python tests after changes to the rust code, you need first build the rust code by: -``` -maturin develop -``` +* Environment: use `uv` for all local Python environment setup in this repository. +* First step in every new worktree or fresh checkout: run `make install` from `python/` before any Python command. This runs `uv sync` (dev and test dependencies are included by default via `[tool.uv] default-groups`) and sets up pre-commit hooks. Add `--group benchmarks`, `--extra torch`, or `--extra 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. +* Only run `uv sync` again when dependencies change (e.g., after pulling new commits that update `pyproject.toml` or `uv.lock`). +* 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: `make build` (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` + +## Beta Releases + +- Python RC and beta preview wheels are published on fury.io, not only PyPI. When a task needs a beta version such as `7.2.0b4`, use a disposable venv and install with `--pre` and the Lance fury indices: + ```shell + uv venv /path/to/venv + uv pip install --python /path/to/venv/bin/python --pre \ + --extra-index-url https://pypi.fury.io/lance-format/ \ + "pylance==7.2.0b4" + ``` + +## API Design + +- Keep bindings as thin wrappers — centralize validation and logic in Rust core. +- Extend existing methods with named arguments instead of adding new methods that accept policy/config objects — the Python API should feel Pythonic (e.g., `cleanup_old_versions(..., retain_versions=N)`), not mirror Rust builder patterns. +- Pass all fields to Python dataclass constructors via PyO3, converting Rust `None` to `py.None()` instead of omitting args — dataclass constructors require all positional params. +- Use parameterized type hints (e.g., `list[DatasetBasePath]`, `Optional[Dict[str, str]]`) — never bare generics. Keep docstring type descriptions in sync with hints. + +## Testing + +- Add tests to existing `test_{module}.py` files rather than creating new test files for the same module. diff --git a/python/CLAUDE.md b/python/CLAUDE.md deleted file mode 120000 index 47dc3e3d863..00000000000 --- a/python/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/python/CONTRIBUTING.md b/python/CONTRIBUTING.md index 924aea1c481..d183d203bfd 100644 --- a/python/CONTRIBUTING.md +++ b/python/CONTRIBUTING.md @@ -10,14 +10,14 @@ The python integration is done via pyo3 + custom python code: To build the Python bindings, first install requirements: ```bash -pip install maturin +cd python +make install ``` To make a dev install: ```bash -cd python -maturin develop +make build ``` After installing, you can run `import lance` in a Python shell within the virtual environment. diff --git a/python/Cargo.lock b/python/Cargo.lock index 090400fd4b3..02505cecf8f 100644 --- a/python/Cargo.lock +++ b/python/Cargo.lock @@ -2,75 +2,21 @@ # It is not intended for manual editing. version = 4 -[[package]] -name = "abi_stable" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69d6512d3eb05ffe5004c59c206de7f99c34951504056ce23fc953842f12c445" -dependencies = [ - "abi_stable_derive", - "abi_stable_shared", - "const_panic", - "core_extensions", - "crossbeam-channel", - "generational-arena", - "libloading", - "lock_api", - "parking_lot", - "paste", - "repr_offset", - "rustc_version", - "serde", - "serde_derive", - "serde_json", -] - -[[package]] -name = "abi_stable_derive" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7178468b407a4ee10e881bc7a328a65e739f0863615cca4429d43916b05e898" -dependencies = [ - "abi_stable_shared", - "as_derive_utils", - "core_extensions", - "proc-macro2", - "quote", - "rustc_version", - "syn 1.0.109", - "typed-arena", -] - -[[package]] -name = "abi_stable_shared" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2b5df7688c123e63f4d4d649cba63f2967ba7f7861b1664fca3f77d3dad2b63" -dependencies = [ - "core_extensions", -] - [[package]] name = "adler2" 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -79,7 +25,7 @@ version = "0.8.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "const-random", "getrandom 0.3.4", "once_cell", @@ -128,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", @@ -143,15 +89,15 @@ 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" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -178,9 +124,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.101" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f0e0fee31ef5ed1ba1316088939cea399010ed7731dba877ed44aeb407a75ea" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -191,20 +137,11 @@ dependencies = [ "num-traits", ] -[[package]] -name = "ar_archive_writer" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb93bbb63b9c227414f6eb3a0adfddca591a8ce1e9b60661bb08969b87e340b" -dependencies = [ - "object", -] - [[package]] name = "arc-swap" -version = "1.8.1" +version = "1.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ded5f9a03ac8f24d1b8a25101ee812cd32cdc8c50a4c50237de2c4915850e73" +checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b" dependencies = [ "rustversion", ] @@ -223,9 +160,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arrow" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a2b10dcb159faf30d3f81f6d56c1211a5bea2ca424eabe477648a44b993320e" +checksum = "6cfdd0833e32a9874d2b55089333ad310c0be208aafa277385ce2461dec90be3" dependencies = [ "arrow-arith", "arrow-array", @@ -245,9 +182,9 @@ dependencies = [ [[package]] name = "arrow-arith" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "288015089e7931843c80ed4032c5274f02b37bcb720c4a42096d50b390e70372" +checksum = "0a41203398f0eaa6f7ec8e62c0da742a21abf282c148fc157f6c35c90e29981a" dependencies = [ "arrow-array", "arrow-buffer", @@ -259,9 +196,9 @@ dependencies = [ [[package]] name = "arrow-array" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65ca404ea6191e06bf30956394173337fa9c35f445bd447fe6c21ab944e1a23c" +checksum = "ae33dad492b7df00a217563a7b0ef2874df68a0deea1b1a3acf628152f7f7a69" dependencies = [ "ahash", "arrow-buffer", @@ -270,7 +207,7 @@ dependencies = [ "chrono", "chrono-tz", "half", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "num-complex", "num-integer", "num-traits", @@ -278,9 +215,9 @@ dependencies = [ [[package]] name = "arrow-buffer" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36356383099be0151dacc4245309895f16ba7917d79bdb71a7148659c9206c56" +checksum = "b9552f96391c005e6ab449fa941420935e7e062489b12b8b1b08879b2163f5b5" dependencies = [ "bytes", "half", @@ -290,9 +227,9 @@ dependencies = [ [[package]] name = "arrow-cast" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c8e372ed52bd4ee88cc1e6c3859aa7ecea204158ac640b10e187936e7e87074" +checksum = "3a8a327c9649f30d8406995f27642b68df354713cca3baaaf100f076f18d5f34" dependencies = [ "arrow-array", "arrow-buffer", @@ -301,7 +238,7 @@ dependencies = [ "arrow-schema", "arrow-select", "atoi", - "base64", + "base64 0.22.1", "chrono", "comfy-table", "half", @@ -312,9 +249,9 @@ dependencies = [ [[package]] name = "arrow-csv" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e4100b729fe656f2e4fb32bc5884f14acf9118d4ad532b7b33c1132e4dce896" +checksum = "af0dd6d90d1955e9f9a014c1e563ee8aeffc21909085d25623e1da44d96eca26" dependencies = [ "arrow-array", "arrow-cast", @@ -327,9 +264,9 @@ dependencies = [ [[package]] name = "arrow-data" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf87f4ff5fc13290aa47e499a8b669a82c5977c6a1fedce22c7f542c1fd5a597" +checksum = "2b24852db04738907e06c04ea61e42fe7fda962a34513022dc0d0e754fb7976b" dependencies = [ "arrow-buffer", "arrow-schema", @@ -340,9 +277,9 @@ dependencies = [ [[package]] name = "arrow-ipc" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb3ca63edd2073fcb42ba112f8ae165df1de935627ead6e203d07c99445f2081" +checksum = "29a908a11fcfb3fb2f6730f4ac15e367bc644e419155e96238f68cf3adde572b" dependencies = [ "arrow-array", "arrow-buffer", @@ -350,24 +287,25 @@ dependencies = [ "arrow-schema", "arrow-select", "flatbuffers", - "lz4_flex 0.12.0", + "lz4_flex", "zstd", ] [[package]] name = "arrow-json" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a36b2332559d3310ebe3e173f75b29989b4412df4029a26a30cc3f7da0869297" +checksum = "b8a96aed3931c076adee39ec2a40d8219fc7f09e79bcdaca1df16272993e1e14" dependencies = [ "arrow-array", "arrow-buffer", "arrow-cast", - "arrow-data", + "arrow-ord", "arrow-schema", + "arrow-select", "chrono", "half", - "indexmap", + "indexmap 2.14.0", "itoa", "lexical-core", "memchr", @@ -380,9 +318,9 @@ dependencies = [ [[package]] name = "arrow-ord" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c4e0530272ca755d6814218dffd04425c5b7854b87fa741d5ff848bf50aa39" +checksum = "63a083ec750f5c043f02946b4baf05fcdbb55f4560a3277055caca5cc99f3eb0" dependencies = [ "arrow-array", "arrow-buffer", @@ -393,9 +331,9 @@ dependencies = [ [[package]] name = "arrow-pyarrow" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f45c7989cb70214b2f362eaa10266d15e1a433692f2ea1514018be3aace679f4" +checksum = "3ffb9be5a873590f825aef50df20e0f8dff5fd42a77058e28bbe7bd44bb53dec" dependencies = [ "arrow-array", "arrow-data", @@ -405,9 +343,9 @@ dependencies = [ [[package]] name = "arrow-row" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b07f52788744cc71c4628567ad834cadbaeb9f09026ff1d7a4120f69edf7abd3" +checksum = "514ba0ef0d4c5896202dae736251ce415abb43a950bed570fb7981b8716c0e4c" dependencies = [ "arrow-array", "arrow-buffer", @@ -418,20 +356,20 @@ dependencies = [ [[package]] name = "arrow-schema" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6bb63203e8e0e54b288d0d8043ca8fa1013820822a27692ef1b78a977d879f2c" +checksum = "21ca356ad6425cecb6eb7b28e4f659f1ee7880fbb1a16127de7dd62901efee9e" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "serde_core", "serde_json", ] [[package]] name = "arrow-select" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c96d8a1c180b44ecf2e66c9a2f2bbcb8b1b6f14e165ce46ac8bde211a363411b" +checksum = "c58da39eb3d8350ad4a549e5c2bc49284dac554016c69829310350f1731b0aad" dependencies = [ "ahash", "arrow-array", @@ -443,9 +381,9 @@ dependencies = [ [[package]] name = "arrow-string" -version = "57.2.0" +version = "58.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8ad6a81add9d3ea30bf8374ee8329992c7fd246ffd8b7e2f48a3cea5aa0cc9a" +checksum = "b6789b388467525e3271326b6b4915666ecfdf5142aef09779445c954b67543c" dependencies = [ "arrow-array", "arrow-buffer", @@ -458,18 +396,6 @@ dependencies = [ "regex-syntax", ] -[[package]] -name = "as_derive_utils" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff3c96645900a44cf11941c111bd08a6573b0e2f9f69bc9264b179d8fae753c4" -dependencies = [ - "core_extensions", - "proc-macro2", - "quote", - "syn 1.0.109", -] - [[package]] name = "async-channel" version = "2.5.0" @@ -484,19 +410,14 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.19" +version = "0.4.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06575e6a9673580f52661c92107baabffbf41e2141373441cbcdc47cb733003c" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" dependencies = [ - "bzip2 0.5.2", - "flate2", - "futures-core", - "memchr", + "compression-codecs", + "compression-core", "pin-project-lite", "tokio", - "xz2", - "zstd", - "zstd-safe", ] [[package]] @@ -504,9 +425,6 @@ name = "async-ffi" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f4de21c0feef7e5a556e51af767c953f0501f7f300ba785cc99c47bdc8081a50" -dependencies = [ - "abi_stable", -] [[package]] name = "async-lock" @@ -527,18 +445,18 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.91" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.2", ] [[package]] @@ -550,6 +468,12 @@ dependencies = [ "loom", ] +[[package]] +name = "asyncband" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2d85fd3d291fabcc40c7232c92c280ec1754fd7b5d7ea769f143222143e179a" + [[package]] name = "atoi" version = "2.0.0" @@ -567,15 +491,15 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-config" -version = "1.8.13" +version = "1.8.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c456581cb3c77fafcc8c67204a70680d40b61112d6da78c77bd31d945b65f1b5" +checksum = "8a8fc176d53d6fe85017f230405e3255cedb4a02221cb55ed6d76dccbbb099b2" dependencies = [ "aws-credential-types", "aws-runtime", @@ -592,7 +516,7 @@ dependencies = [ "bytes", "fastrand", "hex", - "http 1.4.0", + "http 1.4.2", "ring", "time", "tokio", @@ -603,9 +527,9 @@ dependencies = [ [[package]] name = "aws-credential-types" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd362783681b15d136480ad555a099e82ecd8e2d10a841e14dfd0078d67fee3" +checksum = "6d203b0bf2626dcba8665f5cd0871d7c2c0930223d6b6be9097592fea21242d0" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", @@ -615,9 +539,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.15.4" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b7b6141e96a8c160799cc2d5adecd5cbbe5054cb8c7c4af53da0f83bb7ad256" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -625,21 +549,22 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.37.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c34dda4df7017c8db52132f0f8a2e0f8161649d15723ed63fc00c82d0f2081a" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] name = "aws-runtime" -version = "1.6.0" +version = "1.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c635c2dc792cb4a11ce1a4f392a925340d1bdf499289b5ec1ec6810954eb43f5" +checksum = "ede2ddc593e6c8acc6ce3358c28d6677a6dc49b65ba4b37a2befe14a11297e75" dependencies = [ "aws-credential-types", "aws-sigv4", @@ -650,8 +575,9 @@ dependencies = [ "aws-smithy-types", "aws-types", "bytes", + "bytes-utils", "fastrand", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "percent-encoding", "pin-project-lite", @@ -661,9 +587,9 @@ dependencies = [ [[package]] name = "aws-sdk-dynamodb" -version = "1.104.0" +version = "1.107.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f04c47115cc8d46dcc94a9a81e7a3384cea859283c1a737729691d4221f11584" +checksum = "561bf86e858a2759c6876b517b13f3f4051a6484abbb0d8a1f4dfc5d902cc85a" dependencies = [ "aws-credential-types", "aws-runtime", @@ -678,16 +604,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sso" -version = "1.93.0" +version = "1.95.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dcb38bb33fc0a11f1ffc3e3e85669e0a11a37690b86f77e75306d8f369146a0" +checksum = "00c5ff27c6ba2cbd95e6e26e2e736676fdf6bcf96495b187733f521cfe4ce448" dependencies = [ "aws-credential-types", "aws-runtime", @@ -702,16 +628,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-ssooidc" -version = "1.95.0" +version = "1.97.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ada8ffbea7bd1be1f53df1dadb0f8fdb04badb13185b3321b929d1ee3caad09" +checksum = "4d186f1e5a3694a188e5a0640b3115ccc6e084d104e16fd6ba968dca072ffef8" dependencies = [ "aws-credential-types", "aws-runtime", @@ -726,16 +652,16 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sdk-sts" -version = "1.97.0" +version = "1.99.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6443ccadc777095d5ed13e21f5c364878c9f5bad4e35187a6cdbd863b0afcad" +checksum = "9acba7c62f3d4e2408fa998a3a8caacd8b9a5b5549cf36e2372fbdae329d5449" dependencies = [ "aws-credential-types", "aws-runtime", @@ -751,16 +677,16 @@ dependencies = [ "aws-types", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "regex-lite", "tracing", ] [[package]] name = "aws-sigv4" -version = "1.3.8" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efa49f3c607b92daae0c078d48a4571f599f966dce3caee5f1ea55c4d9073f99" +checksum = "37411f8e0f4bea0c3ca0958ce7f18f6439db24d555dbd809787262cd00926aa9" dependencies = [ "aws-credential-types", "aws-smithy-http", @@ -769,20 +695,20 @@ dependencies = [ "bytes", "form_urlencoded", "hex", - "hmac", + "hmac 0.12.1", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "percent-encoding", - "sha2", + "sha2 0.10.9", "time", "tracing", ] [[package]] name = "aws-smithy-async" -version = "1.2.11" +version = "1.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52eec3db979d18cb807fc1070961cc51d87d069abe9ab57917769687368a8c6c" +checksum = "5cc50d0f63e714784b84223abd7abbc8577de8c35d699e0edd19f0a88a08ae13" dependencies = [ "futures-util", "pin-project-lite", @@ -791,9 +717,9 @@ dependencies = [ [[package]] name = "aws-smithy-http" -version = "0.63.3" +version = "0.63.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "630e67f2a31094ffa51b210ae030855cb8f3b7ee1329bdd8d085aaf61e8b97fc" +checksum = "d619373d490ad70966994801bc126846afaa0d1ee920697a031f0cf63f2568e7" dependencies = [ "aws-smithy-runtime-api", "aws-smithy-types", @@ -801,7 +727,7 @@ dependencies = [ "bytes-utils", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "percent-encoding", @@ -812,57 +738,51 @@ dependencies = [ [[package]] name = "aws-smithy-http-client" -version = "1.1.9" +version = "1.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12fb0abf49ff0cab20fd31ac1215ed7ce0ea92286ba09e2854b42ba5cabe7525" +checksum = "00ccbb08c10f6bcf912f398188e42ee2eab5f1767ce215a02a73bc5df1bbdd95" dependencies = [ "aws-smithy-async", "aws-smithy-runtime-api", "aws-smithy-types", - "h2 0.3.27", - "h2 0.4.13", - "http 0.2.12", - "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", + "h2", + "http 1.4.2", + "hyper", + "hyper-rustls", "hyper-util", "pin-project-lite", - "rustls 0.21.12", - "rustls 0.23.36", + "rustls", "rustls-native-certs", "rustls-pki-types", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tower", "tracing", ] [[package]] name = "aws-smithy-json" -version = "0.62.3" +version = "0.62.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cb96aa208d62ee94104645f7b2ecaf77bf27edf161590b6224bfbac2832f979" +checksum = "27b3a779093e18cad88bbae08dc4261e1d95018c4c5b9356a52bcae7c0b6e9bb" dependencies = [ "aws-smithy-types", ] [[package]] name = "aws-smithy-observability" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0a46543fbc94621080b3cf553eb4cbbdc41dd9780a30c4756400f0139440a1d" +checksum = "4d3f39d5bb871aaf461d59144557f16d5927a5248a983a40654d9cf3b9ba183b" dependencies = [ "aws-smithy-runtime-api", ] [[package]] name = "aws-smithy-query" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cebbddb6f3a5bd81553643e9c7daf3cc3dc5b0b5f398ac668630e8a84e6fff0" +checksum = "05f76a580e3d8f8961e5d48763214025a2af65c2fa4cd1fb7f270a0e107a71b0" dependencies = [ "aws-smithy-types", "urlencoding", @@ -870,9 +790,9 @@ dependencies = [ [[package]] name = "aws-smithy-runtime" -version = "1.10.0" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3df87c14f0127a0d77eb261c3bc45d5b4833e2a1f63583ebfb728e4852134ee" +checksum = "22ccf7f6eba8b2dcf8ce9b74806c6c185659c311665c4bf8d6e71ebd454db6bf" dependencies = [ "aws-smithy-async", "aws-smithy-http", @@ -883,7 +803,7 @@ dependencies = [ "bytes", "fastrand", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -895,15 +815,15 @@ dependencies = [ [[package]] name = "aws-smithy-runtime-api" -version = "1.11.3" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49952c52f7eebb72ce2a754d3866cc0f87b97d2a46146b79f80f3a93fb2b3716" +checksum = "b4af6e5def28be846479bbeac55aa4603d6f7986fc5da4601ba324dd5d377516" dependencies = [ "aws-smithy-async", "aws-smithy-types", "bytes", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "pin-project-lite", "tokio", "tracing", @@ -912,16 +832,16 @@ dependencies = [ [[package]] name = "aws-smithy-types" -version = "1.4.3" +version = "1.4.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3a26048eeab0ddeba4b4f9d51654c79af8c3b32357dc5f336cee85ab331c33" +checksum = "8ca2734c16913a45343b37313605d84e7d8b34a4611598ce1d25b35860a2bed3" dependencies = [ "base64-simd", "bytes", "bytes-utils", "futures-core", "http 0.2.12", - "http 1.4.0", + "http 1.4.2", "http-body 0.4.6", "http-body 1.0.1", "http-body-util", @@ -938,18 +858,18 @@ dependencies = [ [[package]] name = "aws-smithy-xml" -version = "0.60.13" +version = "0.60.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11b2f670422ff42bf7065031e72b45bc52a3508bd089f743ea90731ca2b6ea57" +checksum = "b53543b4b86ed43f051644f704a98c7291b3618b67adf057ee77a366fa52fcaa" dependencies = [ "xmlparser", ] [[package]] name = "aws-types" -version = "1.3.11" +version = "1.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d980627d2dd7bfc32a3c025685a033eeab8d365cc840c631ef59d1b8f428164" +checksum = "0470cc047657c6e286346bdf10a8719d26efd6a91626992e0e64481e44323e96" dependencies = [ "aws-credential-types", "aws-smithy-async", @@ -969,10 +889,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "hyper 1.8.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -1002,7 +922,7 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "mime", @@ -1031,6 +951,12 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + [[package]] name = "base64-simd" version = "0.8.0" @@ -1060,26 +986,6 @@ dependencies = [ "num-traits", ] -[[package]] -name = "bincode" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740" -dependencies = [ - "bincode_derive", - "serde", - "unty", -] - -[[package]] -name = "bincode_derive" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09" -dependencies = [ - "virtue", -] - [[package]] name = "bitflags" version = "1.3.2" @@ -1088,24 +994,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3" - -[[package]] -name = "bitpacking" -version = "0.9.3" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96a7139abd3d9cebf8cd6f920a389cf3dc9576172e32f4563f188cae3c3eb019" -dependencies = [ - "crunchy", -] +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -1119,21 +1016,21 @@ version = "0.10.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] name = "blake3" -version = "1.8.3" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", "cc", - "cfg-if", + "cfg-if 1.0.4", "constant_time_eq", - "cpufeatures", + "cpufeatures 0.3.0", ] [[package]] @@ -1145,6 +1042,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "block-padding" version = "0.3.3" @@ -1155,111 +1061,121 @@ dependencies = [ ] [[package]] -name = "bon" -version = "3.8.2" +name = "brotli" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234655ec178edd82b891e262ea7cf71f6584bcd09eff94db786be23f1821825c" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ - "bon-macros", - "rustversion", + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", ] [[package]] -name = "bon-macros" -version = "3.8.2" +name = "brotli-decompressor" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89ec27229c38ed0eb3c0feee3d2c1d6a4379ae44f418a29a658890e062d8f365" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ - "darling 0.23.0", - "ident_case", - "prettyplease", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", + "alloc-no-stdlib", + "alloc-stdlib", ] [[package]] -name = "brotli" -version = "8.0.2" +name = "bs58" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "tinyvec", ] [[package]] -name = "brotli-decompressor" -version = "5.0.0" +name = "bstr" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "memchr", + "regex-automata", + "serde", ] [[package]] name = "bumpalo" -version = "3.19.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] -name = "bytemuck" -version = "1.25.0" +name = "bytecheck" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "0caa33a2c0edca0419d15ac723dff03f1956f7978329b1e3b5fdaaaed9d3ca8b" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "rancor", + "simdutf8", +] [[package]] -name = "byteorder" -version = "1.5.0" +name = "bytecheck_derive" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +checksum = "89385e82b5d1821d2219e0b095efa2cc1f246cbf99080f3be46a1a85c0d392d9" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] -name = "bytes" -version = "1.11.1" +name = "bytecount" +version = "0.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" [[package]] -name = "bytes-utils" -version = "0.1.4" +name = "bytemuck" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ - "bytes", - "either", + "bytemuck_derive", ] [[package]] -name = "bzip2" -version = "0.5.2" +name = "bytemuck_derive" +version = "1.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ - "bzip2-sys", + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] -name = "bzip2" -version = "0.6.1" +name = "byteorder" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] -name = "bzip2-sys" -version = "0.1.13+1.0.8" +name = "bytes-utils" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14" +checksum = "7dafe3a8757b027e2be6e4e5601ed563c55989fcf1546e933c66c8eb3a058d35" dependencies = [ - "cc", - "pkg-config", + "bytes", + "either", ] [[package]] @@ -1273,9 +1189,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.55" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b26a0954ae34af09b50f0de26458fa95369a0d478d8236d3f93082b219bd29" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" dependencies = [ "find-msvc-tools", "jobserver", @@ -1285,18 +1201,18 @@ dependencies = [ [[package]] name = "cedarwood" -version = "0.4.6" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d910bedd62c24733263d0bed247460853c9d22e8956bd4cd964302095e04e90" +checksum = "c0524a528a6a0288df1863c3c20fe92c301875b4941e7b6c4b394ab08c5a4c55" dependencies = [ "smallvec", ] [[package]] -name = "census" -version = "0.4.2" +name = "cfg-if" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" +checksum = "4785bdd1c96b2a846b2bd7cc02e86b6b3dbf14e7e53446c4f54c92a361040822" [[package]] name = "cfg-if" @@ -1310,11 +1226,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fac4744fb15ae8337dc853fee7fb3f4e48c0fbaa23d0afe49c447b4fab126118" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1340,24 +1267,49 @@ version = "0.4.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ - "crypto-common", + "crypto-common 0.1.7", "inout", ] [[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", ] +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" + [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "colored" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "combine" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] [[package]] name = "comfy-table" @@ -1369,6 +1321,23 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1384,6 +1353,12 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + [[package]] name = "const-random" version = "0.1.18" @@ -1404,6 +1379,12 @@ dependencies = [ "tiny-keccak", ] +[[package]] +name = "const-str" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18f12cc9948ed9604230cdddc7c86e270f9401ccbe3c2e98a4378c5e7632212f" + [[package]] name = "const_panic" version = "0.2.15" @@ -1419,6 +1400,16 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -1436,45 +1427,40 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] -name = "core2" -version = "0.4.0" +name = "countio" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b49ba7ef1ad6107f8824dbe97de947cbaac53c44e7f9756a1fba0d37c1eec505" +checksum = "b9702aee5d1d744c01d82f6915644f950f898e014903385464c773b96fefdecb" dependencies = [ - "memchr", + "futures-io", ] [[package]] -name = "core_extensions" -version = "1.5.4" +name = "cpufeatures" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42bb5e5d0269fd4f739ea6cedaf29c16d81c27a7ce7582008e90eb50dcd57003" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" dependencies = [ - "core_extensions_proc_macros", + "libc", ] -[[package]] -name = "core_extensions_proc_macros" -version = "1.5.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533d38ecd2709b7608fb8e18e4504deb99e9a72879e6aa66373a76d8dc4259ea" - [[package]] name = "cpufeatures" -version = "0.2.17" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" dependencies = [ "libc", ] [[package]] -name = "crc32c" -version = "0.6.8" +name = "crc-fast" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a47af21622d091a8f0fb295b88bc886ac74efcc613efc19f5d0b21de5c89e47" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" dependencies = [ - "rustc_version", + "digest 0.10.7", + "spin 0.10.1", ] [[package]] @@ -1483,7 +1469,7 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -1507,18 +1493,18 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] @@ -1555,6 +1541,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csv" version = "1.4.0" @@ -1576,6 +1571,31 @@ dependencies = [ "memchr", ] +[[package]] +name = "ctor" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01334b89b69ff726750c5ce5073fc8bd860e99aa9a8fc5ca11b04730e3aee97a" +dependencies = [ + "link-section", + "linktime-proc-macro", +] + +[[package]] +name = "ctutils" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" +dependencies = [ + "cmov", +] + +[[package]] +name = "daachorse" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db756b5eb7d81d31f31f660f4132f8cf5698de52fca144c143d0ae0cbb5f2e06" + [[package]] name = "darling" version = "0.20.11" @@ -1607,7 +1627,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -1620,7 +1640,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -1631,7 +1651,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" dependencies = [ "darling_core 0.20.11", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -1642,22 +1662,16 @@ checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core 0.23.0", "quote", - "syn 2.0.114", + "syn 2.0.118", ] -[[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" +version = "6.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "crossbeam-utils", "hashbrown 0.14.5", "lock_api", @@ -1667,15 +1681,13 @@ dependencies = [ [[package]] name = "datafusion" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ba7cb113e9c0bedf9e9765926031e132fa05a1b09ba6e93a6d1a4d7044457b8" +checksum = "754ef4e8f073922a26f5b23133b9db4829342362b09be0bc94309cf261c2f098" dependencies = [ "arrow", "arrow-schema", "async-trait", - "bytes", - "bzip2 0.6.1", "chrono", "datafusion-catalog", "datafusion-catalog-listing", @@ -1685,7 +1697,6 @@ dependencies = [ "datafusion-datasource-arrow", "datafusion-datasource-csv", "datafusion-datasource-json", - "datafusion-datasource-parquet", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", @@ -1702,30 +1713,24 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "datafusion-sql", - "flate2", "futures", + "indexmap 2.14.0", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", - "parquet", - "rand 0.9.2", - "regex", - "rstest", "sqlparser", "tempfile", "tokio", "url", "uuid", - "xz2", - "zstd", ] [[package]] name = "datafusion-catalog" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66a3a799f914a59b1ea343906a0486f17061f39509af74e874a866428951130d" +checksum = "06afd1e38dd27bbb1258685a1fc6524df6aff4e07b25b393a47de59635178d99" dependencies = [ "arrow", "async-trait", @@ -1741,16 +1746,16 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "tokio", ] [[package]] name = "datafusion-catalog-listing" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db1b113c80d7a0febcd901476a57aef378e717c54517a163ed51417d87621b0" +checksum = "f0668fb32c12065ec242be0e5b4bc62bd7a06a0be3ecd83791ef877e4be67e02" dependencies = [ "arrow", "async-trait", @@ -1766,39 +1771,39 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", - "tokio", + "object_store 0.13.2", ] [[package]] name = "datafusion-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c10f7659e96127d25e8366be7c8be4109595d6a2c3eac70421f380a7006a1b0" +checksum = "ca43b263cdff57042cfa8fb817fb3469f4878933380dccff25f5e793580abbf9" dependencies = [ - "ahash", "arrow", "arrow-ipc", + "arrow-schema", "chrono", + "foldhash 0.2.0", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "itertools 0.14.0", "libc", "log", - "object_store", + "object_store 0.13.2", "parquet", - "paste", - "recursive", "sqlparser", "tokio", + "uuid", "web-time", ] [[package]] name = "datafusion-common-runtime" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b92065bbc6532c6651e2f7dd30b55cba0c7a14f860c7e1d15f165c41a1868d95" +checksum = "05f0ba2b864792bdca4d76c59a1de0ab6e1b61946596b9936888dbd6360035f2" dependencies = [ "futures", "log", @@ -1807,15 +1812,13 @@ dependencies = [ [[package]] name = "datafusion-datasource" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fde13794244bc7581cd82f6fff217068ed79cdc344cafe4ab2c3a1c3510b38d6" +checksum = "b840a8bce0bcbf5afad02946d438591e7c373f7afccaf3d874c04485772514dd" dependencies = [ "arrow", - "async-compression", "async-trait", "bytes", - "bzip2 0.6.1", "chrono", "datafusion-common", "datafusion-common-runtime", @@ -1826,25 +1829,22 @@ dependencies = [ "datafusion-physical-expr-common", "datafusion-physical-plan", "datafusion-session", - "flate2", "futures", "glob", "itertools 0.14.0", "log", - "object_store", - "rand 0.9.2", + "object_store 0.13.2", + "parking_lot", + "rand 0.9.5", "tokio", - "tokio-util", "url", - "xz2", - "zstd", ] [[package]] name = "datafusion-datasource-arrow" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "804fa9b4ecf3157982021770617200ef7c1b2979d57bec9044748314775a9aea" +checksum = "a24cc0b9cf6e367f27f27406eff13abf48a11b72446aaa40b3105c0ded5c17d9" dependencies = [ "arrow", "arrow-ipc", @@ -1860,15 +1860,15 @@ dependencies = [ "datafusion-session", "futures", "itertools 0.14.0", - "object_store", + "object_store 0.13.2", "tokio", ] [[package]] name = "datafusion-datasource-csv" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61a1641a40b259bab38131c5e6f48fac0717bedb7dc93690e604142a849e0568" +checksum = "e1abe56b2a7a2d1d6de5117dd1a203181e267f28529faa5da546947621b697d7" dependencies = [ "arrow", "async-trait", @@ -1882,16 +1882,16 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "object_store", + "object_store 0.13.2", "regex", "tokio", ] [[package]] name = "datafusion-datasource-json" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeacdb00c1d37271176f8fb6a1d8ce096baba16ea7a4b2671840c5c9c64fe85" +checksum = "9c3e467f0611ad7bdd5aad17c63c9bb6182d04e5282e5496d897ea2b49c024ba" dependencies = [ "arrow", "async-trait", @@ -1905,15 +1905,16 @@ dependencies = [ "datafusion-physical-plan", "datafusion-session", "futures", - "object_store", + "object_store 0.13.2", "tokio", + "tokio-stream", ] [[package]] name = "datafusion-datasource-parquet" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d0b60ffd66f28bfb026565d62b0a6cbc416da09814766a3797bba7d85a3cd9" +checksum = "4cc35b92cd560082155e80d9c826929c852d3c51543f4affd3a51c464a0aab3a" dependencies = [ "arrow", "async-trait", @@ -1923,6 +1924,7 @@ dependencies = [ "datafusion-datasource", "datafusion-execution", "datafusion-expr", + "datafusion-functions", "datafusion-functions-aggregate-common", "datafusion-physical-expr", "datafusion-physical-expr-adapter", @@ -1933,7 +1935,7 @@ dependencies = [ "futures", "itertools 0.14.0", "log", - "object_store", + "object_store 0.13.2", "parking_lot", "parquet", "tokio", @@ -1941,37 +1943,40 @@ dependencies = [ [[package]] name = "datafusion-doc" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b99e13947667b36ad713549237362afb054b2d8f8cc447751e23ec61202db07" +checksum = "d69bb69d8769e34f76839c960dbde24c1ac0c885a79b6c3c2287bdc56ec67891" [[package]] name = "datafusion-execution" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63695643190679037bc946ad46a263b62016931547bf119859c511f7ff2f5178" +checksum = "d8eac0a09bc8d263f52025cad9e001da4d8138d633fa288edda4d06b1772eae6" dependencies = [ "arrow", + "arrow-buffer", "async-trait", "dashmap", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr-common", "futures", "log", - "object_store", + "object_store 0.13.2", "parking_lot", - "rand 0.9.2", + "rand 0.9.5", "tempfile", "url", ] [[package]] name = "datafusion-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9a4787cbf5feb1ab351f789063398f67654a6df75c4d37d7f637dc96f951a91" +checksum = "eeb14d374767ee0fc62dc79a5ba8bcf8a63c14e993c7d992d0e63adfa23d77d3" dependencies = [ "arrow", + "arrow-schema", "async-trait", "chrono", "datafusion-common", @@ -1980,87 +1985,95 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", - "recursive", "serde_json", "sqlparser", ] [[package]] name = "datafusion-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ce2fb1b8c15c9ac45b0863c30b268c69dc9ee7a1ee13ecf5d067738338173dc" +checksum = "b7b19a8c95522bee8cbb313d74263b85e355d2b52f42e67ef5694bf5de9e9356" dependencies = [ "arrow", "datafusion-common", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", - "paste", ] [[package]] name = "datafusion-ffi" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec510e7787641279b0336e8b79e4b7bd1385d5976875ff9b97f4269ce5231a67" +checksum = "d9a4a09f24f1387408810acca070207a9cf6c7fc2368122982a8e7671f643d1e" dependencies = [ - "abi_stable", "arrow", "arrow-schema", "async-ffi", "async-trait", - "datafusion", + "chrono", + "datafusion-catalog", "datafusion-common", + "datafusion-datasource", + "datafusion-execution", + "datafusion-expr", "datafusion-functions-aggregate-common", + "datafusion-physical-expr", + "datafusion-physical-expr-common", + "datafusion-physical-optimizer", + "datafusion-physical-plan", "datafusion-proto", "datafusion-proto-common", + "datafusion-session", "futures", + "libloading", "log", "prost", "semver", + "stabby", "tokio", ] [[package]] name = "datafusion-functions" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "794a9db7f7b96b3346fc007ff25e994f09b8f0511b4cf7dff651fadfe3ebb28f" +checksum = "5f64c983bbbdcb729d921a2b2ac3375598719b5cc0c30345ad664936f3176fc7" dependencies = [ "arrow", "arrow-buffer", - "base64", + "base64 0.22.1", "blake2", "blake3", "chrono", + "chrono-tz", "datafusion-common", "datafusion-doc", "datafusion-execution", "datafusion-expr", "datafusion-expr-common", "datafusion-macros", + "datafusion-physical-expr-common", "hex", "itertools 0.14.0", "log", "md-5", + "memchr", "num-traits", - "rand 0.9.2", + "rand 0.9.5", "regex", - "sha2", - "unicode-segmentation", + "sha2 0.11.0", "uuid", ] [[package]] name = "datafusion-functions-aggregate" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c25210520a9dcf9c2b2cbbce31ebd4131ef5af7fc60ee92b266dc7d159cb305" +checksum = "89bc17041e424a47ed062f43df24d84aab8b57c4c3221e5c1a5eef46d6c5718b" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-doc", @@ -2070,18 +2083,18 @@ dependencies = [ "datafusion-macros", "datafusion-physical-expr", "datafusion-physical-expr-common", + "foldhash 0.2.0", "half", "log", - "paste", + "num-traits", ] [[package]] name = "datafusion-functions-aggregate-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62f4a66f3b87300bb70f4124b55434d2ae3fe80455f3574701d0348da040b55d" +checksum = "97dd2a9e865c6108059f5b37b77934f84b50bfb108f837bd0e5c9536e03f0545" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr-common", @@ -2090,9 +2103,9 @@ dependencies = [ [[package]] name = "datafusion-functions-nested" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae5c06eed03918dc7fe7a9f082a284050f0e9ecf95d72f57712d1496da03b8c4" +checksum = "75f0bdfeef16d96417b9632ef855645376b242e9006a126dfd0bedfc54a93f5f" dependencies = [ "arrow", "arrow-ord", @@ -2106,32 +2119,34 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-macros", "datafusion-physical-expr-common", + "hashbrown 0.17.1", "itertools 0.14.0", + "itoa", "log", - "paste", + "memchr", ] [[package]] name = "datafusion-functions-table" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db4fed1d71738fbe22e2712d71396db04c25de4111f1ec252b8f4c6d3b25d7f5" +checksum = "f4e4941673c917819616877e9993da4503e4f4739812be0bc32c5356184c6383" dependencies = [ "arrow", "async-trait", "datafusion-catalog", "datafusion-common", "datafusion-expr", + "datafusion-physical-expr", "datafusion-physical-plan", "parking_lot", - "paste", ] [[package]] name = "datafusion-functions-window" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d92206aa5ae21892f1552b4d61758a862a70956e6fd7a95cb85db1de74bc6d1" +checksum = "12dd2e16c12b84b6f6b41b19f55b366dd1c46876bb35b86896c6349067379e8d" dependencies = [ "arrow", "datafusion-common", @@ -2142,14 +2157,13 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "log", - "paste", ] [[package]] name = "datafusion-functions-window-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53ae9bcc39800820d53a22d758b3b8726ff84a5a3e24cecef04ef4e5fdf1c7cc" +checksum = "4cdc5e4b6f8b6ef823cc1c761f85088ad4c884fe8df64df3cbcc6b2b84698441" dependencies = [ "datafusion-common", "datafusion-physical-expr-common", @@ -2157,20 +2171,20 @@ dependencies = [ [[package]] name = "datafusion-macros" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1063ad4c9e094b3f798acee16d9a47bd7372d9699be2de21b05c3bd3f34ab848" +checksum = "1a3614234dd93578c92428cb4f408e020874f0d2b7e6c90c928d9d28b5df2ceb" dependencies = [ "datafusion-doc", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "datafusion-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f35f9ec5d08b87fd1893a30c2929f2559c2f9806ca072d8fefca5009dc0f06a" +checksum = "a0635620b050b81bb92764e99250868f654e2cd5ad1bece413283b3f73c83179" dependencies = [ "arrow", "chrono", @@ -2178,21 +2192,19 @@ dependencies = [ "datafusion-expr", "datafusion-expr-common", "datafusion-physical-expr", - "indexmap", + "indexmap 2.14.0", "itertools 0.14.0", "log", - "recursive", "regex", "regex-syntax", ] [[package]] name = "datafusion-physical-expr" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c30cc8012e9eedcb48bbe112c6eff4ae5ed19cf3003cb0f505662e88b7014c5d" +checksum = "8cabf7a86eb70b816729e33c81bf7767c936ee1226f607a114f5dac2decac8d0" dependencies = [ - "ahash", "arrow", "datafusion-common", "datafusion-expr", @@ -2200,19 +2212,19 @@ dependencies = [ "datafusion-functions-aggregate-common", "datafusion-physical-expr-common", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "parking_lot", - "paste", "petgraph", + "tokio", ] [[package]] name = "datafusion-physical-expr-adapter" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9ff2dbd476221b1f67337699eff432781c4e6e1713d2aefdaa517dfbf79768" +checksum = "de222e04f7e6744501555a54ab0abe26bfdfebee380af79a9bdc175704246859" dependencies = [ "arrow", "datafusion-common", @@ -2225,23 +2237,26 @@ dependencies = [ [[package]] name = "datafusion-physical-expr-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90da43e1ec550b172f34c87ec68161986ced70fd05c8d2a2add66eef9c276f03" +checksum = "72d0d0057fc5a502d45c870cb6d47c66eb7bdd5edb1bd71ad6f3f724975ac2a8" dependencies = [ - "ahash", "arrow", + "chrono", "datafusion-common", "datafusion-expr-common", - "hashbrown 0.14.5", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", + "parking_lot", + "pin-project", ] [[package]] name = "datafusion-physical-optimizer" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce9804f799acd7daef3be7aaffe77c0033768ed8fdbf5fb82fc4c5f2e6bc14e6" +checksum = "86046eed10950c5f9aaed9acfd148e9bd2e1dfdfe4f9aef607d1447b271e4183" dependencies = [ "arrow", "datafusion-common", @@ -2253,35 +2268,36 @@ dependencies = [ "datafusion-physical-plan", "datafusion-pruning", "itertools 0.14.0", - "recursive", ] [[package]] name = "datafusion-physical-plan" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0acf0ad6b6924c6b1aa7d213b181e012e2d3ec0a64ff5b10ee6282ab0f8532ac" +checksum = "9bc84da934c903407ba297971ebcc020c4c1a38aafd765d6c144c76eff3fa6a1" dependencies = [ - "ahash", "arrow", + "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "async-trait", - "chrono", "datafusion-common", "datafusion-common-runtime", "datafusion-execution", "datafusion-expr", + "datafusion-functions", "datafusion-functions-aggregate-common", "datafusion-functions-window-common", "datafusion-physical-expr", "datafusion-physical-expr-common", "futures", "half", - "hashbrown 0.14.5", - "indexmap", + "hashbrown 0.17.1", + "indexmap 2.14.0", "itertools 0.14.0", "log", + "num-traits", "parking_lot", "pin-project-lite", "tokio", @@ -2289,9 +2305,9 @@ dependencies = [ [[package]] name = "datafusion-proto" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d368093a98a17d1449b1083ac22ed16b7128e4c67789991869480d8c4a40ecb9" +checksum = "67791dcfacd142a9d95f8f73c2a161094cc936d231d80c235f5017dacf24e84e" dependencies = [ "arrow", "chrono", @@ -2310,15 +2326,15 @@ dependencies = [ "datafusion-physical-expr-common", "datafusion-physical-plan", "datafusion-proto-common", - "object_store", + "object_store 0.13.2", "prost", ] [[package]] name = "datafusion-proto-common" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b6aef3d5e5c1d2bc3114c4876730cb76a9bdc5a8df31ef1b6db48f0c1671895" +checksum = "b8cd9e80d637891645d074db0f6c650b591117367247deb313fdfb78dff559cb" dependencies = [ "arrow", "datafusion-common", @@ -2327,9 +2343,9 @@ dependencies = [ [[package]] name = "datafusion-pruning" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac2c2498a1f134a9e11a9f5ed202a2a7d7e9774bd9249295593053ea3be999db" +checksum = "eb63eeac6de19be40f487b65dd84e546195f783c5a9928618e0c4f2a3569b0d7" dependencies = [ "arrow", "datafusion-common", @@ -2338,15 +2354,14 @@ dependencies = [ "datafusion-physical-expr", "datafusion-physical-expr-common", "datafusion-physical-plan", - "itertools 0.14.0", "log", ] [[package]] name = "datafusion-session" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f96eebd17555386f459037c65ab73aae8df09f464524c709d6a3134ad4f4776" +checksum = "5f961d209177f91bd014db5cbb2c33b7d28a2597b9003e77f17aeb712964315a" dependencies = [ "async-trait", "datafusion-common", @@ -2358,27 +2373,27 @@ dependencies = [ [[package]] name = "datafusion-sql" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fc195fe60634b2c6ccfd131b487de46dc30eccae8a3c35a13f136e7f440414f" +checksum = "b1d71cb454da682b2af7488e1fc1ddd72ee1b28f19297b8ccad73f0a21ee9a69" dependencies = [ "arrow", "bigdecimal", "chrono", "datafusion-common", "datafusion-expr", - "indexmap", + "datafusion-functions-nested", + "indexmap 2.14.0", "log", - "recursive", "regex", "sqlparser", ] [[package]] name = "datafusion-substrait" -version = "51.0.0" +version = "54.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2505af06d103a55b4e8ded0c6aeb6c72a771948da939c0bd3f8eee67af475a9c" +checksum = "f047a6fbf967b6b523758a48d2377bb5f9373a20e7ae4c4326d3c569f80ba3d7" dependencies = [ "async-recursion", "async-trait", @@ -2386,34 +2401,19 @@ dependencies = [ "datafusion", "half", "itertools 0.14.0", - "object_store", + "object_store 0.13.2", "pbjson-types", "prost", "substrait", "tokio", "url", - "uuid", -] - -[[package]] -name = "deepsize" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1cdb987ec36f6bf7bfbea3f928b75590b736fc42af8e54d97592481351b2b96c" -dependencies = [ - "deepsize_derive", ] [[package]] -name = "deepsize_derive" -version = "0.1.2" +name = "datasketches" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990101d41f3bc8c1a45641024377ee284ecc338e5ecf3ea0f0e236d897c72796" -dependencies = [ - "proc-macro2", - "quote", - "syn 1.0.109", -] +checksum = "46c4cf71a36b46dcfc00e5014c0c20ccad2b1b6a008304d7d57d2749b2d41b3d" [[package]] name = "der" @@ -2421,16 +2421,16 @@ version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "const-oid", + "const-oid 0.9.6", "pem-rfc7468", "zeroize", ] [[package]] name = "deranged" -version = "0.5.5" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ececcb659e7ba858fb4f10388c250a7252eb0a27373f1a72b8748afdd248e587" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ "powerfmt", "serde_core", @@ -2454,7 +2454,7 @@ dependencies = [ "darling 0.20.11", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -2464,7 +2464,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab63b0e2bf4d5928aff72e83a7dace85d7bba5fe12dcc3c5a572d78caffd3f3c" dependencies = [ "derive_builder_core", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -2473,12 +2473,24 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "const-oid", - "crypto-common", + "block-buffer 0.10.4", + "const-oid 0.9.6", + "crypto-common 0.1.7", "subtle", ] +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "dirs" version = "6.0.0" @@ -2502,13 +2514,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -2520,12 +2532,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" @@ -2550,73 +2556,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - -[[package]] -name = "encoding" -version = "0.2.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b0d943856b990d12d3b55b359144ff341533e516d94098b1d3fc1ac666d36ec" -dependencies = [ - "encoding-index-japanese", - "encoding-index-korean", - "encoding-index-simpchinese", - "encoding-index-singlebyte", - "encoding-index-tradchinese", -] - -[[package]] -name = "encoding-index-japanese" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04e8b2ff42e9a05335dbf8b5c6f7567e5591d0d916ccef4e0b1710d32a0d0c91" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding-index-korean" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4dc33fb8e6bcba213fe2f14275f0963fd16f0a02c878e3095ecfdf5bee529d81" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding-index-simpchinese" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87a7194909b9118fc707194baa434a4e3b0fb6a5a757c73c3adb07aa25031f7" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding-index-singlebyte" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3351d5acffb224af9ca265f435b859c7c01537c0849754d3db3fdf2bfe2ae84a" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding-index-tradchinese" -version = "1.20141219.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd0e20d5688ce3cab59eb3ef3a2083a5c77bf496cb798dc6fcdb75f323890c18" -dependencies = [ - "encoding_index_tests", -] - -[[package]] -name = "encoding_index_tests" -version = "0.1.4" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a246d82be1c9d791c5dfde9a2bd045fc3cbba3fa2b11ad558f27d01712f00569" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" [[package]] name = "encoding_rs" @@ -2624,7 +2566,7 @@ version = "0.8.35" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -2636,11 +2578,17 @@ dependencies = [ "encoding_rs", ] +[[package]] +name = "endian-type" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c34f04666d835ff5d62e058c3995147c06f42fe86ff053337632bca83e42702d" + [[package]] name = "env_filter" -version = "0.1.4" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bf3c259d255ca70051b30e2e95b5446cdb8949ac4cd22c0d7fd634d89f568e2" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ "log", "regex", @@ -2648,9 +2596,9 @@ dependencies = [ [[package]] name = "env_logger" -version = "0.11.8" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c863f0904021b108aa8b2f55046443e6b1ebde8fd4a15c399893aae4fa069f" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ "anstream", "anstyle", @@ -2677,17 +2625,16 @@ dependencies = [ [[package]] name = "ethnum" -version = "1.5.2" +version = "1.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca81e6b4777c89fd810c25a4be2b1bd93ea034fbe58e6a75216a34c6b82c539b" +checksum = "40404c3f5f511ec4da6fe866ddf6a717c309fdbb69fbbad7b0f3edab8f2e835f" [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2708,28 +2655,11 @@ 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.3.0" +version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" - -[[package]] -name = "filetime" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" -dependencies = [ - "cfg-if", - "libc", - "libredox", -] +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "find-msvc-tools" @@ -2749,7 +2679,7 @@ version = "25.12.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "rustc_version", ] @@ -2798,15 +2728,70 @@ dependencies = [ ] [[package]] -name = "fs4" -version = "0.8.4" +name = "foyer-common" +version = "0.22.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7e180ac76c23b45e767bd7ae9579bc0bb458618c4bc71835926e098e61d15f8" +checksum = "643b47d510032a01e5af70dca288b0c5ec531646c1a8392e793fb5b0c13bef30" dependencies = [ - "rustix 0.38.44", - "windows-sys 0.52.0", + "anyhow", + "bytes", + "cfg-if 1.0.4", + "foyer-tokio", + "mixtrics", + "parking_lot", + "pin-project", + "twox-hash", +] + +[[package]] +name = "foyer-intrusive-collections" +version = "0.10.0-dev" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e4fee46bea69e0596130e3210e65d3424e0ac1e6df3bde6636304bdf1ca4a3b" +dependencies = [ + "memoffset", +] + +[[package]] +name = "foyer-memory" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae51f5089f3d9025ae77f17ea66d2489ac0f82fbb1d91462807bea174d486d82" +dependencies = [ + "anyhow", + "asyncband", + "bitflags 2.13.0", + "datasketches", + "equivalent", + "foyer-common", + "foyer-intrusive-collections", + "foyer-tokio", + "futures-util", + "hashbrown 0.17.1", + "itertools 0.15.0", + "mixtrics", + "parking_lot", + "paste", + "pin-project", + "serde", + "tracing", +] + +[[package]] +name = "foyer-tokio" +version = "0.22.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6741d1133dfaab64d3f8a9290bbfed3685084add28f8b6ee7a6264aa4dc26918" +dependencies = [ + "tokio", ] +[[package]] +name = "frostem" +version = "1.20260804.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0ae10cfccbae085dd8612669ccc116fe88bd5dfe39d202a8fa68c24c1e546f" + [[package]] name = "fs_extra" version = "1.3.0" @@ -2815,10 +2800,10 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "fsst" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", - "rand 0.9.2", + "rand 0.9.5", ] [[package]] @@ -2838,9 +2823,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" dependencies = [ "futures-channel", "futures-core", @@ -2853,9 +2838,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" dependencies = [ "futures-core", "futures-sink", @@ -2863,15 +2848,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" [[package]] name = "futures-executor" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" dependencies = [ "futures-core", "futures-task", @@ -2880,44 +2865,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" [[package]] name = "futures-macro" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "futures-sink" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" [[package]] name = "futures-task" -version = "0.3.31" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" - -[[package]] -name = "futures-timer" -version = "3.0.3" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" [[package]] name = "futures-util" -version = "0.3.31" +version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" dependencies = [ "futures-channel", "futures-core", @@ -2927,27 +2906,26 @@ dependencies = [ "futures-task", "memchr", "pin-project-lite", - "pin-utils", "slab", ] [[package]] -name = "generational-arena" -version = "0.2.9" +name = "gearhash" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877e94aff08e743b651baaea359664321055749b398adff8740a7399af7796e7" +checksum = "c8cf82cf76cd16485e56295a1377c775ce708c9f1a0be6b029076d60a245d213" dependencies = [ - "cfg-if", + "cfg-if 0.1.10", ] [[package]] name = "generator" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f04ae4152da20c76fe800fa48659201d5cf627c5149ca0b707b69d7eef6cf9" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "libc", "log", "rustversion", @@ -2994,9 +2972,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", @@ -3007,9 +2985,9 @@ dependencies = [ [[package]] name = "geoarrow-array" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1cc4106ac0a0a512c398961ce95d8150475c84a84e17c4511c3643fa120a17" +checksum = "dafe7b7de3fab1a8b7099fd6a6434ca955fa65065f9c19f0f8a133693f3c2b0e" dependencies = [ "arrow-array", "arrow-buffer", @@ -3023,9 +3001,9 @@ dependencies = [ [[package]] name = "geoarrow-expr-geo" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa84300361ce57fb875bcaa6e32b95b0aff5c6b1af692b936bdd58ff343f4394" +checksum = "8e4a62ac19c86827c6ec81ea584594b3ee96db5a8119b9774d3466c6b373c434" dependencies = [ "arrow-array", "arrow-buffer", @@ -3037,9 +3015,9 @@ dependencies = [ [[package]] name = "geoarrow-schema" -version = "0.7.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e97be4e9f523f92bd6a0e0458323f4b783d073d011664decd8dbf05651704f34" +checksum = "4d4a7edb2a1d87024a93805332a9c8184a0354836271d42c0d18cf628a5e3cd0" dependencies = [ "arrow-schema", "geo-traits", @@ -3050,12 +3028,13 @@ dependencies = [ [[package]] name = "geodatafusion" -version = "0.2.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773cfa1fb0d7f7661b76b3fde00f3ffd8e0ff7b3635096f0ff6294fe5ca62a2b" +checksum = "fecbdd00d0fff2b04635c1b1e4129c217908f0c2d17539e0a2275308afce2552" dependencies = [ "arrow-arith", "arrow-array", + "arrow-buffer", "arrow-schema", "datafusion", "geo", @@ -3070,18 +3049,18 @@ dependencies = [ [[package]] name = "geographiclib-rs" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f611040a2bb37eaa29a78a128d1e92a378a03e0b6e66ae27398d42b1ba9a7841" +checksum = "c5a7f08910fd98737a6eda7568e7c5e645093e073328eeef49758cfe8b0489c7" dependencies = [ "libm", ] [[package]] name = "geohash" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fb94b1a65401d6cbf22958a9040aa364812c26674f841bee538b12c135db1e6" +checksum = "7f58890382f70caccc5fa388981f7ac80c913795042afce9f3e065695d8f7464" dependencies = [ "geo-types", "libm", @@ -3093,10 +3072,10 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "js-sys", "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "wasm-bindgen", ] @@ -3106,23 +3085,55 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ - "cfg-if", - "js-sys", + "cfg-if 1.0.4", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] -name = "glob" -version = "0.3.3" +name = "getrandom" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if 1.0.4", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] [[package]] -name = "gloo-timers" -version = "0.3.0" +name = "git-version" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad568aa3db0fcbc81f2f116137f263d7304f512a1209b35b85150d3ef88ad19" +dependencies = [ + "git-version-macro", +] + +[[package]] +name = "git-version-macro" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53010ccb100b96a67bc32c0175f0ed1426b31b655d562898e57325f81c023ac0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gloo-timers" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" dependencies = [ @@ -3133,37 +3144,50 @@ dependencies = [ ] [[package]] -name = "h2" -version = "0.3.27" +name = "goosefs-sdk" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +checksum = "1e471f7bce40936cf2023855c7350e31e11b3f703eb7cb2ce73c9ffc451f87d2" dependencies = [ + "arc-swap", + "async-trait", "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", + "dashmap", + "fastrand", + "foyer-common", + "foyer-memory", + "futures", + "hostname", + "io-uring", + "itoa", + "libc", + "lru", + "prost", + "rand 0.9.5", + "serde", + "thiserror 2.0.18", "tokio", - "tokio-util", + "tokio-stream", + "tonic", + "tonic-prost", "tracing", + "uuid", + "xxhash-rust", ] [[package]] name = "h2" -version = "0.4.13" +version = "0.4.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +checksum = "a9f37a958b41b3b19ee2707c06439c0e9e547e847223eb791ecb0cb821c65e27" dependencies = [ "atomic-waker", "bytes", "fnv", "futures-core", "futures-sink", - "http 1.4.0", - "indexmap", + "http 1.4.2", + "indexmap 2.14.0", "slab", "tokio", "tokio-util", @@ -3176,7 +3200,8 @@ version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "cfg-if", + "bytemuck", + "cfg-if 1.0.4", "crunchy", "num-traits", "zerocopy", @@ -3191,15 +3216,17 @@ dependencies = [ "byteorder", ] +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + [[package]] name = "hashbrown" version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" -dependencies = [ - "ahash", - "allocator-api2", -] [[package]] name = "hashbrown" @@ -3207,8 +3234,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", ] @@ -3223,6 +3248,23 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "heapify" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" + [[package]] name = "heapless" version = "0.8.0" @@ -3251,29 +3293,59 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hf-xet" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "http 1.4.2", + "more-asserts", + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "uuid", + "xet-client", + "xet-core-structures", + "xet-data", + "xet-runtime", +] + [[package]] name = "hmac" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] -name = "home" -version = "0.5.12" +name = "hmac" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d" +checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "windows-sys 0.61.2", + "digest 0.11.3", ] [[package]] -name = "htmlescape" -version = "0.3.1" +name = "hostname" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" +checksum = "617aaa3557aef3810a6369d0a99fac8a080891b68bd9f9812a1eeda0c0730cbd" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "windows-link", +] [[package]] name = "http" @@ -3288,9 +3360,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -3314,7 +3386,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -3325,7 +3397,7 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "pin-project-lite", ] @@ -3344,52 +3416,36 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] -name = "hyper" -version = "0.14.32" +name = "hybrid-array" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" 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", + "typenum", ] [[package]] name = "hyper" -version = "1.8.1" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.4.2", "http-body 1.0.1", "httparse", "httpdate", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -3397,35 +3453,31 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.24.2" +version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "futures-util", - "http 0.2.12", - "hyper 0.14.32", - "log", - "rustls 0.21.12", + "http 1.4.2", + "hyper", + "hyper-util", + "rustls", + "rustls-native-certs", "tokio", - "tokio-rustls 0.24.1", + "tokio-rustls", + "tower-service", ] [[package]] -name = "hyper-rustls" -version = "0.27.7" +name = "hyper-timeout" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" dependencies = [ - "http 1.4.0", - "hyper 1.8.1", + "hyper", "hyper-util", - "rustls 0.23.36", - "rustls-native-certs", - "rustls-pki-types", + "pin-project-lite", "tokio", - "tokio-rustls 0.26.4", "tower-service", - "webpki-roots", ] [[package]] @@ -3434,21 +3486,23 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "futures-channel", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", - "hyper 1.8.1", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -3529,35 +3583,58 @@ 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", ] +[[package]] +name = "icu_locale" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_locale_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + [[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", + "serde", "tinystr", "writeable", "zerovec", ] +[[package]] +name = "icu_locale_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" + [[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", @@ -3569,15 +3646,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", @@ -3589,18 +3666,20 @@ 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", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -3608,6 +3687,27 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_segmenter" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +dependencies = [ + "icu_collections", + "icu_locale", + "icu_provider", + "icu_segmenter_data", + "potential_utf", + "utf8_iter", + "zerovec", +] + +[[package]] +name = "icu_segmenter_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" + [[package]] name = "ident_case" version = "1.0.1" @@ -3627,68 +3727,35 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", ] [[package]] -name = "include-flate" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01b7cb6ca682a621e7cda1c358c9724b53a7b4409be9be1dd443b7f3a26f998" -dependencies = [ - "include-flate-codegen", - "include-flate-compress", - "libflate", - "zstd", -] - -[[package]] -name = "include-flate-codegen" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f49bf5274aebe468d6e6eba14a977eaf1efa481dc173f361020de70c1c48050" -dependencies = [ - "include-flate-compress", - "libflate", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.114", - "zstd", -] - -[[package]] -name = "include-flate-compress" -version = "0.3.1" +name = "indexmap" +version = "1.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eae6a40e716bcd5931f5dbb79cd921512a4f647e2e9413fded3171fca3824dbc" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ - "libflate", - "zstd", + "autocfg", + "hashbrown 0.12.3", + "serde", ] [[package]] name = "indexmap" -version = "2.13.0" +version = "2.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.16.1", -] - -[[package]] -name = "indoc" -version = "2.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" -dependencies = [ - "rustversion", + "hashbrown 0.17.1", + "serde", + "serde_core", ] [[package]] @@ -3708,20 +3775,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8bb03732005da905c88227371639bf1ad885cc712789c011c31c5fb3ab3ccf02" [[package]] -name = "ipnet" -version = "2.11.0" +name = "io-uring" +version = "0.7.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" +checksum = "9080b15e63775b9a2ac7dca720f7050a8b955e092ea0f6020a4a80f69998cdc0" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "libc", +] [[package]] -name = "iri-string" -version = "0.7.10" +name = "ipnet" +version = "2.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" -dependencies = [ - "memchr", - "serde", -] +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "is_terminal_polyfill" @@ -3740,45 +3808,45 @@ dependencies = [ [[package]] name = "itertools" -version = "0.13.0" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ "either", ] [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] [[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" -version = "0.8.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "348294e44ee7e3c42685da656490f8febc7359632544019621588902216da95c" +checksum = "38fc0f3831de71556de69643b80a08a5c8cd260a23c6b8dbeb7cd923c779cac5" dependencies = [ "phf_codegen", ] [[package]] name = "jieba-rs" -version = "0.8.1" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "766bd7012aa5ba49411ebdf4e93bddd59b182d2918e085d58dec5bb9b54b7105" +checksum = "bb5bdea4dc241d589e179f39d2a778f31490f3370aa2f626223dbd930ebc5c9d" dependencies = [ + "bytecount", "cedarwood", - "include-flate", "jieba-macros", "phf 0.13.1", "regex", @@ -3787,35 +3855,37 @@ dependencies = [ [[package]] name = "jiff" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89a5b5e10d5a9ad6e5d1f4bd58225f655d6fe9767575a5e8ac5a6fe64e04495" +checksum = "4603d3033e49e2b0e31229fcab20a5d40089c607d975cd9c80551dc69eed9102" dependencies = [ "jiff-static", "jiff-tzdb-platform", + "js-sys", "log", "portable-atomic", "portable-atomic-util", "serde_core", - "windows-sys 0.61.2", + "wasm-bindgen", + "windows-link", ] [[package]] name = "jiff-static" -version = "0.2.19" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7a39c8862fc1369215ccf0a8f12dd4598c7f6484704359f0351bd617034dbf" +checksum = "782d32378dddf207193ac91cefb848ad41abb58195c95168e1291227a0832b47" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "jiff-tzdb" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68971ebff725b9e2ca27a601c5eb38a4c5d64422c4cbab0c535f248087eda5c2" +checksum = "c900ef84826f1338a557697dc8fc601df9ca9af4ac137c7fb61d4c6f2dfd3076" [[package]] name = "jiff-tzdb-platform" @@ -3827,58 +3897,93 @@ dependencies = [ ] [[package]] -name = "jobserver" -version = "0.1.34" +name = "jni" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "getrandom 0.3.4", - "libc", + "cfg-if 1.0.4", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror 2.0.18", + "walkdir", + "windows-link", ] [[package]] -name = "js-sys" -version = "0.3.85" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "once_cell", - "wasm-bindgen", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.118", ] [[package]] -name = "jsonb" -version = "0.5.5" +name = "jni-sys" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a901f06163d352fbe41c3c2ff5e08b75330a003cc941e988fb501022f5421e6" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" dependencies = [ - "byteorder", - "ethnum", - "fast-float2", - "itoa", - "jiff", - "nom 8.0.0", - "num-traits", - "ordered-float 5.1.0", - "rand 0.9.2", - "ryu", - "serde", - "serde_json", + "jni-sys-macros", ] [[package]] -name = "jsonwebtoken" -version = "9.3.1" +name = "jni-sys-macros" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ - "base64", - "js-sys", - "pem", - "ring", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if 1.0.4", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonb" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb98fb29636087c40ad0d1274d9a30c0c1e83e03ae93f6e7e89247b37fcc6953" +dependencies = [ + "byteorder", + "ethnum", + "fast-float2", + "itoa", + "jiff", + "nom", + "num-traits", + "ordered-float 5.3.0", + "rand 0.9.5", "serde", "serde_json", - "simple_asn1", + "zmij", ] [[package]] @@ -3890,14 +3995,33 @@ dependencies = [ "bitflags 1.3.2", ] +[[package]] +name = "konst" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f660d5f887e3562f9ab6f4a14988795b694099d66b4f5dedc02d197ba9becb1d" +dependencies = [ + "const_panic", + "konst_proc_macros", + "typewit", +] + +[[package]] +name = "konst_proc_macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a" + [[package]] name = "lance" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ipc", "arrow-ord", "arrow-row", @@ -3906,11 +4030,12 @@ dependencies = [ "async-recursion", "async-trait", "async_cell", - "aws-credential-types", "aws-sdk-dynamodb", + "blake3", "byteorder", "bytes", "chrono", + "crossbeam-queue", "crossbeam-skiplist", "dashmap", "datafusion", @@ -3918,37 +4043,40 @@ dependencies = [ "datafusion-functions", "datafusion-physical-expr", "datafusion-physical-plan", - "deepsize", "either", + "fst", "futures", - "half", "humantime", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", + "lance-bitpacking", "lance-core", "lance-datafusion", "lance-encoding", "lance-file", - "lance-geo", "lance-index", "lance-io", "lance-linalg", "lance-namespace", + "lance-select", "lance-table", + "lance-tokenizer", "log", "moka", - "object_store", + "object_store 0.14.1", "permutation", "pin-project", "prost", + "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.5", + "rayon", "roaring", + "rustc-hash", "semver", "serde", "serde_json", "snafu", - "tantivy", "tokio", "tokio-stream", "tokio-util", @@ -3959,76 +4087,102 @@ dependencies = [ [[package]] name = "lance-arrow" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", - "arrow-cast", "arrow-data", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", + "bytemuck", "bytes", + "futures", "getrandom 0.2.17", "half", "jsonb", "num-traits", - "rand 0.9.2", + "rand 0.9.5", +] + +[[package]] +name = "lance-arrow-scalar" +version = "58.0.0" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-row", + "arrow-schema", + "half", +] + +[[package]] +name = "lance-arrow-stats" +version = "58.0.0" +dependencies = [ + "arrow-array", + "arrow-schema", + "lance-arrow-scalar", ] [[package]] name = "lance-bitpacking" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrayref", + "crunchy", "paste", "seq-macro", ] [[package]] name = "lance-core" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", "arrow-buffer", + "arrow-data", "arrow-schema", "async-trait", - "byteorder", + "blake3", "bytes", - "chrono", "datafusion-common", "datafusion-sql", - "deepsize", "futures", - "itertools 0.13.0", "lance-arrow", + "lance-derive", "libc", + "libm", "log", - "mock_instant", "moka", "num_cpus", - "object_store", + "object_store 0.14.1", "pin-project", "prost", - "rand 0.9.2", + "quick_cache", + "rand 0.9.5", "roaring", "serde_json", "snafu", "tempfile", "tokio", - "tokio-stream", "tokio-util", "tracing", + "twox-hash", "url", ] [[package]] name = "lance-datafusion" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-ord", "arrow-schema", "arrow-select", @@ -4040,22 +4194,22 @@ dependencies = [ "datafusion-physical-expr", "datafusion-substrait", "futures", + "half", "jsonb", "lance-arrow", "lance-core", - "lance-datagen", "lance-geo", "log", "pin-project", "prost", - "snafu", + "prost-build", "tokio", "tracing", ] [[package]] name = "lance-datagen" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4065,15 +4219,23 @@ dependencies = [ "futures", "half", "hex", - "rand 0.9.2", - "rand_distr 0.5.1", + "rand 0.9.5", + "rand_distr", "rand_xoshiro", - "random_word", +] + +[[package]] +name = "lance-derive" +version = "14.0.0-beta.6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] name = "lance-encoding" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", @@ -4089,7 +4251,7 @@ dependencies = [ "futures", "hex", "hyperloglogplus", - "itertools 0.13.0", + "itertools 0.14.0", "lance-arrow", "lance-bitpacking", "lance-core", @@ -4098,10 +4260,6 @@ dependencies = [ "num-traits", "prost", "prost-build", - "prost-types", - "rand 0.9.2", - "snafu", - "strum 0.26.3", "tokio", "tracing", "xxhash-rust", @@ -4110,11 +4268,12 @@ dependencies = [ [[package]] name = "lance-file" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-arith", "arrow-array", "arrow-buffer", + "arrow-cast", "arrow-data", "arrow-schema", "arrow-select", @@ -4123,7 +4282,6 @@ dependencies = [ "byteorder", "bytes", "datafusion-common", - "deepsize", "futures", "lance-arrow", "lance-core", @@ -4131,18 +4289,18 @@ dependencies = [ "lance-io", "log", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "snafu", + "serde", "tokio", "tracing", ] [[package]] name = "lance-geo" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "datafusion", "geo-traits", @@ -4156,18 +4314,19 @@ dependencies = [ [[package]] name = "lance-index" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-arith", "arrow-array", + "arrow-ipc", "arrow-ord", "arrow-schema", "arrow-select", "async-channel", "async-recursion", "async-trait", - "bitpacking", "bitvec", "bytes", "crossbeam-queue", @@ -4175,8 +4334,6 @@ dependencies = [ "datafusion-common", "datafusion-expr", "datafusion-physical-expr", - "datafusion-sql", - "deepsize", "dirs", "fst", "futures", @@ -4184,160 +4341,207 @@ dependencies = [ "geoarrow-array", "geoarrow-schema", "half", - "itertools 0.13.0", + "hashbrown 0.16.1", + "hex", + "itertools 0.14.0", "jieba-rs", "jsonb", "lance-arrow", + "lance-arrow-stats", + "lance-bitpacking", "lance-core", "lance-datafusion", - "lance-datagen", "lance-encoding", "lance-file", "lance-geo", + "lance-index-core", "lance-io", "lance-linalg", + "lance-select", "lance-table", - "libm", - "lindera", - "lindera-tantivy", + "lance-tokenizer", + "libsais-rs", "log", "ndarray", "num-traits", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "rand 0.9.2", - "rand_distr 0.5.1", + "rand 0.9.5", + "rand_distr", "rangemap", "rayon", + "regex-syntax", "roaring", "serde", "serde_json", "smallvec", - "snafu", - "tantivy", "tempfile", "tokio", "tracing", "twox-hash", - "uuid", +] + +[[package]] +name = "lance-index-core" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-schema", + "arrow-select", + "async-trait", + "bytes", + "datafusion", + "datafusion-common", + "datafusion-expr", + "futures", + "lance-core", + "lance-io", + "lance-select", + "prost-types", + "roaring", + "serde", + "serde_json", + "tokio", ] [[package]] name = "lance-io" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", - "arrow-arith", "arrow-array", - "arrow-buffer", - "arrow-cast", - "arrow-data", "arrow-schema", - "arrow-select", - "async-recursion", "async-trait", "aws-config", "aws-credential-types", "byteorder", "bytes", "chrono", - "deepsize", "futures", - "lance-arrow", + "goosefs-sdk", + "http 1.4.2", + "io-uring", "lance-core", "lance-namespace", "log", - "object_store", + "metrics", + "moka", + "object_store 0.14.1", "object_store_opendal", "opendal", + "opendal-service-goosefs", "path_abs", "pin-project", "prost", - "rand 0.9.2", + "rand 0.9.5", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", "serde", - "shellexpand", - "snafu", + "serde_json", + "tempfile", "tokio", "tracing", "url", + "uuid", ] [[package]] name = "lance-linalg" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow-array", - "arrow-buffer", "arrow-schema", "cc", - "deepsize", "half", "lance-arrow", "lance-core", "num-traits", - "rand 0.9.2", + "rayon", ] [[package]] name = "lance-namespace" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "async-trait", "bytes", "lance-core", "lance-namespace-reqwest-client", + "serde", + "serde_json", "snafu", ] [[package]] name = "lance-namespace-impls" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "arc-swap", "arrow", "arrow-ipc", "arrow-schema", "async-trait", "axum", "bytes", - "chrono", + "datafusion-common", + "datafusion-physical-plan", "futures", "lance", "lance-core", "lance-index", "lance-io", + "lance-linalg", "lance-namespace", + "lance-table", "log", - "object_store", - "rand 0.9.2", - "reqwest", + "object_store 0.14.1", + "rand 0.9.5", + "reqwest 0.12.28", + "roaring", "serde", "serde_json", - "snafu", "tokio", "tower", "tower-http 0.5.2", "url", + "uuid", ] [[package]] name = "lance-namespace-reqwest-client" -version = "0.4.5" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2acdba67f84190067532fce07b51a435dd390d7cdc1129a05003e5cb3274cf0" +checksum = "828f291764feda98898452bea4ce3756b73e5d5c7c67b62edda27456b7cc72c1" dependencies = [ - "reqwest", + "reqwest 0.12.28", "serde", "serde_json", "serde_repr", + "serde_with", "url", ] +[[package]] +name = "lance-select" +version = "14.0.0-beta.6" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-schema", + "byteorder", + "itertools 0.14.0", + "lance-core", + "roaring", + "tracing", +] + [[package]] name = "lance-table" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ "arrow", "arrow-array", @@ -4347,21 +4551,22 @@ dependencies = [ "async-trait", "aws-credential-types", "aws-sdk-dynamodb", + "blake3", "byteorder", "bytes", "chrono", - "deepsize", "futures", "lance-arrow", "lance-core", "lance-file", "lance-io", + "lance-select", "log", - "object_store", + "object_store 0.14.1", "prost", "prost-build", "prost-types", - "rand 0.9.2", + "rand 0.9.5", "rangemap", "roaring", "semver", @@ -4374,21 +4579,28 @@ dependencies = [ "uuid", ] +[[package]] +name = "lance-tokenizer" +version = "14.0.0-beta.6" +dependencies = [ + "frostem", + "icu_segmenter", + "jieba-rs", + "lindera", + "serde", + "stop-words", + "unicode-normalization", +] + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.9", ] -[[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" @@ -4446,50 +4658,20 @@ dependencies = [ "lexical-util", ] -[[package]] -name = "libbz2-rs-sys" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" - [[package]] name = "libc" -version = "0.2.180" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" - -[[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" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a599cb10a9cd92b1300debcef28da8f70b935ec937f44fcd1b70a7c986a11c5c" -dependencies = [ - "core2", - "hashbrown 0.16.1", - "rle-decode-fast", -] +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" -version = "0.7.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" dependencies = [ - "cfg-if", - "winapi", + "cfg-if 1.0.4", + "windows-link", ] [[package]] @@ -4500,167 +4682,99 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.12" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ - "bitflags 2.10.0", "libc", - "redox_syscall 0.7.0", +] + +[[package]] +name = "libsais-rs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40fe164dbd47ea0c20e78a121c980ef673326905f1d4fba55e3645a20ef6717f" +dependencies = [ + "rayon", ] [[package]] name = "lindera" -version = "0.44.1" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50aba4ef41052280722f2120f65606b9218e8718032a3c752b953c4d8091f02e" +checksum = "74cda79d7161e99b414e4d292ff673cc3f8d22f070d8be3b6185c033363a9216" dependencies = [ "anyhow", - "bincode", "byteorder", "csv", + "daachorse", "kanaria", - "lindera-cc-cedict", "lindera-dictionary", - "lindera-ipadic", - "lindera-ipadic-neologd", - "lindera-ko-dic", - "lindera-unidic", + "log", "once_cell", + "percent-encoding", "regex", "serde", "serde_json", - "serde_yaml", - "strum 0.27.2", - "strum_macros 0.27.2", + "serde_yaml_ng", + "strum", + "strum_macros", "unicode-blocks", "unicode-normalization", "unicode-segmentation", - "yada", -] - -[[package]] -name = "lindera-cc-cedict" -version = "0.44.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d77e7a0830fd60f23828ad914439997288c1d2cdd9e269be67f967c27b56350" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", + "url", ] [[package]] name = "lindera-dictionary" -version = "0.44.1" +version = "3.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "489cc70922782af3fd397c0e130846caefe1c15b27c2211aac8f88a9f4590aaf" +checksum = "e2385456ca9fe87c29072c5f156b52fdd5e28d5b5738ddfb3979501dbd736530" dependencies = [ "anyhow", - "bincode", "byteorder", "csv", + "daachorse", "derive_builder", - "encoding", "encoding_rs", "encoding_rs_io", - "flate2", "glob", "log", - "md5", "memmap2", + "num_cpus", "once_cell", - "rand 0.9.2", - "reqwest", + "regex", + "rkyv", "serde", - "tar", + "serde_json", + "strum", + "strum_macros", "thiserror 2.0.18", - "tokio", - "yada", ] [[package]] -name = "lindera-ipadic" -version = "0.44.1" +name = "link-section" +version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78870521431dfaf0f94ddd3484fa08367e9d354fc8c708572f2f00007225ddfa" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", -] +checksum = "c2b1dd6fe32e55c0fc0ea9493aa57459ca3cf4ff3c857c7d0302290150da6e4f" [[package]] -name = "lindera-ipadic-neologd" -version = "0.44.1" +name = "linktime-proc-macro" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abcb3dc3056e5c683e12c2c5e8d40076f7ecfd7bd46f5fc0e4ae9e58152b5d85" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "tokio", -] +checksum = "8c7b0a3383c2a1002d11349c92c85a666a5fb679e96c79d782cf0dbe557fd6ee" [[package]] -name = "lindera-ko-dic" -version = "0.44.1" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e99316158bab14f0256d912055521ca784f76c63e7460db8a74775c5dc1f8bc2" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "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" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52672945166c14276bbba25e4ec79d7e126db1b503c0a6aa07ffc0141ae15cfa" -dependencies = [ - "bincode", - "byteorder", - "lindera-dictionary", - "once_cell", - "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.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" +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" @@ -4673,9 +4787,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "loom" @@ -4683,7 +4797,7 @@ version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "generator", "scoped-tls", "tracing", @@ -4692,11 +4806,11 @@ dependencies = [ [[package]] name = "lru" -version = "0.12.5" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" dependencies = [ - "hashbrown 0.15.5", + "hashbrown 0.16.1", ] [[package]] @@ -4726,30 +4840,13 @@ dependencies = [ [[package]] name = "lz4_flex" -version = "0.11.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a" - -[[package]] -name = "lz4_flex" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab6473172471198271ff72e9379150e9dfd70d8e533e0752a27e515b48dd375e" +checksum = "7ef0d4ed8669f8f8826eb00dc878084aa8f253506c4fd5e8f58f5bce72ddb97e" dependencies = [ "twox-hash", ] -[[package]] -name = "lzma-sys" -version = "0.1.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fda04ab3764e6cde78b9974eec4f779acaba7c4e84b36eca3cf77c581b85d27" -dependencies = [ - "cc", - "libc", - "pkg-config", -] - [[package]] name = "matchers" version = "0.2.0" @@ -4780,40 +4877,34 @@ dependencies = [ [[package]] name = "md-5" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" dependencies = [ - "cfg-if", - "digest", + "cfg-if 1.0.4", + "digest 0.11.3", ] [[package]] -name = "md5" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae960838283323069879657ca3de837e9f7bbb4c7bf6ea7f1b290d5e9476d2e0" - -[[package]] -name = "measure_time" -version = "0.9.0" +name = "mea" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51c55d61e72fc3ab704396c5fa16f4c184db37978ae4e94ca8959693a235fc0e" +checksum = "2640d335e7273dacdcf51044026139b2e269c3bb0dfc3f8cb3496b85e3f6a42c" dependencies = [ - "log", + "slab", ] [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" [[package]] name = "memmap2" -version = "0.9.9" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -4827,6 +4918,36 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metrics" +version = "0.24.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89550ee9f79e88fef3119de263694973a8adb26c21d75322164fb8c493039fe2" +dependencies = [ + "portable-atomic", + "rapidhash", +] + +[[package]] +name = "metrics-util" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8496cc523d1f94c1385dd8f0f0c2c480b2b8aeccb5b7e4485ad6365523ae376" +dependencies = [ + "aho-corasick", + "crossbeam-epoch", + "crossbeam-utils", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "metrics", + "ordered-float 4.6.0", + "quanta", + "radix_trie", + "rand 0.9.5", + "rand_xoshiro", + "sketches-ddsketch", +] + [[package]] name = "mime" version = "0.3.17" @@ -4843,12 +4964,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" @@ -4861,26 +4976,30 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", - "wasi", + "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] [[package]] -name = "mock_instant" -version = "0.6.0" +name = "mixtrics" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" +checksum = "2c46b5adfb7a3ae4996d327a5bdc90e78fec025806dd312bdbe6f07a755e0ec9" +dependencies = [ + "itertools 0.15.0", + "parking_lot", +] [[package]] name = "moka" -version = "0.12.13" +version = "0.12.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4ac832c50ced444ef6be0767a008b02c106a909ba79d1d830501e94b96f6b7e" +checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" dependencies = [ "async-lock", "crossbeam-channel", @@ -4896,6 +5015,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + [[package]] name = "multimap" version = "0.10.1" @@ -4903,10 +5028,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" [[package]] -name = "murmurhash32" -version = "0.3.1" +name = "munge" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e17401f259eba956ca16491461b6e8f72913a0a114e39736ce404410f915a0c" +dependencies = [ + "munge_macro", +] + +[[package]] +name = "munge_macro" +version = "0.4.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" +checksum = "4568f25ccbd45ab5d5603dc34318c1ec56b117531781260002151b8530a9f931" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "ndarray" @@ -4924,13 +5063,24 @@ dependencies = [ ] [[package]] -name = "nom" -version = "7.1.3" +name = "nibble_vec" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +checksum = "77a5d83df9f36fe23f0c3648c6bbb8b0298bb5f1939c8f2704431371f4b84d43" dependencies = [ - "memchr", - "minimal-lexical", + "smallvec", +] + +[[package]] +name = "nix" +version = "0.31.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" +dependencies = [ + "bitflags 2.13.0", + "cfg-if 1.0.4", + "cfg_aliases", + "libc", ] [[package]] @@ -4942,6 +5092,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "ntapi" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3b335231dfd352ffb0f8017f3b6027a4917f7df785ea2143d8af2adc66980ae" +dependencies = [ + "winapi", +] + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -4972,7 +5131,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.5", + "rand 0.8.6", "smallvec", "zeroize", ] @@ -4988,9 +5147,9 @@ dependencies = [ [[package]] name = "num-conv" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" @@ -5034,9 +5193,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", @@ -5044,51 +5203,100 @@ 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", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] -name = "object" -version = "0.37.3" +name = "objc2-core-foundation" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "memchr", + "bitflags 2.13.0", +] + +[[package]] +name = "objc2-io-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" +dependencies = [ + "libc", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-system-configuration" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396" +dependencies = [ + "objc2-core-foundation", ] [[package]] name = "object_store" -version = "0.12.5" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "622acbc9100d3c10e2ee15804b0caa40e55c933d5aa53814cd520805b7958a49" +dependencies = [ + "async-trait", + "bytes", + "chrono", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", + "humantime", + "itertools 0.14.0", + "parking_lot", + "percent-encoding", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", +] + +[[package]] +name = "object_store" +version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfbfff40aeccab00ec8a910b57ca8ecf4319b335c542f2edcd19dd25a1e2a00" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" dependencies = [ "async-trait", - "base64", + "aws-lc-rs", + "base64 0.22.1", "bytes", "chrono", + "crc-fast", "form_urlencoded", - "futures", - "http 1.4.0", + "futures-channel", + "futures-core", + "futures-util", + "http 1.4.2", "http-body-util", "httparse", "humantime", - "hyper 1.8.1", - "itertools 0.14.0", + "hyper", + "itertools 0.15.0", "md-5", + "nix", "parking_lot", "percent-encoding", - "quick-xml 0.38.4", - "rand 0.9.2", - "reqwest", - "ring", - "rustls-pemfile", + "quick-xml", + "rand 0.10.1", + "reqwest 0.13.4", + "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", @@ -5099,19 +5307,21 @@ dependencies = [ "walkdir", "wasm-bindgen-futures", "web-time", + "windows-sys 0.61.2", ] [[package]] name = "object_store_opendal" -version = "0.55.0" +version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113ab0769e972eee585e57407b98de08bda5354fa28e8ba4d89038d6cb6a8991" +checksum = "c479bcc317f0ed98972b7184e0f9cc1bea83a07f60cdade96d7207a48ebfb779" dependencies = [ "async-trait", + "asyncband", "bytes", "chrono", "futures", - "object_store", + "object_store 0.14.1", "opendal", "pin-project", "tokio", @@ -5119,9 +5329,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" @@ -5137,145 +5347,407 @@ checksum = "269bca4c2591a28585d6bf10d9ed0332b7d76900a1b02bec41bdc3a2cdcda107" [[package]] name = "opendal" -version = "0.55.0" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fe43e16d96bed57937eb7c4a28559bf4c5fc4a3951656505d1fdd4f856349b4" +dependencies = [ + "ctor", + "opendal-core", + "opendal-http-transport-reqwest", + "opendal-layer-concurrent-limit", + "opendal-layer-logging", + "opendal-layer-retry", + "opendal-layer-timeout", + "opendal-service-azblob", + "opendal-service-azdls", + "opendal-service-cos", + "opendal-service-gcs", + "opendal-service-goosefs", + "opendal-service-hf", + "opendal-service-oss", + "opendal-service-s3", + "opendal-service-tos", +] + +[[package]] +name = "opendal-core" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d075ab8a203a6ab4bc1bce0a4b9fe486a72bf8b939037f4b78d95386384bc80a" +checksum = "de4566a412776e3f65d53dd9fceced5b432a0a9c9a1e8d50ccf58823795e5c5b" dependencies = [ "anyhow", - "backon", - "base64", + "asyncband", + "base64 0.23.0", "bytes", - "crc32c", "futures", - "getrandom 0.2.17", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", "jiff", "log", "md-5", "percent-encoding", - "quick-xml 0.38.4", - "reqsign", - "reqwest", + "quick-xml", + "reqsign-core", "serde", "serde_json", - "sha2", "tokio", "url", "uuid", + "web-time", ] [[package]] -name = "openssl-probe" -version = "0.2.1" +name = "opendal-http-transport-reqwest" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +checksum = "f772ce6c137ab43647726116d505a649a88d41928fcd813438eb30b02312bcbb" +dependencies = [ + "bytes", + "futures", + "http 1.4.2", + "http-body 1.0.1", + "opendal-core", + "reqwest 0.13.4", +] [[package]] -name = "option-ext" -version = "0.2.0" +name = "opendal-layer-concurrent-limit" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "3734c648617e5ad724d49175dde8323550624f9614f50e9e81b7aafae30b1734" +dependencies = [ + "asyncband", + "futures", + "http 1.4.2", + "opendal-core", +] [[package]] -name = "ordered-float" -version = "2.10.1" +name = "opendal-layer-logging" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +checksum = "0a8daab5a91eac76f94516e7c71e677195c9221840ce8f5e71815e3fc8207742" dependencies = [ - "num-traits", + "log", + "opendal-core", ] [[package]] -name = "ordered-float" -version = "5.1.0" +name = "opendal-layer-retry" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4779c6901a562440c3786d08192c6fbda7c1c2060edd10006b05ee35d10f2d" +checksum = "eb8e22cb1f86ef9af771a8947d3ccf380c8f5b76236f628314dd41c9ba73331a" dependencies = [ - "num-traits", + "backon", + "log", + "opendal-core", ] [[package]] -name = "ordered-multimap" -version = "0.7.3" +name = "opendal-layer-timeout" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +checksum = "08ac6c77fe0d1b18e0064a02c19b98dbebcab78bf5e7753ad1f3abbc18850ff4" dependencies = [ - "dlv-list", - "hashbrown 0.14.5", + "opendal-core", + "tokio", ] [[package]] -name = "outref" -version = "0.5.2" +name = "opendal-service-azblob" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +checksum = "c6cf31b02e7b44191d97c07254eea109b5da9c17210a29e478c8d0dc26dffb1f" +dependencies = [ + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "sha2 0.11.0", + "uuid", +] [[package]] -name = "ownedbytes" -version = "0.9.0" +name = "opendal-service-azdls" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fbd56f7631767e61784dc43f8580f403f4475bd4aaa4da003e6295e1bab4a7e" +checksum = "354f4a8d26f0fb7639f5c4ad3a924d7c75625830f69550b8c807c080ea0df8bc" dependencies = [ - "stable_deref_trait", + "asyncband", + "base64 0.23.0", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "opendal-service-azure-common", + "quick-xml", + "reqsign-azure-storage", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "serde_json", ] [[package]] -name = "parking" -version = "2.2.1" +name = "opendal-service-azure-common" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +checksum = "8107324c3f3cb9970fe20526c2c341b7c5769acbd4699bab73bd525f5fd63d62" +dependencies = [ + "http 1.4.2", + "opendal-core", +] [[package]] -name = "parking_lot" -version = "0.12.5" +name = "opendal-service-cos" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +checksum = "feb737e6609b4fd42e4773b7b2d8d05031b63cd0df46c7ba68ddd64bae1dd3f3" dependencies = [ - "lock_api", - "parking_lot_core", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-tencent-cos", + "serde", ] [[package]] -name = "parking_lot_core" -version = "0.9.12" +name = "opendal-service-gcs" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +checksum = "3d80eca6f9227bdf65d43e5b8d5fe75dca8cfab1da07571442a0420400847bea" dependencies = [ - "cfg-if", - "libc", - "redox_syscall 0.5.18", - "smallvec", - "windows-link", + "async-trait", + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-google", + "serde", + "serde_json", + "tokio", + "uuid", ] [[package]] -name = "parquet" -version = "57.2.0" +name = "opendal-service-goosefs" +version = "0.59.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f6a2926a30477c0b95fea6c28c3072712b139337a242c2cc64817bdc20a8854" +checksum = "2134aca0ef64dbff844cb1e608d28833f2a1b34b884e7d6b307416e966694e9f" dependencies = [ - "ahash", - "arrow-array", + "asyncband", + "bytes", + "goosefs-sdk", + "log", + "opendal-core", + "serde", +] + +[[package]] +name = "opendal-service-hf" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0cba627efaee637746804b74bc2c2b2b207fe214307e1e2ba31b49aad708a3" +dependencies = [ + "asyncband", + "bytes", + "hf-xet", + "http 1.4.2", + "log", + "opendal-core", + "percent-encoding", + "serde", + "serde_json", +] + +[[package]] +name = "opendal-service-oss" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0ebfa355b1d4edb0e5b2a5b86d8de9f811fae1d7baca6d65246b230b2afc0bd" +dependencies = [ + "bytes", + "http 1.4.2", + "log", + "opendal-core", + "quick-xml", + "reqsign-aliyun-oss", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", +] + +[[package]] +name = "opendal-service-s3" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2942f2d8d3d4953c0e8d07cd1244879628d084948e48ce6b6d808c5938f7858d" +dependencies = [ + "base64 0.23.0", + "bytes", + "crc-fast", + "http 1.4.2", + "log", + "md-5", + "opendal-core", + "quick-xml", + "reqsign-aws-v4", + "reqsign-core", + "reqsign-file-read-tokio", + "serde", + "url", +] + +[[package]] +name = "opendal-service-tos" +version = "0.59.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89f4ee342d2e20a8fc109f854fd8cbaeab9682b3109b7efc8682fa321b5352e2" +dependencies = [ + "bytes", + "http 1.4.2", + "opendal-core", + "quick-xml", + "reqsign-core", + "reqsign-file-read-tokio", + "reqsign-volcengine-tos", + "serde", + "serde_json", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-float" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "os_str_bytes" +version = "6.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2355d85b9a3786f481747ced0e0ff2ba35213a1f9bd406ed906554d7af805a1" +dependencies = [ + "memchr", +] + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if 1.0.4", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "parquet" +version = "58.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d298093b2dec60289dce0684c986d0f7679e9dd15771c2c65406e1aaf604a704" +dependencies = [ + "ahash", + "arrow-array", "arrow-buffer", - "arrow-cast", "arrow-data", "arrow-ipc", "arrow-schema", "arrow-select", - "base64", + "base64 0.22.1", "brotli", "bytes", "chrono", "flate2", "futures", "half", - "hashbrown 0.16.1", - "lz4_flex 0.12.0", + "hashbrown 0.17.1", + "lz4_flex", "num-bigint", "num-integer", "num-traits", - "object_store", + "object_store 0.13.2", "paste", "seq-macro", "simdutf8", @@ -5310,7 +5782,7 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "898bac3fa00d0ba57a4e8289837e965baa2dee8c3749f3b11d45a64b4223d9c3" dependencies = [ - "base64", + "base64 0.22.1", "serde", ] @@ -5347,8 +5819,8 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" dependencies = [ - "digest", - "hmac", + "digest 0.10.7", + "hmac 0.12.1", ] [[package]] @@ -5357,7 +5829,7 @@ version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64", + "base64 0.22.1", "serde_core", ] @@ -5390,7 +5862,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset", "hashbrown 0.15.5", - "indexmap", + "indexmap 2.14.0", "serde", ] @@ -5453,29 +5925,29 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.10" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "pin-project-lite" -version = "0.2.16" +version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "pin-utils" @@ -5505,7 +5977,7 @@ dependencies = [ "der", "pbkdf2", "scrypt", - "sha2", + "sha2 0.10.9", "spki", ] @@ -5523,9 +5995,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" @@ -5535,19 +6007,21 @@ checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" [[package]] name = "portable-atomic-util" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9db96d7fa8782dd8c15ce32ffe8680bbd1e978a43bf51a34d39483540495f5" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" dependencies = [ "portable-atomic", ] [[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 = [ + "serde_core", + "writeable", "zerovec", ] @@ -5573,42 +6047,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.114", + "syn 2.0.118", ] [[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", ] -[[package]] -name = "proc-macro-error" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" -dependencies = [ - "proc-macro-error-attr", - "proc-macro2", - "quote", - "syn 1.0.109", - "version_check", -] - -[[package]] -name = "proc-macro-error-attr" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -5620,9 +6070,9 @@ dependencies = [ [[package]] name = "prost" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" dependencies = [ "bytes", "prost-derive", @@ -5630,9 +6080,9 @@ dependencies = [ [[package]] name = "prost-build" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "343d3bd7056eda839b03204e68deff7d1b13aba7af2b2fd16890697274262ee7" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ "heck", "itertools 0.14.0", @@ -5643,48 +6093,60 @@ dependencies = [ "prost", "prost-types", "regex", - "syn 2.0.114", + "syn 2.0.118", "tempfile", ] [[package]] name = "prost-derive" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" dependencies = [ "anyhow", "itertools 0.14.0", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "prost-types" -version = "0.14.3" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8991c4cbdb8bc5b11f0b074ffe286c30e523de90fee5ba8132f1399f23cb3dd7" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" dependencies = [ "prost", ] [[package]] -name = "psm" -version = "0.1.29" +name = "ptr_meta" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa96cb91275ed31d6da3e983447320c4eb219ac180fa1679a0889ff32861e2d" +checksum = "0b9a0cf95a1196af61d4f1cbdab967179516d9a4a4312af1f31948f8f6224a79" dependencies = [ - "ar_archive_writer", - "cc", + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7347867d0a7e1208d93b46767be83e2b8f978c3dad35f775ac8d8847551d6fe1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", ] [[package]] name = "pylance" -version = "3.0.0-beta.2" +version = "14.0.0-beta.6" dependencies = [ + "alloc-stdlib", "arrow", "arrow-array", + "arrow-cast", "arrow-data", "arrow-schema", "async-trait", @@ -5711,17 +6173,17 @@ dependencies = [ "lance-table", "libc", "log", - "object_store", + "metrics", + "metrics-util", + "object_store 0.14.1", "prost", "prost-types", "pyo3", "pythonize", - "regex", "roaring", "serde", "serde_json", "serde_yaml", - "snafu", "tokio", "tracing", "tracing-chrome", @@ -5732,36 +6194,33 @@ dependencies = [ [[package]] name = "pyo3" -version = "0.26.0" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ba0117f4212101ee6544044dae45abe1083d30ce7b29c4b5cbdfa2354e07383" +checksum = "91fd8e38a3b50ed1167fb981cd6fd60147e091784c427b8f7183a7ee32c31c12" dependencies = [ "chrono", - "indoc", "libc", - "memoffset", "once_cell", "portable-atomic", "pyo3-build-config", "pyo3-ffi", "pyo3-macros", - "unindent", ] [[package]] name = "pyo3-build-config" -version = "0.26.0" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fc6ddaf24947d12a9aa31ac65431fb1b851b8f4365426e182901eabfb87df5f" +checksum = "e368e7ddfdeb98c9bca7f8383be1648fd84ab466bf2bc015e94008db6d35611e" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.26.0" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "025474d3928738efb38ac36d4744a74a400c901c7596199e20e45d98eb194105" +checksum = "7f29e10af80b1f7ccaf7f69eace800a03ecd13e883acfacc1e5d0988605f651e" dependencies = [ "libc", "pyo3-build-config", @@ -5769,59 +6228,76 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.26.0" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e64eb489f22fe1c95911b77c44cc41e7c19f3082fc81cce90f657cdc42ffded" +checksum = "df6e520eff47c45997d2fc7dd8214b25dd1310918bbb2642156ef66a67f29813" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "pyo3-macros-backend" -version = "0.26.0" +version = "0.28.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "100246c0ecf400b475341b8455a9213344569af29a3c841d29270e53102e0fcf" +checksum = "c4cdc218d835738f81c2338f822078af45b4afdf8b2e33cbb5916f108b813acb" dependencies = [ "heck", "proc-macro2", "pyo3-build-config", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "pythonize" -version = "0.26.0" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11e06e4cff9be2bbf2bddf28a486ae619172ea57e79787f856572878c62dcfe2" +checksum = "0b79f670c9626c8b651c0581011b57b6ba6970bb69faf01a7c4c0cfc81c43f95" dependencies = [ "pyo3", "serde", ] [[package]] -name = "quick-xml" -version = "0.37.5" +name = "quanta" +version = "0.12.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" dependencies = [ - "memchr", - "serde", + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.1+wasi-snapshot-preview1", + "web-sys", + "winapi", ] [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", "serde", ] +[[package]] +name = "quick_cache" +version = "0.6.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9c6658afe513a3b484e3abfdaa0d03ef3c0bbf017542c178dd55f94eb3051f9" +dependencies = [ + "ahash", + "equivalent", + "hashbrown 0.16.1", + "parking_lot", +] + [[package]] name = "quinn" version = "0.11.9" @@ -5834,8 +6310,8 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.36", - "socket2 0.6.2", + "rustls", + "socket2", "thiserror 2.0.18", "tokio", "tracing", @@ -5844,17 +6320,19 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ + "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.2", + "rand 0.10.1", + "rand_pcg", "ring", "rustc-hash", - "rustls 0.23.36", + "rustls", "rustls-pki-types", "slab", "thiserror 2.0.18", @@ -5872,16 +6350,16 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2", "tracing", "windows-sys 0.60.2", ] [[package]] name = "quote" -version = "1.0.44" +version = "1.0.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b2ebcf727b7760c461f091f9f0f539b77b8e87f2fd88131e7f1b433b3cece4" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" dependencies = [ "proc-macro2", ] @@ -5892,33 +6370,68 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "radium" version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" +[[package]] +name = "radix_trie" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c069c179fcdc6a2fe24d8d18305cf085fdbd4f922c041943e203685d6a1c58fd" +dependencies = [ + "endian-type", + "nibble_vec", +] + +[[package]] +name = "rancor" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a063ea72381527c2a0561da9c80000ef822bdd7c3241b1cc1b12100e3df081ee" +dependencies = [ + "ptr_meta", +] + [[package]] name = "rand" -version = "0.8.5" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" dependencies = [ - "libc", "rand_chacha 0.3.1", "rand_core 0.6.4", ] [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -5958,14 +6471,10 @@ dependencies = [ ] [[package]] -name = "rand_distr" -version = "0.4.3" +name = "rand_core" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand 0.8.5", -] +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" [[package]] name = "rand_distr" @@ -5974,29 +6483,25 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8615d50dcf34fa31f7ab52692afec947c4dd0ab803cc87cb3b0b4570ff7463" dependencies = [ "num-traits", - "rand 0.9.2", + "rand 0.9.5", ] [[package]] -name = "rand_xoshiro" -version = "0.7.0" +name = "rand_pcg" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" dependencies = [ - "rand_core 0.9.5", + "rand_core 0.10.1", ] [[package]] -name = "random_word" -version = "0.5.2" +name = "rand_xoshiro" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e47a395bdb55442b883c89062d6bcff25dc90fa5f8369af81e0ac6d49d78cf81" +checksum = "f703f4665700daf5512dcca5f43afa6af89f09db47fb56be587f80636bda2d41" dependencies = [ - "ahash", - "brotli", - "paste", - "rand 0.9.2", - "unicase", + "rand_core 0.9.5", ] [[package]] @@ -6006,67 +6511,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "973443cf09a9c8656b574a866ab68dfa19f0867d0340648c7d2f6a71b8a8ea68" [[package]] -name = "rawpointer" -version = "0.2.1" +name = "rapidhash" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" +checksum = "32b266a82f4aa99bb5c25e28d11cc44ace63d91adbcbcee4d323e2ae3d49ef37" +dependencies = [ + "rustversion", +] [[package]] -name = "rayon" -version = "1.11.0" +name = "raw-cpuid" +version = "11.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" dependencies = [ - "either", - "rayon-core", + "bitflags 2.13.0", ] [[package]] -name = "rayon-core" -version = "1.13.0" +name = "rawpointer" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" [[package]] -name = "recursive" -version = "0.1.1" +name = "rayon" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0786a43debb760f491b1bc0269fe5e84155353c67482b9e60d0cfb596054b43e" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" dependencies = [ - "recursive-proc-macro-impl", - "stacker", + "either", + "rayon-core", ] [[package]] -name = "recursive-proc-macro-impl" -version = "0.1.1" +name = "rayon-core" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76009fbe0614077fc1a2ce255e3a1881a2e3a3527097d5dc6d8212c585e7e38b" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" dependencies = [ - "quote", - "syn 2.0.114", + "crossbeam-deque", + "crossbeam-utils", ] [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "redb" +version = "3.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "4ba239c1c1693315d3cc0e601db3b3965543afbf48c41730fdca2f069f510f4a" dependencies = [ - "bitflags 2.10.0", + "libc", ] [[package]] name = "redox_syscall" -version = "0.7.0" +version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", ] [[package]] @@ -6080,11 +6583,31 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "regex" -version = "1.12.3" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f1292b7759ae1cb9ec195452d1390a074f0cd8541ab7a5a8c31cd6db45d4a6ba" dependencies = [ "aho-corasick", "memchr", @@ -6111,9 +6634,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.9" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "regress" @@ -6126,69 +6649,187 @@ dependencies = [ ] [[package]] -name = "relative-path" -version = "1.9.3" +name = "rend" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba39f3699c378cd8970968dcbff9c43159ea4cfbd88d43c00b22f2ef10a435d2" +checksum = "cadadef317c2f20755a64d7fdc48f9e7178ee6b0e1f7fce33fa60f1d68a276e6" +dependencies = [ + "bytecheck", +] [[package]] -name = "repr_offset" -version = "0.2.2" +name = "reqsign-aliyun-oss" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c0f9f69a519dd6958c4b43606bb8e1278cdc76d611fc8fed4b796eee548dc0f" +dependencies = [ + "anyhow", + "form_urlencoded", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-aws-core" +version = "3.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bac4749b7dfa7bfaccd01eb03e9dc795ed37e3f20d6f0f38e2c67ee85ad6bc86" +dependencies = [ + "bytes", + "form_urlencoded", + "hex", + "http 1.4.2", + "log", + "percent-encoding", + "quick-xml", + "reqsign-core", + "rust-ini", + "serde", + "serde_json", + "serde_urlencoded", + "sha1", +] + +[[package]] +name = "reqsign-aws-v4" +version = "3.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb1070755bd29dffc19d0971cab794e607839ba2ef4b69a9e6fbc8733c1b72ea" +checksum = "ff250f0fd0b913fbd565e405acc553da0f13bde30bfb5403178c9d0313cdc15f" dependencies = [ - "tstr", + "bytes", + "http 1.4.2", + "log", + "quick-xml", + "reqsign-aws-core", + "reqsign-core", + "serde", ] [[package]] -name = "reqsign" -version = "0.16.5" +name = "reqsign-azure-storage" +version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43451dbf3590a7590684c25fb8d12ecdcc90ed3ac123433e500447c7d77ed701" +checksum = "a6ebd8524185ce9c64063e3095f83968acfa90922f00c601a4a0f3aca15b077e" dependencies = [ "anyhow", - "async-trait", - "base64", - "chrono", + "base64 0.22.1", + "bytes", "form_urlencoded", - "getrandom 0.2.17", + "http 1.4.2", + "log", + "pem", + "percent-encoding", + "reqsign-core", + "rsa", + "serde", + "serde_json", + "sha1", +] + +[[package]] +name = "reqsign-core" +version = "3.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff052daffb0599681c50f85c59e7236438976efe991ab864edd9f3b235501a0f" +dependencies = [ + "anyhow", + "base64 0.23.0", + "bytes", + "futures", "hex", - "hmac", - "home", - "http 1.4.0", - "jsonwebtoken", + "hmac 0.13.0", + "http 1.4.2", + "jiff", "log", - "once_cell", + "mea", "percent-encoding", - "quick-xml 0.37.5", - "rand 0.8.5", - "reqwest", "rsa", - "rust-ini", "serde", "serde_json", "sha1", - "sha2", + "sha2 0.11.0", + "windows-sys 0.61.2", +] + +[[package]] +name = "reqsign-file-read-tokio" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3235df90a6bca681aa47dd86f2393d122a6d77042aa8a7c81e218cd45c5bfc0" +dependencies = [ + "anyhow", + "reqsign-core", "tokio", ] +[[package]] +name = "reqsign-google" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a96da0b579b846d358090cb06b9e3c2ad1375529efbe3e0c45f96bd7bcf043ea" +dependencies = [ + "form_urlencoded", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-aws-v4", + "reqsign-core", + "rsa", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "reqsign-tencent-cos" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6497dd9f6e3d1349b420521484099b284f95e8d3a65f088fccef42493a7b644" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", + "serde", + "serde_json", +] + +[[package]] +name = "reqsign-volcengine-tos" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7e7a3a0635e0aa9ef851577c63cba5b3c98fb584f6a6637c2fd5cc50702c99" +dependencies = [ + "anyhow", + "http 1.4.2", + "log", + "percent-encoding", + "reqsign-core", +] + [[package]] name = "reqwest" version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "encoding_rs", "futures-core", "futures-util", - "h2 0.4.13", - "http 1.4.0", + "h2", + "http 1.4.2", "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", @@ -6197,7 +6838,7 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls 0.23.36", + "rustls", "rustls-native-certs", "rustls-pki-types", "serde", @@ -6205,17 +6846,71 @@ dependencies = [ "serde_urlencoded", "sync_wrapper", "tokio", - "tokio-rustls 0.26.4", + "tokio-rustls", "tokio-util", "tower", - "tower-http 0.6.8", + "tower-http 0.6.11", "tower-service", "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams", + "wasm-streams 0.4.2", "web-sys", - "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "h2", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tokio-util", + "tower", + "tower-http 0.6.11", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams 0.5.0", + "web-sys", +] + +[[package]] +name = "reqwest-middleware" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bc3f1384cffa4f274dad2d4ddd73aed32fed8f786d96c6be8aa4e5fd3c3b58" +dependencies = [ + "anyhow", + "async-trait", + "http 1.4.2", + "reqwest 0.13.4", + "thiserror 2.0.18", + "tower-service", ] [[package]] @@ -6225,7 +6920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" dependencies = [ "cc", - "cfg-if", + "cfg-if 1.0.4", "getrandom 0.2.17", "libc", "untrusted", @@ -6233,16 +6928,40 @@ dependencies = [ ] [[package]] -name = "rle-decode-fast" -version = "1.0.3" +name = "rkyv" +version = "0.8.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "815cc8a37159a463064825246cadb07961e25cd9885908606f6d08a98d8f8874" +dependencies = [ + "bytecheck", + "bytes", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "munge", + "ptr_meta", + "rancor", + "rend", + "rkyv_derive", + "tinyvec", + "uuid", +] + +[[package]] +name = "rkyv_derive" +version = "0.8.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3582f63211428f83597b51b2ddb88e2a91a9d52d12831f9d08f5e624e8977422" +checksum = "c0ed1a78a1b19d184b0daa629dd9a024573173ec7d485b287cb369fb3607cc1c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] [[package]] name = "roaring" -version = "0.10.12" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19e8d2cfa184d94d0726d650a9f4a1be7f9b76ac9fdb954219878dc00c1c1e7b" +checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" dependencies = [ "bytemuck", "byteorder", @@ -6260,15 +6979,15 @@ version = "0.9.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" dependencies = [ - "const-oid", - "digest", + "const-oid 0.9.6", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature", "spki", "subtle", @@ -6286,60 +7005,21 @@ dependencies = [ "smallvec", ] -[[package]] -name = "rstest" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5a3193c063baaa2a95a33f03035c8a72b83d97a54916055ba22d35ed3839d49" -dependencies = [ - "futures-timer", - "futures-util", - "rstest_macros", -] - -[[package]] -name = "rstest_macros" -version = "0.26.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c845311f0ff7951c5506121a9ad75aec44d083c31583b2ea5a30bcb0b0abba0" -dependencies = [ - "cfg-if", - "glob", - "proc-macro-crate", - "proc-macro2", - "quote", - "regex", - "relative-path", - "rustc_version", - "syn 2.0.114", - "unicode-ident", -] - [[package]] name = "rust-ini" version = "0.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "ordered-multimap", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-hash" -version = "2.1.1" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -6352,62 +7032,37 @@ dependencies = [ [[package]] name = "rustix" -version = "0.38.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" -dependencies = [ - "bitflags 2.10.0", - "errno", - "libc", - "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", -] - -[[package]] -name = "rustix" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "errno", "libc", - "linux-raw-sys 0.11.0", + "linux-raw-sys", "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.36" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", - "rustls-webpki 0.103.9", + "rustls-webpki", "subtle", "zeroize", ] [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -6415,40 +7070,48 @@ dependencies = [ "security-framework", ] -[[package]] -name = "rustls-pemfile" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "rustls-pki-types" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", ] [[package]] -name = "rustls-webpki" -version = "0.101.7" +name = "rustls-platform-verifier" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "ring", - "untrusted", + "core-foundation 0.10.1", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.61.2", ] +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -6464,9 +7127,15 @@ checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" [[package]] name = "ryu" -version = "1.0.22" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "safe-transmute" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984" +checksum = "3944826ff8fa8093089aba3acb4ef44b9446a99a16f3bf4e74af3f77d340ab7d" [[package]] name = "salsa20" @@ -6488,9 +7157,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", ] @@ -6507,6 +7176,30 @@ dependencies = [ "serde_json", ] +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + [[package]] name = "schemars_derive" version = "0.8.22" @@ -6516,7 +7209,7 @@ dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6539,27 +7232,17 @@ checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" dependencies = [ "pbkdf2", "salsa20", - "sha2", -] - -[[package]] -name = "sct" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" -dependencies = [ - "ring", - "untrusted", + "sha2 0.10.9", ] [[package]] name = "security-framework" -version = "3.5.1" +version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3297343eaf830f66ede390ea39da1d462b6b0c1b000f420d0a83f898bbbe6ef" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.10.0", - "core-foundation", + "bitflags 2.13.0", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6567,9 +7250,9 @@ dependencies = [ [[package]] name = "security-framework-sys" -version = "2.15.0" +version = "2.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" dependencies = [ "core-foundation-sys", "libc", @@ -6577,9 +7260,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", @@ -6593,9 +7276,9 @@ checksum = "1bc711410fbe7399f390ca1c3b60ad0f53f80e95c5eb935e52268a0e2cd49acc" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6603,22 +7286,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 3.0.2", ] [[package]] @@ -6629,15 +7312,16 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ + "indexmap 2.14.0", "itoa", "memchr", "serde", @@ -6664,19 +7348,19 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "serde_tokenstream" -version = "0.2.2" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64060d864397305347a78851c51588fd283767e7e7589829e8121d65512340f1" +checksum = "d7c49585c52c01f13c5c2ebb333f14f6885d76daa768d8a037d28017ec538c69" dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6691,13 +7375,58 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_with" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +dependencies = [ + "darling 0.23.0", + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "serde_yaml" version = "0.9.34+deprecated" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" dependencies = [ - "indexmap", + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap 2.14.0", "itoa", "ryu", "serde", @@ -6706,13 +7435,13 @@ dependencies = [ [[package]] name = "sha1" -version = "0.10.6" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] [[package]] @@ -6721,11 +7450,28 @@ version = "0.10.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ - "cfg-if", - "cpufeatures", - "digest", + "cfg-if 1.0.4", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if 1.0.4", + "cpufeatures 0.3.0", + "digest 0.11.3", ] +[[package]] +name = "sha2-const-stable" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f179d4e11094a893b82fff208f74d448a7512f99f5a0acbd5c679b705f83ed9" + [[package]] name = "sharded-slab" version = "0.1.7" @@ -6737,18 +7483,20 @@ dependencies = [ [[package]] name = "shellexpand" -version = "3.1.1" +version = "3.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb" +checksum = "32824fab5e16e6c4d86dc1ba84489390419a39f97699852b66480bb87d297ed8" dependencies = [ + "bstr", "dirs", + "os_str_bytes", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -6766,48 +7514,43 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] [[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" -version = "0.1.5" +name = "simd_cesu8" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] [[package]] -name = "simple_asn1" -version = "0.6.3" +name = "simdutf8" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "297f631f50729c8c99b84667867963997ec0b50f32b2a7dbcab828ef0541e8bb" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "sketches-ddsketch" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1e9a774a6c28142ac54bb25d25562e6bcf957493a184f15ad4eebccb23e410a" -dependencies = [ - "serde", -] +checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "slab" @@ -6817,29 +7560,29 @@ checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "snafu" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e84b3f4eacbf3a1ce05eac6763b4d629d60cbc94d632e4092c54ade71f1e1a2" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" dependencies = [ "snafu-derive", ] [[package]] name = "snafu-derive" -version = "0.8.9" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6850,31 +7593,21 @@ 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.2" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[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", @@ -6882,9 +7615,15 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" + +[[package]] +name = "spin" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" [[package]] name = "spki" @@ -6898,24 +7637,57 @@ dependencies = [ [[package]] name = "sqlparser" -version = "0.59.0" +version = "0.62.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4591acadbcf52f0af60eafbb2c003232b2b4cd8de5f0e9437cb8b1b59046cc0f" +checksum = "13c6d1b651dc4edf07eead2a0c6c78016ce971bc2c10da5266861b13f25e7cec" dependencies = [ "log", - "recursive", "sqlparser_derive", ] [[package]] name = "sqlparser_derive" -version = "0.3.0" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6dd45d8fc1c79299bfbb7190e42ccbbdf6a5f52e4a6ad98d92357ea965bd289" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + +[[package]] +name = "stabby" +version = "72.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7b834ec7ced12095fea1e4b07dcb7e8cf2b59b18afa3eac52494d835965a5ec" +dependencies = [ + "rustversion", + "stabby-abi", +] + +[[package]] +name = "stabby-abi" +version = "72.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff1a4f477858a5bdf927c9fab7f579899de9b13e39f8b3b3b300c89fbab632f4" +dependencies = [ + "rustc_version", + "rustversion", + "sha2-const-stable", + "stabby-macros", +] + +[[package]] +name = "stabby-macros" +version = "72.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da5fc6819faabb412da764b99d3b713bb55083c11e7e0c00144d386cd6a1939c" +checksum = "b31c4b2434980b67ad83f300a58088ba14d59454dcd79ba3d87419bbd924d31e" dependencies = [ + "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -6925,16 +7697,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" [[package]] -name = "stacker" -version = "0.1.22" +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "statrs" +version = "0.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1f8b29fb42aafcea4edeeb6b2f2d7ecd0d969c48b4cf0d2e64aafc471dd6e59" +checksum = "2a3fe7c28c6512e766b0874335db33c94ad7b8f9054228ae1c2abd47ce7d335e" dependencies = [ - "cc", - "cfg-if", - "libc", - "psm", - "windows-sys 0.59.0", + "approx", + "num-traits", ] [[package]] @@ -6950,61 +7725,49 @@ 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" +name = "stop-words" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +checksum = "d68df56303396bcfb639455b3c166804aeb7994005010aab5e9e8a1277b8871d" dependencies = [ - "strum_macros 0.26.4", + "serde_json", ] [[package]] -name = "strum" -version = "0.27.2" +name = "strsim" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf" -dependencies = [ - "strum_macros 0.27.2", -] +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "strum_macros" -version = "0.26.4" +name = "strum" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd" dependencies = [ - "heck", - "proc-macro2", - "quote", - "rustversion", - "syn 2.0.114", + "strum_macros", ] [[package]] name = "strum_macros" -version = "0.27.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7695ce3845ea4b33927c055a39dc438a45b059f7c1b3d91d38d10355fb8cbca7" +checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "substrait" -version = "0.62.2" +version = "0.63.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62fc4b483a129b9772ccb9c3f7945a472112fdd9140da87f8a4e7f1d44e045d0" +checksum = "e620ff4d5c02fd6f7752931aa74b16a26af66a63022cc1ad412c77edbe0bab47" dependencies = [ "heck", + "indexmap 2.14.0", "pbjson", "pbjson-build", "pbjson-types", @@ -7013,12 +7776,12 @@ dependencies = [ "prost-build", "prost-types", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_yaml", - "syn 2.0.114", + "syn 2.0.118", "typify", "walkdir", ] @@ -7029,11 +7792,17 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" -version = "1.0.109" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -7042,9 +7811,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.114" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a" +checksum = "a207d6d6a2b7fc470b80443726053f18a2481b7e1eee970597051596567987a3" dependencies = [ "proc-macro2", "quote", @@ -7068,160 +7837,49 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", -] - -[[package]] -name = "tagptr" -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.5", - "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", + "syn 2.0.118", ] [[package]] -name = "tantivy-query-grammar" -version = "0.24.0" +name = "sysinfo" +version = "0.38.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e810cdeeebca57fc3f7bfec5f85fdbea9031b2ac9b990eb5ff49b371d52bbe6a" +checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" dependencies = [ - "nom 7.1.3", - "serde", - "serde_json", + "libc", + "memchr", + "ntapi", + "objc2-core-foundation", + "objc2-io-kit", + "windows", ] [[package]] -name = "tantivy-sstable" -version = "0.5.0" +name = "system-configuration" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "709f22c08a4c90e1b36711c1c6cad5ae21b20b093e535b69b18783dd2cb99416" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "futures-util", - "itertools 0.14.0", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", + "bitflags 2.13.0", + "core-foundation 0.9.4", + "system-configuration-sys", ] [[package]] -name = "tantivy-stacker" -version = "0.5.0" +name = "system-configuration-sys" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bcdebb267671311d1e8891fd9d1301803fdb8ad21ba22e0a30d0cab49ba59c1" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" dependencies = [ - "murmurhash32", - "rand_distr 0.4.3", - "tantivy-common", + "core-foundation-sys", + "libc", ] [[package]] -name = "tantivy-tokenizer-api" -version = "0.5.0" +name = "tagptr" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfa942fcee81e213e09715bbce8734ae2180070b97b33839a795ba1de201547d" -dependencies = [ - "serde", -] +checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] name = "tap" @@ -7229,33 +7887,22 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[package]] -name = "tar" -version = "0.4.44" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" -dependencies = [ - "filetime", - "libc", - "xattr", -] - [[package]] name = "target-lexicon" -version = "0.13.4" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1dd07eb858a2067e2f3c7155d54e929265c264e6f37efe3ee7a8d1b5a1dd0ba" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" -version = "3.24.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix 1.1.3", + "rustix", "windows-sys 0.61.2", ] @@ -7285,7 +7932,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -7296,7 +7943,7 @@ checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -7314,7 +7961,7 @@ version = "1.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", ] [[package]] @@ -7370,19 +8017,20 @@ 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", + "serde_core", "zerovec", ] [[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", ] @@ -7395,9 +8043,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -7405,29 +8053,30 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2", "tokio-macros", "windows-sys 0.61.2", ] [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] -name = "tokio-rustls" -version = "0.24.1" +name = "tokio-retry" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +checksum = "4a129d95275ebf4c493ec53bf0f8cd95f5ac161bc4f381700809a54f595d4470" dependencies = [ - "rustls 0.21.12", + "pin-project-lite", + "rand 0.10.1", "tokio", ] @@ -7437,62 +8086,125 @@ version = "0.26.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" dependencies = [ - "rustls 0.23.36", + "rustls", "tokio", ] [[package]] name = "tokio-stream" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", "futures-sink", + "libc", "pin-project-lite", "tokio", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.118", +] + [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ - "indexmap", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", ] [[package]] -name = "toml_parser" -version = "1.0.6+spec-1.1.0" +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bytes", + "http 1.4.2", + "http-body 1.0.1", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" dependencies = [ - "winnow", + "bytes", + "prost", + "tonic", ] [[package]] @@ -7503,9 +8215,12 @@ checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" dependencies = [ "futures-core", "futures-util", + "indexmap 2.14.0", "pin-project-lite", + "slab", "sync_wrapper", "tokio", + "tokio-util", "tower-layer", "tower-service", "tracing", @@ -7517,9 +8232,9 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e9cd434a998747dd2c4276bc96ee2e0c7a2eadf3cae88e52be55a05fa9053f5" dependencies = [ - "bitflags 2.10.0", + "bitflags 2.13.0", "bytes", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", "pin-project-lite", @@ -7530,25 +8245,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.10.0", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", - "http 1.4.0", + "http 1.4.2", "http-body 1.0.1", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -7575,6 +8290,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.18", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -7583,7 +8311,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -7618,22 +8346,35 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[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", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] @@ -7642,47 +8383,26 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" -[[package]] -name = "tstr" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f8e0294f14baae476d0dd0a2d780b2e24d66e349a9de876f5126777a37bdba7" -dependencies = [ - "tstr_proc_macros", -] - -[[package]] -name = "tstr_proc_macros" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e78122066b0cb818b8afd08f7ed22f7fdbc3e90815035726f0840d0d26c0747a" - [[package]] name = "twox-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" dependencies = [ - "rand 0.9.2", + "rand 0.10.1", ] -[[package]] -name = "typed-arena" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" - [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typewit" -version = "1.14.2" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c1ae7cc0fdb8b842d65d127cb981574b0d2b249b74d1c7a2986863dc134f71" +checksum = "214ca0b2191785cbc06209b9ca1861e048e39b5ba33574b3cedd58363d5bb5f6" [[package]] name = "typify" @@ -7705,11 +8425,11 @@ dependencies = [ "proc-macro2", "quote", "regress", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", - "syn 2.0.114", + "syn 2.0.118", "thiserror 2.0.18", "unicode-ident", ] @@ -7722,12 +8442,12 @@ checksum = "911c32f3c8514b048c1b228361bebb5e6d73aeec01696e8cc0e82e2ffef8ab7a" dependencies = [ "proc-macro2", "quote", - "schemars", + "schemars 0.8.22", "semver", "serde", "serde_json", "serde_tokenstream", - "syn 2.0.114", + "syn 2.0.118", "typify-impl", ] @@ -7745,9 +8465,9 @@ checksum = "6b12e05d9e06373163a9bb6bb8c263c261b396643a99445fe6b9811fd376581b" [[package]] name = "unicode-ident" -version = "1.0.22" +version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-normalization" @@ -7760,9 +8480,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.12.0" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -7770,12 +8490,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unindent" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7264e107f553ccae879d21fbea1d6724ac785e8c3bfc762137959b5802826ef3" - [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -7788,12 +8502,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "unty" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" - [[package]] name = "url" version = "2.5.8" @@ -7832,11 +8540,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.20.0" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee48d38b119b0cd71fe4141b30f5ba9c7c5d9f4e7a3a8b4a674e4b6ef789976f" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -7854,12 +8562,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "virtue" -version = "0.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1" - [[package]] name = "vsimd" version = "0.8.0" @@ -7891,22 +8593,40 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + [[package]] name = "wasip2" -version = "1.0.2+wasi-0.2.9" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42" +dependencies = [ + "wasi 0.14.7+wasi-0.2.4", +] + [[package]] name = "wasm-bindgen" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" dependencies = [ - "cfg-if", + "cfg-if 1.0.4", "once_cell", "rustversion", "wasm-bindgen-macro", @@ -7915,23 +8635,19 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.58" +version = "0.4.75" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" dependencies = [ - "cfg-if", - "futures-util", "js-sys", - "once_cell", "wasm-bindgen", - "web-sys", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7939,22 +8655,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.108" +version = "0.2.125" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" dependencies = [ "unicode-ident", ] @@ -7972,11 +8688,24 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "web-sys" -version = "0.3.85" +version = "0.3.102" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" dependencies = [ "js-sys", "wasm-bindgen", @@ -7993,14 +8722,27 @@ dependencies = [ ] [[package]] -name = "webpki-roots" -version = "1.0.6" +name = "webpki-root-certs" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] +[[package]] +name = "whoami" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +dependencies = [ + "libc", + "libredox", + "objc2-system-configuration", + "wasite", + "web-sys", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8032,6 +8774,27 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + [[package]] name = "windows-core" version = "0.62.2" @@ -8045,6 +8808,17 @@ dependencies = [ "windows-strings", ] +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + [[package]] name = "windows-implement" version = "0.60.2" @@ -8053,7 +8827,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -8064,7 +8838,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] @@ -8073,6 +8847,27 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + [[package]] name = "windows-result" version = "0.4.1" @@ -8100,15 +8895,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" @@ -8160,6 +8946,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -8258,18 +9053,18 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" dependencies = [ "memchr", ] [[package]] name = "wit-bindgen" -version = "0.51.0" +version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "wkb" @@ -8298,9 +9093,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" @@ -8312,47 +9107,163 @@ dependencies = [ ] [[package]] -name = "xattr" -version = "1.6.1" +name = "xet-client" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ - "libc", - "rustix 1.1.3", + "anyhow", + "async-trait", + "base64 0.22.1", + "bytes", + "crc32fast", + "futures", + "http 1.4.2", + "hyper", + "more-asserts", + "rand 0.10.1", + "redb", + "reqwest 0.13.4", + "reqwest-middleware", + "serde", + "serde_json", + "serde_repr", + "statrs", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio_with_wasm", + "tracing", + "url", + "urlencoding", + "web-time", + "xet-core-structures", + "xet-runtime", ] [[package]] -name = "xmlparser" -version = "0.13.6" +name = "xet-core-structures" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" +dependencies = [ + "async-trait", + "base64 0.22.1", + "blake3", + "bytemuck", + "bytes", + "countio", + "futures", + "futures-util", + "getrandom 0.4.3", + "heapify", + "itertools 0.14.0", + "lz4_flex", + "more-asserts", + "rand 0.10.1", + "regex", + "safe-transmute", + "serde", + "static_assertions", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", + "uuid", + "web-time", + "xet-runtime", +] [[package]] -name = "xxhash-rust" -version = "0.8.15" +name = "xet-data" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" +dependencies = [ + "anyhow", + "async-trait", + "bytes", + "chrono", + "gearhash", + "http 1.4.2", + "itertools 0.14.0", + "more-asserts", + "rand 0.10.1", + "serde", + "serde_json", + "sha2 0.11.0", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "url", + "uuid", + "web-time", + "xet-client", + "xet-core-structures", + "xet-runtime", +] [[package]] -name = "xz2" -version = "0.1.7" +name = "xet-runtime" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "388c44dc09d76f1536602ead6d325eb532f5c122f17782bd57fb47baeeb767e2" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ - "lzma-sys", + "anyhow", + "async-trait", + "bytes", + "chrono", + "colored", + "const-str", + "ctor", + "dirs", + "futures", + "git-version", + "humantime", + "konst", + "libc", + "more-asserts", + "oneshot", + "pin-project", + "rand 0.10.1", + "reqwest 0.13.4", + "serde", + "serde_json", + "shellexpand", + "sysinfo", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tokio_with_wasm", + "tracing", + "tracing-appender", + "tracing-subscriber", + "web-time", + "whoami", + "winapi", ] [[package]] -name = "yada" -version = "0.5.1" +name = "xmlparser" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66fee0b777b0f5ac1c69bb06d361268faafa61cd4682ae064a171c16c433e9e4" + +[[package]] +name = "xxhash-rust" +version = "0.8.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aed111bd9e48a802518765906cbdadf0b45afb72b9c81ab049a3b86252adffdd" +checksum = "aee1b19627c7c60102ab80d3a9cbe18de90bfe03bfa6c3715447681f0e8c8af6" [[package]] name = "yoke" -version = "0.8.1" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8361,80 +9272,82 @@ 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", - "syn 2.0.114", + "syn 2.0.118", "synstructure", ] [[package]] name = "zerocopy" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57cf3aa6855b23711ee9852dfc97dfaa51c45feaba5b645d0c777414d494a961" +checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.38" +version = "0.8.52" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a616990af1a287837c4fe6596ad77ef57948f787e46ce28e166facc0cc1cb75" +checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930" dependencies = [ "proc-macro2", "quote", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "zerofrom" -version = "0.1.6" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" 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", - "syn 2.0.114", + "syn 2.0.118", "synstructure", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[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", "zerofrom", + "zerovec", ] [[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 = [ + "serde", "yoke", "zerofrom", "zerovec-derive", @@ -8442,26 +9355,26 @@ 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", - "syn 2.0.114", + "syn 2.0.118", ] [[package]] name = "zlib-rs" -version = "0.6.0" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7948af682ccbc3342b6e9420e8c51c1fe5d7bf7756002b4a3c6cabfe96a7e3c" +checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" [[package]] name = "zmij" -version = "1.0.19" +version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff05f8caa9038894637571ae6b9e29466c1f4f829d26c9b28f869a29cbe3445" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" [[package]] name = "zstd" diff --git a/python/Cargo.toml b/python/Cargo.toml index f234c02c126..2d955b8cf3a 100644 --- a/python/Cargo.toml +++ b/python/Cargo.toml @@ -1,25 +1,38 @@ [package] name = "pylance" -version = "3.0.0-beta.2" -edition = "2021" +version = "14.0.0-beta.6" +edition = "2024" authors = ["Lance Devs "] -rust-version = "1.88" +license = "Apache-2.0" +rust-version = "1.94" exclude = ["python/lance/conftest.py"] publish = false +# `python/` is excluded from the top-level workspace (see ../Cargo.toml) and keeps +# its own Cargo.lock. Declaring an empty workspace here makes this crate an explicit +# workspace root, so cargo resolves it standalone even when the checkout is nested +# inside another cargo project (e.g. a git worktree placed under the main checkout). +[workspace] + [lib] name = "lance" crate-type = ["cdylib"] [dependencies] -arrow = { version = "57.0.0", features = ["pyarrow"] } -arrow-array = "57.0.0" -arrow-data = "57.0.0" -arrow-schema = "57.0.0" -object_store = "0.12.4" -datafusion = "51.0.0" -datafusion-ffi = "51.0.0" -datafusion-common = "51.0.0" +arrow = { version = "58.0.0", features = ["pyarrow"] } +arrow-array = "58.0.0" +arrow-cast = "58.0.0" +arrow-data = "58.0.0" +arrow-schema = "58.0.0" +object_store = "0.13.2" +url = "2.5.7" +datafusion = { version = "54.0.0", default-features = false } +datafusion-ffi = "54.0.0" +datafusion-common = "54.0.0" +# Keep the Python FFI build on the working Brotli allocator resolution until +# datafusion-ffi no longer enables datafusion-proto/default. +# See https://github.com/lance-format/lance/issues/7271. +alloc-stdlib = "=0.2.2" async-trait = "0.1" chrono = "0.4.42" env_logger = "0.11.7" @@ -29,8 +42,10 @@ half = { version = "2.5", default-features = false, features = [ "std", ] } lance = { path = "../rust/lance", features = [ + "goosefs", "dynamodb", "substrait", + "metrics", ] } lance-arrow = { path = "../rust/lance-arrow" } lance-core = { path = "../rust/lance-core" } @@ -41,40 +56,39 @@ lance-index = { path = "../rust/lance-index", features = [ "tokenizer-lindera", "tokenizer-jieba", ] } -lance-io = { path = "../rust/lance-io" } +lance-io = { path = "../rust/lance-io", features = ["metrics"] } lance-linalg = { path = "../rust/lance-linalg" } lance-namespace = { path = "../rust/lance-namespace" } -lance-namespace-impls = { path = "../rust/lance-namespace-impls", features = ["rest", "rest-adapter"] } +lance-namespace-impls = { path = "../rust/lance-namespace-impls", features = ["rest", "rest-adapter", "dir-goosefs"] } lance-table = { path = "../rust/lance-table" } lance-datafusion = { path = "../rust/lance-datafusion" } libc = "0.2.176" log = "0.4" +metrics = "0.24" +metrics-util = "0.19" prost = "0.14.1" prost-types = "0.14.1" -pyo3 = { version = "0.26", features = [ - "extension-module", - "abi3-py39", +pyo3 = { version = "0.28", features = [ + "abi3-py310", "py-clone", "chrono", ] } -pythonize = "0.26" +pythonize = "0.28" tokio = { version = "1.48", features = ["rt-multi-thread"] } uuid = "1.3.0" -regex = "1" -roaring = "0.10.1" +roaring = "0.11.4" serde_json = "1" serde = "1.0.197" serde_yaml = "0.9.34" -snafu = "0.8" tracing-chrome = "0.7.1" tracing-subscriber = "0.3.17" tracing = { version = "0.1" } -url = "2.5.0" -bytes = "1.4" +bytes = "1.11.1" [features] -default = [] +default = ["extension-module"] datagen = ["lance-datagen"] +extension-module = ["pyo3/extension-module"] fp16kernels = ["lance/fp16kernels"] [profile.ci] @@ -90,3 +104,6 @@ debug = false debug-assertions = false strip = "debuginfo" incremental = false + +[lints.clippy] +disallowed_macros = "deny" diff --git a/python/DEVELOPMENT.md b/python/DEVELOPMENT.md index 9effb0fb456..18e1b9b1eef 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 +``` + +Add `--group benchmarks` for benchmarks, or `--extra torch` / `--extra geo` for those optional features. 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 --uv ``` 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 --group benchmarks +uv run maturin develop --uv --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 --group benchmarks +uv run maturin develop --uv --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 --group benchmarks +uv run maturin develop --uv --profile release-with-debug --features datagen +uv run pytest --benchmark-compare=$COMPARE_ID python/benchmarks -m "not slow" ``` ## Tracing diff --git a/python/Makefile b/python/Makefile index 1f1a30f7842..12dad5196c6 100644 --- a/python/Makefile +++ b/python/Makefile @@ -1,53 +1,85 @@ +.DEFAULT_GOAL := help +.PHONY: help install build test integtest doctest compattest format format-python lint lint-python lint-rust clean +PYTHON ?= +PYTEST_ARGS ?= -vvv -s +KEEP_COMPOSE ?= 0 +COMPOSE_FILE ?= ../docker-compose.yml +UV_SYNC = uv sync +UV_RUN = uv run --frozen + +# Set PYTHON=3.12 to use a specific Python version with uv. +ifneq ($(strip $(PYTHON)),) + UV_PYTHON_ARG = --python $(PYTHON) + UV_SYNC += $(UV_PYTHON_ARG) + UV_RUN += $(UV_PYTHON_ARG) +endif + ifeq ($(CI), true) - PYTEST_ARGS = -vvv -s --durations=30 -m "not recurring" -else - PYTEST_ARGS = -vvv -s -m "not recurring" + PYTEST_ARGS += --durations=30 +endif + +# Number of pytest-xdist workers, e.g. `auto` or an explicit count. Empty (the +# default) runs serially; CI sets it per runner size. Much of the suite is spent +# waiting on IO rather than saturating the Rust core's own thread pools, so +# sharding across processes wins back time a single pytest process leaves idle. +PYTEST_WORKERS ?= +ifneq ($(strip $(PYTEST_WORKERS)),) + # loadgroup rather than the default scheduler so that tests sharing state on + # disk can be pinned to a single worker with @pytest.mark.xdist_group. + PYTEST_ARGS += -n $(PYTEST_WORKERS) --dist loadgroup endif -test: +help: ## Show this help + @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf " \033[36m%-20s\033[0m %s\n", $$1, $$2 }' $(MAKEFILE_LIST) + +install: ## Sync dependencies and set up local development tools + @if ! command -v uv > /dev/null 2>&1; then \ + echo "uv not found. Install it from https://docs.astral.sh/uv/getting-started/installation/"; \ + exit 1; \ + fi + $(UV_SYNC) + uv tool run pre-commit install + +build: ## Build the local Rust extension with maturin + $(UV_RUN) maturin develop --uv + +test: ## Run Python tests pytest $(PYTEST_ARGS) python/tests -.PHONY: test -integtest: - pytest --run-integration $(PYTEST_ARGS) python/tests/test_s3_ddb.py python/tests/test_namespace_integration.py -.PHONY: integtest +integtest: ## Start LocalStack and run integration tests + @if [ "$(KEEP_COMPOSE)" = "1" ]; then \ + docker compose -f $(COMPOSE_FILE) up -d --wait && \ + pytest --run-integration $(PYTEST_ARGS) python/tests/test_s3_ddb.py python/tests/test_namespace_integration.py; \ + else \ + trap 'docker compose -f $(COMPOSE_FILE) down -v --remove-orphans --timeout 0 >/dev/null 2>&1 || true' EXIT; \ + docker compose -f $(COMPOSE_FILE) up -d --wait && \ + pytest --run-integration $(PYTEST_ARGS) python/tests/test_s3_ddb.py python/tests/test_namespace_integration.py; \ + fi -doctest: +doctest: ## Run Python doctests pytest --doctest-modules $(PYTEST_ARGS) python/lance -.PHONY: doctest -compattest: - pytest --run-compat $(PYTEST_ARGS) python/tests/compat -.PHONY: compattest +compattest: ## Run upgrade/downgrade compatibility tests + pytest --run-compat $(PYTEST_ARGS) python/tests/compat -format: format-python +format: format-python ## Auto-format Python and Rust cargo fmt -.PHONY: format - -build: - maturin develop -.PHONY: build -format-python: +format-python: ## Auto-format Python (ruff) ruff format python ruff check --fix python -.PHONY: format-python -lint: lint-python lint-rust -.PHONY: lint +lint: lint-python lint-rust ## Check Python and Rust formatting/lints -lint-python: +lint-python: ## Check Python formatting, linting, and types ruff format --check --diff python ruff check python pyright -.PHONY: lint-python -lint-rust: +lint-rust: ## Check Rust formatting and clippy lints cargo fmt -- --check cargo clippy -- -D warnings -.PHONY: lint-rust -clean: +clean: ## Remove build artifacts cargo clean rm -rf target -.PHONY: clean \ No newline at end of file diff --git a/python/PYTHON_THIRD_PARTY_LICENSES.md b/python/PYTHON_THIRD_PARTY_LICENSES.md new file mode 100644 index 00000000000..4c1373cb06e --- /dev/null +++ b/python/PYTHON_THIRD_PARTY_LICENSES.md @@ -0,0 +1,72 @@ +| Name | Version | License | URL | +|--------------------------------|-------------|---------------------------------------------------|----------------------------------------------------------------------| +| Jinja2 | 3.1.6 | BSD License | https://github.com/pallets/jinja/ | +| MarkupSafe | 3.0.3 | BSD-3-Clause | https://github.com/pallets/markupsafe/ | +| PyYAML | 6.0.3 | MIT License | https://pyyaml.org/ | +| Pygments | 2.19.2 | BSD License | https://pygments.org | +| aiohappyeyeballs | 2.6.1 | Python Software Foundation License | https://github.com/aio-libs/aiohappyeyeballs | +| aiohttp | 3.13.4 | Apache-2.0 AND MIT | https://github.com/aio-libs/aiohttp | +| aiosignal | 1.4.0 | Apache Software License | https://github.com/aio-libs/aiosignal | +| annotated-types | 0.7.0 | MIT License | https://github.com/annotated-types/annotated-types | +| arro3-core | 0.6.5 | UNKNOWN | https://kylebarron.dev/arro3 | +| attrs | 25.3.0 | MIT | https://www.attrs.org/en/stable/changelog.html | +| boto3 | 1.40.43 | Apache Software License | https://github.com/boto/boto3 | +| botocore | 1.40.43 | Apache Software License | https://github.com/boto/botocore | +| certifi | 2025.8.3 | Mozilla Public License 2.0 (MPL 2.0) | https://github.com/certifi/python-certifi | +| charset-normalizer | 3.4.3 | MIT | https://github.com/jawah/charset_normalizer/blob/master/CHANGELOG.md | +| datafusion | 53.0.0 | Apache Software License | https://datafusion.apache.org/python | +| datasets | 4.1.1 | Apache Software License | https://github.com/huggingface/datasets | +| dill | 0.4.0 | BSD License | https://github.com/uqfoundation/dill | +| duckdb | 1.4.0 | MIT License | https://github.com/duckdb/duckdb-python | +| filelock | 3.19.1 | Unlicense | https://github.com/tox-dev/py-filelock | +| frozenlist | 1.7.0 | Apache-2.0 | https://github.com/aio-libs/frozenlist | +| fsspec | 2025.9.0 | BSD-3-Clause | https://github.com/fsspec/filesystem_spec | +| geoarrow-rust-core | 0.6.1 | UNKNOWN | https://geoarrow.org/geoarrow-rs/ | +| geoarrow-rust-io | 0.6.1 | UNKNOWN | https://geoarrow.org/geoarrow-rs/ | +| hf-xet | 1.1.10 | Apache-2.0 | https://github.com/huggingface/xet-core | +| huggingface-hub | 0.35.3 | Apache Software License | https://github.com/huggingface/huggingface_hub | +| idna | 3.10 | BSD License | https://github.com/kjd/idna | +| iniconfig | 2.1.0 | MIT | https://github.com/pytest-dev/iniconfig | +| jmespath | 1.0.1 | MIT License | https://github.com/jmespath/jmespath.py | +| lance-namespace | 0.7.6 | Apache-2.0 | https://github.com/lance-format/lance-namespace | +| lance-namespace-urllib3-client | 0.7.6 | Apache-2.0 | https://github.com/lance-format/lance-namespace | +| maturin | 1.13.3 | MIT OR Apache-2.0 | https://github.com/pyo3/maturin | +| ml_dtypes | 0.5.3 | Apache-2.0 | https://github.com/jax-ml/ml_dtypes | +| mpmath | 1.3.0 | BSD License | http://mpmath.org/ | +| multidict | 6.6.4 | Apache License 2.0 | https://github.com/aio-libs/multidict | +| multiprocess | 0.70.16 | BSD License | https://github.com/uqfoundation/multiprocess | +| networkx | 3.5 | BSD License | https://networkx.org/ | +| nodeenv | 1.9.1 | BSD License | https://github.com/ekalinin/nodeenv | +| numpy | 2.3.3 | BSD License | https://numpy.org | +| packaging | 25.0 | Apache Software License; BSD License | https://github.com/pypa/packaging | +| pandas | 2.3.3 | BSD License | https://pandas.pydata.org | +| pillow | 11.3.0 | MIT-CMU | https://python-pillow.github.io | +| pluggy | 1.6.0 | MIT License | UNKNOWN | +| polars | 1.34.0 | MIT License | https://www.pola.rs/ | +| polars-runtime-32 | 1.34.0 | MIT License | https://www.pola.rs/ | +| propcache | 0.3.2 | Apache Software License | https://github.com/aio-libs/propcache | +| psutil | 7.1.0 | BSD-3-Clause | https://github.com/giampaolo/psutil | +| py-cpuinfo | 9.0.0 | MIT License | https://github.com/workhorsy/py-cpuinfo | +| pyarrow | 23.0.1 | Apache-2.0 | https://arrow.apache.org/ | +| pydantic | 2.12.4 | MIT | https://github.com/pydantic/pydantic | +| pydantic_core | 2.41.5 | MIT | https://github.com/pydantic/pydantic-core | +| pylance | 7.0.0b14 | Apache Software License | UNKNOWN | +| pyproj | 3.7.2 | MIT | https://pyproj4.github.io/pyproj/ | +| pyright | 1.1.406 | MIT | https://github.com/RobertCraigie/pyright-python | +| pytest | 8.4.2 | MIT License | https://docs.pytest.org/en/latest/ | +| pytest-benchmark | 5.1.0 | BSD License | https://github.com/ionelmc/pytest-benchmark | +| python-dateutil | 2.9.0.post0 | Apache Software License; BSD License | https://github.com/dateutil/dateutil | +| pytz | 2025.2 | MIT License | http://pythonhosted.org/pytz | +| requests | 2.33.0 | Apache Software License | https://github.com/psf/requests | +| ruff | 0.11.2 | MIT License | https://docs.astral.sh/ruff | +| s3transfer | 0.14.0 | Apache Software License | https://github.com/boto/s3transfer | +| six | 1.17.0 | MIT License | https://github.com/benjaminp/six | +| sympy | 1.14.0 | BSD License | https://sympy.org | +| torch | 2.8.0 | BSD License | https://pytorch.org/ | +| tqdm | 4.67.1 | MIT License; Mozilla Public License 2.0 (MPL 2.0) | https://tqdm.github.io | +| typing-inspection | 0.4.2 | MIT | https://github.com/pydantic/typing-inspection | +| typing_extensions | 4.15.0 | PSF-2.0 | https://github.com/python/typing_extensions | +| tzdata | 2025.2 | Apache Software License | https://github.com/python/tzdata | +| urllib3 | 2.5.0 | MIT | https://github.com/urllib3/urllib3/blob/main/CHANGES.rst | +| xxhash | 3.6.0 | BSD License | https://github.com/ifduyue/python-xxhash | +| yarl | 1.20.1 | Apache Software License | https://github.com/aio-libs/yarl | diff --git a/python/RUST_THIRD_PARTY_LICENSES.html b/python/RUST_THIRD_PARTY_LICENSES.html new file mode 100644 index 00000000000..8e550498f20 --- /dev/null +++ b/python/RUST_THIRD_PARTY_LICENSES.html @@ -0,0 +1,16121 @@ + + + + + + + +

    +
    +

    Third Party Licenses

    +

    This page lists the licenses of the projects used in cargo-about.

    +
    + +

    Overview of licenses:

    + + +

    All license text:

    +
      +
    • +

      BSD Zero Clause License

      +

      Used by:

      + +
      Copyright (C) Jonas Schievink <jonasschievink@gmail.com>
      +
      +Permission to use, copy, modify, and/or distribute this software for
      +any purpose with or without fee is hereby granted.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
      +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
      +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
      +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
      +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2023 Jacob Pratt et al.
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2024 Jacob Pratt et al.
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +--- LLVM Exceptions to the Apache 2.0 License ----
      +
      +As an exception, if, as a result of your compiling your source code, portions
      +of this Software are embedded into an Object form of such source code, you
      +may redistribute such embedded portions in such Object form without complying
      +with the conditions of Sections 4(a), 4(b) and 4(d) of the License.
      +
      +In addition, if you combine or link compiled forms of this Software with
      +software that is licensed under the GPLv2 ("Combined Software") and if a
      +court of competent jurisdiction determines that the patent provision (Section
      +3), the indemnity provision (Section 9) or other Section of the License
      +conflicts with the conditions of the GPLv2, you may retroactively and
      +prospectively choose to deem waived or otherwise exclude such Section(s) of
      +the License, but only in their entirety and only with respect to the Combined
      +Software.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +This project includes code from Apache Aurora.
      +
      +* dev/release/{release,changelog,release-candidate} are based on the scripts from
      +  Apache Aurora
      +
      +Copyright: 2016 The Apache Software Foundation.
      +Home page: https://aurora.apache.org/
      +License: http://www.apache.org/licenses/LICENSE-2.0
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   Copyright 2019-2022 Jeff Parsons, and [contributors](https://github.com/jeffparsons/rangemap/contributors)
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      +                                 Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +    1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +    2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +    3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +    4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +    5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +    6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +    7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +    8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +    9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +    END OF TERMS AND CONDITIONS
      +
      +    APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +    Copyright [yyyy] [name of copyright owner]
      +
      +    Licensed under the Apache License, Version 2.0 (the "License");
      +    you may not use this file except in compliance with the License.
      +    You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +    Unless required by applicable law or agreed to in writing, software
      +    distributed under the License is distributed on an "AS IS" BASIS,
      +    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +    See the License for the specific language governing permissions and
      +    limitations under the License.
      +
      +
      +--- LLVM Exceptions to the Apache 2.0 License ----
      +
      +As an exception, if, as a result of your compiling your source code, portions
      +of this Software are embedded into an Object form of such source code, you
      +may redistribute such embedded portions in such Object form without complying
      +with the conditions of Sections 4(a), 4(b) and 4(d) of the License.
      +
      +In addition, if you combine or link compiled forms of this Software with
      +software that is licensed under the GPLv2 ("Combined Software") and if a
      +court of competent jurisdiction determines that the patent provision (Section
      +3), the indemnity provision (Section 9) or other Section of the License
      +conflicts with the conditions of the GPLv2, you may retroactively and
      +prospectively choose to deem waived or otherwise exclude such Section(s) of
      +the License, but only in their entirety and only with respect to the Combined
      +Software.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright (c) Microsoft Corporation.
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2020 - 2026 Tatsuya Kawano
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2020 Tomasz "Soveu" Marx
      +
      +   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.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2021 Datafuse Labs
      +
      +   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.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2023 The Fuchsia Authors
      +
      +   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.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2023 dAxpeDDa
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "[]"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +   
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2017 Juniper Networks, Inc.
      +
      +   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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright {yyyy} {name of copyright owner}
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                       Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright {yyyy} {name of copyright owner}
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                      Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright 2018-2020 Amazon.com, Inc. or its affiliates. All Rights Reserved.
      +
      +   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.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                     Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright [yyyy] [name of copyright owner]
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright (c) 2016 Alex Crichton
      +Copyright (c) 2017 The Tokio Authors
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2014 Paho Lurie-Gregg
      +
      +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.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2016 Sean McArthur
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2017 http-rs authors
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2017 quininer kel
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2018 The pin-utils authors
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2019 The CryptoCorrosion Contributors
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2019 quininer kel
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2019- Jake Goulding
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2020 Andrew Straw
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2020 ridiculous_fish
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2021 Ivan Smirnov
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2023 Dirkjan Ochtman
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2023 The BigDecimal-rs Contributors
      +
      +
      +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.
      +
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [2015] [Dan Burkert]
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +    http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	https://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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                    Apache License
      +                        Version 2.0, January 2004
      +                     https://www.apache.org/licenses/LICENSE-2.0
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	https://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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
                                   Apache License
      +                       Version 2.0, January 2004
      +                    http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +  "License" shall mean the terms and conditions for use, reproduction,
      +  and distribution as defined by Sections 1 through 9 of this document.
      +
      +  "Licensor" shall mean the copyright owner or entity authorized by
      +  the copyright owner that is granting the License.
      +
      +  "Legal Entity" shall mean the union of the acting entity and all
      +  other entities that control, are controlled by, or are under common
      +  control with that entity. For the purposes of this definition,
      +  "control" means (i) the power, direct or indirect, to cause the
      +  direction or management of such entity, whether by contract or
      +  otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +  outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +  "You" (or "Your") shall mean an individual or Legal Entity
      +  exercising permissions granted by this License.
      +
      +  "Source" form shall mean the preferred form for making modifications,
      +  including but not limited to software source code, documentation
      +  source, and configuration files.
      +
      +  "Object" form shall mean any form resulting from mechanical
      +  transformation or translation of a Source form, including but
      +  not limited to compiled object code, generated documentation,
      +  and conversions to other media types.
      +
      +  "Work" shall mean the work of authorship, whether in Source or
      +  Object form, made available under the License, as indicated by a
      +  copyright notice that is included in or attached to the work
      +  (an example is provided in the Appendix below).
      +
      +  "Derivative Works" shall mean any work, whether in Source or Object
      +  form, that is based on (or derived from) the Work and for which the
      +  editorial revisions, annotations, elaborations, or other modifications
      +  represent, as a whole, an original work of authorship. For the purposes
      +  of this License, Derivative Works shall not include works that remain
      +  separable from, or merely link (or bind by name) to the interfaces of,
      +  the Work and Derivative Works thereof.
      +
      +  "Contribution" shall mean any work of authorship, including
      +  the original version of the Work and any modifications or additions
      +  to that Work or Derivative Works thereof, that is intentionally
      +  submitted to Licensor for inclusion in the Work by the copyright owner
      +  or by an individual or Legal Entity authorized to submit on behalf of
      +  the copyright owner. For the purposes of this definition, "submitted"
      +  means any form of electronic, verbal, or written communication sent
      +  to the Licensor or its representatives, including but not limited to
      +  communication on electronic mailing lists, source code control systems,
      +  and issue tracking systems that are managed by, or on behalf of, the
      +  Licensor for the purpose of discussing and improving the Work, but
      +  excluding communication that is conspicuously marked or otherwise
      +  designated in writing by the copyright owner as "Not a Contribution."
      +
      +  "Contributor" shall mean Licensor and any individual or Legal Entity
      +  on behalf of whom a Contribution has been received by Licensor and
      +  subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +  this License, each Contributor hereby grants to You a perpetual,
      +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +  copyright license to reproduce, prepare Derivative Works of,
      +  publicly display, publicly perform, sublicense, and distribute the
      +  Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +  this License, each Contributor hereby grants to You a perpetual,
      +  worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +  (except as stated in this section) patent license to make, have made,
      +  use, offer to sell, sell, import, and otherwise transfer the Work,
      +  where such license applies only to those patent claims licensable
      +  by such Contributor that are necessarily infringed by their
      +  Contribution(s) alone or by combination of their Contribution(s)
      +  with the Work to which such Contribution(s) was submitted. If You
      +  institute patent litigation against any entity (including a
      +  cross-claim or counterclaim in a lawsuit) alleging that the Work
      +  or a Contribution incorporated within the Work constitutes direct
      +  or contributory patent infringement, then any patent licenses
      +  granted to You under this License for that Work shall terminate
      +  as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +  Work or Derivative Works thereof in any medium, with or without
      +  modifications, and in Source or Object form, provided that You
      +  meet the following conditions:
      +
      +  (a) You must give any other recipients of the Work or
      +      Derivative Works a copy of this License; and
      +
      +  (b) You must cause any modified files to carry prominent notices
      +      stating that You changed the files; and
      +
      +  (c) You must retain, in the Source form of any Derivative Works
      +      that You distribute, all copyright, patent, trademark, and
      +      attribution notices from the Source form of the Work,
      +      excluding those notices that do not pertain to any part of
      +      the Derivative Works; and
      +
      +  (d) If the Work includes a "NOTICE" text file as part of its
      +      distribution, then any Derivative Works that You distribute must
      +      include a readable copy of the attribution notices contained
      +      within such NOTICE file, excluding those notices that do not
      +      pertain to any part of the Derivative Works, in at least one
      +      of the following places: within a NOTICE text file distributed
      +      as part of the Derivative Works; within the Source form or
      +      documentation, if provided along with the Derivative Works; or,
      +      within a display generated by the Derivative Works, if and
      +      wherever such third-party notices normally appear. The contents
      +      of the NOTICE file are for informational purposes only and
      +      do not modify the License. You may add Your own attribution
      +      notices within Derivative Works that You distribute, alongside
      +      or as an addendum to the NOTICE text from the Work, provided
      +      that such additional attribution notices cannot be construed
      +      as modifying the License.
      +
      +  You may add Your own copyright statement to Your modifications and
      +  may provide additional or different license terms and conditions
      +  for use, reproduction, or distribution of Your modifications, or
      +  for any such Derivative Works as a whole, provided Your use,
      +  reproduction, and distribution of the Work otherwise complies with
      +  the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +  any Contribution intentionally submitted for inclusion in the Work
      +  by You to the Licensor shall be under the terms and conditions of
      +  this License, without any additional terms or conditions.
      +  Notwithstanding the above, nothing herein shall supersede or modify
      +  the terms of any separate license agreement you may have executed
      +  with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +  names, trademarks, service marks, or product names of the Licensor,
      +  except as required for reasonable and customary use in describing the
      +  origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +  agreed to in writing, Licensor provides the Work (and each
      +  Contributor provides its Contributions) on an "AS IS" BASIS,
      +  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +  implied, including, without limitation, any warranties or conditions
      +  of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +  PARTICULAR PURPOSE. You are solely responsible for determining the
      +  appropriateness of using or redistributing the Work and assume any
      +  risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +  whether in tort (including negligence), contract, or otherwise,
      +  unless required by applicable law (such as deliberate and grossly
      +  negligent acts) or agreed to in writing, shall any Contributor be
      +  liable to You for damages, including any direct, indirect, special,
      +  incidental, or consequential damages of any character arising as a
      +  result of this License or out of the use or inability to use the
      +  Work (including but not limited to damages for loss of goodwill,
      +  work stoppage, computer failure or malfunction, or any and all
      +  other commercial damages or losses), even if such Contributor
      +  has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +  the Work or Derivative Works thereof, You may choose to offer,
      +  and charge a fee for, acceptance of support, warranty, indemnity,
      +  or other liability obligations and/or rights consistent with this
      +  License. However, in accepting such obligations, You may act only
      +  on Your own behalf and on Your sole responsibility, not on behalf
      +  of any other Contributor, and only if You agree to indemnify,
      +  defend, and hold each Contributor harmless for any liability
      +  incurred by, or claims asserted against, such Contributor by reason
      +  of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +  To apply the Apache License to your work, attach the following
      +  boilerplate notice, with the fields enclosed by brackets "[]"
      +  replaced with your own identifying information. (Don't include
      +  the brackets!)  The text should be enclosed in the appropriate
      +  comment syntax for the file format. We also recommend that a
      +  file or class name and description of purpose be included on the
      +  same "printed page" as the copyright notice for easier
      +  identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +   http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +                           Version 2.0, January 2004
      +                        http://www.apache.org/licenses/
      +
      +   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +   1. Definitions.
      +
      +      "License" shall mean the terms and conditions for use, reproduction,
      +      and distribution as defined by Sections 1 through 9 of this document.
      +
      +      "Licensor" shall mean the copyright owner or entity authorized by
      +      the copyright owner that is granting the License.
      +
      +      "Legal Entity" shall mean the union of the acting entity and all
      +      other entities that control, are controlled by, or are under common
      +      control with that entity. For the purposes of this definition,
      +      "control" means (i) the power, direct or indirect, to cause the
      +      direction or management of such entity, whether by contract or
      +      otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +      outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +      "You" (or "Your") shall mean an individual or Legal Entity
      +      exercising permissions granted by this License.
      +
      +      "Source" form shall mean the preferred form for making modifications,
      +      including but not limited to software source code, documentation
      +      source, and configuration files.
      +
      +      "Object" form shall mean any form resulting from mechanical
      +      transformation or translation of a Source form, including but
      +      not limited to compiled object code, generated documentation,
      +      and conversions to other media types.
      +
      +      "Work" shall mean the work of authorship, whether in Source or
      +      Object form, made available under the License, as indicated by a
      +      copyright notice that is included in or attached to the work
      +      (an example is provided in the Appendix below).
      +
      +      "Derivative Works" shall mean any work, whether in Source or Object
      +      form, that is based on (or derived from) the Work and for which the
      +      editorial revisions, annotations, elaborations, or other modifications
      +      represent, as a whole, an original work of authorship. For the purposes
      +      of this License, Derivative Works shall not include works that remain
      +      separable from, or merely link (or bind by name) to the interfaces of,
      +      the Work and Derivative Works thereof.
      +
      +      "Contribution" shall mean any work of authorship, including
      +      the original version of the Work and any modifications or additions
      +      to that Work or Derivative Works thereof, that is intentionally
      +      submitted to Licensor for inclusion in the Work by the copyright owner
      +      or by an individual or Legal Entity authorized to submit on behalf of
      +      the copyright owner. For the purposes of this definition, "submitted"
      +      means any form of electronic, verbal, or written communication sent
      +      to the Licensor or its representatives, including but not limited to
      +      communication on electronic mailing lists, source code control systems,
      +      and issue tracking systems that are managed by, or on behalf of, the
      +      Licensor for the purpose of discussing and improving the Work, but
      +      excluding communication that is conspicuously marked or otherwise
      +      designated in writing by the copyright owner as "Not a Contribution."
      +
      +      "Contributor" shall mean Licensor and any individual or Legal Entity
      +      on behalf of whom a Contribution has been received by Licensor and
      +      subsequently incorporated within the Work.
      +
      +   2. Grant of Copyright License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      copyright license to reproduce, prepare Derivative Works of,
      +      publicly display, publicly perform, sublicense, and distribute the
      +      Work and such Derivative Works in Source or Object form.
      +
      +   3. Grant of Patent License. Subject to the terms and conditions of
      +      this License, each Contributor hereby grants to You a perpetual,
      +      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +      (except as stated in this section) patent license to make, have made,
      +      use, offer to sell, sell, import, and otherwise transfer the Work,
      +      where such license applies only to those patent claims licensable
      +      by such Contributor that are necessarily infringed by their
      +      Contribution(s) alone or by combination of their Contribution(s)
      +      with the Work to which such Contribution(s) was submitted. If You
      +      institute patent litigation against any entity (including a
      +      cross-claim or counterclaim in a lawsuit) alleging that the Work
      +      or a Contribution incorporated within the Work constitutes direct
      +      or contributory patent infringement, then any patent licenses
      +      granted to You under this License for that Work shall terminate
      +      as of the date such litigation is filed.
      +
      +   4. Redistribution. You may reproduce and distribute copies of the
      +      Work or Derivative Works thereof in any medium, with or without
      +      modifications, and in Source or Object form, provided that You
      +      meet the following conditions:
      +
      +      (a) You must give any other recipients of the Work or
      +          Derivative Works a copy of this License; and
      +
      +      (b) You must cause any modified files to carry prominent notices
      +          stating that You changed the files; and
      +
      +      (c) You must retain, in the Source form of any Derivative Works
      +          that You distribute, all copyright, patent, trademark, and
      +          attribution notices from the Source form of the Work,
      +          excluding those notices that do not pertain to any part of
      +          the Derivative Works; and
      +
      +      (d) If the Work includes a "NOTICE" text file as part of its
      +          distribution, then any Derivative Works that You distribute must
      +          include a readable copy of the attribution notices contained
      +          within such NOTICE file, excluding those notices that do not
      +          pertain to any part of the Derivative Works, in at least one
      +          of the following places: within a NOTICE text file distributed
      +          as part of the Derivative Works; within the Source form or
      +          documentation, if provided along with the Derivative Works; or,
      +          within a display generated by the Derivative Works, if and
      +          wherever such third-party notices normally appear. The contents
      +          of the NOTICE file are for informational purposes only and
      +          do not modify the License. You may add Your own attribution
      +          notices within Derivative Works that You distribute, alongside
      +          or as an addendum to the NOTICE text from the Work, provided
      +          that such additional attribution notices cannot be construed
      +          as modifying the License.
      +
      +      You may add Your own copyright statement to Your modifications and
      +      may provide additional or different license terms and conditions
      +      for use, reproduction, or distribution of Your modifications, or
      +      for any such Derivative Works as a whole, provided Your use,
      +      reproduction, and distribution of the Work otherwise complies with
      +      the conditions stated in this License.
      +
      +   5. Submission of Contributions. Unless You explicitly state otherwise,
      +      any Contribution intentionally submitted for inclusion in the Work
      +      by You to the Licensor shall be under the terms and conditions of
      +      this License, without any additional terms or conditions.
      +      Notwithstanding the above, nothing herein shall supersede or modify
      +      the terms of any separate license agreement you may have executed
      +      with Licensor regarding such Contributions.
      +
      +   6. Trademarks. This License does not grant permission to use the trade
      +      names, trademarks, service marks, or product names of the Licensor,
      +      except as required for reasonable and customary use in describing the
      +      origin of the Work and reproducing the content of the NOTICE file.
      +
      +   7. Disclaimer of Warranty. Unless required by applicable law or
      +      agreed to in writing, Licensor provides the Work (and each
      +      Contributor provides its Contributions) on an "AS IS" BASIS,
      +      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +      implied, including, without limitation, any warranties or conditions
      +      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +      PARTICULAR PURPOSE. You are solely responsible for determining the
      +      appropriateness of using or redistributing the Work and assume any
      +      risks associated with Your exercise of permissions under this License.
      +
      +   8. Limitation of Liability. In no event and under no legal theory,
      +      whether in tort (including negligence), contract, or otherwise,
      +      unless required by applicable law (such as deliberate and grossly
      +      negligent acts) or agreed to in writing, shall any Contributor be
      +      liable to You for damages, including any direct, indirect, special,
      +      incidental, or consequential damages of any character arising as a
      +      result of this License or out of the use or inability to use the
      +      Work (including but not limited to damages for loss of goodwill,
      +      work stoppage, computer failure or malfunction, or any and all
      +      other commercial damages or losses), even if such Contributor
      +      has been advised of the possibility of such damages.
      +
      +   9. Accepting Warranty or Additional Liability. While redistributing
      +      the Work or Derivative Works thereof, You may choose to offer,
      +      and charge a fee for, acceptance of support, warranty, indemnity,
      +      or other liability obligations and/or rights consistent with this
      +      License. However, in accepting such obligations, You may act only
      +      on Your own behalf and on Your sole responsibility, not on behalf
      +      of any other Contributor, and only if You agree to indemnify,
      +      defend, and hold each Contributor harmless for any liability
      +      incurred by, or claims asserted against, such Contributor by reason
      +      of your accepting any such warranty or additional liability.
      +
      +   END OF TERMS AND CONDITIONS
      +
      +   APPENDIX: How to apply the Apache License to your work.
      +
      +      To apply the Apache License to your work, attach the following
      +      boilerplate notice, with the fields enclosed by brackets "{}"
      +      replaced with your own identifying information. (Don't include
      +      the brackets!)  The text should be enclosed in the appropriate
      +      comment syntax for the file format. We also recommend that a
      +      file or class name and description of purpose be included on the
      +      same "printed page" as the copyright notice for easier
      +      identification within third-party archives.
      +
      +   Copyright {yyyy} {name of copyright owner}
      +
      +   Licensed under the Apache License, Version 2.0 (the "License");
      +   you may not use this file except in compliance with the License.
      +   You may obtain a copy of the License at
      +
      +       http://www.apache.org/licenses/LICENSE-2.0
      +
      +   Unless required by applicable law or agreed to in writing, software
      +   distributed under the License is distributed on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +   See the License for the specific language governing permissions and
      +   limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +    1. Definitions.
      +
      +        "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
      +
      +        "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
      +
      +        "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +        "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
      +
      +        "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
      +
      +        "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
      +
      +        "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
      +
      +        "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
      +
      +        "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
      +
      +        "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
      +    2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
      +    3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
      +    4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
      +        (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
      +        (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
      +        (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
      +        (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
      +
      +        You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
      +    5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
      +    6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
      +    7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
      +    8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
      +    9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!) The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +"License" shall mean the terms and conditions for use, reproduction,
      +and distribution as defined by Sections 1 through 9 of this document.
      +
      +"Licensor" shall mean the copyright owner or entity authorized by
      +the copyright owner that is granting the License.
      +
      +"Legal Entity" shall mean the union of the acting entity and all
      +other entities that control, are controlled by, or are under common
      +control with that entity. For the purposes of this definition,
      +"control" means (i) the power, direct or indirect, to cause the
      +direction or management of such entity, whether by contract or
      +otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +"You" (or "Your") shall mean an individual or Legal Entity
      +exercising permissions granted by this License.
      +
      +"Source" form shall mean the preferred form for making modifications,
      +including but not limited to software source code, documentation
      +source, and configuration files.
      +
      +"Object" form shall mean any form resulting from mechanical
      +transformation or translation of a Source form, including but
      +not limited to compiled object code, generated documentation,
      +and conversions to other media types.
      +
      +"Work" shall mean the work of authorship, whether in Source or
      +Object form, made available under the License, as indicated by a
      +copyright notice that is included in or attached to the work
      +(an example is provided in the Appendix below).
      +
      +"Derivative Works" shall mean any work, whether in Source or Object
      +form, that is based on (or derived from) the Work and for which the
      +editorial revisions, annotations, elaborations, or other modifications
      +represent, as a whole, an original work of authorship. For the purposes
      +of this License, Derivative Works shall not include works that remain
      +separable from, or merely link (or bind by name) to the interfaces of,
      +the Work and Derivative Works thereof.
      +
      +"Contribution" shall mean any work of authorship, including
      +the original version of the Work and any modifications or additions
      +to that Work or Derivative Works thereof, that is intentionally
      +submitted to Licensor for inclusion in the Work by the copyright owner
      +or by an individual or Legal Entity authorized to submit on behalf of
      +the copyright owner. For the purposes of this definition, "submitted"
      +means any form of electronic, verbal, or written communication sent
      +to the Licensor or its representatives, including but not limited to
      +communication on electronic mailing lists, source code control systems,
      +and issue tracking systems that are managed by, or on behalf of, the
      +Licensor for the purpose of discussing and improving the Work, but
      +excluding communication that is conspicuously marked or otherwise
      +designated in writing by the copyright owner as "Not a Contribution."
      +
      +"Contributor" shall mean Licensor and any individual or Legal Entity
      +on behalf of whom a Contribution has been received by Licensor and
      +subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +copyright license to reproduce, prepare Derivative Works of,
      +publicly display, publicly perform, sublicense, and distribute the
      +Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +this License, each Contributor hereby grants to You a perpetual,
      +worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +(except as stated in this section) patent license to make, have made,
      +use, offer to sell, sell, import, and otherwise transfer the Work,
      +where such license applies only to those patent claims licensable
      +by such Contributor that are necessarily infringed by their
      +Contribution(s) alone or by combination of their Contribution(s)
      +with the Work to which such Contribution(s) was submitted. If You
      +institute patent litigation against any entity (including a
      +cross-claim or counterclaim in a lawsuit) alleging that the Work
      +or a Contribution incorporated within the Work constitutes direct
      +or contributory patent infringement, then any patent licenses
      +granted to You under this License for that Work shall terminate
      +as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +Work or Derivative Works thereof in any medium, with or without
      +modifications, and in Source or Object form, provided that You
      +meet the following conditions:
      +
      +(a) You must give any other recipients of the Work or
      +Derivative Works a copy of this License; and
      +
      +(b) You must cause any modified files to carry prominent notices
      +stating that You changed the files; and
      +
      +(c) You must retain, in the Source form of any Derivative Works
      +that You distribute, all copyright, patent, trademark, and
      +attribution notices from the Source form of the Work,
      +excluding those notices that do not pertain to any part of
      +the Derivative Works; and
      +
      +(d) If the Work includes a "NOTICE" text file as part of its
      +distribution, then any Derivative Works that You distribute must
      +include a readable copy of the attribution notices contained
      +within such NOTICE file, excluding those notices that do not
      +pertain to any part of the Derivative Works, in at least one
      +of the following places: within a NOTICE text file distributed
      +as part of the Derivative Works; within the Source form or
      +documentation, if provided along with the Derivative Works; or,
      +within a display generated by the Derivative Works, if and
      +wherever such third-party notices normally appear. The contents
      +of the NOTICE file are for informational purposes only and
      +do not modify the License. You may add Your own attribution
      +notices within Derivative Works that You distribute, alongside
      +or as an addendum to the NOTICE text from the Work, provided
      +that such additional attribution notices cannot be construed
      +as modifying the License.
      +
      +You may add Your own copyright statement to Your modifications and
      +may provide additional or different license terms and conditions
      +for use, reproduction, or distribution of Your modifications, or
      +for any such Derivative Works as a whole, provided Your use,
      +reproduction, and distribution of the Work otherwise complies with
      +the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +any Contribution intentionally submitted for inclusion in the Work
      +by You to the Licensor shall be under the terms and conditions of
      +this License, without any additional terms or conditions.
      +Notwithstanding the above, nothing herein shall supersede or modify
      +the terms of any separate license agreement you may have executed
      +with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +names, trademarks, service marks, or product names of the Licensor,
      +except as required for reasonable and customary use in describing the
      +origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +agreed to in writing, Licensor provides the Work (and each
      +Contributor provides its Contributions) on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +implied, including, without limitation, any warranties or conditions
      +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +PARTICULAR PURPOSE. You are solely responsible for determining the
      +appropriateness of using or redistributing the Work and assume any
      +risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +whether in tort (including negligence), contract, or otherwise,
      +unless required by applicable law (such as deliberate and grossly
      +negligent acts) or agreed to in writing, shall any Contributor be
      +liable to You for damages, including any direct, indirect, special,
      +incidental, or consequential damages of any character arising as a
      +result of this License or out of the use or inability to use the
      +Work (including but not limited to damages for loss of goodwill,
      +work stoppage, computer failure or malfunction, or any and all
      +other commercial damages or losses), even if such Contributor
      +has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +the Work or Derivative Works thereof, You may choose to offer,
      +and charge a fee for, acceptance of support, warranty, indemnity,
      +or other liability obligations and/or rights consistent with this
      +License. However, in accepting such obligations, You may act only
      +on Your own behalf and on Your sole responsibility, not on behalf
      +of any other Contributor, and only if You agree to indemnify,
      +defend, and hold each Contributor harmless for any liability
      +incurred by, or claims asserted against, such Contributor by reason
      +of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following
      +boilerplate notice, with the fields enclosed by brackets "[]"
      +replaced with your own identifying information. (Don't include
      +the brackets!)  The text should be enclosed in the appropriate
      +comment syntax for the file format. We also recommend that a
      +file or class name and description of purpose be included on the
      +same "printed page" as the copyright notice for easier
      +identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Apache License
      +Version 2.0, January 2004
      +http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +"License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document.
      +
      +"Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License.
      +
      +"Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +"You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License.
      +
      +"Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files.
      +
      +"Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types.
      +
      +"Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below).
      +
      +"Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof.
      +
      +"Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution."
      +
      +"Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions:
      +
      +     (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and
      +
      +     (b) You must cause any modified files to carry prominent notices stating that You changed the files; and
      +
      +     (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and
      +
      +     (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License.
      +
      +     You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +To apply the Apache License to your work, attach the following boilerplate notice, with the fields enclosed by brackets "[]" replaced with your own identifying information. (Don't include the brackets!)  The text should be enclosed in the appropriate comment syntax for the file format. We also recommend that a file or class name and description of purpose be included on the same "printed page" as the copyright notice for easier identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Chrono-TZ is dual-licensed under the MIT License and Apache 2.0 Licence.
      +The licenses do not apply to files in the tzdb folder which are in the
      +public domain. parse-zoneinfo was forked from zoneinfo-parse, which
      +was originally created by Benjamin Sago under the MIT license.
      +
      +Copyright (c) 2016-2024 Benjamin Sago & the chronotope maintainers
      +
      +The MIT License
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright 2016 Djzin
      +
      +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.
      +
      +
    • +
    • +

      Apache License 2.0

      +

      Used by:

      + +
      Rust-chrono is dual-licensed under The MIT License [1] and
      +Apache 2.0 License [2]. Copyright (c) 2014--2026, Kang Seonghoon and
      +contributors.
      +
      +Nota Bene: This is same as the Rust Project's own license.
      +
      +
      +[1]: <http://opensource.org/licenses/MIT>, which is reproduced below:
      +
      +~~~~
      +The MIT License (MIT)
      +
      +Copyright (c) 2014, Kang Seonghoon.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +~~~~
      +
      +
      +[2]: <http://www.apache.org/licenses/LICENSE-2.0>, which is reproduced below:
      +
      +~~~~
      +                              Apache License
      +                        Version 2.0, January 2004
      +                     http://www.apache.org/licenses/
      +
      +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
      +
      +1. Definitions.
      +
      +   "License" shall mean the terms and conditions for use, reproduction,
      +   and distribution as defined by Sections 1 through 9 of this document.
      +
      +   "Licensor" shall mean the copyright owner or entity authorized by
      +   the copyright owner that is granting the License.
      +
      +   "Legal Entity" shall mean the union of the acting entity and all
      +   other entities that control, are controlled by, or are under common
      +   control with that entity. For the purposes of this definition,
      +   "control" means (i) the power, direct or indirect, to cause the
      +   direction or management of such entity, whether by contract or
      +   otherwise, or (ii) ownership of fifty percent (50%) or more of the
      +   outstanding shares, or (iii) beneficial ownership of such entity.
      +
      +   "You" (or "Your") shall mean an individual or Legal Entity
      +   exercising permissions granted by this License.
      +
      +   "Source" form shall mean the preferred form for making modifications,
      +   including but not limited to software source code, documentation
      +   source, and configuration files.
      +
      +   "Object" form shall mean any form resulting from mechanical
      +   transformation or translation of a Source form, including but
      +   not limited to compiled object code, generated documentation,
      +   and conversions to other media types.
      +
      +   "Work" shall mean the work of authorship, whether in Source or
      +   Object form, made available under the License, as indicated by a
      +   copyright notice that is included in or attached to the work
      +   (an example is provided in the Appendix below).
      +
      +   "Derivative Works" shall mean any work, whether in Source or Object
      +   form, that is based on (or derived from) the Work and for which the
      +   editorial revisions, annotations, elaborations, or other modifications
      +   represent, as a whole, an original work of authorship. For the purposes
      +   of this License, Derivative Works shall not include works that remain
      +   separable from, or merely link (or bind by name) to the interfaces of,
      +   the Work and Derivative Works thereof.
      +
      +   "Contribution" shall mean any work of authorship, including
      +   the original version of the Work and any modifications or additions
      +   to that Work or Derivative Works thereof, that is intentionally
      +   submitted to Licensor for inclusion in the Work by the copyright owner
      +   or by an individual or Legal Entity authorized to submit on behalf of
      +   the copyright owner. For the purposes of this definition, "submitted"
      +   means any form of electronic, verbal, or written communication sent
      +   to the Licensor or its representatives, including but not limited to
      +   communication on electronic mailing lists, source code control systems,
      +   and issue tracking systems that are managed by, or on behalf of, the
      +   Licensor for the purpose of discussing and improving the Work, but
      +   excluding communication that is conspicuously marked or otherwise
      +   designated in writing by the copyright owner as "Not a Contribution."
      +
      +   "Contributor" shall mean Licensor and any individual or Legal Entity
      +   on behalf of whom a Contribution has been received by Licensor and
      +   subsequently incorporated within the Work.
      +
      +2. Grant of Copyright License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   copyright license to reproduce, prepare Derivative Works of,
      +   publicly display, publicly perform, sublicense, and distribute the
      +   Work and such Derivative Works in Source or Object form.
      +
      +3. Grant of Patent License. Subject to the terms and conditions of
      +   this License, each Contributor hereby grants to You a perpetual,
      +   worldwide, non-exclusive, no-charge, royalty-free, irrevocable
      +   (except as stated in this section) patent license to make, have made,
      +   use, offer to sell, sell, import, and otherwise transfer the Work,
      +   where such license applies only to those patent claims licensable
      +   by such Contributor that are necessarily infringed by their
      +   Contribution(s) alone or by combination of their Contribution(s)
      +   with the Work to which such Contribution(s) was submitted. If You
      +   institute patent litigation against any entity (including a
      +   cross-claim or counterclaim in a lawsuit) alleging that the Work
      +   or a Contribution incorporated within the Work constitutes direct
      +   or contributory patent infringement, then any patent licenses
      +   granted to You under this License for that Work shall terminate
      +   as of the date such litigation is filed.
      +
      +4. Redistribution. You may reproduce and distribute copies of the
      +   Work or Derivative Works thereof in any medium, with or without
      +   modifications, and in Source or Object form, provided that You
      +   meet the following conditions:
      +
      +   (a) You must give any other recipients of the Work or
      +       Derivative Works a copy of this License; and
      +
      +   (b) You must cause any modified files to carry prominent notices
      +       stating that You changed the files; and
      +
      +   (c) You must retain, in the Source form of any Derivative Works
      +       that You distribute, all copyright, patent, trademark, and
      +       attribution notices from the Source form of the Work,
      +       excluding those notices that do not pertain to any part of
      +       the Derivative Works; and
      +
      +   (d) If the Work includes a "NOTICE" text file as part of its
      +       distribution, then any Derivative Works that You distribute must
      +       include a readable copy of the attribution notices contained
      +       within such NOTICE file, excluding those notices that do not
      +       pertain to any part of the Derivative Works, in at least one
      +       of the following places: within a NOTICE text file distributed
      +       as part of the Derivative Works; within the Source form or
      +       documentation, if provided along with the Derivative Works; or,
      +       within a display generated by the Derivative Works, if and
      +       wherever such third-party notices normally appear. The contents
      +       of the NOTICE file are for informational purposes only and
      +       do not modify the License. You may add Your own attribution
      +       notices within Derivative Works that You distribute, alongside
      +       or as an addendum to the NOTICE text from the Work, provided
      +       that such additional attribution notices cannot be construed
      +       as modifying the License.
      +
      +   You may add Your own copyright statement to Your modifications and
      +   may provide additional or different license terms and conditions
      +   for use, reproduction, or distribution of Your modifications, or
      +   for any such Derivative Works as a whole, provided Your use,
      +   reproduction, and distribution of the Work otherwise complies with
      +   the conditions stated in this License.
      +
      +5. Submission of Contributions. Unless You explicitly state otherwise,
      +   any Contribution intentionally submitted for inclusion in the Work
      +   by You to the Licensor shall be under the terms and conditions of
      +   this License, without any additional terms or conditions.
      +   Notwithstanding the above, nothing herein shall supersede or modify
      +   the terms of any separate license agreement you may have executed
      +   with Licensor regarding such Contributions.
      +
      +6. Trademarks. This License does not grant permission to use the trade
      +   names, trademarks, service marks, or product names of the Licensor,
      +   except as required for reasonable and customary use in describing the
      +   origin of the Work and reproducing the content of the NOTICE file.
      +
      +7. Disclaimer of Warranty. Unless required by applicable law or
      +   agreed to in writing, Licensor provides the Work (and each
      +   Contributor provides its Contributions) on an "AS IS" BASIS,
      +   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
      +   implied, including, without limitation, any warranties or conditions
      +   of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
      +   PARTICULAR PURPOSE. You are solely responsible for determining the
      +   appropriateness of using or redistributing the Work and assume any
      +   risks associated with Your exercise of permissions under this License.
      +
      +8. Limitation of Liability. In no event and under no legal theory,
      +   whether in tort (including negligence), contract, or otherwise,
      +   unless required by applicable law (such as deliberate and grossly
      +   negligent acts) or agreed to in writing, shall any Contributor be
      +   liable to You for damages, including any direct, indirect, special,
      +   incidental, or consequential damages of any character arising as a
      +   result of this License or out of the use or inability to use the
      +   Work (including but not limited to damages for loss of goodwill,
      +   work stoppage, computer failure or malfunction, or any and all
      +   other commercial damages or losses), even if such Contributor
      +   has been advised of the possibility of such damages.
      +
      +9. Accepting Warranty or Additional Liability. While redistributing
      +   the Work or Derivative Works thereof, You may choose to offer,
      +   and charge a fee for, acceptance of support, warranty, indemnity,
      +   or other liability obligations and/or rights consistent with this
      +   License. However, in accepting such obligations, You may act only
      +   on Your own behalf and on Your sole responsibility, not on behalf
      +   of any other Contributor, and only if You agree to indemnify,
      +   defend, and hold each Contributor harmless for any liability
      +   incurred by, or claims asserted against, such Contributor by reason
      +   of your accepting any such warranty or additional liability.
      +
      +END OF TERMS AND CONDITIONS
      +
      +APPENDIX: How to apply the Apache License to your work.
      +
      +   To apply the Apache License to your work, attach the following
      +   boilerplate notice, with the fields enclosed by brackets "[]"
      +   replaced with your own identifying information. (Don't include
      +   the brackets!)  The text should be enclosed in the appropriate
      +   comment syntax for the file format. We also recommend that a
      +   file or class name and description of purpose be included on the
      +   same "printed page" as the copyright notice for easier
      +   identification within third-party archives.
      +
      +Copyright [yyyy] [name of copyright owner]
      +
      +Licensed under the Apache License, Version 2.0 (the "License");
      +you may not use this file except in compliance with the License.
      +You may obtain a copy of the License at
      +
      +	http://www.apache.org/licenses/LICENSE-2.0
      +
      +Unless required by applicable law or agreed to in writing, software
      +distributed under the License is distributed on an "AS IS" BASIS,
      +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
      +See the License for the specific language governing permissions and
      +limitations under the License.
      +~~~~
      +
      +
      +
    • +
    • +

      BSD 2-Clause "Simplified" License

      +

      Used by:

      + +
      Copyright (c) 2013-2014, Naoki Yoshinaga, Paul Meng
      +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.
      +
      +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
      +HOLDER 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.
      +
      +
    • +
    • +

      BSD 2-Clause "Simplified" License

      +

      Used by:

      + +
      Copyright (c) 2015 David Roundy <roundyd@physics.oregonstate.edu>
      +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.
      +
      +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
      +HOLDER 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.
      +
      +
    • +
    • +

      BSD 2-Clause "Simplified" License

      +

      Used by:

      + +
      Copyright (c) 2017-2020
      +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.
      +
      +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.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      BSD 3-Clause License
      +
      +Copyright (c) 2013, Julien Schmidt
      +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 copyright holder 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 HOLDER 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.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) 2001, Dr Martin Porter
      +Copyright (c) 2004,2005, Richard Boulton
      +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.
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) 2016 Dropbox, Inc.
      +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 copyright holder 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 HOLDER 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.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) 2016-2017 Isis Agora Lovecruft, Henry de Valence. All rights reserved.
      +Copyright (c) 2016-2024 Isis Agora Lovecruft. 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 copyright holder 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
      +HOLDER 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. 
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright (c) <year> <owner>. 
      +
      +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 copyright holder 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 HOLDER 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.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright 2011, The Snappy-Rust Authors. All rights reserved.
      +
      +Redistribution and use in source and binary forms, with or without
      +modification, are permitted provided that the following conditions are
      +met:
      +
      +    * Redistributions of source code must retain the above copyright
      +notice, this list of conditions and the following disclaimer.
      +    * 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.
      +    * Neither the name of the copyright holder 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.
      +
      +
    • +
    • +

      BSD 3-Clause "New" or "Revised" License

      +

      Used by:

      + +
      Copyright © WHATWG (Apple, Google, Mozilla, Microsoft).
      +
      +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 copyright holder 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 HOLDER 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.
      +
      +
    • +
    • +

      Boost Software License 1.0

      +

      Used by:

      + +
      Boost Software License - Version 1.0 - August 17th, 2003
      +
      +Permission is hereby granted, free of charge, to any person or organization
      +obtaining a copy of the software and accompanying documentation covered by
      +this license (the "Software") to use, reproduce, display, distribute,
      +execute, and transmit the Software, and to prepare derivative works of the
      +Software, and to permit third-parties to whom the Software is furnished to
      +do so, all subject to the following:
      +
      +The copyright notices in the Software and this entire statement, including
      +the above license grant, this restriction and the following disclaimer,
      +must be included in all copies of the Software, in whole or in part, and
      +all derivative works of the Software, unless such copies or derivative
      +works are solely in the form of machine-executable object code generated by
      +a source language processor.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT
      +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE
      +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE,
      +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      Creative Commons Zero v1.0 Universal

      +

      Used by:

      + +
      Creative Commons Legal Code
      +
      +CC0 1.0 Universal
      +
      +    CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE
      +    LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN
      +    ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS
      +    INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES
      +    REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS
      +    PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM
      +    THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED
      +    HEREUNDER.
      +
      +Statement of Purpose
      +
      +The laws of most jurisdictions throughout the world automatically confer
      +exclusive Copyright and Related Rights (defined below) upon the creator
      +and subsequent owner(s) (each and all, an "owner") of an original work of
      +authorship and/or a database (each, a "Work").
      +
      +Certain owners wish to permanently relinquish those rights to a Work for
      +the purpose of contributing to a commons of creative, cultural and
      +scientific works ("Commons") that the public can reliably and without fear
      +of later claims of infringement build upon, modify, incorporate in other
      +works, reuse and redistribute as freely as possible in any form whatsoever
      +and for any purposes, including without limitation commercial purposes.
      +These owners may contribute to the Commons to promote the ideal of a free
      +culture and the further production of creative, cultural and scientific
      +works, or to gain reputation or greater distribution for their Work in
      +part through the use and efforts of others.
      +
      +For these and/or other purposes and motivations, and without any
      +expectation of additional consideration or compensation, the person
      +associating CC0 with a Work (the "Affirmer"), to the extent that he or she
      +is an owner of Copyright and Related Rights in the Work, voluntarily
      +elects to apply CC0 to the Work and publicly distribute the Work under its
      +terms, with knowledge of his or her Copyright and Related Rights in the
      +Work and the meaning and intended legal effect of CC0 on those rights.
      +
      +1. Copyright and Related Rights. A Work made available under CC0 may be
      +protected by copyright and related or neighboring rights ("Copyright and
      +Related Rights"). Copyright and Related Rights include, but are not
      +limited to, the following:
      +
      +  i. the right to reproduce, adapt, distribute, perform, display,
      +     communicate, and translate a Work;
      + ii. moral rights retained by the original author(s) and/or performer(s);
      +iii. publicity and privacy rights pertaining to a person's image or
      +     likeness depicted in a Work;
      + iv. rights protecting against unfair competition in regards to a Work,
      +     subject to the limitations in paragraph 4(a), below;
      +  v. rights protecting the extraction, dissemination, use and reuse of data
      +     in a Work;
      + vi. database rights (such as those arising under Directive 96/9/EC of the
      +     European Parliament and of the Council of 11 March 1996 on the legal
      +     protection of databases, and under any national implementation
      +     thereof, including any amended or successor version of such
      +     directive); and
      +vii. other similar, equivalent or corresponding rights throughout the
      +     world based on applicable law or treaty, and any national
      +     implementations thereof.
      +
      +2. Waiver. To the greatest extent permitted by, but not in contravention
      +of, applicable law, Affirmer hereby overtly, fully, permanently,
      +irrevocably and unconditionally waives, abandons, and surrenders all of
      +Affirmer's Copyright and Related Rights and associated claims and causes
      +of action, whether now known or unknown (including existing as well as
      +future claims and causes of action), in the Work (i) in all territories
      +worldwide, (ii) for the maximum duration provided by applicable law or
      +treaty (including future time extensions), (iii) in any current or future
      +medium and for any number of copies, and (iv) for any purpose whatsoever,
      +including without limitation commercial, advertising or promotional
      +purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each
      +member of the public at large and to the detriment of Affirmer's heirs and
      +successors, fully intending that such Waiver shall not be subject to
      +revocation, rescission, cancellation, termination, or any other legal or
      +equitable action to disrupt the quiet enjoyment of the Work by the public
      +as contemplated by Affirmer's express Statement of Purpose.
      +
      +3. Public License Fallback. Should any part of the Waiver for any reason
      +be judged legally invalid or ineffective under applicable law, then the
      +Waiver shall be preserved to the maximum extent permitted taking into
      +account Affirmer's express Statement of Purpose. In addition, to the
      +extent the Waiver is so judged Affirmer hereby grants to each affected
      +person a royalty-free, non transferable, non sublicensable, non exclusive,
      +irrevocable and unconditional license to exercise Affirmer's Copyright and
      +Related Rights in the Work (i) in all territories worldwide, (ii) for the
      +maximum duration provided by applicable law or treaty (including future
      +time extensions), (iii) in any current or future medium and for any number
      +of copies, and (iv) for any purpose whatsoever, including without
      +limitation commercial, advertising or promotional purposes (the
      +"License"). The License shall be deemed effective as of the date CC0 was
      +applied by Affirmer to the Work. Should any part of the License for any
      +reason be judged legally invalid or ineffective under applicable law, such
      +partial invalidity or ineffectiveness shall not invalidate the remainder
      +of the License, and in such case Affirmer hereby affirms that he or she
      +will not (i) exercise any of his or her remaining Copyright and Related
      +Rights in the Work or (ii) assert any associated claims and causes of
      +action with respect to the Work, in either case contrary to Affirmer's
      +express Statement of Purpose.
      +
      +4. Limitations and Disclaimers.
      +
      + a. No trademark or patent rights held by Affirmer are waived, abandoned,
      +    surrendered, licensed or otherwise affected by this document.
      + b. Affirmer offers the Work as-is and makes no representations or
      +    warranties of any kind concerning the Work, express, implied,
      +    statutory or otherwise, including without limitation warranties of
      +    title, merchantability, fitness for a particular purpose, non
      +    infringement, or the absence of latent or other defects, accuracy, or
      +    the present or absence of errors, whether or not discoverable, all to
      +    the greatest extent permissible under applicable law.
      + c. Affirmer disclaims responsibility for clearing rights of other persons
      +    that may apply to the Work or any use thereof, including without
      +    limitation any person's Copyright and Related Rights in the Work.
      +    Further, Affirmer disclaims responsibility for obtaining any necessary
      +    consents, permissions or other rights required for any use of the
      +    Work.
      + d. Affirmer understands and acknowledges that Creative Commons is not a
      +    party to this document and has no duty or obligation with respect to
      +    this CC0 or use of the Work.
      +
      +
    • +
    • +

      Community Data License Agreement Permissive 2.0

      +

      Used by:

      + +
      # Community Data License Agreement - Permissive - Version 2.0
      +
      +This is the Community Data License Agreement - Permissive, Version
      +2.0 (the "agreement"). Data Provider(s) and Data Recipient(s) agree
      +as follows:
      +
      +## 1. Provision of the Data
      +
      +1.1. A Data Recipient may use, modify, and share the Data made
      +available by Data Provider(s) under this agreement if that Data
      +Recipient follows the terms of this agreement.
      +
      +1.2. This agreement does not impose any restriction on a Data
      +Recipient's use, modification, or sharing of any portions of the
      +Data that are in the public domain or that may be used, modified,
      +or shared under any other legal exception or limitation.
      +
      +## 2. Conditions for Sharing Data
      +
      +2.1. A Data Recipient may share Data, with or without modifications, so
      +long as the Data Recipient makes available the text of this agreement
      +with the shared Data.
      +
      +## 3. No Restrictions on Results
      +
      +3.1. This agreement does not impose any restriction or obligations
      +with respect to the use, modification, or sharing of Results.
      +
      +## 4. No Warranty; Limitation of Liability
      +
      +4.1. All Data Recipients receive the Data subject to the following
      +terms:
      +
      +THE DATA IS PROVIDED ON AN "AS IS" BASIS, WITHOUT REPRESENTATIONS,
      +WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR IMPLIED
      +INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF TITLE,
      +NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
      +
      +NO DATA PROVIDER SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT,
      +INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING
      +WITHOUT LIMITATION LOST PROFITS), 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 DATA OR RESULTS,
      +EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
      +
      +## 5. Definitions
      +
      +5.1. "Data" means the material received by a Data Recipient under
      +this agreement.
      +
      +5.2. "Data Provider" means any person who is the source of Data
      +provided under this agreement and in reliance on a Data Recipient's
      +agreement to its terms.
      +
      +5.3. "Data Recipient" means any person who receives Data directly
      +or indirectly from a Data Provider and agrees to the terms of this
      +agreement.
      +
      +5.4. "Results" means any outcome obtained by computational analysis
      +of Data, including for example machine learning models and models'
      +insights.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      // Copyright 2015-2016 Brian Smith.
      +//
      +// Permission to use, copy, modify, and/or distribute this software for any
      +// purpose with or without fee is hereby granted, provided that the above
      +// copyright notice and this permission notice appear in all copies.
      +//
      +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
      +// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
      +// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
      +// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
      +// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Copyright (c) 2017 Adam Wick
      +
      +Permission to use, copy, modify, and/or distribute this software for any purpose
      +with or without fee is hereby granted, provided that the above copyright notice
      +and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
      +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
      +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
      +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
      +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
      +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
      +THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Copyright 2015-2025 Brian Smith.
      +
      +Permission to use, copy, modify, and/or distribute this software for any
      +purpose with or without fee is hereby granted, provided that the above
      +copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
      +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY
      +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION
      +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN
      +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Copyright © 2015, Simonas Kazlauskas
      +
      +Permission to use, copy, modify, and/or distribute this software for any purpose with or without
      +fee is hereby granted, provided that the above copyright notice and this permission notice appear
      +in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
      +SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
      +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
      +NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
      +THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      Except as otherwise noted, this project is licensed under the following
      +(ISC-style) terms:
      +
      +Copyright 2015 Brian Smith.
      +
      +Permission to use, copy, modify, and/or distribute this software for any
      +purpose with or without fee is hereby granted, provided that the above
      +copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHORS DISCLAIM ALL WARRANTIES
      +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
      +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR
      +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
      +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
      +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
      +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +The files under third-party/chromium are licensed as described in
      +third-party/chromium/LICENSE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      ISC License
      +
      +Copyright (c) 2016, Mapbox
      +Copyright (c) 2018, Tree Cricket
      +
      +Permission to use, copy, modify, and/or distribute this software for any purpose
      +with or without fee is hereby granted, provided that the above copyright notice
      +and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
      +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND
      +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
      +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS
      +OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER
      +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
      +THIS SOFTWARE.
      +
      +
    • +
    • +

      ISC License

      +

      Used by:

      + +
      ISC License:
      +
      +Copyright (c) 2004-2010 by Internet Systems Consortium, Inc. ("ISC")
      +Copyright (c) 1995-2003 by Internet Software Consortium
      +
      +Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
      +
      +THE SOFTWARE IS PROVIDED "AS IS" AND ISC DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL ISC BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
                                        MIT License
      +
      +Copyright 2024 Antoine Catton
      +Copyright 2016-2024 David Tolnay
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of
      +this software and associated documentation files (the “Software�), to deal in
      +the Software without restriction, including without limitation the rights to
      +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
      +of the Software, and to permit persons to whom the Software is furnished to do
      +so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED “AS IS�, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014 Carl Lerche and other MIO contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014-2019 Geoffroy Couprie
      +
      +Permission is hereby granted, free of charge, to any person obtaining
      +a copy of this software and associated documentation files (the
      +"Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish,
      +distribute, sublicense, and/or sell copies of the Software, and to
      +permit persons to whom the Software is furnished to do so, subject to
      +the following conditions:
      +
      +The above copyright notice and this permission notice shall be
      +included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
      +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
      +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
      +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2014-2026 Sean McArthur
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2015 Jonathan Reem
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2015 steffengy
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2016 Paul Masurel
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2017 Redox OS Developers
      +
      +MIT License
      +
      +Permission is hereby granted, free of charge, to any person obtaining
      +a copy of this software and associated documentation files (the
      +"Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish,
      +distribute, sublicense, and/or sell copies of the Software, and to
      +permit persons to whom the Software is furnished to do so, subject to
      +the following conditions:
      +
      +The above copyright notice and this permission notice shall be
      +included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
      +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
      +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
      +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2017 h2 authors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2018 Carl Lerche
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2018-2019 Sean McArthur
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2018-2023 Sean McArthur
      +Copyright (c) 2016 Alex Crichton
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Axum Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Carl Lerche
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Eliza Weisman
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Eliza Weisman
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Hyper Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019 Tower Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019-2021 Tower Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019-2024 Sean McArthur & Hyper Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2019-2025 Sean McArthur & Hyper Contributors
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2020 RustCrypto Developers
      +Copyright (c) 2017 Project Nayuki, Artyom Pavlov
      +
      +Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2022-present David Hewitt and Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright (c) 2023-2025 Sean McArthur
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2016 Nika Layzell
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2020 David Koloski
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2021 Axum Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2021 David Koloski
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2023 David Koloski
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2023 David Koloski
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2024 David Koloski
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Copyright 2024, Orson R. L. Peters
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of
      +this software and associated documentation files (the “Software�), to deal in
      +the Software without restriction, including without limitation the rights to
      +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software is furnished to do so,
      +subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED “AS IS�, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
      +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
      +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
      +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2016 Michael Ma
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Denis Kurilenko
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Elliot Linder <darfink@gmail.com>
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Garrett Berg <vitiral@gmail.com>
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Sam Rijs
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2017 Ted Driggs
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2018 The typed-arena developers
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2018 myrrlyn (Alexander Payne)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2018 sgodwincs
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Acrimon
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Aeledfyr
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Arne Beer
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Graham Esau
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Peter Glotfelty
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 Yoshua Wuyts
      +Copyright (c) Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2019 kneecaw (Nika Layzell)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 Anastasios Bakogiannis
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 Scripta Qumranica Electronica
      +
      +Created by Bronson Brown-deVost
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 Thoren Paulson
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 magiclen.org (Ron Li)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020 myrrlyn (Alexander Payne)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020-2022 Oliver Margetts
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2020-2026 Nugine
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2021 Sam Sartor
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2022 Ibraheem Ahmed
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2022 Nugine
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2023 4lDO2
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2023 iShape-Rust
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2024 Spire Team
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) 2024 iShape-Rust
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) <year> <copyright holders>
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
      +associated documentation files (the "Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
      +following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial
      +portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
      +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
      +EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
      +USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) Tokio Contributors
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      MIT License
      +
      +Copyright (c) [2021] [Marvin Countryman]
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Permission is hereby granted, free of charge, to any
      +person obtaining a copy of this software and associated
      +documentation files (the "Software"), to deal in the
      +Software without restriction, including without
      +limitation the rights to use, copy, modify, merge,
      +publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software
      +is furnished to do so, subject to the following
      +conditions:
      +
      +The above copyright notice and this permission notice
      +shall be included in all copies or substantial portions
      +of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
      +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
      +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
      +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
      +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
      +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
      +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
      +DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      Permission is hereby granted, free of charge, to any person obtaining
      +a copy of this software and associated documentation files (the
      +"Software"), to deal in the Software without restriction, including
      +without limitation the rights to use, copy, modify, merge, publish,
      +distribute, sublicense, and/or sell copies of the Software, and to
      +permit persons to whom the Software is furnished to do so, subject to
      +the following conditions:
      +
      +The above copyright notice and this permission notice shall be
      +included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
      +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
      +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
      +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
      +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
      +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014 Mathijs van de Nes
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014 Y. T. CHUNG
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014-2022 Steven Fackler, Yuki Okushi
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of
      +this software and associated documentation files (the "Software"), to deal in
      +the Software without restriction, including without limitation the rights to
      +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software is furnished to do so,
      +subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
      +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
      +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
      +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Andrew Gallant
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Artem V. Navrotskiy
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Danny Guo
      +Copyright (c) 2016 Titus Wormer <tituswormer@gmail.com>
      +Copyright (c) 2018 Akash Kurdekar
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Guillaume Gomez
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Jake Goulding
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Markus Westerlind
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Vincent Prouillet
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015-2020 the fiat-crypto authors (see
      +https://github.com/mit-plv/fiat-crypto/blob/master/AUTHORS).
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Google Inc. (lewinb@google.com) -- though not an official
      +Google product or in any way related!
      +Copyright (c) 2018-2020 Lewin Bormann (lbo@spheniscida.de)
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to
      +deal in the Software without restriction, including without limitation the
      +rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
      +sell copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
      +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
      +IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Jonathan Creekmore
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 nabijaczleweli
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2017 Andrew Gallant
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2017 Jose Narvaez
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2020 Pascal Seitz
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of
      +this software and associated documentation files (the "Software"), to deal in
      +the Software without restriction, including without limitation the rights to
      +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
      +the Software, and to permit persons to whom the Software is furnished to do so,
      +subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
      +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
      +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
      +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
      +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright 2017-2023 Eira Fransham.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +Copyright (c) 2016 Alexandre Bury
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2014 Benjamin Sago
      +Copyright (c) 2021-2022 The Nushell Project Developers
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Austin Bonander
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2015 Bartłomiej Kamiński
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in all
      +copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
      +SOFTWARE.
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      The MIT License (MIT)
      +
      +Copyright (c) 2016 Johann Tuffe
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      MIT License

      +

      Used by:

      + +
      © 2016 Bertram Truong
      +© 2021 Kornel Lesiński
      +
      +Permission is hereby granted, free of charge, to any person obtaining a copy
      +of this software and associated documentation files (the "Software"), to deal
      +in the Software without restriction, including without limitation the rights
      +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
      +copies of the Software, and to permit persons to whom the Software is
      +furnished to do so, subject to the following conditions:
      +
      +The above copyright notice and this permission notice shall be included in
      +all copies or substantial portions of the Software.
      +
      +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
      +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
      +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
      +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
      +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
      +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
      +THE SOFTWARE.
      +
      +
    • +
    • +

      Mozilla Public License 2.0

      +

      Used by:

      + +
      Mozilla Public License Version 2.0
      +==================================
      +
      +1. Definitions
      +--------------
      +
      +1.1. "Contributor"
      +    means each individual or legal entity that creates, contributes to
      +    the creation of, or owns Covered Software.
      +
      +1.2. "Contributor Version"
      +    means the combination of the Contributions of others (if any) used
      +    by a Contributor and that particular Contributor's Contribution.
      +
      +1.3. "Contribution"
      +    means Covered Software of a particular Contributor.
      +
      +1.4. "Covered Software"
      +    means Source Code Form to which the initial Contributor has attached
      +    the notice in Exhibit A, the Executable Form of such Source Code
      +    Form, and Modifications of such Source Code Form, in each case
      +    including portions thereof.
      +
      +1.5. "Incompatible With Secondary Licenses"
      +    means
      +
      +    (a) that the initial Contributor has attached the notice described
      +        in Exhibit B to the Covered Software; or
      +
      +    (b) that the Covered Software was made available under the terms of
      +        version 1.1 or earlier of the License, but not also under the
      +        terms of a Secondary License.
      +
      +1.6. "Executable Form"
      +    means any form of the work other than Source Code Form.
      +
      +1.7. "Larger Work"
      +    means a work that combines Covered Software with other material, in
      +    a separate file or files, that is not Covered Software.
      +
      +1.8. "License"
      +    means this document.
      +
      +1.9. "Licensable"
      +    means having the right to grant, to the maximum extent possible,
      +    whether at the time of the initial grant or subsequently, any and
      +    all of the rights conveyed by this License.
      +
      +1.10. "Modifications"
      +    means any of the following:
      +
      +    (a) any file in Source Code Form that results from an addition to,
      +        deletion from, or modification of the contents of Covered
      +        Software; or
      +
      +    (b) any new file in Source Code Form that contains any Covered
      +        Software.
      +
      +1.11. "Patent Claims" of a Contributor
      +    means any patent claim(s), including without limitation, method,
      +    process, and apparatus claims, in any patent Licensable by such
      +    Contributor that would be infringed, but for the grant of the
      +    License, by the making, using, selling, offering for sale, having
      +    made, import, or transfer of either its Contributions or its
      +    Contributor Version.
      +
      +1.12. "Secondary License"
      +    means either the GNU General Public License, Version 2.0, the GNU
      +    Lesser General Public License, Version 2.1, the GNU Affero General
      +    Public License, Version 3.0, or any later versions of those
      +    licenses.
      +
      +1.13. "Source Code Form"
      +    means the form of the work preferred for making modifications.
      +
      +1.14. "You" (or "Your")
      +    means an individual or a legal entity exercising rights under this
      +    License. For legal entities, "You" includes any entity that
      +    controls, is controlled by, or is under common control with You. For
      +    purposes of this definition, "control" means (a) the power, direct
      +    or indirect, to cause the direction or management of such entity,
      +    whether by contract or otherwise, or (b) ownership of more than
      +    fifty percent (50%) of the outstanding shares or beneficial
      +    ownership of such entity.
      +
      +2. License Grants and Conditions
      +--------------------------------
      +
      +2.1. Grants
      +
      +Each Contributor hereby grants You a world-wide, royalty-free,
      +non-exclusive license:
      +
      +(a) under intellectual property rights (other than patent or trademark)
      +    Licensable by such Contributor to use, reproduce, make available,
      +    modify, display, perform, distribute, and otherwise exploit its
      +    Contributions, either on an unmodified basis, with Modifications, or
      +    as part of a Larger Work; and
      +
      +(b) under Patent Claims of such Contributor to make, use, sell, offer
      +    for sale, have made, import, and otherwise transfer either its
      +    Contributions or its Contributor Version.
      +
      +2.2. Effective Date
      +
      +The licenses granted in Section 2.1 with respect to any Contribution
      +become effective for each Contribution on the date the Contributor first
      +distributes such Contribution.
      +
      +2.3. Limitations on Grant Scope
      +
      +The licenses granted in this Section 2 are the only rights granted under
      +this License. No additional rights or licenses will be implied from the
      +distribution or licensing of Covered Software under this License.
      +Notwithstanding Section 2.1(b) above, no patent license is granted by a
      +Contributor:
      +
      +(a) for any code that a Contributor has removed from Covered Software;
      +    or
      +
      +(b) for infringements caused by: (i) Your and any other third party's
      +    modifications of Covered Software, or (ii) the combination of its
      +    Contributions with other software (except as part of its Contributor
      +    Version); or
      +
      +(c) under Patent Claims infringed by Covered Software in the absence of
      +    its Contributions.
      +
      +This License does not grant any rights in the trademarks, service marks,
      +or logos of any Contributor (except as may be necessary to comply with
      +the notice requirements in Section 3.4).
      +
      +2.4. Subsequent Licenses
      +
      +No Contributor makes additional grants as a result of Your choice to
      +distribute the Covered Software under a subsequent version of this
      +License (see Section 10.2) or under the terms of a Secondary License (if
      +permitted under the terms of Section 3.3).
      +
      +2.5. Representation
      +
      +Each Contributor represents that the Contributor believes its
      +Contributions are its original creation(s) or it has sufficient rights
      +to grant the rights to its Contributions conveyed by this License.
      +
      +2.6. Fair Use
      +
      +This License is not intended to limit any rights You have under
      +applicable copyright doctrines of fair use, fair dealing, or other
      +equivalents.
      +
      +2.7. Conditions
      +
      +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
      +in Section 2.1.
      +
      +3. Responsibilities
      +-------------------
      +
      +3.1. Distribution of Source Form
      +
      +All distribution of Covered Software in Source Code Form, including any
      +Modifications that You create or to which You contribute, must be under
      +the terms of this License. You must inform recipients that the Source
      +Code Form of the Covered Software is governed by the terms of this
      +License, and how they can obtain a copy of this License. You may not
      +attempt to alter or restrict the recipients' rights in the Source Code
      +Form.
      +
      +3.2. Distribution of Executable Form
      +
      +If You distribute Covered Software in Executable Form then:
      +
      +(a) such Covered Software must also be made available in Source Code
      +    Form, as described in Section 3.1, and You must inform recipients of
      +    the Executable Form how they can obtain a copy of such Source Code
      +    Form by reasonable means in a timely manner, at a charge no more
      +    than the cost of distribution to the recipient; and
      +
      +(b) You may distribute such Executable Form under the terms of this
      +    License, or sublicense it under different terms, provided that the
      +    license for the Executable Form does not attempt to limit or alter
      +    the recipients' rights in the Source Code Form under this License.
      +
      +3.3. Distribution of a Larger Work
      +
      +You may create and distribute a Larger Work under terms of Your choice,
      +provided that You also comply with the requirements of this License for
      +the Covered Software. If the Larger Work is a combination of Covered
      +Software with a work governed by one or more Secondary Licenses, and the
      +Covered Software is not Incompatible With Secondary Licenses, this
      +License permits You to additionally distribute such Covered Software
      +under the terms of such Secondary License(s), so that the recipient of
      +the Larger Work may, at their option, further distribute the Covered
      +Software under the terms of either this License or such Secondary
      +License(s).
      +
      +3.4. Notices
      +
      +You may not remove or alter the substance of any license notices
      +(including copyright notices, patent notices, disclaimers of warranty,
      +or limitations of liability) contained within the Source Code Form of
      +the Covered Software, except that You may alter any license notices to
      +the extent required to remedy known factual inaccuracies.
      +
      +3.5. Application of Additional Terms
      +
      +You may choose to offer, and to charge a fee for, warranty, support,
      +indemnity or liability obligations to one or more recipients of Covered
      +Software. However, You may do so only on Your own behalf, and not on
      +behalf of any Contributor. You must make it absolutely clear that any
      +such warranty, support, indemnity, or liability obligation is offered by
      +You alone, and You hereby agree to indemnify every Contributor for any
      +liability incurred by such Contributor as a result of warranty, support,
      +indemnity or liability terms You offer. You may include additional
      +disclaimers of warranty and limitations of liability specific to any
      +jurisdiction.
      +
      +4. Inability to Comply Due to Statute or Regulation
      +---------------------------------------------------
      +
      +If it is impossible for You to comply with any of the terms of this
      +License with respect to some or all of the Covered Software due to
      +statute, judicial order, or regulation then You must: (a) comply with
      +the terms of this License to the maximum extent possible; and (b)
      +describe the limitations and the code they affect. Such description must
      +be placed in a text file included with all distributions of the Covered
      +Software under this License. Except to the extent prohibited by statute
      +or regulation, such description must be sufficiently detailed for a
      +recipient of ordinary skill to be able to understand it.
      +
      +5. Termination
      +--------------
      +
      +5.1. The rights granted under this License will terminate automatically
      +if You fail to comply with any of its terms. However, if You become
      +compliant, then the rights granted under this License from a particular
      +Contributor are reinstated (a) provisionally, unless and until such
      +Contributor explicitly and finally terminates Your grants, and (b) on an
      +ongoing basis, if such Contributor fails to notify You of the
      +non-compliance by some reasonable means prior to 60 days after You have
      +come back into compliance. Moreover, Your grants from a particular
      +Contributor are reinstated on an ongoing basis if such Contributor
      +notifies You of the non-compliance by some reasonable means, this is the
      +first time You have received notice of non-compliance with this License
      +from such Contributor, and You become compliant prior to 30 days after
      +Your receipt of the notice.
      +
      +5.2. If You initiate litigation against any entity by asserting a patent
      +infringement claim (excluding declaratory judgment actions,
      +counter-claims, and cross-claims) alleging that a Contributor Version
      +directly or indirectly infringes any patent, then the rights granted to
      +You by any and all Contributors for the Covered Software under Section
      +2.1 of this License shall terminate.
      +
      +5.3. In the event of termination under Sections 5.1 or 5.2 above, all
      +end user license agreements (excluding distributors and resellers) which
      +have been validly granted by You or Your distributors under this License
      +prior to termination shall survive termination.
      +
      +************************************************************************
      +*                                                                      *
      +*  6. Disclaimer of Warranty                                           *
      +*  -------------------------                                           *
      +*                                                                      *
      +*  Covered Software is provided under this License on an "as is"       *
      +*  basis, without warranty of any kind, either expressed, implied, or  *
      +*  statutory, including, without limitation, warranties that the       *
      +*  Covered Software is free of defects, merchantable, fit for a        *
      +*  particular purpose or non-infringing. The entire risk as to the     *
      +*  quality and performance of the Covered Software is with You.        *
      +*  Should any Covered Software prove defective in any respect, You     *
      +*  (not any Contributor) assume the cost of any necessary servicing,   *
      +*  repair, or correction. This disclaimer of warranty constitutes an   *
      +*  essential part of this License. No use of any Covered Software is   *
      +*  authorized under this License except under this disclaimer.         *
      +*                                                                      *
      +************************************************************************
      +
      +************************************************************************
      +*                                                                      *
      +*  7. Limitation of Liability                                          *
      +*  --------------------------                                          *
      +*                                                                      *
      +*  Under no circumstances and under no legal theory, whether tort      *
      +*  (including negligence), contract, or otherwise, shall any           *
      +*  Contributor, or anyone who distributes Covered Software as          *
      +*  permitted above, be liable to You for any direct, indirect,         *
      +*  special, incidental, or consequential damages of any character      *
      +*  including, without limitation, damages for lost profits, loss of    *
      +*  goodwill, work stoppage, computer failure or malfunction, or any    *
      +*  and all other commercial damages or losses, even if such party      *
      +*  shall have been informed of the possibility of such damages. This   *
      +*  limitation of liability shall not apply to liability for death or   *
      +*  personal injury resulting from such party's negligence to the       *
      +*  extent applicable law prohibits such limitation. Some               *
      +*  jurisdictions do not allow the exclusion or limitation of           *
      +*  incidental or consequential damages, so this exclusion and          *
      +*  limitation may not apply to You.                                    *
      +*                                                                      *
      +************************************************************************
      +
      +8. Litigation
      +-------------
      +
      +Any litigation relating to this License may be brought only in the
      +courts of a jurisdiction where the defendant maintains its principal
      +place of business and such litigation shall be governed by laws of that
      +jurisdiction, without reference to its conflict-of-law provisions.
      +Nothing in this Section shall prevent a party's ability to bring
      +cross-claims or counter-claims.
      +
      +9. Miscellaneous
      +----------------
      +
      +This License represents the complete agreement concerning the subject
      +matter hereof. If any provision of this License is held to be
      +unenforceable, such provision shall be reformed only to the extent
      +necessary to make it enforceable. Any law or regulation which provides
      +that the language of a contract shall be construed against the drafter
      +shall not be used to construe this License against a Contributor.
      +
      +10. Versions of the License
      +---------------------------
      +
      +10.1. New Versions
      +
      +Mozilla Foundation is the license steward. Except as provided in Section
      +10.3, no one other than the license steward has the right to modify or
      +publish new versions of this License. Each version will be given a
      +distinguishing version number.
      +
      +10.2. Effect of New Versions
      +
      +You may distribute the Covered Software under the terms of the version
      +of the License under which You originally received the Covered Software,
      +or under the terms of any subsequent version published by the license
      +steward.
      +
      +10.3. Modified Versions
      +
      +If you create software not governed by this License, and you want to
      +create a new license for such software, you may create and use a
      +modified version of this License if you rename the license and remove
      +any references to the name of the license steward (except to note that
      +such modified license differs from this License).
      +
      +10.4. Distributing Source Code Form that is Incompatible With Secondary
      +Licenses
      +
      +If You choose to distribute Source Code Form that is Incompatible With
      +Secondary Licenses under the terms of this version of the License, the
      +notice described in Exhibit B of this License must be attached.
      +
      +Exhibit A - Source Code Form License Notice
      +-------------------------------------------
      +
      +  This Source Code Form is subject to the terms of the Mozilla Public
      +  License, v. 2.0. If a copy of the MPL was not distributed with this
      +  file, You can obtain one at http://mozilla.org/MPL/2.0/.
      +
      +If it is not possible or desirable to put the notice in a particular
      +file, then You may include the notice in a location (such as a LICENSE
      +file in a relevant directory) where a recipient would be likely to look
      +for such a notice.
      +
      +You may add additional accurate notices of copyright ownership.
      +
      +Exhibit B - "Incompatible With Secondary Licenses" Notice
      +---------------------------------------------------------
      +
      +  This Source Code Form is "Incompatible With Secondary Licenses", as
      +  defined by the Mozilla Public License, v. 2.0.
      +
      +
    • +
    • +

      Mozilla Public License 2.0

      +

      Used by:

      + +
      Mozilla Public License Version 2.0
      +==================================
      +
      +1. Definitions
      +--------------
      +
      +1.1. "Contributor"
      +    means each individual or legal entity that creates, contributes to
      +    the creation of, or owns Covered Software.
      +
      +1.2. "Contributor Version"
      +    means the combination of the Contributions of others (if any) used
      +    by a Contributor and that particular Contributor's Contribution.
      +
      +1.3. "Contribution"
      +    means Covered Software of a particular Contributor.
      +
      +1.4. "Covered Software"
      +    means Source Code Form to which the initial Contributor has attached
      +    the notice in Exhibit A, the Executable Form of such Source Code
      +    Form, and Modifications of such Source Code Form, in each case
      +    including portions thereof.
      +
      +1.5. "Incompatible With Secondary Licenses"
      +    means
      +
      +    (a) that the initial Contributor has attached the notice described
      +        in Exhibit B to the Covered Software; or
      +
      +    (b) that the Covered Software was made available under the terms of
      +        version 1.1 or earlier of the License, but not also under the
      +        terms of a Secondary License.
      +
      +1.6. "Executable Form"
      +    means any form of the work other than Source Code Form.
      +
      +1.7. "Larger Work"
      +    means a work that combines Covered Software with other material, in 
      +    a separate file or files, that is not Covered Software.
      +
      +1.8. "License"
      +    means this document.
      +
      +1.9. "Licensable"
      +    means having the right to grant, to the maximum extent possible,
      +    whether at the time of the initial grant or subsequently, any and
      +    all of the rights conveyed by this License.
      +
      +1.10. "Modifications"
      +    means any of the following:
      +
      +    (a) any file in Source Code Form that results from an addition to,
      +        deletion from, or modification of the contents of Covered
      +        Software; or
      +
      +    (b) any new file in Source Code Form that contains any Covered
      +        Software.
      +
      +1.11. "Patent Claims" of a Contributor
      +    means any patent claim(s), including without limitation, method,
      +    process, and apparatus claims, in any patent Licensable by such
      +    Contributor that would be infringed, but for the grant of the
      +    License, by the making, using, selling, offering for sale, having
      +    made, import, or transfer of either its Contributions or its
      +    Contributor Version.
      +
      +1.12. "Secondary License"
      +    means either the GNU General Public License, Version 2.0, the GNU
      +    Lesser General Public License, Version 2.1, the GNU Affero General
      +    Public License, Version 3.0, or any later versions of those
      +    licenses.
      +
      +1.13. "Source Code Form"
      +    means the form of the work preferred for making modifications.
      +
      +1.14. "You" (or "Your")
      +    means an individual or a legal entity exercising rights under this
      +    License. For legal entities, "You" includes any entity that
      +    controls, is controlled by, or is under common control with You. For
      +    purposes of this definition, "control" means (a) the power, direct
      +    or indirect, to cause the direction or management of such entity,
      +    whether by contract or otherwise, or (b) ownership of more than
      +    fifty percent (50%) of the outstanding shares or beneficial
      +    ownership of such entity.
      +
      +2. License Grants and Conditions
      +--------------------------------
      +
      +2.1. Grants
      +
      +Each Contributor hereby grants You a world-wide, royalty-free,
      +non-exclusive license:
      +
      +(a) under intellectual property rights (other than patent or trademark)
      +    Licensable by such Contributor to use, reproduce, make available,
      +    modify, display, perform, distribute, and otherwise exploit its
      +    Contributions, either on an unmodified basis, with Modifications, or
      +    as part of a Larger Work; and
      +
      +(b) under Patent Claims of such Contributor to make, use, sell, offer
      +    for sale, have made, import, and otherwise transfer either its
      +    Contributions or its Contributor Version.
      +
      +2.2. Effective Date
      +
      +The licenses granted in Section 2.1 with respect to any Contribution
      +become effective for each Contribution on the date the Contributor first
      +distributes such Contribution.
      +
      +2.3. Limitations on Grant Scope
      +
      +The licenses granted in this Section 2 are the only rights granted under
      +this License. No additional rights or licenses will be implied from the
      +distribution or licensing of Covered Software under this License.
      +Notwithstanding Section 2.1(b) above, no patent license is granted by a
      +Contributor:
      +
      +(a) for any code that a Contributor has removed from Covered Software;
      +    or
      +
      +(b) for infringements caused by: (i) Your and any other third party's
      +    modifications of Covered Software, or (ii) the combination of its
      +    Contributions with other software (except as part of its Contributor
      +    Version); or
      +
      +(c) under Patent Claims infringed by Covered Software in the absence of
      +    its Contributions.
      +
      +This License does not grant any rights in the trademarks, service marks,
      +or logos of any Contributor (except as may be necessary to comply with
      +the notice requirements in Section 3.4).
      +
      +2.4. Subsequent Licenses
      +
      +No Contributor makes additional grants as a result of Your choice to
      +distribute the Covered Software under a subsequent version of this
      +License (see Section 10.2) or under the terms of a Secondary License (if
      +permitted under the terms of Section 3.3).
      +
      +2.5. Representation
      +
      +Each Contributor represents that the Contributor believes its
      +Contributions are its original creation(s) or it has sufficient rights
      +to grant the rights to its Contributions conveyed by this License.
      +
      +2.6. Fair Use
      +
      +This License is not intended to limit any rights You have under
      +applicable copyright doctrines of fair use, fair dealing, or other
      +equivalents.
      +
      +2.7. Conditions
      +
      +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
      +in Section 2.1.
      +
      +3. Responsibilities
      +-------------------
      +
      +3.1. Distribution of Source Form
      +
      +All distribution of Covered Software in Source Code Form, including any
      +Modifications that You create or to which You contribute, must be under
      +the terms of this License. You must inform recipients that the Source
      +Code Form of the Covered Software is governed by the terms of this
      +License, and how they can obtain a copy of this License. You may not
      +attempt to alter or restrict the recipients' rights in the Source Code
      +Form.
      +
      +3.2. Distribution of Executable Form
      +
      +If You distribute Covered Software in Executable Form then:
      +
      +(a) such Covered Software must also be made available in Source Code
      +    Form, as described in Section 3.1, and You must inform recipients of
      +    the Executable Form how they can obtain a copy of such Source Code
      +    Form by reasonable means in a timely manner, at a charge no more
      +    than the cost of distribution to the recipient; and
      +
      +(b) You may distribute such Executable Form under the terms of this
      +    License, or sublicense it under different terms, provided that the
      +    license for the Executable Form does not attempt to limit or alter
      +    the recipients' rights in the Source Code Form under this License.
      +
      +3.3. Distribution of a Larger Work
      +
      +You may create and distribute a Larger Work under terms of Your choice,
      +provided that You also comply with the requirements of this License for
      +the Covered Software. If the Larger Work is a combination of Covered
      +Software with a work governed by one or more Secondary Licenses, and the
      +Covered Software is not Incompatible With Secondary Licenses, this
      +License permits You to additionally distribute such Covered Software
      +under the terms of such Secondary License(s), so that the recipient of
      +the Larger Work may, at their option, further distribute the Covered
      +Software under the terms of either this License or such Secondary
      +License(s).
      +
      +3.4. Notices
      +
      +You may not remove or alter the substance of any license notices
      +(including copyright notices, patent notices, disclaimers of warranty,
      +or limitations of liability) contained within the Source Code Form of
      +the Covered Software, except that You may alter any license notices to
      +the extent required to remedy known factual inaccuracies.
      +
      +3.5. Application of Additional Terms
      +
      +You may choose to offer, and to charge a fee for, warranty, support,
      +indemnity or liability obligations to one or more recipients of Covered
      +Software. However, You may do so only on Your own behalf, and not on
      +behalf of any Contributor. You must make it absolutely clear that any
      +such warranty, support, indemnity, or liability obligation is offered by
      +You alone, and You hereby agree to indemnify every Contributor for any
      +liability incurred by such Contributor as a result of warranty, support,
      +indemnity or liability terms You offer. You may include additional
      +disclaimers of warranty and limitations of liability specific to any
      +jurisdiction.
      +
      +4. Inability to Comply Due to Statute or Regulation
      +---------------------------------------------------
      +
      +If it is impossible for You to comply with any of the terms of this
      +License with respect to some or all of the Covered Software due to
      +statute, judicial order, or regulation then You must: (a) comply with
      +the terms of this License to the maximum extent possible; and (b)
      +describe the limitations and the code they affect. Such description must
      +be placed in a text file included with all distributions of the Covered
      +Software under this License. Except to the extent prohibited by statute
      +or regulation, such description must be sufficiently detailed for a
      +recipient of ordinary skill to be able to understand it.
      +
      +5. Termination
      +--------------
      +
      +5.1. The rights granted under this License will terminate automatically
      +if You fail to comply with any of its terms. However, if You become
      +compliant, then the rights granted under this License from a particular
      +Contributor are reinstated (a) provisionally, unless and until such
      +Contributor explicitly and finally terminates Your grants, and (b) on an
      +ongoing basis, if such Contributor fails to notify You of the
      +non-compliance by some reasonable means prior to 60 days after You have
      +come back into compliance. Moreover, Your grants from a particular
      +Contributor are reinstated on an ongoing basis if such Contributor
      +notifies You of the non-compliance by some reasonable means, this is the
      +first time You have received notice of non-compliance with this License
      +from such Contributor, and You become compliant prior to 30 days after
      +Your receipt of the notice.
      +
      +5.2. If You initiate litigation against any entity by asserting a patent
      +infringement claim (excluding declaratory judgment actions,
      +counter-claims, and cross-claims) alleging that a Contributor Version
      +directly or indirectly infringes any patent, then the rights granted to
      +You by any and all Contributors for the Covered Software under Section
      +2.1 of this License shall terminate.
      +
      +5.3. In the event of termination under Sections 5.1 or 5.2 above, all
      +end user license agreements (excluding distributors and resellers) which
      +have been validly granted by You or Your distributors under this License
      +prior to termination shall survive termination.
      +
      +************************************************************************
      +*                                                                      *
      +*  6. Disclaimer of Warranty                                           *
      +*  -------------------------                                           *
      +*                                                                      *
      +*  Covered Software is provided under this License on an "as is"       *
      +*  basis, without warranty of any kind, either expressed, implied, or  *
      +*  statutory, including, without limitation, warranties that the       *
      +*  Covered Software is free of defects, merchantable, fit for a        *
      +*  particular purpose or non-infringing. The entire risk as to the     *
      +*  quality and performance of the Covered Software is with You.        *
      +*  Should any Covered Software prove defective in any respect, You     *
      +*  (not any Contributor) assume the cost of any necessary servicing,   *
      +*  repair, or correction. This disclaimer of warranty constitutes an   *
      +*  essential part of this License. No use of any Covered Software is   *
      +*  authorized under this License except under this disclaimer.         *
      +*                                                                      *
      +************************************************************************
      +
      +************************************************************************
      +*                                                                      *
      +*  7. Limitation of Liability                                          *
      +*  --------------------------                                          *
      +*                                                                      *
      +*  Under no circumstances and under no legal theory, whether tort      *
      +*  (including negligence), contract, or otherwise, shall any           *
      +*  Contributor, or anyone who distributes Covered Software as          *
      +*  permitted above, be liable to You for any direct, indirect,         *
      +*  special, incidental, or consequential damages of any character      *
      +*  including, without limitation, damages for lost profits, loss of    *
      +*  goodwill, work stoppage, computer failure or malfunction, or any    *
      +*  and all other commercial damages or losses, even if such party      *
      +*  shall have been informed of the possibility of such damages. This   *
      +*  limitation of liability shall not apply to liability for death or   *
      +*  personal injury resulting from such party's negligence to the       *
      +*  extent applicable law prohibits such limitation. Some               *
      +*  jurisdictions do not allow the exclusion or limitation of           *
      +*  incidental or consequential damages, so this exclusion and          *
      +*  limitation may not apply to You.                                    *
      +*                                                                      *
      +************************************************************************
      +
      +8. Litigation
      +-------------
      +
      +Any litigation relating to this License may be brought only in the
      +courts of a jurisdiction where the defendant maintains its principal
      +place of business and such litigation shall be governed by laws of that
      +jurisdiction, without reference to its conflict-of-law provisions.
      +Nothing in this Section shall prevent a party's ability to bring
      +cross-claims or counter-claims.
      +
      +9. Miscellaneous
      +----------------
      +
      +This License represents the complete agreement concerning the subject
      +matter hereof. If any provision of this License is held to be
      +unenforceable, such provision shall be reformed only to the extent
      +necessary to make it enforceable. Any law or regulation which provides
      +that the language of a contract shall be construed against the drafter
      +shall not be used to construe this License against a Contributor.
      +
      +10. Versions of the License
      +---------------------------
      +
      +10.1. New Versions
      +
      +Mozilla Foundation is the license steward. Except as provided in Section
      +10.3, no one other than the license steward has the right to modify or
      +publish new versions of this License. Each version will be given a
      +distinguishing version number.
      +
      +10.2. Effect of New Versions
      +
      +You may distribute the Covered Software under the terms of the version
      +of the License under which You originally received the Covered Software,
      +or under the terms of any subsequent version published by the license
      +steward.
      +
      +10.3. Modified Versions
      +
      +If you create software not governed by this License, and you want to
      +create a new license for such software, you may create and use a
      +modified version of this License if you rename the license and remove
      +any references to the name of the license steward (except to note that
      +such modified license differs from this License).
      +
      +10.4. Distributing Source Code Form that is Incompatible With Secondary
      +Licenses
      +
      +If You choose to distribute Source Code Form that is Incompatible With
      +Secondary Licenses under the terms of this version of the License, the
      +notice described in Exhibit B of this License must be attached.
      +
      +Exhibit A - Source Code Form License Notice
      +-------------------------------------------
      +
      +  This Source Code Form is subject to the terms of the Mozilla Public
      +  License, v. 2.0. If a copy of the MPL was not distributed with this
      +  file, You can obtain one at http://mozilla.org/MPL/2.0/.
      +
      +If it is not possible or desirable to put the notice in a particular
      +file, then You may include the notice in a location (such as a LICENSE
      +file in a relevant directory) where a recipient would be likely to look
      +for such a notice.
      +
      +You may add additional accurate notices of copyright ownership.
      +
      +Exhibit B - "Incompatible With Secondary Licenses" Notice
      +---------------------------------------------------------
      +
      +  This Source Code Form is "Incompatible With Secondary Licenses", as
      +  defined by the Mozilla Public License, v. 2.0.
      +
      +
    • +
    • +

      Mozilla Public License 2.0

      +

      Used by:

      + +
      Mozilla Public License Version 2.0
      +==================================
      +
      +1. Definitions
      +--------------
      +
      +1.1. "Contributor"
      +    means each individual or legal entity that creates, contributes to
      +    the creation of, or owns Covered Software.
      +
      +1.2. "Contributor Version"
      +    means the combination of the Contributions of others (if any) used
      +    by a Contributor and that particular Contributor's Contribution.
      +
      +1.3. "Contribution"
      +    means Covered Software of a particular Contributor.
      +
      +1.4. "Covered Software"
      +    means Source Code Form to which the initial Contributor has attached
      +    the notice in Exhibit A, the Executable Form of such Source Code
      +    Form, and Modifications of such Source Code Form, in each case
      +    including portions thereof.
      +
      +1.5. "Incompatible With Secondary Licenses"
      +    means
      +
      +    (a) that the initial Contributor has attached the notice described
      +        in Exhibit B to the Covered Software; or
      +
      +    (b) that the Covered Software was made available under the terms of
      +        version 1.1 or earlier of the License, but not also under the
      +        terms of a Secondary License.
      +
      +1.6. "Executable Form"
      +    means any form of the work other than Source Code Form.
      +
      +1.7. "Larger Work"
      +    means a work that combines Covered Software with other material, in 
      +    a separate file or files, that is not Covered Software.
      +
      +1.8. "License"
      +    means this document.
      +
      +1.9. "Licensable"
      +    means having the right to grant, to the maximum extent possible,
      +    whether at the time of the initial grant or subsequently, any and
      +    all of the rights conveyed by this License.
      +
      +1.10. "Modifications"
      +    means any of the following:
      +
      +    (a) any file in Source Code Form that results from an addition to,
      +        deletion from, or modification of the contents of Covered
      +        Software; or
      +
      +    (b) any new file in Source Code Form that contains any Covered
      +        Software.
      +
      +1.11. "Patent Claims" of a Contributor
      +    means any patent claim(s), including without limitation, method,
      +    process, and apparatus claims, in any patent Licensable by such
      +    Contributor that would be infringed, but for the grant of the
      +    License, by the making, using, selling, offering for sale, having
      +    made, import, or transfer of either its Contributions or its
      +    Contributor Version.
      +
      +1.12. "Secondary License"
      +    means either the GNU General Public License, Version 2.0, the GNU
      +    Lesser General Public License, Version 2.1, the GNU Affero General
      +    Public License, Version 3.0, or any later versions of those
      +    licenses.
      +
      +1.13. "Source Code Form"
      +    means the form of the work preferred for making modifications.
      +
      +1.14. "You" (or "Your")
      +    means an individual or a legal entity exercising rights under this
      +    License. For legal entities, "You" includes any entity that
      +    controls, is controlled by, or is under common control with You. For
      +    purposes of this definition, "control" means (a) the power, direct
      +    or indirect, to cause the direction or management of such entity,
      +    whether by contract or otherwise, or (b) ownership of more than
      +    fifty percent (50%) of the outstanding shares or beneficial
      +    ownership of such entity.
      +
      +2. License Grants and Conditions
      +--------------------------------
      +
      +2.1. Grants
      +
      +Each Contributor hereby grants You a world-wide, royalty-free,
      +non-exclusive license:
      +
      +(a) under intellectual property rights (other than patent or trademark)
      +    Licensable by such Contributor to use, reproduce, make available,
      +    modify, display, perform, distribute, and otherwise exploit its
      +    Contributions, either on an unmodified basis, with Modifications, or
      +    as part of a Larger Work; and
      +
      +(b) under Patent Claims of such Contributor to make, use, sell, offer
      +    for sale, have made, import, and otherwise transfer either its
      +    Contributions or its Contributor Version.
      +
      +2.2. Effective Date
      +
      +The licenses granted in Section 2.1 with respect to any Contribution
      +become effective for each Contribution on the date the Contributor first
      +distributes such Contribution.
      +
      +2.3. Limitations on Grant Scope
      +
      +The licenses granted in this Section 2 are the only rights granted under
      +this License. No additional rights or licenses will be implied from the
      +distribution or licensing of Covered Software under this License.
      +Notwithstanding Section 2.1(b) above, no patent license is granted by a
      +Contributor:
      +
      +(a) for any code that a Contributor has removed from Covered Software;
      +    or
      +
      +(b) for infringements caused by: (i) Your and any other third party's
      +    modifications of Covered Software, or (ii) the combination of its
      +    Contributions with other software (except as part of its Contributor
      +    Version); or
      +
      +(c) under Patent Claims infringed by Covered Software in the absence of
      +    its Contributions.
      +
      +This License does not grant any rights in the trademarks, service marks,
      +or logos of any Contributor (except as may be necessary to comply with
      +the notice requirements in Section 3.4).
      +
      +2.4. Subsequent Licenses
      +
      +No Contributor makes additional grants as a result of Your choice to
      +distribute the Covered Software under a subsequent version of this
      +License (see Section 10.2) or under the terms of a Secondary License (if
      +permitted under the terms of Section 3.3).
      +
      +2.5. Representation
      +
      +Each Contributor represents that the Contributor believes its
      +Contributions are its original creation(s) or it has sufficient rights
      +to grant the rights to its Contributions conveyed by this License.
      +
      +2.6. Fair Use
      +
      +This License is not intended to limit any rights You have under
      +applicable copyright doctrines of fair use, fair dealing, or other
      +equivalents.
      +
      +2.7. Conditions
      +
      +Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
      +in Section 2.1.
      +
      +3. Responsibilities
      +-------------------
      +
      +3.1. Distribution of Source Form
      +
      +All distribution of Covered Software in Source Code Form, including any
      +Modifications that You create or to which You contribute, must be under
      +the terms of this License. You must inform recipients that the Source
      +Code Form of the Covered Software is governed by the terms of this
      +License, and how they can obtain a copy of this License. You may not
      +attempt to alter or restrict the recipients' rights in the Source Code
      +Form.
      +
      +3.2. Distribution of Executable Form
      +
      +If You distribute Covered Software in Executable Form then:
      +
      +(a) such Covered Software must also be made available in Source Code
      +    Form, as described in Section 3.1, and You must inform recipients of
      +    the Executable Form how they can obtain a copy of such Source Code
      +    Form by reasonable means in a timely manner, at a charge no more
      +    than the cost of distribution to the recipient; and
      +
      +(b) You may distribute such Executable Form under the terms of this
      +    License, or sublicense it under different terms, provided that the
      +    license for the Executable Form does not attempt to limit or alter
      +    the recipients' rights in the Source Code Form under this License.
      +
      +3.3. Distribution of a Larger Work
      +
      +You may create and distribute a Larger Work under terms of Your choice,
      +provided that You also comply with the requirements of this License for
      +the Covered Software. If the Larger Work is a combination of Covered
      +Software with a work governed by one or more Secondary Licenses, and the
      +Covered Software is not Incompatible With Secondary Licenses, this
      +License permits You to additionally distribute such Covered Software
      +under the terms of such Secondary License(s), so that the recipient of
      +the Larger Work may, at their option, further distribute the Covered
      +Software under the terms of either this License or such Secondary
      +License(s).
      +
      +3.4. Notices
      +
      +You may not remove or alter the substance of any license notices
      +(including copyright notices, patent notices, disclaimers of warranty,
      +or limitations of liability) contained within the Source Code Form of
      +the Covered Software, except that You may alter any license notices to
      +the extent required to remedy known factual inaccuracies.
      +
      +3.5. Application of Additional Terms
      +
      +You may choose to offer, and to charge a fee for, warranty, support,
      +indemnity or liability obligations to one or more recipients of Covered
      +Software. However, You may do so only on Your own behalf, and not on
      +behalf of any Contributor. You must make it absolutely clear that any
      +such warranty, support, indemnity, or liability obligation is offered by
      +You alone, and You hereby agree to indemnify every Contributor for any
      +liability incurred by such Contributor as a result of warranty, support,
      +indemnity or liability terms You offer. You may include additional
      +disclaimers of warranty and limitations of liability specific to any
      +jurisdiction.
      +
      +4. Inability to Comply Due to Statute or Regulation
      +---------------------------------------------------
      +
      +If it is impossible for You to comply with any of the terms of this
      +License with respect to some or all of the Covered Software due to
      +statute, judicial order, or regulation then You must: (a) comply with
      +the terms of this License to the maximum extent possible; and (b)
      +describe the limitations and the code they affect. Such description must
      +be placed in a text file included with all distributions of the Covered
      +Software under this License. Except to the extent prohibited by statute
      +or regulation, such description must be sufficiently detailed for a
      +recipient of ordinary skill to be able to understand it.
      +
      +5. Termination
      +--------------
      +
      +5.1. The rights granted under this License will terminate automatically
      +if You fail to comply with any of its terms. However, if You become
      +compliant, then the rights granted under this License from a particular
      +Contributor are reinstated (a) provisionally, unless and until such
      +Contributor explicitly and finally terminates Your grants, and (b) on an
      +ongoing basis, if such Contributor fails to notify You of the
      +non-compliance by some reasonable means prior to 60 days after You have
      +come back into compliance. Moreover, Your grants from a particular
      +Contributor are reinstated on an ongoing basis if such Contributor
      +notifies You of the non-compliance by some reasonable means, this is the
      +first time You have received notice of non-compliance with this License
      +from such Contributor, and You become compliant prior to 30 days after
      +Your receipt of the notice.
      +
      +5.2. If You initiate litigation against any entity by asserting a patent
      +infringement claim (excluding declaratory judgment actions,
      +counter-claims, and cross-claims) alleging that a Contributor Version
      +directly or indirectly infringes any patent, then the rights granted to
      +You by any and all Contributors for the Covered Software under Section
      +2.1 of this License shall terminate.
      +
      +5.3. In the event of termination under Sections 5.1 or 5.2 above, all
      +end user license agreements (excluding distributors and resellers) which
      +have been validly granted by You or Your distributors under this License
      +prior to termination shall survive termination.
      +
      +************************************************************************
      +*                                                                      *
      +*  6. Disclaimer of Warranty                                           *
      +*  -------------------------                                           *
      +*                                                                      *
      +*  Covered Software is provided under this License on an "as is"       *
      +*  basis, without warranty of any kind, either expressed, implied, or  *
      +*  statutory, including, without limitation, warranties that the       *
      +*  Covered Software is free of defects, merchantable, fit for a        *
      +*  particular purpose or non-infringing. The entire risk as to the     *
      +*  quality and performance of the Covered Software is with You.        *
      +*  Should any Covered Software prove defective in any respect, You     *
      +*  (not any Contributor) assume the cost of any necessary servicing,   *
      +*  repair, or correction. This disclaimer of warranty constitutes an   *
      +*  essential part of this License. No use of any Covered Software is   *
      +*  authorized under this License except under this disclaimer.         *
      +*                                                                      *
      +************************************************************************
      +
      +************************************************************************
      +*                                                                      *
      +*  7. Limitation of Liability                                          *
      +*  --------------------------                                          *
      +*                                                                      *
      +*  Under no circumstances and under no legal theory, whether tort      *
      +*  (including negligence), contract, or otherwise, shall any           *
      +*  Contributor, or anyone who distributes Covered Software as          *
      +*  permitted above, be liable to You for any direct, indirect,         *
      +*  special, incidental, or consequential damages of any character      *
      +*  including, without limitation, damages for lost profits, loss of    *
      +*  goodwill, work stoppage, computer failure or malfunction, or any    *
      +*  and all other commercial damages or losses, even if such party      *
      +*  shall have been informed of the possibility of such damages. This   *
      +*  limitation of liability shall not apply to liability for death or   *
      +*  personal injury resulting from such party's negligence to the       *
      +*  extent applicable law prohibits such limitation. Some               *
      +*  jurisdictions do not allow the exclusion or limitation of           *
      +*  incidental or consequential damages, so this exclusion and          *
      +*  limitation may not apply to You.                                    *
      +*                                                                      *
      +************************************************************************
      +
      +8. Litigation
      +-------------
      +
      +Any litigation relating to this License may be brought only in the
      +courts of a jurisdiction where the defendant maintains its principal
      +place of business and such litigation shall be governed by laws of that
      +jurisdiction, without reference to its conflict-of-law provisions.
      +Nothing in this Section shall prevent a party's ability to bring
      +cross-claims or counter-claims.
      +
      +9. Miscellaneous
      +----------------
      +
      +This License represents the complete agreement concerning the subject
      +matter hereof. If any provision of this License is held to be
      +unenforceable, such provision shall be reformed only to the extent
      +necessary to make it enforceable. Any law or regulation which provides
      +that the language of a contract shall be construed against the drafter
      +shall not be used to construe this License against a Contributor.
      +
      +10. Versions of the License
      +---------------------------
      +
      +10.1. New Versions
      +
      +Mozilla Foundation is the license steward. Except as provided in Section
      +10.3, no one other than the license steward has the right to modify or
      +publish new versions of this License. Each version will be given a
      +distinguishing version number.
      +
      +10.2. Effect of New Versions
      +
      +You may distribute the Covered Software under the terms of the version
      +of the License under which You originally received the Covered Software,
      +or under the terms of any subsequent version published by the license
      +steward.
      +
      +10.3. Modified Versions
      +
      +If you create software not governed by this License, and you want to
      +create a new license for such software, you may create and use a
      +modified version of this License if you rename the license and remove
      +any references to the name of the license steward (except to note that
      +such modified license differs from this License).
      +
      +10.4. Distributing Source Code Form that is Incompatible With Secondary
      +Licenses
      +
      +If You choose to distribute Source Code Form that is Incompatible With
      +Secondary Licenses under the terms of this version of the License, the
      +notice described in Exhibit B of this License must be attached.
      +
      +Exhibit A - Source Code Form License Notice
      +-------------------------------------------
      +
      +  This Source Code Form is subject to the terms of the Mozilla Public
      +  License, v. 2.0. If a copy of the MPL was not distributed with this
      +  file, You can obtain one at https://mozilla.org/MPL/2.0/.
      +
      +If it is not possible or desirable to put the notice in a particular
      +file, then You may include the notice in a location (such as a LICENSE
      +file in a relevant directory) where a recipient would be likely to look
      +for such a notice.
      +
      +You may add additional accurate notices of copyright ownership.
      +
      +Exhibit B - "Incompatible With Secondary Licenses" Notice
      +---------------------------------------------------------
      +
      +  This Source Code Form is "Incompatible With Secondary Licenses", as
      +  defined by the Mozilla Public License, v. 2.0.
      +
      +
    • +
    • +

      Unicode License v3

      +

      Used by:

      + +
      UNICODE LICENSE V3
      +
      +COPYRIGHT AND PERMISSION NOTICE
      +
      +Copyright © 1991-2023 Unicode, Inc.
      +
      +NOTICE TO USER: Carefully read the following legal agreement. BY
      +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
      +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
      +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
      +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a
      +copy of data files and any associated documentation (the "Data Files") or
      +software and any associated documentation (the "Software") to deal in the
      +Data Files or Software without restriction, including without limitation
      +the rights to use, copy, modify, merge, publish, distribute, and/or sell
      +copies of the Data Files or Software, and to permit persons to whom the
      +Data Files or Software are furnished to do so, provided that either (a)
      +this copyright and permission notice appear with all copies of the Data
      +Files or Software, or (b) this copyright and permission notice appear in
      +associated Documentation.
      +
      +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
      +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
      +THIRD PARTY RIGHTS.
      +
      +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
      +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
      +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
      +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
      +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
      +FILES OR SOFTWARE.
      +
      +Except as contained in this notice, the name of a copyright holder shall
      +not be used in advertising or otherwise to promote the sale, use or other
      +dealings in these Data Files or Software without prior written
      +authorization of the copyright holder.
      +
      +
    • +
    • +

      Unicode License v3

      +

      Used by:

      + +
      UNICODE LICENSE V3
      +
      +COPYRIGHT AND PERMISSION NOTICE
      +
      +Copyright © 2020-2024 Unicode, Inc.
      +
      +NOTICE TO USER: Carefully read the following legal agreement. BY
      +DOWNLOADING, INSTALLING, COPYING OR OTHERWISE USING DATA FILES, AND/OR
      +SOFTWARE, YOU UNEQUIVOCALLY ACCEPT, AND AGREE TO BE BOUND BY, ALL OF THE
      +TERMS AND CONDITIONS OF THIS AGREEMENT. IF YOU DO NOT AGREE, DO NOT
      +DOWNLOAD, INSTALL, COPY, DISTRIBUTE OR USE THE DATA FILES OR SOFTWARE.
      +
      +Permission is hereby granted, free of charge, to any person obtaining a
      +copy of data files and any associated documentation (the "Data Files") or
      +software and any associated documentation (the "Software") to deal in the
      +Data Files or Software without restriction, including without limitation
      +the rights to use, copy, modify, merge, publish, distribute, and/or sell
      +copies of the Data Files or Software, and to permit persons to whom the
      +Data Files or Software are furnished to do so, provided that either (a)
      +this copyright and permission notice appear with all copies of the Data
      +Files or Software, or (b) this copyright and permission notice appear in
      +associated Documentation.
      +
      +THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
      +KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
      +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
      +THIRD PARTY RIGHTS.
      +
      +IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
      +BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
      +OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
      +WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
      +ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
      +FILES OR SOFTWARE.
      +
      +Except as contained in this notice, the name of a copyright holder shall
      +not be used in advertising or otherwise to promote the sale, use or other
      +dealings in these Data Files or Software without prior written
      +authorization of the copyright holder.
      +
      +SPDX-License-Identifier: Unicode-3.0
      +
      +—
      +
      +Portions of ICU4X may have been adapted from ICU4C and/or ICU4J.
      +ICU 1.8.1 to ICU 57.1 © 1995-2016 International Business Machines Corporation and others.
      +
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      (C) 2024 Trifecta Tech Foundation 
      +
      +This software is provided 'as-is', without any express or implied
      +warranty. In no event will the authors be held liable for any damages
      +arising from the use of this software.
      +
      +Permission is granted to anyone to use this software for any purpose,
      +including commercial applications, and to alter it and redistribute it
      +freely, subject to the following restrictions:
      +
      +1. The origin of this software must not be misrepresented; you must not
      +   claim that you wrote the original software. If you use this software
      +   in a product, an acknowledgment in the product documentation would be
      +   appreciated but is not required.
      +
      +2. Altered source versions must be plainly marked as such, and must not be
      +   misrepresented as being the original software.
      +
      +3. This notice may not be removed or altered from any source distribution.
      +
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      Copyright (c) 2021 Matias Rodriguez.
      +
      +This software is provided 'as-is', without any express or implied
      +warranty. In no event will the authors be held liable for any damages
      +arising from the use of this software.
      +
      +Permission is granted to anyone to use this software for any purpose,
      +including commercial applications, and to alter it and redistribute it
      +freely, subject to the following restrictions:
      +
      +1. The origin of this software must not be misrepresented; you must not
      +   claim that you wrote the original software. If you use this software
      +   in a product, an acknowledgment in the product documentation would be
      +   appreciated but is not required.
      +2. Altered source versions must be plainly marked as such, and must not be
      +   misrepresented as being the original software.
      +3. This notice may not be removed or altered from any source distribution.
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      Copyright (c) 2023 Matias Rodriguez.
      +
      +This software is provided 'as-is', without any express or implied
      +warranty. In no event will the authors be held liable for any damages
      +arising from the use of this software.
      +
      +Permission is granted to anyone to use this software for any purpose,
      +including commercial applications, and to alter it and redistribute it
      +freely, subject to the following restrictions:
      +
      +1. The origin of this software must not be misrepresented; you must not
      +   claim that you wrote the original software. If you use this software
      +   in a product, an acknowledgment in the product documentation would be
      +   appreciated but is not required.
      +2. Altered source versions must be plainly marked as such, and must not be
      +   misrepresented as being the original software.
      +3. This notice may not be removed or altered from any source distribution.
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      Copyright (c) 2024 Orson Peters
      +
      +This software is provided 'as-is', without any express or implied warranty. In
      +no event will the authors be held liable for any damages arising from the use of
      +this software.
      +
      +Permission is granted to anyone to use this software for any purpose, including
      +commercial applications, and to alter it and redistribute it freely, subject to
      +the following restrictions:
      +
      +1. The origin of this software must not be misrepresented; you must not claim
      +    that you wrote the original software. If you use this software in a product,
      +    an acknowledgment in the product documentation would be appreciated but is
      +    not required.
      +
      +2. Altered source versions must be plainly marked as such, and must not be
      +    misrepresented as being the original software.
      +
      +3. This notice may not be removed or altered from any source distribution.
      +
    • +
    • +

      zlib License

      +

      Used by:

      + +
      zlib License
      +
      +This software is provided 'as-is', without any express or implied warranty.  In no event will the authors be held liable for any damages arising from the use of this software.
      +
      +Permission is granted to anyone to use this software for any purpose, including commercial applications, and to alter it and redistribute it freely, subject to the following restrictions:
      +
      +     1. The origin of this software must not be misrepresented; you must not claim that you wrote the original software. If you use this software in a product, an acknowledgment in the product documentation would be appreciated but is not required.
      +
      +     2. Altered source versions must be plainly marked as such, and must not be misrepresented as being the original software.
      +
      +     3. This notice may not be removed or altered from any source distribution.
      +
      +
    • +
    • +

      bzip2 and libbzip2 License v1.0.6

      +

      Used by:

      + +
      +--------------------------------------------------------------------------
      +
      +The original program, "bzip2", the associated library "libbzip2", and all
      +documentation, are
      +
      +Copyright (C) 1996-2021 Julian R Seward.
      +Copyright (C) 2019-2020 Federico Mena Quintero
      +Copyright (C) 2021 Micah Snyder
      +
      +This Rust translation, "libbzip2-rs" is a derived work based on "bzip2" and
      +"libbzip2", and is Copyright (C) 2024-2025 Trifecta Tech Foundation and contributors
      +
      +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. The origin of this software must not be misrepresented; you must
      +   not claim that you wrote the original software.  If you use this
      +   software in a product, an acknowledgment in the product
      +   documentation would be appreciated but is not required.
      +
      +3. Altered source versions must be plainly marked as such, and must
      +   not be misrepresented as being the original software.
      +
      +4. The name of the author may not be used to endorse or promote
      +   products derived from this software without specific prior written
      +   permission.
      +
      +THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``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 AUTHOR 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.
      +
      +Julian Seward, jseward@acm.org
      +bzip2/libbzip2 version 1.1.0 of 6 September 2010
      +
      +--------------------------------------------------------------------------
      +
      +
    • +
    +
    + + + diff --git a/python/pyproject.toml b/python/pyproject.toml index 4b6814aa070..f6972e03af5 100644 --- a/python/pyproject.toml +++ b/python/pyproject.toml @@ -1,13 +1,13 @@ [project] name = "pylance" dynamic = ["version"] -dependencies = ["pyarrow>=14", "numpy>=1.22", "lance-namespace>=0.4.5"] +dependencies = ["pyarrow>=14", "numpy>=1.22", "lance-namespace>=0.11.1,<0.12"] description = "python wrapper for Lance columnar format" authors = [{ name = "Lance Devs", email = "dev@lance.org" }] license = { file = "LICENSE" } repository = "https://github.com/lancedb/lance" readme = "README.md" -requires-python = ">=3.9" +requires-python = ">=3.10" keywords = [ "data-format", "data-science", @@ -30,11 +30,11 @@ classifiers = [ "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", "Programming Language :: Rust", "Topic :: Scientific/Engineering", ] @@ -49,27 +49,55 @@ build-backend = "maturin" [project.optional-dependencies] tests = [ "boto3", - "datasets", - "duckdb", + "datasets==4.4.0", + "duckdb>=1.5.0,<1.6.0", "ml_dtypes", "pillow", "pandas", "polars[pyarrow,pandas]", "psutil", "pytest", - # Only test tensorflow on linux for now. We will deprecate tensorflow soon. - "tensorflow; sys_platform == 'linux'", + "pytest-xdist", "tqdm", - "datafusion>=50.1", + "datafusion>=54,<55", ] -dev = ["ruff==0.4.1", "pyright"] +dev = ["ruff==0.11.2", "pyright"] benchmarks = ["pytest-benchmark"] +otel = ["opentelemetry-api", "opentelemetry-sdk"] torch = ["torch>=2.0"] geo = [ "geoarrow-rust-core", "geoarrow-rust-io", ] +[dependency-groups] +tests = [ + "boto3==1.40.43", + "datasets==4.4.0", + "duckdb>=1.5.0,<1.6.0", + "ml_dtypes==0.5.3", + "pillow==11.3.0", + "pandas==2.3.3", + "polars[pyarrow,pandas]==1.34.0", + "psutil==7.1.0", + "pytest==8.4.2", + "pytest-xdist==3.8.0", + "tqdm==4.67.1", + "datafusion==54.0.0", + "opentelemetry-sdk==1.30.0", +] +dev = [ + "maturin==1.13.3", + "pyright==1.1.406", + "ruff==0.11.2", +] +benchmarks = [ + "pytest-benchmark==5.1.0", +] + +[tool.uv] +default-groups = ["dev", "tests"] + [tool.ruff] lint.select = ["F", "E", "W", "I", "G", "TCH", "PERF", "B019"] @@ -77,7 +105,7 @@ lint.select = ["F", "E", "W", "I", "G", "TCH", "PERF", "B019"] "*.pyi" = ["E301", "E302"] [tool.pyright] -pythonVersion = "3.13" +pythonVersion = "3.14" # TODO: expand this list as we fix more files. include = [ "python/lance/util.py", @@ -85,8 +113,13 @@ include = [ "python/lance/tracing.py", "python/lance/dependencies.py", "python/lance/schema.py", + "python/tests/test_schema.py", "python/lance/file.py", "python/lance/util.py", + "python/lance/arrow.py", + "python/tests/test_arrow.py", + "python/tests/test_fragment_typing.py", + "python/tests/test_udf.py", ] # Dependencies like pyarrow make this difficult to enforce strictly. reportMissingTypeStubs = "warning" @@ -103,16 +136,14 @@ markers = [ "gpu: tests which rely on pytorch and some kind of gpu", "slow", "torch: tests which rely on pytorch being installed", - "recurring: marks tests as recurring tests", ] filterwarnings = [ 'error::FutureWarning', 'error::DeprecationWarning', - # TensorFlow import can emit NumPy deprecation FutureWarnings in some environments. - # We keep FutureWarnings as errors generally, but ignore this known-noisy import-time warning. - 'ignore:.*`np\\.object` will be defined as the corresponding NumPy scalar\\..*:FutureWarning', # Boto3 'ignore:.*datetime\.datetime\.utcnow\(\) is deprecated.*:DeprecationWarning', + # Hugging Face Hub calls this deprecated hf-xet API internally. + 'ignore:.*hf_xet\.download_files\(\) is deprecated.*:DeprecationWarning', # Pandas 2.2 on Python 2.12 'ignore:.*datetime\.datetime\.utcfromtimestamp\(\) is deprecated.*:DeprecationWarning', # Pytorch 2.2 on Python 3.12 @@ -122,7 +153,8 @@ filterwarnings = [ 'ignore:.*the load_module\(\) method is deprecated.*:DeprecationWarning', # Pytorch uses deprecated jit.script_method internally (torch/utils/mkldnn.py) 'ignore:.*torch\.jit\.script_method.*is deprecated.*:DeprecationWarning', - # TensorFlow/Keras import can emit NumPy deprecation FutureWarnings in some environments. - # Keep FutureWarnings as errors generally, but ignore this known-noisy import-time warning. - 'ignore:.*np\.object.*:FutureWarning', + # Pytorch uses the same API internally on Python 3.14+ with a different warning message. + 'ignore:.*torch\.jit\.script_method.*is not supported in Python 3\.14\+.*:DeprecationWarning', + # huggingface_hub still calls the deprecated hf_xet.download_files() during Xet downloads + 'ignore:.*hf_xet\.download_files\(\) is deprecated.*:DeprecationWarning', ] diff --git a/python/python/benchmarks/duplicate_pairs.md b/python/python/benchmarks/duplicate_pairs.md new file mode 100644 index 00000000000..e1b10aabec6 --- /dev/null +++ b/python/python/benchmarks/duplicate_pairs.md @@ -0,0 +1,84 @@ +# Duplicate-pair CPU, I/O and memory benchmark + +This Linux profiler exercises the complete native partition-pair stream on +1,536-dimensional cosine IVF_RQ5/RQ8 indices with 6,408 and 17,783 rows per +partition. The fixtures are synthetic, with deterministic duplicate groups. +They match the partition dimensions/cardinalities in issue #9535, not the full +production table, centroid distribution, or distributed execution environment. + +## Prepare once, then measure read-only + +Follow `python/AGENTS.md` to install the project. Use the same repository +`release-with-debug` profile, toolchain and environment for baseline and candidate. +Run from `python/`: + +```sh +uv run maturin develop --profile release-with-debug --uv +uv run python python/benchmarks/duplicate_pairs.py prepare \ + --prefix s3://YOUR-BUCKET/dedup/UNIQUE_RUN_ID --output cases.json +``` + +`prepare` creates new tables and indices and refuses to overwrite tables. Never +point it at production data. Keep the generated `cases.json`: all comparisons +must reuse these exact persisted dataset versions and segment IDs, without +rebuilding the indices between implementations. + +```sh +LANCE_CPU_THREADS=8 RAYON_NUM_THREADS=8 OPENBLAS_NUM_THREADS=1 OMP_NUM_THREADS=1 \ + taskset -c 0-7 uv run --no-sync python python/benchmarks/duplicate_pairs.py run \ + --config cases.json --case 0 --threshold 0.01 --label candidate \ + --memory-limit 268435456 --max-concurrency 8 --output result.json +``` + +Use cases 0..3 and thresholds 0.01 (sparse output) and 1e9 (all pairs; any +threshold at or above `ALL_PAIRS_THRESHOLD` asserts N*(N-1)/2 output pairs). +Omit the two resource arguments when running a baseline that predates them. +Each invocation is a fresh process, fully consumes the stream, and asserts zero +writes on the source object store. Run only one measured process at a time, with no concurrent builds. +The optional global `--credentials` argument reads an AWS credential-process +JSON file; credential values are never written to results. Normal environment +credentials work without that argument. + +## What the results mean + +- `elapsed_s` includes native planning, code staging, quantizer batch scoring and + streaming output hashing. Imports and opening the pinned dataset are excluded; + opening is reported separately as `dataset_open_s`. Table/index creation is + not timed. +- Three independent SHA-256 hashes cover the ordered row-ID and float32-distance + columns; they depend on output order but not on batch boundaries. Output is + in tile order (vector batch pairs, row-major within a tile), which differs + from implementations that kept each `row_id_a` contiguous. Compare + `pair_set_hash` (an order-independent hash of the unordered ID pairs) and + `output_rows` between implementations; compare the ordered hashes only + between runs of one implementation, for example across `--max-concurrency` + and `--memory-limit`, where they must match exactly. +- Cosine distances are `1 - cos` of the renormalized index reconstructions, in + [0, 2]. Compare distance hashes only between implementations with the same + scoring semantics; versions that reported normalized L2 / 2 for quantized + cosine differ. +- `cpu_cores` is process user+system CPU time divided by wall time. It includes + the Python consumer and monitoring overhead, not just Rust scoring threads. +- RSS is for the whole process, including Python, shared libraries and native + allocations. `peak_rss_bytes` is Linux's process high-water mark; it is not the + code staging budget. Each run also records its initial RSS. +- Source bytes/IOPS come from Lance's object-store counters after dataset open. + They are distinct from temporary-file I/O. The last observed source read and + peak source rate use approximately 100 ms sampling, not exact span timing. +- `disk_read_bytes`/`disk_write_bytes` are Linux process storage-I/O accounting. + `process_rchar`/`process_wchar` include logical syscall traffic, including + page-cache reads, monitoring and IPC; do not label them physical disk traffic + or exclusively spill traffic. Buffered write accounting is not an instantaneous + device-throughput measurement. +- Host network counters include TLS and any background/control traffic. Source + object-store counters are the authoritative bytes attributed to the dataset. + +The adjacent `.trace.json` file contains the time series, so short I/O preparation +can be distinguished from a long compute phase. Report repetitions and statistics +explicitly; a single long scan must not be described as a median. + +## Resource diagnostics + +Also compare `--max-concurrency 1` to 8 and force code spill with +`--memory-limit 0`. The latter changes the storage/memory tradeoff; OS file +cache is not included in process RSS and may retain the code spill pages. diff --git a/python/python/benchmarks/duplicate_pairs.py b/python/python/benchmarks/duplicate_pairs.py new file mode 100644 index 00000000000..ebf5625cdbe --- /dev/null +++ b/python/python/benchmarks/duplicate_pairs.py @@ -0,0 +1,288 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Profile index-only duplicate pairs against immutable local or S3 fixtures. + +Use `prepare` once on a new, dedicated prefix, then run each measured invocation +in a fresh process. `run` never writes to the source dataset. Its trace records +source-store I/O separately from process disk I/O and total network traffic. +""" + +import argparse +import hashlib +import json +import os +import resource +import threading +import time +from pathlib import Path + +import lance +import numpy as np +import pyarrow as pa +from lance.vector import find_duplicate_pairs_in_partition + + +def credentials(path): + if path: + value = json.loads(Path(path).read_text()) + for key, name in [ + ("AccessKeyId", "AWS_ACCESS_KEY_ID"), + ("SecretAccessKey", "AWS_SECRET_ACCESS_KEY"), + ("SessionToken", "AWS_SESSION_TOKEN"), + ]: + os.environ[name] = value[key] + os.environ.setdefault("AWS_REGION", "us-east-1") + + +def prepare(args): + cases = [] + for rows in [6408, 17783]: + rng = np.random.default_rng(9535) + vectors = rng.normal(size=(rows, 1536)).astype(np.float32) + vectors /= np.linalg.norm(vectors, axis=1, keepdims=True) + # Known duplicate groups give the sparse threshold meaningful output. + for start in range(0, rows, 32): + vectors[start : min(start + 16, rows)] = vectors[start] + table = pa.table( + { + "id": pa.array(np.arange(rows, dtype=np.uint64)), + "latents": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1)), 1536 + ), + } + ) + for bits in [5, 8]: + uri = f"{args.prefix.rstrip('/')}/rq{bits}-{rows}.lance" + # Refuse to overwrite an existing table. This command is only for + # creating dedicated synthetic fixtures, never production inputs. + ds = lance.write_dataset( + table, + uri, + mode="create", + max_rows_per_file=4096, + max_rows_per_group=4096, + ) + ds = ds.create_index( + "latents", + "IVF_RQ", + metric="cosine", + num_bits=bits, + num_partitions=1, + ivf_centroids=np.full((1, 1536), 1 / np.sqrt(1536), dtype=np.float32), + ) + segment = str(ds.describe_indices()[0].segments[0].uuid) + cases.append( + dict( + uri=uri, + version=ds.version, + segment_id=segment, + partition_id=0, + column="latents", + rows=rows, + dimension=1536, + num_bits=bits, + metric="cosine", + seed=9535, + ) + ) + Path(args.output).write_text(json.dumps(cases, indent=2)) + + +# Any threshold at or above this value must emit every pair of the partition. +ALL_PAIRS_THRESHOLD = 1e9 + + +def pair_hash_sum(batch): + a = batch.column(0).to_numpy(zero_copy_only=True) + b = batch.column(1).to_numpy(zero_copy_only=True) + x = np.minimum(a, b) * np.uint64(0x9E3779B97F4A7C15) ^ np.maximum(a, b) + x ^= x >> np.uint64(33) + x *= np.uint64(0xFF51AFD7ED558CCD) + x ^= x >> np.uint64(33) + return x.sum(dtype=np.uint64) + + +def process_snapshot(): + stats = resource.getrusage(resource.RUSAGE_SELF) + status = {} + for line in Path("/proc/self/status").read_text().splitlines(): + if line.startswith(("VmRSS:", "VmHWM:")): + key, value, _ = line.split() + status[key.rstrip(":")] = int(value) * 1024 + io = {} + for line in Path("/proc/self/io").read_text().splitlines(): + key, value = line.split(":") + io[key] = int(value) + received = sent = 0 + for device in Path("/sys/class/net").iterdir(): + if device.name == "lo": + continue + received += int((device / "statistics/rx_bytes").read_text()) + sent += int((device / "statistics/tx_bytes").read_text()) + return dict( + cpu_s=stats.ru_utime + stats.ru_stime, + rss_bytes=status["VmRSS"], + peak_rss_bytes=status["VmHWM"], + disk_read_bytes=io["read_bytes"], + disk_write_bytes=io["write_bytes"], + process_rchar=io["rchar"], + process_wchar=io["wchar"], + host_rx_bytes=received, + host_tx_bytes=sent, + ) + + +def source_snapshot(dataset): + stats = dataset.io_stats_snapshot() + return dict( + source_read_bytes=stats.read_bytes, + source_read_iops=stats.read_iops, + source_write_iops=stats.write_iops, + source_written_bytes=stats.written_bytes, + ) + + +def run(args): + case = json.loads(Path(args.config).read_text())[args.case] + open_start = time.perf_counter() + dataset = lance.dataset(case["uri"], version=case["version"]) + open_s = time.perf_counter() - open_start + dataset.io_stats_incremental() + output = Path(args.output) + output.parent.mkdir(parents=True, exist_ok=True) + trace = [] + stop = threading.Event() + start = time.perf_counter() + + def sample(): + trace.append( + dict( + t_s=time.perf_counter() - start, + **process_snapshot(), + **source_snapshot(dataset), + ) + ) + + def monitor(): + while not stop.wait(0.1): + sample() + + sample() + observer = threading.Thread(target=monitor, daemon=True) + observer.start() + hashes = [hashlib.sha256() for _ in range(3)] + # Order-independent multiset hash of unordered ID pairs, so implementations + # with different (but deterministic) emission orders can be compared. + pair_set_hash = 0 + count = 0 + batches = 0 + kwargs = {} + if args.memory_limit is not None: + kwargs["memory_limit"] = args.memory_limit + if args.max_concurrency is not None: + kwargs["max_concurrency"] = args.max_concurrency + try: + with find_duplicate_pairs_in_partition( + dataset, + case["column"], + case["segment_id"], + case["partition_id"], + args.threshold, + **kwargs, + ) as reader: + for batch in reader: + count += batch.num_rows + batches += 1 + for column, digest in zip(batch.columns, hashes): + digest.update(column.to_numpy(zero_copy_only=True).tobytes()) + pair_set_hash = (pair_set_hash + int(pair_hash_sum(batch))) % 2**64 + elapsed = time.perf_counter() - start + finally: + stop.set() + observer.join() + sample() + final = source_snapshot(dataset) + assert final["source_write_iops"] == 0 + assert final["source_written_bytes"] == 0 + possible_pairs = case["rows"] * (case["rows"] - 1) // 2 + if args.threshold >= ALL_PAIRS_THRESHOLD: + assert count == possible_pairs, (count, possible_pairs) + source_windows = [ + (a, b) + for a, b in zip(trace, trace[1:]) + if b["source_read_bytes"] > a["source_read_bytes"] + ] + result = dict( + label=args.label, + case=case, + threshold=args.threshold, + memory_limit=args.memory_limit, + max_concurrency=args.max_concurrency, + package_file=lance.__file__, + dataset_open_s=open_s, + elapsed_s=elapsed, + possible_pairs=possible_pairs, + output_rows=count, + output_batches=batches, + output_sha256=[h.hexdigest() for h in hashes], + pair_set_hash=f"{pair_set_hash:016x}", + pairs_per_s=possible_pairs / elapsed, + cpu_s=trace[-1]["cpu_s"] - trace[0]["cpu_s"], + cpu_cores=(trace[-1]["cpu_s"] - trace[0]["cpu_s"]) / elapsed, + start_rss_bytes=trace[0]["rss_bytes"], + peak_rss_bytes=max(point["peak_rss_bytes"] for point in trace), + **final, + ) + for key in [ + "disk_read_bytes", + "disk_write_bytes", + "process_rchar", + "process_wchar", + "host_rx_bytes", + "host_tx_bytes", + ]: + result[key] = trace[-1][key] - trace[0][key] + result["source_last_read_observed_s"] = ( + source_windows[-1][1]["t_s"] if source_windows else 0 + ) + result["source_peak_sample_bytes_per_s"] = ( + max( + ((b["source_read_bytes"] - a["source_read_bytes"]) / (b["t_s"] - a["t_s"])) + for a, b in source_windows + ) + if source_windows + else 0 + ) + output.with_suffix(".trace.json").write_text(json.dumps(trace)) + output.write_text(json.dumps(result, indent=2)) + print(json.dumps(result), flush=True) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--credentials", help="Private AWS credential JSON; never logged" + ) + commands = parser.add_subparsers(dest="command", required=True) + create = commands.add_parser("prepare") + create.add_argument("--prefix", required=True) + create.add_argument("--output", required=True) + measure = commands.add_parser("run") + measure.add_argument("--config", required=True) + measure.add_argument("--case", type=int, required=True) + measure.add_argument("--threshold", type=float, required=True) + measure.add_argument("--label", required=True) + measure.add_argument("--output", required=True) + measure.add_argument("--memory-limit", type=int) + measure.add_argument("--max-concurrency", type=int) + args = parser.parse_args() + credentials(args.credentials) + if args.command == "prepare": + prepare(args) + else: + run(args) + + +if __name__ == "__main__": + main() diff --git a/python/python/benchmarks/test_blob.py b/python/python/benchmarks/test_blob.py new file mode 100644 index 00000000000..c2465f36fad --- /dev/null +++ b/python/python/benchmarks/test_blob.py @@ -0,0 +1,73 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +from itertools import count +from time import process_time + +import pyarrow as pa +import pytest +from lance.file import LanceFileSession + +# Many small blobs isolate per-row Python overhead; fewer large blobs show how +# the bulk path behaves once payload copying accounts for more of the CPU time. +WORKLOADS = [ + pytest.param(50_000, 256, id="50000x256b"), + pytest.param(2_000, 64 * 1024, id="2000x64kib"), +] + + +def _packed_blob_benchmark(benchmark, tmpdir_factory, row_count, payload_size, mode): + payload = b"x" * payload_size + payloads = pa.repeat(payload, row_count) + files = LanceFileSession(str(tmpdir_factory.mktemp("packed_blob_writer"))) + file_number = count() + + if mode == "scalar_preconverted": + python_payloads = payloads.to_pylist() + + def write(): + writer = files.open_packed_blob_writer( + f"scalar-{next(file_number)}.lance", 1 + ) + for value in python_payloads: + writer.write_blob(value) + return writer.finish() + + elif mode == "scalar_from_arrow": + + def write(): + writer = files.open_packed_blob_writer( + f"scalar-arrow-{next(file_number)}.lance", 1 + ) + for value in payloads.to_pylist(): + writer.write_blob(value) + return writer.finish() + + elif mode == "bulk": + + def write(): + writer = files.open_packed_blob_writer(f"bulk-{next(file_number)}.lance", 1) + writer.write_blobs(payloads) + return writer.finish_array("blob") + + else: + raise ValueError(f"Unknown benchmark mode: {mode}") + + result = benchmark.pedantic(write, iterations=1, rounds=5) + assert len(result) == row_count + + +@pytest.mark.benchmark(group="packed_blob_writer_cpu", timer=process_time) +@pytest.mark.parametrize("row_count,payload_size", WORKLOADS) +@pytest.mark.parametrize( + "mode", + ["scalar_preconverted", "scalar_from_arrow", "bulk"], +) +def test_packed_blob_writer(benchmark, tmpdir_factory, row_count, payload_size, mode): + _packed_blob_benchmark( + benchmark, + tmpdir_factory, + row_count, + payload_size, + mode, + ) diff --git a/python/python/benchmarks/test_file.py b/python/python/benchmarks/test_file.py index 16d63c84a60..a47e925cf33 100644 --- a/python/python/benchmarks/test_file.py +++ b/python/python/benchmarks/test_file.py @@ -12,8 +12,8 @@ @pytest.mark.parametrize( "version", - ["2.0", "2.1"], - ids=["2_0", "2_1"], + ["2.0", "2.1", "2.2"], + ids=["2_0", "2_1", "2_2"], ) @pytest.mark.benchmark(group="scan_single_column") def test_scan_integer(tmp_path: Path, benchmark, version): @@ -47,8 +47,8 @@ def read_all(): @pytest.mark.parametrize( "version", - ["2.0", "2.1"], - ids=["2_0", "2_1"], + ["2.0", "2.1", "2.2"], + ids=["2_0", "2_1", "2_2"], ) @pytest.mark.benchmark(group="scan_single_column") def test_scan_nullable_integer(tmp_path: Path, benchmark, version): @@ -133,8 +133,8 @@ def read_all(): @pytest.mark.parametrize( "version", - ["2.0", "2.1"], - ids=["2_0", "2_1"], + ["2.0", "2.1", "2.2"], + ids=["2_0", "2_1", "2_2"], ) @pytest.mark.benchmark(group="sample_single_column") def test_sample_integer(tmp_path: Path, benchmark, version): diff --git a/python/python/benchmarks/test_index.py b/python/python/benchmarks/test_index.py index 0c6c41295fb..622ce42cd13 100644 --- a/python/python/benchmarks/test_index.py +++ b/python/python/benchmarks/test_index.py @@ -8,10 +8,47 @@ import pyarrow.compute as pc import pytest from lance.indices import IndicesBuilder, IvfModel, PqModel +from lance.vector import find_duplicate_pairs N_DIMS = 512 +@pytest.mark.benchmark(group="duplicate_pairs") +@pytest.mark.parametrize("index_type", ["IVF_FLAT", "IVF_PQ"]) +@pytest.mark.parametrize("num_rows", [1024, 1025, 8192]) +def test_duplicate_pairs_exhaust_partition(tmp_path, benchmark, index_type, num_rows): + rng = np.random.default_rng(9493) + vectors = rng.normal(size=(num_rows, 64)).astype(np.float32) + dataset = lance.write_dataset( + pa.table( + { + "vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 64)), + } + ), + tmp_path, + ) + params = {} + if index_type == "IVF_PQ": + params = dict( + num_bits=8, + num_sub_vectors=16, + pq_codebook=rng.normal(size=(16, 256, 4)).astype(np.float32), + ) + dataset.create_index( + "vector", + index_type, + num_partitions=1, + ivf_centroids=np.zeros((1, 64), dtype=np.float32), + **params, + ) + + def exhaust(): + with find_duplicate_pairs(dataset, "vector", 0.0) as reader: + return sum(batch.num_rows for batch in reader) + + assert benchmark(exhaust) == 0 + + def gen_table(num_rows): values = pc.random(num_rows * N_DIMS).cast(pa.float32()) vectors = pa.FixedSizeListArray.from_arrays(values, N_DIMS) diff --git a/python/python/benchmarks/test_scan.py b/python/python/benchmarks/test_scan.py index c813ab5653d..c43e1703ac8 100644 --- a/python/python/benchmarks/test_scan.py +++ b/python/python/benchmarks/test_scan.py @@ -9,6 +9,7 @@ import pytest NUM_ROWS = 10_000 +READER_BRIDGE_ROWS = 100_000 @pytest.mark.parametrize( @@ -76,6 +77,28 @@ def sample_dataset(tmpdir_factory): return lance.write_dataset(table, tmp_path) +@pytest.fixture(scope="module") +def reader_bridge_dataset(tmpdir_factory): + tmp_path = Path(tmpdir_factory.mktemp("reader_bridge")) + table = pa.table({"value": pa.array(range(READER_BRIDGE_ROWS), type=pa.int32())}) + return lance.write_dataset(table, tmp_path) + + +@pytest.mark.parametrize("batch_size", [64, 1024, 8192, 65536]) +@pytest.mark.parametrize( + "columns", + [pytest.param([], id="zero_columns"), pytest.param(["value"], id="i32")], +) +@pytest.mark.benchmark(group="scan_reader_bridge") +def test_scan_reader_bridge(benchmark, reader_bridge_dataset, columns, batch_size): + scanner = reader_bridge_dataset.scanner(columns=columns, batch_size=batch_size) + + def consume_reader(): + return sum(batch.num_rows for batch in scanner.to_reader()) + + assert benchmark(consume_reader) == READER_BRIDGE_ROWS + + @pytest.mark.benchmark(group="scan_table") def test_scan_table_full(benchmark, sample_dataset): result = benchmark( diff --git a/python/python/benchmarks/test_search.py b/python/python/benchmarks/test_search.py index 61076e61687..b86a2818e97 100644 --- a/python/python/benchmarks/test_search.py +++ b/python/python/benchmarks/test_search.py @@ -78,10 +78,12 @@ def create_base_dataset(data_dir: Path) -> lance.LanceDataset: rows_remaining -= next_batch_length table = create_table(next_batch_length, offset) if offset == 0: - dataset = lance.write_dataset(table, tmp_path, use_legacy_format=False) + dataset = lance.write_dataset( + table, tmp_path, data_storage_version="stable" + ) else: dataset = lance.write_dataset( - table, tmp_path, mode="append", use_legacy_format=False + table, tmp_path, mode="append", data_storage_version="stable" ) offset += next_batch_length @@ -98,7 +100,7 @@ def create_base_dataset(data_dir: Path) -> lance.LanceDataset: dataset.create_scalar_index("category", "BITMAP") dataset.create_scalar_index("genres", "LABEL_LIST") - return lance.dataset(tmp_path, index_cache_size=64 * 1024) + return lance.dataset(tmp_path, index_cache_size_bytes=512 * 1024 * 1024) def create_delete_dataset(data_dir): @@ -113,7 +115,7 @@ def create_delete_dataset(data_dir): dataset = lance.dataset(tmp_path) dataset.delete("filterable % 2 != 0") - return lance.dataset(tmp_path, index_cache_size=64 * 1024) + return lance.dataset(tmp_path, index_cache_size_bytes=512 * 1024 * 1024) def create_new_rows_dataset(data_dir): @@ -129,7 +131,7 @@ def create_new_rows_dataset(data_dir): table = create_table(NEW_ROWS, offset=NUM_ROWS) dataset = lance.write_dataset(table, tmp_path, mode="append") - return lance.dataset(tmp_path, index_cache_size=64 * 1024) + return lance.dataset(tmp_path, index_cache_size_bytes=512 * 1024 * 1024) class Datasets(NamedTuple): @@ -208,6 +210,49 @@ def test_ann_with_refine(test_dataset, benchmark): assert result.num_rows > 0 +N_BATCH_QUERIES = 32 + + +@pytest.mark.benchmark(group="query_ann_batch") +def test_batch_ann_search(test_dataset, benchmark): + # One request carrying all query vectors: the index shares each partition's + # scan across the batch (issue #6822). + queries = np.random.randn(N_BATCH_QUERIES, N_DIMS).astype(np.float32) + result = benchmark( + test_dataset.to_table, + columns=[], + with_row_id=True, + nearest=dict( + column="vector", + q=queries, + k=100, + nprobes=10, + ), + ) + assert result.num_rows > 0 + + +@pytest.mark.benchmark(group="query_ann_batch") +def test_repeated_single_ann_search(test_dataset, benchmark): + # Baseline: the same query vectors issued one indexed search at a time. + queries = np.random.randn(N_BATCH_QUERIES, N_DIMS).astype(np.float32) + + def run(): + for q in queries: + test_dataset.to_table( + columns=[], + with_row_id=True, + nearest=dict( + column="vector", + q=q, + k=100, + nprobes=10, + ), + ) + + benchmark(run) + + @pytest.mark.benchmark(group="query_ann") @pytest.mark.parametrize("selectivity", (0.25, 0.75)) @pytest.mark.parametrize("prefilter", (False, True)) diff --git a/python/python/benchmarks/test_take.py b/python/python/benchmarks/test_take.py index e7c8a2a46a9..68bd91b3681 100644 --- a/python/python/benchmarks/test_take.py +++ b/python/python/benchmarks/test_take.py @@ -127,7 +127,9 @@ def gen_ranges(total_rows, num_rows): @pytest.mark.benchmark() @pytest.mark.parametrize("file_size", [1024 * 1024], ids=["1MB"]) @pytest.mark.parametrize( - "lance_format_version", [("2.0", "V2_0"), ("2.1", "V2_1")], ids=["V2_0", "V2_1"] + "lance_format_version", + [("2.0", "V2_0"), ("2.1", "V2_1"), ("2.2", "V2_2")], + ids=["V2_0", "V2_1", "V2_2"], ) @pytest.mark.parametrize("num_rows", [100, 1000], ids=["100rows", "1000rows"]) @pytest.mark.parametrize( diff --git a/python/python/benchmarks/zone_map_seeds.py b/python/python/benchmarks/zone_map_seeds.py new file mode 100644 index 00000000000..023e14cfcf7 --- /dev/null +++ b/python/python/benchmarks/zone_map_seeds.py @@ -0,0 +1,351 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +""" +Benchmark: Zone Map Index Seeds vs. No Seeds + +Measures the time to update a zone map index when new data is appended to an +already-indexed dataset, with and without use_seeds. + +Seeds allow Lance to embed per-zone min/max statistics in data files at write +time. When optimize_indices() runs, it harvests those pre-computed stats +instead of re-scanning the full column, which eliminates the read I/O for +wide data types. + +WHY the workflow matters +------------------------ +Seeds only engage when merging new unindexed fragments into an existing +non-empty index segment. A first optimize_indices() call on a freshly +created empty-index always does a full rebuild (no old fragments to merge +against), so seeds would never be used. + +Correct workflow: + 1. Write an initial batch of data and build the zone map index on it. + This creates a non-empty indexed segment; all subsequent writes will + embed seed buffers in their data files (when use_seeds=True). + 2. Append the bulk of the data in multiple batches. + 3. optimize_indices() — harvests seeds (phase 2 data) and merges them into + the segment from phase 1. Without seeds, phase 3 must re-read all of + the phase-2 data from disk. + +Phases timed: + initial_write write seed_fraction of total rows + create/build index + ingest append remaining rows (seeds collected here when enabled) + update_index optimize_indices() — seed harvest happens here + total sum of the three phases above + +Three scenarios: + int64 10 M rows ~80 MB on disk + FixedSizeBinary(4096) 10 M rows ~38 GB on disk + LargeBinary(20 KiB) 1 M rows ~19 GB on disk + +Usage: + uv run python python/benchmarks/zone_map_seeds.py + uv run python python/benchmarks/zone_map_seeds.py --tmpdir /fast/nvme/bench + uv run python python/benchmarks/zone_map_seeds.py --scale 0.01 +""" + +import argparse +import os +import shutil +import tempfile +import time +from pathlib import Path + +import lance +import numpy as np +import pyarrow as pa +from lance.indices import IndexConfig + +KiB = 1024 + +# Fraction of total rows written in the initial indexed batch. +SEED_FRACTION = 0.1 + + +# --------------------------------------------------------------------------- +# Data generators (yield pa.Table one batch at a time) +# --------------------------------------------------------------------------- + + +def gen_int_batches(num_rows: int, batch_size: int): + rng = np.random.default_rng() + for start in range(0, num_rows, batch_size): + n = min(batch_size, num_rows - start) + yield pa.table({"value": pa.array(rng.integers(0, 2**31, n, dtype=np.int64))}) + + +def gen_fsb_batches(num_rows: int, blob_bytes: int, batch_size: int): + for start in range(0, num_rows, batch_size): + n = min(batch_size, num_rows - start) + raw = os.urandom(n * blob_bytes) + arr = pa.FixedSizeBinaryArray.from_buffers( + pa.binary(blob_bytes), n, [None, pa.py_buffer(raw)] + ) + yield pa.table({"value": arr}) + + +def gen_large_binary_batches(num_rows: int, blob_bytes: int, batch_size: int): + for start in range(0, num_rows, batch_size): + n = min(batch_size, num_rows - start) + yield pa.table( + { + "value": pa.array( + [os.urandom(blob_bytes) for _ in range(n)], + type=pa.large_binary(), + ) + } + ) + + +# --------------------------------------------------------------------------- +# Core benchmark runner +# --------------------------------------------------------------------------- + + +def run_scenario( + dataset_path: Path, + schema: pa.Schema, + seed_rows: int, + seed_batch_size: int, + gen_seed_batches, # () -> Iterator[pa.Table] for the initial write + gen_bulk_batches, # () -> Iterator[pa.Table] for the appended data + use_seeds: bool, +) -> dict: + """ + Run one full scenario and return per-phase timings. + + Seeds are only engaged by optimize_indices() when there is at least one + previously indexed fragment. This scenario ensures that by writing + seed_rows of data and building the index before any bulk ingest. + """ + if dataset_path.exists(): + shutil.rmtree(dataset_path) + + index_config = IndexConfig( + index_type="ZONEMAP", parameters={"use_seeds": use_seeds} + ) + timings = {} + + # Phase 1: initial write + index build + t0 = time.perf_counter() + ds = None + for batch in gen_seed_batches(): + if ds is None: + ds = lance.write_dataset(batch, dataset_path, schema=schema) + else: + ds = lance.write_dataset(batch, dataset_path, mode="append") + ds.create_scalar_index("value", index_type=index_config, replace=True) + timings["initial_write"] = time.perf_counter() - t0 + + # Phase 2: bulk ingest — seeds are collected per-batch when enabled + t0 = time.perf_counter() + for batch in gen_bulk_batches(): + ds = lance.write_dataset(batch, dataset_path, mode="append") + timings["ingest"] = time.perf_counter() - t0 + + # Phase 3: index update — seed harvest happens here + t0 = time.perf_counter() + ds.optimize.optimize_indices() + timings["update_index"] = time.perf_counter() - t0 + + timings["total"] = sum(timings.values()) + shutil.rmtree(dataset_path) + return timings + + +# --------------------------------------------------------------------------- +# Output formatting +# --------------------------------------------------------------------------- + + +def print_comparison(title: str, no_seeds: dict, with_seeds: dict) -> None: + phases = ["initial_write", "ingest", "update_index", "total"] + col_w = 13 + + print(f"\n{'=' * 70}") + print(f" {title}") + print(f"{'=' * 70}") + print( + f" {'Phase':<20} {'No Seeds':>{col_w}} " + + f"{'With Seeds':>{col_w}} {'Speedup':>{col_w}}" + ) + print(f" {'-' * (20 + col_w * 3 + 6)}") + for phase in phases: + t_no = no_seeds[phase] + t_yes = with_seeds[phase] + speedup = t_no / t_yes if t_yes > 0 else float("inf") + marker = "* " if phase == "total" else " " + print( + f"{marker}{phase:<20} {t_no:>{col_w}.2f}s" + f" {t_yes:>{col_w}.2f}s" + f" {speedup:>{col_w - 1}.2f}x" + ) + + +# --------------------------------------------------------------------------- +# Per-type benchmark wrappers +# --------------------------------------------------------------------------- + + +def bench_integers(base: Path, num_rows: int) -> None: + seed_rows = max(1, int(num_rows * SEED_FRACTION)) + bulk_rows = num_rows - seed_rows + # ~400 KB per seed batch; ~4 MB per bulk batch → ~20 bulk fragments + seed_bs = max(1, seed_rows // 5) + bulk_bs = max(1, bulk_rows // 20) + print( + f"\nInteger (int64): {num_rows:,} rows " + f"(initial={seed_rows:,}, bulk={bulk_rows:,}) ..." + ) + schema = pa.schema([pa.field("value", pa.int64())]) + + no_seeds = run_scenario( + base / "int_no_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_int_batches(seed_rows, seed_bs), + lambda: gen_int_batches(bulk_rows, bulk_bs), + use_seeds=False, + ) + with_seeds = run_scenario( + base / "int_with_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_int_batches(seed_rows, seed_bs), + lambda: gen_int_batches(bulk_rows, bulk_bs), + use_seeds=True, + ) + print_comparison(f"int64 — {num_rows:,} rows", no_seeds, with_seeds) + + +def bench_vectors(base: Path, num_rows: int) -> None: + blob_bytes = 4 * KiB + seed_rows = max(1, int(num_rows * SEED_FRACTION)) + bulk_rows = num_rows - seed_rows + total_gb = num_rows * blob_bytes / (1024**3) + # ~200 MB per batch to stay memory-friendly + seed_bs = max(1, (200 * KiB * KiB) // blob_bytes) + bulk_bs = seed_bs + print( + f"\nVector (FixedSizeBinary {blob_bytes // KiB} KiB): {num_rows:,} rows" + f" (~{total_gb:.1f} GB) (initial={seed_rows:,}, bulk={bulk_rows:,}) ..." + ) + schema = pa.schema([pa.field("value", pa.binary(blob_bytes))]) + + no_seeds = run_scenario( + base / "vec_no_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_fsb_batches(seed_rows, blob_bytes, seed_bs), + lambda: gen_fsb_batches(bulk_rows, blob_bytes, bulk_bs), + use_seeds=False, + ) + with_seeds = run_scenario( + base / "vec_with_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_fsb_batches(seed_rows, blob_bytes, seed_bs), + lambda: gen_fsb_batches(bulk_rows, blob_bytes, bulk_bs), + use_seeds=True, + ) + print_comparison( + f"FixedSizeBinary({blob_bytes // KiB} KiB) — {num_rows:,} rows", + no_seeds, + with_seeds, + ) + + +def bench_large_binary(base: Path, num_rows: int) -> None: + blob_bytes = 20 * KiB + seed_rows = max(1, int(num_rows * SEED_FRACTION)) + bulk_rows = num_rows - seed_rows + total_gb = num_rows * blob_bytes / (1024**3) + # ~200 MB per batch + seed_bs = max(1, (200 * KiB * KiB) // blob_bytes) + bulk_bs = seed_bs + print( + f"\nLargeBinary ({blob_bytes // KiB} KiB blobs): {num_rows:,} rows" + f" (~{total_gb:.1f} GB) (initial={seed_rows:,}, bulk={bulk_rows:,}) ..." + ) + schema = pa.schema([pa.field("value", pa.large_binary())]) + + no_seeds = run_scenario( + base / "bin_no_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_large_binary_batches(seed_rows, blob_bytes, seed_bs), + lambda: gen_large_binary_batches(bulk_rows, blob_bytes, bulk_bs), + use_seeds=False, + ) + with_seeds = run_scenario( + base / "bin_with_seeds", + schema, + seed_rows, + seed_bs, + lambda: gen_large_binary_batches(seed_rows, blob_bytes, seed_bs), + lambda: gen_large_binary_batches(bulk_rows, blob_bytes, bulk_bs), + use_seeds=True, + ) + print_comparison( + f"LargeBinary({blob_bytes // KiB} KiB) — {num_rows:,} rows", + no_seeds, + with_seeds, + ) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main() -> None: + parser = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + "--tmpdir", + type=Path, + default=None, + help="Directory for benchmark datasets (default: system temp dir)", + ) + parser.add_argument( + "--scale", + type=float, + default=1.0, + help="Row-count scale factor (default 1.0; use 0.01 for a quick smoke test)", + ) + args = parser.parse_args() + + int_rows = max(2, int(10_000_000 * args.scale)) + vec_rows = max(2, int(10_000_000 * args.scale)) + bin_rows = max(2, int(1_000_000 * args.scale)) + + print( + f"Scale: {args.scale} → " + f"{int_rows:,} int / {vec_rows:,} vector / {bin_rows:,} binary rows" + ) + + def run(tmp_dir: Path) -> None: + bench_integers(tmp_dir, int_rows) + bench_vectors(tmp_dir, vec_rows) + bench_large_binary(tmp_dir, bin_rows) + + if args.tmpdir is not None: + args.tmpdir.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory( + prefix="lance_zonemap_bench_", dir=args.tmpdir + ) as tmp: + run(Path(tmp)) + else: + with tempfile.TemporaryDirectory(prefix="lance_zonemap_bench_") as tmp: + run(Path(tmp)) + + +if __name__ == "__main__": + main() diff --git a/python/python/ci_benchmarks/benchmark.py b/python/python/ci_benchmarks/benchmark.py index 7d80596e305..8d17bef5ae5 100644 --- a/python/python/ci_benchmarks/benchmark.py +++ b/python/python/ci_benchmarks/benchmark.py @@ -20,7 +20,7 @@ def workload(dataset): import json from dataclasses import dataclass -from typing import Any, Callable, List +from typing import Any, Callable, List, Optional import pytest @@ -90,6 +90,7 @@ def __call__( func: Callable, dataset: Any, warmup: bool = True, + setup: Optional[Callable[[], Any]] = None, ) -> Any: """ Run a benchmark function with IO and memory tracking. @@ -102,28 +103,47 @@ def __call__( The dataset to pass to the function. warmup : bool, default True Whether to run a warmup iteration before measuring. + setup : Callable, optional + Called before the warmup run and again before the measured run. + Required for workloads that mutate the dataset, so each run starts + from the same state. Setup runs before IO stats are reset, so its + own IO is never attributed to the benchmark. If it returns a value, + that value is passed to ``func`` instead of ``dataset`` -- use this + when the reset produces a new dataset handle. Returns ------- Any The return value of the benchmark function. """ + + def prepare() -> Any: + if setup is None: + return dataset + replacement = setup() + return dataset if replacement is None else replacement + # Warmup run (not measured) if warmup: - func(dataset) + func(prepare()) + + target = prepare() - # Reset IO stats before the measured run + # Reset IO stats before the measured run. Stats are tracked on the + # ObjectStore, which is shared by every handle from the same session, + # so resetting/reading through `dataset` also captures work that `func` + # performs through a handle returned by `setup`. dataset.io_stats_incremental() # Run with memory tracking if available if MEMTEST_AVAILABLE: memtest.reset_stats() - result = func(dataset) + result = func(target) mem_stats = memtest.get_stats() self._stats.peak_bytes = mem_stats["peak_bytes"] self._stats.total_allocations = mem_stats["total_allocations"] else: - result = func(dataset) + result = func(target) # Capture IO stats io_stats = dataset.io_stats_incremental() @@ -159,7 +179,11 @@ def workload(dataset): if marker is None: # Not an io_memory_benchmark test, return a simple passthrough class PassthroughBenchmark: - def __call__(self, func, dataset, warmup=True): + def __call__(self, func, dataset, warmup=True, setup=None): + if setup is not None: + replacement = setup() + if replacement is not None: + dataset = replacement return func(dataset) yield PassthroughBenchmark() diff --git a/python/python/ci_benchmarks/benchmarks/test_count_rows.py b/python/python/ci_benchmarks/benchmarks/test_count_rows.py new file mode 100644 index 00000000000..f6228b04f13 --- /dev/null +++ b/python/python/ci_benchmarks/benchmarks/test_count_rows.py @@ -0,0 +1,133 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Benchmark count_rows acceleration for IS NULL / IS NOT NULL filters. + +Tests five index configurations against an int32 dataset with ~1% NULL +values. Each configuration stores the same data in a separate column so +that only one index type is active per measurement: + + none — no index, full column scan (baseline) + BITMAP — bitmap index + BTREE — btree index + ZONEMAP — zone-map index + BLOOMFILTER — bloom-filter index + +Two filters are exercised for each configuration: + IS NULL — count the ~1% null rows + IS NOT NULL — count the ~99% non-null rows + +Indexed configurations are tested in two cache states to separate first-load +latency from steady-state throughput: + + warm — one prewarm call is made before measuring; the same dataset instance + is reused so its in-memory index cache is already populated. + cold — a fresh ``lance.dataset()`` instance is created inside each measured + round so the in-memory index cache starts empty every time. No + prewarm pass is performed. +""" + +from __future__ import annotations + +import lance +import pytest +from ci_benchmarks.datasets import get_dataset_uri + +# --------------------------------------------------------------------------- +# Parameters +# --------------------------------------------------------------------------- + +# Indexed configs only (warm/cold dimension applies to these) +_INDEXED_CONFIGS: list[tuple[str, str]] = [ + ("bitmap", "value_bitmap"), + ("btree", "value_btree"), + ("zonemap", "value_zonemap"), + ("bloomfilter", "value_bloomfilter"), +] +_INDEXED_IDS = [cfg[0] for cfg in _INDEXED_CONFIGS] + +_FILTERS = ["is_null", "is_not_null"] + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture(scope="module") +def count_rows_ds() -> lance.LanceDataset: + """Shared dataset instance (index cache persists across rounds — use for warm).""" + return lance.dataset(get_dataset_uri("count_rows")) + + +@pytest.fixture(scope="module") +def count_rows_uri() -> str: + return get_dataset_uri("count_rows") + + +# --------------------------------------------------------------------------- +# No-index baseline (no warm/cold — there is no index cache to speak of) +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("filter_type", _FILTERS) +def test_count_rows_no_index( + benchmark, + count_rows_ds: lance.LanceDataset, + filter_type: str, +) -> None: + """Full-scan baseline with no scalar index.""" + filt = ( + "value_none IS NULL" if filter_type == "is_null" else "value_none IS NOT NULL" + ) + + def bench() -> int: + return count_rows_ds.count_rows(filter=filt) + + benchmark.pedantic(bench, warmup_rounds=1, rounds=5) + + +# --------------------------------------------------------------------------- +# Indexed benchmarks — warm vs cold +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("warm", [True, False], ids=["warm", "cold"]) +@pytest.mark.parametrize("filter_type", _FILTERS) +@pytest.mark.parametrize("index_id,column", _INDEXED_CONFIGS, ids=_INDEXED_IDS) +def test_count_rows_indexed( + benchmark, + count_rows_ds: lance.LanceDataset, + count_rows_uri: str, + index_id: str, + column: str, + filter_type: str, + warm: bool, +) -> None: + """Benchmark count_rows with a scalar index, in warm and cold cache states. + + Args: + index_id: Human-readable index name (parametrize label only). + column: Dataset column that carries this index type. + filter_type: ``"is_null"`` or ``"is_not_null"``. + warm: If True, prewarm the index cache before measuring and reuse the + shared dataset instance. If False, create a fresh dataset + instance on every round so the index cache starts empty. + """ + filt = f"{column} IS NULL" if filter_type == "is_null" else f"{column} IS NOT NULL" + + if warm: + + def bench() -> int: + return count_rows_ds.count_rows(filter=filt) + + # warmup_rounds=1 makes one unmeasured call that populates the cache. + benchmark.pedantic(bench, warmup_rounds=1, rounds=5) + else: + + def bench() -> int: + # Fresh instance → empty in-memory index cache every round. + ds = lance.dataset(count_rows_uri) + return ds.count_rows(filter=filt) + + benchmark.pedantic(bench, warmup_rounds=0, rounds=5) diff --git a/python/python/ci_benchmarks/benchmarks/test_merge_insert.py b/python/python/ci_benchmarks/benchmarks/test_merge_insert.py new file mode 100644 index 00000000000..49fcd693eb6 --- /dev/null +++ b/python/python/ci_benchmarks/benchmarks/test_merge_insert.py @@ -0,0 +1,805 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Benchmarks for `merge_insert`. + +merge_insert has two execution paths and the routing between them is +structural, not a flag. `use_index` is the one knob that selects between them, +so every benchmark here is parametrized on it: + + ``v1_indexed`` — ``use_index(True)`` with an indexed key. Takes the legacy + indexed-scan path (``create_indexed_scan_joined_stream``). + ``v2_hash`` — ``use_index(False)``. Disables the index gate in + ``can_use_create_plan``, so the DataFusion path + (``LanceRead + HashJoin``) runs instead. + +For a partial-schema source the write sink is a second, independent choice, made +with ``write_mode``. Under the default ``"auto"`` v1 patches columns in place +(``UpdateMode::RewriteColumns``) while v2 rewrites whole rows +(``RewriteRows``); the ``v2_rewrite_columns`` variants of +``test_update_subset_*`` ask v2 for the patching sink instead. That makes +``write_bytes`` the interesting metric for the ``test_update_*`` benchmarks: +which sink writes less depends on how wide the omitted columns are and how many +rows are matched, so these sweeps are where the crossover shows up. + +Targets are mutated, so each measured run is preceded by an untimed restore to +the ``merge_insert_base`` tag written by ``datagen/merge_insert.py``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +from typing import TYPE_CHECKING, Callable, Iterable, Optional, Sequence + +import lance +import numpy as np +import pyarrow as pa +import pytest +from ci_benchmarks.datagen.merge_insert import ( + BASE_TAG, + DELETED_ROW_STRIDE, + FRAGS_NUM_ROWS, + FRAGS_SCHEMA, + NARROW_NUM_ROWS, + NARROW_SCHEMA, + UNINDEXED_TAIL_ROWS, + WIDE_NUM_ROWS, + WIDE_ROWS_PER_FRAGMENT, + WIDE_SCALAR_COLUMNS, + WIDE_SCHEMA, + WIDE_VECTOR_DIM, + narrow_batch, +) +from ci_benchmarks.datasets import get_dataset_uri + +if TYPE_CHECKING: + from lance.dataset import ExecuteResult + +PLANS = ["v1_indexed", "v2_hash"] + +# Partial-column updates have a third shape: v2 asked for the column-patching +# sink, which is the only v2 sink that does not rewrite whole rows. +WRITE_PLANS = ["v1_indexed", "v2_hash", "v2_rewrite_columns"] + +# Brackets the cold-random break-even, which the design analysis puts at +# roughly target_rows / 4096 -- about 2.4K rows for the 10M-row narrow target. +SOURCE_SIZES = [1_000, 10_000, 100_000] + +NARROW_KEYS = ["id_int", "id_uuid7", "id_uuid4"] + +# DataFusion accounts each 8,192-row slice of the 1.32 GiB full-schema source +# for its shared backing buffers. Budget the resulting ~159 GiB of reservations +# without changing the source layout measured by this benchmark. +_MEM_POOL_SIZE = 160 * 1024**3 + + +@pytest.fixture(scope="module", autouse=True) +def _merge_insert_mem_pool() -> Iterable[None]: + """Use a bounded pool large enough for every merge_insert benchmark.""" + with pytest.MonkeyPatch.context() as monkeypatch: + monkeypatch.setenv("LANCE_MEM_POOL_SIZE", str(_MEM_POOL_SIZE)) + yield + + +# --------------------------------------------------------------------------- +# Target management +# --------------------------------------------------------------------------- + + +@dataclass +class Target: + """A merge_insert target that can be rewound to a pristine state. + + ``reset`` returns a handle sitting on a fresh version whose contents match + the ``merge_insert_base`` tag. It is called from an untimed benchmark + setup hook, so the restore never lands in the measurement. + """ + + uri: str + dataset: lance.LanceDataset + base_version: int + + def reset(self, cold: bool = False) -> lance.LanceDataset: + if cold: + # A new session means an empty index cache. `checkout_version` + # deliberately shares the cache, so a cold run cannot reuse + # `self.dataset`. + handle = lance.dataset(self.uri) + else: + handle = self.dataset + reverted = handle.checkout_version(self.base_version) + reverted.restore() + return reverted + + +def _open_target(name: str) -> Iterable[Target]: + uri = get_dataset_uri(name) + dataset = lance.dataset(uri) + tags = dataset.tags.list() + if BASE_TAG not in tags: + pytest.skip( + f"Dataset {name} has no {BASE_TAG} tag; " + "run python/ci_benchmarks/datagen/gen_all.py" + ) + base_version = tags[BASE_TAG]["version"] + + yield Target(uri=uri, dataset=dataset, base_version=base_version) + + # Leave the dataset pristine and drop the versions and data files the + # benchmarks produced. Cleanup never deletes a tagged version, so the tag + # has to move onto the restored version first or the old base would be + # retained forever. + final = dataset.checkout_version(base_version) + final.restore() + final.tags.update(BASE_TAG, final.version) + final.cleanup_old_versions(older_than=timedelta(0), delete_unverified=True) + + +@pytest.fixture(scope="module") +def narrow() -> Iterable[Target]: + yield from _open_target("merge_insert_narrow") + + +@pytest.fixture(scope="module") +def wide() -> Iterable[Target]: + yield from _open_target("merge_insert_wide") + + +@pytest.fixture(scope="module") +def frags() -> Iterable[Target]: + yield from _open_target("merge_insert_frags") + + +@pytest.fixture(scope="module") +def deleted() -> Iterable[Target]: + yield from _open_target("merge_insert_deleted") + + +@pytest.fixture(scope="module") +def unindexed_tail() -> Iterable[Target]: + yield from _open_target("merge_insert_unindexed_tail") + + +# --------------------------------------------------------------------------- +# Source construction +# --------------------------------------------------------------------------- +# +# Sources are built from a contiguous run of row indices. Which key column the +# merge joins on then decides whether those keys are clustered or scattered in +# the index's key order: `id_int` and `id_uuid7` are monotonic in the row index, +# `id_uuid4` scrambles it. This keeps one source builder for all key shapes. + + +def narrow_source(row_indices: np.ndarray) -> pa.Table: + """A full-schema narrow source. ``value`` is offset so updates are real.""" + return pa.Table.from_batches([narrow_batch(row_indices, value_offset=1)]) + + +def existing_rows(num_rows: int, offset: int = 0) -> np.ndarray: + return np.arange(offset, offset + num_rows, dtype=np.int64) + + +def new_rows(num_rows: int) -> np.ndarray: + """Row indices past the end of the target, so every key is a new key.""" + return np.arange(NARROW_NUM_ROWS, NARROW_NUM_ROWS + num_rows, dtype=np.int64) + + +def wide_row_indices(fraction: float) -> np.ndarray: + """Row indices covering ``fraction`` of every fragment's rows. + + Spread evenly within each fragment rather than contiguously: that is the + harder case for the in-place column updater, which has to interleave + updated and untouched values. + """ + per_fragment = max(1, round(fraction * WIDE_ROWS_PER_FRAGMENT)) + num_fragments = WIDE_NUM_ROWS // WIDE_ROWS_PER_FRAGMENT + return np.concatenate( + [ + fragment * WIDE_ROWS_PER_FRAGMENT + + np.linspace(0, WIDE_ROWS_PER_FRAGMENT - 1, per_fragment, dtype=np.int64) + for fragment in range(num_fragments) + ] + ) + + +def wide_source(row_indices: np.ndarray, columns: Sequence[str]) -> pa.Table: + """A wide source carrying ``id_int`` plus ``columns``.""" + arrays = [pa.array(row_indices)] + names = ["id_int"] + for column in columns: + names.append(column) + if column == "vec": + values = np.linspace( + 1.0, + 2.0, + num=len(row_indices) * WIDE_VECTOR_DIM, + dtype=np.float32, + ) + arrays.append( + pa.FixedSizeListArray.from_arrays(pa.array(values), WIDE_VECTOR_DIM) + ) + elif WIDE_SCHEMA.field(column).type == pa.string(): + arrays.append( + pa.array([f"updated_{column}_{v}" for v in row_indices], pa.string()) + ) + else: + arrays.append(pa.array(row_indices + 1)) + return pa.table(arrays, schema=pa.schema([WIDE_SCHEMA.field(n) for n in names])) + + +# --------------------------------------------------------------------------- +# Runner +# --------------------------------------------------------------------------- + + +def run( + benchmark, + target: Target, + job: Callable[[lance.LanceDataset], ExecuteResult], + *, + rounds: int = 3, + cold: bool = False, + warmup: bool = True, + expected_rows: Optional[int] = None, +) -> None: + """Benchmark ``job``, restoring the target before every round. + + ``cold`` opens a fresh session for each round so the index cache starts + empty. ``warmup`` runs one unmeasured round first; turn it off for + expensive shapes where a second pass is not worth the wall clock. + + ``expected_rows`` guards against a benchmark that silently stops doing + work: a semantics regression should fail the run, not post a fast time. + Row count is checked instead of the returned stats because it is + independent of which execution path ran. + """ + state: dict = {} + + def setup() -> None: + state["dataset"] = target.reset(cold=cold) + + def bench() -> None: + dataset = state["dataset"] + state["stats"] = job(dataset) + if expected_rows is not None: + state["row_count"] = dataset.count_rows() + + benchmark.pedantic( + bench, + setup=setup, + rounds=rounds, + iterations=1, + # `setup` runs before each warmup round too, so a warmup never leaves + # the target dirty for the measured rounds. + warmup_rounds=1 if warmup and not cold else 0, + ) + + if expected_rows is not None: + assert state["row_count"] == expected_rows, ( + f"expected {expected_rows} rows after merge_insert, " + f"got {state['row_count']} (stats: {state['stats']})" + ) + + +def upsert( + key: str, source: pa.Table, *, use_index: bool +) -> Callable[[lance.LanceDataset], ExecuteResult]: + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert(key) + .when_matched_update_all() + .when_not_matched_insert_all() + .use_index(use_index) + .execute(source) + ) + + return job + + +def uses_index(plan: str) -> bool: + return plan == "v1_indexed" + + +def write_mode_for(plan: str) -> str: + return "rewrite_columns" if plan == "v2_rewrite_columns" else "auto" + + +# --------------------------------------------------------------------------- +# A. Cost model core -- merge_insert_narrow, 10M rows +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("plan", PLANS) +def test_upsert_point(benchmark, narrow: Target, plan: str) -> None: + """Single-row upsert latency -- the extreme where a probe should win.""" + source = narrow_source(existing_rows(1)) + run( + benchmark, + narrow, + upsert("id_int", source, use_index=uses_index(plan)), + rounds=5, + expected_rows=NARROW_NUM_ROWS, + ) + + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("key", NARROW_KEYS) +@pytest.mark.parametrize("num_rows", SOURCE_SIZES) +def test_upsert_ratio_sweep( + benchmark, narrow: Target, num_rows: int, key: str, plan: str +) -> None: + """Source/target ratio sweep against a warm index.""" + source = narrow_source(existing_rows(num_rows)) + run( + benchmark, + narrow, + upsert(key, source, use_index=uses_index(plan)), + expected_rows=NARROW_NUM_ROWS, + ) + + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("key", ["id_int", "id_uuid4"]) +@pytest.mark.parametrize("num_rows", [1_000, 100_000]) +def test_upsert_cold_ratio_sweep( + benchmark, narrow: Target, num_rows: int, key: str, plan: str +) -> None: + """Same sweep with a cold index cache, where page reads are not free.""" + source = narrow_source(existing_rows(num_rows)) + run( + benchmark, + narrow, + upsert(key, source, use_index=uses_index(plan)), + cold=True, + expected_rows=NARROW_NUM_ROWS, + ) + + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("num_rows", [10_000, 100_000]) +def test_upsert_all_new_keys( + benchmark, narrow: Target, num_rows: int, plan: str +) -> None: + """Time-ordered ingest: every key is new, so no index page should be read.""" + source = narrow_source(new_rows(num_rows)) + run( + benchmark, + narrow, + upsert("id_uuid7", source, use_index=uses_index(plan)), + expected_rows=NARROW_NUM_ROWS + num_rows, + ) + + +@pytest.mark.parametrize("num_rows", [1_000, 100_000]) +def test_upsert_unindexed_baseline(benchmark, narrow: Target, num_rows: int) -> None: + """Joining on a column with no index at all -- the no-index reference.""" + source = narrow_source(existing_rows(num_rows)) + run( + benchmark, + narrow, + upsert("id_no_index", source, use_index=True), + expected_rows=NARROW_NUM_ROWS, + ) + + +# --------------------------------------------------------------------------- +# B. Write path -- merge_insert_wide, 1M rows +# --------------------------------------------------------------------------- + +# Fractions are of each fragment's rows, which is what decides whether the +# in-place updater interleaves or rewrites a whole column file. +ROW_FRACTIONS = [0.001, 0.01, 0.1, 1.0] +FRACTION_IDS = ["0.1pct", "1pct", "10pct", "100pct"] + +# Isolates the two pressures a partial-column update can exert: field count +# (scalar columns, cheap per field) and byte volume (the vector column). +PROJECTIONS = { + "one_scalar": WIDE_SCALAR_COLUMNS[:1], + "ten_scalars": WIDE_SCALAR_COLUMNS[:10], + "vector": ["vec"], + "vector_and_ten_scalars": ["vec"] + WIDE_SCALAR_COLUMNS[:10], +} + + +def _wide_rounds(fraction: float) -> int: + # A full-fragment update rewrites every column file it touches, which for + # the vector column is ~1 GB per round. + return 1 if fraction == 1.0 else 3 + + +def update_subset( + source: pa.Table, *, use_index: bool, write_mode: str = "auto" +) -> Callable[[lance.LanceDataset], ExecuteResult]: + """Partial-schema update. No insert clause: matched rows only.""" + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_matched_update_all() + .use_index(use_index) + .write_mode(write_mode) + .execute(source) + ) + + return job + + +@pytest.mark.parametrize("plan", WRITE_PLANS) +@pytest.mark.parametrize("fraction", ROW_FRACTIONS, ids=FRACTION_IDS) +def test_update_subset_row_fraction( + benchmark, wide: Target, fraction: float, plan: str +) -> None: + """Row-fraction sweep at a fixed, minimal projection. + + Locates the crossover between patching a column in place and rewriting the + whole column file, without byte volume confounding it. + """ + source = wide_source(wide_row_indices(fraction), PROJECTIONS["one_scalar"]) + run( + benchmark, + wide, + update_subset( + source, + use_index=uses_index(plan), + write_mode=write_mode_for(plan), + ), + rounds=_wide_rounds(fraction), + warmup=fraction != 1.0, + expected_rows=WIDE_NUM_ROWS, + ) + + +@pytest.mark.parametrize("plan", WRITE_PLANS) +@pytest.mark.parametrize("fraction", [0.01, 1.0], ids=["1pct", "100pct"]) +@pytest.mark.parametrize("projection", list(PROJECTIONS), ids=list(PROJECTIONS)) +def test_update_subset_projection( + benchmark, wide: Target, projection: str, fraction: float, plan: str +) -> None: + """Projection sweep: field count vs byte volume, plus the cross term.""" + source = wide_source(wide_row_indices(fraction), PROJECTIONS[projection]) + run( + benchmark, + wide, + update_subset( + source, + use_index=uses_index(plan), + write_mode=write_mode_for(plan), + ), + rounds=_wide_rounds(fraction), + warmup=fraction != 1.0, + expected_rows=WIDE_NUM_ROWS, + ) + + +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("fraction", [0.01, 1.0], ids=["1pct", "100pct"]) +def test_upsert_wide_full_schema( + benchmark, wide: Target, fraction: float, plan: str +) -> None: + """Full-schema baseline for the partial-column benchmarks above.""" + row_indices = wide_row_indices(fraction) + source = wide_source( + row_indices, [f.name for f in WIDE_SCHEMA if f.name != "id_int"] + ) + run( + benchmark, + wide, + upsert("id_int", source, use_index=uses_index(plan)), + rounds=_wide_rounds(fraction), + warmup=fraction != 1.0, + expected_rows=WIDE_NUM_ROWS, + ) + + +# --------------------------------------------------------------------------- +# C. Clause shapes -- merge_insert_narrow, 10K-row source +# --------------------------------------------------------------------------- + +CLAUSE_SOURCE_ROWS = 10_000 + + +@pytest.mark.parametrize("plan", PLANS) +def test_insert_if_not_exists(benchmark, narrow: Target, plan: str) -> None: + """Dedup ingest: half the keys already exist, matched rows are untouched. + + The probe only needs to know whether a key exists, so no target payload has + to be read. + """ + half = CLAUSE_SOURCE_ROWS // 2 + row_indices = np.concatenate([existing_rows(half), new_rows(half)]) + source = narrow_source(row_indices) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_not_matched_insert_all() + .use_index(uses_index(plan)) + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=NARROW_NUM_ROWS + half) + + +@pytest.mark.parametrize("plan", PLANS) +def test_update_only(benchmark, narrow: Target, plan: str) -> None: + """No insert clause: unmatched source rows are dropped.""" + half = CLAUSE_SOURCE_ROWS // 2 + row_indices = np.concatenate([existing_rows(half), new_rows(half)]) + source = narrow_source(row_indices) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_matched_update_all() + .use_index(uses_index(plan)) + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=NARROW_NUM_ROWS) + + +def test_delete_by_source(benchmark, narrow: Target) -> None: + """Deleting rows absent from the source requires a full target scan. + + The index gate in `can_use_create_plan` rejects this shape outright, so + there is no v1 variant to compare against. + """ + source = narrow_source(existing_rows(CLAUSE_SOURCE_ROWS)) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_matched_update_all() + .when_not_matched_insert_all() + .when_not_matched_by_source_delete() + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=CLAUSE_SOURCE_ROWS) + + +@pytest.mark.parametrize("plan", PLANS) +def test_conditional_update(benchmark, narrow: Target, plan: str) -> None: + """A condition on target columns forces the target payload to be read.""" + source = narrow_source(existing_rows(CLAUSE_SOURCE_ROWS)) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_matched_update_all(condition="source.value > target.value") + .when_not_matched_insert_all() + .use_index(uses_index(plan)) + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=NARROW_NUM_ROWS) + + +@pytest.mark.parametrize("plan", PLANS) +def test_composite_key_fully_indexed(benchmark, narrow: Target, plan: str) -> None: + """Both key columns indexed: v1 probes each index and AND-folds.""" + source = narrow_source(existing_rows(CLAUSE_SOURCE_ROWS)) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert(["composite_a", "composite_b"]) + .when_matched_update_all() + .when_not_matched_insert_all() + .use_index(uses_index(plan)) + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=NARROW_NUM_ROWS) + + +def test_composite_key_partially_indexed(benchmark, narrow: Target) -> None: + """One key column unindexed, which forces the hash join regardless of flag. + + A partial index probe would under-match, so `can_use_create_plan` keeps + this shape off the indexed path. + """ + source = narrow_source(existing_rows(CLAUSE_SOURCE_ROWS)) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + # `composite_a` is indexed, `id_no_index` is not. Both are equal to the + # row index in source and target, so every source row matches. + return ( + dataset.merge_insert(["composite_a", "id_no_index"]) + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(source) + ) + + run(benchmark, narrow, job, expected_rows=NARROW_NUM_ROWS) + + +# --------------------------------------------------------------------------- +# D. Target shape +# --------------------------------------------------------------------------- + +TARGET_SHAPE_ROWS = 10_000 + + +@pytest.mark.parametrize("plan", PLANS) +def test_upsert_unindexed_tail(benchmark, unindexed_tail: Target, plan: str) -> None: + """10% of the target was appended after indexing, so a scan is unioned in.""" + source = narrow_source(existing_rows(TARGET_SHAPE_ROWS)) + total_rows = NARROW_NUM_ROWS + UNINDEXED_TAIL_ROWS + run( + benchmark, + unindexed_tail, + upsert("id_int", source, use_index=uses_index(plan)), + expected_rows=total_rows, + ) + + +@pytest.mark.parametrize("plan", PLANS) +def test_upsert_with_deletion_files(benchmark, deleted: Target, plan: str) -> None: + """Every fragment carries a deletion file, which the probe has to mask.""" + source = narrow_source(existing_rows(TARGET_SHAPE_ROWS)) + # Row 0 and every DELETED_ROW_STRIDE-th row after it were deleted at + # generation time, so those source rows insert rather than update. + reinserted = TARGET_SHAPE_ROWS // DELETED_ROW_STRIDE + total_rows = NARROW_NUM_ROWS - NARROW_NUM_ROWS // DELETED_ROW_STRIDE + reinserted + run( + benchmark, + deleted, + upsert("id_int", source, use_index=uses_index(plan)), + expected_rows=total_rows, + ) + + +@pytest.mark.parametrize("plan", PLANS) +def test_upsert_many_small_fragments(benchmark, frags: Target, plan: str) -> None: + """10K fragments of 1K rows, to expose per-fragment overhead.""" + row_indices = existing_rows(TARGET_SHAPE_ROWS) + source = pa.table( + [pa.array(row_indices), pa.array(row_indices + 1)], schema=FRAGS_SCHEMA + ) + run( + benchmark, + frags, + upsert("id_int", source, use_index=uses_index(plan)), + expected_rows=FRAGS_NUM_ROWS, + ) + + +# --------------------------------------------------------------------------- +# E. Memory and regime edges +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("plan", PLANS) +def test_upsert_streaming_source(benchmark, narrow: Target, plan: str) -> None: + """A one-shot reader source, which v1 has to buffer in full to fork it. + + Peak memory is the number of interest here. + """ + num_rows = 1_000_000 + batch_size = 100_000 + + def make_reader() -> pa.RecordBatchReader: + def batches(): + for start in range(0, num_rows, batch_size): + yield narrow_batch( + existing_rows(batch_size, offset=start), value_offset=1 + ) + + return pa.RecordBatchReader.from_batches(NARROW_SCHEMA, batches()) + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + return ( + dataset.merge_insert("id_int") + .when_matched_update_all() + .when_not_matched_insert_all() + .use_index(uses_index(plan)) + .execute(make_reader()) + ) + + run( + benchmark, + narrow, + job, + rounds=1, + warmup=False, + expected_rows=NARROW_NUM_ROWS, + ) + + +def test_upsert_source_equals_target(benchmark, narrow: Target) -> None: + """Source the same size as the target -- the probe must not be chosen here. + + v2 only: the v1 indexed path cannot run this shape at all. Its source-side + hash join asks for more than the whole memory pool and the operation fails + with "Resources exhausted". So there is no v1 baseline to compare against, + and this benchmark exists to keep the v2 path honest at this size. + """ + source = narrow_source(existing_rows(NARROW_NUM_ROWS)) + run( + benchmark, + narrow, + upsert("id_int", source, use_index=False), + rounds=1, + warmup=False, + expected_rows=NARROW_NUM_ROWS, + ) + + +# --------------------------------------------------------------------------- +# IO / memory variants +# --------------------------------------------------------------------------- +# +# A subset of the above, re-run under the io_memory_benchmark fixture. Write +# amplification (`write_bytes`) is the headline for the partial-column cases and +# peak memory is the headline for the streaming source. + + +@pytest.mark.io_memory_benchmark() +@pytest.mark.parametrize("plan", PLANS) +@pytest.mark.parametrize("key", NARROW_KEYS) +@pytest.mark.parametrize("num_rows", [1_000, 100_000]) +def test_io_mem_upsert_ratio( + io_mem_benchmark, narrow: Target, num_rows: int, key: str, plan: str +) -> None: + source = narrow_source(existing_rows(num_rows)) + job = upsert(key, source, use_index=uses_index(plan)) + io_mem_benchmark(job, narrow.dataset, setup=lambda: narrow.reset()) + + +@pytest.mark.io_memory_benchmark() +@pytest.mark.parametrize("plan", WRITE_PLANS) +@pytest.mark.parametrize("fraction", ROW_FRACTIONS, ids=FRACTION_IDS) +def test_io_mem_update_subset_row_fraction( + io_mem_benchmark, wide: Target, fraction: float, plan: str +) -> None: + source = wide_source(wide_row_indices(fraction), PROJECTIONS["one_scalar"]) + job = update_subset( + source, use_index=uses_index(plan), write_mode=write_mode_for(plan) + ) + io_mem_benchmark( + job, + wide.dataset, + warmup=fraction != 1.0, + setup=lambda: wide.reset(), + ) + + +@pytest.mark.io_memory_benchmark() +@pytest.mark.parametrize("plan", WRITE_PLANS) +@pytest.mark.parametrize("projection", list(PROJECTIONS), ids=list(PROJECTIONS)) +def test_io_mem_update_subset_projection( + io_mem_benchmark, wide: Target, projection: str, plan: str +) -> None: + source = wide_source(wide_row_indices(0.01), PROJECTIONS[projection]) + job = update_subset( + source, use_index=uses_index(plan), write_mode=write_mode_for(plan) + ) + io_mem_benchmark(job, wide.dataset, setup=lambda: wide.reset()) + + +@pytest.mark.io_memory_benchmark() +@pytest.mark.parametrize("plan", PLANS) +def test_io_mem_upsert_streaming_source( + io_mem_benchmark, narrow: Target, plan: str +) -> None: + num_rows = 1_000_000 + batch_size = 100_000 + + def job(dataset: lance.LanceDataset) -> ExecuteResult: + def batches(): + for start in range(0, num_rows, batch_size): + yield narrow_batch( + existing_rows(batch_size, offset=start), value_offset=1 + ) + + reader = pa.RecordBatchReader.from_batches(NARROW_SCHEMA, batches()) + return ( + dataset.merge_insert("id_int") + .when_matched_update_all() + .when_not_matched_insert_all() + .use_index(uses_index(plan)) + .execute(reader) + ) + + io_mem_benchmark(job, narrow.dataset, warmup=False, setup=lambda: narrow.reset()) diff --git a/python/python/ci_benchmarks/benchmarks/test_overlay_manifest.py b/python/python/ci_benchmarks/benchmarks/test_overlay_manifest.py new file mode 100644 index 00000000000..4a9d8c75e83 --- /dev/null +++ b/python/python/ci_benchmarks/benchmarks/test_overlay_manifest.py @@ -0,0 +1,50 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Impact of overlay files on manifest size. + +Each committed overlay adds a ``DataOverlayFile`` entry (a value-file pointer +plus a serialized coverage bitmap) to every overlaid fragment's metadata in the +manifest. This measures how the manifest grows with the number of overlays and +the coverage size, since manifests are read on every dataset open. +""" + +import pytest +from ci_benchmarks.overlays import ( + commit_overlay_layers, + make_base_dataset, + manifest_size, +) + +NUM_ROWS = 1_000_000 +# Single fragment so every overlay lands on the same fragment's metadata. +ROWS_PER_FILE = NUM_ROWS + +# Strided 10% coverage is the worst case for bitmap size, so manifest growth is +# measured at its upper bound rather than swept across coverage shapes. +COVERAGE_FRACTION = 0.1 +COVERAGE_PATTERN = "stride" + + +@pytest.mark.parametrize("num_overlays", [0, 4, 64]) +def test_overlay_manifest_size(tmp_path, record_property, num_overlays): + base = str(tmp_path / "ds") + ds = make_base_dataset(base, NUM_ROWS, ROWS_PER_FILE, "int32") + base_bytes = manifest_size(ds) + + if num_overlays: + ds = commit_overlay_layers( + ds, num_overlays, COVERAGE_FRACTION, COVERAGE_PATTERN, "int32" + ) + + total = manifest_size(ds) + growth = total - base_bytes + # Guard the fixture: committed overlays must enlarge the manifest, else the + # benchmark would report growth=0 for overlays that were never recorded. + if num_overlays: + assert growth > 0, "overlays did not grow the manifest" + per_overlay = growth / num_overlays if num_overlays else 0 + + record_property("manifest_bytes", total) + record_property("manifest_growth_bytes", growth) + record_property("bytes_per_overlay", per_overlay) diff --git a/python/python/ci_benchmarks/benchmarks/test_overlay_read.py b/python/python/ci_benchmarks/benchmarks/test_overlay_read.py new file mode 100644 index 00000000000..a5774e5ea85 --- /dev/null +++ b/python/python/ci_benchmarks/benchmarks/test_overlay_read.py @@ -0,0 +1,139 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Impact of overlay files on take and scan workloads. + +On read, every overlay covering a requested cell must be consulted and its value +merged over the base. This measures how take and full-scan cost scale with: +- the number of overlay layers stacked on a fragment (compaction payoff), +- fragmentation (contiguous run vs. strided -> pages touched), and +- value width (a 4-byte int32 vs. a wide fixed-size-list embedding). + +Coverage is fixed at 1% and only the default storage version is measured — these +exist to catch regressions, not to explore the parameter space. + +Wall time is measured warm via pytest-benchmark; read IO (bytes + IOPS) is +measured once cold, after dropping the page cache, via ``io_stats_incremental``. +""" + +import random + +import lance +import pytest +from ci_benchmarks.overlays import commit_overlay_layers, make_base_dataset +from ci_benchmarks.utils import wipe_os_cache + +NUM_ROWS = 1_000_000 +ROWS_PER_FILE = NUM_ROWS # single fragment: isolates overlay-layer scaling +TAKE_ROWS = 100 + +# Wide value column: a 3072-d float32 embedding is 12 KiB/row, ~750x an int32 +# cell. Fewer rows keep the base file to ~1.2 GiB while each cell still dominates +# read cost, so the merge/interleave a scan pays per overlay layer moves real +# payload rather than 4-byte integers. +WIDE_EMBEDDING_DIM = 3072 +NUM_ROWS_WIDE = 100_000 + +# Coverage size is held constant so the sweeps isolate layer count and coverage +# shape; 1% models a small targeted update, the case overlays are built for. +COVERAGE_FRACTION = 0.01 + + +def _take_indices(num_rows: int) -> list[int]: + rng = random.Random(0) + return sorted(rng.sample(range(num_rows), TAKE_ROWS)) + + +def _covered_value(ds: lance.LanceDataset): + """``val`` at offset 0, which every coverage pattern includes. + + Used to guard the fixture: after committing overlays a covered cell must + read back a new value, otherwise a read-invisible overlay would let the + benchmark silently time plain base reads. + """ + return ds.take([0], columns=["val"]).column("val").to_pylist()[0] + + +def _measure_cold_io(ds: lance.LanceDataset, base: str, work): + """Drop the page cache, run ``work`` once, return its read IO stats.""" + wipe_os_cache(base) + ds.io_stats_incremental() # reset + work() + stats = ds.io_stats_incremental() + return stats.read_bytes, stats.read_iops + + +def _run_read(benchmark, record_property, base, ds, workload, num_rows): + if workload == "take": + indices = _take_indices(num_rows) + + def work(): + ds.take(indices, columns=["val"]) + else: + + def work(): + ds.to_table(columns=["val"]) + + read_bytes, read_iops = _measure_cold_io(ds, base, work) + record_property("cold_read_bytes", read_bytes) + record_property("cold_read_iops", read_iops) + + benchmark(work) + + +@pytest.mark.parametrize("workload", ["take", "scan"]) +@pytest.mark.parametrize("num_overlays", [0, 4]) +@pytest.mark.parametrize("pattern", ["contiguous", "stride"]) +def test_overlay_read_scaling( + benchmark, + tmp_path, + record_property, + workload, + num_overlays, + pattern, +): + base = str(tmp_path / "ds") + ds = make_base_dataset(base, NUM_ROWS, ROWS_PER_FILE, "int32") + if num_overlays: + base_val = _covered_value(ds) + ds = commit_overlay_layers( + ds, num_overlays, COVERAGE_FRACTION, pattern, "int32" + ) + assert _covered_value(ds) != base_val, "overlay not visible on read" + _run_read(benchmark, record_property, base, ds, workload, NUM_ROWS) + + +# Mirror test_overlay_read_scaling but on a wide 3072-d embedding column, so the +# take/scan-vs-layers story can be read for a fat value column rather than a +# 4-byte one. +@pytest.mark.parametrize("workload", ["take", "scan"]) +@pytest.mark.parametrize("num_overlays", [0, 4]) +@pytest.mark.parametrize("pattern", ["contiguous", "stride"]) +def test_overlay_read_wide( + benchmark, + tmp_path, + record_property, + workload, + num_overlays, + pattern, +): + base = str(tmp_path / "ds") + ds = make_base_dataset( + base, + NUM_ROWS_WIDE, + NUM_ROWS_WIDE, + "embedding", + embedding_dim=WIDE_EMBEDDING_DIM, + ) + if num_overlays: + base_val = _covered_value(ds) + ds = commit_overlay_layers( + ds, + num_overlays, + COVERAGE_FRACTION, + pattern, + "embedding", + embedding_dim=WIDE_EMBEDDING_DIM, + ) + assert _covered_value(ds) != base_val, "overlay not visible on read" + _run_read(benchmark, record_property, base, ds, workload, NUM_ROWS_WIDE) diff --git a/python/python/ci_benchmarks/benchmarks/test_scan.py b/python/python/ci_benchmarks/benchmarks/test_scan.py index 22186ea33c1..468fab7ebf3 100644 --- a/python/python/ci_benchmarks/benchmarks/test_scan.py +++ b/python/python/ci_benchmarks/benchmarks/test_scan.py @@ -30,4 +30,9 @@ def bench(): ds = lance.dataset(dataset_uri) ds.to_table(offset=num_rows - 100, limit=50) - benchmark.pedantic(bench, rounds=1, iterations=1) + # A single unwarmed round measures whatever page-cache state the preceding + # benchmarks left behind (test_full_scan alone cycles the dataset plus a + # full in-memory table through RAM), not the scan itself. Warm up once and + # take several rounds so the recorded value tracks the code path + # deterministically (see issue #8289). + benchmark.pedantic(bench, rounds=5, iterations=1, warmup_rounds=1) diff --git a/python/python/ci_benchmarks/benchmarks/test_search.py b/python/python/ci_benchmarks/benchmarks/test_search.py index 2ca76f8d865..84eb10fc872 100644 --- a/python/python/ci_benchmarks/benchmarks/test_search.py +++ b/python/python/ci_benchmarks/benchmarks/test_search.py @@ -102,11 +102,17 @@ def bench(): BASIC_LARGE_IN_FILTER = ( "row_number IN (" + ", ".join([str(i) for i in range(100000, 100100)]) + ")" ) +# row_number runs 0..10M, so this selects 7M rows (70% of the dataset). Both +# bounds land mid-page, so the scan has to walk the great majority of the +# btree's leaves and union their row id lists +BASIC_LARGE_RANGE_FILTER = "row_number >= 1000000 AND row_number < 8000000" + BASIC_BTREE_FILTERS = [ None, "row_number = 100000", "row_number != 100000", "row_number >= 100000 AND row_number <= 100007", + BASIC_LARGE_RANGE_FILTER, BASIC_LARGE_IN_FILTER, ] @@ -115,6 +121,7 @@ def bench(): "equal", "not_equal", "small_range", + "large_range", "large_in", ] diff --git a/python/python/ci_benchmarks/datagen/basic.py b/python/python/ci_benchmarks/datagen/basic.py index 9629ac09509..b24193907b7 100644 --- a/python/python/ci_benchmarks/datagen/basic.py +++ b/python/python/ci_benchmarks/datagen/basic.py @@ -72,7 +72,7 @@ def _create(dataset_uri: str): schema=SCHEMA, mode="create", ) - if ds.list_indices() == []: + if not ds.describe_indices(): ds.create_scalar_index("row_number", "BTREE") ds.create_scalar_index("row_number_bitmap", "BITMAP") diff --git a/python/python/ci_benchmarks/datagen/count_rows.py b/python/python/ci_benchmarks/datagen/count_rows.py new file mode 100644 index 00000000000..ff5fa02cf8a --- /dev/null +++ b/python/python/ci_benchmarks/datagen/count_rows.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Generate the count_rows benchmark dataset. + +Creates a 10-million-row Lance dataset with five int32 columns that all hold the +same values and the same ~1% null mask. Each column carries a different scalar +index so the benchmark can compare no-index, BITMAP, BTREE, ZONEMAP, and +BLOOMFILTER side-by-side on the same underlying data. + +Column layout +------------- +value_none — no index (full-scan baseline) +value_bitmap — BITMAP index +value_btree — BTREE index +value_zonemap — ZONEMAP index +value_bloomfilter — BLOOMFILTER index + +Null pattern: row i is null when i % 100 == 0 (~1% of rows exactly). +""" + +import lance +import numpy as np +import pyarrow as pa +from lance.log import LOGGER + +from ci_benchmarks.datasets import get_dataset_uri + +NUM_ROWS = 10_000_000 +BATCH_SIZE = 1_000_000 # 1 M rows per batch → 10 batches total + +COLUMNS = [ + "value_none", + "value_bitmap", + "value_btree", + "value_zonemap", + "value_bloomfilter", +] + +SCHEMA = pa.schema([(col, pa.int32()) for col in COLUMNS]) + + +def _gen_data(): + num_batches = NUM_ROWS // BATCH_SIZE + for batch_idx in range(num_batches): + offset = batch_idx * BATCH_SIZE + values = np.arange(offset, offset + BATCH_SIZE, dtype=np.int32) + # Null mask: True where the value should be null (~1% of rows) + null_mask = (np.arange(BATCH_SIZE) + offset) % 100 == 0 + col = pa.array(values, type=pa.int32(), mask=null_mask) + yield pa.record_batch([col] * len(COLUMNS), schema=SCHEMA) + + +def gen_count_rows() -> lance.LanceDataset: + dataset_uri = get_dataset_uri("count_rows") + + try: + ds = lance.dataset(dataset_uri) + if ds.count_rows() == NUM_ROWS: + LOGGER.info( + "count_rows dataset already exists at %s (%d rows)", + dataset_uri, + NUM_ROWS, + ) + return ds + LOGGER.warning( + "count_rows dataset at %s has unexpected row count %d; regenerating", + dataset_uri, + ds.count_rows(), + ) + except Exception: + pass + + LOGGER.info( + "Writing count_rows dataset (%d rows, %d columns) to %s", + NUM_ROWS, + len(COLUMNS), + dataset_uri, + ) + ds = lance.write_dataset( + _gen_data(), + dataset_uri, + schema=SCHEMA, + mode="overwrite", + ) + LOGGER.info("Dataset written; building scalar indexes …") + + for index_type, column in [ + ("BITMAP", "value_bitmap"), + ("BTREE", "value_btree"), + ("ZONEMAP", "value_zonemap"), + ("BLOOMFILTER", "value_bloomfilter"), + ]: + LOGGER.info(" Creating %s index on %s …", index_type, column) + ds.create_scalar_index(column, index_type) + + LOGGER.info("count_rows dataset ready.") + return ds diff --git a/python/python/ci_benchmarks/datagen/gen_all.py b/python/python/ci_benchmarks/datagen/gen_all.py index 1da7c05fd9b..79725708bb8 100644 --- a/python/python/ci_benchmarks/datagen/gen_all.py +++ b/python/python/ci_benchmarks/datagen/gen_all.py @@ -6,7 +6,9 @@ from lance.log import LOGGER from ci_benchmarks.datagen.basic import gen_basic +from ci_benchmarks.datagen.count_rows import gen_count_rows from ci_benchmarks.datagen.lineitems import gen_tcph +from ci_benchmarks.datagen.merge_insert import gen_merge_insert from ci_benchmarks.datagen.wikipedia import gen_wikipedia @@ -40,6 +42,12 @@ def setup_logging(): LOGGER.info("Generating Wikipedia dataset...") gen_wikipedia() + LOGGER.info("Generating count_rows benchmark dataset...") + gen_count_rows() + + LOGGER.info("Generating merge_insert benchmark datasets...") + gen_merge_insert() + LOGGER.info("=" * 80) LOGGER.info("All datasets generated successfully!") LOGGER.info("=" * 80) 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: diff --git a/python/python/ci_benchmarks/datagen/merge_insert.py b/python/python/ci_benchmarks/datagen/merge_insert.py new file mode 100644 index 00000000000..d69a7f4c85f --- /dev/null +++ b/python/python/ci_benchmarks/datagen/merge_insert.py @@ -0,0 +1,423 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Generate the merge_insert benchmark datasets. + +merge_insert benchmarks mutate their target, so each dataset carries a +``merge_insert_base`` tag pointing at a pristine version. Benchmarks restore +to that tag before every measured run (see ``benchmarks/test_merge_insert.py``), +which makes the suite tolerant of a crashed run leaving stale versions behind. + +Datasets +-------- +``merge_insert_narrow`` + 10M rows, 10 fragments. The key columns differ along two independent + axes so benchmarks can separate them: + + * ``id_int`` vs ``id_uuid7`` -- key *type* (int64 vs string), both + clustered. + * ``id_uuid7`` vs ``id_uuid4`` -- key *distribution* (clustered vs + random), both string. + + ``id_no_index`` holds the same values as ``id_int`` with no index, for the + "user never built an index" baseline. ``composite_a``/``composite_b`` are + both indexed, for composite-key probes. + +``merge_insert_wide`` + 1M rows, 10 fragments. 20 narrow scalar columns (~200 MB) plus one + 256-dim float32 vector column (~1 GB). These exert different pressures on + a partial-column update: the scalar columns stress field count and the + number of distinct buffers to schedule, the vector column stresses raw + byte volume. + +``merge_insert_frags`` + 10M rows in 10K fragments of 1K rows, for per-fragment overhead. + +``merge_insert_deleted`` + ``merge_insert_narrow`` layout with a deletion file on every fragment. + +``merge_insert_unindexed_tail`` + ``merge_insert_narrow`` layout where 10% of the rows were appended after + the index was built, so the probe must union an unindexed scan. +""" + +from __future__ import annotations + +import os +from contextlib import contextmanager + +import lance +import numpy as np +import pyarrow as pa +from lance.log import LOGGER + +from ci_benchmarks.datasets import get_dataset_uri + +# Tag marking the pristine version that benchmarks restore to. +BASE_TAG = "merge_insert_base" + +# BTREE training sorts the full column under DataFusion's FairSpillPool. The +# default 150 MiB pool is tight for 10M 32-byte string keys (uuid columns): +# ExternalSorterMerge has peaked near the pool edge and aborted datagen with +# ResourcesExhausted. Index build is one-shot setup, not a measured path, so +# temporarily enlarge the pool only around create_scalar_index. +_INDEX_BUILD_MEM_POOL_SIZE = str(1024 * 1024 * 1024) + +NARROW_NUM_ROWS = 10_000_000 +NARROW_ROWS_PER_FRAGMENT = 1_000_000 + +WIDE_NUM_ROWS = 1_000_000 +WIDE_ROWS_PER_FRAGMENT = 100_000 +WIDE_NUM_SCALAR_COLUMNS = 20 +WIDE_VECTOR_DIM = 256 + +FRAGS_NUM_ROWS = 10_000_000 +FRAGS_ROWS_PER_FRAGMENT = 1_000 + +# Rows appended to `merge_insert_unindexed_tail` after the index is built. +UNINDEXED_TAIL_ROWS = NARROW_NUM_ROWS // 10 + +# `merge_insert_deleted` deletes every Nth row, which puts a deletion file on +# every fragment. +DELETED_ROW_STRIDE = 1000 + +_BATCH_SIZE = 1_000_000 + +NARROW_INDEXED_COLUMNS = [ + "id_int", + "id_uuid7", + "id_uuid4", + "composite_a", + "composite_b", +] + +NARROW_SCHEMA = pa.schema( + [ + ("id_int", pa.int64()), + ("id_uuid7", pa.string()), + ("id_uuid4", pa.string()), + ("id_no_index", pa.int64()), + ("composite_a", pa.int64()), + ("composite_b", pa.int64()), + ("value", pa.int64()), + ] +) + +FRAGS_SCHEMA = pa.schema([("id_int", pa.int64()), ("value", pa.int64())]) + +WIDE_SCALAR_COLUMNS = [f"scalar_{i}" for i in range(WIDE_NUM_SCALAR_COLUMNS)] + +# Half int64, half string, so a partial-column update touches a mix of +# fixed-width and variable-width buffers. +WIDE_SCHEMA = pa.schema( + [("id_int", pa.int64())] + + [ + (name, pa.int64() if i % 2 == 0 else pa.string()) + for i, name in enumerate(WIDE_SCALAR_COLUMNS) + ] + + [("vec", pa.list_(pa.float32(), WIDE_VECTOR_DIM))] +) + + +_GOLDEN = np.uint64(0x9E3779B97F4A7C15) +_MIX1 = np.uint64(0xBF58476D1CE4E5B9) +_MIX2 = np.uint64(0x94D049BB133111EB) +_HEX_DIGITS = np.frombuffer(b"0123456789abcdef", dtype="S1") +_NIBBLE_SHIFTS = np.arange(60, -4, -4, dtype=np.uint64) +# Two uint64 halves rendered as hex. +_KEY_WIDTH = 32 + + +def _scramble(values: np.ndarray) -> np.ndarray: + """splitmix64 finalizer. Wrapping uint64 arithmetic, vectorized.""" + x = values.astype(np.uint64) * _GOLDEN + x = (x ^ (x >> np.uint64(30))) * _MIX1 + x = (x ^ (x >> np.uint64(27))) * _MIX2 + return x ^ (x >> np.uint64(31)) + + +def _hex_keys(high: np.ndarray, low: np.ndarray) -> pa.Array: + """Format two uint64 columns as 32-character lowercase hex strings. + + Vectorized because the narrow dataset needs 10M of these and the + benchmarks rebuild source keys from row indices on every run. + """ + # One nibble at a time into a preallocated byte matrix. Broadcasting all 16 + # shifts at once would materialize an (n, 16) uint64 array per half, which is + # 2.5 GB of scratch for a 10M-row source. + num_rows = len(high) + half = len(_NIBBLE_SHIFTS) + digits = np.empty((num_rows, _KEY_WIDTH), dtype="S1") + for position, shift in enumerate(_NIBBLE_SHIFTS): + digits[:, position] = _HEX_DIGITS[(high >> shift) & np.uint64(0xF)] + digits[:, position + half] = _HEX_DIGITS[(low >> shift) & np.uint64(0xF)] + + # Built from buffers rather than `pa.array`, which splits a numpy byte array + # into chunks above ~1M elements. RecordBatch needs a contiguous Array. + offsets = np.arange(0, _KEY_WIDTH * (num_rows + 1), _KEY_WIDTH, dtype=np.int32) + return pa.StringArray.from_buffers( + num_rows, pa.py_buffer(offsets), pa.py_buffer(digits) + ) + + +def uuid7_keys(row_indices: np.ndarray) -> pa.Array: + """Sortable, UUIDv7-shaped keys: monotonic prefix, scrambled suffix. + + Real UUIDv7 keys are time-ordered, so a stream of them lands in a narrow, + advancing slice of the index. Reproducing that ordering is what matters + for the benchmark; the exact bit layout is not. + + Deterministic in the row index so benchmarks can reconstruct a key without + reading the dataset. + """ + row_indices = row_indices.astype(np.uint64) + return _hex_keys(row_indices, _scramble(row_indices)) + + +def uuid4_keys(row_indices: np.ndarray) -> pa.Array: + """Same width as `uuid7_keys`, but scattered across the index key space. + + Deterministic for the same reason, but the leading bytes are scrambled, so + a contiguous run of row indices maps to keys spread over the whole index + rather than a narrow slice. + """ + row_indices = row_indices.astype(np.uint64) + return _hex_keys(_scramble(row_indices), row_indices) + + +def narrow_batch(row_indices: np.ndarray, value_offset: int = 0) -> pa.RecordBatch: + """Build a full-schema narrow batch from row indices. + + Shared with the benchmarks, which reconstruct source rows from row indices. + ``value_offset`` shifts the payload column so an update writes a value that + differs from what the target already holds. + """ + return pa.record_batch( + [ + pa.array(row_indices), + uuid7_keys(row_indices), + uuid4_keys(row_indices), + pa.array(row_indices), + pa.array(row_indices), + # `composite_b` is a deterministic function of `composite_a` so a + # source row can target an existing composite key without the + # benchmark tracking extra state. + pa.array(row_indices % 1024), + pa.array(row_indices + value_offset), + ], + schema=NARROW_SCHEMA, + ) + + +def _narrow_data(num_rows: int, offset: int = 0): + LOGGER.info("Generating %d narrow rows starting at %d", num_rows, offset) + for start in range(offset, offset + num_rows, _BATCH_SIZE): + count = min(_BATCH_SIZE, offset + num_rows - start) + yield narrow_batch(np.arange(start, start + count, dtype=np.int64)) + + +def _frags_data(num_rows: int): + LOGGER.info("Generating %d small-fragment rows", num_rows) + for start in range(0, num_rows, _BATCH_SIZE): + ids = np.arange( + start, start + min(_BATCH_SIZE, num_rows - start), dtype=np.int64 + ) + yield pa.record_batch([pa.array(ids), pa.array(ids)], schema=FRAGS_SCHEMA) + + +def _wide_batch(offset: int, num_rows: int) -> pa.RecordBatch: + ids = np.arange(offset, offset + num_rows, dtype=np.int64) + columns: list[pa.Array] = [pa.array(ids)] + for i in range(WIDE_NUM_SCALAR_COLUMNS): + if i % 2 == 0: + columns.append(pa.array(ids + i)) + else: + columns.append(pa.array([f"s{i}_{v}" for v in ids], type=pa.string())) + # Deterministic float payload; the values are irrelevant, the byte volume + # is the point. + vectors = np.linspace( + 0.0, 1.0, num=num_rows * WIDE_VECTOR_DIM, dtype=np.float32 + ).reshape(num_rows, WIDE_VECTOR_DIM) + columns.append( + pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1)), WIDE_VECTOR_DIM + ) + ) + return pa.record_batch(columns, schema=WIDE_SCHEMA) + + +def _wide_data(num_rows: int): + LOGGER.info("Generating %d wide rows", num_rows) + # The vector column makes full batches large, so use a smaller batch here. + batch_size = WIDE_ROWS_PER_FRAGMENT + for start in range(0, num_rows, batch_size): + yield _wide_batch(start, min(batch_size, num_rows - start)) + + +def _tag_base(ds: lance.LanceDataset) -> None: + """Point BASE_TAG at the current version, creating the tag if needed.""" + if BASE_TAG in ds.tags.list(): + ds.tags.update(BASE_TAG, ds.version) + else: + ds.tags.create(BASE_TAG, ds.version) + + +def _already_generated(uri: str, expected_rows: int) -> bool: + """True when a usable dataset with a pristine base tag already exists. + + A previous benchmark run may have left extra versions behind, so the row + count is checked at the tagged version rather than at the latest one. + + Incomplete generations (dataset written, tag never created) and missing + tags both return False so the caller can overwrite and retag. + """ + try: + ds = lance.dataset(uri) + except ValueError: + return False + tags = ds.tags.list() + if BASE_TAG not in tags: + return False + base_version = tags[BASE_TAG]["version"] + return ds.checkout_version(base_version).count_rows() == expected_rows + + +@contextmanager +def _enlarged_mem_pool_for_index_build(): + """Raise LANCE_MEM_POOL_SIZE only for the duration of index training.""" + previous = os.environ.get("LANCE_MEM_POOL_SIZE") + os.environ["LANCE_MEM_POOL_SIZE"] = _INDEX_BUILD_MEM_POOL_SIZE + try: + yield + finally: + if previous is None: + os.environ.pop("LANCE_MEM_POOL_SIZE", None) + else: + os.environ["LANCE_MEM_POOL_SIZE"] = previous + + +def _gen( + name: str, + data, + schema: pa.Schema, + expected_rows: int, + rows_per_fragment: int, + indexed_columns: list[str], +) -> lance.LanceDataset: + dataset_uri = get_dataset_uri(name) + if _already_generated(dataset_uri, expected_rows): + LOGGER.info("Dataset %s already exists, skipping", name) + return lance.dataset(dataset_uri) + + LOGGER.info("Creating dataset %s", name) + ds = lance.write_dataset( + data, + dataset_uri, + schema=schema, + mode="overwrite", + max_rows_per_file=rows_per_fragment, + max_rows_per_group=min(rows_per_fragment, 100_000), + ) + with _enlarged_mem_pool_for_index_build(): + for column in indexed_columns: + LOGGER.info("Building BTREE index on %s.%s", name, column) + ds.create_scalar_index(column, "BTREE") + _tag_base(ds) + return ds + + +def gen_merge_insert_narrow() -> lance.LanceDataset: + return _gen( + "merge_insert_narrow", + _narrow_data(NARROW_NUM_ROWS), + NARROW_SCHEMA, + NARROW_NUM_ROWS, + NARROW_ROWS_PER_FRAGMENT, + NARROW_INDEXED_COLUMNS, + ) + + +def gen_merge_insert_wide() -> lance.LanceDataset: + return _gen( + "merge_insert_wide", + _wide_data(WIDE_NUM_ROWS), + WIDE_SCHEMA, + WIDE_NUM_ROWS, + WIDE_ROWS_PER_FRAGMENT, + ["id_int"], + ) + + +def gen_merge_insert_frags() -> lance.LanceDataset: + return _gen( + "merge_insert_frags", + _frags_data(FRAGS_NUM_ROWS), + FRAGS_SCHEMA, + FRAGS_NUM_ROWS, + FRAGS_ROWS_PER_FRAGMENT, + ["id_int"], + ) + + +def gen_merge_insert_deleted() -> lance.LanceDataset: + """Narrow layout with a deletion file on every fragment. + + One row in every `DELETED_ROW_STRIDE` is deleted, which lands in every + fragment. + """ + name = "merge_insert_deleted" + dataset_uri = get_dataset_uri(name) + expected_rows = NARROW_NUM_ROWS - NARROW_NUM_ROWS // DELETED_ROW_STRIDE + if _already_generated(dataset_uri, expected_rows): + LOGGER.info("Dataset %s already exists, skipping", name) + return lance.dataset(dataset_uri) + + ds = _gen( + name, + _narrow_data(NARROW_NUM_ROWS), + NARROW_SCHEMA, + NARROW_NUM_ROWS, + NARROW_ROWS_PER_FRAGMENT, + NARROW_INDEXED_COLUMNS, + ) + LOGGER.info("Deleting rows from %s to create deletion files", name) + ds.delete(f"id_int % {DELETED_ROW_STRIDE} == 0") + _tag_base(ds) + return ds + + +def gen_merge_insert_unindexed_tail() -> lance.LanceDataset: + """Narrow layout where the last 10% of rows are not covered by the index.""" + name = "merge_insert_unindexed_tail" + dataset_uri = get_dataset_uri(name) + expected_rows = NARROW_NUM_ROWS + UNINDEXED_TAIL_ROWS + if _already_generated(dataset_uri, expected_rows): + LOGGER.info("Dataset %s already exists, skipping", name) + return lance.dataset(dataset_uri) + + _gen( + name, + _narrow_data(NARROW_NUM_ROWS), + NARROW_SCHEMA, + NARROW_NUM_ROWS, + NARROW_ROWS_PER_FRAGMENT, + NARROW_INDEXED_COLUMNS, + ) + LOGGER.info("Appending %d unindexed rows to %s", UNINDEXED_TAIL_ROWS, name) + ds = lance.write_dataset( + _narrow_data(UNINDEXED_TAIL_ROWS, offset=NARROW_NUM_ROWS), + dataset_uri, + schema=NARROW_SCHEMA, + mode="append", + max_rows_per_file=NARROW_ROWS_PER_FRAGMENT, + ) + _tag_base(ds) + return ds + + +def gen_merge_insert() -> None: + gen_merge_insert_narrow() + gen_merge_insert_wide() + gen_merge_insert_frags() + gen_merge_insert_deleted() + gen_merge_insert_unindexed_tail() diff --git a/python/python/ci_benchmarks/overlays.py b/python/python/ci_benchmarks/overlays.py new file mode 100644 index 00000000000..9d9d182de12 --- /dev/null +++ b/python/python/ci_benchmarks/overlays.py @@ -0,0 +1,168 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Shared helpers for the data-overlay benchmark suite. + +Data overlay files supply replacement values for a subset of (row offset, field) +cells in a fragment, merged on read, without rewriting the base data file. These +helpers build synthetic base datasets, commit overlay layers through the public +``lance.LanceOperation.DataOverlay`` operation, and measure their cost. +""" + +import os + +# Data overlay support is gated off in release builds unless this is set (it is +# always on in debug builds). Benchmarks usually run against a release build, so +# enable it here, before lance is imported, or reading overlay datasets fails. +os.environ.setdefault("LANCE_ENABLE_UNSTABLE_DATA_OVERLAY_FILES", "1") + +from typing import List # noqa: E402 +from urllib.parse import urlparse # noqa: E402 + +import lance # noqa: E402 +import numpy as np # noqa: E402 +import pyarrow as pa # noqa: E402 +from lance._datagen import rand_batches # noqa: E402 +from lance.file import LanceFileWriter # noqa: E402 + +# Default width for the ``embedding`` dtype. Individual benchmarks override it +# via ``embedding_dim`` to model narrow vs. wide (e.g. 3072-d) value columns. +EMBEDDING_DIM = 128 + + +def _value_type(dtype: str, embedding_dim: int = EMBEDDING_DIM) -> pa.DataType: + if dtype == "int32": + return pa.int32() + if dtype == "embedding": + return pa.list_(pa.float32(), embedding_dim) + raise ValueError(f"unknown overlay benchmark dtype {dtype!r}") + + +def _gen_values( + dtype: str, + n: int, + rng: np.random.Generator, + embedding_dim: int = EMBEDDING_DIM, +) -> pa.Array: + if dtype == "int32": + return pa.array(rng.integers(0, 1 << 30, size=n, dtype=np.int32)) + if dtype == "embedding": + flat = rng.random(n * embedding_dim, dtype=np.float32) + return pa.FixedSizeListArray.from_arrays(pa.array(flat), embedding_dim) + raise ValueError(f"unknown overlay benchmark dtype {dtype!r}") + + +def make_base_dataset( + base_path: str, + num_rows: int, + rows_per_file: int, + dtype: str, + embedding_dim: int = EMBEDDING_DIM, +) -> lance.LanceDataset: + """Create a base dataset with an ``id`` column and a ``val`` column. + + ``val`` is the column overlays target. ``rows_per_file`` controls the number + of fragments (``num_rows // rows_per_file``), which must divide ``num_rows``. + ``embedding_dim`` sets the width of the ``val`` column when ``dtype`` is + ``embedding`` (e.g. 3072 for a wide embedding). + """ + if num_rows % rows_per_file: + raise ValueError( + f"num_rows ({num_rows}) must be a multiple of rows_per_file " + f"({rows_per_file})" + ) + schema = pa.schema({"id": pa.int64(), "val": _value_type(dtype, embedding_dim)}) + # One fragment per batch; lance-datagen fills both columns with random data. + reader = rand_batches( + schema, num_batches=num_rows // rows_per_file, rows_per_batch=rows_per_file + ) + return lance.write_dataset(reader, base_path, max_rows_per_file=rows_per_file) + + +def coverage_offsets(num_rows: int, fraction: float, pattern: str) -> List[int]: + """Offsets within a fragment covered by an overlay. + + ``contiguous`` packs the covered cells into a single leading run (few pages + touched); ``stride`` spreads them evenly across the fragment (many pages + touched). Both cover ``round(num_rows * fraction)`` cells. + """ + count = max(1, int(round(num_rows * fraction))) + if pattern == "contiguous": + return list(range(count)) + if pattern == "stride": + step = max(1, num_rows // count) + return list(range(0, num_rows, step))[:count] + raise ValueError(f"unknown coverage pattern {pattern!r}") + + +def _val_field_id(ds: lance.LanceDataset) -> int: + base_df = ds.get_fragments()[0].metadata.files[0] + names = [f.name for f in ds.schema] + return base_df.fields[names.index("val")] + + +def commit_overlay_layers( + ds: lance.LanceDataset, + num_layers: int, + fraction: float, + pattern: str, + dtype: str, + *, + seed: int = 0, + embedding_dim: int = EMBEDDING_DIM, +) -> lance.LanceDataset: + """Commit ``num_layers`` overlays on ``val``, each covering the same offsets + in every fragment so that all layers must be consulted on read (the case + that motivates compaction). Returns the updated dataset. + """ + base_df = ds.get_fragments()[0].metadata.files[0] + field_id = _val_field_id(ds) + data_dir = os.path.join(_local_path(ds), "data") + for layer in range(num_layers): + rng = np.random.default_rng(seed + layer + 1) + groups = [] + for frag in ds.get_fragments(): + offsets = coverage_offsets(frag.count_rows(), fraction, pattern) + values = _gen_values(dtype, len(offsets), rng, embedding_dim) + batch = pa.record_batch([values], names=["val"]) + name = f"overlay_l{layer}_f{frag.fragment_id}.lance" + path = os.path.join(data_dir, name) + with LanceFileWriter(path) as writer: + writer.write_batch(batch) + df = lance.fragment.DataFile( + path=name, + fields=[field_id], + column_indices=[0], + file_major_version=base_df.file_major_version, + file_minor_version=base_df.file_minor_version, + file_size_bytes=os.path.getsize(path), + ) + groups.append( + lance.LanceOperation.DataOverlayGroup( + fragment_id=frag.fragment_id, + overlays=[ + lance.LanceOperation.DataOverlayFile( + data_file=df, offsets=offsets + ) + ], + ) + ) + op = lance.LanceOperation.DataOverlay(groups=groups) + ds = lance.LanceDataset.commit(ds, op, read_version=ds.version) + return ds + + +# --- Measurement helpers ---------------------------------------------------- + + +def _local_path(ds: lance.LanceDataset) -> str: + parsed = urlparse(ds.uri) + return parsed.path if parsed.scheme == "file" else ds.uri + + +def manifest_size(ds: lance.LanceDataset) -> int: + """Size in bytes of the manifest for the dataset's current version.""" + # Manifests are named `{u64::MAX - version}.manifest` so that a plain + # lexicographic directory listing yields newest-version-first. + name = f"{(1 << 64) - 1 - ds.version}.manifest" + return os.path.getsize(os.path.join(_local_path(ds), "_versions", name)) diff --git a/python/python/lance/__init__.py b/python/python/lance/__init__.py index 92b32f6dc6a..b122c5755d6 100644 --- a/python/python/lance/__init__.py +++ b/python/python/lance/__init__.py @@ -9,11 +9,23 @@ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Union from . import io, log -from .blob import Blob, BlobArray, BlobColumn, BlobFile, blob_array, blob_field +from .blob import ( + Blob, + BlobArray, + BlobColumn, + BlobDescriptor, + BlobDescriptorArrayBuilder, + BlobFile, + DedicatedBlobWriter, + PackedBlobWriter, + blob_array, + blob_field, +) from .dataset import ( DataStatistics, FieldStatistics, Index, + IndexFile, LanceDataset, LanceOperation, LanceScanner, @@ -26,17 +38,32 @@ ) from .fragment import FragmentMetadata, LanceFragment from .lance import ( + CleanupCandidateFile, + CleanupExplanation, + CleanupReferencedBranch, + CleanupStats, DatasetBasePath, FFILanceTableProvider, + FtsToken, ScanStatistics, bytes_read_counter, iops_counter, + simd_info, + tokenize, ) -from .namespace import ( - DescribeTableRequest, - LanceNamespace, - LanceNamespaceStorageOptionsProvider, +from .mem_wal import ( + CompactedSsTable, + ExecutionPlan, + LsmPointLookupPlanner, + LsmScanner, + LsmVectorSearchPlanner, + ShardingField, + ShardingSpec, + ShardSnapshot, + ShardWriter, + evaluate_sharding_spec, ) +from .progress import IndexProgress from .schema import json_to_schema, schema_to_json from .util import sanitize_ts @@ -46,6 +73,7 @@ from lance.commit import CommitLock from lance.dependencies import pandas as pd + from lance.namespace import LanceNamespace ts_types = Union[datetime, pd.Timestamp, str] @@ -55,18 +83,27 @@ "BlobArray", "BlobColumn", "BlobFile", + "DedicatedBlobWriter", + "BlobDescriptorArrayBuilder", + "PackedBlobWriter", + "BlobDescriptor", "blob_array", "blob_field", + "CleanupCandidateFile", + "CleanupExplanation", + "CleanupReferencedBranch", + "CleanupStats", "DatasetBasePath", "DataStatistics", "FieldStatistics", "FragmentMetadata", + "FtsToken", "Index", + "IndexFile", "LanceDataset", "LanceFragment", "LanceOperation", "LanceScanner", - "LanceNamespaceStorageOptionsProvider", "MergeInsertBuilder", "ScanStatistics", "Transaction", @@ -79,8 +116,21 @@ "json_to_schema", "schema_to_json", "set_logger", + "simd_info", + "tokenize", "write_dataset", "FFILanceTableProvider", + "IndexProgress", + "ExecutionPlan", + "LsmPointLookupPlanner", + "LsmScanner", + "LsmVectorSearchPlanner", + "CompactedSsTable", + "ShardSnapshot", + "ShardWriter", + "ShardingField", + "ShardingSpec", + "evaluate_sharding_spec", ] @@ -95,11 +145,11 @@ def dataset( default_scan_options: Optional[Dict[str, str]] = None, metadata_cache_size_bytes: Optional[int] = None, index_cache_size_bytes: Optional[int] = None, - read_params: Optional[Dict[str, any]] = 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, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, ) -> LanceDataset: """ Opens the Lance dataset from the address specified. @@ -109,7 +159,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. @@ -134,6 +184,13 @@ def dataset( 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. + + For datasets with additional registered base paths, a key of the form + ``base_.`` applies ```` only to the base path with that + manifest id, overriding the unscoped options that every base inherits. + For example ``{"account_key": "shared", "base_1.account_key": "abc"}`` + makes base 1 use ``account_key = abc`` while all other options are + shared. default_scan_options : optional, dict Default scan options that are used when scanning the dataset. This accepts the same arguments described in :py:meth:`lance.LanceDataset.scanner`. The @@ -153,70 +210,81 @@ def dataset( read_params : optional, dict Dictionary of read parameters. Currently supports: - cache_repetition_index (bool): Whether to cache repetition indices for - large string/binary columns + large string/binary columns. This is enabled by default. You can disable + it globally by setting LANCE_READ_CACHE_REPETITION_INDEX=false. - validate_on_decode (bool): Whether to validate data during decoding 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`. + base_store_params : dict of str to dict, optional + Runtime-only object store parameters keyed by base path URI. Each key + is a base path URI (e.g., "s3://bucket/path") and each value is a dict + of storage options (credentials, endpoint, etc.) for that base. These + take precedence over ``base_.`` entries in ``storage_options``. + When a base has no explicit entry here, the top-level + ``storage_options`` is used as a fallback. 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 - 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) + # Resolve the latest table metadata here. The requested dataset version is + # applied by the lower-level dataset open path after namespace resolution. + from .namespace import DescribeTableRequest + + request = DescribeTableRequest(id=table_id, version=None) + 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) + 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: @@ -224,7 +292,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, @@ -238,7 +308,10 @@ 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, + base_store_params=base_store_params, ) if version is None and asof is not None: ts_cutoff = sanitize_ts(asof) @@ -262,7 +335,10 @@ 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, + base_store_params=base_store_params, ) else: return ds @@ -287,3 +363,14 @@ def __warn_on_fork(): if hasattr(os, "register_at_fork"): os.register_at_fork(before=__warn_on_fork) + + +# `lance.namespace` pulls in the generated `lance_namespace` REST client, which +# costs more than a second of import time. Most users never touch a namespace, +# so resolve these re-exports on first access instead of at `import lance`. +def __getattr__(name: str): + if name in ("DescribeTableRequest", "LanceNamespace"): + from . import namespace + + return getattr(namespace, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/python/python/lance/_datagen.py b/python/python/lance/_datagen.py index b156066eca6..45826503c5b 100644 --- a/python/python/lance/_datagen.py +++ b/python/python/lance/_datagen.py @@ -21,10 +21,13 @@ def rand_batches( *, num_batches: Optional[int] = None, batch_size_bytes: Optional[int] = None, + rows_per_batch: Optional[int] = None, ): if not datagen.is_datagen_supported(): raise NotImplementedError( "This version of lance was not built with the datagen feature" ) - batch_iter = datagen.rand_batches(schema, num_batches, batch_size_bytes) + batch_iter = datagen.rand_batches( + schema, num_batches, batch_size_bytes, rows_per_batch + ) return pa.RecordBatchReader.from_batches(schema, batch_iter) diff --git a/python/python/lance/arrow.py b/python/python/lance/arrow.py index 54da15705f8..5e9f671ab80 100644 --- a/python/python/lance/arrow.py +++ b/python/python/lance/arrow.py @@ -4,10 +4,12 @@ """Extensions to PyArrows.""" import json +import typing from pathlib import Path from typing import Callable, Iterable, Optional, Union import pyarrow as pa +import pyarrow.compute as pc from ._arrow.bf16 import ( # noqa: F401 BFloat16, @@ -19,8 +21,10 @@ from .lance import bfloat16_array __all__ = [ + "BFloat16", "BFloat16Array", "BFloat16Type", + "PandasBFloat16Array", "bfloat16_array", "cast", "EncodedImageType", @@ -220,14 +224,15 @@ def from_uris( ['file::///tmp/1.png'] """ + storage: pa.Array if isinstance(uris, (pa.StringArray, pa.LargeStringArray)): - pass + storage = uris elif isinstance(uris, Iterable): - uris = pa.array((str(uri) for uri in uris), type=pa.string()) + storage = pa.array((str(uri) for uri in uris), type=pa.string()) else: raise TypeError("Cannot build a ImageURIArray from {}".format(type(uris))) - return cls.from_storage(ImageURIType(uris.type), uris) + return cls.from_storage(ImageURIType(storage.type), storage) def read_uris(self, storage_type=pa.binary()) -> "EncodedImageArray": """ @@ -268,7 +273,8 @@ def download(url): print("Failed to reach the server: ", e.reason) elif hasattr(e, "code"): print( - "The server could not fulfill the request. Error code: ", e.code + "The server could not fulfill the request. Error code: ", + getattr(e, "code"), ) images = [] @@ -277,7 +283,9 @@ def download(url): if parsed_uri.scheme in ("http", "https"): images.append(download(uri)) else: - filesystem, path = fs.FileSystem.from_uri(uri.as_py()) + filesystem, path = fs.FileSystem.from_uri( # pyright: ignore[reportPrivateImportUsage] + uri.as_py() + ) with filesystem.open_input_stream(path) as f: images.append(f.read()) @@ -297,21 +305,12 @@ def __repr__(self): def pillow_metadata_decoder(images): import io - from PIL import Image + from PIL import Image # pyright: ignore[reportMissingImports] img = Image.open(io.BytesIO(images[0].as_py())) return img - def tensorflow_metadata_decoder(images): - import tensorflow as tf - - img = tf.io.decode_image(images[0].as_py()) - return img - - decoders = ( - ("tensorflow", tensorflow_metadata_decoder), - ("PIL", pillow_metadata_decoder), - ) + decoders = (("PIL", pillow_metadata_decoder),) decoder = None for libname, metadata_decoder in decoders: @@ -343,7 +342,7 @@ def to_tensor( decoder : Callable[pa.binary()], optional A function that takes a binary array and returns a numpy.ndarray or pa.fixed_shape_tensor. If not provided, will attempt to use - tensorflow and then pillow decoder in that order. + pillow. Returns ------- @@ -365,27 +364,19 @@ def to_tensor( if not decoder: - def pillow_decoder(images): + def pillow_decoder(images) -> "np.ndarray": import io - from PIL import Image + from PIL import Image # pyright: ignore[reportMissingImports] return np.stack( - [Image.open(io.BytesIO(img)) for img in images.to_pylist()] + [ + np.asarray(Image.open(io.BytesIO(img))) + for img in images.to_pylist() + ] ) - def tensorflow_decoder(images): - import tensorflow as tf - - decoded_to_tensor = tuple( - tf.io.decode_image(img) for img in images.to_pylist() - ) - return tf.stack(decoded_to_tensor, axis=0).numpy() - - decoders = [ - ("tensorflow", tensorflow_decoder), - ("PIL", pillow_decoder), - ] + decoders = [("PIL", pillow_decoder)] for libname, decoder_function in decoders: try: __import__(libname) @@ -395,15 +386,16 @@ def tensorflow_decoder(images): pass else: raise ValueError( - "No image decoder available. Please either install one of " - "tensorflow, pillow, or pass a decoder argument." + "No image decoder available. Please install pillow or pass a " + "decoder argument." ) image_array = decoder(self.storage) if isinstance(image_array, pa.FixedShapeTensorType): - shape = image_array.shape - arrow_type = image_array.storage_type - tensor_array = image_array + tensor = typing.cast("pa.Array", image_array) + shape = tensor.shape + arrow_type = tensor.storage_type + tensor_array = tensor else: shape = image_array.shape[1:] arrow_type = pa.from_numpy_dtype(image_array.dtype) @@ -476,7 +468,7 @@ def to_encoded(self, encoder=None, storage_type=pa.binary()) -> "EncodedImageArr def pillow_encoder(x): import io - from PIL import Image + from PIL import Image # pyright: ignore[reportMissingImports] encoded_images = [] for y in x: @@ -485,19 +477,8 @@ def pillow_encoder(x): encoded_images.append(buf.getvalue()) return pa.array(encoded_images, type=storage_type) - def tensorflow_encoder(x): - import tensorflow as tf - - encoded_images = ( - tf.io.encode_png(y).numpy() for y in tf.convert_to_tensor(x) - ) - return pa.array(encoded_images, type=storage_type) - if not encoder: - encoders = ( - ("PIL", pillow_encoder), - ("tensorflow", tensorflow_encoder), - ) + encoders = (("PIL", pillow_encoder),) for libname, encoder_function in encoders: try: __import__(libname) @@ -507,8 +488,8 @@ def tensorflow_encoder(x): pass else: raise ValueError( - "No image encoder available. Please either install one of " - "tensorflow, pillow, or pass an encoder argument." + "No image encoder available. Please install pillow or pass an " + "encoder argument." ) return EncodedImageArray.from_storage( @@ -571,7 +552,8 @@ def cast( + f"got: {target_type}" ) np_arr = arr.to_numpy() - float_arr = np_arr.astype(target_type.to_pandas_dtype()) + float_type = typing.cast("pa.DataType", target_type) + float_arr = np_arr.astype(float_type.to_pandas_dtype()) return pa.array(float_arr) elif isinstance(target_type, BFloat16Type) or target_type in ["bfloat16", "bf16"]: if not pa.types.is_floating(arr.type): @@ -586,15 +568,16 @@ def cast( target_type ): # Casting fixed size list to fixed size list - if arr.type.list_size != target_type.list_size: + list_type = typing.cast("pa.DataType", target_type) + if arr.type.list_size != list_type.list_size: raise ValueError( "Only support casting fixed size list to fixed size list " f"with the same size, got: {arr.type} to {target_type}" ) - values = cast(arr.values, target_type.value_type) + values = cast(arr.values, list_type.value_type) return pa.FixedSizeListArray.from_arrays( - values=values, list_size=target_type.list_size + values=values, list_size=list_type.list_size ) # Fallback to normal cast. - return pa.compute.cast(arr, target_type, *args, **kwargs) + return pc.cast(arr, target_type, *args, **kwargs) diff --git a/python/python/lance/bitmap.py b/python/python/lance/bitmap.py new file mode 100644 index 00000000000..cfd3224faef --- /dev/null +++ b/python/python/lance/bitmap.py @@ -0,0 +1,16 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +from collections.abc import MutableSet + +from .lance import Bitmap as Bitmap + +# `Bitmap` implements the whole `MutableSet` interface in Rust, but it is a +# distinct type rather than a `set` subclass, so `isinstance(b, set)` is False. +# Registering it here means the check that does work across set-like types, +# `isinstance(b, collections.abc.Set)`, recognizes it. Modules that expose a +# `Bitmap` import it from here rather than from `.lance` so the registration +# always runs. +MutableSet.register(Bitmap) + +__all__ = ["Bitmap"] diff --git a/python/python/lance/blob.py b/python/python/lance/blob.py index d23e6299f49..97ea422557f 100644 --- a/python/python/lance/blob.py +++ b/python/python/lance/blob.py @@ -1,13 +1,39 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import ctypes import io from dataclasses import dataclass from typing import IO, Any, Iterator, Optional, Union import pyarrow as pa -from .lance import LanceBlobFile +from .lance import ( + BlobDescriptor as BlobDescriptor, +) +from .lance import ( + BlobDescriptorArrayBuilder as BlobDescriptorArrayBuilder, +) +from .lance import ( + DedicatedBlobWriter as DedicatedBlobWriter, +) +from .lance import ( + LanceBlobFile, +) +from .lance import ( + PackedBlobWriter as PackedBlobWriter, +) + +_BLOB_INLINE_SIZE_THRESHOLD_META_KEY = b"lance-encoding:blob-inline-size-threshold" +_BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY = ( + b"lance-encoding:blob-dedicated-size-threshold" +) +_BLOB_PACK_FILE_SIZE_THRESHOLD_META_KEY = ( + b"lance-encoding:blob-pack-file-size-threshold" +) +_MAX_RUST_USIZE = ctypes.c_size_t(-1).value +# Default sequential read-ahead size in bytes. +DEFAULT_BLOB_BUFFER_SIZE = 512 * 1024 @dataclass(frozen=True) @@ -15,27 +41,52 @@ class Blob: """ A logical blob value for writing Lance blob columns. - A blob can be represented either by inlined bytes or by an external URI. + A blob can be represented as: + - inline bytes + - an external URI, optionally with a non-empty range + + Every blob must use exactly one representation. Use ``None`` for a null + blob and :meth:`empty` for a valid empty blob. """ data: Optional[bytes] = None uri: Optional[str] = None + position: Optional[int] = None + size: Optional[int] = None def __post_init__(self) -> None: if self.data is not None and self.uri is not None: raise ValueError("Blob cannot have both data and uri") if self.uri == "": raise ValueError("Blob uri cannot be empty") + if (self.position is not None or self.size is not None) and self.uri is None: + raise ValueError("External packed blob must have a uri") + if (self.position is None) != (self.size is None): + raise ValueError( + "External blob must set both position and size, or neither" + ) + if self.data is not None and self.position is not None: + raise ValueError( + "Blob cannot have both inline data and external slice metadata" + ) + if self.data is None and self.uri is None: + raise ValueError("Blob must set `data` or `uri`; use None for a null blob") + if self.size == 0: + raise ValueError("External blob range size must be greater than zero") @staticmethod def from_bytes(data: Union[bytes, bytearray, memoryview]) -> "Blob": return Blob(data=bytes(data)) @staticmethod - def from_uri(uri: str) -> "Blob": + def from_uri( + uri: str, position: Optional[int] = None, size: Optional[int] = None + ) -> "Blob": if uri == "": raise ValueError("Blob uri cannot be empty") - return Blob(uri=uri) + if (position is not None and position < 0) or (size is not None and size < 0): + raise ValueError("External blob position and size must be non-negative") + return Blob(uri=uri, position=position, size=size) @staticmethod def empty() -> "Blob": @@ -47,7 +98,13 @@ class BlobType(pa.ExtensionType): A PyArrow extension type for Lance blob columns. This is the "logical" type users write. Lance will store it in a compact - descriptor format, and reads will return descriptors by default. + descriptor format, and reads will return descriptors by default. Its storage + type defaults to ``Struct``. Arrow deserialization also preserves the accepted minimal + ``Struct`` storage type. ``position`` and + ``size`` select a range within an external ``uri`` and must either both be set + or both be null. When set, ``size`` must be greater than zero. Every non-null + value must set exactly one of ``data`` and ``uri``. """ def __init__(self) -> None: @@ -55,6 +112,8 @@ def __init__(self) -> None: [ pa.field("data", pa.large_binary(), nullable=True), pa.field("uri", pa.utf8(), nullable=True), + pa.field("position", pa.uint64(), nullable=True), + pa.field("size", pa.uint64(), nullable=True), ] ) pa.ExtensionType.__init__(self, storage_type, "lance.blob.v2") @@ -62,11 +121,47 @@ def __init__(self) -> None: def __arrow_ext_serialize__(self) -> bytes: return b"" + @staticmethod + def _validate_storage_type(storage_type: pa.DataType) -> None: + if not pa.types.is_struct(storage_type): + raise TypeError("BlobType storage type must be a struct") + + fields = list(storage_type) + if len(fields) not in (2, 4): + raise TypeError( + "BlobType storage struct must contain either data/uri or " + "data/uri/position/size" + ) + + expected_fields = [ + ("data", pa.large_binary()), + ("uri", pa.utf8()), + ("position", pa.uint64()), + ("size", pa.uint64()), + ] + for index, field in enumerate(fields): + expected_name, expected_type = expected_fields[index] + if field.name != expected_name or field.type != expected_type: + raise TypeError( + "BlobType storage field " + f"{index} must be {expected_name}: {expected_type}, got " + f"{field.name}: {field.type}" + ) + if index < 2 and not field.nullable: + raise TypeError(f"BlobType storage field {field.name} must be nullable") + + @classmethod + def _from_storage_type(cls, storage_type: pa.DataType) -> "BlobType": + cls._validate_storage_type(storage_type) + instance = cls.__new__(cls) + pa.ExtensionType.__init__(instance, storage_type, "lance.blob.v2") + return instance + @classmethod def __arrow_ext_deserialize__( cls, storage_type: pa.DataType, serialized: bytes ) -> "BlobType": - return BlobType() + return cls._from_storage_type(storage_type) def __arrow_ext_class__(self): return BlobArray @@ -98,18 +193,24 @@ class BlobArray(pa.ExtensionArray): def from_pylist(cls, values: list[Any]) -> "BlobArray": data_values: list[Optional[bytes]] = [] uri_values: list[Optional[str]] = [] + position_values: list[Optional[int]] = [] + size_values: list[Optional[int]] = [] null_mask: list[bool] = [] for v in values: if v is None: data_values.append(None) uri_values.append(None) + position_values.append(None) + size_values.append(None) null_mask.append(True) continue if isinstance(v, Blob): data_values.append(v.data) uri_values.append(v.uri) + position_values.append(v.position) + size_values.append(v.size) null_mask.append(False) continue @@ -118,12 +219,16 @@ def from_pylist(cls, values: list[Any]) -> "BlobArray": raise ValueError("Blob uri cannot be empty") data_values.append(None) uri_values.append(v) + position_values.append(None) + size_values.append(None) null_mask.append(False) continue if isinstance(v, (bytes, bytearray, memoryview)): data_values.append(bytes(v)) uri_values.append(None) + position_values.append(None) + size_values.append(None) null_mask.append(False) continue @@ -134,9 +239,13 @@ def from_pylist(cls, values: list[Any]) -> "BlobArray": data_arr = pa.array(data_values, type=pa.large_binary()) uri_arr = pa.array(uri_values, type=pa.utf8()) + position_arr = pa.array(position_values, type=pa.uint64()) + size_arr = pa.array(size_values, type=pa.uint64()) mask_arr = pa.array(null_mask, type=pa.bool_()) storage = pa.StructArray.from_arrays( - [data_arr, uri_arr], names=["data", "uri"], mask=mask_arr + [data_arr, uri_arr, position_arr, size_arr], + names=["data", "uri", "position", "size"], + mask=mask_arr, ) return pa.ExtensionArray.from_storage(BlobType(), storage) # type: ignore[return-value] @@ -155,9 +264,85 @@ def blob_array(values: list[Any]) -> BlobArray: return BlobArray.from_pylist(values) -def blob_field(name: str, *, nullable: bool = True) -> pa.Field: - """Construct an Arrow field for a Lance blob column.""" - return pa.field(name, BlobType(), nullable=nullable) +def _validate_threshold(name: str, value: Optional[int], *, allow_zero: bool) -> None: + if value is None: + return + if isinstance(value, bool) or not isinstance(value, int): + raise TypeError(f"{name} must be an int, got {type(value).__name__}") + if allow_zero: + if value < 0: + raise ValueError(f"{name} must be non-negative") + elif value <= 0: + raise ValueError(f"{name} must be positive") + if value > _MAX_RUST_USIZE: + raise OverflowError(f"{name} must fit in a Rust usize") + + +def blob_field( + name: str, + *, + nullable: bool = True, + inline_size_threshold: Optional[int] = None, + dedicated_size_threshold: Optional[int] = None, + pack_file_size_threshold: Optional[int] = None, +) -> pa.Field: + """ + Construct an Arrow field for a Lance blob column. + + The returned field uses the complete logical blob shape + ``Struct``. + Every non-null value must set exactly one of ``data`` and ``uri``. External + ranges must set both ``position`` and a positive ``size``. Lance preserves + this logical schema across create, append, and merge-insert writes while + storing compact descriptors internally. + + Parameters + ---------- + name : str + Field name. + nullable : bool, default True + Whether the blob column accepts null values. + inline_size_threshold : optional, int + Maximum payload size in bytes to keep inline in the data file before + using packed blob storage. + dedicated_size_threshold : optional, int + Maximum payload size in bytes to store in packed blob storage before + using dedicated blob storage. This threshold is checked before + ``inline_size_threshold``. + pack_file_size_threshold : optional, int + Maximum size in bytes of a single packed blob sidecar (``.pack``) file. + Once a sidecar reaches this size a new one is started. + """ + _validate_threshold("inline_size_threshold", inline_size_threshold, allow_zero=True) + _validate_threshold( + "dedicated_size_threshold", dedicated_size_threshold, allow_zero=False + ) + _validate_threshold( + "pack_file_size_threshold", pack_file_size_threshold, allow_zero=False + ) + + field = pa.field(name, BlobType(), nullable=nullable) + if ( + inline_size_threshold is None + and dedicated_size_threshold is None + and pack_file_size_threshold is None + ): + return field + + metadata = dict(field.metadata or {}) + if inline_size_threshold is not None: + metadata[_BLOB_INLINE_SIZE_THRESHOLD_META_KEY] = str( + inline_size_threshold + ).encode() + if dedicated_size_threshold is not None: + metadata[_BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY] = str( + dedicated_size_threshold + ).encode() + if pack_file_size_threshold is not None: + metadata[_BLOB_PACK_FILE_SIZE_THRESHOLD_META_KEY] = str( + pack_file_size_threshold + ).encode() + return field.with_metadata(metadata) class BlobIterator: @@ -177,9 +362,11 @@ class BlobColumn: file-like objects. This can be useful for working with medium-to-small binary objects that need - to interface with APIs that expect file-like objects. For very large binary - objects (4-8MB or more per value) you might be better off creating a blob column - and using :py:meth:`lance.Dataset.take_blobs` to access the blob data. + to interface with APIs that expect file-like objects. For very large binary + objects (4-8MB or more per value) you might be better off creating a blob + column. Use :py:meth:`lance.Dataset.read_blobs` when you need complete blob + bytes, or :py:meth:`lance.Dataset.take_blobs` when you need lazy file-like + access. """ def __init__(self, blob_column: Union[pa.Array, pa.ChunkedArray]): @@ -200,18 +387,141 @@ def __iter__(self) -> Iterator[IO[bytes]]: return BlobIterator(iter(self.blob_column)) -class BlobFile(io.RawIOBase): - """Represents a blob in a Lance dataset as a file-like object.""" +class BlobFile(io.BufferedIOBase): + """Represents a blob in a Lance dataset as a file-like object. - def __init__(self, inner: LanceBlobFile): + ``read_range`` and ``read_ranges`` do not use the sequential buffer and + do not change the sequential cursor. + + Obtain a handle from :py:meth:`lance.dataset.Dataset.take_blobs`. + """ + + def __init__( + self, + inner: LanceBlobFile, + buffer_size: int = DEFAULT_BLOB_BUFFER_SIZE, + ): + super().__init__() + self.inner = inner + self.inner.set_buffer_size(_validate_buffer_size(buffer_size)) + self._raw = _RawBlobFile(inner) + + def close(self) -> None: + self._raw.close() + + @property + def closed(self) -> bool: + return self._raw.closed + + def readable(self) -> bool: + return True + + def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: + return self._raw.seek(offset, whence) + + def seekable(self) -> bool: + return True + + def tell(self) -> int: + return self._raw.tell() + + def size(self) -> int: + """ + Returns the size of the blob in bytes. + """ + return self._raw.size() + + def read(self, size: int = -1) -> bytes: + if self.closed: + raise ValueError("read of closed file") + if size is None or size < 0: + return self._raw.readall() + if size == 0: + return b"" + buf = bytearray(size) + n = self.readinto(buf) + if n == size: + return bytes(buf) + return bytes(buf[:n]) + + def read1(self, size: int = -1) -> bytes: + if self.closed: + raise ValueError("read of closed file") + if size is None or size < 0: + size = io.DEFAULT_BUFFER_SIZE + return self._raw.read(size) + + def readall(self) -> bytes: + if self.closed: + raise ValueError("read of closed file") + return self._raw.readall() + + def readinto(self, b) -> int: + if isinstance(b, bytearray): + if self.closed: + raise ValueError("readinto of closed file") + if not b: + return 0 + n = self._raw.readinto(b) + if n == 0 or n == len(b): + return n + return n + self._refill_readinto(memoryview(b)[n:]) + + view = memoryview(b).cast("B") + if view.readonly: + raise TypeError( + "readinto() argument must be read-write bytes-like object, " + f"not {type(b).__name__}" + ) + if self.closed: + raise ValueError("readinto of closed file") + return self._refill_readinto(view) + + def read_range(self, offset: int, length: int) -> bytes: + """Read a blob-local byte range without changing the current cursor.""" + return self._raw.read_range(offset, length) + + def read_ranges(self, ranges: list[tuple[int, int]]) -> list[bytes]: """ - Internal only: To obtain a BlobFile use - :py:meth:`lance.dataset.Dataset.take_blobs`. + Read multiple blob-local byte ranges without changing the current cursor. + + Each range is an ``(offset, length)`` pair, matching + :py:meth:`read_range`. The underlying physical reads may be reordered, + coalesced, or split for efficiency. For every range, offset plus length + must fit in an unsigned 64-bit integer and must not extend beyond the + blob size. + + Parameters + ---------- + ranges : List[Tuple[int, int]] + The ``(offset, length)`` byte ranges to read. + + Returns + ------- + data : List[bytes] + One payload per requested range, in input order. """ + return self._raw.read_ranges(ranges) + + def __repr__(self) -> str: + return f"" + + def _refill_readinto(self, view) -> int: + filled = 0 + while filled < len(view): + n = self._raw.readinto(view[filled:]) + if n == 0: + break + filled += n + return filled + + +class _RawBlobFile(io.RawIOBase): + """One inner ``read_up_to`` per ``read`` / ``readinto``.""" + + def __init__(self, inner: LanceBlobFile): self.inner = inner - ## Note: most methods undocumented since they are defined by - ## the base class. def close(self) -> None: self.inner.close() @@ -224,14 +534,16 @@ def readable(self) -> bool: def seek(self, offset: int, whence: int = io.SEEK_SET) -> int: if whence == io.SEEK_SET: - self.inner.seek(offset) + position = offset elif whence == io.SEEK_CUR: - self.inner.seek(self.inner.tell() + offset) + position = self.inner.tell() + offset elif whence == io.SEEK_END: - self.inner.seek(self.inner.size() + offset) + position = self.inner.size() + offset else: raise ValueError(f"Invalid whence: {whence}") - + if position < 0: + raise ValueError(f"negative seek value {position}") + self.inner.seek(position) return self.inner.tell() def seekable(self) -> bool: @@ -241,16 +553,31 @@ def tell(self) -> int: return self.inner.tell() def size(self) -> int: - """ - Returns the size of the blob in bytes. - """ return self.inner.size() def readall(self) -> bytes: return self.inner.readall() - def readinto(self, b: bytearray) -> int: - return self.inner.read_into(b) - - def __repr__(self) -> str: - return f"" + def read_range(self, offset: int, length: int) -> bytes: + return self.inner.read_range(offset, length) + + def read_ranges(self, ranges: list[tuple[int, int]]) -> list[bytes]: + return self.inner.read_ranges(ranges) + + def readinto(self, b) -> int: + # The Rust binding requires bytearray. + if isinstance(b, bytearray): + return self.inner.read_into(b) + view = memoryview(b).cast("B") + buffer = bytearray(len(view)) + bytes_read = self.inner.read_into(buffer) + view[:bytes_read] = buffer[:bytes_read] + return bytes_read + + +def _validate_buffer_size(buffer_size: int) -> int: + if isinstance(buffer_size, bool) or not isinstance(buffer_size, int): + raise TypeError(f"buffer_size must be an int, got {type(buffer_size).__name__}") + if buffer_size < 0: + raise ValueError("buffer_size must be non-negative") + return buffer_size diff --git a/python/python/lance/commit.py b/python/python/lance/commit.py index 806067a38c5..ab46b70e55f 100644 --- a/python/python/lance/commit.py +++ b/python/python/lance/commit.py @@ -4,8 +4,18 @@ from contextlib import AbstractContextManager from typing import Callable -CommitLock = Callable[[int], AbstractContextManager] +CommitLock = Callable[[int], AbstractContextManager[None]] -class CommitConflictError(Exception): - pass +class CommitConflictError(OSError): + """A commit conflicted with a concurrent transaction. + + Subclasses :class:`OSError` so existing ``except OSError`` handlers keep + working. ``retryable`` is ``True`` when the transaction was preempted and + can be retried against the newer version, ``False`` when the conflict is + incompatible and retrying will not help. + """ + + def __init__(self, message: str = "", retryable: bool = True): + super().__init__(message) + self.retryable = retryable diff --git a/python/python/lance/dataset.py b/python/python/lance/dataset.py index 97a9a0c6775..711e4dff954 100644 --- a/python/python/lance/dataset.py +++ b/python/python/lance/dataset.py @@ -6,8 +6,10 @@ import copy import dataclasses import json +import operator import os import random +import re import time import uuid import warnings @@ -26,10 +28,10 @@ Literal, Optional, Sequence, - Set, Tuple, TypedDict, Union, + cast, ) import pyarrow as pa @@ -38,48 +40,63 @@ from lance.log import LOGGER -from .blob import BlobFile +# Imported at runtime, not only for the annotations below: importing it here +# is what registers `Bitmap` as a `collections.abc.MutableSet`. +from .bitmap import Bitmap # noqa: TC001 +from .blob import DEFAULT_BLOB_BUFFER_SIZE, BlobFile, _validate_buffer_size from .dependencies import ( _check_for_numpy, _check_for_torch, + _is_pydantic_base_model_class, + _validate_pydantic_list, + model_to_dict, torch, ) 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 ( + CleanupExplanation, CleanupStats, Compaction, CompactionMetrics, DatasetBasePath, IOStats, LanceSchema, + PySearchFilter, ScanStatistics, _Dataset, + _format_field_path, _MergeInsertBuilder, + _parse_field_path, _Scanner, + _serialize_row_addrs, _write_dataset, indices, ) from .lance import __version__ as __version__ from .lance import _Session as Session -from .query import FullTextQuery -from .types import _coerce_reader +from .query import DocumentGranularity, FullTextQuery, MinHashQuery +from .types import _coerce_reader, _is_materialized from .udf import BatchUDF, normalize_transform from .udf import BatchUDFCheckpoint as BatchUDFCheckpoint from .udf import batch_udf as batch_udf -from .util import _target_partition_size_to_num_partitions, td_to_micros +from .util import ( + _normalize_index_segment_ids, + _target_partition_size_to_num_partitions, + td_to_micros, +) if TYPE_CHECKING: from pyarrow._compute import Expression from lance.namespace import LanceNamespace + from . import mem_wal from .commit import CommitLock - from .io import StorageOptionsProvider from .lance.indices import IndexDescription - from .progress import FragmentWriteProgress + from .progress import FragmentWriteProgress, IndexProgress from .types import ReaderLike QueryVectorLike = Union[ @@ -87,9 +104,275 @@ pa.Array, pa.Scalar, np.ndarray, - Iterable[float], + Iterable[Union[float, Iterable[float]]], ] LANCE_COMMIT_MESSAGE_KEY = "__lance_commit_message" +# Mirrors Rust's `lance::dataset::DEFAULT_COMMIT_TIMEOUT`; keep the two in sync. +_DEFAULT_COMMIT_TIMEOUT = timedelta(minutes=30) +_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 _field_blob_paths(field: pa.Field, parent: str = "") -> Iterator[str]: + segment = _format_field_path([field.name]) + field_path = f"{parent}.{segment}" if parent else segment + if _is_blob_field(field): + yield field_path + elif pa.types.is_struct(field.type): + for i in range(field.type.num_fields): + yield from _field_blob_paths(field.type.field(i), field_path) + + +def _blob_paths_in_schema(schema: pa.Schema) -> list[str]: + return [path for field in schema for path in _field_blob_paths(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 _sources_from_transforms( + output_paths: list[str], + transforms: dict[str, str], +) -> dict[str, str]: + result = {} + for path in output_paths: + segments = _parse_field_path(path) + source_path = transforms[segments[0]] + result[path] = ( + source_path + if not segments[1:] + else _format_field_path(_parse_field_path(source_path) + segments[1:]) + ) + return result + + +def _sources_from_direct_projection( + output_paths: list[str], + dataset_schema: pa.Schema, +) -> dict[str, str]: + source_paths = set(_blob_paths_in_schema(dataset_schema)) + result = {} + for path in output_paths: + segments = _parse_field_path(path) + result[path] = ( + segments[0] if len(segments) == 1 and segments[0] in source_paths else path + ) + return result + + +def _blob_column_sources( + schema: pa.Schema, + snapshot: Dict[str, Any], + dataset_schema: pa.Schema, +) -> dict[str, str]: + output_paths = _blob_paths_in_schema(schema) + transforms = dict(snapshot.get("_columns_with_transform") or ()) + if transforms: + return _sources_from_transforms(output_paths, transforms) + return _sources_from_direct_projection(output_paths, dataset_schema) + + +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 + return False + + +def _descriptors_at_path(table: pa.Table, path: str) -> list[Optional[Dict[str, Any]]]: + segments = _parse_field_path(path) + values = table.column(segments[0]).to_pylist() + + for segment in segments[1:]: + values = [value.get(segment) if value is not None else None for value in values] + + return values + + +def _replace_value_at_path( + parent: Optional[Dict[str, Any]], + segments: list[str], + value: Any, +) -> Optional[Dict[str, Any]]: + if parent is None: + return None + + segment = segments[0] + updated = dict(parent) + + if len(segments) == 1: + updated[segment] = value + else: + updated[segment] = _replace_value_at_path( + parent[segment], + segments[1:], + value, + ) + + return updated + + +def _replace_in_struct_column( + dataframe: "pd.DataFrame", + path: str, + values: list[Optional[BlobFile]], +) -> None: + segments = _parse_field_path(path) + parent_name = segments[0] + nested_segments = segments[1:] + + parents = dataframe[parent_name].tolist() + + dataframe[parent_name] = [ + _replace_value_at_path(parent, nested_segments, value) + for parent, value in zip(parents, values) + ] + + +def _fetch_blob_files_for_paths( + dataset: "LanceDataset", + table: pa.Table, + blob_paths: list[str], + blob_sources: dict[str, str], + row_addrs: list[int], +) -> dict[str, list[Optional[BlobFile]]]: + blob_files: dict[str, list[Optional[BlobFile]]] = {} + for path in blob_paths: + descriptors = _descriptors_at_path(table, path) + + null_mask = [ + _is_null_blob_description(descriptor) for descriptor in descriptors + ] + + non_null_addresses = [ + row_addrs[index] for index, is_null in enumerate(null_mask) if not is_null + ] + + fetched_blobs = ( + iter(dataset.take_blobs(blob_sources[path], addresses=non_null_addresses)) + if non_null_addresses + else iter([]) + ) + + blobs_for_path = [] + for is_null in null_mask: + if is_null: + blobs_for_path.append(None) + else: + blobs_for_path.append(next(fetched_blobs)) + + blob_files[path] = blobs_for_path + + return blob_files + + +def _place_blob_files( + dataframe: "pd.DataFrame", + blob_files: dict[str, list[Optional[BlobFile]]], + schema: pa.Schema, +) -> None: + top_level: dict[str, list[Optional[BlobFile]]] = {} + nested: dict[str, list[Optional[BlobFile]]] = {} + for path, values in blob_files.items(): + segments = _parse_field_path(path) + if len(segments) == 1: + top_level[segments[0]] = values + else: + nested[path] = values + + for index, field in enumerate(schema): + if field.name in top_level: + dataframe.insert(index, field.name, top_level[field.name]) + + for path, values in nested.items(): + _replace_in_struct_column(dataframe, path, values) + + +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): @@ -113,6 +396,12 @@ def execute(self, data_obj: ReaderLike, *, schema: Optional[pa.Schema] = None): """ reader = _coerce_reader(data_obj, schema) + # Materialized sources are wrapped in an in-memory table so retries never + # spill and the source's statistics can drive the join; everything else is + # treated as a one-shot stream. + if _is_materialized(data_obj): + return super(MergeInsertBuilder, self).execute_batches(reader) + return super(MergeInsertBuilder, self).execute(reader) def execute_uncommitted( @@ -137,6 +426,9 @@ def execute_uncommitted( """ reader = _coerce_reader(data_obj, schema) + if _is_materialized(data_obj): + return super(MergeInsertBuilder, self).execute_uncommitted_batches(reader) + return super(MergeInsertBuilder, self).execute_uncommitted(reader) # These next three overrides exist only to document the methods @@ -235,6 +527,21 @@ def retry_timeout(self, timeout: timedelta) -> "MergeInsertBuilder": """ return super(MergeInsertBuilder, self).retry_timeout(timeout) + def data_storage_version(self, version: str) -> "MergeInsertBuilder": + """Set the exact data storage version for files written by this operation. + + If omitted, use the dataset's default write version without changing it. + Accepts "2.0", "2.1", "2.2", "2.3", "stable", or "next" for V2 + datasets. Release selectors are resolved by the engine; V1/V2 + cross-family targets are rejected. + + Examples + -------- + ``dataset.merge_insert("id").data_storage_version("2.2")`` selects V2.2 + for the files written when the builder executes. + """ + return super(MergeInsertBuilder, self).data_storage_version(version) + def use_index(self, use_index: bool) -> "MergeInsertBuilder": """ Controls whether to use indices for the merge operation. @@ -256,15 +563,106 @@ def use_index(self, use_index: bool) -> "MergeInsertBuilder": """ return super(MergeInsertBuilder, self).use_index(use_index) + def write_mode( + self, mode: Literal["auto", "rewrite_rows", "rewrite_columns"] + ) -> "MergeInsertBuilder": + """ + Selects how the merged rows are written to disk. + + For a partial-schema update (the source omits some dataset columns) the + two modes have different cost shapes. ``rewrite_columns`` never reads or + writes the columns the source omits, but its replacement column file + covers every row of each fragment it touches, so the bytes written barely + fall as fewer rows match. ``rewrite_rows`` instead scales with the number + of matched rows. Patching columns wins once the fraction of rows matched + exceeds roughly the fraction of each row's bytes the source columns + occupy: for a KB-scale update of a MB-per-row table that is nearly + always, and for a table whose columns are all narrow it may never be. + + The per-fragment matched row count that decides this is only known once + the join has run, so the caller picks rather than the planner guessing. + + Parameters + ---------- + mode : {'auto', 'rewrite_rows', 'rewrite_columns'} + ``auto`` (default) lets the engine choose. It rewrites whole rows, + except on the one path that predates this parameter: a + partial-schema update whose join key carries a scalar index patches + columns. ``rewrite_rows`` deletes the matched rows and writes whole + rows into new fragments; for a partial-schema update it gives up + that index probe, since the indexed path only ever patches columns. + ``rewrite_columns`` attaches new data files holding the source + columns to the fragments that already hold the matched rows; it + raises if the merge cannot be expressed that way, which requires + updating matched rows only (no inserts, no matched deletes, no + delete-by-source) with a source that omits at least one dataset + column, carries at least one column besides the join key, and + carries no blob column. + + Returns + ------- + MergeInsertBuilder + The builder instance for method chaining. + """ + return super(MergeInsertBuilder, self).write_mode(mode) + + def target_bases(self, bases: List[str]) -> "MergeInsertBuilder": + """ + Write new fragments produced by this merge insert to these bases. + + Each entry references a base path registered in the dataset manifest, + by name or by path URI, like the ``target_bases`` parameter of + :func:`~lance.write_dataset`. An entry equal to the dataset's URI + includes the dataset's primary storage in the rotation, e.g. + ``[ds.uri, "base1", "base2"]`` spreads new data files across primary + storage and both bases. New data files are distributed across the + target bases round-robin. Data files that patch existing fragments + and deletion files are always written to the dataset's primary + storage. + + Parameters + ---------- + bases : List[str] + Base names or path URIs to write new data files to. + + Returns + ------- + MergeInsertBuilder + The builder instance for method chaining. + """ + return super(MergeInsertBuilder, self).target_bases(bases) + + def target_all_bases(self, include_primary: bool = True) -> "MergeInsertBuilder": + """ + Write new fragments to every base registered in the dataset manifest. + + The bases are resolved when the merge insert executes, so bases added + later are picked up automatically. When ``include_primary`` is True + (the default), the dataset's primary storage participates in the + round-robin rotation as the first slot. Cannot be combined with + :meth:`target_bases`. + + Parameters + ---------- + include_primary : bool, default True + Whether the dataset's primary storage is part of the rotation. + + Returns + ------- + MergeInsertBuilder + The builder instance for method chaining. + """ + return super(MergeInsertBuilder, self).target_all_bases(include_primary) + def explain_plan( self, schema: Optional[pa.Schema] = None, verbose: bool = False ) -> str: """ Generate the execution plan for the merge insert operation. - This method creates the execution plan that would be used for the given - source schema and returns it as a formatted string for debugging and - analysis purposes. + This reports the plan a *streaming* source of the given schema would run. + It takes a schema rather than data, so it cannot know how ``execute`` would + wrap the source; see the note under the example. Parameters ---------- @@ -298,16 +696,21 @@ 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, ...] - CoalesceBatchesExec: target_batch_size=... - HashJoinExec: mode=CollectLeft, join_type=Right, ... - CooperativeExec - LanceRead: uri=test_dataset/data, projection=[id], ... - RepartitionExec: ... - StreamingTableExec: partition_sizes=1, ... + ProjectionExec: expr=[...] + HashJoinExec: mode=CollectLeft, join_type=Right, ... + LanceRead: uri=test_dataset/data, projection=[id], ... + RepartitionExec: ... + ProjectionExec: expr=[..., true as __merge_source_sentinel] + StreamingTableExec: partition_sizes=1, ... + This is always the streaming shape. `explain_plan` receives a schema rather + than data, so it cannot know how `execute` would wrap the source, and the + wrapping affects the plan. Use `analyze_plan`, which receives the real + source, when that matters. Note that `analyze_plan` runs the merge to + collect metrics and may write data files, whereas `explain_plan` writes + nothing. + >>> # Or with explicit schema >>> source_schema = pa.schema([ ... pa.field("id", pa.int64()), @@ -318,11 +721,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, ...] - CoalesceBatchesExec: target_batch_size=... - HashJoinExec: mode=CollectLeft, join_type=Right, ... - ... + ProjectionExec: expr=[...] + HashJoinExec: mode=CollectLeft, join_type=Right, ... + ... """ return super(MergeInsertBuilder, self).explain_plan(schema, verbose=verbose) @@ -383,14 +784,20 @@ 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=[...] - CoalesceBatchesExec: elapsed=..., ..., metrics=[...] - HashJoinExec: elapsed=..., mode=CollectLeft, join_type=Right, ... - CooperativeExec, elapsed=..., metrics=[] - LanceRead: elapsed=..., ..., metrics=[..., bytes_read=..., ...] - RepartitionExec: ... - StreamingTableExec: ..., metrics=[] + ProjectionExec: elapsed=..., expr=[...], metrics=[...] + RepartitionExec: ... + HashJoinExec: elapsed=..., mode=CollectLeft, join_type=Left, ... + ProjectionExec: elapsed=..., expr=[..., true as __merge_source_sentinel], metrics=[...] + DataSourceExec: ..., metrics=[] + LanceRead: elapsed=..., ..., metrics=[..., bytes_read=..., ...] + + The reported plan follows how the source was passed. `new_data` above is a + `pa.Table`, so it is wrapped in an in-memory table that reports exact + statistics, while a `pa.RecordBatchReader` reports none. DataFusion chooses + which side of the join to collect from those statistics and from the two + sides' sizes, so the same merge can plan differently depending on which one + you hand it. Use `explain_plan` only for the streaming shape: it takes a + schema rather than data, so it cannot know how the source would be wrapped. The two key parts of the plan analysis are LanceRead and MergeInsert. LanceRead scans join keys and columns in conditions. MergeInsert writes @@ -411,8 +818,41 @@ def analyze_plan( - requests: number of storage requests made """ # noqa: E501 reader = _coerce_reader(data_obj, schema) + + # Route exactly as execute() does, so the reported plan is the one that + # would run. A materialized source reports exact statistics where a stream + # reports none, which can change which side of the join is collected. + if _is_materialized(data_obj): + return super(MergeInsertBuilder, self).analyze_plan_batches(reader) + return super(MergeInsertBuilder, self).analyze_plan(reader) + def mark_sstables_as_compacted( + self, sstables: "List[mem_wal.CompactedSsTable]" + ) -> "MergeInsertBuilder": + """Mark MemWAL SSTables as compacted into the base table. + + Call this before executing merge_insert when it compacts MemWAL SSTables. + The progress is recorded in the same commit as the data. + + For multi-pass compaction, call this only on the final successful + data-changing pass. Intermediate passes must not carry compaction + progress. Lance cannot tell whether a caller has another pass planned, + so it cannot enforce this: if a delete pass carried the marker and the + process then died before the matching upsert, the recorded progress + would claim rows were copied in that never were. + + Parameters + ---------- + sstables : list of CompactedSsTable + SSTables to mark as compacted. + """ + from .mem_wal import _to_raw_compacted_sstables + + raw_sstables = _to_raw_compacted_sstables(sstables) + super(MergeInsertBuilder, self).mark_sstables_as_compacted(raw_sstables) + return self + class LanceDataset(pa.dataset.Dataset): """A Lance Dataset in Lance format where the data is stored at the given uri.""" @@ -432,12 +872,15 @@ 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_client: Optional[Any] = None, + table_id: Optional[List[str]] = None, + namespace_client_managed_versioning: bool = False, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, ): 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 + self._base_store_params = base_store_params # Handle deprecation warning for index_cache_size if index_cache_size is not None: @@ -449,6 +892,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,7 +912,10 @@ def __init__( 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, + base_store_params=base_store_params, ) self._default_scan_options = default_scan_options self._read_params = read_params @@ -476,6 +929,7 @@ def __deserialize__( manifest: bytes, default_scan_options: Optional[Dict[str, Any]], read_params: Optional[Dict[str, Any]] = None, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, ): return cls( uri, @@ -484,8 +938,58 @@ def __deserialize__( serialized_manifest=manifest, default_scan_options=default_scan_options, read_params=read_params, + base_store_params=base_store_params, ) + @classmethod + def from_pydantic_model( + cls, + model_class, + data, + uri: Optional[Union[str, Path]] = None, + mode: str = "create", + **kwargs, + ) -> "LanceDataset": + """Create a LanceDataset from a Pydantic model class and a list of instances. + + The table name is inferred from the model class name converted to snake_case. + The schema is inferred from the model class's field annotations, not from + the data, so optional fields are typed correctly even if every value in a + given batch happens to be None. + + Parameters + ---------- + model_class : type + A Pydantic BaseModel subclass. + data : list + A list of Pydantic model instances. + uri : str or Path, optional + The URI to write the dataset to. If not provided, the model class name + converted to snake_case is used as the path. + mode : str, optional + The write mode. One of "create", "overwrite", or "append". + **kwargs + Additional arguments passed to write_dataset(). + """ + if not _is_pydantic_base_model_class(model_class): + raise TypeError( + f"`model_class` must be a Pydantic BaseModel subclass, " + f"got {model_class!r}" + ) + _validate_pydantic_list(data, model_class) + if not data: + raise ValueError( + "`data` must be a non-empty list of Pydantic model instances." + ) + if uri is None: + uri = re.sub(r"(? 1 else None self._ds = _Dataset( self._uri, version, @@ -524,16 +1032,25 @@ def __setstate__(self, state): manifest=manifest, default_scan_options=default_scan_options, read_params=read_params, + base_store_params=base_store_params, ) self._default_scan_options = default_scan_options self._read_params = read_params - self._storage_options_provider = None + self._base_store_params = base_store_params + 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._base_store_params = self._base_store_params + 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 @@ -606,16 +1123,17 @@ def create_branch( Parameters ---------- branch: str - Name of the branch to create. + Name of the branch to create. ``"main"`` is reserved for the + default branch and cannot be used as a new branch name. reference: Optional[int | str | Tuple[Optional[str], Optional[int]] An integer specifies a version number in the current branch; a string specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies a version number in a specified branch. (None, None) means the latest - version_number on the main branch. + version_number on the default branch. ``("main", version)`` is an + explicit alias for the default branch in this reference context. storage_options: Optional[Dict[str, str]] Storage options for the underlying object store. If not provided, the storage options from the current dataset will be used. - Returns ------- LanceDataset @@ -628,7 +1146,12 @@ 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._base_store_params = self._base_store_params + ds._namespace_client = self._namespace_client + ds._table_id = self._table_id + ds._namespace_client_managed_versioning = ( + self._namespace_client_managed_versioning + ) ds._default_scan_options = self._default_scan_options ds._read_params = self._read_params return ds @@ -637,28 +1160,49 @@ def checkout_latest(self): """Check out the latest version of the current branch.""" self._ds.checkout_latest() - def list_indices(self) -> List[Index]: + def list_indices(self) -> List[IndexInformation]: """ Returns index 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. + This method is deprecated. Use describe_indices() instead, which returns + richer per-index information. + + Each returned :class:`IndexInformation` describes one index segment. """ - # TODO: https://github.com/lancedb/lance/issues/5237 deprecate this method - # warnings.warn( - # "The 'list_indices' method is deprecated. It may be removed in a future" - # "version. Use describe_indices() instead.", - # DeprecationWarning, - # ) + warnings.warn( + "The 'list_indices' method is deprecated. It may be removed in a future " + "version. Use describe_indices() instead.", + DeprecationWarning, + ) - return self._ds.load_indices() + return [ + { + "name": desc.name, + "type": desc.index_type, + "uuid": segment.uuid, + "fields": desc.field_names, + "version": segment.dataset_version_at_last_update, + "fragment_ids": segment.fragment_ids, + "base_id": segment.base_id, + } + for desc in self.describe_indices() + for segment in desc.segments + ] def describe_indices(self) -> List[IndexDescription]: """Returns index information for all indices in the dataset.""" return self._ds.describe_indices() + def remap_row_addrs(self, addrs: "pa.Array") -> "Optional[pa.Array]": + """Remap row addresses across compactions still recorded in the + fragment-reuse index. Rows a compaction dropped become null. The index + retains only recent rounds (older ones are pruned as index remap catches + up), so remap promptly: an address whose round was pruned is returned + unchanged, not remapped. Returns ``None`` when there is no fragment-reuse + index. + """ + return self._ds.remap_row_addrs(addrs) + def index_statistics(self, index_name: str) -> Dict[str, Any]: warnings.warn( "LanceDataset.index_statistics() is deprecated, " @@ -669,7 +1213,7 @@ def index_statistics(self, index_name: str) -> Dict[str, Any]: @property def has_index(self): - return len(self.list_indices()) > 0 + return len(self.describe_indices()) > 0 def _apply_default_scan_options(self, builder: ScannerBuilder): if self._default_scan_options: @@ -679,16 +1223,20 @@ def _apply_default_scan_options(self, builder: ScannerBuilder): def scanner( self, columns: Optional[Union[List[str], Dict[str, str]]] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[ + Union[str, Expression, FullTextQuery, VectorSearchQuery, Dict[str, Any]] + ] = None, limit: Optional[int] = None, offset: Optional[int] = None, - nearest: Optional[dict] = None, + nearest: Optional[Union[Dict[str, Any], MinHashQuery]] = 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, fragments: Optional[Iterable[LanceFragment]] = None, - full_text_query: Optional[Union[str, dict, FullTextQuery]] = None, + index_segments: Optional[Iterable[Union[str, uuid.UUID]]] = None, + full_text_query: Optional[Union[str, Dict[str, Any], FullTextQuery]] = None, *, prefilter: Optional[bool] = None, with_row_id: Optional[bool] = None, @@ -706,6 +1254,8 @@ def scanner( strict_batch_size: Optional[bool] = None, order_by: Optional[List[Union[ColumnOrdering, str]]] = None, disable_scoring_autoprojection: Optional[bool] = None, + row_addr_allowlist: Optional[bytes] = None, + row_addr_blocklist: Optional[bytes] = None, ) -> LanceScanner: """Return a Scanner that can support various pushdowns. @@ -715,16 +1265,61 @@ def scanner( List of column names to be fetched. Or a dictionary of column names to SQL expressions. All columns are fetched if None or unspecified. - filter: pa.compute.Expression or str - Expression or str that is a valid SQL where clause. See - `Lance filter pushdown `_ - for valid SQL expressions. + filter: pa.compute.Expression, str, VectorSearchQuery, FullTextQuery or dict + Lance supports 2 kinds of filters: expression filter and search filter. + + - Expression filter is pa.compute.Expression or str that is a valid SQL + where clause. See `Lance filter pushdown + `_ + for valid SQL expressions. Expression filter is applied to filtered scan, + full text search and vector search. + + - VectorSearchQuery is a vector search that can only be applied to full + text search. Example: + .. code-block:: python + + filter=VectorSearchQuery( + "vector", + np.array([12, 17, 300, 10], dtype=np.float32), + 5, + 20, + True, + ) + + - FullTextQuery is a full text search that can only be applied to vector + search. Example: + .. code-block:: python + + filter=PhraseQuery("hello world", "col") + + - Dictionary is a combined filter containing both expression filter with + key `expr_filter` and search filter with key `search_filter`. Example: + .. code-block:: python + + scanner = ds.scanner( + nearest={ + "column": "vector", + "q": np.array([12, 17, 300, 10], dtype=np.float32), + "k": 5, + "minimum_nprobes": 20, + "use_index": True, + }, + filter={ + "expr_filter": "category='geography'", + "search_filter": PhraseQuery("hello world", "col"), + }, + ) limit: int, default None Fetch up to this many rows. All rows if None or unspecified. offset: int, default None Fetch starting with this row. 0 if None or unspecified. - nearest: dict, default None - Get the rows corresponding to the K most similar vectors. Example: + nearest: dict or MinHashQuery, default None + Get the rows corresponding to the K most similar vectors, or, when a + :class:`~lance.query.MinHashQuery` is given, the ``limit`` rows most + similar to its text under the column's MinHash LSH index (adds a + ``_distance`` column equal to ``1 - estimated Jaccard similarity``; + unindexed rows are scored on the fly unless ``fast_search`` is set). + Vector search example: .. code-block:: python @@ -738,24 +1333,52 @@ def scanner( "distance_range": (0.0, 1.0), } + ``q`` may also be a 2-D array-like value, or a list of vectors, for + fixed-size vector columns. In that case Lance runs a batch nearest-neighbor + query, returns up to ``k`` rows for each query vector, and adds + an Int32 non-null ``query_index`` as the first output column to identify + the source query for each result row. + Flattened 1-D arrays whose length is a multiple of the vector dimension are + rejected. Datasets that already contain a ``query_index`` column cannot be + used for batch nearest-neighbor search. When ``use_index`` is true and a + vector index is available, each query vector is searched through the index + path; otherwise the flat batch path is used. + 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. If a byte limit is also configured, both + limits apply and the one reached first determines the batch size. + batch_size_bytes: int, default None + If set, the scanner will produce batches whose total size in bytes + is approximately this value. If ``batch_size`` is also set, both + limits apply and the one reached first determines the batch size. + This cannot be combined with ``strict_batch_size=True`` because + strict row batching can merge batches beyond the byte limit. + 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. + The maximum number of bytes to buffer from storage before applying + backpressure. See ``ScannerBuilder.io_buffer_size`` for more information. batch_readahead: int, optional - The number of batches to read ahead. + The number of batches to decode concurrently. fragment_readahead: int, optional - The number of fragments to read ahead. + The number of fragments whose reads may be scheduled concurrently. + This applies even when ``scan_in_order`` is true. Set this to ``1`` + to avoid overlapping I/O from multiple fragments. scan_in_order: bool, default True - Whether to read the fragments and batches in order. If false, - throughput may be higher, but batches will be returned out of order - and memory use might increase. + Whether to return fragments and batches in order. This does not make + storage reads sequential; use ``fragment_readahead=1`` for that. If + false, throughput may be higher, but batches will be returned out of + order and memory use might increase. fragments: iterable of LanceFragment, default None If specified, only scan these fragments. If scan_in_order is True, then the fragments will be scanned in the order given. + index_segments: iterable of str or uuid.UUID, default None + If specified, restrict vector index search to these index segment UUIDs. + Only supported for vector search. If fragments is also specified, rows + from those fragments not covered by the selected index segments will be + searched with flat KNN. prefilter: bool, default False If True then the filter will be applied before the vector query is run. This will generate more correct results but it may be a more costly @@ -804,12 +1427,17 @@ def scanner( - query: str The query string to search for. fast_search: bool, default False - If True, then the search will only be performed on the indexed data, which - yields faster search time. + If True, then vector search, full text search, and scalar-indexed + filters will only search indexed fragments, which yields faster + search time but may skip recently appended unindexed data. scan_stats_callback: Callable[[ScanStatistics], None], default None A callback function that will be called with the scan statistics after the scan is complete. Errors raised by the callback will be logged but not re-raised. + strict_batch_size: bool, default False + If True, all batches except the last batch will have exactly + ``batch_size`` rows. This cannot be combined with a byte limit, + including one configured through ``FileReaderOptions``. include_deleted_rows: bool, default False If True, then rows that have been deleted, but are still present in the fragment, will be returned. These rows will have the _rowid column set @@ -832,6 +1460,14 @@ def scanner( This parameter allows you to opt-in to the new behavior early, to avoid being subject to breaking changes in the future. + row_addr_allowlist: bytes, default None + Restrict the scan to these row addresses. A serialized roaring treemap + over ``_rowid`` (``RowAddrTreeMap::serialize_into`` output). Applied + before KNN / BM25 ranking, so top-k is computed over the surviving rows + rather than filtered afterwards. + row_addr_blocklist: bytes, default None + Exclude these row addresses, same encoding as ``row_addr_allowlist``. + Combined with it when both are given. .. note:: @@ -874,14 +1510,18 @@ def setopt(opt, val): setopt(builder.filter, filter) setopt(builder.prefilter, prefilter) + if row_addr_allowlist is not None or row_addr_blocklist is not None: + builder.row_addr_prefilter(row_addr_allowlist, row_addr_blocklist) 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) setopt(builder.scan_in_order, scan_in_order) setopt(builder.with_fragments, fragments) + setopt(builder.with_index_segments, index_segments) setopt(builder.late_materialization, late_materialization) setopt(builder.blob_handling, blob_handling) setopt(builder.with_row_id, with_row_id) @@ -918,7 +1558,10 @@ def setopt(opt, val): elif isinstance(full_text_query, dict): builder = builder.full_text_search(**full_text_query) if nearest is not None: - builder = builder.nearest(**nearest) + if isinstance(nearest, MinHashQuery): + builder = builder.minhash_search(nearest) + else: + builder = builder.nearest(**nearest) return builder.to_scanner() @property @@ -941,10 +1584,22 @@ def lance_schema(self) -> "LanceSchema": @property def data_storage_version(self) -> str: """ - The version of the data storage format this dataset is using + The default data file version for writes that omit ``data_storage_version``. + Existing files may use other V2 versions; this is not a snapshot version + summary. """ return self._ds.data_storage_version + @property + def has_stable_row_ids(self) -> bool: + """ + Whether this dataset has stable row IDs enabled. + + This is based on the dataset manifest feature flag and does not depend on + whether the current version has any fragments. + """ + return self._ds.has_stable_row_ids + @property def max_field_id(self) -> int: """ @@ -955,11 +1610,12 @@ def max_field_id(self) -> int: def to_table( self, columns: Optional[Union[List[str], Dict[str, str]]] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[Union[str, Expression]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - nearest: Optional[dict] = None, + nearest: Optional[Union[Dict[str, Any], MinHashQuery]] = 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, @@ -969,7 +1625,7 @@ def to_table( 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, + full_text_query: Optional[Union[str, Dict[str, Any], FullTextQuery]] = None, io_buffer_size: Optional[int] = None, late_materialization: Optional[bool | List[str]] = None, blob_handling: Optional[str] = None, @@ -994,8 +1650,13 @@ def to_table( Fetch up to this many rows. All rows if None or unspecified. offset: int, default None Fetch starting with this row. 0 if None or unspecified. - nearest: dict, default None - Get the rows corresponding to the K most similar vectors. Example: + nearest: dict or MinHashQuery, default None + Get the rows corresponding to the K most similar vectors, or, when a + :class:`~lance.query.MinHashQuery` is given, the ``limit`` rows most + similar to its text under the column's MinHash LSH index (adds a + ``_distance`` column equal to ``1 - estimated Jaccard similarity``; + unindexed rows are scored on the fly unless ``fast_search`` is set). + Vector search example: .. code-block:: python @@ -1040,6 +1701,8 @@ def to_table( use_stats: bool, optional, default True Use stats pushdown during filters. fast_search: bool, optional, default False + Only search indexed fragments for vector, full text, and scalar-indexed + filter queries. This may skip recently appended unindexed data. full_text_query: str or dict, optional query string to search for, the results will be ranked by BM25. e.g. "hello world", would match documents contains "hello" or "world". @@ -1087,6 +1750,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, @@ -1105,6 +1769,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, Expression]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + nearest: Optional[Union[Dict[str, Any], MinHashQuery]] = 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[str, Any], 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): """ @@ -1458,11 +2189,12 @@ def io_stats_incremental(self) -> IOStats: def to_batches( self, columns: Optional[Union[List[str], Dict[str, str]]] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[Union[str, Expression]] = None, limit: Optional[int] = None, offset: Optional[int] = None, - nearest: Optional[dict] = None, + nearest: Optional[Union[Dict[str, Any], MinHashQuery]] = 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, @@ -1471,7 +2203,7 @@ def to_batches( with_row_id: Optional[bool] = None, with_row_address: Optional[bool] = None, use_stats: Optional[bool] = None, - full_text_query: Optional[Union[str, dict]] = None, + full_text_query: Optional[Union[str, Dict[str, Any]]] = None, io_buffer_size: Optional[int] = None, late_materialization: Optional[bool | List[str]] = None, blob_handling: Optional[str] = None, @@ -1499,6 +2231,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, @@ -1615,7 +2348,9 @@ def take_blobs( ids: Optional[Union[List[int], pa.Array]] = None, addresses: Optional[Union[List[int], pa.Array]] = None, indices: Optional[Union[List[int], pa.Array]] = None, - ) -> List[BlobFile]: + *, + buffer_size: int = DEFAULT_BLOB_BUFFER_SIZE, + ) -> List[Optional[BlobFile]]: """ Select blobs by row IDs. @@ -1623,6 +2358,13 @@ def take_blobs( this API allows you to open binary blob data as a regular Python file-like object. For more details, see :py:class:`lance.BlobFile`. + If you plan to read each selected blob completely with ``read()`` or + ``readall()``, use :py:meth:`read_blobs` instead. It materializes blob + payloads with Lance's planned batched reader. + + ``read_range`` and ``read_ranges`` do not use the sequential buffer and + do not change the sequential cursor. + Exactly one of ids, addresses, or indices must be specified. Parameters @@ -1635,46 +2377,228 @@ def take_blobs( The (unstable) row addresses to select in the dataset. indices : Integer Array or array-like The offset / indices of the row in the dataset. + buffer_size : int, default 512 KiB + Sequential read-ahead size in bytes. ``0`` disables read-ahead. Returns ------- - blob_files : List[BlobFile] + blob_files : List[Optional[BlobFile]] + One element per selected row. Null blob values return ``None``; + valid empty blobs return a ``BlobFile`` with size zero. """ - 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" - ) + buffer_size = _validate_buffer_size(buffer_size) + 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") - return [BlobFile(lance_blob_file) for lance_blob_file in lance_blob_files] + lance_blob_files = self._ds.take_blobs_by_indices( + selection_values, blob_column + ) + return [ + BlobFile(lance_blob_file, buffer_size=buffer_size) + if lance_blob_file is not None + else None + for lance_blob_file in lance_blob_files + ] - def head(self, num_rows, **kwargs): + 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, Optional[bytes]]]: """ - Load the first N rows of the dataset. + 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. Use this API for training loaders, + batch preprocessing, and other workflows that need complete blob bytes. + + Exactly one of ids, addresses, or indices must be specified. Parameters ---------- - num_rows : int - The number of rows to load. - **kwargs : dict, optional - See scanner() method for full parameter description. + 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 ------- - table : Table + blobs : List[Tuple[int, Optional[bytes]]] + One ``(row_address, blob_bytes)`` pair per selected row. Null blob + values return ``None``; valid empty blobs return ``b""``. """ - kwargs["limit"] = num_rows - return self.scanner(**kwargs).to_table() + 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 read_blob_ranges( + self, + blob_column: str, + requests: Sequence[Tuple[int, int, int]], + *, + selector: Literal["ids", "addresses", "indices"], + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, int, Optional[bytes]]]: + """ + Read row-specific blob-local byte ranges with one planned API call. + + Each request is a ``(row, offset, length)`` tuple. ``selector`` defines + whether every ``row`` value is interpreted as a stable row ID, physical + row address, or dataset index. Repeat a row value to read multiple ranges + from the same blob. Planning, range validation, physical-source grouping, + request coalescing, and bounded I/O scheduling all happen in Rust; this + method does not use a Python thread pool. + + Every request produces one result. Requests on null blobs return ``None``, + including when the requested range is empty. Empty ranges on non-null + blobs return empty bytes without issuing payload I/O. For every request, + offset plus length must fit in an unsigned 64-bit integer. Ranges on + non-null blobs must not extend beyond the logical blob size. Blob-local + bounds are not evaluated for null values because they have no logical + payload length. By default results preserve the input request order. + + Parameters + ---------- + blob_column : str + The name of the blob column to read. + requests : Sequence[Tuple[int, int, int]] + Complete ``(row, offset, length)`` requests. + selector : {"ids", "addresses", "indices"} + Interpretation of every request's ``row`` value. + io_buffer_size : int, optional + Override the scheduler I/O buffer size used while materializing ranges. + preserve_order : bool, optional + If True, returned results follow request order. If False, + ``request_index`` still identifies every result. + + Examples + -------- + Read two disjoint ranges from the same row index: + + .. code-block:: python + + dataset.read_blob_ranges( + "images", + requests=[(7, 0, 1024), (7, 4096, 1024)], + selector="indices", + ) + + Returns + ------- + results : List[Tuple[int, int, Optional[bytes]]] + One ``(request_index, row_address, data)`` tuple per request. + The Python list retains all returned payload bytes in memory; peak + result memory is therefore proportional to their total logical size. + """ + return self._ds.read_blob_ranges( + list(requests), + blob_column, + selector, + io_buffer_size=io_buffer_size, + preserve_order=preserve_order, + ) + + def head(self, num_rows, **kwargs): + """ + Load the first N rows of the dataset. + + Parameters + ---------- + num_rows : int + The number of rows to load. + **kwargs : dict, optional + See scanner() method for full parameter description. + + Returns + ------- + table : Table + """ + kwargs["limit"] = num_rows + return self.scanner(**kwargs).to_table() + + def slice( + self, + start: int, + end: int, + columns: Optional[Union[List[str], Dict[str, str]]] = None, + ) -> pa.Table: + """Select a contiguous range of rows by position. + + Equivalent to ``dataset.take(list(range(start, end)))``, but pushed + down as an offset/limit scan instead of a materialized index list. + + Parameters + ---------- + start : int + The index of the first row to include (inclusive). Must be + non-negative. + end : int + The index to stop before (exclusive). Must be greater than or + equal to ``start``. + columns: list of str, or dict of str to str default None + List of column names to be fetched. + Or a dictionary of column names to SQL expressions. + All columns are fetched if None or unspecified. + + Returns + ------- + table : pyarrow.Table + + Examples + -------- + >>> import lance + >>> import pyarrow as pa + >>> tbl = pa.table({"id": range(100)}) + >>> dataset = lance.write_dataset(tbl, "memory://slice_dataset") + >>> dataset.slice(10, 20) + pyarrow.Table + id: int64 + ---- + id: [[10,11,12,13,14,15,16,17,18,19]] + """ + if start < 0: + raise ValueError(f"start must be non-negative, got {start}") + if end < start: + raise ValueError(f"end ({end}) must be >= start ({start})") + return self.scanner(offset=start, limit=end - start, columns=columns).to_table() def count_rows( - self, filter: Optional[Union[str, pa.compute.Expression]] = None, **kwargs + self, filter: Optional[Union[str, Expression]] = None, **kwargs: Any ) -> int: """Count rows matching the scanner filter. @@ -1745,9 +2669,9 @@ def alter_columns(self, *alterations: Iterable[AlterColumn]): not changed. - "nullable": bool, optional Whether the column should be nullable. If not specified, the column - nullability is not changed. Only non-nullable columns can be changed - to nullable. Currently, you cannot change a nullable column to - non-nullable. + nullability is not changed. A non-nullable column can always be made + nullable. A nullable column can be made non-nullable only if it + contains no NULL values; otherwise an error is raised. - "data_type": pyarrow.DataType, optional The new data type to cast the column to. If not specified, the column data type is not changed. @@ -1971,11 +2895,11 @@ def drop_columns(self, columns: List[str]): def delete( self, - predicate: Union[str, pa.compute.Expression], + predicate: Union[str, Expression], *, conflict_retries: int = 10, retry_timeout: timedelta = timedelta(seconds=30), - ): + ) -> DeleteResult: """ Delete rows from the dataset. @@ -1996,6 +2920,12 @@ def delete( regardless of how long it takes to complete. Subsequent attempts will be cancelled once this timeout is reached. Default is 30 seconds. + Returns + ------- + dict + A dictionary containing the number of rows deleted, with the key + ``num_deleted_rows``. + Examples -------- >>> import lance @@ -2003,17 +2933,14 @@ def delete( >>> table = pa.table({"a": [1, 2, 3], "b": ["a", "b", "c"]}) >>> dataset = lance.write_dataset(table, "example") >>> dataset.delete("a = 1 or b in ('a', 'b')") - >>> dataset.to_table() - pyarrow.Table - a: int64 - b: string - ---- - a: [[3]] - b: [["c"]] + {'num_deleted_rows': 2} """ - if isinstance(predicate, pa.compute.Expression): - predicate = str(predicate) - self._ds.delete(predicate, conflict_retries, retry_timeout) + encoded_predicate = ( + _serialize_expression(predicate, self._ds.schema) + if isinstance(predicate, pa.compute.Expression) + else predicate + ) + return self._ds.delete(encoded_predicate, conflict_retries, retry_timeout) def truncate_table(self) -> None: """ @@ -2170,9 +3097,10 @@ def merge_insert( def update( self, updates: Dict[str, str], - where: Optional[str] = None, + where: Optional[Union[str, Expression]] = None, conflict_retries: int = 10, retry_timeout: timedelta = timedelta(seconds=30), + data_storage_version: Optional[str] = None, ) -> UpdateResult: """ Update column values for rows matching where. @@ -2181,8 +3109,9 @@ def update( ---------- updates : dict of str to str A mapping of column names to a SQL expression. - where : str, optional - A SQL predicate indicating which rows should be updated. + where : str or pa.compute.Expression, optional + A SQL predicate or pyarrow Expression indicating which rows should + be updated. conflict_retries : int, optional Number of times to retry the operation if there is contention. Default is 10. @@ -2191,6 +3120,10 @@ def update( the operation before giving up. At least one attempt will be made, regardless of how long it takes to complete. Subsequent attempts will be cancelled once this timeout is reached. Default is 30 seconds. + data_storage_version : str, optional + Output data file version, such as "2.2", "stable", or "next". If + omitted, use the dataset's default write version without changing it. + V1/V2 cross-family targets are rejected. Returns ------- @@ -2211,25 +3144,62 @@ def update( 1 4 b 2 5 c """ - if isinstance(where, pa.compute.Expression): - where = str(where) - return self._ds.update(updates, where, conflict_retries, retry_timeout) + encoded_where = ( + _serialize_expression(where, self._ds.schema) + if isinstance(where, pa.compute.Expression) + else where + ) + return self._ds.update( + updates, + encoded_where, + conflict_retries, + retry_timeout, + data_storage_version, + ) - def versions(self): + def versions(self) -> List[Version]: """ Return all versions in this dataset. """ versions = self._ds.versions() for v in versions: - # TODO: python datetime supports only microsecond precision. When a - # separate Version object is implemented, expose the precise timestamp - # (ns) to python. - ts_nanos = v["timestamp"] - v["timestamp"] = datetime.fromtimestamp(ts_nanos // 1e9) + timedelta( - microseconds=(ts_nanos % 1e9) // 1e3 - ) + _convert_version_timestamp(v) return versions + def get_version(self) -> Version: + """ + Return the currently checked out version, with its timestamp and the + summary of its manifest. + + The summary is in ``metadata``. It counts the fragments, data files, + deletion files, rows and bytes of the version, and is computed from the + manifest that is already loaded, so nothing is read from storage. + + Use :attr:`version` instead when only the version number is needed. + + Examples + -------- + >>> import lance + >>> import pyarrow as pa + >>> data = pa.table({"x": [1, 2, 3]}) + >>> dataset = lance.write_dataset(data, "memory://get_version") + >>> version = dataset.get_version() + >>> version["version"] + 1 + >>> version["metadata"]["total_rows"] + '3' + """ + return _convert_version_timestamp(self._ds.current_version()) + + def version_refs(self) -> List[VersionRef]: + """ + Return lightweight references to all attached versions in the current branch. + + Unlike :meth:`versions`, this does not read or deserialize every manifest. + Use :attr:`latest_version` instead when only the latest version is needed. + """ + return self._ds.version_refs() + @property def version(self) -> int: """ @@ -2305,7 +3275,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( @@ -2324,7 +3295,8 @@ def checkout_version( An integer specifies a version number in the current branch; a string specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies a version number in a specified branch. (None, None) means the latest - version_number on the main branch. + version_number on the default branch. ``("main", version)`` is an + explicit alias for the default branch in this reference context. Returns ------- @@ -2343,6 +3315,47 @@ def restore(self): """ self._ds.restore() + def base_paths(self) -> Dict[int, DatasetBasePath]: + """Return the base paths registered in the current dataset snapshot. + + The returned dictionary maps each base path ID to an independent + :class:`DatasetBasePath` object. It includes registered bases that are not + referenced by any data files. The primary dataset storage is not added to + the result unless it was explicitly registered as a base path. + + This method does not refresh the dataset to the latest version. Modifying + the returned dictionary does not modify the dataset, and previously + returned values do not change when the dataset is updated or checked out + at another version. The dictionary iteration order is unspecified. + + Returns + ------- + Dict[int, DatasetBasePath] + Registered base paths keyed by base path ID. Each value exposes + ``id``, ``name``, ``path``, and ``is_dataset_root`` as read-only + attributes. ``is_dataset_root`` describes the base's path layout; it + does not identify the dataset's current primary storage. Runtime + storage options are not included. + + Examples + -------- + >>> import lance + >>> import pyarrow as pa + >>> dataset = lance.write_dataset( + ... pa.table({"x": [1]}), + ... "memory://base-paths-example", + ... initial_bases=[ + ... lance.DatasetBasePath( + ... "memory://base-paths-data", name="data" + ... ) + ... ], + ... ) + >>> base_paths = dataset.base_paths() + >>> all(base_id == base.id for base_id, base in base_paths.items()) + True + """ + return self._ds.base_paths() + def add_bases( self, new_bases: list, transaction_properties: Optional[Dict[str, str]] = None ): @@ -2376,6 +3389,8 @@ def cleanup_old_versions( *, delete_unverified: bool = False, error_if_tagged_old_versions: bool = True, + delete_rate_limit: Optional[int] = None, + versions: Optional[List[int]] = None, ) -> CleanupStats: """ Cleans up old versions of the dataset. @@ -2396,7 +3411,7 @@ def cleanup_old_versions( ``retain_versions`` are not specified, this will default to two weeks. retain_versions: int, optional - Retain the last N versions of the dataset. + Retain the last N versions of the dataset. Must be positive. delete_unverified: bool, default False Files leftover from a failed transaction may appear to be part of an @@ -2415,8 +3430,19 @@ def cleanup_old_versions( tagged versions match the parameters. Otherwise, tagged versions will be ignored without any error and only untagged versions will be cleaned up. + + delete_rate_limit: int, optional + Maximum number of delete operations per second. When not set (default), + deletions run at full speed. Set this to a positive integer to avoid + hitting object store request rate limits (e.g. S3 HTTP 503 SlowDown). + For example, ``delete_rate_limit=100`` limits to 100 operations/second. + + versions: list[int], optional + Clean up only the specified dataset versions. The current version is + never removed, and tagged versions are still protected by + ``error_if_tagged_old_versions``. """ - if older_than is None and retain_versions is None: + if older_than is None and retain_versions is None and versions is None: older_than = timedelta(days=14) return self._ds.cleanup_old_versions( @@ -2424,8 +3450,229 @@ def cleanup_old_versions( retain_versions, delete_unverified, error_if_tagged_old_versions, + delete_rate_limit, + versions, + ) + + def explain_cleanup_old_versions( + self, + older_than: Optional[timedelta] = None, + retain_versions: Optional[int] = None, + *, + delete_unverified: bool = False, + error_if_tagged_old_versions: bool = True, + delete_rate_limit: Optional[int] = None, + versions: Optional[List[int]] = None, + include_files: bool = False, + max_files: int = 1000, + ) -> CleanupExplanation: + """ + Explain what :meth:`cleanup_old_versions` would remove without deleting files. + + Parameters + ---------- + + older_than: timedelta, optional + Only versions older than this would be removed. If ``older_than`` and + ``retain_versions`` are not specified, this will default to two weeks. + + retain_versions: int, optional + Retain the last N versions of the dataset. Must be positive. + + delete_unverified: bool, default False + Include unverified files that cleanup would remove when this is set. + + error_if_tagged_old_versions: bool, default True + If set to `True`, an exception will be raised if any tagged versions + match the parameters. Otherwise, tagged versions will be ignored. + + delete_rate_limit: int, optional + Accepted for parity with :meth:`cleanup_old_versions`; no deletes are + issued by explain. + + versions: list[int], optional + Explain cleanup only for the specified dataset versions. + + include_files: bool, default False + If `True`, include candidate files in the explanation up to + ``max_files`` entries. Aggregate stats always include all candidates. + + max_files: int, default 1000 + Maximum number of candidate files to include when ``include_files`` + is `True`. + """ + if older_than is None and retain_versions is None and versions is None: + older_than = timedelta(days=14) + if max_files <= 0: + raise ValueError("max_files must be positive") + + return self._ds.explain_cleanup_old_versions( + td_to_micros(older_than) if older_than else None, + retain_versions, + delete_unverified, + error_if_tagged_old_versions, + delete_rate_limit, + versions, + include_files, + max_files, ) + def _prepare_scalar_index_request( + self, + column: Union[str, List[str]], + index_type: Union[str, IndexConfig], + kwargs: dict, + ) -> tuple[str, str, str]: + """Validate and normalize a scalar-index request for the native + ``create_index`` call. + """ + if isinstance(column, str): + column = [column] + + if len(column) > 1: + raise NotImplementedError( + "Scalar indices currently only support a single column" + ) + + column = column[0] + lance_field = self._ds.lance_schema.field_case_insensitive(column) + + if isinstance(index_type, str): + index_type = index_type.upper() + if index_type not in [ + "BTREE", + "BITMAP", + "NGRAM", + "ZONEMAP", + "LABEL_LIST", + "INVERTED", + "FTS", + "BLOOMFILTER", + "RTREE", + ]: + raise NotImplementedError( + ( + 'Only "BTREE", "BITMAP", "NGRAM", "ZONEMAP", "LABEL_LIST", ' + '"INVERTED", "BLOOMFILTER" or "RTREE" are supported for ' + f"scalar columns. Received {index_type}", + ) + ) + + if lance_field is None: + if index_type in ["INVERTED", "FTS"]: + # Rust resolves public FTS paths through intervening list layers. + return column, index_type, index_type + raise KeyError(f"{column} not found in schema") + + field = lance_field.to_arrow() + + field_type = field.type + field_meta = field.metadata + if hasattr(field_type, "storage_type"): + field_type = field_type.storage_type + + if index_type in ["BTREE", "BITMAP"]: + if ( + not pa.types.is_integer(field_type) + and not pa.types.is_floating(field_type) + and not pa.types.is_boolean(field_type) + and not pa.types.is_string(field_type) + and not pa.types.is_large_string(field_type) + and not pa.types.is_binary(field_type) + and not pa.types.is_large_binary(field_type) + and not pa.types.is_temporal(field_type) + and not pa.types.is_decimal128(field_type) + and not pa.types.is_decimal256(field_type) + and not pa.types.is_fixed_size_binary(field_type) + ): + raise TypeError( + f"BTREE/BITMAP index column {column} must be int", + ", float, bool, str, large_str, binary, large_binary, " + "decimal, fixed-size-binary, or temporal", + ) + elif index_type == "LABEL_LIST": + if not ( + pa.types.is_list(field_type) or pa.types.is_large_list(field_type) + ): + raise TypeError( + f"LABEL_LIST index column {column} must be a list or large list" + ) + elif index_type == "NGRAM": + if not pa.types.is_string(field_type) and not pa.types.is_large_string( + field_type + ): + raise TypeError(f"NGRAM index column {column} must be a string") + elif index_type in ["INVERTED", "FTS"]: + value_type = field_type + if pa.types.is_list(field_type) or pa.types.is_large_list(field_type): + value_type = field_type.value_type + if ( + not pa.types.is_string(value_type) + and not pa.types.is_large_string(value_type) + and not ( + pa.types.is_large_binary(value_type) + and field_meta[b"ARROW:extension:name"] == b"lance.json" + ) + ): + raise TypeError( + f"INVERTED index column {column} must be string, large string" + f" or list of strings, or json, but got {value_type}" + ) + + return column, index_type, index_type + elif isinstance(index_type, IndexConfig): + logical_index_type = index_type.index_type.upper() + if lance_field is None and logical_index_type not in ["INVERTED", "FTS"]: + raise KeyError(f"{column} not found in schema") + parameters = dict(index_type.parameters) + if logical_index_type in ["INVERTED", "FTS"]: + parameters["document_granularity"] = kwargs["document_granularity"] + config = json.dumps(parameters) + kwargs["config"] = indices.IndexConfig(index_type.index_type, config) + return column, "scalar", logical_index_type + else: + raise Exception("index_type must be str or IndexConfig") + + @staticmethod + def _normalized_index_type( + index_type: Union[str, IndexConfig], + ) -> str: + if isinstance(index_type, IndexConfig): + index_type = index_type.index_type + return index_type.upper() + + @classmethod + def _is_segment_native_scalar_index_type( + cls, + index_type: Union[str, IndexConfig], + ) -> bool: + return cls._normalized_index_type(index_type) in { + "BTREE", + "BITMAP", + "INVERTED", + "FTS", + "NGRAM", + "RTREE", + "ZONEMAP", + "BLOOMFILTER", + "LABEL_LIST", + } + + @classmethod + def _requires_uncommitted_scalar_index( + cls, + index_type: Union[str, IndexConfig], + ) -> bool: + return cls._normalized_index_type(index_type) in { + "BTREE", + "BITMAP", + "NGRAM", + "RTREE", + "ZONEMAP", + "BLOOMFILTER", + "LABEL_LIST", + } + def create_scalar_index( self, column: str, @@ -2447,8 +3694,11 @@ def create_scalar_index( train: bool = True, fragment_ids: Optional[List[int]] = None, index_uuid: Optional[str] = None, - **kwargs, - ): + progress_callback: Optional[Callable[[IndexProgress], None]] = None, + format_version: Optional[Union[int, str]] = None, + document_granularity: DocumentGranularity = DocumentGranularity.ROW, + **kwargs: Any, + ) -> None: """Create a scalar index on a column. Scalar indices, like vector indices, can be used to speed up scans. A scalar @@ -2481,7 +3731,7 @@ def create_scalar_index( ) - There are 5 types of scalar indices available today. + Lance supports the following scalar index types: * ``BTREE``. The most common type is ``BTREE``. This index is inspired by the btree data structure although only the first few layers of the btree @@ -2513,9 +3763,21 @@ def create_scalar_index( but can only handle filters with equals and not equals and may require more I/O than a btree or bitmap index``` - Note that the ``LANCE_BYPASS_SPILLING`` environment variable can be used to - bypass spilling to disk. Setting this to true can avoid memory exhaustion - issues (see https://github.com/apache/datafusion/issues/10073 for more info). + Index training sorts can spill to disk under memory pressure. + ``LANCE_MEM_POOL_SIZE`` configures this pool in bytes; it is not a limit + on the process's total memory usage. Spilling still requires memory for + individual input batches and for sorting and merging them, so large + batches or a small pool can cause memory reservation failures. + + Setting ``LANCE_BYPASS_SPILLING`` to any value (including ``0`` or + ``false``) bypasses the bounded memory pool for these sorts. Sorting then + stays in memory, ignoring ``LANCE_MEM_POOL_SIZE``. This can avoid pool + reservation failures but increases memory use and can exhaust system + memory; use it only when the sort fits in available memory. The historical + SortExec allocation issues in + https://github.com/apache/datafusion/issues/10073 were fixed upstream by + https://github.com/apache/datafusion/pull/14644; that fix does not remove + the batch-size and memory-pool constraints above. **Experimental API** @@ -2523,7 +3785,8 @@ def create_scalar_index( ---------- column : str The column to be indexed. Must be a boolean, integer, float, - or string column. + string, binary, decimal, fixed-size-binary, or + supported temporal column. index_type : str The type of the index. One of ``"BTREE"``, ``"BITMAP"``, ``"LABEL_LIST"``, ``"NGRAM"``, ``"ZONEMAP"``, ``"INVERTED"``, @@ -2540,14 +3803,30 @@ 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 optionally merged with + other segments and committed later with + ``commit_existing_index_segments(...)``. 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. + progress_callback : callable, optional + A callback that receives :class:`lance.progress.IndexProgress` events while + the index is being built. + format_version: int or str, optional + This is for the ``INVERTED`` / ``FTS`` index. Explicit on-disk FTS + format version to write when creating a new index. Accepts ``1``, + ``2``, ``3``, ``"v1"``, ``"v2"``, or ``"v3"``. + If unset, Lance uses ``LANCE_FTS_FORMAT_VERSION`` when present and + otherwise writes v2 for text analysis with ``block_size=128`` and + v3 for code analysis or ``block_size=256``. + + document_granularity: DocumentGranularity, default ROW + This is for the ``INVERTED`` / ``FTS`` index. ``ROW`` treats all + selected text in one dataset row as one document. ``LIST_ELEMENT`` + treats each element of the deepest list on ``column`` as one document. with_position: bool, default False This is for the ``INVERTED`` index. If True, the index will store the @@ -2555,16 +3834,36 @@ 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. + block_size: int, default 128 + This is for the ``INVERTED`` index. Number of documents per compressed + posting block. Must be one of ``128`` or ``256``. + ``block_size=256`` is experimental and may introduce breaking changes. + Use ``128`` when stable compatibility with the legacy posting layout is + required. + 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: * "simple": splits tokens on whitespace and punctuation. * "whitespace": splits tokens on whitespace. * "raw": no tokenization. + * "ngram": produces character N-grams for substring search. + * "icu": ICU dictionary-based Unicode word segmentation. + * "icu/split": ICU segmentation with simple-style delimiter splitting. language: str, default "English" This is for the ``INVERTED`` index. The language for stemming and stop words. This is only used when `stem` or `remove_stop_words` is true @@ -2574,10 +3873,10 @@ def create_scalar_index( lower_case: bool, default True This is for the ``INVERTED`` index. If True, the index will convert all text to lowercase. - stem: bool, default True + stem: bool, default True (False for the "ngram" tokenizer) This is for the ``INVERTED`` index. If True, the index will stem the tokens. - remove_stop_words: bool, default True + remove_stop_words: bool, default True (False for the "ngram" tokenizer) This is for the ``INVERTED`` index. If True, the index will remove stop words. custom_stop_words: Optional[List[str]], default None @@ -2598,7 +3897,7 @@ def create_scalar_index( import lance dataset = lance.dataset("/tmp/images.lance") - dataset.create_index( + dataset.create_scalar_index( "category", "BTREE", ) @@ -2623,107 +3922,35 @@ def create_scalar_index( ``MaterializeIndex`` operator. """ - if isinstance(column, str): - column = [column] + if not isinstance(document_granularity, DocumentGranularity): + raise TypeError( + "document_granularity must be a lance.query.DocumentGranularity" + ) + kwargs["document_granularity"] = document_granularity.value + column, index_type, logical_index_type = self._prepare_scalar_index_request( + column, index_type, kwargs + ) - if len(column) > 1: - raise NotImplementedError( - "Scalar indices currently only support a single column" + if fragment_ids is not None and self._requires_uncommitted_scalar_index( + logical_index_type + ): + raise ValueError( + f"{logical_index_type} distributed indexing uses " + "create_index_uncommitted(..., " + f'index_type="{logical_index_type}", fragment_ids=...)' ) + # Add fragment_ids and index_uuid to kwargs if provided + if fragment_ids is not None: + 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 + if format_version is not None: + kwargs["format_version"] = format_version + self._ds.create_index([column], index_type, name, replace, train, None, kwargs) - column = column[0] - lance_field = self._ds.lance_schema.field_case_insensitive(column) - if lance_field is None: - raise KeyError(f"{column} not found in schema") - - # TODO: Add documentation of IndexConfig approach for creating - # indexes that need parameterization - if isinstance(index_type, str): - index_type = index_type.upper() - if index_type not in [ - "BTREE", - "BITMAP", - "NGRAM", - "ZONEMAP", - "LABEL_LIST", - "INVERTED", - "FTS", - "BLOOMFILTER", - "RTREE", - ]: - raise NotImplementedError( - ( - 'Only "BTREE", "BITMAP", "NGRAM", "ZONEMAP", "LABEL_LIST", ' - '"INVERTED", "BLOOMFILTER" or "RTREE" are supported for ' - f"scalar columns. Received {index_type}", - ) - ) - - field = lance_field.to_arrow() - - field_type = field.type - field_meta = field.metadata - if hasattr(field_type, "storage_type"): - field_type = field_type.storage_type - - if index_type in ["BTREE", "BITMAP", "ZONEMAP"]: - if ( - not pa.types.is_integer(field_type) - and not pa.types.is_floating(field_type) - and not pa.types.is_boolean(field_type) - and not pa.types.is_string(field_type) - and not pa.types.is_temporal(field_type) - and not pa.types.is_fixed_size_binary(field_type) - ): - raise TypeError( - f"BTREE/BITMAP index column {column} must be int", - ", float, bool, str, fixed-size-binary, or temporal ", - ) - elif index_type == "LABEL_LIST": - if not pa.types.is_list(field_type): - raise TypeError(f"LABEL_LIST index column {column} must be a list") - elif index_type == "NGRAM": - if not pa.types.is_string(field_type) and not pa.types.is_large_string( - field_type - ): - raise TypeError(f"NGRAM index column {column} must be a string") - elif index_type in ["INVERTED", "FTS"]: - value_type = field_type - if pa.types.is_list(field_type) or pa.types.is_large_list(field_type): - value_type = field_type.value_type - if ( - not pa.types.is_string(value_type) - and not pa.types.is_large_string(value_type) - and not ( - pa.types.is_large_binary(value_type) - and field_meta[b"ARROW:extension:name"] == b"lance.json" - ) - ): - raise TypeError( - f"INVERTED index column {column} must be string, large string" - f" or list of strings, or json, but got {value_type}" - ) - - if pa.types.is_duration(field_type): - raise TypeError( - f"Scalar index column {column} cannot currently be a duration" - ) - elif isinstance(index_type, IndexConfig): - config = json.dumps(index_type.parameters) - kwargs["config"] = indices.IndexConfig(index_type.index_type, config) - index_type = "scalar" - else: - raise Exception("index_type must be str or IndexConfig") - - # Add fragment_ids and index_uuid to kwargs if provided - if fragment_ids is not None: - kwargs["fragment_ids"] = fragment_ids - if index_uuid is not None: - kwargs["index_uuid"] = index_uuid - - 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, @@ -2742,192 +3969,29 @@ 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, + streaming_sample_rate: Optional[int] = None, + streaming_coreset_rate: Optional[int] = None, + streaming_refine_passes: Optional[int] = None, + skip_transpose: bool = False, + rabitq_model: Optional[str] = None, + 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_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): @@ -2975,6 +4039,17 @@ def create_index( f"got {field.type.value_type}" ) + if index_cache_size is not None: + # The parameter has never reached Rust: index building does not use + # the index cache, and the cache is sized on the dataset or session. + warnings.warn( + "The 'index_cache_size' parameter of create_index is ignored. " + "The index cache is sized on the dataset, via " + "lance.dataset(..., index_cache_size_bytes=...) or a Session.", + DeprecationWarning, + stacklevel=3, + ) + if not isinstance(metric, str) or metric.lower() not in [ "l2", "cosine", @@ -3035,13 +4110,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 ( @@ -3121,6 +4204,12 @@ def create_index( kwargs["num_partitions"] = num_partitions if target_partition_size is not None: kwargs["target_partition_size"] = target_partition_size + if streaming_sample_rate is not None: + kwargs["streaming_sample_rate"] = streaming_sample_rate + if streaming_coreset_rate is not None: + kwargs["streaming_coreset_rate"] = streaming_coreset_rate + if streaming_refine_passes is not None: + kwargs["streaming_refine_passes"] = streaming_refine_passes if (precomputed_partition_dataset is not None) and (ivf_centroids is None): raise ValueError( @@ -3162,14 +4251,26 @@ def create_index( if _check_for_numpy(ivf_centroids) and isinstance( ivf_centroids, np.ndarray ): - if ( - len(ivf_centroids.shape) != 2 - or ivf_centroids.shape[0] != num_partitions - ): + if len(ivf_centroids.shape) != 2: raise ValueError( f"Ivf centroids must be 2D array: (clusters, dim), " f"got {ivf_centroids.shape}" ) + if ivf_centroids.shape[0] == 0: + # num_partitions was derived from shape[0] above, and + # zero partitions panics in the Rust residual step. + raise ValueError( + "Ivf centroids must have at least one cluster, " + f"got {ivf_centroids.shape}" + ) + if ( + num_partitions is not None + and ivf_centroids.shape[0] != num_partitions + ): + raise ValueError( + f"Ivf centroids has {ivf_centroids.shape[0]} clusters, " + f"but num_partitions={num_partitions}" + ) if ivf_centroids.dtype not in [np.float16, np.float32, np.float64]: raise TypeError( "IVF centroids must be floating number" @@ -3189,65 +4290,497 @@ def create_index( ) kwargs["num_sub_vectors"] = num_sub_vectors - # Always attach PQ codebook if provided (global training invariant) - if pq_codebook is not None: - # User provided PQ codebook - if _check_for_numpy(pq_codebook) and isinstance( - pq_codebook, np.ndarray - ): - if ( - len(pq_codebook.shape) != 3 - or pq_codebook.shape[0] != num_sub_vectors - or pq_codebook.shape[1] != 256 - ): - raise ValueError( - f"PQ codebook must be 3D array: (sub_vectors, 256, dim), " - f"got {pq_codebook.shape}" - ) - if pq_codebook.dtype not in [np.float16, np.float32, np.float64]: - raise TypeError( - "PQ codebook must be floating number" - + f"got {pq_codebook.dtype}" - ) - values = pa.array(pq_codebook.reshape(-1)) - pq_codebook = pa.FixedSizeListArray.from_arrays( - values, pq_codebook.shape[2] - ) - pq_codebook_batch = pa.RecordBatch.from_arrays( - [pq_codebook], ["_pq_codebook"] - ) - kwargs["pq_codebook"] = pq_codebook_batch + # Always attach PQ codebook if provided (global training invariant) + if pq_codebook is not None: + # User provided PQ codebook + if _check_for_numpy(pq_codebook) and isinstance( + pq_codebook, np.ndarray + ): + num_bits = kwargs.get("num_bits", 8) + expected_centroids = 2**num_bits + if ( + len(pq_codebook.shape) != 3 + or pq_codebook.shape[0] != num_sub_vectors + or pq_codebook.shape[1] != expected_centroids + ): + raise ValueError( + "PQ codebook must be 3D array: " + f"(sub_vectors, {expected_centroids}, dim) " + f"for num_bits={num_bits}, " + f"got {pq_codebook.shape}" + ) + if pq_codebook.dtype not in [np.float16, np.float32, np.float64]: + raise TypeError( + "PQ codebook must be floating number" + + f"got {pq_codebook.dtype}" + ) + values = pa.array(pq_codebook.reshape(-1)) + pq_codebook = pa.FixedSizeListArray.from_arrays( + values, pq_codebook.shape[2] + ) + pq_codebook_batch = pa.RecordBatch.from_arrays( + [pq_codebook], ["_pq_codebook"] + ) + kwargs["pq_codebook"] = pq_codebook_batch + + if shuffle_partition_batches is not None: + kwargs["shuffle_partition_batches"] = shuffle_partition_batches + if shuffle_partition_concurrency is not None: + kwargs["shuffle_partition_concurrency"] = shuffle_partition_concurrency + + if skip_transpose: + kwargs["skip_transpose"] = True + + if rabitq_model is not None: + kwargs["rabitq_model"] = rabitq_model + + # Add fragment_ids and index_uuid to kwargs if provided for + # distributed indexing + if fragment_ids is not None: + kwargs["fragment_ids"] = fragment_ids + if index_uuid is not None: + kwargs["index_uuid"] = index_uuid + + timers["final_create_index:start"] = time.time() + index = self._ds.create_index( + column, index_type, name, replace, train, storage_options, kwargs + ) + timers["final_create_index:end"] = time.time() + final_create_index_time = ( + timers["final_create_index:end"] - timers["final_create_index:start"] + ) + LOGGER.info("Final create_index rust time: %ss", final_create_index_time) + # Save disk space + if "precomputed_shuffle_buffers_path" in kwargs.keys() and os.path.exists( + kwargs["precomputed_shuffle_buffers_path"] + ): + LOGGER.info( + "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, + streaming_sample_rate: Optional[int] = None, + streaming_coreset_rate: Optional[int] = None, + streaming_refine_passes: Optional[int] = None, + skip_transpose: bool = False, + progress_callback: Optional[Callable[[IndexProgress], None]] = None, + **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_clusters x dimension`` array of existing K-mean centroids + for IVF clustering. The row count determines the number of IVF + partitions. 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 ^ num_bits) x + (dimensions // num_sub_vectors)`` array of K-mean centroids for PQ + codebook. ``num_bits`` defaults to 8. + 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 + Deprecated and ignored. Index building does not read the index cache; + size it on the dataset with + ``lance.dataset(..., index_cache_size_bytes=...)`` or on a ``Session``. + 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 one segment but does not commit the index + to the dataset. The returned metadata can be passed to + ``merge_existing_index_segments(...)`` if grouping is needed and then + committed with ``commit_existing_index_segments(...)``. + + Vector segments support both shared and independent model scopes. If + the caller provides the same IVF centroids, and for IVF_PQ the same + PQ codebook, to each worker, the resulting segments share model + semantics and are suitable for workflows that physically merge + compatible segments. If those artifacts are omitted, each segment can + train its own IVF/PQ model for its assigned fragments. Such segments + can be committed together and are queried independently by segment + UUID; partition ids are interpreted within each segment's own model. + Keep independently trained segments as separate physical segments + unless the merge workflow can preserve or reconcile the model state. + 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. + Otherwise, the target partition size will be set by index type. + streaming_sample_rate : int, optional + If set below ``sample_rate``, IVF kmeans trains incrementally and samples + at most ``num_partitions * streaming_sample_rate`` vectors per step. For + ``num_partitions > 256``, chunks are compressed into a weighted coreset + and final centroids are trained with weighted hierarchical kmeans. + streaming_coreset_rate : int, optional + If set, controls the final weighted coreset budget independently from + ``streaming_sample_rate``. The budget is + ``num_partitions * streaming_coreset_rate``. + streaming_refine_passes : int, optional + Number of extra streaming Lloyd refinement passes to run after streaming + coreset training. Each pass loads at most + ``num_partitions * streaming_sample_rate`` raw vectors at a time. + 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 5. + + 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" + ) - if shuffle_partition_batches is not None: - kwargs["shuffle_partition_batches"] = shuffle_partition_batches - if shuffle_partition_concurrency is not None: - kwargs["shuffle_partition_concurrency"] = shuffle_partition_concurrency + 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. - # Add fragment_ids and index_uuid to kwargs if provided for - # distributed indexing - if fragment_ids is not None: - kwargs["fragment_ids"] = fragment_ids - if index_uuid is not None: - kwargs["index_uuid"] = index_uuid + References + ---------- + * `Faiss Index `_ + * IVF introduced in `Video Google: a text retrieval approach to object matching + in videos `_ + * `Product quantization for nearest neighbor search + `_ - timers["final_create_index:start"] = time.time() - self._ds.create_index( - column, index_type, name, replace, train, storage_options, kwargs + """ + if progress_callback is not None: + kwargs["progress_callback"] = progress_callback + 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, + streaming_sample_rate=streaming_sample_rate, + streaming_coreset_rate=streaming_coreset_rate, + streaming_refine_passes=streaming_refine_passes, + skip_transpose=skip_transpose, + require_commit=True, + **kwargs, ) - timers["final_create_index:end"] = time.time() - final_create_index_time = ( - timers["final_create_index:end"] - timers["final_create_index:start"] + return self + + def create_index_uncommitted( + self, + column: Union[str, List[str]], + index_type: Union[str, IndexConfig], + 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, + streaming_sample_rate: Optional[int] = None, + streaming_coreset_rate: Optional[int] = None, + streaming_refine_passes: Optional[int] = None, + skip_transpose: bool = False, + rabitq_model: Optional[str] = None, + **kwargs, + ) -> Index: + """ + Create one segment without publishing it and return its metadata. + + This is the public distributed-build API for vector, BTREE scalar, + canonical bitmap scalar, INVERTED scalar, NGRAM scalar, RTREE scalar, + ZONEMAP scalar, BLOOMFILTER scalar, and LABEL_LIST scalar 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 that worker's + assigned ``fragment_ids`` + 2. collect the returned :class:`Index` objects + 3. optionally merge caller-defined groups with + :meth:`merge_existing_index_segments` + 4. commit the final segment list with + :meth:`commit_existing_index_segments` + + BTREE, BITMAP, INVERTED, NGRAM, RTREE, ZONEMAP, BLOOMFILTER, and + LABEL_LIST segments may be merged with + :meth:`merge_existing_index_segments` before commit. NGRAM segments + built before a deferred compaction must be merged before commit so + their postings can be rebuilt against current row addresses. + Parameters are the same as :meth:`create_index`, with one additional + requirement: + + - ``fragment_ids`` must be provided + - Vector segments support both shared and independent model scopes. Pass + the same IVF centroids, and for IVF_PQ the same PQ codebook, to each + worker when segments need shared model semantics or physical merge + compatibility. If these artifacts are omitted, each segment may train + its own IVF/PQ model and can be committed with other segments as one + logical index; query execution searches each segment by UUID and + interprets partition ids within that segment. Keep independently + trained segments as separate physical segments unless the merge + workflow can preserve or reconcile the model state. + - ``rabitq_model`` (``IVF_RQ`` only): a JSON string produced by + ``lance.lance.indices.build_rq_model``. It must be identical across all + workers for their segments to be mergeable, since it pins the RaBitQ + rotation so every segment rotates vectors the same way. If omitted, each + call generates its own random rotation, which is only safe for a single, + non-merged segment. + + Returns + ------- + Index + Metadata for the segment that was written by this call. + """ + is_scalar_segment_request = self._is_segment_native_scalar_index_type( + index_type ) - LOGGER.info("Final create_index rust time: %ss", final_create_index_time) - # Save disk space - if "precomputed_shuffle_buffers_path" in kwargs.keys() and os.path.exists( - kwargs["precomputed_shuffle_buffers_path"] - ): - LOGGER.info( - "Temporary shuffle buffers stored at %s, you may want to delete it.", - kwargs["precomputed_shuffle_buffers_path"], + if is_scalar_segment_request: + if fragment_ids is None: + raise ValueError( + "create_index_uncommitted requires fragment_ids " + "for distributed index build" + ) + + kwargs = dict(kwargs) + column, rust_index_type, _ = self._prepare_scalar_index_request( + column, index_type, kwargs ) - return self + kwargs["fragment_ids"] = fragment_ids + if index_uuid is not None: + kwargs["index_uuid"] = index_uuid + + return self._ds.create_index( + [column], + rust_index_type, + name, + replace, + train, + storage_options, + kwargs, + ) + + 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, + streaming_sample_rate=streaming_sample_rate, + streaming_coreset_rate=streaming_coreset_rate, + streaming_refine_passes=streaming_refine_passes, + skip_transpose=skip_transpose, + rabitq_model=rabitq_model, + require_commit=False, + **kwargs, + ) def drop_index(self, name: str): """ @@ -3255,43 +4788,67 @@ def drop_index(self, name: str): Note: Indices are dropped by "index name". This is not the same as the field name. If you did not specify a name when you created the index then a name was - generated for you. You can use the `list_indices` method to get the names of - the indices. + generated for you. You can use the `describe_indices` method to get the names + of the indices. """ return self._ds.drop_index(name) - def prewarm_index(self, name: str): + def prewarm_index( + self, + name: str, + *, + with_position: bool = False, + index_segments: Optional[Iterable[Union[str, uuid.UUID]]] = None, + ): """ Prewarm an index - This will load the entire index into memory. This can help avoid cold start - issues with index queries. If the index does not fit in the index cache, then - this will result in wasted I/O. + By default, this will load the entire index into memory. This can help + avoid cold start issues with index queries. If the index does not fit in + the index cache, then this will result in wasted I/O. + + Use ``session().index_cache_size_bytes()`` before and after prewarm to + inspect how much the index cache grew. Parameters ---------- name: str The name of the index to prewarm. - """ - return self._ds.prewarm_index(name) + 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. + index_segments: iterable of str or uuid.UUID, default None + If specified, prewarm only these physical index segment UUIDs from the + named logical index. Use :meth:`describe_indices` to inspect logical + indices and obtain segment UUIDs from ``IndexDescription.segments``. + """ + return self._ds.prewarm_index( + name, + with_position=with_position, + index_segments=_normalize_index_segment_ids(index_segments), + ) def merge_index_metadata( self, index_uuid: str, index_type: str, batch_readhead: Optional[int] = None, + progress_callback: Optional[Callable[[IndexProgress], None]] = 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 and Bitmap distributed indexing no longer use this API. For + those index families, 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. - 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. @@ -3299,13 +4856,16 @@ 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. + progress_callback: callable, optional + A callback that receives :class:`lance.progress.IndexProgress` events while + metadata is being merged. """ # Normalize type t = index_type.upper() @@ -3317,15 +4877,39 @@ 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: + """ + Merge one caller-defined group of existing uncommitted segments. + """ + return self._ds.merge_existing_index_segments(segments) + + def commit_existing_index_segments( + self, index_name: str, column: str, segments: List[Union[IndexSegment, Index]] + ) -> 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. """ return self._ds.session() + @staticmethod + def _inherit_base_store_params( + dataset_or_uri: Union[str, Path, LanceDataset, None], + base_store_params: Optional[Dict[str, Dict[str, str]]], + ) -> Optional[Dict[str, Dict[str, str]]]: + if base_store_params is None and isinstance(dataset_or_uri, LanceDataset): + return dataset_or_uri._base_store_params + return base_store_params + @staticmethod def _commit( base_uri: Union[str, Path], @@ -3346,12 +4930,17 @@ 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_client: Optional["LanceNamespace"] = None, + table_id: Optional[List[str]] = None, + namespace_client_managed_versioning: bool = False, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, + commit_timeout: Optional[timedelta] = _DEFAULT_COMMIT_TIMEOUT, ) -> LanceDataset: """Create a new version of dataset @@ -3391,8 +4980,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 @@ -3413,6 +5000,23 @@ def commit( commit_message: str, optional A message to associate with this commit. This message will be stored in the dataset's metadata and can be retrieved using read_transaction(). + enable_stable_row_ids: bool, optional + If True, enables stable row IDs when creating a new dataset. Stable + 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_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_client. + base_store_params : dict of str to dict, optional + Runtime-only object store parameters keyed by base path URI. + commit_timeout : timedelta, optional + Maximum time to wait for the commit operation (including retries on + conflict) to complete. Defaults to 30 minutes. Pass ``None`` to + disable the timeout entirely. Must be a positive duration. Returns ------- @@ -3440,6 +5044,10 @@ def commit( 2 3 c 3 4 d """ + base_store_params = LanceDataset._inherit_base_store_params( + base_uri, base_store_params + ) + if isinstance(base_uri, Path): base_uri = str(base_uri) elif isinstance(base_uri, LanceDataset): @@ -3466,6 +5074,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( @@ -3478,10 +5089,14 @@ 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_client=namespace_client, + table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, + commit_timeout=commit_timeout, ) elif isinstance(operation, LanceOperation.BaseOperation): new_ds = _Dataset.commit( @@ -3490,11 +5105,15 @@ 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_client=namespace_client, + table_id=table_id, + namespace_client_managed_versioning=namespace_client_managed_versioning, + commit_timeout=commit_timeout, ) else: raise TypeError( @@ -3504,7 +5123,10 @@ def commit( ds = LanceDataset.__new__(LanceDataset) ds._storage_options = storage_options - ds._storage_options_provider = storage_options_provider + ds._base_store_params = base_store_params + 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 @@ -3517,10 +5139,11 @@ 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, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, + commit_timeout: Optional[timedelta] = _DEFAULT_COMMIT_TIMEOUT, ) -> BulkCommitResult: """Create a new version of dataset with multiple transactions. @@ -3546,8 +5169,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 @@ -3565,6 +5186,12 @@ def commit_batch( the future. max_retries : int The maximum number of retries to perform when committing the dataset. + base_store_params : dict of str to dict, optional + Runtime-only object store parameters keyed by base path URI. + commit_timeout : timedelta, optional + Maximum time to wait for the commit operation (including retries on + conflict) to complete. Defaults to 30 minutes. Pass ``None`` to + disable the timeout entirely. Must be a positive duration. Returns ------- @@ -3574,6 +5201,10 @@ def commit_batch( merged: Transaction The merged transaction that was applied to the dataset. """ + base_store_params = LanceDataset._inherit_base_store_params( + dest, base_store_params + ) + if isinstance(dest, Path): dest = str(dest) elif isinstance(dest, LanceDataset): @@ -3594,16 +5225,19 @@ 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, + commit_timeout=commit_timeout, ) ds = LanceDataset.__new__(LanceDataset) ds._ds = new_ds ds._uri = new_ds.uri ds._storage_options = storage_options - ds._storage_options_provider = storage_options_provider + ds._base_store_params = base_store_params + ds._namespace_client = None + ds._table_id = None + ds._namespace_client_managed_versioning = False ds._default_scan_options = None ds._read_params = None return BulkCommitResult( @@ -3641,7 +5275,8 @@ def shallow_clone( An integer specifies a version number in the current branch; a string specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies a version number in a specified branch. (None, None) means the latest - version_number on the main branch. + version_number on the default branch. ``("main", version)`` is an + explicit alias for the default branch in this reference context. storage_options : dict, optional Object store configuration for the new dataset (e.g., credentials, endpoints). If not specified, the storage options of the source dataset @@ -3664,6 +5299,47 @@ def shallow_clone( # Open and return a fresh dataset at the target URI to avoid manual overrides return LanceDataset(target_uri, storage_options=storage_options, **kwargs) + def deep_clone( + self, + target_path: str | Path, + reference: int | str | Tuple[Optional[str], Optional[int]], + storage_options: Optional[Dict[str, str]] = None, + **kwargs, + ) -> "LanceDataset": + """ + Deep clone the specified version into a new dataset at target_path. + + This operation copies the data, deletion, and index files referenced by + the selected version before committing the destination dataset. + + Parameters + ---------- + target_path : str or Path + The URI or filesystem path to clone the dataset into. + reference : int, str or Tuple[Optional[str], Optional[int]] + An integer specifies a version number in the current branch; a string + specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies + a version number in a specified branch. + storage_options : dict, optional + Object store configuration for the destination dataset. If omitted, + the source dataset's storage options are used. + + Returns + ------- + LanceDataset + The deep-cloned dataset. + """ + if isinstance(target_path, Path): + target_uri = os.fspath(target_path) + else: + target_uri = target_path + + if storage_options is None: + storage_options = self._storage_options + self._ds.deep_clone(target_uri, reference, storage_options) + + return LanceDataset(target_uri, storage_options=storage_options, **kwargs) + def migrate_manifest_paths_v2(self): """ Migrate the manifest paths to the new format. @@ -3868,6 +5544,31 @@ def drop( storage_options: Optional[Dict[str, str]] = None, ignore_not_found: Optional[bool] = None, ) -> None: + """Delete a dataset and everything under ``base_uri``. + + To limit the damage a mistyped or misconfigured path can do, ``base_uri`` + must be a dataset root, meaning it holds a manifest that can be read, or a + namespace declare/deregister marker. Anything else raises + :class:`ValueError`, including a path that holds only data files or only + unreadable manifests: such leftovers need an explicit storage-level delete. + + Note that a path which passes this check is deleted in full, including any + unmanaged files kept next to the dataset. + + Parameters + ---------- + base_uri : str or Path + Root of the dataset to delete. + storage_options : optional, dict + Extra options for the storage backend. + ignore_not_found : optional, bool + If True, return successfully when ``base_uri`` does not exist. + + Raises + ------ + ValueError + If ``base_uri`` is not a Lance dataset root. + """ _Dataset.drop(str(base_uri), storage_options, ignore_not_found=ignore_not_found) def get_ivf_model(self, index_name: str): @@ -3888,9 +5589,19 @@ def _default_vector_index_for_column(self, column: str) -> str: Raises KeyError if no such index exists. """ - for meta in self.list_indices(): - if column in meta["fields"] and meta["type"].startswith("IVF"): - return meta["name"] + # Resolve column path to field id for describe_indices matching. + lance_field = self._ds.lance_schema.field_case_insensitive(column) + if lance_field is None: + raise KeyError(f"No IVF index for column '{column}'") + field_id = lance_field.id() + + indices = self.describe_indices() + for idx in indices: + if field_id in idx.fields: + # Use index_stats to get the concrete IVF subtype. + index_type = self.stats.index_stats(idx.name).get("index_type", "") + if index_type.startswith("IVF"): + return idx.name raise KeyError(f"No IVF index for column '{column}'") def centroids( @@ -3920,6 +5631,273 @@ def centroids( return ivf.centroids + def initialize_mem_wal( + self, + *, + maintained_indexes: Optional[List[str]] = None, + bucket_column: Optional[str] = None, + num_buckets: Optional[int] = None, + identity_column: Optional[str] = None, + unsharded: bool = False, + durable_write: Optional[bool] = None, + max_wal_buffer_size: Optional[int] = None, + max_wal_flush_interval_ms: Optional[int] = None, + max_memtable_size: Optional[int] = None, + max_memtable_rows: Optional[int] = None, + max_memtable_batches: Optional[int] = None, + max_unflushed_memtable_bytes: Optional[int] = None, + manifest_scan_batch_size: Optional[int] = None, + backpressure_log_interval_ms: Optional[int] = None, + stats_log_interval_ms: Optional[int] = None, + hnsw_params: Optional[Dict[str, Dict[str, int]]] = None, + ) -> None: + """Initialize MemWAL on this dataset. + + Must be called once before any calls to `mem_wal_writer`. Append-only + tables may omit primary-key metadata; primary keys are only required + for primary-key lookup and last-write-wins deduplication workflows. + + At most one sharding mode may be selected: bucket sharding + (``bucket_column`` + ``num_buckets``), identity sharding + (``identity_column``), or ``unsharded``. With none selected, shards are + managed manually by passing shard IDs to `mem_wal_writer`. + + Any writer-configuration keyword arguments (``durable_write``, + ``max_memtable_size``, ``max_wal_flush_interval_ms``, etc. — the same + knobs accepted by `mem_wal_writer`) are recorded as the default + `~lance.mem_wal.ShardWriter` configuration in the MemWAL index, so + every writer starts from the same defaults. + + Parameters + ---------- + maintained_indexes : list of str, optional + Names of existing indexes to keep updated as data is written + through the MemWAL. Must reference indexes that already exist. + Omitted (the default) keeps every index on the table updated, + including ones created later; an empty list keeps none. + hnsw_params : dict, optional + Per-index HNSW build-parameter overrides recorded as writer-config + defaults, keyed by maintained vector index name. Each value is a dict + with any of ``num_edges`` (graph degree; level 0 keeps + ``2 * num_edges``, equivalent to FAISS's ``M``), ``ef_construction``, + and ``max_level``. Without an override an index uses the default + parameters. These are writer config — they affect the MemTable a + writer builds and may be overridden per-writer in + :meth:`mem_wal_writer`. + bucket_column : str, optional + With ``num_buckets``, hash-bucket writes by this scalar column. + num_buckets : int, optional + Number of hash buckets (shards). Required with ``bucket_column``. + identity_column : str, optional + Shard by the raw value of this scalar column. + unsharded : bool, default False + Route every write to a single shard. + + Raises + ------ + IOError + - An entry in *maintained_indexes* does not exist on the dataset. + - MemWAL has already been initialized on this dataset. + ValueError + More than one sharding mode was selected, or bucket sharding was + given only one of ``bucket_column`` / ``num_buckets``. + """ + self._ds.initialize_mem_wal( + maintained_indexes=maintained_indexes, + bucket_column=bucket_column, + num_buckets=num_buckets, + identity_column=identity_column, + unsharded=unsharded, + durable_write=durable_write, + max_wal_buffer_size=max_wal_buffer_size, + max_wal_flush_interval_ms=max_wal_flush_interval_ms, + max_memtable_size=max_memtable_size, + max_memtable_rows=max_memtable_rows, + max_memtable_batches=max_memtable_batches, + max_unflushed_memtable_bytes=max_unflushed_memtable_bytes, + manifest_scan_batch_size=manifest_scan_batch_size, + backpressure_log_interval_ms=backpressure_log_interval_ms, + stats_log_interval_ms=stats_log_interval_ms, + hnsw_params=hnsw_params, + ) + + def mem_wal_index_details(self) -> Optional[Dict[str, Any]]: + """Return the MemWAL index details, or ``None`` if not initialized. + + Returns + ------- + dict or None + A dict with ``num_shards``, ``maintained_indexes``, + ``writer_config_defaults``, and ``sharding_specs``, or ``None`` when + MemWAL has not been initialized on this dataset. + """ + return self._ds.mem_wal_index_details() + + def mem_wal_writer( + self, + shard_id: str, + *, + durable_write: Optional[bool] = None, + max_wal_buffer_size: Optional[int] = None, + max_wal_flush_interval_ms: Optional[int] = None, + max_memtable_size: Optional[int] = None, + max_memtable_rows: Optional[int] = None, + max_memtable_batches: Optional[int] = None, + max_unflushed_memtable_bytes: Optional[int] = None, + manifest_scan_batch_size: Optional[int] = None, + backpressure_log_interval_ms: Optional[int] = None, + stats_log_interval_ms: Optional[int] = None, + hnsw_params: Optional[Dict[str, Dict[str, int]]] = None, + ) -> "mem_wal.ShardWriter": + """Get a ShardWriter for the specified shard. + + `initialize_mem_wal` must be called before using this method. + Each shard is an independent write path; use different shard IDs + to achieve parallel ingestion without writer contention. + + Parameters + ---------- + shard_id : str + UUID string identifying the write shard (e.g. + ``str(uuid.uuid4())``). + durable_write : bool, optional + Whether to fsync WAL writes (default: ``True``). + max_wal_buffer_size : int, optional + Maximum WAL buffer size in bytes (default: 10 MB). + max_wal_flush_interval_ms : int, optional + Maximum WAL flush interval in milliseconds (default: 100). + max_memtable_size : int, optional + Maximum MemTable size in bytes (default: 256 MB). + max_memtable_rows : int, optional + Maximum rows per MemTable (default: 100 000). + max_memtable_batches : int, optional + Maximum batches per MemTable (default: 8 000). + max_unflushed_memtable_bytes : int, optional + Maximum unflushed bytes before backpressure (default: 1 GB). + manifest_scan_batch_size : int, optional + Batch size for manifest scans (default: 2). + backpressure_log_interval_ms : int, optional + Interval for backpressure log messages in milliseconds + (default: 30 000). + stats_log_interval_ms : int, optional + Interval for statistics log messages in milliseconds + (default: 60 000). Pass ``0`` to disable. + hnsw_params : dict, optional + Per-index HNSW build-parameter overrides for the MemTable this + writer builds, keyed by maintained vector index name. Each value is + a dict with any of ``num_edges`` (graph degree; level 0 keeps + ``2 * num_edges``, equivalent to FAISS's ``M``), ``ef_construction``, + and ``max_level``. Without an override an index uses the default + parameters. + + Returns + ------- + ShardWriter + A context-manager-compatible writer for the specified shard. + + Examples + -------- + >>> import lance + >>> import pyarrow as pa + >>> import tempfile + >>> import uuid + >>> schema = pa.schema([ + ... pa.field("id", pa.int64(), nullable=False, + ... metadata={"lance-schema:unenforced-primary-key": "true"}), + ... pa.field("val", pa.float32()), + ... ]) + >>> with tempfile.TemporaryDirectory() as tmpdir: + ... ds = lance.write_dataset( + ... pa.table({"id": [1], "val": [0.1]}, schema=schema), + ... tmpdir, + ... ) + ... ds.initialize_mem_wal() + ... shard_id = str(uuid.uuid4()) + ... new_data = pa.table({"id": [2], "val": [0.2]}, schema=schema) + ... with ds.mem_wal_writer(shard_id) as writer: + ... writer.put(new_data) + """ + import lance.mem_wal as _mw + + kwargs = { + name: val + for name, val in [ + ("durable_write", durable_write), + ("max_wal_buffer_size", max_wal_buffer_size), + ("max_wal_flush_interval_ms", max_wal_flush_interval_ms), + ("max_memtable_size", max_memtable_size), + ("max_memtable_rows", max_memtable_rows), + ("max_memtable_batches", max_memtable_batches), + ("max_unflushed_memtable_bytes", max_unflushed_memtable_bytes), + ("manifest_scan_batch_size", manifest_scan_batch_size), + ("backpressure_log_interval_ms", backpressure_log_interval_ms), + ("stats_log_interval_ms", stats_log_interval_ms), + ("hnsw_params", hnsw_params), + ] + if val is not None + } + raw = self._ds.mem_wal_writer(shard_id, **kwargs) + return _mw.ShardWriter(raw) + + def tracked_files( + self, + *, + min_version: Optional[int] = None, + progress: Optional[Callable] = None, + ) -> pa.RecordBatchReader: + """Stream all files referenced by any manifest version of this dataset. + + Parameters + ---------- + min_version : int, optional + If set, only include manifests with version >= min_version. + progress : callable, optional + Called after each manifest is processed with two arguments: + ``(manifests_processed: int, manifests_total: Optional[int])``. + ``manifests_total`` is ``None`` until all manifest locations + have been listed. Works well with ``tqdm``:: + + from tqdm import tqdm + pbar = tqdm(unit="manifest") + def on_progress(processed, total): + if total is not None: + pbar.total = total + pbar.update(1) + reader = ds.tracked_files(progress=on_progress) + table = reader.read_all() + pbar.close() + + Returns + ------- + pyarrow.RecordBatchReader + Schema: + + - **version** (int64): manifest version number + - **base_uri** (dictionary): storage root URI + - **path** (utf8): file path relative to ``base_uri`` + - **type** (dictionary): one of ``manifest``, + ``data file``, ``deletion file``, ``transaction file``, + ``index file`` + + Output order is non-deterministic. + """ + return self._ds.tracked_files(min_version=min_version, progress=progress) + + def all_files(self) -> pa.RecordBatchReader: + """Stream all files physically present at this dataset's base URI. + + Returns a :class:`pyarrow.RecordBatchReader` with schema: + + - **base_uri** (dictionary): storage root URI + - **path** (utf8): file path relative to ``base_uri`` + - **size_bytes** (int64): file size in bytes + - **last_modified** (timestamp[us, UTC]): last modification time + + Only the primary object store is scanned; alternate ``base_paths`` + entries are not included. + """ + return self._ds.all_files() + class SqlQuery: """ @@ -4003,6 +5981,41 @@ def with_row_addr(self, with_row_addr: bool = True) -> "SqlQueryBuilder": self._builder = self._builder.with_row_addr(with_row_addr) return self + def blob_handling( + self, + blob_handling: Literal["all_binary", "blobs_descriptions", "all_descriptions"], + ) -> "SqlQueryBuilder": + """ + Control how blob columns are returned by this SQL query. + + - ``"all_binary"`` materializes blob columns as binary values. + - ``"blobs_descriptions"`` returns blob descriptors (the default). + - ``"all_descriptions"`` returns descriptions for all binary-like + columns. + """ + self._builder = self._builder.blob_handling(blob_handling) + return self + + def batch_size(self, batch_size: int) -> "SqlQueryBuilder": + """ + Set the maximum number of rows produced by each query batch. + + If :meth:`batch_size_bytes` is also set, both limits apply and the one + reached first determines the scan batch size. + """ + self._builder = self._builder.batch_size(batch_size) + return self + + def batch_size_bytes(self, batch_size_bytes: int) -> "SqlQueryBuilder": + """ + Set the approximate maximum bytes produced by each scan batch. + + If :meth:`batch_size` is also set, both limits apply and the one + reached first determines the scan batch size. + """ + self._builder = self._builder.batch_size_bytes(batch_size_bytes) + return self + def build(self) -> SqlQuery: """ Build the query. @@ -4042,7 +6055,15 @@ def get_updated_rows(self) -> pa.RecordBatchReader: """ Return a streaming RecordBatchReader for updated rows. """ - return self._delta.get_updated_rows() + return self._delta.get_updated_rows() + + def get_deleted_row_ids(self) -> pa.RecordBatchReader: + """ + Return a streaming RecordBatchReader of the row ids deleted in the range. + + The batches carry a single ``_rowid`` column. Requires stable row ids. + """ + return self._delta.get_deleted_row_ids() class _DatasetDeltaBuilder: @@ -4088,14 +6109,19 @@ class Transaction: class Tag(TypedDict): branch: Optional[str] version: int + created_at: Optional[datetime] + updated_at: Optional[datetime] manifest_size: int + metadata: Dict[str, str] class Branch(TypedDict): parent_branch: Optional[str] + branch_identifier: List[Tuple[int, str]] parent_version: int create_at: int manifest_size: int + metadata: Dict[str, str] class Version(TypedDict): @@ -4104,10 +6130,29 @@ class Version(TypedDict): metadata: Dict[str, str] +def _convert_version_timestamp(version: Version) -> Version: + # TODO: python datetime supports only microsecond precision. When a + # separate Version object is implemented, expose the precise timestamp + # (ns) to python. + ts_nanos = version["timestamp"] + version["timestamp"] = datetime.fromtimestamp(ts_nanos // 1e9) + timedelta( + microseconds=(ts_nanos % 1e9) // 1e3 + ) + return version + + +class VersionRef(TypedDict): + version: int + + class UpdateResult(TypedDict): num_rows_updated: int +class DeleteResult(TypedDict): + num_deleted_rows: int + + class AlterColumn(TypedDict): path: str name: Optional[str] @@ -4121,6 +6166,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.""" @@ -4129,10 +6182,28 @@ class Index: name: str fields: List[int] dataset_version: int - fragment_ids: Set[int] + fragment_ids: Bitmap + """The fragments covered by this index. A ``Set[int]``/``List[int]`` is + also accepted when constructing an ``Index``.""" index_version: int created_at: Optional[datetime] = None base_id: Optional[int] = None + files: Optional[List["IndexFile"]] = None + index_details: Optional[Tuple[str, bytes]] = None + covering_fields: List[int] = dataclasses.field(default_factory=list) + + +class IndexInformation(TypedDict): + """Information about a single index segment, as returned by + :meth:`LanceDataset.list_indices`.""" + + name: str + type: str + uuid: str + fields: List[str] + version: int + fragment_ids: Bitmap + base_id: Optional[int] class AutoCleanupConfig(TypedDict): @@ -4172,7 +6243,13 @@ class Overwrite(BaseOperation): new_schema: pyarrow.Schema The schema of the new dataset. fragments: list[FragmentMetadata] - The fragments that make up the new dataset. + The newly written fragments that make up the new dataset. They are + assigned fresh ids when the operation is committed, continuing from + the highest id the dataset has ever used, so any id they carry is + ignored. Since we reassign fragment ids, a fragment with a deletion + file is rejected: use :class:`LanceOperation.Delete` to commit + deletions, or :class:`LanceOperation.Merge` to change the schema of + existing fragments. initial_bases: list[DatasetBasePath], optional Base paths to register when creating a new dataset (CREATE mode only). **Only valid in CREATE mode**. Will raise an error if used with @@ -4343,8 +6420,15 @@ class Update(BaseOperation): The ids of the fragments that have been removed entirely. updated_fragments: list[FragmentMetadata] The fragments that have been updated with new deletion vectors. + These are used as given, so pass back the metadata read from the + dataset rather than a freshly constructed object, or the fragment + loses its row id and version metadata. new_fragments: list[FragmentMetadata] - The fragments that contain the new rows. + The fragments that contain the new rows. On a dataset that uses + stable row ids, set ``row_id_meta`` on these to carry the ids of + rewritten rows over; see :class:`lance.fragment.RowIdSequence`. The + created-at and last-updated-at version metadata are derived during + the commit and should be left as None. fields_modified: list[int] If any fields are modified in updated_fragments, then they must be listed here so those fragments can be removed from indices covering @@ -4352,6 +6436,11 @@ class Update(BaseOperation): fields_for_preserving_frag_bitmap: list[int] The fields that used to judge whether to preserve the new frag's id into the frag bitmap of the specified indices. + updated_fragment_offsets: dict[int, bytes], optional + Physical row offsets that matched the update, keyed by fragment id, + each serialized in the portable RoaringBitmap format. Set on + ``rewrite_columns`` updates over stable row ids so the commit + refreshes row-level version metadata for the matched rows only. """ removed_fragment_ids: List[int] = dataclasses.field(default_factory=list) @@ -4364,6 +6453,7 @@ class Update(BaseOperation): default_factory=list ) update_mode: str = "" + updated_fragment_offsets: Optional[Dict[int, bytes]] = None def __post_init__(self): LanceOperation._validate_fragments(self.updated_fragments) @@ -4382,6 +6472,14 @@ class Merge(BaseOperation): schema: LanceSchema or pyarrow.Schema The schema of the new dataset. Passing a LanceSchema is preferred, and passing a pyarrow.Schema is deprecated. + preserves_nullability: bool + True when this merge makes no nullability-affecting schema change: + it introduces no field that data staged against an earlier schema + could not safely omit. Without the assertion (the default) the + merge conservatively conflicts with concurrent appends, whose + fragments would omit new columns and read as null; that can only + cause a retry. Pass True when every column this merge introduces + is nullable to let concurrent appends commit without conflict. Warning ------- @@ -4427,6 +6525,7 @@ class Merge(BaseOperation): fragments: Iterable[FragmentMetadata] schema: LanceSchema | pa.Schema + preserves_nullability: bool = False def __post_init__(self): if isinstance(self.schema, pa.Schema): @@ -4446,6 +6545,38 @@ class Restore(BaseOperation): version: int + @dataclass + class Clone(BaseOperation): + """Operation that creates a clone or branch from a dataset reference. + + This operation is created internally by clone and branch APIs. It is + exposed so transactions returned by :meth:`LanceDataset.get_transactions` + can represent clone metadata without losing information. + + Only a shallow clone can be committed directly. A deep clone must copy + the source files first, so committing one raises ``OSError``; use + :meth:`LanceDataset.deep_clone` instead. + + Attributes + ---------- + is_shallow: bool + Whether data files are shared with the source dataset. + ref_name: str, optional + Source branch name, or ``None`` for the main branch. + ref_version: int + Source dataset version. + ref_path: str + Source dataset URI. + branch_name: str, optional + Destination branch name, when the clone creates a branch. + """ + + is_shallow: bool + ref_name: Optional[str] + ref_version: int + ref_path: str + branch_name: Optional[str] + @dataclass class RewriteGroup: """ @@ -4519,6 +6650,80 @@ class DataReplacement(BaseOperation): replacements: List[LanceOperation.DataReplacementGroup] + @dataclass + class DataOverlayFile: + """ + An overlay file supplying new values for a subset of + ``(physical offset, field)`` cells of a fragment, resolved on read and + layered over the base data without rewriting the base files. + + The overlay is dense or sparse depending on the shape of ``offsets``: + pass a single iterable of ints (e.g. a :class:`~lance.bitmap.Bitmap` + or a ``List[int]``) for a dense overlay (one offset set shared by + every field in ``data_file``), or a list of int iterables for a + sparse overlay (one offset set per field, in the order of the file's + fields). Offsets are **physical** row offsets (positions in the base + files, counting deleted rows), like deletion vectors. + + Attributes + ---------- + data_file : DataFile + The Lance data file storing the overlay's new cell values — one + value column per covered field. The value at each covered offset is + stored at the rank (0-based count of covered offsets below it) of + that offset in the field's coverage. + offsets : Iterable[int] | List[Iterable[int]] + The covered physical row offsets. A single int iterable is dense + coverage (shared by every field); a list of int iterables is + sparse coverage (in field order). Offsets are always resolved in + ascending order — the smallest covered offset maps to row 0 of + ``data_file``, the next-smallest to row 1, and so on — regardless + of the order values are given in, so a plain ``List[int]`` need + not be pre-sorted. A repeated offset raises ``ValueError``: it + would shift every later offset onto the wrong row. + committed_version : Optional[int] + The dataset version at which this overlay became effective. Leave as + ``None`` when creating an overlay to commit — the commit stamps it. + It is populated when reading an existing fragment's overlays so they + round-trip through :class:`FragmentMetadata`. + """ + + data_file: DataFile + offsets: Union[Iterable[int], List[Iterable[int]]] + committed_version: Optional[int] = None + + @dataclass + class DataOverlayGroup: + """ + Overlay files to append to a single fragment. + + Attributes + ---------- + fragment_id : int + The id of the fragment the overlays apply to. + overlays : List[LanceOperation.DataOverlayFile] + The overlay files to append, ordered oldest-first (a later entry is + newer and wins where coverage overlaps). + """ + + fragment_id: int + overlays: List[LanceOperation.DataOverlayFile] + + @dataclass + class DataOverlay(BaseOperation): + """ + Operation that appends data overlay files to fragments. + + Overlays are appended to each fragment's existing overlays (overlays + written by concurrent commits are preserved) and resolved on read + over the base data without rewriting it. + + If multiple groups target the same data then the values in the + latest group take precedence. + """ + + groups: List[LanceOperation.DataOverlayGroup] + @dataclass class Project(BaseOperation): """ @@ -4529,6 +6734,11 @@ class Project(BaseOperation): ---------- schema: LanceSchema The lance schema of the new dataset. + preserves_nullability: bool + True when this projection makes no nullability-affecting schema + change, as a rename or a drop does not. Without the assertion + (the default) the projection conservatively conflicts with + concurrent writes, which can only cause a retry. Examples -------- @@ -4557,6 +6767,7 @@ class Project(BaseOperation): """ schema: LanceSchema + preserves_nullability: bool = False @dataclass class UpdateMap: @@ -4617,25 +6828,100 @@ class ColumnOrdering: nulls_first: bool = False +def _needs_substrait_placeholder(t: pa.DataType) -> bool: + """Return True if *t* contains a type that PyArrow's substrait serializer + cannot handle at any nesting depth. + + Three cases require a placeholder: + + * ``fixed_size_list`` — substrait has no equivalent type. + * Arrow extension types (e.g. ``fixed_shape_tensor``) — substrait cannot + represent them. + * A struct whose fields carry non-``None`` metadata — substrait rejects + such structs. Extension types leave ``metadata={}`` on struct fields + after a lance round-trip; ``{}`` is non-``None``. + """ + if pa.types.is_fixed_size_list(t): + return True + if isinstance(t, pa.lib.BaseExtensionType): + return True + if pa.types.is_struct(t): + for i in range(t.num_fields): + f = t.field(i) + if f.metadata is not None: + return True + if _needs_substrait_placeholder(f.type): + return True + if pa.types.is_list(t) or pa.types.is_large_list(t): + return _needs_substrait_placeholder(t.value_type) + return False + + +def _serialize_expression( + expression: pa.compute.Expression, schema: pa.Schema +) -> bytes: + from pyarrow.substrait import serialize_expressions + + # Keep each field's position so Substrait references still resolve against + # the stored schema when PyArrow cannot serialize an unrelated field's type. + scalar_schema = pa.schema( + [ + ( + pa.field(f"__unlikely_name_placeholder_{i}", pa.int8()) + if _needs_substrait_placeholder(field.type) + else field + ) + for i, field in enumerate(schema) + ] + ) + serialized = serialize_expressions([expression], ["my_filter"], scalar_schema) + if isinstance(serialized, memoryview): + return serialized.tobytes() + try: + return serialized.to_pybytes() + except AttributeError: + raise TypeError( + f"serialize_expressions returned unexpected type {type(serialized)}" + ) + + +def serialize_row_addrs(addrs: Iterable[int]) -> bytes: + """Encode row addresses for ``row_addr_allowlist`` / ``row_addr_blocklist``. + + Those parameters take a serialized roaring treemap over ``_rowid``; this is + the way to produce one from Python. + + >>> blob = serialize_row_addrs([0, 2, 4]) # doctest: +SKIP + >>> ds.scanner(row_addr_allowlist=blob).to_table() # doctest: +SKIP + """ + return _serialize_row_addrs(list(addrs)) + + class ScannerBuilder: def __init__(self, ds: LanceDataset): self.ds = ds self._limit = None self._filter = None + self._search_filter = None self._substrait_filter = None self._prefilter = False + self._row_addr_allowlist: Optional[bytes] = None + self._row_addr_blocklist: Optional[bytes] = None self._late_materialization = None self._blob_handling = None self._offset = None self._columns = None self._columns_with_transform = None self._nearest = None + self._minhash_query: Optional[Dict[str, str]] = 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 self._scan_in_order = True self._fragments = None + self._index_segments = None self._with_row_id = False self._with_row_address = False self._use_stats = True @@ -4647,6 +6933,7 @@ def __init__(self, ds: LanceDataset): self._strict_batch_size = False self._orderings = None self._disable_scoring_autoprojection = False + self._substrait_aggregate = None def apply_defaults(self, default_opts: Dict[str, Any]) -> ScannerBuilder: for key, value in default_opts.items(): @@ -4660,10 +6947,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. + + If a byte limit is also configured, both limits apply and the one + reached first determines the batch size. + """ 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. If ``batch_size`` is also set, both + limits apply and the one reached first determines the 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 @@ -4673,18 +6978,14 @@ def io_buffer_size(self, io_buffer_size: int) -> ScannerBuilder: used by the scanner. If the buffer is full then the scanner will block until the buffer is processed. - Generally this should scale with the number of concurrent I/O threads. The - default is 2GiB which comfortably provides enough space for somewhere between - 32 and 256 concurrent I/O threads. + Generally this should scale with the number of concurrent I/O threads. If + unset, v2 scans choose a default based on the object store and + ``LANCE_DEFAULT_IO_BUFFER_SIZE`` can override that default. This value is not a hard cap on the amount of RAM the scanner will use. Some space is used for the compute (which can be controlled by the batch size) and Lance does not keep track of memory after it is returned to the user. - Currently, if there is a single batch of data which is larger than the io buffer - size then the scanner will deadlock. This is a known issue and will be fixed in - a future release. - This parameter is only used when reading v2 files """ self._io_buffer_size = io_buffer_size @@ -4692,10 +6993,12 @@ def io_buffer_size(self, io_buffer_size: int) -> ScannerBuilder: def batch_readahead(self, nbatches: Optional[int] = None) -> ScannerBuilder: """ - This parameter is ignored when reading v2 files + Set the maximum number of batches to decode concurrently. + + This parameter must be greater than zero. """ - if nbatches is not None and int(nbatches) < 0: - raise ValueError("batch_readahead must be non-negative") + if nbatches is not None and int(nbatches) <= 0: + raise ValueError("batch_readahead must be greater than 0") self._batch_readahead = nbatches return self @@ -4747,50 +7050,56 @@ def columns( ) return self - def filter(self, filter: Union[str, pa.compute.Expression]) -> ScannerBuilder: - if isinstance(filter, pa.compute.Expression): - try: - from pyarrow.substrait import serialize_expressions - - fields_without_lists = [] - counter = 0 - # Pyarrow cannot handle fixed size lists when converting - # types to Substrait. So we can't use those in our filter, - # which is ok for now but we need to replace them with some - # kind of placeholder because Substrait is going to use - # ordinal field references and we want to make sure those are - # correct. - for field in self.ds.schema: - if pa.types.is_fixed_size_list(field.type): - pos = counter - counter += 1 - fields_without_lists.append( - pa.field(f"__unlikely_name_placeholder_{pos}", pa.int8()) - ) - else: - fields_without_lists.append(field) - # Serialize the pyarrow compute expression toSubstrait and use - # that as a filter. - scalar_schema = pa.schema(fields_without_lists) - substrait_filter = serialize_expressions( - [filter], ["my_filter"], scalar_schema - ) - if isinstance(substrait_filter, memoryview): - self._substrait_filter = substrait_filter.tobytes() - else: - try: - self._substrait_filter = substrait_filter.to_pybytes() - except AttributeError: - raise TypeError( - "serialize_expressions returned unexpected" - f"type {type(substrait_filter)}" - ) - except ImportError: - # serialize_expressions was introduced in pyarrow 14. Fallback to - # stringifying the expression if pyarrow is too old - self._filter = str(filter) - else: + def filter( + self, + filter: Union[ + str, pa.compute.Expression, FullTextQuery, VectorSearchQuery, dict + ], + ) -> ScannerBuilder: + """ + Add a filter to the scanner. + + :param filter: The filter to apply. This can be a string, a pyarrow compute + expression, a FullTextQuery, a VectorSearchQuery, or a dictionary. + + :return: The scanner builder. + """ + if isinstance(filter, FullTextQuery): + self._search_filter = PySearchFilter.from_full_text_query(filter.inner) + elif isinstance(filter, VectorSearchQuery): + self._search_filter = PySearchFilter.from_vector_search_query(filter.inner) + elif isinstance(filter, str): self._filter = filter + elif isinstance(filter, pa.compute.Expression): + self._substrait_filter = _serialize_expression(filter, self.ds._ds.schema) + else: + expr_filter = filter.get("expr_filter") + if expr_filter is not None: + self.filter(expr_filter) + + search_filter = filter.get("search_filter") + if search_filter is not None: + self.filter(search_filter) + + return self + + def row_addr_prefilter( + self, + allowlist: Optional[bytes] = None, + blocklist: Optional[bytes] = None, + ) -> ScannerBuilder: + """Restrict the scan to an externally supplied set of row addresses. + + allowlist / blocklist are serialized roaring treemaps over ``_rowid`` + (``RowAddrTreeMap::serialize_into`` output); passing neither clears the + mask. Applied before KNN / BM25 ranking, so top-k is computed over the + surviving rows rather than filtered afterwards. + + Bytes rather than an object so the mask can be produced by a different + extension module -- nothing Rust-typed crosses the boundary. + """ + self._row_addr_allowlist = allowlist + self._row_addr_blocklist = blocklist return self def prefilter(self, prefilter: bool) -> ScannerBuilder: @@ -4874,6 +7183,12 @@ def with_fragments( self._fragments = fragments return self + def with_index_segments( + self, index_segments: Optional[Iterable[Union[str, uuid.UUID]]] + ) -> ScannerBuilder: + self._index_segments = _normalize_index_segment_ids(index_segments) + return self + def nearest( self, column: str, @@ -4886,126 +7201,62 @@ def nearest( refine_factor: Optional[int] = None, use_index: bool = True, ef: Optional[int] = None, + query_parallelism: Optional[int] = None, + approx_mode: Literal["fast", "normal", "accurate"] = "normal", distance_range: Optional[tuple[Optional[float], Optional[float]]] = None, ) -> ScannerBuilder: """Configure nearest neighbor search. Parameters ---------- - column: str - The name of the vector column to search. q: QueryVectorLike - The query vector. - k: int, optional - The number of nearest neighbors to return. - metric: str, optional - The distance metric to use (e.g., "L2", "cosine", "dot", "hamming"). - nprobes: int, optional - The number of partitions to search. Sets both minimum_nprobes and - maximum_nprobes to the same value. - minimum_nprobes: int, optional - The minimum number of partitions to search. - maximum_nprobes: int, optional - The maximum number of partitions to search. - refine_factor: int, optional - The refine factor for the search. - use_index: bool, default True - Whether to use the index for the search. - ef: int, optional - The ef parameter for HNSW search. - distance_range: tuple[Optional[float], Optional[float]], optional - A tuple of (lower_bound, upper_bound) to filter results by distance. - Both bounds are optional. The lower bound is inclusive and the upper - bound is exclusive, so (0.0, 1.0) keeps distances d where - 0.0 <= d < 1.0, (None, 0.5) keeps d < 0.5, and (0.5, None) keeps d >= 0.5. - - Returns - ------- - ScannerBuilder - The scanner builder for method chaining. - """ - q, q_dim = _coerce_query_vector(q) - - lance_field = self.ds._ds.lance_schema.field_case_insensitive(column) - if lance_field is None: - raise ValueError(f"Embedding column {column} is not in the dataset") - - column_field = lance_field.to_arrow() - column_type = column_field.type - if hasattr(column_type, "storage_type"): - column_type = column_type.storage_type - if pa.types.is_fixed_size_list(column_type): - dim = column_type.list_size - elif pa.types.is_list(column_type) and pa.types.is_fixed_size_list( - column_type.value_type - ): - dim = column_type.value_type.list_size - else: - raise TypeError( - f"Query column {column} must be a vector. Got {column_field.type}." - ) - - if q_dim != dim: - raise ValueError( - f"Query vector size {len(q)} does not match index column size {dim}" - ) - - if k is not None and int(k) <= 0: - raise ValueError(f"Nearest-K must be > 0 but got {k}") - if nprobes is not None and int(nprobes) <= 0: - raise ValueError(f"Nprobes must be > 0 but got {nprobes}") - if minimum_nprobes is not None and int(minimum_nprobes) < 0: - raise ValueError(f"Minimum nprobes must be >= 0 but got {minimum_nprobes}") - if maximum_nprobes is not None and int(maximum_nprobes) < 0: - raise ValueError(f"Maximum nprobes must be >= 0 but got {maximum_nprobes}") - - if nprobes is not None: - if minimum_nprobes is not None or maximum_nprobes is not None: - raise ValueError( - "nprobes cannot be set in combination with minimum_nprobes or " - "maximum_nprobes" - ) - else: - minimum_nprobes = nprobes - maximum_nprobes = nprobes - if ( - minimum_nprobes is not None - and maximum_nprobes is not None - and minimum_nprobes > maximum_nprobes - ): - raise ValueError("minimum_nprobes must be <= maximum_nprobes") - if refine_factor is not None and int(refine_factor) < 1: - raise ValueError(f"Refine factor must be 1 or more got {refine_factor}") - if ef is not None and int(ef) <= 0: - # `ef` should be >= `k`, but `k` could be None so we can't check it here - # the rust code will check it - raise ValueError(f"ef must be > 0 but got {ef}") - - if distance_range is not None: - if len(distance_range) != 2: - raise ValueError( - "distance_range must be a tuple of (lower_bound, upper_bound)" - ) - - self._nearest = { - "column": column, - "q": q, - "k": k, - "metric": metric, - "minimum_nprobes": minimum_nprobes, - "maximum_nprobes": maximum_nprobes, - "refine_factor": refine_factor, - "use_index": use_index, - "ef": ef, - "distance_range": distance_range, - } + A single query vector or, for fixed-size vector columns, a 2-D array-like + or list-shaped batch of query vectors. Batch queries return up to ``k`` rows + per query and include Int32 non-null ``query_index`` as the first output + column. Flattened 1-D inputs whose length is a multiple of the vector + dimension are rejected. Datasets with an existing ``query_index`` column + cannot be used for batch search. + When ``use_index`` is true and a vector index is available, each query + vector is searched through the index path; otherwise the flat batch path + is used. + query_parallelism: int, optional + Maximum partition-search concurrency for a single vector query. + The default is 0. Value 0 uses the automatic policy, which + currently maps to the single-worker sequential path. Value -1 uses + the CPU pool size. Value 1 uses the single-worker sequential path. + Values >= 2 use the partition-parallel path and are clamped to the + CPU pool size. + approx_mode: {"fast", "normal", "accurate"}, default "normal" + Controls the speed / accuracy tradeoff for approximate vector search + when supported by the selected index. This currently only affects + RQ-quantized indexes, such as IVF_RQ. Other index types ignore this + setting. ``fast`` favors lower latency and may reduce recall, + ``normal`` uses the default balance, and ``accurate`` favors higher + recall and may increase latency. + """ + self._nearest = _build_vector_search_query( + column, + q, + dataset=self.ds, + k=k, + metric=metric, + nprobes=nprobes, + minimum_nprobes=minimum_nprobes, + maximum_nprobes=maximum_nprobes, + refine_factor=refine_factor, + use_index=use_index, + ef=ef, + query_parallelism=query_parallelism, + approx_mode=approx_mode, + distance_range=distance_range, + ) return self def fast_search(self, flag: bool) -> ScannerBuilder: - """Enable fast search, which only perform search on the indexed data. + """Enable fast search, which only performs search on indexed fragments. - Users can use `Table::optimize()` or `create_index()` to include the new data - into index, thus make new data searchable. + Users can use `Table::optimize()` or `create_index()` to include new data + in an index, thus making new data searchable. """ self._fast_search = flag return self @@ -5048,6 +7299,33 @@ def full_text_search( } return self + def minhash_search(self, query: MinHashQuery) -> ScannerBuilder: + """ + Find the rows most similar to ``query.text`` using the MinHash LSH index + on ``query.column``. + + The number of rows comes from :meth:`limit` (default 10). Results carry + a ``_distance`` column equal to ``1 - estimated Jaccard similarity`` and + are ordered by ascending distance. Cannot be combined with + :meth:`nearest` or :meth:`full_text_search`. + + Rows the index does not cover (fragments appended after the index was + built, or rows changed by a data overlay) are scored on the fly, which + costs a scan of those rows per query; run ``optimize_indices`` to index + them, or enable :meth:`fast_search` to search only the indexed rows. + + Parameters + ---------- + query : MinHashQuery + The query text and the column to search. + """ + if not isinstance(query, MinHashQuery): + raise TypeError( + f"minhash_search expects a MinHashQuery, got {type(query).__name__}" + ) + self._minhash_query = {"column": query.column, "text": query.text} + return self + def scan_stats_callback( self, callback: Callable[[ScanStatistics], None] ) -> ScannerBuilder: @@ -5067,6 +7345,9 @@ def strict_batch_size(self, strict_batch_size: bool = False) -> ScannerBuilder: If this is true then small batches will need to be merged together which will require a data copy and incur a (typically very small) performance penalty. + + This cannot be combined with ``batch_size_bytes`` because merging + batches to the strict row count can exceed the byte limit. """ self._strict_batch_size = strict_batch_size return self @@ -5090,21 +7371,41 @@ def disable_scoring_autoprojection(self, disable: bool = True) -> ScannerBuilder self._disable_scoring_autoprojection = disable return self + def substrait_aggregate(self, aggregate: bytes) -> ScannerBuilder: + """ + Set a Substrait aggregate expression for the scanner. + + Parameters + ---------- + aggregate : bytes + The serialized Substrait Aggregate plan bytes. + + Returns + ------- + ScannerBuilder + This builder for method chaining. + """ + self._substrait_aggregate = aggregate + return self + def to_scanner(self) -> LanceScanner: scanner = self.ds._ds.scanner( self._columns, self._columns_with_transform, self._filter, + self._search_filter, self._prefilter, self._limit, self._offset, self._nearest, self._batch_size, + self._batch_size_bytes, self._io_buffer_size, self._batch_readahead, self._fragment_readahead, self._scan_in_order, self._fragments, + self._index_segments, self._with_row_id, self._with_row_address, self._use_stats, @@ -5119,14 +7420,24 @@ def to_scanner(self) -> LanceScanner: self._strict_batch_size, self._orderings, self._disable_scoring_autoprojection, + self._substrait_aggregate, + self._row_addr_allowlist, + self._row_addr_blocklist, + self._minhash_query, ) - 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: """ @@ -5140,6 +7451,66 @@ 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_paths = _blob_paths_in_schema(schema) + if not blob_paths 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() + blob_files = _fetch_blob_files_for_paths( + self._ds, table, blob_paths, blob_sources, row_addrs + ) + + columns_to_drop = [] + for path in blob_files: + segments = _parse_field_path(path) + if len(segments) == 1 and segments[0] in table.schema.names: + columns_to_drop.append(segments[0]) + 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) + + _place_blob_files(dataframe, blob_files, schema) + return dataframe + @property def projected_schema(self) -> Schema: return self._scanner.schema @@ -5209,7 +7580,7 @@ def head(self, num_rows): """ return self.to_table()[:num_rows] - def count_rows(self): + def count_rows(self) -> int: """Count rows matching the scanner filter. Returns @@ -5234,20 +7605,25 @@ def explain_plan(self, verbose=False) -> str: return self._scanner.explain_plan(verbose=verbose) - def analyze_plan(self) -> str: + def analyze_plan(self, count_rows: bool = False) -> str: """Execute the plan for this scanner and display with runtime metrics. + Full-text-search nodes include the execution-time ``tokenized_query`` + text and positions. + Parameters ---------- - verbose : bool, default False - Use a verbose output format. + count_rows : bool, default False + If True, auto-apply a ``COUNT(*)`` aggregate before analyzing so + the returned plan reflects what :py:meth:`count_rows` would + execute (including the optimizer's count-pushdown decisions). Returns ------- plan : str """ - return self._scanner.analyze_plan() + return self._scanner.analyze_plan(count_rows=count_rows) class DatasetOptimizer: @@ -5257,13 +7633,23 @@ 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, + 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[ + Literal["reencode", "try_binary_copy", "force_binary_copy"] + ] = None, + binary_copy_read_batch_bytes: Optional[int] = None, + max_source_fragments: Optional[int] = None, + max_source_rows: Optional[int] = None, + max_source_bytes: Optional[int] = None, + excluded_fragment_ids: Optional[list[int]] = None, + data_storage_version: Optional[str] = None, ) -> CompactionMetrics: """Compacts small files in the dataset, reducing total number of files. @@ -5279,14 +7665,37 @@ 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``, + ``lance.compaction.max_source_fragments``, + ``lance.compaction.max_source_rows``, + ``lance.compaction.max_source_bytes``, + ``lance.compaction.data_storage_version``. + 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. @@ -5297,12 +7706,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. + 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. @@ -5311,6 +7725,47 @@ def compact_files( to reduce this if you are running out of memory during compaction. The default will use the same default from ``scanner``. + compaction_mode: str, optional + The compaction mode. Valid values: + + - ``"reencode"``: Decode and re-encode data (default). + - ``"try_binary_copy"``: Try binary copy if fragments are + compatible, fall back to reencode otherwise. + - ``"force_binary_copy"``: Use binary copy or fail if fragments + are not compatible. + binary_copy_read_batch_bytes: int, optional + The batch size in bytes for reading during binary copy operations. + Controls how much data is read at once when performing binary copy. + Defaults to 16MB. + max_source_fragments: int, optional + Maximum number of source fragments to compact in a single run. + Compaction tasks are included until adding the next task would + exceed this limit, allowing compaction to proceed incrementally. + Fragments are processed oldest first. If not specified, uses the + manifest config value, or applies no limit. + max_source_rows: int, optional + Maximum number of source rows to compact in a single run. Rows are + counted as live rows (physical rows minus soft-deleted rows). + Tasks are included until adding the next task would exceed this + limit. + max_source_bytes: int, optional + Maximum number of source bytes to compact in a single run, + measured as the total size of the source fragments' data and + overlay files. Tasks are included until adding the next task + would exceed this limit. Blob v2 payloads live in separate + blob files and are not counted, so this is not a cap on total + compaction I/O for datasets with blob columns. + excluded_fragment_ids: list[int], optional + Fragment IDs to exclude from compaction planning. Excluded + fragments remain unchanged and act as boundaries, so fragments + on opposite sides are not combined into the same compaction task. + Duplicate and unknown IDs are ignored. + data_storage_version: str, optional + Output data file version, such as "2.2", "stable", or "next". + Uses the compaction config target when set, otherwise the dataset's + default write version. Does not change that default or the versions + of unselected files. V1/V2 cross-family targets are rejected. + Returns ------- CompactionMetrics @@ -5320,18 +7775,30 @@ 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, - num_threads=num_threads, - batch_size=batch_size, - ) + 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, + max_source_fragments=max_source_fragments, + max_source_rows=max_source_rows, + max_source_bytes=max_source_bytes, + excluded_fragment_ids=excluded_fragment_ids, + data_storage_version=data_storage_version, + ).items() + if v is not None + } return Compaction.execute(self._dataset, opts) - def optimize_indices(self, **kwargs): + def optimize_indices(self, **kwargs) -> None: """Optimizes index performance. As new data arrives it is not added to existing indexes automatically. @@ -5339,10 +7806,10 @@ def optimize_indices(self, **kwargs): an expensive unindexed search on the new data. As the amount of new unindexed data grows this can have an impact on search latency. This function will add the new data to existing indexes, restoring the - performance. This function does not retrain the index, it only assigns - the new data to existing partitions. This means an update is much quicker - than retraining the entire index but may have less accuracy (especially - if the new data exhibits new patterns, concepts, or trends) + performance. By default, this function does not retrain the index, it only + assigns the new data to existing partitions. This means an update is much + quicker than retraining the entire index but may have less accuracy + (especially if the new data exhibits new patterns, concepts, or trends) Parameters ---------- @@ -5352,7 +7819,7 @@ def optimize_indices(self, **kwargs): index_names: List[str], default None The names of the indices to optimize. If None, all indices will be optimized. - retrain: bool, default False, deprecated + retrain: bool, default False Whether to retrain the whole index. If true, the index will be retrained based on the current data, `num_indices_to_merge` will be ignored, @@ -5360,7 +7827,7 @@ def optimize_indices(self, **kwargs): This is useful when the data distribution has changed significantly, and we want to retrain the index to improve the search quality. - This would be faster than re-create the index from scratch. + This rebuilds the index from the source data and may be expensive. """ self._dataset._ds.optimize_indices(**kwargs) @@ -5403,11 +7870,13 @@ def list(self) -> dict[str, Tag]: Returns ------- dict[str, Tag] - A dictionary mapping tag names to version numbers. + A dictionary mapping tag names to tag metadata, including the + referenced branch, version, timestamps, manifest size, and any + attached metadata. """ return self._ds.tags() - def get_version(self, tag: str) -> Optional[int]: + def get_version(self, tag: str) -> int: """ Get the version of a specific tag by name. @@ -5418,12 +7887,17 @@ def get_version(self, tag: str) -> Optional[int]: Returns ------- - int or None - The version number of the tag if it exists, otherwise None. + int + The version number of the tag. + + Raises + ------ + ValueError + If the tag does not exist. Use :meth:`list` to check for presence. """ return self._ds.get_version(tag) - def list_ordered(self, order: Optional[str] = None) -> list[str, Tag]: + def list_ordered(self, order: Optional[str] = None) -> List[Tuple[str, Tag]]: """ List all dataset tags. @@ -5436,7 +7910,7 @@ def list_ordered(self, order: Optional[str] = None) -> list[str, Tag]: Returns ------- - list[str, Tag] + List[Tuple[str, Tag]] An ordered list of tuples mapping tag names to its `Tag` metadata. """ return self._ds.tags_ordered(order) @@ -5458,7 +7932,8 @@ def create( An integer specifies a version number in the current branch; a string specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies a version number in a specified branch. (None, None) means the latest - version_number on the main branch. + version_number on the default branch. ``("main", version)`` is an + explicit alias for the default branch in this reference context. """ self._ds.create_tag(tag, reference) @@ -5490,10 +7965,22 @@ def update( An integer specifies a version number in the current branch; a string specifies a tag name; a Tuple[Optional[str], Optional[int]] specifies a version number in a specified branch. (None, None) means the latest - version_number on the main branch. + version_number on the default branch. ``("main", version)`` is an + explicit alias for the default branch in this reference context. """ self._ds.update_tag(tag, reference) + def replace_metadata(self, tag: str, metadata: Dict[str, str]) -> None: + """ + Replace metadata for an existing tag. + + This replaces the entire metadata map instead of merging with existing + keys. It does not change the tag reference, and it does not update + `updated_at`. `updated_at` only changes when `update()` moves the tag + to a different reference. + """ + self._ds.replace_tag_metadata(tag, metadata) + class Branches: """ @@ -5526,6 +8013,12 @@ def delete(self, branch: str) -> None: """ self._ds.delete_branch(branch) + def replace_metadata(self, branch: str, metadata: Dict[str, str]) -> None: + """ + Replace metadata for a branch. + """ + self._ds.replace_branch_metadata(branch, metadata) + @dataclass class FieldStatistics: @@ -5594,11 +8087,13 @@ def write_dataset( max_rows_per_file: int = 1024 * 1024, max_rows_per_group: int = 1024, max_bytes_per_file: int = 90 * 1024 * 1024 * 1024, + data_cache_bytes: Optional[int] = None, + max_page_bytes: Optional[int] = None, commit_lock: Optional[CommitLock] = None, progress: Optional[FragmentWriteProgress] = None, storage_options: Optional[Dict[str, str]] = None, data_storage_version: Optional[ - Literal["stable", "2.0", "2.1", "2.2", "next", "legacy", "0.1"] + Literal["stable", "2.0", "2.1", "2.2", "2.3", "next", "legacy", "0.1"] ] = None, use_legacy_format: Optional[bool] = None, enable_v2_manifest_paths: bool = True, @@ -5608,8 +8103,14 @@ def write_dataset( transaction_properties: Optional[Dict[str, str]] = None, initial_bases: Optional[List[DatasetBasePath]] = None, target_bases: Optional[List[str]] = None, - namespace: Optional[LanceNamespace] = None, + target_all_bases: Optional[bool] = None, + base_store_params: Optional[Dict[str, Dict[str, 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, + session: Optional[Session] = None, ) -> LanceDataset: """Write a given data_obj to the given uri @@ -5622,7 +8123,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. @@ -5641,6 +8142,13 @@ def write_dataset( means larger groups may cause this to be overshot meaningfully. This defaults to 90 GB, since we have a hard limit of 100 GB per file on object stores. + data_cache_bytes : int, optional + Total bytes to buffer for column data before writing pages. The budget + is divided evenly across top-level columns. If not set, the current + file writer uses 8 MiB per column. Ignored for legacy V1 files. + max_page_bytes : int, optional + Best-effort maximum page size in bytes. If not set, the current file + writer uses its configured default. Ignored for legacy V1 files. commit_lock : CommitLock, optional A custom commit lock. Only needed if your object store does not support atomic commits. See the user guide for more details. @@ -5651,10 +8159,19 @@ def write_dataset( 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. + + For writes involving additional base paths, a key of the form + ``base_.`` applies ```` only to the base path with that + id, overriding the unscoped options that every base inherits. For + example ``{"account_key": "shared", "base_1.account_key": "abc"}`` + makes base 1 use ``account_key = abc`` while all other options are + shared. data_storage_version: optional, str, default None The version of the data storage format to use. Newer versions are more - efficient but require newer versions of lance to read. The default (None) - will use the latest stable version. See the user guide for more details. + efficient but require newer versions of lance to read. For create and + overwrite, None uses the latest stable version. For append, None uses + the dataset's default write version; an explicit V2 version applies + only to new files and does not change that default. use_legacy_format : optional, bool, default None Deprecated method for setting the data storage version. Use the `data_storage_version` parameter instead. @@ -5702,96 +8219,92 @@ def write_dataset( **CREATE mode**: References must match bases in `initial_bases` **APPEND/OVERWRITE modes**: References must match bases in the existing manifest - namespace : optional, LanceNamespace - A namespace instance from which to fetch table location and storage options. + target_all_bases: bool, optional + Write new data files round-robin across every base registered in the + manifest. When True (include primary), the dataset's primary storage + participates in the rotation as the first slot; when False, only the + registered bases are used. Cannot be combined with ``target_bases``. + base_store_params : dict of str to dict, optional + Runtime-only object store parameters keyed by base path URI. Each key + is a base path URI (e.g., "s3://bucket/path") and each value is a dict + of storage options (credentials, endpoint, etc.) for that base. These + are not persisted to the manifest. These take precedence over + ``base_.`` entries in ``storage_options``. When a base has no + explicit entry here, the top-level ``storage_options`` is used as a + fallback. + 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 a registered non-dataset-root + base path. If True, external blob URIs outside registered bases are + allowed. Only valid when ``external_blob_mode="reference"``. Setting + this to True with ``"ingest"`` mode raises an error. + 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`. 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 # This follows the same pattern as the Rust implementation: - # - CREATE mode: calls namespace.create_empty_table() + # - CREATE mode: calls namespace.declare_table() # - APPEND/OVERWRITE mode: calls namespace.describe_table() # - Both modes: create storage options provider and merge storage options from .namespace import ( - CreateEmptyTableRequest, DeclareTableRequest, DescribeTableRequest, - LanceNamespaceStorageOptionsProvider, ) # Determine which namespace method to call based on mode if mode == "create": - # Try declare_table first, fall back to deprecated create_empty_table - # for backward compatibility with older namespace implementations. - # create_empty_table support will be removed in 3.0.0. - if hasattr(namespace, "declare_table"): - try: - from lance_namespace.errors import UnsupportedOperationError - - declare_request = DeclareTableRequest(id=table_id, location=None) - response = namespace.declare_table(declare_request) - except (UnsupportedOperationError, NotImplementedError): - # Fall back to deprecated create_empty_table - warnings.warn( - "create_empty_table is deprecated, use declare_table instead. " - "Support will be removed in 3.0.0.", - DeprecationWarning, - stacklevel=2, - ) - fallback_request = CreateEmptyTableRequest( - id=table_id, location=None - ) - response = namespace.create_empty_table(fallback_request) - else: - # Namespace doesn't have declare_table, fall back to create_empty_table - warnings.warn( - "create_empty_table is deprecated, use declare_table instead. " - "Support will be removed in 3.0.0.", - DeprecationWarning, - stacklevel=2, - ) - fallback_request = CreateEmptyTableRequest(id=table_id, location=None) - response = namespace.create_empty_table(fallback_request) + declare_request = DeclareTableRequest(id=table_id, location=None) + 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}") @@ -5802,30 +8315,30 @@ def write_dataset( f"Namespace did not return a table location in {mode} response" ) + # Check if namespace manages versioning (commits go through namespace API) + 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 + namespace_client_managed_versioning = False if use_legacy_format is not None: warnings.warn( @@ -5840,6 +8353,7 @@ def write_dataset( reader = _coerce_reader(data_obj, schema) _validate_schema(reader.schema) # TODO add support for passing in LanceDataset and LanceScanner here + base_store_params = LanceDataset._inherit_base_store_params(uri, base_store_params) # Merge properties and commit_message with priority to commit_message merged_properties = _merge_message_to_properties( @@ -5851,6 +8365,8 @@ def write_dataset( "max_rows_per_file": max_rows_per_file, "max_rows_per_group": max_rows_per_group, "max_bytes_per_file": max_bytes_per_file, + "data_cache_bytes": data_cache_bytes, + "max_page_bytes": max_page_bytes, "progress": progress, "storage_options": storage_options, "data_storage_version": data_storage_version, @@ -5860,11 +8376,22 @@ def write_dataset( "transaction_properties": merged_properties, "initial_bases": initial_bases, "target_bases": target_bases, + "target_all_bases": target_all_bases, + "base_store_params": base_store_params, + "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, + "session": session, } - # Add storage_options_provider if created from namespace - if storage_options_provider is not None: - params["storage_options_provider"] = storage_options_provider + # 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): @@ -5882,7 +8409,10 @@ def write_dataset( ds = LanceDataset.__new__(LanceDataset) ds._storage_options = storage_options - ds._storage_options_provider = None + ds._base_store_params = base_store_params + 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 @@ -5914,8 +8444,7 @@ def _coerce_query_vector(query: QueryVectorLike) -> tuple[pa.Array, int]: if isinstance(query.type, pa.FixedSizeListType): query = query.values elif isinstance(query, (list, tuple)) or ( - _check_for_numpy(query), - isinstance(query, np.ndarray), + _check_for_numpy(query) and isinstance(query, np.ndarray) ): query = np.array(query).astype("float64") # workaround for GH-608 query = pa.FloatingPointArray.from_pandas(query, type=pa.float32()) @@ -5942,6 +8471,161 @@ def _coerce_query_vector(query: QueryVectorLike) -> tuple[pa.Array, int]: return (query, len(query)) +def _build_vector_search_query( + column: str, + q, + *, + dataset: Optional["LanceDataset"] = None, + k: Optional[int] = None, + metric: Optional[str] = None, + nprobes: Optional[int] = None, + minimum_nprobes: Optional[int] = None, + maximum_nprobes: Optional[int] = None, + refine_factor: Optional[int] = None, + use_index: bool = True, + ef: Optional[int] = None, + query_parallelism: Optional[int] = None, + approx_mode: Literal["fast", "normal", "accurate"] = "normal", + distance_range: Optional[tuple[Optional[float], Optional[float]]] = None, +) -> dict: + """Configure nearest neighbor search. + + Parameters + ---------- + column: str + The name of the vector column to search. + q: QueryVectorLike + The query vector. For fixed-size vector columns, this may be a 2-D + array-like or list-shaped batch of query vectors. Batch queries return up to + ``k`` rows per query vector and include Int32 non-null ``query_index`` as + the first output column. + Flattened 1-D inputs whose length is a multiple of the vector dimension are + rejected. Datasets with an existing ``query_index`` column cannot be used for + batch search. When ``use_index`` is true and a vector index is available, + each query vector is searched through the index path; otherwise the flat batch + path is used. + k: int, optional + The number of nearest neighbors to return. + metric: str, optional + The distance metric to use (e.g., "L2", "cosine", "dot", "hamming"). + nprobes: int, optional + The number of partitions to search, setting both the minimum and maximum. + minimum_nprobes: int, optional + The minimum number of partitions to search. + maximum_nprobes: int, optional + The maximum number of partitions to search. + refine_factor: int, optional + The refine factor for the search. + use_index: bool, default True + Whether to use the index for the search. + ef: int, optional + The ef parameter for HNSW search. + query_parallelism: int, optional + Maximum partition-search concurrency for a single vector query. + The default is 0. Value 0 uses the automatic policy, which currently + maps to the single-worker sequential path. Value -1 uses the CPU pool + size. Value 1 uses the single-worker sequential path. Values >= 2 use + the partition-parallel path and are clamped to the CPU pool size. + approx_mode: {"fast", "normal", "accurate"}, default "normal" + Controls the speed / accuracy tradeoff for approximate vector search + when supported by the selected index. This currently only affects + RQ-quantized indexes, such as IVF_RQ. Other index types ignore this + setting. ``fast`` favors lower latency and may reduce recall, + ``normal`` uses the default balance, and ``accurate`` favors higher + recall and may increase latency. + distance_range: tuple[Optional[float], Optional[float]], optional + A tuple of (lower_bound, upper_bound) to filter results by distance. + Both bounds are optional. The lower bound is inclusive and the upper + bound is exclusive, so (0.0, 1.0) keeps distances d where + 0.0 <= d < 1.0, (None, 0.5) keeps d < 0.5, and (0.5, None) keeps d >= 0.5. + + Returns + ------- + ScannerBuilder + The scanner builder for method chaining. + """ + q, q_dim = _coerce_query_vector(q) + + lance_field = dataset._ds.lance_schema.field_case_insensitive(column) + if lance_field is None: + raise ValueError(f"Embedding column {column} is not in the dataset") + + column_field = lance_field.to_arrow() + column_type = column_field.type + if hasattr(column_type, "storage_type"): + column_type = column_type.storage_type + if pa.types.is_fixed_size_list(column_type): + dim = column_type.list_size + elif pa.types.is_list(column_type) and pa.types.is_fixed_size_list( + column_type.value_type + ): + dim = column_type.value_type.list_size + else: + raise TypeError( + f"Query column {column} must be a vector. Got {column_field.type}." + ) + + if q_dim != dim: + raise ValueError( + f"Query vector size {len(q)} does not match index column size {dim}" + ) + + if k is not None and int(k) <= 0: + raise ValueError(f"Nearest-K must be > 0 but got {k}") + if nprobes is not None and int(nprobes) <= 0: + raise ValueError(f"Nprobes must be > 0 but got {nprobes}") + if minimum_nprobes is not None and int(minimum_nprobes) < 0: + raise ValueError(f"Minimum nprobes must be >= 0 but got {minimum_nprobes}") + if maximum_nprobes is not None and int(maximum_nprobes) < 0: + raise ValueError(f"Maximum nprobes must be >= 0 but got {maximum_nprobes}") + + if ( + minimum_nprobes is not None + and maximum_nprobes is not None + and minimum_nprobes > maximum_nprobes + ): + raise ValueError("minimum_nprobes must be <= maximum_nprobes") + if refine_factor is not None and int(refine_factor) < 1: + raise ValueError(f"Refine factor must be 1 or more got {refine_factor}") + if ef is not None and int(ef) <= 0: + # `ef` should be >= `k`, but `k` could be None so we can't check it here + # the rust code will check it + raise ValueError(f"ef must be > 0 but got {ef}") + if query_parallelism is not None: + query_parallelism = operator.index(query_parallelism) + + if query_parallelism is not None and query_parallelism < -1: + raise ValueError("query_parallelism must be >= -1") + + if approx_mode not in {"fast", "normal", "accurate"}: + raise ValueError( + "approx_mode must be one of 'fast', 'normal', or 'accurate', " + f"got {approx_mode!r}" + ) + + if distance_range is not None: + if len(distance_range) != 2: + raise ValueError( + "distance_range must be a tuple of (lower_bound, upper_bound)" + ) + + return { + "column": column, + "q": q, + "k": k, + "metric": metric, + "nprobes": nprobes, + "minimum_nprobes": minimum_nprobes, + "maximum_nprobes": maximum_nprobes, + "refine_factor": refine_factor, + "use_index": use_index, + "ef": ef, + "query_parallelism": query_parallelism, + "approx_mode": approx_mode, + "distance_range": distance_range, + } + + def _validate_schema(schema: pa.Schema): """ Make sure the metadata is valid utf8 @@ -5990,7 +8674,6 @@ class VectorIndexReader: """ This class allows you to initialize a reader for a specific vector index, retrieve the number of partitions, - access the centroids of the index, and read specific partitions of the index. Parameters @@ -6047,22 +8730,6 @@ def num_partitions(self) -> int: return self.stats["indices"][0]["num_partitions"] - def centroids(self) -> np.ndarray: - """ - Returns the centroids of the index - - Returns - ------- - np.ndarray - The centroids of IVF - with shape (num_partitions, dim) - """ - # when we have more delta indices, - # they are with the same centroids - return np.array( - self.dataset._ds.get_index_centroids(self.stats["indices"][0]["centroids"]) - ) - def read_partition( self, partition_id: int, *, with_vector: bool = False ) -> pa.Table: @@ -6093,3 +8760,40 @@ def read_partition( return self.dataset._ds.read_index_partition( self.index_name, partition_id, with_vector ).read_all() + + +class VectorSearchQuery: + _inner: dict + + def __init__( + self, + column: str, + q: QueryVectorLike, + k: Optional[int] = None, + metric: Optional[str] = None, + nprobes: Optional[int] = None, + minimum_nprobes: Optional[int] = None, + maximum_nprobes: Optional[int] = None, + refine_factor: Optional[int] = None, + use_index: bool = True, + ef: Optional[int] = None, + query_parallelism: Optional[int] = None, + approx_mode: Literal["fast", "normal", "accurate"] = "normal", + ): + self._inner = _build_vector_search_query( + column, + q, + k=k, + metric=metric, + nprobes=nprobes, + minimum_nprobes=minimum_nprobes, + maximum_nprobes=maximum_nprobes, + refine_factor=refine_factor, + use_index=use_index, + ef=ef, + query_parallelism=query_parallelism, + approx_mode=approx_mode, + ) + + def inner(self): + return self._inner diff --git a/python/python/lance/dependencies.py b/python/python/lance/dependencies.py index df6b71cb22f..e1435e6fca1 100644 --- a/python/python/lance/dependencies.py +++ b/python/python/lance/dependencies.py @@ -27,7 +27,7 @@ _CAGRA_AVAILABLE = True _RAFT_COMMON_AVAILABLE = True _HUGGING_FACE_AVAILABLE = True -_TENSORFLOW_AVAILABLE = True +_PYDANTIC_AVAILABLE = True class _LazyModule(ModuleType): @@ -49,7 +49,6 @@ class _LazyModule(ModuleType): "pandas": "pd.", "polars": "pl.", "torch": "torch.", - "tensorflow": "tf.", } def __init__( @@ -163,8 +162,7 @@ def _lazy_import(module_name: str) -> tuple[ModuleType, bool]: import numpy import pandas import polars - import tensorflow - import torch + import torch # type: ignore[reportMissingImports] else: # heavy/optional third party libs numpy, _NUMPY_AVAILABLE = _lazy_import("numpy") @@ -172,7 +170,7 @@ def _lazy_import(module_name: str) -> tuple[ModuleType, bool]: polars, _POLARS_AVAILABLE = _lazy_import("polars") torch, _TORCH_AVAILABLE = _lazy_import("torch") datasets, _HUGGING_FACE_AVAILABLE = _lazy_import("datasets") - tensorflow, _TENSORFLOW_AVAILABLE = _lazy_import("tensorflow") + _, _PYDANTIC_AVAILABLE = _lazy_import("pydantic") @lru_cache(maxsize=None) @@ -215,33 +213,77 @@ def _check_for_hugging_face(obj: Any, *, check_type: bool = True) -> bool: ) -def _check_for_tensorflow(obj: Any, *, check_type: bool = True) -> bool: - return _TENSORFLOW_AVAILABLE and _might_be( - cast("Hashable", type(obj) if check_type else obj), "tensorflow" +def _check_for_pydantic(obj: Any, *, check_type: bool = True) -> bool: + return _PYDANTIC_AVAILABLE and _might_be( + cast("Hashable", type(obj) if check_type else obj), "pydantic" ) +def _is_pydantic_base_model(obj: Any) -> bool: + if not _PYDANTIC_AVAILABLE: + return False + from pydantic import BaseModel + + return isinstance(obj, BaseModel) + + +def _is_pydantic_base_model_class(obj: Any) -> bool: + if not _PYDANTIC_AVAILABLE: + return False + from pydantic import BaseModel + + return isinstance(obj, type) and issubclass(obj, BaseModel) + + +def model_to_dict(obj: Any) -> dict[str, Any]: + return obj.model_dump() if hasattr(obj, "model_dump") else obj.dict() + + +def _validate_pydantic_list(data: Any, model_class: type) -> None: + """Validate that `data` is a list of exact `model_class` instances. + + Rejects non-list iterables (e.g. generators), which would otherwise be + drained by this validation loop and leave nothing for the caller to + serialize, and rejects subclass instances, whose extra/overridden fields + would not match the schema derived from `model_class`. + """ + if not isinstance(data, list): + raise TypeError( + f"Pydantic model data must be provided as a list, got {type(data)!r}" + ) + for i, item in enumerate(data): + if type(item) is not model_class: + raise TypeError( + f"data[{i}] must be an instance of {model_class!r} exactly " + f"(subclasses are not accepted), got {type(item)!r} " + f"(data has {len(data)} items)" + ) + + __all__ = [ # lazy-load third party libs "datasets", "numpy", "pandas", "polars", - "tensorflow", "torch", # lazy utilities "_check_for_hugging_face", "_check_for_numpy", "_check_for_pandas", "_check_for_polars", - "_check_for_tensorflow", + "_check_for_pydantic", "_check_for_torch", + "_is_pydantic_base_model", + "_is_pydantic_base_model_class", "_LazyModule", + "model_to_dict", + "_validate_pydantic_list", # exported flags/guards "_NUMPY_AVAILABLE", "_PANDAS_AVAILABLE", "_POLARS_AVAILABLE", + "_PYDANTIC_AVAILABLE", "_TORCH_AVAILABLE", "_HUGGING_FACE_AVAILABLE", - "_TENSORFLOW_AVAILABLE", ] 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/python/python/lance/file.py b/python/python/lance/file.py index 8a20e4aff2f..2e5e969d5b8 100644 --- a/python/python/lance/file.py +++ b/python/python/lance/file.py @@ -1,12 +1,12 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +from dataclasses import dataclass 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 +25,10 @@ LanceFileWriter as _LanceFileWriter, ) +if TYPE_CHECKING: + from .blob import DedicatedBlobWriter, PackedBlobWriter + from .namespace import LanceNamespace + class ReaderResults: """ @@ -67,7 +71,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 +87,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 +105,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, ) @@ -200,6 +209,26 @@ def num_rows(self) -> int: return self._reader.num_rows() +@dataclass +class ListResult: + """ + Result of a non-recursive, delimited list (see + :meth:`LanceFileSession.list_with_delimiter`). + + Attributes + ---------- + common_prefixes : List[str] + The immediate child "directories" of the listed path, relative to the + session's base path. + objects : List[str] + The immediate child files of the listed path, relative to the session's + base path. + """ + + common_prefixes: List[str] + objects: List[str] + + class LanceFileSession: """ A file session for reading and writing Lance files. @@ -213,7 +242,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 +257,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( @@ -274,7 +308,8 @@ def open_writer( If provided, creates a schema-bound writer; otherwise a lazy writer is created. data_cache_bytes : int, optional - Size of the row-group/page write cache in bytes. + Total bytes to buffer for column data before writing pages. The + budget is divided evenly across top-level columns. version : str, optional Lance file format version (e.g. "2"). Parsed by the Rust layer. keep_original_array : bool, optional @@ -299,6 +334,24 @@ def open_writer( _inner_writer=inner, ) + def open_packed_blob_writer(self, path: str, blob_id: int) -> "PackedBlobWriter": + """ + Opens a packed blob writer for the given data file path. + + The path will be appended to the base path of the session. + """ + return self._session.open_packed_blob_writer(path, blob_id) + + def open_dedicated_blob_writer( + self, path: str, blob_id: int + ) -> "DedicatedBlobWriter": + """ + Opens a dedicated blob writer for the given data file path. + + The path will be appended to the base path of the session. + """ + return self._session.open_dedicated_blob_writer(path, blob_id) + def contains(self, path: str) -> bool: """ Check if a file exists at the given path (relative to this session's base path). @@ -332,6 +385,66 @@ def list(self, path: Optional[str] = None) -> List[str]: """ return self._session.list(path) + def list_with_delimiter(self, path: Optional[str] = None) -> ListResult: + """ + Non-recursively list a single directory level (relative to this + session's base path). + + Unlike :meth:`list`, which recurses into the entire subtree, this + returns only the immediate children of ``path``: the child + "directories" as ``common_prefixes`` and the direct child files as + ``objects``. + + Parameters + ---------- + path : str, optional + Path relative to `base_path` to list. If None, lists the base path. + + Returns + ------- + ListResult + The immediate child prefixes and objects of `path`. + """ + common_prefixes, objects = self._session.list_with_delimiter(path) + return ListResult(common_prefixes=common_prefixes, objects=objects) + + def read_range(self, path: str, offset: int, length: int) -> bytes: + """ + Read a byte range from a file (relative to this session's base path). + + Issues a single ranged read. Reading a missing object raises + ``OSError``, consistent with ``download_file``. + + Parameters + ---------- + path : str + Path relative to `base_path` to read from. + offset : int + Byte offset at which to start reading. + length : int + Number of bytes to read. + + Returns + ------- + bytes + The requested byte range. + """ + return self._session.read_range(path, offset, length) + + def delete_file(self, path: str) -> None: + """ + Delete a file (relative to this session's base path). + + Deleting a path that does not exist raises ``OSError``, consistent with + ``download_file``. + + Parameters + ---------- + path : str + Path relative to `base_path` to delete. + """ + self._session.delete_file(path) + def upload_file(self, local_path: Union[str, Path], remote_path: str) -> None: """ Upload a file from local filesystem to the object store. @@ -370,6 +483,12 @@ class LanceFileWriter: This class is used to write Lance data files, a low level structure optimized for storing multi-modal tabular data. If you are working with Lance datasets then you should use the LanceDataset class instead. + + Attributes + ---------- + size_bytes: Optional[int] + The final size of the file in bytes. This is None until `close` is + called. """ def __init__( @@ -380,7 +499,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, @@ -398,8 +518,9 @@ def __init__( the schema will be inferred from the first batch. If the schema is not specified and no data is written then the write will fail. data_cache_bytes: int - How many bytes (per column) to cache before writing a page. The - default is an appropriate value based on the filesystem. + Total bytes to buffer for column data before writing pages. The + budget is divided evenly across top-level columns. By default, + each column uses 8 MiB. version: str The version of the file format to write. If not specified then the latest stable version will be used. Newer versions are more @@ -407,10 +528,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,11 +550,13 @@ 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, ) self.closed = False + self.size_bytes: Optional[int] = None def write_batch(self, batch: Union[pa.RecordBatch, pa.Table]) -> None: """ @@ -453,11 +578,17 @@ def close(self) -> Optional[int]: Write the file metadata and close the file Returns the number of rows written to the file + + After this returns, ``size_bytes`` holds the final size of the file. This + is reported by the writer itself, so it is available for object stores + without issuing a separate metadata request. """ if self.closed: return self.closed = True - return self._writer.finish() + summary = self._writer.finish() + self.size_bytes = summary.size_bytes + return summary.num_rows def add_schema_metadata(self, key: str, value: str) -> None: """ diff --git a/python/python/lance/fragment.py b/python/python/lance/fragment.py index f8cff450f06..1bc9c5e5d67 100644 --- a/python/python/lance/fragment.py +++ b/python/python/lance/fragment.py @@ -11,8 +11,10 @@ from pathlib import Path from typing import ( TYPE_CHECKING, + Any, Callable, Dict, + Iterable, Iterator, List, Literal, @@ -24,6 +26,7 @@ import pyarrow as pa +from .bitmap import Bitmap from .lance import ( DeletionFile as DeletionFile, ) @@ -33,21 +36,29 @@ from .lance import ( RowIdMeta as RowIdMeta, ) +from .lance import ( + RowIdSequence as RowIdSequence, +) from .lance import _Fragment, _write_fragments, _write_fragments_transaction +from .lance import _Session as Session from .progress import FragmentWriteProgress, NoopFragmentWriteProgress from .types import _coerce_reader from .udf import BatchUDF, normalize_transform if TYPE_CHECKING: + from pyarrow._compute import Expression + from .dataset import ( ColumnOrdering, DatasetBasePath, LanceDataset, + LanceOperation, LanceScanner, ReaderLike, Transaction, ) from .lance import LanceSchema + from .namespace import LanceNamespace DEFAULT_MAX_BYTES_PER_FILE = 90 * 1024 * 1024 * 1024 @@ -70,11 +81,26 @@ class FragmentMetadata: deletion_file : Optional[DeletionFile] The deletion file, if any. row_id_meta : Optional[RowIdMeta] - The row id metadata, if any. + The stable row ids of this fragment's rows, if any. When committing a + transaction by hand on a dataset that uses stable row ids, set this to + carry the ids of rewritten rows over to their new fragment; build it with + :class:`RowIdSequence`. Rows left without an id are treated as newly + inserted and are assigned ids during the commit. created_at_version_meta : Optional[RowDatasetVersionMeta] - The row created at version metadata, if any. + The dataset version each row was created in. Derived during the commit + from ``row_id_meta`` -- a rewritten row keeps the version it first + appeared in -- so leave this as None when building a transaction. Any + value set here is ignored for newly written fragments. last_updated_at_version_meta : Optional[RowDatasetVersionMeta] - The row last updated at version metadata, if any. + The dataset version each row was last modified in. Derived during the + commit, like ``created_at_version_meta``; leave this as None. It cannot + be computed ahead of time because a commit that loses a race is retried + against a later version than the one it was built for. + overlays : List[LanceOperation.DataOverlayFile] + The data overlay files layered over this fragment's base data, if any. + Overlays are created via :class:`LanceOperation.DataOverlay`; they are + carried here so they survive operations that round-trip fragment + metadata (e.g. a manual ``Delete``, ``Update``, or ``Merge`` commit). """ id: int @@ -84,6 +110,7 @@ class FragmentMetadata: row_id_meta: Optional[RowIdMeta] = None created_at_version_meta: Optional[RowDatasetVersionMeta] = None last_updated_at_version_meta: Optional[RowDatasetVersionMeta] = None + overlays: List["LanceOperation.DataOverlayFile"] = field(default_factory=list) @property def num_deletions(self) -> int: @@ -107,12 +134,32 @@ def data_files(self) -> List[DataFile]: def to_json(self) -> dict: """Get this as a simple JSON-serializable dictionary.""" - files = [asdict(f) for f in self.files] - for f in files: - f["path"] = f.pop("_path") + + def _data_file_to_json(f: DataFile) -> dict: + d = asdict(f) + d["path"] = d.pop("_path") + return d + + def _offsets_to_json(offsets): + # `offsets` is a Bitmap (dense) or a list of Bitmap/int-list (sparse, + # per field); normalize to plain (nested) lists of ints for JSON. + if isinstance(offsets, Bitmap): + return list(offsets) + return [list(o) if isinstance(o, Bitmap) else o for o in offsets] + + files = [_data_file_to_json(f) for f in self.files] + overlays = [ + dict( + data_file=_data_file_to_json(o.data_file), + offsets=_offsets_to_json(o.offsets), + committed_version=o.committed_version, + ) + for o in self.overlays + ] return dict( id=self.id, files=files, + overlays=overlays, physical_rows=self.physical_rows, deletion_file=( self.deletion_file.asdict() if self.deletion_file is not None else None @@ -142,7 +189,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: @@ -156,6 +203,20 @@ def from_json(json_data: str) -> FragmentMetadata: json.dumps(last_updated_at_version_meta) ) + overlays = [] + overlays_json = json_data.get("overlays") + if overlays_json: + from .dataset import LanceOperation + + overlays = [ + LanceOperation.DataOverlayFile( + data_file=DataFile(**o["data_file"]), + offsets=o["offsets"], + committed_version=o.get("committed_version"), + ) + for o in overlays_json + ] + return FragmentMetadata( id=json_data["id"], files=[DataFile(**f) for f in json_data["files"]], @@ -164,6 +225,7 @@ def from_json(json_data: str) -> FragmentMetadata: row_id_meta=row_id_meta, created_at_version_meta=created_at_version_meta, last_updated_at_version_meta=last_updated_at_version_meta, + overlays=overlays, ) @@ -249,6 +311,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__( @@ -271,10 +363,7 @@ def __repr__(self): return self._fragment.__repr__() def __reduce__(self): - from .dataset import LanceDataset - - ds = LanceDataset(self._ds.uri, self._ds.version) - return LanceFragment, (ds, self.fragment_id) + return LanceFragment, (self._ds, self.fragment_id) @staticmethod def create_from_file( @@ -307,13 +396,16 @@ def create( data: ReaderLike, fragment_id: Optional[int] = None, schema: Optional[pa.Schema] = None, - max_rows_per_group: int = 1024, + max_rows_per_group: Optional[int] = 1024, progress: Optional[FragmentWriteProgress] = None, mode: str = "append", *, 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, + session: Optional[Session] = None, ) -> FragmentMetadata: """Create a :class:`FragmentMetadata` from the given data. @@ -334,8 +426,9 @@ def create( schema: pa.Schema, optional The schema of the data. If not specified, the schema will be inferred from the data. - max_rows_per_group: int, default 1024 - The maximum number of rows per group in the data file. + max_rows_per_group: int, optional, default 1024 + The maximum number of rows per group in the data file. ``None`` + leaves the writer default in place. progress: FragmentWriteProgress, optional *Experimental API*. Progress tracking for writing the fragment. Pass a custom class that defines hooks to be called when each fragment is @@ -353,6 +446,18 @@ 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`. + session : optional, Session + A session to reuse across operations. The session holds shared + caches (metadata and index) and the object store registry. See Also -------- @@ -368,6 +473,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,15 +509,16 @@ def create( mode=mode, data_storage_version=data_storage_version, storage_options=storage_options, + namespace_client=namespace_client, + table_id=table_id, + session=session, ) @property def fragment_id(self): return self._fragment.id() - def count_rows( - self, filter: Optional[Union[pa.compute.Expression, str]] = None - ) -> int: + def count_rows(self, filter: Optional[Union[Expression, str]] = None) -> int: if isinstance(filter, pa.compute.Expression): return self.scanner( with_row_id=True, columns=[], filter=filter @@ -424,6 +540,16 @@ def physical_rows(self) -> int: """ return self._fragment.physical_rows + def validate(self) -> None: + """ + Validate the fragment. + + This checks the integrity of the fragment and will raise an exception if + the fragment is corrupted. Unlike :meth:`lance.LanceDataset.validate`, + which checks every fragment, this validates only this fragment. + """ + self._fragment.validate() + @property def physical_schema(self) -> pa.Schema: # override the pyarrow super class method otherwise causes segfault @@ -442,7 +568,7 @@ def scanner( *, columns: Optional[Union[List[str], Dict[str, str]]] = None, batch_size: Optional[int] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[Union[str, Expression]] = None, limit: Optional[int] = None, offset: Optional[int] = None, with_row_id: bool = False, @@ -452,9 +578,22 @@ def scanner( Literal["all_binary", "blobs_descriptions", "all_descriptions"] ] = None, order_by: Optional[List[ColumnOrdering]] = None, + use_scalar_index: Optional[bool] = None, + io_buffer_size: Optional[int] = None, + late_materialization: Optional[bool | List[str]] = None, + include_deleted_rows: Optional[bool] = None, + batch_size_bytes: Optional[int] = None, + strict_batch_size: Optional[bool] = None, ) -> "LanceScanner": """See Dataset::scanner for details""" - filter_str = str(filter) if filter is not None else None + from .dataset import LanceScanner, _serialize_expression + + if isinstance(filter, pa.compute.Expression): + filter_str = None + substrait_filter = _serialize_expression(filter, self._ds._ds.schema) + else: + filter_str = str(filter) if filter is not None else None + substrait_filter = None columns_arg = {} if isinstance(columns, dict): @@ -466,6 +605,7 @@ def scanner( s = self._fragment.scanner( batch_size=batch_size, filter=filter_str, + substrait_filter=substrait_filter, limit=limit, offset=offset, with_row_id=with_row_id, @@ -473,11 +613,51 @@ def scanner( batch_readahead=batch_readahead, blob_handling=blob_handling, order_by=order_by, + use_scalar_index=use_scalar_index, + io_buffer_size=io_buffer_size, + late_materialization=late_materialization, + include_deleted_rows=include_deleted_rows, + batch_size_bytes=batch_size_bytes, + strict_batch_size=strict_batch_size, **columns_arg, ) - from .dataset import LanceScanner - - return LanceScanner(s, self._ds) + snapshot = { + "_limit": limit, + "_filter": filter_str, + "_search_filter": None, + "_substrait_filter": substrait_filter, + "_prefilter": False, + "_late_materialization": late_materialization, + "_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, + "_batch_size_bytes": batch_size_bytes, + "_io_buffer_size": io_buffer_size, + "_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": use_scalar_index, + "_include_deleted_rows": include_deleted_rows, + "_scan_stats_callback": None, + "_strict_batch_size": ( + strict_batch_size if strict_batch_size is not None else 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, @@ -513,7 +693,7 @@ def to_batches( *, columns: Optional[Union[List[str], Dict[str, str]]] = None, batch_size: Optional[int] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[Union[str, Expression]] = None, limit: Optional[int] = None, offset: Optional[int] = None, with_row_id: bool = False, @@ -523,6 +703,12 @@ def to_batches( Literal["all_binary", "blobs_descriptions", "all_descriptions"] ] = None, order_by: Optional[List[ColumnOrdering]] = None, + use_scalar_index: Optional[bool] = None, + io_buffer_size: Optional[int] = None, + late_materialization: Optional[bool | List[str]] = None, + include_deleted_rows: Optional[bool] = None, + batch_size_bytes: Optional[int] = None, + strict_batch_size: Optional[bool] = None, ) -> Iterator[pa.RecordBatch]: return self.scanner( columns=columns, @@ -535,12 +721,18 @@ def to_batches( batch_readahead=batch_readahead, blob_handling=blob_handling, order_by=order_by, + use_scalar_index=use_scalar_index, + io_buffer_size=io_buffer_size, + late_materialization=late_materialization, + include_deleted_rows=include_deleted_rows, + batch_size_bytes=batch_size_bytes, + strict_batch_size=strict_batch_size, ).to_batches() def to_table( self, columns: Optional[Union[List[str], Dict[str, str]]] = None, - filter: Optional[Union[str, pa.compute.Expression]] = None, + filter: Optional[Union[str, Expression]] = None, limit: Optional[int] = None, offset: Optional[int] = None, with_row_id: bool = False, @@ -549,6 +741,12 @@ def to_table( Literal["all_binary", "blobs_descriptions", "all_descriptions"] ] = None, order_by: Optional[List[ColumnOrdering]] = None, + use_scalar_index: Optional[bool] = None, + io_buffer_size: Optional[int] = None, + late_materialization: Optional[bool | List[str]] = None, + include_deleted_rows: Optional[bool] = None, + batch_size_bytes: Optional[int] = None, + strict_batch_size: Optional[bool] = None, ) -> pa.Table: return self.scanner( columns=columns, @@ -559,8 +757,57 @@ def to_table( with_row_address=with_row_address, blob_handling=blob_handling, order_by=order_by, + use_scalar_index=use_scalar_index, + io_buffer_size=io_buffer_size, + late_materialization=late_materialization, + include_deleted_rows=include_deleted_rows, + batch_size_bytes=batch_size_bytes, + strict_batch_size=strict_batch_size, ).to_table() + def to_pandas( + self, + columns: Optional[Union[List[str], Dict[str, str]]] = None, + filter: Optional[Union[str, Expression]] = None, + limit: Optional[int] = None, + offset: Optional[int] = None, + batch_size: 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`` and accepts + ``batch_size`` for scan batching. + + 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 + batch_size: int, optional + The maximum number of rows per scan batch. + **kwargs + Forwarded to :meth:`pyarrow.Table.to_pandas` for non-blob columns. + """ + return self.scanner( + columns=columns, + filter=filter, + limit=limit, + offset=offset, + batch_size=batch_size, + 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, @@ -629,17 +876,43 @@ def merge( right_on = left_on reader = _coerce_reader(data_obj, schema) - max_field_id = self._ds.max_field_id - metadata, schema = self._fragment.merge(reader, left_on, right_on, max_field_id) + metadata, schema = self._fragment.merge(reader, left_on, right_on) return metadata, schema + @overload + def update_columns( + self, + data_obj: ReaderLike, + left_on: str = "_rowid", + right_on: Optional[str] = None, + schema=None, + *, + with_offsets: Literal[False] = False, + ) -> Tuple[FragmentMetadata, List[int]]: ... + + @overload def update_columns( self, data_obj: ReaderLike, left_on: str = "_rowid", right_on: Optional[str] = None, schema=None, - ) -> Tuple[FragmentMetadata, List[int]]: + *, + with_offsets: Literal[True], + ) -> Tuple[FragmentMetadata, List[int], bytes]: ... + + def update_columns( + self, + data_obj: ReaderLike, + left_on: str = "_rowid", + right_on: Optional[str] = None, + schema=None, + *, + with_offsets: bool = False, + ) -> Union[ + Tuple[FragmentMetadata, List[int]], + Tuple[FragmentMetadata, List[int], bytes], + ]: """ Update existing columns in this fragment. @@ -665,6 +938,14 @@ def update_columns( The name of the column in data_obj to join on. If None, defaults to left_on. schema: pa.Schema, optional The schema of the data. If not specified, the schema will be inferred. + with_offsets: bool, default False + If True, also return the physical row offsets (0-based within this + fragment) that matched the join, serialized in the portable + RoaringBitmap format. Pass them to + :class:`LanceOperation.Update ` as + ``updated_fragment_offsets`` with ``update_mode="rewrite_columns"`` + so a commit over stable row ids refreshes row-level version + metadata for the matched rows only. Returns ------- @@ -673,6 +954,10 @@ def update_columns( - FragmentMetadata: The updated fragment metadata - List[int]: The list of field IDs that were modified + When ``with_offsets`` is True, the tuple has a third element: + - bytes: The matched physical row offsets as portable + RoaringBitmap bytes + Examples -------- @@ -728,9 +1013,11 @@ def update_columns( right_on = left_on reader = _coerce_reader(data_obj, schema) - metadata, fields_modified = self._fragment.update_columns( - reader, left_on, right_on + metadata, fields_modified, matched_offsets = self._fragment.update_columns( + reader, left_on, right_on, with_offsets ) + if matched_offsets is not None: + return metadata, fields_modified, matched_offsets return metadata, fields_modified def merge_columns( @@ -834,18 +1121,36 @@ def delete(self, predicate: str) -> FragmentMetadata | None: return None return raw_fragment.metadata() + def delete_rows(self, offsets: "Iterable[int]") -> FragmentMetadata | None: + """Delete rows by their local (within-fragment) physical row offsets. + + Adds the given 0-based offsets to this fragment's deletion file and + returns a new fragment, or None if no rows are left. Unlike + :meth:`delete`, this deletes exactly the supplied rows without + re-evaluating a SQL predicate -- useful when the caller already knows + which rows to delete (e.g. offsets collected from a prior scan). + + .. warning:: + + Internal API. This method is not intended to be used by end users. + """ + raw_fragment = self._fragment.delete_rows([int(o) for o in offsets]) + if raw_fragment is None: + return None + return raw_fragment.metadata() + @property def schema(self) -> pa.Schema: """Return the schema of this fragment.""" return self._fragment.schema() - def data_files(self): + def data_files(self) -> List[DataFile]: """Return the data files of this fragment.""" return self._fragment.data_files() - def deletion_file(self): + def deletion_file(self) -> Optional[str]: """Return the deletion file, if any""" return self._fragment.deletion_file() @@ -871,16 +1176,24 @@ def write_fragments( return_transaction: Literal[True], mode: str = "append", max_rows_per_file: int = 1024 * 1024, - max_rows_per_group: int = 1024, + max_rows_per_group: Optional[int] = 1024, max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE, + data_cache_bytes: Optional[int] = None, + max_page_bytes: Optional[int] = None, progress: Optional[FragmentWriteProgress] = None, 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, + target_all_bases: Optional[bool] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + base_store_params: Optional[Dict[str, Dict[str, 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, + session: Optional[Session] = None, ) -> Transaction: ... @overload @@ -892,16 +1205,24 @@ def write_fragments( return_transaction: Literal[False] = False, mode: str = "append", max_rows_per_file: int = 1024 * 1024, - max_rows_per_group: int = 1024, + max_rows_per_group: Optional[int] = 1024, max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE, + data_cache_bytes: Optional[int] = None, + max_page_bytes: Optional[int] = None, progress: Optional[FragmentWriteProgress] = None, 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, + target_all_bases: Optional[bool] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + base_store_params: Optional[Dict[str, Dict[str, 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, + session: Optional[Session] = None, ) -> List[FragmentMetadata]: ... @@ -913,16 +1234,24 @@ def write_fragments( return_transaction: bool = False, mode: str = "append", max_rows_per_file: int = 1024 * 1024, - max_rows_per_group: int = 1024, + max_rows_per_group: Optional[int] = 1024, max_bytes_per_file: int = DEFAULT_MAX_BYTES_PER_FILE, + data_cache_bytes: Optional[int] = None, + max_page_bytes: Optional[int] = None, progress: Optional[FragmentWriteProgress] = None, 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, + target_all_bases: Optional[bool] = None, initial_bases: Optional[List["DatasetBasePath"]] = None, + base_store_params: Optional[Dict[str, Dict[str, 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, + session: Optional[Session] = None, ) -> List[FragmentMetadata] | Transaction: """ Write data into one or more fragments. @@ -949,14 +1278,22 @@ def write_fragments( "overwrite" to assign new field ids to the schema. max_rows_per_file : int, default 1024 * 1024 The maximum number of rows per data file. - max_rows_per_group : int, default 1024 - The maximum number of rows per group in the data file. + max_rows_per_group : int, optional, default 1024 + The maximum number of rows per group in the data file. ``None`` leaves + the writer default in place. max_bytes_per_file : int, default 90 * 1024 * 1024 * 1024 The max number of bytes to write before starting a new file. This is a soft limit. This limit is checked after each group is written, which means larger groups may cause this to be overshot meaningfully. This defaults to 90 GB, since we have a hard limit of 100 GB per file on object stores. + data_cache_bytes : int, optional + Total bytes to buffer for column data before writing pages. The budget + is divided evenly across top-level columns. If not set, the current + file writer uses 8 MiB per column. Ignored for legacy V1 files. + max_page_bytes : int, optional + Best-effort maximum page size in bytes. If not set, the current file + writer uses its configured default. Ignored for legacy V1 files. progress : FragmentWriteProgress, optional *Experimental API*. Progress tracking for writing the fragment. Pass a custom class that defines hooks to be called when each fragment is @@ -971,15 +1308,13 @@ 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. - This makes compaction more efficient, since with stable row ids no - secondary indices need to be updated to point to new row ids. + Has no effect. Row ids are not assigned by this function; they are + assigned when the fragments are committed to a dataset (e.g. via + :class:`lance.LanceOperation.Append`, or + :class:`lance.LanceOperation.Merge` when merging staged fragments + with existing ones, together with :meth:`lance.LanceDataset.commit`), + based on whether the target dataset uses stable row ids. target_bases : list of str, optional References to base paths where data should be written. Can be specified in all modes. @@ -991,6 +1326,11 @@ def write_fragments( **CREATE mode**: References must match bases in `initial_bases` **APPEND/OVERWRITE modes**: References must match bases in the existing manifest + target_all_bases : bool, optional + Write new data files round-robin across every base registered in the + manifest, resolved at execution time. When True, the dataset's + primary storage participates as the first slot. Cannot be combined + with `target_bases`. initial_bases : list of DatasetBasePath, optional Base paths to register when creating a new dataset (CREATE mode only). @@ -1002,6 +1342,37 @@ def write_fragments( **Only valid in CREATE mode**. Will raise an error if used with APPEND/OVERWRITE modes. + base_store_params : dict of str to dict, optional + Runtime-only object store parameters keyed by exact base path URI. + Each value is a dict of storage options for that base. These settings + are not persisted to the manifest. When a base has no explicit entry, + top-level ``storage_options`` is used as a fallback. If ``dataset_uri`` + is a LanceDataset and this is omitted, the dataset's base store params + are inherited. + 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 a registered non-dataset-root + base path. If True, external blob URIs outside registered bases are + allowed. Only valid when ``external_blob_mode="reference"``. Setting + this to True with ``"ingest"`` mode raises an error. + 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`. + session : optional, Session + A session to reuse across operations. The session holds shared caches + (metadata and index) and the object store registry. Returns ------- @@ -1019,11 +1390,31 @@ 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): dataset_uri = str(dataset_uri) elif isinstance(dataset_uri, LanceDataset): + if base_store_params is None: + base_store_params = dataset_uri._base_store_params + if storage_options is None: + storage_options = dataset_uri._storage_options + if session is not None and not session.is_same_as(dataset_uri.session()): + raise ValueError( + "The provided session is not the destination dataset's own " + "session. Please pass the dataset's session or omit the " + "'session' parameter." + ) dataset_uri = dataset_uri._ds elif not isinstance(dataset_uri, str): raise TypeError(f"Unknown dataset_uri type {type(dataset_uri)}") @@ -1047,13 +1438,21 @@ def write_fragments( max_rows_per_file=max_rows_per_file, max_rows_per_group=max_rows_per_group, max_bytes_per_file=max_bytes_per_file, + data_cache_bytes=data_cache_bytes, + max_page_bytes=max_page_bytes, 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, + target_all_bases=target_all_bases, initial_bases=initial_bases, + base_store_params=base_store_params, + external_blob_mode=external_blob_mode, + allow_external_blob_outside_bases=allow_external_blob_outside_bases, + session=session, ) diff --git a/python/python/lance/indices/__init__.py b/python/python/lance/indices/__init__.py index ac586876da0..675754cc2d0 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"] +IndexSegmentDescription = _lance.indices.IndexSegmentDescription +IndexSegment = _lance.indices.IndexSegment + +__all__ = [ + "IndicesBuilder", + "IndexConfig", + "PqModel", + "IvfModel", + "IndexFileVersion", + "IndexSegment", + "IndexSegmentDescription", +] class IndexFileVersion(str, Enum): @@ -19,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/lance/indices/builder.py b/python/python/lance/indices/builder.py index ca033780a0e..e235f348979 100644 --- a/python/python/lance/indices/builder.py +++ b/python/python/lance/indices/builder.py @@ -51,11 +51,14 @@ 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) - self.dimension = self.dataset.schema.field(self.column[0]).type.list_size + self.dimension = self._vector_dimension( + self.dataset.schema.field(self.column[0]).type + ) def train_ivf( self, @@ -65,6 +68,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. @@ -89,7 +93,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 @@ -105,8 +109,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) @@ -123,9 +129,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 @@ -139,7 +150,7 @@ def train_ivf( max_iters=max_iters, ) num_dims = ivf_centroids.shape[1] - ivf_centroids.shape = -1 + ivf_centroids = ivf_centroids.reshape(-1) flat_centroids_array = pa.array(ivf_centroids) centroids_array = pa.FixedSizeListArray.from_arrays( flat_centroids_array, num_dims @@ -153,6 +164,8 @@ def train_pq( *, sample_rate: int = 256, max_iters: int = 50, + num_bits: int = 8, + fragment_ids: Optional[list[int]] = None, ) -> PqModel: """ Train a PQ model for a given column. @@ -183,13 +196,16 @@ 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. + num_bits: int + The number of bits used to encode each PQ centroid. + 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() - self.dataset.schema.field(self.column[0]).type.list_size + num_rows = self._count_rows(fragment_ids) num_subvectors = self._normalize_pq_params(num_subvectors, self.dimension) - self._verify_pq_sample_rate(num_rows, sample_rate) + self._verify_pq_sample_rate(num_rows, sample_rate, num_bits) distance_type = ivf_model.distance_type pq_codebook = indices.train_pq_model( self.dataset._ds, @@ -200,8 +216,10 @@ def train_pq( sample_rate, max_iters, ivf_model.centroids, + fragment_ids, + num_bits, ) - return PqModel(num_subvectors, pq_codebook) + return PqModel(num_subvectors, pq_codebook, num_bits=num_bits) def prepare_global_ivf_pq( self, @@ -212,11 +230,18 @@ def prepare_global_ivf_pq( accelerator: Optional[Union[str, "torch.Device"]] = None, sample_rate: int = 256, max_iters: int = 50, + num_bits: int = 8, + 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 @@ -238,6 +263,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 @@ -246,6 +272,8 @@ def prepare_global_ivf_pq( num_subvectors, sample_rate=sample_rate, max_iters=max_iters, + num_bits=num_bits, + fragment_ids=fragment_ids, ) return {"ivf_centroids": ivf_model.centroids, "pq_codebook": pq_model.codebook} @@ -338,7 +366,7 @@ def transform_vectors( """ from lance.lance import indices - dimension = self.dataset.schema.field(self.column[0]).type.list_size + dimension = self.dimension num_subvectors = pq.num_subvectors distance_type = ivf.distance_type if fragments is None: @@ -359,6 +387,7 @@ def transform_vectors( dest_uri, fragments, partition_ds_uri, + pq.num_bits, ) def shuffle_transformed_vectors( @@ -431,7 +460,7 @@ def load_shuffled_vectors( The PQ model used to create the inputs. """ - pq_dimension = self.dataset.schema.field(self.column[0]).type.list_size + pq_dimension = self.dimension num_subvectors = pq.num_subvectors distance_type = ivf.distance_type @@ -449,6 +478,7 @@ def load_shuffled_vectors( num_subvectors, distance_type, index_name, + pq.num_bits, ) else: raise ValueError("filenames must be a list of strings") @@ -458,6 +488,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: @@ -492,13 +534,17 @@ def _verify_base_sample_rate(self, sample_rate: int): f"The sample_rate must be an int greater than 1, got {sample_rate}" ) - def _verify_pq_sample_rate(self, num_rows: int, sample_rate: int): + def _verify_pq_sample_rate( + self, num_rows: int, sample_rate: int, num_bits: int = 8 + ): self._verify_base_sample_rate(sample_rate) - if 256 * sample_rate > num_rows: + required_rows = (2**num_bits) * sample_rate + if required_rows > num_rows: raise ValueError( "There are not enough rows in the dataset to create PQ" - f" codebook with a sample rate of {sample_rate}. {sample_rate * 256}" - f" rows needed and there are {num_rows}" + f" codebook with a sample rate of {sample_rate} and num_bits" + f" of {num_bits}. {required_rows} rows needed and there are" + f" {num_rows}" ) def _verify_ivf_sample_rate( @@ -529,6 +575,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() @@ -544,25 +591,46 @@ def _normalize_column(self, column): if c not in self.dataset.schema.names: raise KeyError(f"{c} not found in schema") field = self.dataset.schema.field(c) - if not ( - pa.types.is_fixed_size_list(field.type) - or ( - isinstance(field.type, pa.FixedShapeTensorType) - and len(field.type.shape) == 1 - ) - ): + vector_type = self._describe_vector_type(field.type) + if vector_type is None: raise TypeError( - f"Vector column {c} must be FixedSizeListArray " + f"Vector column {c} must be FixedSizeListArray, " + "list (multivector), or " f"1-dimensional FixedShapeTensorArray, got {field.type}" ) - if not pa.types.is_floating(field.type.value_type): + _, value_type = vector_type + if not ( + pa.types.is_floating(value_type) + or pa.types.is_unsigned_integer(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 {value_type}" ) return column + def _vector_dimension(self, data_type): + vector_type = self._describe_vector_type(data_type) + if vector_type is not None: + return vector_type[0] + raise TypeError( + "Vector column must be FixedSizeListArray, " + "list (multivector), or " + f"1-dimensional FixedShapeTensorArray, got {data_type}" + ) + + def _describe_vector_type(self, data_type): + if pa.types.is_fixed_size_list(data_type): + return data_type.list_size, data_type.value_type + if pa.types.is_list(data_type) and pa.types.is_fixed_size_list( + data_type.value_type + ): + return data_type.value_type.list_size, data_type.value_type.value_type + if isinstance(data_type, pa.FixedShapeTensorType) and len(data_type.shape) == 1: + return data_type.shape[0], data_type.value_type + return None + @dataclass class IndexConfig: 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..e3d334ccf48 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 @@ -12,9 +14,13 @@ class PqModel: Can be saved / loaded to checkpoint progress. """ - def __init__(self, num_subvectors: int, codebook: pa.FixedSizeListArray): + def __init__( + self, num_subvectors: int, codebook: pa.FixedSizeListArray, *, num_bits: int = 8 + ): self.num_subvectors = num_subvectors """The number of subvectors to divide source vectors into""" + self.num_bits = num_bits + """The number of bits used to encode each PQ centroid""" self.codebook = codebook """The centroids of the PQ clusters""" @@ -23,7 +29,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,19 +39,25 @@ 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, pa.schema( [pa.field("codebook", self.codebook.type)], - metadata={b"num_subvectors": str(self.num_subvectors).encode()}, + metadata={ + b"num_subvectors": str(self.num_subvectors).encode(), + b"num_bits": str(self.num_bits).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,12 +67,15 @@ 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 + metadata = reader.metadata().schema.metadata or {} num_subvectors = int(metadata[b"num_subvectors"].decode()) + num_bits = int(metadata.get(b"num_bits", b"8").decode()) codebook = ( reader.read_all(batch_size=num_rows).to_table().column("codebook").chunk(0) ) - return cls(num_subvectors, codebook) + return cls(num_subvectors, codebook, num_bits=num_bits) 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 d976bea8cf4..d98e118df2a 100644 --- a/python/python/lance/lance/__init__.pyi +++ b/python/python/lance/lance/__init__.pyi @@ -12,6 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. +from datetime import timedelta from pathlib import Path from typing import ( Any, @@ -20,6 +21,7 @@ from typing import ( Iterable, Iterator, List, + Literal, Optional, Self, Sequence, @@ -28,6 +30,7 @@ from typing import ( ) import pyarrow as pa +from lance_namespace import LanceNamespace from .._arrow.bf16 import BFloat16Array from ..commit import CommitLock @@ -41,15 +44,17 @@ from ..dataset import ( Transaction, UpdateResult, Version, + VersionRef, ) from ..fragment import ( DataFile, FragmentMetadata, ) -from ..io import StorageOptionsProvider 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 .bitmap import Bitmap as Bitmap from .debug import format_fragment as format_fragment from .debug import format_manifest as format_manifest from .debug import format_schema as format_schema @@ -60,7 +65,12 @@ from .fragment import ( from .fragment import ( RowIdMeta as RowIdMeta, ) +from .fragment import ( + RowIdSequence as RowIdSequence, +) from .indices import IndexDescription as IndexDescription +from .indices import IndexSegment as IndexSegment +from .lance import PySearchFilter from .optimize import ( Compaction as Compaction, ) @@ -82,9 +92,90 @@ from .trace import capture_trace_events as capture_trace_events from .trace import shutdown_tracing as shutdown_tracing from .trace import trace_to_chrome as trace_to_chrome +class MetricPoint: + name: str + kind: str + attributes: Dict[str, str] + value: Optional[float] + buckets: Optional[List[Tuple[str, int]]] + count: Optional[int] + sum: Optional[float] + +class MetricDescription: + name: str + kind: str + unit: Optional[str] + description: str + +def register_lance_metrics_recorder() -> bool: ... +def lance_metrics_catalog() -> List[MetricDescription]: ... +def snapshot_lance_metrics() -> List[MetricPoint]: ... + +class FtsToken: + text: str + position: int + def __repr__(self) -> str: ... + +def tokenize( + query: str, + *, + analyzer: Optional[Literal["text", "code"]] = None, + base_tokenizer: Optional[str] = None, + language: Optional[str] = None, + max_token_length: Optional[int] = 40, + lower_case: Optional[bool] = None, + stem: Optional[bool] = None, + remove_stop_words: Optional[bool] = None, + custom_stop_words: Optional[List[str]] = None, + ascii_folding: Optional[bool] = None, + min_ngram_length: Optional[int] = None, + max_ngram_length: Optional[int] = None, + prefix_only: Optional[bool] = None, + split_identifiers: Optional[bool] = None, + split_on_numerics: Optional[bool] = None, + preserve_original: Optional[bool] = None, + index_operators: Optional[bool] = None, +) -> List[FtsToken]: + """Tokenize an FTS query without an index. + + ``max_token_length`` defaults to 40; pass ``None`` to disable the limit. + """ + ... + class CleanupStats: bytes_removed: int old_versions: int + data_files_removed: int + transaction_files_removed: int + index_files_removed: int + deletion_files_removed: int + failed_deletes: int + +class CleanupCandidateFile: + path: str + kind: str + unverified: bool + size_bytes: int + +class CleanupReferencedBranch: + name: str + referenced_version: int + cleanup_candidate: bool + +class CleanupExplanation: + read_version: int + stats: CleanupStats + candidate_files: List[CleanupCandidateFile] + candidate_files_truncated: bool + candidate_file_limit: int + referenced_branches: List[CleanupReferencedBranch] + warnings: List[str] + +class LanceFileWriteSummary: + num_rows: int + size_bytes: int + + def __repr__(self) -> str: ... class LanceFileWriter: def __init__( @@ -94,21 +185,69 @@ 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], ): ... def write_batch(self, batch: pa.RecordBatch) -> None: ... - def finish(self) -> int: ... + def finish(self) -> LanceFileWriteSummary: ... def add_schema_metadata(self, key: str, value: str) -> None: ... def add_global_buffer(self, data: bytes) -> int: ... +class BlobDescriptor: + def __repr__(self) -> str: ... + +class PackedBlobWriter: + @property + def blob_id(self) -> int: ... + @property + def path(self) -> str: ... + @property + def field(self) -> pa.Field: ... + def write_blob(self, data: bytes) -> None: ... + def write_blobs( + self, + payloads: Union[ + pa.BinaryArray, + pa.LargeBinaryArray, + pa.BinaryViewArray, + pa.FixedSizeBinaryArray, + pa.ChunkedArray, + ], + ) -> None: ... + def finish(self) -> List[BlobDescriptor]: ... + def finish_array(self, field_name: str) -> pa.StructArray: ... + +class DedicatedBlobWriter: + @property + def blob_id(self) -> int: ... + @property + def path(self) -> str: ... + def write(self, data: bytes) -> None: ... + def finish(self) -> BlobDescriptor: ... + +class BlobDescriptorArrayBuilder: + def __init__(self, column: str): ... + @property + def field(self) -> pa.Field: ... + def extend_packed( + self, blob_id: int, offsets: List[int], sizes: List[int] + ) -> None: ... + def append_dedicated(self, blob_id: int, size: int) -> None: ... + def append(self, value: BlobDescriptor) -> None: ... + def extend(self, values: List[BlobDescriptor]) -> None: ... + def append_inline(self, data: bytes) -> None: ... + def append_null(self) -> None: ... + def finish(self) -> pa.Array: ... + class LanceFileSession: 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, ): ... def open_reader( self, path: str, columns: Optional[List[str]] = None @@ -122,8 +261,17 @@ class LanceFileSession: keep_original_array: Optional[bool] = None, max_page_bytes: Optional[int] = None, ) -> LanceFileWriter: ... + def open_packed_blob_writer(self, path: str, blob_id: int) -> PackedBlobWriter: ... + def open_dedicated_blob_writer( + self, path: str, blob_id: int + ) -> DedicatedBlobWriter: ... def contains(self, path: str) -> bool: ... def list(self, path: Optional[str] = None) -> List[str]: ... + def list_with_delimiter( + self, path: Optional[str] = None + ) -> tuple[List[str], List[str]]: ... + def read_range(self, path: str, offset: int, length: int) -> bytes: ... + def delete_file(self, path: str) -> None: ... def upload_file(self, local_path: str, remote_path: str) -> None: ... def download_file(self, remote_path: str, local_path: str) -> None: ... @@ -132,7 +280,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( @@ -178,7 +327,25 @@ class LanceColumnStatistics: size_bytes: int class _Session: + def __init__( + self, + index_cache_size_bytes: Optional[int] = None, + metadata_cache_size_bytes: Optional[int] = None, + index_cache_backend: Optional[str | Dict[str, Any]] = None, + metadata_cache_backend: Optional[str | Dict[str, Any]] = None, + ) -> None: + """Create a Lance session. + + Cache backends may be backend URI strings such as + ``"moka://?capacity=1048576"`` or dictionaries such as + ``{"kind": "moka", "options": {"capacity": "1048576"}}``. + ``index_cache_backend`` is mutually exclusive with + ``index_cache_size_bytes``. ``metadata_cache_backend`` is mutually + exclusive with ``metadata_cache_size_bytes``. + """ + ... def size_bytes(self) -> int: ... + def index_cache_size_bytes(self) -> int: ... class LanceBlobFile: def close(self): ... @@ -187,7 +354,11 @@ class LanceBlobFile: def tell(self) -> int: ... def size(self) -> int: ... def readall(self) -> bytes: ... + def read_range(self, offset: int, length: int) -> bytes: ... + def read_ranges(self, ranges: List[Tuple[int, int]]) -> List[bytes]: ... def read_into(self, b: bytearray) -> int: ... + def set_buffer_size(self, buffer_size: int) -> None: ... + def _range_submission_count(self) -> int: ... class _Dataset: @property @@ -214,15 +385,19 @@ class _Dataset: def replace_field_metadata(self, field_name: str, metadata: Dict[str, str]): ... @property def data_storage_version(self) -> str: ... + @property + def has_stable_row_ids(self) -> bool: ... def index_statistics(self, index_name: str) -> str: ... def serialized_manifest(self) -> bytes: ... - def load_indices(self) -> List[Index]: ... + def base_paths(self) -> Dict[int, DatasetBasePath]: ... def describe_indices(self) -> List[IndexDescription]: ... + def remap_row_addrs(self, addrs: pa.Array) -> Optional[pa.Array]: ... def scanner( self, columns: Optional[List[str]] = None, columns_with_transform: Optional[List[Tuple[str, str]]] = None, filter: Optional[str] = None, + search_filter: Optional[PySearchFilter] = None, prefilter: Optional[bool] = None, limit: Optional[int] = None, offset: Optional[int] = None, @@ -233,6 +408,7 @@ class _Dataset: fragment_readahead: Optional[int] = None, scan_in_order: Optional[bool] = None, fragments: Optional[List[_Fragment]] = None, + index_segments: Optional[List[str]] = None, with_row_id: Optional[bool] = None, with_row_address: Optional[bool] = None, use_stats: Optional[bool] = None, @@ -243,6 +419,14 @@ class _Dataset: blob_handling: Optional[str] = None, use_scalar_index: Optional[bool] = None, include_deleted_rows: Optional[bool] = None, + scan_stats_callback: Optional[Callable[[Any], None]] = None, + strict_batch_size: Optional[bool] = None, + order_by: Optional[List[Any]] = None, + disable_scoring_autoprojection: Optional[bool] = None, + substrait_aggregate: Optional[bytes] = None, + row_addr_allowlist: Optional[bytes] = None, + row_addr_blocklist: Optional[bytes] = None, + minhash_query: Optional[Dict[str, str]] = None, ) -> _Scanner: ... def count_rows(self, filter: Optional[str] = None) -> int: ... def take( @@ -261,17 +445,46 @@ class _Dataset: self, row_ids: List[int], blob_column: str, - ) -> List[LanceBlobFile]: ... + ) -> List[Optional[LanceBlobFile]]: ... def take_blobs_by_addresses( self, row_addresses: List[int], blob_column: str, - ) -> List[LanceBlobFile]: ... + ) -> List[Optional[LanceBlobFile]]: ... def take_blobs_by_indices( self, row_indices: List[int], blob_column: str, - ) -> List[LanceBlobFile]: ... + ) -> List[Optional[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, Optional[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, Optional[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, Optional[bytes]]]: ... + def read_blob_ranges( + self, + requests: List[Tuple[int, int, int]], + blob_column: str, + selector: Literal["ids", "addresses", "indices"], + io_buffer_size: Optional[int] = None, + preserve_order: Optional[bool] = None, + ) -> List[Tuple[int, int, Optional[bytes]]]: ... def take_scan( self, row_slices: Iterable[Tuple[int, int]], @@ -280,15 +493,20 @@ class _Dataset: ) -> pa.RecordBatchReader: ... def alter_columns(self, alterations: List[AlterColumn]): ... def merge(self, reader: pa.RecordBatchReader, left_on: str, right_on: str): ... - def delete(self, predicate: str): ... + def delete(self, predicate: str | bytes): ... def update( self, updates: Dict[str, str], - predicate: Optional[str] = None, + predicate: Optional[str | bytes] = None, + conflict_retries: Optional[int] = None, + retry_timeout: Optional[timedelta] = None, + data_storage_version: Optional[str] = None, ) -> UpdateResult: ... def count_deleted_rows(self) -> int: ... def versions(self) -> List[Version]: ... + def version_refs(self) -> List[VersionRef]: ... def version(self) -> int: ... + def current_version(self) -> Version: ... def latest_version(self) -> int: ... def checkout_version( self, version: int | str | Tuple[Optional[str], Optional[int]] @@ -303,10 +521,22 @@ class _Dataset: def restore(self): ... def cleanup_old_versions( self, - older_than_micros: int, + older_than_micros: Optional[int] = None, + retain_versions: Optional[int] = None, delete_unverified: Optional[bool] = None, error_if_tagged_old_versions: Optional[bool] = None, + delete_rate_limit: Optional[int] = None, ) -> CleanupStats: ... + def explain_cleanup_old_versions( + self, + older_than_micros: Optional[int] = None, + retain_versions: Optional[int] = None, + delete_unverified: Optional[bool] = None, + error_if_tagged_old_versions: Optional[bool] = None, + delete_rate_limit: Optional[int] = None, + include_files: bool = False, + max_files: int = 1000, + ) -> CleanupExplanation: ... def get_version(self, tag: str) -> int: ... # Tag operations def tags(self) -> Dict[str, Tag]: ... @@ -322,6 +552,11 @@ class _Dataset: tag: str, reference: Optional[int | str | Tuple[Optional[str], Optional[int]]] = None, ): ... + def replace_tag_metadata( + self, + tag: str, + metadata: Dict[str, str], + ) -> None: ... # Branch operations def branches(self) -> Dict[str, Branch]: ... def branches_ordered(self, order: Optional[str]) -> List[Tuple[str, Branch]]: ... @@ -333,6 +568,11 @@ class _Dataset: **kwargs, ) -> _Dataset: ... def delete_branch(self, branch: str) -> None: ... + def replace_branch_metadata( + self, + branch: str, + metadata: Dict[str, str], + ) -> None: ... def optimize_indices(self, **kwargs): ... def create_index( self, @@ -344,10 +584,24 @@ 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, + index_segments: Optional[List[str]] = None, + ): ... 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( + self, index_name: str, column: str, segments: List[Union[IndexSegment, Index]] + ) -> None: ... def count_fragments(self) -> int: ... def num_small_files(self, max_rows_per_group: int) -> int: ... def get_fragments(self) -> List[_Fragment]: ... @@ -368,10 +622,11 @@ 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, + enable_stable_row_ids: Optional[bool] = None, + commit_timeout: Optional[timedelta] = None, **kwargs, ) -> _Dataset: ... @staticmethod @@ -380,10 +635,10 @@ 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, + commit_timeout: Optional[timedelta] = None, ) -> Tuple[_Dataset, Transaction]: ... def validate(self): ... def migrate_manifest_paths_v2(self): ... @@ -402,6 +657,50 @@ class _Dataset: def get_transactions( self, recent_transactions=10 ) -> List[Optional[Transaction]]: ... + def find_duplicate_pairs( + self, + column: str, + distance_threshold: float, + *, + memory_limit: Optional[int] = None, + max_concurrency: Optional[int] = None, + ) -> pa.RecordBatchReader: ... + def find_duplicate_pairs_in_partition( + self, + column: str, + segment_id: str, + partition_id: int, + distance_threshold: float, + *, + memory_limit: Optional[int] = None, + max_concurrency: Optional[int] = None, + ) -> pa.RecordBatchReader: ... + def hamming_clustering_for_ivf_partition( + self, + index_name: str, + partition_id: int, + hamming_threshold: int, + index_segments: Optional[List[str]] = None, + ) -> pa.RecordBatchReader: ... + def get_ivf_partition_info( + self, + index_name: str, + index_segments: Optional[List[str]] = None, + ) -> List[dict]: ... + def hamming_clustering_for_sample( + self, + column: str, + sample_size: Optional[int], + hamming_threshold: int, + ) -> pa.RecordBatchReader: ... + def hamming_clustering_for_range( + self, + column: str, + fragment_id: int, + start_row: int, + num_rows: int, + hamming_threshold: int, + ) -> pa.RecordBatchReader: ... class _MergeInsertBuilder: def __init__(self, dataset: _Dataset, on: str | Iterable[str]): ... @@ -409,13 +708,26 @@ class _MergeInsertBuilder: def when_matched_fail(self) -> Self: ... def when_not_matched_insert_all(self) -> Self: ... def when_not_matched_by_source_delete(self, expr: Optional[str] = None) -> Self: ... + def write_mode( + self, mode: Literal["auto", "rewrite_rows", "rewrite_columns"] + ) -> Self: ... + def data_storage_version(self, version: str) -> Self: ... + def target_bases(self, bases: list[str]) -> Self: ... + def target_all_bases(self, include_primary: bool = True) -> Self: ... def execute(self, new_data: pa.RecordBatchReader) -> ExecuteResult: ... + def execute_batches(self, new_data: pa.RecordBatchReader) -> ExecuteResult: ... + def execute_uncommitted( + self, new_data: pa.RecordBatchReader + ) -> tuple[Transaction, ExecuteResult]: ... + def execute_uncommitted_batches( + self, new_data: pa.RecordBatchReader + ) -> tuple[Transaction, ExecuteResult]: ... class _Scanner: @property def schema(self) -> pa.Schema: ... def explain_plan(self, verbose: bool) -> str: ... - def analyze_plan(self) -> str: ... + def analyze_plan(self, count_rows: bool = False) -> str: ... def count_rows(self) -> int: ... def to_pyarrow(self) -> pa.RecordBatchReader: ... @@ -454,6 +766,13 @@ class _Fragment: batch_readahead: Optional[int] = None, blob_handling: Optional[str] = None, order_by: Optional[List[Any]] = None, + use_scalar_index: Optional[bool] = None, + io_buffer_size: Optional[int] = None, + late_materialization: Optional[bool | List[str]] = None, + include_deleted_rows: Optional[bool] = None, + batch_size_bytes: Optional[int] = None, + strict_batch_size: Optional[bool] = None, + substrait_filter: Optional[bytes] = None, ) -> _Scanner: ... def add_columns_from_reader( self, @@ -467,6 +786,7 @@ class _Fragment: batch_size: Optional[int], ) -> Tuple[FragmentMetadata, LanceSchema]: ... def delete(self, predicate: str) -> Optional[_Fragment]: ... + def delete_rows(self, offsets: List[int]) -> Optional[_Fragment]: ... def schema(self) -> pa.Schema: ... def data_files(self) -> List[DataFile]: ... def deletion_file(self) -> Optional[str]: ... @@ -474,6 +794,7 @@ class _Fragment: def physical_rows(self) -> int: ... @property def num_deletions(self) -> int: ... + def validate(self) -> None: ... def iops_counter() -> int: ... def bytes_read_counter() -> int: ... @@ -491,7 +812,16 @@ 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, + target_bases: Optional[List[str]] = None, + target_all_bases: Optional[bool] = None, + initial_bases: Optional[List[Any]] = None, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, + external_blob_mode: Literal["reference", "ingest"] = "reference", + allow_external_blob_outside_bases: bool = False, + session: Optional[_Session] = None, ): ... def _write_fragments_transaction( dataset_uri: str | Path | _Dataset, @@ -503,10 +833,98 @@ 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, + target_bases: Optional[List[str]] = None, + target_all_bases: Optional[bool] = None, + initial_bases: Optional[List[Any]] = None, + base_store_params: Optional[Dict[str, Dict[str, str]]] = None, + external_blob_mode: Literal["reference", "ingest"] = "reference", + allow_external_blob_outside_bases: bool = False, + session: Optional[_Session] = None, ) -> Transaction: ... def _json_to_schema(schema_json: str) -> pa.Schema: ... def _schema_to_json(schema: pa.Schema) -> str: ... +def _parse_field_path(path: str) -> list[str]: ... +def _format_field_path(segments: list[str]) -> str: ... +def _evaluate_sharding_spec( + batch: pa.RecordBatch, + spec: Dict[str, Any], + schema: LanceSchema, +) -> pa.RecordBatch: ... + +class _CompactedSsTable: + shard_id: str + generation: int + def __init__(self, shard_id: str, generation: int) -> None: ... + +class _ShardSnapshot: + shard_id: str + def __init__(self, shard_id: str) -> None: ... + def with_spec_id(self, spec_id: int) -> Self: ... + def with_current_generation(self, generation: int) -> Self: ... + def with_sstable(self, generation: int, path: str) -> Self: ... + +class _ShardWriter: + shard_id: str + def put(self, data: Any) -> None: ... + def delete(self, keys: Any) -> None: ... + def close(self) -> None: ... + def stats(self) -> Dict[str, Any]: ... + def memtable_stats(self) -> Dict[str, Any]: ... + def lsm_scanner( + self, shard_snapshots: Optional[List[_ShardSnapshot]] = None + ) -> _LsmScanner: ... + +class _LsmScanner: + @staticmethod + def from_snapshots( + dataset: _Dataset, shard_snapshots: List[_ShardSnapshot] + ) -> _LsmScanner: ... + def project(self, columns: List[str]) -> Self: ... + def filter(self, expr: str) -> Self: ... + def limit(self, n: int, offset: Optional[int] = None) -> Self: ... + def with_row_address(self) -> Self: ... + def with_memtable_gen(self) -> Self: ... + def to_batch(self) -> pa.RecordBatch: ... + def to_batches(self) -> List[pa.RecordBatch]: ... + def count_rows(self) -> int: ... + +class _ExecutionPlan: + schema: pa.Schema + dataset_schema: pa.Schema + def explain(self) -> str: ... + def to_reader(self) -> pa.RecordBatchReader: ... + def to_batches(self) -> List[pa.RecordBatch]: ... + +class _LsmPointLookupPlanner: + def __init__( + self, + dataset: _Dataset, + shard_snapshots: List[_ShardSnapshot], + pk_columns: Optional[List[str]] = None, + ) -> None: ... + def plan_lookup( + self, pk_value: pa.Array, columns: Optional[List[str]] = None + ) -> _ExecutionPlan: ... + +class _LsmVectorSearchPlanner: + def __init__( + self, + dataset: _Dataset, + shard_snapshots: List[_ShardSnapshot], + vector_column: str, + pk_columns: Optional[List[str]] = None, + distance_type: Optional[str] = None, + ) -> None: ... + def plan_search( + self, + query: pa.Array, + k: int = 10, + nprobes: int = 20, + columns: Optional[List[str]] = None, + ) -> _ExecutionPlan: ... class _Hnsw: @staticmethod @@ -545,7 +963,7 @@ class BFloat16: def __gt__(self, other: BFloat16) -> bool: ... def __ge__(self, other: BFloat16) -> bool: ... -def bfloat16_array(values: List[str | None]) -> BFloat16Array: ... +def bfloat16_array(values: Sequence[float | None]) -> BFloat16Array: ... class PyFullTextQuery: @staticmethod @@ -575,6 +993,13 @@ class PyFullTextQuery: boosts: Optional[List[float]] = None, operator: str = "OR", ) -> PyFullTextQuery: ... + @staticmethod + def combined_fields_query( + query: str, + columns: List[str], + boosts: Optional[List[float]] = None, + operator: str = "OR", + ) -> PyFullTextQuery: ... class ScanStatistics: """Statistics about a scan operation.""" @@ -585,9 +1010,39 @@ class ScanStatistics: indices_loaded: int parts_loaded: int index_comparisons: int + index_cache_hits: int + """Number of index cache page lookups where the loader was not executed + in this scan. Counts both true cache hits on already-populated entries + and coalesced concurrent loads (a follower attached to another caller's + in-flight load). + + Instrumented boundaries in this release: BTree, IVF v2 (write-cache scan + path), inverted posting list (grouped and per-token) and its per-token + metadata, inverted phrase positions, bitmap (Equals / Range / IsIn), + ngram, rtree. + + Caveats: + + * IVF v2 streaming scans and legacy v1 IVF partitions bypass the cache + by design and are therefore reported as a miss on every call. + * A cold posting-list lookup on the grouped inverted layout can record + up to two misses (group + per-token metadata) for a single term. + + Uninstrumented paths (HNSW graph pages, quantizer codebooks) do not + contribute to either counter.""" + index_cache_misses: int + """Number of index cache page lookups where the loader ran (the page was + not resident and had to be materialised, typically from storage). See + the sibling ``index_cache_hits`` for the paired counter and the list of + instrumented boundaries.""" all_counts: Dict[ str, int ] # Additional metrics for debugging purposes. Subject to change. + all_times: Dict[str, int] + """Additional debugging timings in nanoseconds. Keys are subject to change. + + Nested and concurrent stages overlap; summing these values does not + reconstruct query wall time.""" class DatasetBasePath: def __init__( @@ -597,6 +1052,14 @@ class DatasetBasePath: is_dataset_root: bool = False, id: Optional[int] = None, ) -> None: ... + @property + def id(self) -> int: ... + @property + def name(self) -> Optional[str]: ... + @property + def path(self) -> str: ... + @property + def is_dataset_root(self) -> bool: ... __version__: str language_model_home: Callable[[], str] diff --git a/python/python/lance/lance/bitmap.pyi b/python/python/lance/lance/bitmap.pyi new file mode 100644 index 00000000000..f2bfcd7df51 --- /dev/null +++ b/python/python/lance/lance/bitmap.pyi @@ -0,0 +1,66 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +from typing import Iterable, Iterator, Optional, Union + +import pyarrow as pa + +class Bitmap: + """ + A set of non-negative integers backed by a Rust ``RoaringBitmap``. + + Cheap to clone and to pass into Lance APIs that accept a bitmap, since no + per-value Python object is created. Mutating methods (``add``, + ``discard``, ``update``, ...) copy-on-write: cloning a ``Bitmap`` and + mutating one copy never affects the other. + + Implements the ``collections.abc.MutableSet`` interface and is registered + as one, so ``isinstance(b, collections.abc.Set)`` holds. + ``isinstance(b, set)`` does not: it is a distinct type, not a ``set`` + subclass. + + Anywhere a ``Bitmap`` accepts another set, it also accepts any iterable of + ints; order and repeats in that iterable are not significant. + """ + + def __init__( + self, + values: Optional[ + Union[Iterable[int], pa.Array, pa.ChunkedArray, "Bitmap"] + ] = None, + ) -> None: ... + def __len__(self) -> int: ... + def __contains__(self, value: object) -> bool: ... + def __iter__(self) -> Iterator[int]: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + def __le__(self, other: Iterable[int]) -> bool: ... + def __lt__(self, other: Iterable[int]) -> bool: ... + def __ge__(self, other: Iterable[int]) -> bool: ... + def __gt__(self, other: Iterable[int]) -> bool: ... + def __and__(self, other: Iterable[int]) -> "Bitmap": ... + def __rand__(self, other: Iterable[int]) -> "Bitmap": ... + def __or__(self, other: Iterable[int]) -> "Bitmap": ... + def __ror__(self, other: Iterable[int]) -> "Bitmap": ... + def __sub__(self, other: Iterable[int]) -> "Bitmap": ... + def __rsub__(self, other: Iterable[int]) -> "Bitmap": ... + def __xor__(self, other: Iterable[int]) -> "Bitmap": ... + def __rxor__(self, other: Iterable[int]) -> "Bitmap": ... + def __iand__(self, other: Iterable[int]) -> "Bitmap": ... + def __ior__(self, other: Iterable[int]) -> "Bitmap": ... + def __isub__(self, other: Iterable[int]) -> "Bitmap": ... + def __ixor__(self, other: Iterable[int]) -> "Bitmap": ... + def intersection(self, other: Iterable[int]) -> "Bitmap": ... + def union(self, other: Iterable[int]) -> "Bitmap": ... + def difference(self, other: Iterable[int]) -> "Bitmap": ... + def symmetric_difference(self, other: Iterable[int]) -> "Bitmap": ... + def issubset(self, other: Iterable[int]) -> bool: ... + def issuperset(self, other: Iterable[int]) -> bool: ... + def isdisjoint(self, other: Iterable[int]) -> bool: ... + def copy(self) -> "Bitmap": ... + def add(self, value: int) -> None: ... + def discard(self, value: int) -> None: ... + def remove(self, value: int) -> None: ... + def pop(self) -> int: ... + def clear(self) -> None: ... + def update(self, values: Iterable[int]) -> None: ... diff --git a/python/python/lance/lance/fragment.pyi b/python/python/lance/lance/fragment.pyi index 6e80f847620..c92d59b1657 100644 --- a/python/python/lance/lance/fragment.pyi +++ b/python/python/lance/lance/fragment.pyi @@ -1,7 +1,9 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors -from typing import Literal, Optional +from typing import Iterable, Iterator, Literal, Optional, Union + +import pyarrow as pa class DeletionFile: """ @@ -120,3 +122,86 @@ class RowIdMeta: ... def __reduce__(self) -> tuple: ... + +class RowIdSequence: + """ + The stable row ids of the rows in a single fragment, in fragment order. + + Use this to attach pre-existing row ids to a fragment when assembling a + transaction manually, so that rewritten rows keep their identity:: + + sequence = RowIdSequence([7, 12]) + fragment = FragmentMetadata(..., row_id_meta=sequence.to_inline_metadata()) + + The sequence may be shorter than the fragment's ``physical_rows``. The ids + bind to the leading rows and the commit generates new ids for the remaining + ones, which is how a fragment holding both rewritten and newly inserted rows + is expressed: write the rewritten rows first and supply only their ids. + Passing more ids than the fragment has rows is rejected. + + Warning + ------- + Only duplicates within this sequence are rejected. Row ids must also be + unique across the dataset, and Lance does not re-check that when + committing, so the caller owns it. Supply only row ids that already exist + and are being relocated by the same transaction, which must also remove + every earlier occurrence of them. Do not generate ids for new rows yourself + -- they come from a counter in the manifest that a concurrent commit can + advance, so only the commit knows which values are free. Do not supply + unused row ids either: a sequence covering all of a fragment's rows leaves + the dataset's row id allocator untouched, so a later append will hand the + same id out again. + + Parameters + ---------- + row_ids : range | pa.Array | pa.ChunkedArray | Iterable[int] + The row ids, in the order the corresponding rows appear in the + fragment. A ``range`` with a step of one is stored compactly without + materializing its values. + """ + + def __init__( + self, row_ids: Union[range, pa.Array, pa.ChunkedArray, Iterable[int]] + ) -> None: ... + @staticmethod + def from_inline_metadata(metadata: RowIdMeta) -> RowIdSequence: + """ + Read back a sequence stored inline in fragment row id metadata. + + Parameters + ---------- + metadata : RowIdMeta + Row id metadata holding an inline sequence. Metadata pointing at an + external file is not supported. + + Returns + ------- + RowIdSequence + """ + ... + + def to_inline_metadata(self) -> RowIdMeta: + """ + Encode the sequence as row id metadata to store inline in the manifest. + + Returns + ------- + RowIdMeta + Suitable for the ``row_id_meta`` argument of + :class:`lance.fragment.FragmentMetadata`. + """ + ... + + def to_pyarrow(self) -> pa.UInt64Array: + """ + Get the row ids as a ``uint64`` array, in sequence order. + + Returns + ------- + pa.UInt64Array + """ + ... + + def __len__(self) -> int: ... + def __iter__(self) -> Iterator[int]: ... + def __reduce__(self) -> tuple: ... diff --git a/python/python/lance/lance/indices/__init__.pyi b/python/python/lance/lance/indices/__init__.pyi index feed1f934fe..725d32687e4 100644 --- a/python/python/lance/lance/indices/__init__.pyi +++ b/python/python/lance/lance/indices/__init__.pyi @@ -17,10 +17,20 @@ from typing import Optional import pyarrow as pa +from .. import _Fragment +from ..bitmap import Bitmap + class IndexConfig: index_type: str config: str +class IndexSegment: + uuid: str + fragment_ids: Bitmap + index_version: int + + def __repr__(self) -> str: ... + def train_ivf_model( dataset, column: str, @@ -29,7 +39,8 @@ def train_ivf_model( distance_type: str, sample_rate: int, max_iters: int, -) -> pa.Array: ... + fragment_ids: Optional[list[int]] = None, +) -> pa.FixedSizeListArray: ... def train_pq_model( dataset, column: str, @@ -38,8 +49,12 @@ def train_pq_model( distance_type: str, sample_rate: int, max_iters: int, + # Kept as the ``Array`` base type: callers pass ``IvfModel.centroids``, + # which the public ``IvfModel`` constructor accepts as a plain ``pa.Array``. ivf_model: pa.Array, -) -> pa.Array: ... + fragment_ids: Optional[list[int]] = None, + num_bits: int = 8, +) -> pa.FixedSizeListArray: ... def transform_vectors( dataset, column: str, @@ -49,14 +64,25 @@ def transform_vectors( ivf_centroids: pa.Array, pq_codebook: pa.Array, dst_uri: str, + fragments: list[_Fragment], + partitions_ds_uri: Optional[str] = None, + num_bits: int = 8, ): ... +def build_rq_model( + dimension: int, + num_bits: int = 5, + dtype: str = "float32", +) -> str: ... class IndexSegmentDescription: uuid: str dataset_version_at_last_update: int - fragment_ids: set[int] + fragment_ids: Bitmap index_version: int created_at: Optional[datetime] + size_bytes: Optional[int] + base_id: Optional[int] + covering_fields: list[int] def __repr__(self) -> str: ... @@ -69,5 +95,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/lance/lance/optimize.pyi b/python/python/lance/lance/optimize.pyi index 9a26d23c003..c4b6b6546e6 100644 --- a/python/python/lance/lance/optimize.pyi +++ b/python/python/lance/lance/optimize.pyi @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from typing import List +from typing import List, Optional from lance import LanceDataset from lance.fragment import FragmentMetadata @@ -51,5 +51,7 @@ class Compaction: def plan(dataset: "LanceDataset", options: CompactionOptions) -> CompactionPlan: ... @staticmethod def commit( - dataset: "LanceDataset", rewrites: List[RewriteResult] + dataset: "LanceDataset", + rewrites: List[RewriteResult], + options: Optional[CompactionOptions] = None, ) -> CompactionMetrics: ... diff --git a/python/python/lance/lance/schema.pyi b/python/python/lance/lance/schema.pyi index 51a1459779d..49d5229a721 100644 --- a/python/python/lance/lance/schema.pyi +++ b/python/python/lance/lance/schema.pyi @@ -11,9 +11,17 @@ class LanceField: def children(self) -> List[LanceField]: ... def is_unenforced_primary_key(self) -> bool: ... def unenforced_primary_key_position(self) -> Optional[int]: ... + def is_unenforced_clustering_key(self) -> bool: ... + def unenforced_clustering_key_position(self) -> Optional[int]: ... class LanceSchema: + @staticmethod + def _from_protos(metadata_json: str, *field_protos: bytes) -> "LanceSchema": ... def fields(self) -> List[LanceField]: ... + def field(self, name: str) -> Optional[LanceField]: ... + def field_case_insensitive(self, name: str) -> Optional[LanceField]: ... + def unenforced_primary_key(self) -> List[LanceField]: ... + def unenforced_clustering_key(self) -> List[LanceField]: ... def to_pyarrow(self) -> pa.Schema: ... @staticmethod def from_pyarrow(schema: pa.Schema) -> "LanceSchema": ... diff --git a/python/python/lance/mem_wal.py b/python/python/lance/mem_wal.py new file mode 100644 index 00000000000..b9a8890853a --- /dev/null +++ b/python/python/lance/mem_wal.py @@ -0,0 +1,634 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +""" +Python wrappers for Lance MemWAL functionality. + +The MemWAL feature enables high-throughput, low-latency writes to a Lance +dataset via an LSM-tree structure. Data flows through three levels: + +1. **WAL** – append-only durable log (raw writes) +2. **Active MemTable** – in-memory write buffer +3. **SSTable** – Lance files written to object store +4. **Base table** – canonical Lance dataset files (after SSTable compaction) +""" + +from __future__ import annotations + +import math +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, Dict, Iterable, List, Mapping, Optional, Union + +import pyarrow as pa + +from .lance import ( + _CompactedSsTable, + _evaluate_sharding_spec, + _ExecutionPlan, + _LsmPointLookupPlanner, + _LsmScanner, + _LsmVectorSearchPlanner, + _ShardSnapshot, + _ShardWriter, +) +from .types import _coerce_reader + +if TYPE_CHECKING: + import lance + +__all__ = [ + "ShardingField", + "ShardingSpec", + "evaluate_sharding_spec", + "CompactedSsTable", + "ShardSnapshot", + "ShardWriter", + "LsmScanner", + "ExecutionPlan", + "LsmPointLookupPlanner", + "LsmVectorSearchPlanner", +] + + +# --------------------------------------------------------------------------- +# ShardingSpec +# --------------------------------------------------------------------------- + + +@dataclass +class ShardingField: + """Defines one MemWAL sharding field. + + Parameters + ---------- + field_id : str + Identifier for the derived field. + source_ids : list of int + Source field IDs used as inputs. + transform : str, optional + Optional transform name applied to the source fields. + expression : str, optional + Optional expression used to derive the field value. + result_type : str + Output type name for the derived field. + parameters : dict of str to str, optional + Extra transform parameters. + """ + + field_id: str + source_ids: List[int] + transform: Optional[str] = None + expression: Optional[str] = None + result_type: str = "" + parameters: Dict[str, str] = field(default_factory=dict) + + +@dataclass +class ShardingSpec: + """Specification for deriving MemWAL shard routing values.""" + + spec_id: int + fields: List[ShardingField] + + +def evaluate_sharding_spec( + batch: pa.RecordBatch, + spec: Union[ShardingSpec, Mapping[str, object]], + schema: "lance.schema.LanceSchema", +) -> pa.RecordBatch: + """Evaluate a MemWAL sharding spec against one PyArrow RecordBatch. + + Parameters + ---------- + batch : pyarrow.RecordBatch + Input batch containing the sharding source columns. + spec : ShardingSpec or dict + MemWAL sharding spec to evaluate. + schema : LanceSchema + Lance table schema used to resolve source field IDs in the spec to + input batch column names. + """ + if not isinstance(batch, pa.RecordBatch): + raise TypeError(f"Expected pyarrow.RecordBatch, got {type(batch)!r}") + return _evaluate_sharding_spec( + batch, + _sharding_spec_to_dict(spec), + schema, + ) + + +def _sharding_spec_to_dict(spec: Union[ShardingSpec, Mapping[str, object]]) -> dict: + if isinstance(spec, Mapping): + return dict(spec) + return asdict(spec) + + +@dataclass +class CompactedSsTable: + """Points to an SSTable compacted into the base table. + + Pass a list of these to mark_sstables_as_compacted + so Lance can record compaction progress. + + Parameters + ---------- + shard_id : str + UUID string for the write shard. + generation : int + Generation number of the compacted SSTable (as passed to + :meth:`ShardSnapshot.with_sstable`). + """ + + shard_id: str + generation: int + + +class ShardSnapshot: + """Snapshot of a MemWAL shard's state, used when constructing scanners. + + Parameters + ---------- + shard_id : str + UUID string for the write shard. + """ + + def __init__(self, shard_id: str) -> None: + self._raw = _ShardSnapshot(shard_id) + + @property + def shard_id(self) -> str: + """UUID string for this shard.""" + return self._raw.shard_id + + def with_spec_id(self, spec_id: int) -> "ShardSnapshot": + """Set the sharding spec ID.""" + self._raw = self._raw.with_spec_id(spec_id) + return self + + def with_current_generation(self, generation: int) -> "ShardSnapshot": + """Set the current (active) generation number.""" + self._raw = self._raw.with_current_generation(generation) + return self + + def with_sstable(self, generation: int, path: str) -> "ShardSnapshot": + """Add an SSTable with its storage path.""" + self._raw = self._raw.with_sstable(generation, path) + return self + + def __repr__(self) -> str: + return repr(self._raw) + + +class ShardWriter: + """Stateful writer for one MemWAL shard. + + Obtain an instance via mem_wal_writer. + Use as a context manager so the writer is closed automatically:: + + with dataset.mem_wal_writer(shard_id) as writer: + writer.put(batch) + writer.delete(pa.table({"id": [1]})) + + Parameters + ---------- + _raw : _ShardWriter + Internal PyO3 object — do not construct directly. + """ + + def __init__(self, _raw: _ShardWriter) -> None: + self._raw = _raw + + @property + def shard_id(self) -> str: + """UUID string for this writer's shard.""" + return self._raw.shard_id + + def put(self, data, *, schema: Optional[pa.Schema] = None) -> None: + """Write data to the MemWAL. + + Parameters + ---------- + data : ReaderLike + Any Arrow-compatible data `pyarrow.Table`, + `pyarrow.RecordBatch`, ``RecordBatchReader``, pandas + DataFrame, etc. + schema : pa.Schema, optional + Schema hint, needed when *data* is a generator. + + Raises + ------ + IOError + On WAL flush failure. + RuntimeError + If the writer has already been closed. + """ + reader = _coerce_reader(data, schema) + self._raw.put(reader) + + def delete(self, keys, *, schema: Optional[pa.Schema] = None) -> None: + """Delete rows by primary key from the MemWAL. + + Parameters + ---------- + keys : ReaderLike + Any Arrow-compatible data containing this shard's primary key + column(s). Non-primary-key columns, if present, are ignored by + the Rust core delete path. + schema : pa.Schema, optional + Schema hint, needed when *keys* is a generator. + + Raises + ------ + IOError + If delete validation fails, WAL flush fails, or the writer has + already been closed. Delete validation is centralized in Rust and + includes checks for primary-key metadata and tombstone-compatible + nullable non-key columns. + + Examples + -------- + :: + + with dataset.mem_wal_writer(shard_id) as writer: + writer.delete(pa.table({"id": [42]})) + """ + reader = _coerce_reader(keys, schema) + self._raw.delete(reader) + + def close(self) -> None: + """Flush and close the writer. + + After ``close()``, calling :meth:`put` raises an error. + Automatically called when used as a context manager. + """ + self._raw.close() + + def stats(self) -> dict: + """Return a snapshot of write statistics. + + Returns + ------- + dict + Keys: ``put_count``, ``put_time_ms``, ``wal_flush_count``, + ``wal_flush_bytes``, ``wal_flush_time_ms``, + ``memtable_flush_count``, ``memtable_flush_rows``, + ``memtable_flush_time_ms``. + """ + return self._raw.stats() + + def memtable_stats(self) -> dict: + """Return current MemTable statistics. + + Returns + ------- + dict + Keys: ``row_count``, ``batch_count``, ``estimated_size_bytes``, + ``generation``, ``frozen_count``, ``frozen_bytes``. + """ + return self._raw.memtable_stats() + + def lsm_scanner( + self, shard_snapshots: Optional[List[ShardSnapshot]] = None + ) -> "LsmScanner": + """Create an LSM scanner that includes the active MemTable. + + This scanner covers the base table, the given SSTables, + and the current active MemTable — providing strong read-your-writes + consistency. + + Parameters + ---------- + shard_snapshots : list of ShardSnapshot, optional + Snapshots of other shards to include. This writer's own shard + is automatically included. + + Returns + ------- + LsmScanner + """ + raw_snaps = [s._raw for s in (shard_snapshots or [])] + return LsmScanner(self._raw.lsm_scanner(raw_snaps)) + + def __enter__(self) -> "ShardWriter": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> bool: + self.close() + return False + + +class LsmScanner: + """LSM-aware scanner covering all data levels. + + Deduplicates by primary key, always returning the newest version of + each row across base table, SSTables, and the active MemTable. + + Obtain an instance from `ShardWriter.lsm_scanner` (includes + active MemTable) or `LsmScanner.from_snapshots` (SSTables only). + + The builder methods (`project`, `filter`, `limit`) + return ``self`` for chaining. + + Examples + -------- + scanner = LsmScanner.from_snapshots(dataset, [snapshot]) + scanner.project(["id", "value"]).filter("value > 0.5") + table = scanner.to_table() + """ + + def __init__(self, _raw: _LsmScanner) -> None: + self._raw = _raw + + @staticmethod + def from_snapshots( + dataset: "lance.LanceDataset", + shard_snapshots: List[ShardSnapshot], + ) -> "LsmScanner": + """Create a scanner from dataset and shard snapshots. + + Does **not** include the active MemTable; use + `ShardWriter.lsm_scanner` for that. + + Parameters + ---------- + dataset : LanceDataset + The base dataset to scan. + shard_snapshots : list of ShardSnapshot + Shard snapshots specifying SSTables to include. + """ + raw = _LsmScanner.from_snapshots(dataset._ds, [s._raw for s in shard_snapshots]) + return LsmScanner(raw) + + def project(self, columns: List[str]) -> "LsmScanner": + """Select specific columns to return.""" + self._raw = self._raw.project(columns) + return self + + def filter(self, expr: str) -> "LsmScanner": + """Set a SQL filter expression (e.g. ``"value > 0.5"``).""" + self._raw = self._raw.filter(expr) + return self + + def limit( + self, n: Optional[int] = None, offset: Optional[int] = None + ) -> "LsmScanner": + """Limit rows returned, optionally with an offset.""" + self._raw = self._raw.limit(n, offset) + return self + + def with_row_address(self) -> "LsmScanner": + """Include the ``_rowaddr`` internal column in results.""" + self._raw = self._raw.with_row_address() + return self + + def with_memtable_gen(self) -> "LsmScanner": + """Include the ``_memtable_gen`` internal column in results.""" + self._raw = self._raw.with_memtable_gen() + return self + + def to_batch(self) -> pa.RecordBatch: + """Execute the scan and return a single merged :class:`~pyarrow.RecordBatch`.""" + return self._raw.to_batch() + + def to_batches(self) -> List[pa.RecordBatch]: + """Execute the scan and return a list of :class:`~pyarrow.RecordBatch`.""" + return list(self._raw.to_batches()) + + def to_table(self) -> pa.Table: + """Execute the scan and return a :class:`~pyarrow.Table`.""" + batch = self.to_batch() + return pa.Table.from_batches([batch]) + + def count_rows(self) -> int: + """Return the row count without loading all column data.""" + return self._raw.count_rows() + + +class ExecutionPlan: + """Executable physical plan returned by MemWAL planners. + + This wraps the Rust/DataFusion physical plan object. Planner classes only + construct plans; execution happens through this class. + + Parameters + ---------- + _raw : _ExecutionPlan + """ + + def __init__(self, _raw: _ExecutionPlan) -> None: + self._raw = _raw + + @property + def schema(self) -> pa.Schema: + """Output schema of this physical plan.""" + return self._raw.schema + + @property + def dataset_schema(self) -> pa.Schema: + """Base dataset schema used to construct this plan.""" + return self._raw.dataset_schema + + def explain(self) -> str: + """Return the physical plan as an indented string.""" + return self._raw.explain() + + def to_reader(self) -> pa.RecordBatchReader: + """Execute the plan and return a streaming reader.""" + return self._raw.to_reader() + + def to_batches(self) -> List[pa.RecordBatch]: + """Execute the plan and return all record batches.""" + return list(self._raw.to_batches()) + + def to_table(self) -> pa.Table: + """Execute the plan and return a table.""" + return self.to_reader().read_all() + + +class LsmPointLookupPlanner: + """Plans primary-key point lookups across all LSM levels. + + More efficient than `LsmScanner` for known-PK lookups due to + bloom filter optimizations and short-circuit evaluation. + + Parameters + ---------- + dataset : LanceDataset + The base dataset. + shard_snapshots : list of ShardSnapshot + Shard snapshots specifying SSTables to include. + pk_columns : list of str, optional + Primary key column names. Inferred from schema metadata if omitted. + + Examples + -------- + planner = LsmPointLookupPlanner(dataset, [snapshot]) + plan = planner.plan_lookup(pa.array([42], type=pa.int64())) + result = plan.to_table() + """ + + def __init__( + self, + dataset: "lance.LanceDataset", + shard_snapshots: List[ShardSnapshot], + pk_columns: Optional[List[str]] = None, + ) -> None: + self._raw = _LsmPointLookupPlanner( + dataset._ds, + [s._raw for s in shard_snapshots], + pk_columns, + ) + + def plan_lookup( + self, + pk_value: pa.Array, + columns: Optional[List[str]] = None, + ) -> ExecutionPlan: + """Plan a point lookup by primary key value. + + Parameters + ---------- + pk_value : pa.Array + For single-column primary keys, a PyArrow array with exactly one + element. For composite primary keys, a single-row + `pyarrow.StructArray` with one field per primary-key column. + columns : list of str, optional + Columns to project. Returns all columns if omitted. + + Returns + ------- + ExecutionPlan + Physical plan for the lookup. Execute it via `to_table`, + `to_reader`, or `to_batches`. + """ + return ExecutionPlan(self._raw.plan_lookup(pk_value, columns)) + + +class LsmVectorSearchPlanner: + """Plans IVF-PQ vector KNN search across all LSM levels. + + Results include staleness filtering to return only the latest version + of each row. The output schema includes the ``_distance`` column. + + Parameters + ---------- + dataset : LanceDataset + The base dataset. + shard_snapshots : list of ShardSnapshot + Shard snapshots specifying SSTables to include. + vector_column : str + Name of the ``FixedSizeList`` vector column. + pk_columns : list of str, optional + Primary key columns. Inferred from schema metadata if omitted. + distance_type : str, optional + Distance metric — one of ``"l2"`` (default), ``"cosine"``, + ``"dot"``, ``"hamming"``. + filter : str, optional + SQL predicate applied as a prefilter to every LSM source before vector + candidate selection. + + Examples + -------- + import numpy as np + planner = LsmVectorSearchPlanner(dataset, [snapshot], "vector") + query = pa.array(np.random.rand(128).astype("float32")) + plan = planner.plan_search(query, k=10) + result = plan.to_table() + """ + + def __init__( + self, + dataset: "lance.LanceDataset", + shard_snapshots: List[ShardSnapshot], + vector_column: str, + pk_columns: Optional[List[str]] = None, + distance_type: Optional[str] = None, + filter: Optional[str] = None, + ) -> None: + kwargs = {} + if pk_columns is not None: + kwargs["pk_columns"] = pk_columns + if distance_type is not None: + kwargs["distance_type"] = distance_type + if filter is not None: + kwargs["filter"] = filter + self._raw = _LsmVectorSearchPlanner( + dataset._ds, + [s._raw for s in shard_snapshots], + vector_column, + **kwargs, + ) + + def plan_search( + self, + query: pa.Array, + k: int = 10, + nprobes: int = 20, + columns: Optional[List[str]] = None, + refine_base_table: bool = False, + overfetch_factor: float = 1.0, + ) -> ExecutionPlan: + """Plan a KNN vector search. + + Parameters + ---------- + query : pa.Array + A flat ``Float32Array`` of length ``vector_dim``. + k : int, optional + Number of nearest neighbours to return (default: 10). + nprobes : int, optional + Number of IVF partitions to probe (default: 20). + columns : list of str, optional + Columns to project. Returns all columns + ``_distance`` if + omitted. + refine_base_table : bool, optional + When true, the base-table arm re-ranks its candidates with exact + distances. Useful when the base index is approximate (e.g. + IVF-PQ). Memtable arms use exact HNSW and are unaffected. + Auto-enabled whenever stale filtering is on (see ``overfetch_factor``). + overfetch_factor : float, optional + Over-fetch multiple for sources with rows superseded by newer + generations. Must be at least ``1.0``. At ``1.0`` each affected + source fetches exactly ``k`` candidates; above ``1.0`` it fetches + ``ceil(k * overfetch_factor)`` candidates so dropping stale rows is + less likely to under-fill the result. + + Returns + ------- + ExecutionPlan + Physical plan for the vector search. Execute it via + `to_table`, `to_reader`, or `to_batches`. + """ + if k <= 0: + raise ValueError(f"k must be positive, got {k}") + if nprobes <= 0: + raise ValueError(f"nprobes must be positive, got {nprobes}") + if not math.isfinite(overfetch_factor) or overfetch_factor < 1.0: + raise ValueError( + f"overfetch_factor must be finite and >= 1.0, got {overfetch_factor}" + ) + return ExecutionPlan( + self._raw.plan_search( + query, + k, + nprobes, + columns, + refine_base_table, + overfetch_factor, + ) + ) + + +def _unwrap_shard_id(shard_id: str) -> str: + """Validate shard_id is a UUID string.""" + import uuid as _uuid + + _uuid.UUID(shard_id) # raises ValueError if invalid + return shard_id + + +def _to_raw_compacted_sstables( + sstables: Iterable[CompactedSsTable], +) -> list: + """Convert Python CompactedSsTable list to PyO3 _CompactedSsTable list.""" + return [_CompactedSsTable(s.shard_id, s.generation) for s in sstables] diff --git a/python/python/lance/namespace.py b/python/python/lance/namespace.py index 9df0c451173..23c6b88cb3c 100644 --- a/python/python/lance/namespace.py +++ b/python/python/lance/namespace.py @@ -13,39 +13,101 @@ """ from abc import ABC, abstractmethod -from typing import Dict, List, Optional +from typing import Dict, Optional from lance_namespace import ( - CreateEmptyTableRequest, - CreateEmptyTableResponse, + AlterTableAddColumnsRequest, + AlterTableAddColumnsResponse, + AlterTableAlterColumnsRequest, + AlterTableAlterColumnsResponse, + AlterTableBackfillColumnsRequest, + AlterTableBackfillColumnsResponse, + AlterTableDropColumnsRequest, + AlterTableDropColumnsResponse, + AlterTransactionRequest, + AlterTransactionResponse, + AnalyzeTableQueryPlanRequest, + CountTableRowsRequest, + CountTableRowsResponse, + CreateMaterializedViewRequest, + CreateMaterializedViewResponse, CreateNamespaceRequest, CreateNamespaceResponse, + CreateTableBranchRequest, + CreateTableBranchResponse, + CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, CreateTableResponse, + CreateTableTagRequest, + CreateTableTagResponse, DeclareTableRequest, DeclareTableResponse, + DeleteFromTableRequest, + DeleteFromTableResponse, + DeleteTableBranchRequest, + DeleteTableBranchResponse, + 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, + ListTableBranchesRequest, + ListTableBranchesResponse, + ListTableIndicesRequest, + ListTableIndicesResponse, ListTablesRequest, ListTablesResponse, + ListTableTagsRequest, + ListTableTagsResponse, + ListTableVersionsRequest, + ListTableVersionsResponse, + MergeInsertIntoTableRequest, + MergeInsertIntoTableResponse, NamespaceExistsRequest, + NamespaceExistsResponse, + QueryTableResponse, + RefreshMaterializedViewRequest, + RefreshMaterializedViewResponse, RegisterTableRequest, RegisterTableResponse, + RenameTableRequest, + RenameTableResponse, + RestoreTableRequest, + RestoreTableResponse, TableExistsRequest, + TableExistsResponse, + UpdateTableRequest, + UpdateTableResponse, + UpdateTableSchemaMetadataRequest, + UpdateTableSchemaMetadataResponse, + UpdateTableTagRequest, + UpdateTableTagResponse, ) -from .io import StorageOptionsProvider from .lance import PyDirectoryNamespace # Low-level Rust binding try: @@ -62,7 +124,6 @@ "DirectoryNamespace", "RestNamespace", "RestAdapter", - "LanceNamespaceStorageOptionsProvider", "DynamicContextProvider", ] @@ -263,6 +324,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 @@ -275,12 +346,13 @@ class DirectoryNamespace(LanceNamespace): >>> >>> # With AWS credential vending (requires credential-vendor-aws feature) >>> # Use **dict to pass property names with dots - >>> ns = lance.namespace.DirectoryNamespace(**{ + >>> aws_properties = { ... "root": "s3://my-bucket/data", ... "credential_vendor.enabled": "true", ... "credential_vendor.aws_role_arn": "arn:aws:iam::123456789012:role/MyRole", ... "credential_vendor.aws_duration_millis": "3600000", - ... }) + ... } + >>> # ns = lance.namespace.DirectoryNamespace(**aws_properties) With dynamic context provider: @@ -345,8 +417,11 @@ def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse response_dict = self._inner.drop_namespace(request.model_dump()) return DropNamespaceResponse.from_dict(response_dict) - def namespace_exists(self, request: NamespaceExistsRequest) -> None: + def namespace_exists( + self, request: NamespaceExistsRequest + ) -> NamespaceExistsResponse: self._inner.namespace_exists(request.model_dump()) + return NamespaceExistsResponse() # Table operations @@ -362,8 +437,9 @@ def register_table(self, request: RegisterTableRequest) -> RegisterTableResponse response_dict = self._inner.register_table(request.model_dump()) return RegisterTableResponse.from_dict(response_dict) - def table_exists(self, request: TableExistsRequest) -> None: + def table_exists(self, request: TableExistsRequest) -> TableExistsResponse: self._inner.table_exists(request.model_dump()) + return TableExistsResponse() def drop_table(self, request: DropTableRequest) -> DropTableResponse: response_dict = self._inner.drop_table(request.model_dump()) @@ -381,16 +457,466 @@ def create_table( response_dict = self._inner.create_table(request.model_dump(), request_data) return CreateTableResponse.from_dict(response_dict) - def create_empty_table( - self, request: CreateEmptyTableRequest - ) -> CreateEmptyTableResponse: - response_dict = self._inner.create_empty_table(request.model_dump()) - return CreateEmptyTableResponse.from_dict(response_dict) - 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( + self, request: ListTableVersionsRequest + ) -> ListTableVersionsResponse: + response_dict = self._inner.list_table_versions(request.model_dump()) + return ListTableVersionsResponse.from_dict(response_dict) + + def create_table_version(self, request: dict) -> dict: + """Create a table version (for external manifest store integration). + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - version: int - Version number to create + - manifest_path: str - Path to staging manifest + - manifest_size: int (optional) - Size in bytes + - e_tag: str (optional) - ETag for optimistic concurrency + + Returns + ------- + dict + Response dictionary with optional transaction_id + """ + return self._inner.create_table_version(request) + + def describe_table_version(self, request: dict) -> dict: + """Describe a specific table version. + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - version: int (optional) - Version to describe (None = latest) + + Returns + ------- + dict + Response dictionary with version info: + - version: dict with version, manifest_path, manifest_size, e_tag, timestamp + """ + return self._inner.describe_table_version(request) + + def batch_delete_table_versions(self, request: dict) -> dict: + """Delete multiple table versions in a single request. + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - versions: List[int] - List of version numbers to delete + + Returns + ------- + dict + Response dictionary with: + - deleted_versions: List[int] - List of successfully deleted versions + """ + return self._inner.batch_delete_table_versions(request) + + # Data manipulation operations + + def count_table_rows( + self, request: CountTableRowsRequest + ) -> CountTableRowsResponse: + """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 + ------- + CountTableRowsResponse + Response whose ``count`` is the number of rows matching the criteria + """ + count = self._inner.count_table_rows(request.model_dump()) + return CountTableRowsResponse(count=count) + + 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) -> QueryTableResponse: + """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 + ------- + QueryTableResponse + Response whose ``data`` is the query results in Arrow IPC file format + """ + if hasattr(request, "model_dump"): + request = request.model_dump() + data = self._inner.query_table(request) + return QueryTableResponse(data=data) + + # 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) + + def alter_table_backfill_columns( + self, request: AlterTableBackfillColumnsRequest + ) -> AlterTableBackfillColumnsResponse: + """Trigger an async backfill job for a computed column.""" + response_dict = self._inner.alter_table_backfill_columns(request.model_dump()) + return AlterTableBackfillColumnsResponse.from_dict(response_dict) + + def refresh_materialized_view( + self, request: RefreshMaterializedViewRequest + ) -> RefreshMaterializedViewResponse: + """Trigger an async materialized view refresh.""" + response_dict = self._inner.refresh_materialized_view(request.model_dump()) + return RefreshMaterializedViewResponse.from_dict(response_dict) + + def create_materialized_view( + self, request: CreateMaterializedViewRequest + ) -> CreateMaterializedViewResponse: + """Create a materialized view backed by an optional UDTF/chunker.""" + response_dict = self._inner.create_materialized_view(request.model_dump()) + return CreateMaterializedViewResponse.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) + + def create_table_branch( + self, request: CreateTableBranchRequest + ) -> CreateTableBranchResponse: + """Create a new branch forked from a table version.""" + response_dict = self._inner.create_table_branch(request.model_dump()) + return CreateTableBranchResponse.from_dict(response_dict) + + def list_table_branches( + self, request: ListTableBranchesRequest + ) -> ListTableBranchesResponse: + """List all branches of a table.""" + response_dict = self._inner.list_table_branches(request.model_dump()) + return ListTableBranchesResponse.from_dict(response_dict) + + def delete_table_branch( + self, request: DeleteTableBranchRequest + ) -> DeleteTableBranchResponse: + """Delete a branch from a table.""" + response_dict = self._inner.delete_table_branch(request.model_dump()) + return DeleteTableBranchResponse.from_dict(response_dict) + + # 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. @@ -490,8 +1016,11 @@ def drop_namespace(self, request: DropNamespaceRequest) -> DropNamespaceResponse response_dict = self._inner.drop_namespace(request.model_dump()) return DropNamespaceResponse.from_dict(response_dict) - def namespace_exists(self, request: NamespaceExistsRequest) -> None: + def namespace_exists( + self, request: NamespaceExistsRequest + ) -> NamespaceExistsResponse: self._inner.namespace_exists(request.model_dump()) + return NamespaceExistsResponse() # Table operations @@ -507,8 +1036,9 @@ def register_table(self, request: RegisterTableRequest) -> RegisterTableResponse response_dict = self._inner.register_table(request.model_dump()) return RegisterTableResponse.from_dict(response_dict) - def table_exists(self, request: TableExistsRequest) -> None: + def table_exists(self, request: TableExistsRequest) -> TableExistsResponse: self._inner.table_exists(request.model_dump()) + return TableExistsResponse() def drop_table(self, request: DropTableRequest) -> DropTableResponse: response_dict = self._inner.drop_table(request.model_dump()) @@ -526,16 +1056,466 @@ def create_table( response_dict = self._inner.create_table(request.model_dump(), request_data) return CreateTableResponse.from_dict(response_dict) - def create_empty_table( - self, request: CreateEmptyTableRequest - ) -> CreateEmptyTableResponse: - response_dict = self._inner.create_empty_table(request.model_dump()) - return CreateEmptyTableResponse.from_dict(response_dict) - 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( + self, request: ListTableVersionsRequest + ) -> ListTableVersionsResponse: + response_dict = self._inner.list_table_versions(request.model_dump()) + return ListTableVersionsResponse.from_dict(response_dict) + + def create_table_version(self, request: dict) -> dict: + """Create a table version (for external manifest store integration). + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - version: int - Version number to create + - manifest_path: str - Path to staging manifest + - manifest_size: int (optional) - Size in bytes + - e_tag: str (optional) - ETag for optimistic concurrency + + Returns + ------- + dict + Response dictionary with optional transaction_id + """ + return self._inner.create_table_version(request) + + def describe_table_version(self, request: dict) -> dict: + """Describe a specific table version. + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - version: int (optional) - Version to describe (None = latest) + + Returns + ------- + dict + Response dictionary with version info: + - version: dict with version, manifest_path, manifest_size, e_tag, timestamp + """ + return self._inner.describe_table_version(request) + + def batch_delete_table_versions(self, request: dict) -> dict: + """Delete multiple table versions in a single request. + + Parameters + ---------- + request : dict + Request dictionary with keys: + - id: List[str] - Table identifier + - versions: List[int] - List of version numbers to delete + + Returns + ------- + dict + Response dictionary with: + - deleted_versions: List[int] - List of successfully deleted versions + """ + return self._inner.batch_delete_table_versions(request) + + # Data manipulation operations + + def count_table_rows( + self, request: CountTableRowsRequest + ) -> CountTableRowsResponse: + """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 + ------- + CountTableRowsResponse + Response whose ``count`` is the number of rows matching the criteria + """ + count = self._inner.count_table_rows(request.model_dump()) + return CountTableRowsResponse(count=count) + + 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) -> QueryTableResponse: + """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 + ------- + QueryTableResponse + Response whose ``data`` is the query results in Arrow IPC file format + """ + if hasattr(request, "model_dump"): + request = request.model_dump() + data = self._inner.query_table(request) + return QueryTableResponse(data=data) + + # 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) + + def alter_table_backfill_columns( + self, request: AlterTableBackfillColumnsRequest + ) -> AlterTableBackfillColumnsResponse: + """Trigger an async backfill job for a computed column.""" + response_dict = self._inner.alter_table_backfill_columns(request.model_dump()) + return AlterTableBackfillColumnsResponse.from_dict(response_dict) + + def refresh_materialized_view( + self, request: RefreshMaterializedViewRequest + ) -> RefreshMaterializedViewResponse: + """Trigger an async materialized view refresh.""" + response_dict = self._inner.refresh_materialized_view(request.model_dump()) + return RefreshMaterializedViewResponse.from_dict(response_dict) + + def create_materialized_view( + self, request: CreateMaterializedViewRequest + ) -> CreateMaterializedViewResponse: + """Create a materialized view backed by an optional UDTF/chunker.""" + response_dict = self._inner.create_materialized_view(request.model_dump()) + return CreateMaterializedViewResponse.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) + + def create_table_branch( + self, request: CreateTableBranchRequest + ) -> CreateTableBranchResponse: + """Create a new branch forked from a table version.""" + response_dict = self._inner.create_table_branch(request.model_dump()) + return CreateTableBranchResponse.from_dict(response_dict) + + def list_table_branches( + self, request: ListTableBranchesRequest + ) -> ListTableBranchesResponse: + """List all branches of a table.""" + response_dict = self._inner.list_table_branches(request.model_dump()) + return ListTableBranchesResponse.from_dict(response_dict) + + def delete_table_branch( + self, request: DeleteTableBranchRequest + ) -> DeleteTableBranchResponse: + """Delete a branch from a table.""" + response_dict = self._inner.delete_table_branch(request.model_dump()) + return DeleteTableBranchResponse.from_dict(response_dict) + + # 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. @@ -547,11 +1527,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 @@ -579,8 +1560,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, @@ -593,14 +1574,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: @@ -630,117 +1615,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/optimize.py b/python/python/lance/optimize.py index f04e9264c3b..ab18255c853 100644 --- a/python/python/lance/optimize.py +++ b/python/python/lance/optimize.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors -from typing import Optional, TypedDict +from typing import Literal, Optional, TypedDict # Re-exported from native module. See src/dataset/optimize.rs for implementation. from .lance import Compaction as Compaction @@ -13,7 +13,7 @@ # from .lance import CompactionPlan as CompactionPlan -class CompactionOptions(TypedDict): +class CompactionOptions(TypedDict, total=False): """Options for compaction.""" target_rows_per_fragment: Optional[int] @@ -41,7 +41,7 @@ class CompactionOptions(TypedDict): Whether to compact fragments with soft deleted rows so they are no longer present in the file. (default: True) """ - materialize_deletions_threadhold: Optional[float] + materialize_deletions_threshold: Optional[float] """ The fraction of original rows that are soft deleted in a fragment before the fragment is a candidate for compaction. @@ -59,3 +59,75 @@ class CompactionOptions(TypedDict): The default will use the same default from ``scanner``. """ + io_buffer_size: Optional[int] + """ + The number of bytes to allow to queue up in the I/O buffer when scanning + input fragments. Increasing this can avoid a deadlock that occurs when a + single batch of data is larger than the I/O buffer size. + + The default will use the same default from ``scanner``. + """ + compaction_mode: Optional[ + Literal["reencode", "try_binary_copy", "force_binary_copy"] + ] + """ + The compaction mode to use. Valid values: + + - ``"reencode"``: Decode and re-encode data (default). + - ``"try_binary_copy"``: Try binary copy if fragments are compatible, + fall back to reencode otherwise. + - ``"force_binary_copy"``: Use binary copy or fail if fragments are + not compatible. + """ + binary_copy_read_batch_bytes: Optional[int] + """ + The batch size in bytes for reading during binary copy operations. + Controls how much data is read at once when performing binary copy. + (default: 16MB) + """ + defer_index_remap: Optional[bool] + """ + 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) + """ + max_source_rows: Optional[int] + """ + Maximum number of source rows to compact in a single run. Rows are + counted as live rows (physical rows minus soft-deleted rows). Tasks + are included until adding the next task would exceed this limit. + (default: None, no limit) + """ + max_source_bytes: Optional[int] + """ + Maximum number of source bytes to compact in a single run, measured as + the total size of the source fragments' data and overlay files. Tasks + are included until adding the next task would exceed this limit. + Blob v2 payloads live in separate blob files and are not counted, so + this is not a cap on total compaction I/O for datasets with blob + columns. + (default: None, no limit) + """ + excluded_fragment_ids: Optional[list[int]] + """ + Fragment IDs to exclude from compaction planning. Excluded fragments + remain unchanged and act as boundaries, so fragments on opposite sides + are not combined into the same task. Duplicate and unknown IDs are + ignored. (default: None) + """ + data_storage_version: Optional[str] + """ + Output data file version, such as "2.2", "stable", or "next". If omitted, + compaction uses the compaction config target when set, otherwise the dataset's + default write version. It does not change that default. + The planner fixes release selectors to exact versions before distributing + tasks. V1/V2 cross-family targets are rejected. Binary copy requires matching + input versions and no overlays; TryBinaryCopy reencodes ineligible inputs, + while ForceBinaryCopy reports an error. + """ diff --git a/python/python/lance/otel.py b/python/python/lance/otel.py new file mode 100644 index 00000000000..0c9ee1790fd --- /dev/null +++ b/python/python/lance/otel.py @@ -0,0 +1,166 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Bridge Lance's internal metrics into OpenTelemetry. + +Lance core publishes metrics (currently object store request counts, bytes, +latency, errors, and throttles) through the Rust ``metrics`` facade. This module +installs a process-global recorder that aggregates them and registers +OpenTelemetry observable instruments that report the aggregated values into the +user's ``MeterProvider``. + +The bridge is generic: every metric Lance describes is surfaced automatically, +with no per-metric Python code. Histograms have no asynchronous OpenTelemetry +instrument, so each is exported Prometheus-style as cumulative ``le`` buckets +plus ``_count`` and ``_sum`` observable counters. +""" + +from __future__ import annotations + +import warnings +from typing import TYPE_CHECKING, Optional + +from .lance import ( + lance_metrics_catalog, + register_lance_metrics_recorder, + snapshot_lance_metrics, +) + +if TYPE_CHECKING: + from opentelemetry.metrics import MeterProvider + +_INSTRUMENTED = False + + +def instrument_lance_metrics(meter_provider: Optional["MeterProvider"] = None) -> bool: + """Register Lance metrics as OpenTelemetry observable instruments. + + Installs a process-global metrics recorder and creates one observable + instrument per Lance metric on the given (or global) ``MeterProvider``. The + user's configured ``MetricReader`` then collects them on its own schedule. + + Counters and gauges map directly to observable counters/gauges. Each + histogram is exported as cumulative ``le`` bucket counts (``_bucket``, + with an ``le`` attribute) plus ``_count`` and ``_sum``. + + Parameters + ---------- + meter_provider : opentelemetry.metrics.MeterProvider, optional + The provider to register instruments on. Defaults to the global provider + from ``opentelemetry.metrics.get_meter_provider()``. + + Returns + ------- + bool + ``True`` if the recorder is installed and instruments are registered. + ``False`` if a different ``metrics`` recorder is already installed in + this process (``metrics`` permits only one global recorder), in which + case a warning is emitted and no instruments are created. + + Notes + ----- + Requires the OpenTelemetry SDK (``pip install pylance[otel]``). Calling this + more than once is safe; instruments are created only on the first successful + call. + """ + global _INSTRUMENTED + + try: + from opentelemetry.metrics import Observation, get_meter_provider + except ImportError as exc: + raise ImportError( + "instrument_lance_metrics requires the OpenTelemetry API/SDK. " + "Install it with `pip install pylance[otel]` or " + "`pip install opentelemetry-sdk`." + ) from exc + + if not register_lance_metrics_recorder(): + warnings.warn( + "Could not install the Lance metrics recorder: another `metrics` " + "recorder is already installed in this process. Lance metrics will " + "not be exported via OpenTelemetry.", + stacklevel=2, + ) + return False + + if _INSTRUMENTED: + return True + + provider = meter_provider or get_meter_provider() + meter = provider.get_meter("lance") + + def scalar_callback(metric_name: str): + def callback(_options): + return [ + Observation(point.value, point.attributes) + for point in snapshot_lance_metrics() + if point.name == metric_name and point.value is not None + ] + + return callback + + def bucket_callback(metric_name: str): + def callback(_options): + observations = [] + for point in snapshot_lance_metrics(): + if point.name != metric_name or point.buckets is None: + continue + for le, cumulative in point.buckets: + attributes = dict(point.attributes) + attributes["le"] = le + observations.append(Observation(cumulative, attributes)) + return observations + + return callback + + def field_callback(metric_name: str, field: str): + def callback(_options): + observations = [] + for point in snapshot_lance_metrics(): + if point.name != metric_name: + continue + value = getattr(point, field) + if value is not None: + observations.append(Observation(value, point.attributes)) + return observations + + return callback + + for desc in lance_metrics_catalog(): + unit = desc.unit or "" + if desc.kind == "counter": + meter.create_observable_counter( + desc.name, + callbacks=[scalar_callback(desc.name)], + unit=unit, + description=desc.description, + ) + elif desc.kind == "gauge": + meter.create_observable_gauge( + desc.name, + callbacks=[scalar_callback(desc.name)], + unit=unit, + description=desc.description, + ) + elif desc.kind == "histogram": + # `_bucket` and `_count` observe cumulative counts, so they are + # unitless; only `_sum` carries the histogram's unit (e.g. seconds). + meter.create_observable_counter( + f"{desc.name}_bucket", + callbacks=[bucket_callback(desc.name)], + description=f"{desc.description} (cumulative buckets)", + ) + meter.create_observable_counter( + f"{desc.name}_count", + callbacks=[field_callback(desc.name, "count")], + description=f"{desc.description} (count)", + ) + meter.create_observable_counter( + f"{desc.name}_sum", + callbacks=[field_callback(desc.name, "sum")], + unit=unit, + description=f"{desc.description} (sum)", + ) + + _INSTRUMENTED = True + return True 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/lance/pydantic.py b/python/python/lance/pydantic.py new file mode 100644 index 00000000000..99226fcf664 --- /dev/null +++ b/python/python/lance/pydantic.py @@ -0,0 +1,434 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +# +# Pydantic-to-Arrow conversion, ported from +# https://github.com/lancedb/lancedb/blob/f8dc2f78ee3219084d457a647e230b23e2f391b0/python/python/lancedb/pydantic.py +# +# Embedding-function-specific code (LanceModel, parse_embedding_functions, +# EmbeddingFunctionRegistry) is intentionally excluded -- lance has no concept +# of embedding functions, that stays in lancedb. Version detection uses +# hasattr checks against the field/model objects instead of branching on +# pydantic's version number, since pydantic is an optional dependency here. + +"""Pydantic (v1 / v2) to Arrow schema conversion""" + +from __future__ import annotations + +import inspect +import sys +import types +from abc import ABC, abstractmethod +from datetime import date, datetime +from enum import Enum +from typing import Any, Callable, Dict, Generator, List, Type, Union, _GenericAlias + +import pyarrow as pa +import pydantic + +from .dependencies import numpy as np + +_PYDANTIC_V2 = hasattr(pydantic, "GetCoreSchemaHandler") + +try: + from pydantic_core import CoreSchema, core_schema +except ImportError: + if _PYDANTIC_V2: + raise + + +class FixedSizeListMixin(ABC): + @staticmethod + @abstractmethod + def dim() -> int: + raise NotImplementedError + + @staticmethod + @abstractmethod + def value_arrow_type() -> pa.DataType: + raise NotImplementedError + + +def Vector( + dim: int, value_type: pa.DataType = pa.float32(), nullable: bool = True +) -> Type[FixedSizeListMixin]: + """Pydantic type for a fixed-size-list embedding vector column. + + Examples + -------- + >>> import pydantic + >>> from lance.pydantic import Vector + ... + >>> class MyModel(pydantic.BaseModel): + ... id: int + ... embedding: Vector(768) + """ + + class FixedSizeList(list, FixedSizeListMixin): + def __repr__(self): + return f"FixedSizeList(dim={dim})" + + @staticmethod + def nullable() -> bool: + return nullable + + @staticmethod + def dim() -> int: + return dim + + @staticmethod + def value_arrow_type() -> pa.DataType: + return value_type + + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: pydantic.GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls, + core_schema.list_schema( + min_length=dim, + max_length=dim, + items_schema=core_schema.float_schema(), + ), + ) + + @classmethod + def __get_validators__(cls) -> Generator[Callable, None, None]: + yield cls.validate + + # For pydantic v1 + @classmethod + def validate(cls, v): + if not isinstance(v, (list, range, np.ndarray)) or len(v) != dim: + raise TypeError("A list of numbers or numpy.ndarray is needed") + return cls(v) + + if not _PYDANTIC_V2: + + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]): + field_schema["items"] = {"type": "number"} + field_schema["maxItems"] = dim + field_schema["minItems"] = dim + + return FixedSizeList + + +def MultiVector( + dim: int, value_type: pa.DataType = pa.float32(), nullable: bool = True +) -> Type[FixedSizeListMixin]: + """Pydantic type for a list of fixed-size-list embedding vectors. + + Useful for models that produce multiple embeddings per input (e.g. + ColPali-style multi-vector embeddings). + + Examples + -------- + >>> import pydantic + >>> from lance.pydantic import MultiVector + ... + >>> class MyModel(pydantic.BaseModel): + ... id: int + ... embeddings: MultiVector(128) + """ + + class MultiVectorList(list, FixedSizeListMixin): + def __repr__(self): + return f"MultiVector(dim={dim})" + + @staticmethod + def nullable() -> bool: + return nullable + + @staticmethod + def dim() -> int: + return dim + + @staticmethod + def value_arrow_type() -> pa.DataType: + return value_type + + @staticmethod + def is_multi_vector() -> bool: + return True + + @classmethod + def __get_pydantic_core_schema__( + cls, _source_type: Any, _handler: pydantic.GetCoreSchemaHandler + ) -> CoreSchema: + return core_schema.no_info_after_validator_function( + cls, + core_schema.list_schema( + items_schema=core_schema.list_schema( + min_length=dim, + max_length=dim, + items_schema=core_schema.float_schema(), + ), + ), + ) + + @classmethod + def __get_validators__(cls) -> Generator[Callable, None, None]: + yield cls.validate + + # For pydantic v1 + @classmethod + def validate(cls, v): + if not isinstance(v, (list, range)): + raise TypeError("A list of vectors is needed") + for vec in v: + if not isinstance(vec, (list, range, np.ndarray)) or len(vec) != dim: + raise TypeError(f"Each vector must be a list of {dim} numbers") + return cls(v) + + if not _PYDANTIC_V2: + + @classmethod + def __modify_schema__(cls, field_schema: Dict[str, Any]): + field_schema["items"] = { + "type": "array", + "items": {"type": "number"}, + "minItems": dim, + "maxItems": dim, + } + + return MultiVectorList + + +def _field_annotation(field: Any) -> Any: + """Get the type annotation off a pydantic v1 ModelField or v2 FieldInfo.""" + if hasattr(field, "annotation"): + # Pydantic v2 FieldInfo + return field.annotation + # Pydantic v1 ModelField -- Optional-ness is tracked via `allow_none`, + # not folded into `outer_type_`. + return field.outer_type_ + + +def get_extras(field: Any, key: str) -> Any: + """Get extra metadata (from `json_schema_extra`) off a pydantic field.""" + if hasattr(field, "json_schema_extra"): + # Pydantic v2 FieldInfo + return (field.json_schema_extra or {}).get(key) + # Pydantic v1 ModelField + return (field.field_info.extra or {}).get("json_schema_extra", {}).get(key) + + +def _py_type_to_arrow_type(py_type: Type[Any], field: Any) -> pa.DataType: + """Convert a field with native Python type to Arrow data type. + + Raises + ------ + TypeError + If the type is not supported. + """ + if py_type is int: + return pa.int64() + elif py_type is float: + return pa.float64() + elif py_type is str: + return pa.utf8() + elif py_type is bool: + return pa.bool_() + elif py_type is bytes: + return pa.binary() + elif py_type is date: + return pa.date32() + elif py_type is datetime: + tz = get_extras(field, "tz") + return pa.timestamp("us", tz=tz) + elif getattr(py_type, "__origin__", None) in (list, tuple): + # A bare, unparameterised ``typing.List`` / ``typing.Tuple`` matches + # this branch (its ``__origin__`` is ``list`` / ``tuple``) but has no + # ``__args__``, so we cannot infer the element type. Raise a clear + # ``TypeError`` instead of crashing with an opaque ``AttributeError``. + args = getattr(py_type, "__args__", None) + if not args: + raise TypeError( + "Converting Pydantic type to Arrow Type: unsupported type " + f"{py_type}. Specify the element type, e.g. List[int] instead " + "of a bare List." + ) + child = args[0] + return _pydantic_list_child_to_arrow(child, field) + raise TypeError( + f"Converting Pydantic type to Arrow Type: unsupported type {py_type}." + ) + + +def _pydantic_model_to_fields(model: Type[pydantic.BaseModel]) -> List[pa.Field]: + if hasattr(model, "model_fields"): + # Pydantic v2 + return [ + _pydantic_to_field(name, field) + for name, field in model.model_fields.items() + ] + # Pydantic v1 + return [_pydantic_to_field(name, field) for name, field in model.__fields__.items()] + + +def _pydantic_type_to_arrow_type(tp: Any, field: Any) -> pa.DataType: + def _safe_issubclass(candidate: Any, base: type) -> bool: + try: + return issubclass(candidate, base) + except TypeError: + return False + + if inspect.isclass(tp): + if _safe_issubclass(tp, pydantic.BaseModel): + # Struct + fields = _pydantic_model_to_fields(tp) + return pa.struct(fields) + if _safe_issubclass(tp, FixedSizeListMixin): + if getattr(tp, "is_multi_vector", lambda: False)(): + return pa.list_(pa.list_(tp.value_arrow_type(), tp.dim())) + # For regular Vector + return pa.list_(tp.value_arrow_type(), tp.dim()) + if _safe_issubclass(tp, Enum): + # Map Enum to the Arrow type of its value. + # For string-valued enums, use dictionary encoding for efficiency. + # For integer enums, use the native type. + # Fall back to utf8 for mixed-type or empty enums. + value_types = {type(m.value) for m in tp} + if len(value_types) == 1: + value_type = value_types.pop() + if value_type is str: + # Use dictionary encoding for string enums + return pa.dictionary(pa.int32(), pa.utf8()) + return _py_type_to_arrow_type(value_type, field) + return pa.utf8() + return _py_type_to_arrow_type(tp, field) + + +def _pydantic_list_child_to_arrow(child: Any, field: Any) -> pa.DataType: + unwrapped = _unwrap_optional_annotation(child) + if unwrapped is not None: + return pa.list_( + pa.field("item", _pydantic_type_to_arrow_type(unwrapped, field), True) + ) + return pa.list_(_pydantic_type_to_arrow_type(child, field)) + + +def _unwrap_optional_annotation(annotation: Any) -> Any | None: + if isinstance(annotation, (_GenericAlias, types.GenericAlias)): + origin = annotation.__origin__ + args = annotation.__args__ + if origin == Union: + non_none = [arg for arg in args if arg is not type(None)] + if len(non_none) == 1 and len(non_none) != len(args): + return non_none[0] + elif sys.version_info >= (3, 10) and isinstance(annotation, types.UnionType): + args = annotation.__args__ + non_none = [arg for arg in args if arg is not type(None)] + if len(non_none) == 1 and len(non_none) != len(args): + return non_none[0] + return None + + +def _pydantic_to_arrow_type(field: Any) -> pa.DataType: + """Convert a pydantic field (v1 ModelField or v2 FieldInfo) to Arrow DataType""" + annotation = _field_annotation(field) + unwrapped = _unwrap_optional_annotation(annotation) + if unwrapped is not None: + return _pydantic_type_to_arrow_type(unwrapped, field) + if isinstance(annotation, (_GenericAlias, types.GenericAlias)): + origin = annotation.__origin__ + args = annotation.__args__ + + if origin is list: + child = args[0] + return _pydantic_list_child_to_arrow(child, field) + return _pydantic_type_to_arrow_type(annotation, field) + + +def is_nullable(field: Any) -> bool: + """Check if a pydantic field (v1 ModelField or v2 FieldInfo) is nullable. + + Only a true ``Optional``/``Union[..., None]`` annotation (or a nullable + ``Vector``/``MultiVector``) makes a field nullable -- a field with a + plain default value but a non-Optional type is not. + """ + if not hasattr(field, "annotation"): + # Pydantic v1 ModelField: Optional-ness is tracked via `allow_none` + # directly, since `outer_type_` already has Optional stripped. A + # nullable Vector/MultiVector still overrides this, same as v2. + v1_type = _field_annotation(field) + if inspect.isclass(v1_type): + try: + if issubclass(v1_type, FixedSizeListMixin): + return v1_type.nullable() + except TypeError: + pass + return bool(field.allow_none) + + annotation = field.annotation + if _unwrap_optional_annotation(annotation) is not None: + return True + if isinstance(annotation, (_GenericAlias, types.GenericAlias)): + origin = annotation.__origin__ + args = annotation.__args__ + if origin == Union: + if any(typ is type(None) for typ in args): + return True + elif sys.version_info >= (3, 10) and isinstance(annotation, types.UnionType): + args = annotation.__args__ + for typ in args: + if typ is type(None): + return True + elif inspect.isclass(annotation): + try: + if issubclass(annotation, FixedSizeListMixin): + return annotation.nullable() + except TypeError: + return False + return False + + +def _pydantic_to_field(name: str, field: Any) -> pa.Field: + """Convert a pydantic field (v1 ModelField or v2 FieldInfo) to a PyArrow Field.""" + dt = _pydantic_to_arrow_type(field) + return pa.field(name, dt, is_nullable(field)) + + +def pydantic_to_schema(model: Type[pydantic.BaseModel]) -> pa.Schema: + """Convert a [Pydantic Model][pydantic.BaseModel] to a + [PyArrow Schema][pyarrow.Schema]. + + Supports nested ``BaseModel`` fields (-> struct), ``Enum`` fields + (string-valued enums are dictionary-encoded), timezone-aware + ``datetime`` fields (via ``Field(json_schema_extra={"tz": ...})``), and + the ``Vector``/``MultiVector`` fixed-size-list types, in addition to + plain scalar/``Optional``/``List`` types. + + Parameters + ---------- + model : Type[pydantic.BaseModel] + The Pydantic BaseModel to convert to Arrow Schema. + + Returns + ------- + pyarrow.Schema + The Arrow Schema + + Examples + -------- + + >>> from typing import List, Optional + >>> import pydantic + >>> from lance.pydantic import pydantic_to_schema, Vector + >>> class FooModel(pydantic.BaseModel): + ... id: int + ... s: str + ... vec: Vector(1536) # fixed_size_list[1536] + ... li: List[int] + ... + >>> schema = pydantic_to_schema(FooModel) + >>> assert schema == pa.schema([ + ... pa.field("id", pa.int64(), False), + ... pa.field("s", pa.utf8(), False), + ... pa.field("vec", pa.list_(pa.float32(), 1536)), + ... pa.field("li", pa.list_(pa.int64()), False), + ... ]) + """ + fields = _pydantic_model_to_fields(model) + return pa.schema(fields) diff --git a/python/python/lance/query.py b/python/python/lance/query.py index 85bbf121e6e..e91087a1ba2 100644 --- a/python/python/lance/query.py +++ b/python/python/lance/query.py @@ -3,6 +3,7 @@ import abc +from dataclasses import dataclass from enum import Enum from typing import Optional @@ -14,6 +15,7 @@ class FullTextQueryType(Enum): MATCH_PHRASE = "match_phrase" BOOST = "boost" MULTI_MATCH = "multi_match" + COMBINED_FIELDS = "combined_fields" BOOLEAN = "boolean" @@ -22,6 +24,13 @@ class FullTextOperator(Enum): OR = "OR" +class DocumentGranularity(str, Enum): + """The unit treated as one full-text-search document.""" + + ROW = "row" + LIST_ELEMENT = "list_element" + + class Occur(Enum): SHOULD = "SHOULD" MUST = "MUST" @@ -98,6 +107,7 @@ def __init__( max_expansions: int = 50, operator: FullTextOperator = FullTextOperator.OR, prefix_length: int = 0, + document_granularity: Optional[DocumentGranularity] = None, ): """ Match query for full-text search. @@ -129,6 +139,10 @@ def __init__( prefix_length : int, default 0 The number of beginning characters being unchanged for fuzzy matching. This is useful to achieve prefix matching. + document_granularity : DocumentGranularity, optional + Explicitly select row or deepest-list-element documents. If omitted, + the indexed granularity is inferred. When both granularities are indexed + for the field, this must be specified. With no index, ``ROW`` is used. """ self._inner = PyFullTextQuery.match_query( query, @@ -138,6 +152,9 @@ def __init__( max_expansions=max_expansions, operator=operator.value, prefix_length=prefix_length, + document_granularity=( + document_granularity.value if document_granularity is not None else None + ), ) def query_type(self) -> FullTextQueryType: @@ -145,7 +162,14 @@ def query_type(self) -> FullTextQueryType: class PhraseQuery(FullTextQuery): - def __init__(self, query: str, column: str, *, slop: int = 0): + def __init__( + self, + query: str, + column: str, + *, + slop: int = 0, + document_granularity: Optional[DocumentGranularity] = None, + ): """ Phrase query for full-text search. @@ -155,8 +179,21 @@ def __init__(self, query: str, column: str, *, slop: int = 0): The query string to match against. column : str The name of the column to match against. + slop : int, default 0 + The maximum number of intervening positions permitted in the phrase. + document_granularity : DocumentGranularity, optional + Explicitly select row or deepest-list-element documents. If omitted, + the indexed granularity is inferred. When both granularities are indexed + for the field, this must be specified. With no index, ``ROW`` is used. """ - self._inner = PyFullTextQuery.phrase_query(query, column, slop) + self._inner = PyFullTextQuery.phrase_query( + query, + column, + slop, + document_granularity=( + document_granularity.value if document_granularity is not None else None + ), + ) def query_type(self) -> FullTextQueryType: return FullTextQueryType.MATCH_PHRASE @@ -227,6 +264,65 @@ def query_type(self) -> FullTextQueryType: return FullTextQueryType.MULTI_MATCH +class CombinedFieldsQuery(FullTextQuery): + def __init__( + self, + query: str, + columns: list[str], + *, + boosts: Optional[list[float]] = None, + operator: FullTextOperator = FullTextOperator.OR, + ): + """ + Combined-fields (BM25F) query for full-text search. + + Unlike :class:`MultiMatchQuery`, which scores each column independently + and keeps the best field (Elasticsearch ``best_fields``), this query + treats the target columns as a single virtual field so that term + statistics are blended across fields (Elasticsearch ``combined_fields`` + / Lucene ``CombinedFieldQuery``). This makes a term that is rare in one + field but common in another score consistently, and lets a single query + term match across fields (e.g. a first name in one column and a last + name in another). + + Parameters + ---------- + query : str + The query string to match against. + columns : list[str] + The list of columns combined into the virtual field. + boosts : list[float], optional + Per-column weights aligned with ``columns``. Each weight must be + ``>= 1`` (fractional weights allowed). If not provided, every column + defaults to ``1.0``. + operator : FullTextOperator, default OR + The operator applied across the virtual field. If ``AND``, every + term must appear in at least one column. If ``OR``, at least one + term must match. + + Notes + ----- + All target columns must share the same tokenizer/index configuration. + + At least one target column needs an FTS index. Columns without an + index, and rows added since the last index build, are read from the + data and scored together with the indexed rows. + + BM25F adds up each column's contribution for a whole row, so a column + indexed only with + ``document_granularity=DocumentGranularity.LIST_ELEMENT`` is rejected, + because its element coordinates have no counterpart in the other columns + and the result carries no ``_doc_index``. A column that has both indexes + uses the row one. + """ + self._inner = PyFullTextQuery.combined_fields_query( + query, columns, boosts=boosts, operator=operator.value + ) + + def query_type(self) -> FullTextQueryType: + return FullTextQueryType.COMBINED_FIELDS + + class BooleanQuery(FullTextQuery): def __init__(self, queries: list[tuple[Occur, FullTextQuery]]): """ @@ -235,7 +331,9 @@ def __init__(self, queries: list[tuple[Occur, FullTextQuery]]): Parameters ---------- queries : list[tuple(Occur, FullTextQuery)] - The list of queries with their occurrence requirements. + The list of queries with their occurrence requirements. Every MUST + clause must match and contributes its score; matching SHOULD scores + are also added, while MUST_NOT clauses only exclude documents. """ self._inner = PyFullTextQuery.boolean_query( [(occur.value, query.inner) for occur, query in queries] @@ -243,3 +341,31 @@ def __init__(self, queries: list[tuple[Occur, FullTextQuery]]): def query_type(self) -> FullTextQueryType: return FullTextQueryType.BOOLEAN + + +@dataclass +class MinHashQuery: + """A MinHash similarity search over a column with a MinHash LSH index. + + Pass it as ``nearest`` to :meth:`LanceDataset.to_table` or + :meth:`LanceDataset.scanner`. The result carries a ``_distance`` column + equal to ``1 - estimated Jaccard similarity`` between the query text and + each row, ordered ascending; the number of rows is the scan ``limit`` + (default 10). + + Parameters + ---------- + text : str + The query text. It is tokenized and shingled exactly like the indexed + rows, using the tokenizer stored in the index. + column : str + The column that has the MinHash LSH index. + + Examples + -------- + >>> ds.to_table(nearest=MinHashQuery("some near duplicate text", "text"), + ... limit=10) # doctest: +SKIP + """ + + text: str + column: str diff --git a/python/python/lance/sampler.py b/python/python/lance/sampler.py index b7e7230dfc6..4c17825ab04 100644 --- a/python/python/lance/sampler.py +++ b/python/python/lance/sampler.py @@ -193,7 +193,7 @@ def maybe_sample( This is employed to minimize the number of random reads necessary for sampling. A sufficiently large value can provide an effective random sample without the need for excessive random reads. - filter : str, optional + filt : str, optional The filter to apply to the dataset, by default None. If a filter is provided, then we will first load all row ids in memory and then batch through the ids in random order until enough matches have been found. diff --git a/python/python/lance/schema.py b/python/python/lance/schema.py index aacdcb73e8e..e97e97e3322 100644 --- a/python/python/lance/schema.py +++ b/python/python/lance/schema.py @@ -12,17 +12,19 @@ def schema_to_json(schema: pa.Schema) -> Dict[str, Any]: """ - Converts a pyarrow schema to a JSON string. + Converts a pyarrow schema to a JSON-compatible dict. Parameters ---------- + schema: pa.Schema + The PyArrow schema to convert. """ return json.loads(_schema_to_json(schema)) def json_to_schema(schema_json: Dict[str, Any]) -> pa.Schema: """ - Converts a JSON string to a PyArrow schema. + Converts a JSON-compatible dict to a PyArrow schema. Parameters ---------- diff --git a/python/python/lance/tf/__init__.py b/python/python/lance/tf/__init__.py deleted file mode 100644 index 1aa41beb99c..00000000000 --- a/python/python/lance/tf/__init__.py +++ /dev/null @@ -1,10 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright The Lance Authors - -import importlib.util - -if importlib.util.find_spec("tensorflow") is None: - raise ImportError( - "Tensorflow is not installed. Please install tensorflow" - + " to use lance.tf module.", - ) diff --git a/python/python/lance/tf/data.py b/python/python/lance/tf/data.py deleted file mode 100644 index 7b6ff8e51a4..00000000000 --- a/python/python/lance/tf/data.py +++ /dev/null @@ -1,408 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright The Lance Authors - - -"""Tensorflow Dataset (`tf.data `_) -implementation for Lance. - -.. warning:: - - Experimental feature. API stability is not guaranteed. -""" - -from __future__ import annotations - -from functools import partial -from typing import TYPE_CHECKING, Dict, Iterable, List, Optional, Tuple, Union - -import pyarrow as pa - -import lance -from lance import LanceDataset -from lance.arrow import EncodedImageType, FixedShapeImageTensorType, ImageURIType -from lance.dependencies import _check_for_numpy -from lance.dependencies import numpy as np -from lance.dependencies import tensorflow as tf -from lance.fragment import FragmentMetadata, LanceFragment -from lance.log import LOGGER - -if TYPE_CHECKING: - from pathlib import Path - - from lance import LanceNamespace - - -def arrow_data_type_to_tf(dt: pa.DataType) -> tf.DType: - """Convert Pyarrow DataType to Tensorflow.""" - if pa.types.is_boolean(dt): - return tf.bool - elif pa.types.is_int8(dt): - return tf.int8 - elif pa.types.is_int16(dt): - return tf.int16 - elif pa.types.is_int32(dt): - return tf.int32 - elif pa.types.is_int64(dt): - return tf.int64 - elif pa.types.is_uint8(dt): - return tf.uint8 - elif pa.types.is_uint16(dt): - return tf.uint16 - elif pa.types.is_uint32(dt): - return tf.uint32 - elif pa.types.is_uint64(dt): - return tf.uint64 - elif pa.types.is_float16(dt): - return tf.float16 - elif pa.types.is_float32(dt): - return tf.float32 - elif pa.types.is_float64(dt): - return tf.float64 - elif ( - pa.types.is_string(dt) - or pa.types.is_large_string(dt) - or pa.types.is_binary(dt) - or pa.types.is_large_binary(dt) - ): - return tf.string - - raise TypeError(f"Arrow/Tf conversion: Unsupported arrow data type: {dt}") - - -def data_type_to_tensor_spec(dt: pa.DataType) -> tf.TensorSpec: - """Convert PyArrow DataType to Tensorflow TensorSpec.""" - if ( - pa.types.is_boolean(dt) - or pa.types.is_integer(dt) - or pa.types.is_floating(dt) - or pa.types.is_string(dt) - or pa.types.is_binary(dt) - ): - return tf.TensorSpec(shape=(None,), dtype=arrow_data_type_to_tf(dt)) - elif isinstance(dt, pa.FixedShapeTensorType): - return tf.TensorSpec( - shape=(None, *dt.shape), dtype=arrow_data_type_to_tf(dt.value_type) - ) - elif pa.types.is_fixed_size_list(dt): - return tf.TensorSpec( - shape=(None, dt.list_size), dtype=arrow_data_type_to_tf(dt.value_type) - ) - elif pa.types.is_list(dt) or pa.types.is_large_list(dt): - return tf.TensorSpec( - shape=( - None, - None, - ), - dtype=arrow_data_type_to_tf(dt.value_type), - ) - elif pa.types.is_struct(dt): - return {field.name: data_type_to_tensor_spec(field.type) for field in dt} - elif isinstance(dt, (EncodedImageType, ImageURIType)): - return tf.TensorSpec(shape=(None,), dtype=tf.string) - elif isinstance(dt, FixedShapeImageTensorType): - return tf.TensorSpec( - shape=(None, *dt.shape), dtype=arrow_data_type_to_tf(dt.arrow_type) - ) - - raise TypeError("Unsupported data type: ", dt) - - -def schema_to_spec(schema: pa.Schema) -> tf.TypeSpec: - """Convert PyArrow Schema to Tensorflow output signature.""" - signature = {} - for name in schema.names: - field = schema.field(name) - signature[name] = data_type_to_tensor_spec(field.type) - return signature - - -def column_to_tensor(array: pa.Array, tensor_spec: tf.TensorSpec) -> tf.Tensor: - """Convert a PyArrow array into a TensorFlow tensor.""" - if isinstance(tensor_spec, tf.RaggedTensorSpec): - return tf.ragged.constant(array.to_pylist(), dtype=tensor_spec.dtype) - elif isinstance(array.type, pa.FixedShapeTensorType): - return tf.constant(array.to_numpy_ndarray(), dtype=tensor_spec.dtype) - elif isinstance(array.type, FixedShapeImageTensorType): - return tf.constant(array.to_numpy(), dtype=tensor_spec.dtype) - elif isinstance(array.type, pa.StructType): - return { - field.name: column_to_tensor(array.field(i), tensor_spec[field.name]) - for (i, field) in enumerate(array.type) - } - else: - return tf.constant(array.to_pylist(), dtype=tensor_spec.dtype) - - -def from_lance( - dataset: Optional[Union[str, Path, LanceDataset]] = None, - *, - columns: Optional[Union[List[str], Dict[str, str]]] = None, - batch_size: int = 256, - 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, - table_id: Optional[List[str]] = None, - ignore_namespace_table_storage_options: bool = False, -) -> tf.data.Dataset: - """Create a ``tf.data.Dataset`` from a Lance dataset. - - Parameters - ---------- - dataset : Union[str, Path, LanceDataset], optional - Lance dataset or dataset URI/path. Either ``dataset`` or both - ``namespace`` 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. - batch_size : int, optional - Batch size, by default 256 - filter : Optional[str], optional - SQL filter expression, by default None. - fragments : Union[List[LanceFragment], tf.data.Dataset], optional - If provided, only the fragments are read. It can be used to feed - for distributed training. - 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. - table_id : Optional[List[str]], optional - Table identifier used together with ``namespace`` to locate the table. - ignore_namespace_table_storage_options : bool, default False - When using ``namespace``/``table_id``, ignore storage options returned - by the namespace. - - Examples - -------- - - .. code-block:: python - - import tensorflow as tf - import lance.tf.data - - ds = lance.tf.data.from_lance( - "s3://bucket/path", - columns=["image", "id"], - filter="catalog = 'train' AND split = 'train'", - batch_size=100) - - for batch in ds.repeat(10).shuffle(128).map(io_decode): - print(batch["image"].shape) - - ``from_lance`` can take an iterator or ``tf.data.Dataset`` of - Fragments. So that it can be used to feed for distributed training. - - .. code-block:: python - - import tensorflow as tf - import lance.tf.data - - seed = 200 # seed to shuffle the fragments in distributed machines. - fragments = lance.tf.data.lance_fragments("s3://bucket/path") - repeat(10).shuffle(4, seed=seed) - ds = lance.tf.data.from_lance( - "s3://bucket/path", - columns=["image", "id"], - filter="catalog = 'train' AND split = 'train'", - fragments=fragments, - batch_size=100) - for batch in ds.shuffle(128).map(io_decode): - print(batch["image"].shape) - - """ - if isinstance(dataset, LanceDataset): - if namespace is not None or table_id is not None: - raise ValueError( - "Cannot specify 'namespace' or 'table_id' when passing " - "a LanceDataset instance" - ) - else: - dataset = lance.dataset( - dataset, - namespace=namespace, - table_id=table_id, - ignore_namespace_table_storage_options=ignore_namespace_table_storage_options, - ) - - if isinstance(fragments, tf.data.Dataset): - fragments = list(fragments.as_numpy_iterator()) - elif _check_for_numpy(fragments) and isinstance(fragments, np.ndarray): - fragments = list(fragments) - - if fragments is not None: - - def gen_fragments(fragments): - for f in fragments: - if isinstance(f, int) or ( - _check_for_numpy(f) and isinstance(f, np.integer) - ): - yield LanceFragment(dataset, int(f)) - elif isinstance(f, FragmentMetadata): - yield LanceFragment(dataset, f.id) - elif isinstance(f, LanceFragment): - yield f - else: - raise TypeError(f"Invalid type passed to `fragments`: {type(f)}") - - # A Generator of Fragments - fragments = gen_fragments(fragments) - - scanner = dataset.scanner( - filter=filter, columns=columns, batch_size=batch_size, fragments=fragments - ) - - if output_signature is None: - schema = scanner.projected_schema - output_signature = schema_to_spec(schema) - LOGGER.debug("Output signature: %s", output_signature) - - def generator(): - for batch in scanner.to_batches(): - yield { - name: column_to_tensor(batch[name], output_signature[name]) - for name in batch.schema.names - } - - return tf.data.Dataset.from_generator(generator, output_signature=output_signature) - - -def lance_fragments(dataset: Union[str, Path, LanceDataset]) -> tf.data.Dataset: - """Create a ``tf.data.Dataset`` of Lance Fragments in the dataset. - - Parameters - ---------- - dataset : Union[str, Path, LanceDataset] - A Lance Dataset or dataset URI/path. - """ - if not isinstance(dataset, LanceDataset): - dataset = lance.dataset(dataset) - return tf.data.Dataset.from_tensor_slices( - [f.fragment_id for f in dataset.get_fragments()] - ) - - -def _ith_batch(i: int, batch_size: int, total_size: int) -> Tuple[int, int]: - """ - Get the start and end index of the ith batch. - - This takes into account the total_size, the total number of rows in the dataset. - """ - start = i * batch_size - end = tf.math.minimum(start + batch_size, total_size) - return (start, end) - - -def from_lance_batches( - dataset: Union[str, Path, LanceDataset], - *, - shuffle: bool = False, - seed: Optional[int] = None, - batch_size: int = 1024, - skip: int = 0, -) -> tf.data.Dataset: - """ - Create a ``tf.data.Dataset`` of batch indices for a Lance dataset. - - Parameters - ---------- - dataset : Union[str, Path, LanceDataset] - A Lance Dataset or dataset URI/path. - shuffle : bool, optional - Shuffle the batches, by default False - seed : Optional[int], optional - Random seed for shuffling, by default None - batch_size : int, optional - Batch size, by default 1024 - skip : int, optional - Number of batches to skip. - - Returns - ------- - tf.data.Dataset - A tensorflow dataset of batch slice ranges. These can be passed to - :func:`lance_take_batches` to create a Tensorflow dataset of batches. - """ - if not isinstance(dataset, LanceDataset): - dataset = lance.dataset(dataset) - num_rows = dataset.count_rows() - num_batches = (num_rows + batch_size - 1) // batch_size - indices = tf.data.Dataset.range(num_batches, dtype=tf.int64) - if shuffle: - indices = indices.shuffle(num_batches, seed=seed) - if skip > 0: - indices = indices.skip(skip) - return indices.map(partial(_ith_batch, batch_size=batch_size, total_size=num_rows)) - - -def lance_take_batches( - dataset: Union[str, Path, LanceDataset], - batch_ranges: Iterable[Tuple[int, int]], - *, - columns: Optional[List[str]] = None, - output_signature: Optional[Dict[str, tf.TypeSpec]] = None, - batch_readahead: int = 10, -) -> tf.data.Dataset: - """ - Create a ``tf.data.Dataset`` of batches from a Lance dataset. - - Parameters - ---------- - dataset : Union[str, Path, LanceDataset] - A Lance Dataset or dataset URI/path. - batch_ranges : Iterable[Tuple[int, int]] - Iterable of batch indices. - columns : Optional[List[str]], optional - List of columns to include in the output dataset. - If not set, all columns will be read. - 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. - batch_readahead : int, default 10 - The number of batches to read ahead in parallel. - - Examples - -------- - You can compose this with ``from_lance_batches`` to create a randomized Tensorflow - dataset. With ``from_lance_batches``, you can deterministically randomized the - batches by setting ``seed``. - - .. code-block:: python - - batch_iter = from_lance_batches(dataset, batch_size=100, shuffle=True, seed=200) - batch_iter = batch_iter.as_numpy_iterator() - lance_ds = lance_take_batches(dataset, batch_iter) - lance_ds = lance_ds.unbatch().shuffle(500, seed=42).batch(100) - """ - if not isinstance(dataset, LanceDataset): - dataset = lance.dataset(dataset) - - if output_signature is None: - schema = dataset.scanner(columns=columns).projected_schema - output_signature = schema_to_spec(schema) - LOGGER.debug("Output signature: %s", output_signature) - - def gen_ranges(): - for start, end in batch_ranges: - yield (start, end) - - def gen_batches(): - batches = dataset._ds.take_scan( - gen_ranges(), - columns=columns, - batch_readahead=batch_readahead, - ) - for batch in batches: - yield { - name: column_to_tensor(batch[name], output_signature[name]) - for name in batch.schema.names - } - - return tf.data.Dataset.from_generator( - gen_batches, output_signature=output_signature - ) - - -# Register `from_lance` to ``tf.data.Dataset``. -tf.data.Dataset.from_lance = from_lance -tf.data.Dataset.from_lance_batches = from_lance_batches diff --git a/python/python/lance/torch/bench_utils.py b/python/python/lance/torch/bench_utils.py index b0b19fc22a9..41d8f8278f3 100644 --- a/python/python/lance/torch/bench_utils.py +++ b/python/python/lance/torch/bench_utils.py @@ -128,7 +128,7 @@ def recall(expected: np.ndarray, actual: np.ndarray) -> np.ndarray: ---------- expected: ndarray The ground truth - results: ndarray + actual: ndarray The ANN results """ assert expected.shape == actual.shape diff --git a/python/python/lance/torch/data.py b/python/python/lance/torch/data.py index d5adcbbfe19..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 @@ -443,10 +499,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/lance/torch/distance.py b/python/python/lance/torch/distance.py index 81201027c87..ed5292cdb2f 100644 --- a/python/python/lance/torch/distance.py +++ b/python/python/lance/torch/distance.py @@ -150,8 +150,9 @@ def pairwise_l2( if x.dtype != y.dtype or (y2 is not None and x.dtype != y2.dtype): raise ValueError("pairwise_l2 data types do not match") origin_dtype = x.dtype - if x.device == torch.device("cpu") and x.dtype == torch.float16: - # Pytorch does not support `x @ y.T` for float16 on CPU + if x.device == torch.device("cpu") and x.dtype in (torch.float16, torch.bfloat16): + # Pytorch does not support `x @ y.T` for float16 on CPU, and bfloat16 + # can hit backend-specific compiler paths. Accumulate in float32. x = x.type(torch.float32) y = y.type(torch.float32) if y2 is not None: diff --git a/python/python/lance/torch/kmeans.py b/python/python/lance/torch/kmeans.py index 44fca9ae60a..84874c47f46 100644 --- a/python/python/lance/torch/kmeans.py +++ b/python/python/lance/torch/kmeans.py @@ -177,7 +177,7 @@ def _updated_centroids( for idx in zero_counts.nonzero(as_tuple=False): # split the largest cluster and remove empty cluster max_idx = torch.argmax(counts).item() - # add 1% gassuian noise to the largest centroid + # add 1% gaussian noise to the largest centroid # do this twice so we effectively split the largest cluster into 2 # rand_like returns on [0, 1) so we need to shift it to [-0.5, 0.5) noise = (torch.rand_like(centroids[idx]) - 0.5) * 0.01 + 1 diff --git a/python/python/lance/types.py b/python/python/lance/types.py index 41cc191e4d6..3b5cc5b0683 100644 --- a/python/python/lance/types.py +++ b/python/python/lance/types.py @@ -9,7 +9,13 @@ from pyarrow import RecordBatch from . import dataset -from .dependencies import _check_for_hugging_face, _check_for_pandas +from .dependencies import ( + _check_for_hugging_face, + _check_for_pandas, + _is_pydantic_base_model, + _validate_pydantic_list, + model_to_dict, +) from .dependencies import pandas as pd if TYPE_CHECKING: @@ -52,6 +58,34 @@ def _casting_recordbatch_iter( yield batch +def _is_materialized(data_obj: ReaderLike) -> bool: + """Whether ``data_obj`` is fully materialized in memory. + + Materialized sources (tables, in-memory frames) can be wrapped in an + in-memory table for replay without spilling and to expose exact statistics. + Streaming or re-readable sources (readers, scanners, datasets, generators) + are not considered materialized. + """ + if _check_for_pandas(data_obj) and isinstance(data_obj, pd.DataFrame): + return True + if isinstance(data_obj, (pa.Table, pa.RecordBatch)): + return True + if ( + type(data_obj).__module__.startswith("polars") + and data_obj.__class__.__name__ == "DataFrame" + ): + return True + if isinstance(data_obj, dict): + return True + if ( + isinstance(data_obj, list) + and len(data_obj) > 0 + and isinstance(data_obj[0], dict) + ): + return True + return False + + def _coerce_reader( data_obj: ReaderLike, schema: Optional[pa.Schema] = None ) -> pa.RecordBatchReader: @@ -116,6 +150,20 @@ def batch_iter(): # List of dictionaries batch = pa.RecordBatch.from_pylist(data_obj, schema=schema) return pa.RecordBatchReader.from_batches(batch.schema, [batch]) + elif ( + isinstance(data_obj, list) + and len(data_obj) > 0 + and _is_pydantic_base_model(data_obj[0]) + ): + model_class = type(data_obj[0]) + _validate_pydantic_list(data_obj, model_class) + if schema is None: + from .pydantic import pydantic_to_schema + + schema = pydantic_to_schema(model_class) + dicts = [model_to_dict(item) for item in data_obj] + batch = pa.RecordBatch.from_pylist(dicts, schema=schema) + return pa.RecordBatchReader.from_batches(batch.schema, [batch]) # for other iterables, assume they are of type Iterable[RecordBatch] elif isinstance(data_obj, Iterable): if schema is not None: diff --git a/python/python/lance/udf.py b/python/python/lance/udf.py index de6c7c4ff59..45bd008f564 100644 --- a/python/python/lance/udf.py +++ b/python/python/lance/udf.py @@ -7,7 +7,7 @@ import pickle import sqlite3 from contextlib import closing -from typing import TYPE_CHECKING, Dict, List, NamedTuple, Optional +from typing import TYPE_CHECKING, Any, Callable, Dict, List, NamedTuple, Optional, Union import pyarrow as pa @@ -18,6 +18,8 @@ from .types import _coerce_reader if TYPE_CHECKING: + from pathlib import Path + from .dataset import LanceDataset, LanceFragment from .types import ReaderLike @@ -28,20 +30,26 @@ class BatchUDF: Use :func:`lance.add_columns_udf` decorator to wrap a function with this class. """ - def __init__(self, func, output_schema=None, checkpoint_file=None): + def __init__( + self, + func: Callable[[pa.RecordBatch], Any], + output_schema: Optional[pa.Schema] = None, + checkpoint_file: Optional[Union[str, Path]] = None, + ) -> None: self.func = func self.output_schema = output_schema + self.cache: Optional[BatchUDFCheckpoint] if checkpoint_file is not None: self.cache = BatchUDFCheckpoint(checkpoint_file) else: self.cache = None - def __call__(self, batch: pa.RecordBatch): + def __call__(self, batch: pa.RecordBatch) -> Any: # Directly call inner function. This is to allow the user to test the # function and have it behave exactly as it was written. return self.func(batch) - def _call(self, batch: pa.RecordBatch): + def _call(self, batch: pa.RecordBatch) -> pa.RecordBatch: if self.output_schema is None: raise ValueError( "output_schema must be provided when using a function that " @@ -59,7 +67,10 @@ def _call(self, batch: pa.RecordBatch): return result -def batch_udf(output_schema=None, checkpoint_file=None): +def batch_udf( + output_schema: Optional[pa.Schema] = None, + checkpoint_file: Optional[Union[str, Path]] = None, +) -> Callable[[Callable[[pa.RecordBatch], Any]], BatchUDF]: """ Create a user defined function (UDF) that adds columns to a dataset. @@ -88,7 +99,7 @@ def batch_udf(output_schema=None, checkpoint_file=None): AddColumnsUDF """ - def inner(func): + def inner(func: Callable[[pa.RecordBatch], Any]) -> BatchUDF: return BatchUDF(func, output_schema, checkpoint_file) return inner @@ -205,7 +216,9 @@ def normalize_transform( ) ) ) - if isinstance(sample_batch, pd.DataFrame): + if _check_for_pandas(sample_batch) and isinstance( + sample_batch, pd.DataFrame + ): sample_batch = pa.RecordBatch.from_pandas(sample_batch) udf_like.output_schema = sample_batch.schema @@ -233,7 +246,9 @@ def normalize_transform( ) ) ) - if isinstance(sample_batch, pd.DataFrame): + if _check_for_pandas(sample_batch) and isinstance( + sample_batch, pd.DataFrame + ): sample_batch = pa.RecordBatch.from_pandas(sample_batch) udf_like = BatchUDF(udf_like, output_schema=sample_batch.schema) diff --git a/python/python/lance/util.py b/python/python/lance/util.py index 5b94ad5c35a..180e2441b43 100644 --- a/python/python/lance/util.py +++ b/python/python/lance/util.py @@ -3,8 +3,18 @@ from __future__ import annotations +import uuid from datetime import datetime, timedelta -from typing import TYPE_CHECKING, Iterator, Literal, Optional, Union, cast +from typing import ( + TYPE_CHECKING, + Iterable, + Iterator, + List, + Literal, + Optional, + Union, + cast, +) import pyarrow as pa @@ -51,6 +61,29 @@ def td_to_micros(td: timedelta) -> int: return round(td / timedelta(microseconds=1)) +def _normalize_index_segment_ids( + index_segments: Optional[Iterable[Union[str, uuid.UUID]]], +) -> Optional[List[str]]: + """Normalize a physical index segment selection to a list of UUID strings.""" + if index_segments is None: + return None + if isinstance(index_segments, (str, uuid.UUID)): + raise TypeError( + "index_segments must be an iterable of str or uuid.UUID, " + f"not a single {type(index_segments)}." + ) + segment_ids = [] + for segment_id in index_segments: + if isinstance(segment_id, (str, uuid.UUID)): + segment_ids.append(str(segment_id)) + else: + raise TypeError( + "index_segments must be an iterable of str or uuid.UUID. " + f"Got {type(segment_id)} instead." + ) + return segment_ids + + class KMeans: """KMean model for clustering. @@ -254,4 +287,4 @@ def _target_partition_size_to_num_partitions( if target_partition_size is None: target_partition_size = 8192 num_partitions = num_rows // target_partition_size - return max(1, num_partitions, 4096) + return min(max(1, num_partitions), 4096) diff --git a/python/python/lance/vector.py b/python/python/lance/vector.py index 73f7981fe28..c1c0d5e96df 100644 --- a/python/python/lance/vector.py +++ b/python/python/lance/vector.py @@ -19,9 +19,10 @@ ) from .dependencies import numpy as np from .log import LOGGER -from .util import MetricType, _normalize_metric_type +from .util import MetricType, _normalize_index_segment_ids, _normalize_metric_type if TYPE_CHECKING: + import uuid from pathlib import Path from . import LanceDataset @@ -197,6 +198,33 @@ def train_pq_codebook_on_accelerator( return pq_codebook, kmeans_list +def _sample_init_centroids( + ds: Iterable["torch.Tensor"], k: int, filter_nan: bool +) -> "torch.Tensor": + """Take up to k vectors from ds to seed kmeans, skipping non-finite ones.""" + # `column is not null` does not exclude NaN vectors, so they can still be + # sampled here. Training drops them (distance returns id -1), but a NaN + # centroid never recovers and leaves every partition NaN. + sampled = [] + num_sampled = 0 + for batch in ds: + if filter_nan: + batch = batch[batch.isfinite().flatten(1).all(dim=1)] + if batch.shape[0] == 0: + continue + sampled.append(batch) + num_sampled += batch.shape[0] + if num_sampled >= k: + break + + if num_sampled == 0: + raise ValueError( + "Cannot initialize centroids: the sampled vectors are all null or " + "non-finite" + ) + return torch.cat(sampled)[:k] + + def train_ivf_centroids_on_accelerator( dataset: LanceDataset, column: str, @@ -244,7 +272,7 @@ def train_ivf_centroids_on_accelerator( filter=filt, ) - init_centroids = next(iter(ds)) + init_centroids = _sample_init_centroids(ds, k, filter_nan) LOGGER.info("Done sampling: centroids shape: %s", init_centroids.shape) ds = TorchDataset( @@ -270,10 +298,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 @@ -292,12 +316,12 @@ def compute_pq_codes( Dataset to compute pq codes for. kmeans_list: List[lance.torch.kmeans.KMeans] KMeans models to use to compute pq (one per subspace) - batch_size: int, default 10240 + batch_size: int, default 40960 The batch size used to read the dataset. dst_dataset_uri: Union[str, Path], optional The path to store the partitions. If not specified a random directory is used instead - allow_tf32: bool, default True + allow_cuda_tf32: bool, default True Whether to allow tf32 for matmul on CUDA. Returns @@ -421,12 +445,12 @@ def compute_partitions( Column name of the vector column. kmeans: lance.torch.kmeans.KMeans KMeans model to use to compute partitions. - batch_size: int, default 10240 + batch_size: int, default 40960 The batch size used to read the dataset. dst_dataset_uri: Union[str, Path], optional The path to store the partitions. If not specified a random directory is used instead - allow_tf32: bool, default True + allow_cuda_tf32: bool, default True Whether to allow tf32 for matmul on CUDA. Returns @@ -753,3 +777,311 @@ def _partition_and_pq_codes_assignment() -> Iterable[pa.RecordBatch]: data_file.path for frag in ds.get_fragments() for data_file in frag.data_files() ] return dst_dataset_uri, shuffle_buffers + + +# ============================================================================= +# Embedding Duplicate Pairs +# ============================================================================= + + +def find_duplicate_pairs( + dataset: LanceDataset, + column: str, + distance_threshold: float, + *, + memory_limit: Optional[int] = None, + max_concurrency: Optional[int] = None, +) -> pa.RecordBatchReader: + """Stream embedding duplicate pairs from an existing vector index. + + The column must have exactly one current-format vector index covering all + dataset fragments. Segments and partitions are evaluated independently; + cross-partition and cross-segment pairs are omitted. No search or top-k + truncation is performed. Deleted rows are excluded at this dataset + snapshot. This operation does not delete rows. + + Distances are computed natively between the index representations ``a`` + and ``b`` of two rows (the vectors that the index codes reconstruct; the + stored vectors for IVF_FLAT) without fetching or reconstructing any + vector. They use the index metric's definition: + + ========= ========================================================== + metric distance + ========= ========================================================== + l2 squared L2 ``||a - b||^2`` + cosine ``1 - cos(a, b)`` in ``[0, 2]``; 0 for identical codes + dot ``1 - a . b`` + hamming number of differing bits + ========= ========================================================== + + Quantized cosine renormalizes the reconstructions, as exact or refined + search does. Unrefined ANN search over a quantized cosine index reports the + squared L2 of normalized vectors instead, roughly twice this value. A pair + qualifies when its finite distance is ``<= distance_threshold``. Distances + are bit-identical regardless of ``memory_limit``, ``max_concurrency`` and + whether this function or :func:`find_duplicate_pairs_in_partition` is used. + + Returns a reader with non-null ``row_id_a: uint64``, ``row_id_b: uint64`` + and ``distance: float32`` columns, containing every qualifying unordered + pair once. ``row_id_a`` is the row at the lower index storage position, + not the numerically smaller row ID. Close the reader to cancel further work. + + Output order is deterministic: segments, partitions in ascending order, then + tiles of the partition's vector batches (anchor batch ``I``, candidate batch + ``J >= I``), row-major within a tile (anchor row, then candidate row). Pairs + of one ``row_id_a`` are contiguous within a tile but not across tiles. Each + Arrow batch holds the pairs of up to 32 anchor rows against one vector + batch, so it contains at most ``32 * 8,192`` rows. + + Each partition's index codes are staged once, in vector batches of at most + 8,192 rows (fewer for wide codes, targeting 16 MiB per batch, at least 32 + rows). ``memory_limit`` (default 256 MiB) bounds the staged codes of a + partition; larger partitions, or any with ``memory_limit=0``, are staged to + temporary session spill storage, read back once per tile, and reclaimed + when the reader advances or closes. Staging completes before emitting pairs + from the partition. The budget excludes quantizer models, row masks, spill + metadata, source reads and in-flight scoring buffers; it is not a process + RSS limit. ``max_concurrency`` (positive; defaults to the CPU pool size) + limits in-flight scoring jobs; each shares its tile's two vector batches and + produces one output batch. Spilled batches are read back as decoded + copies; those alive at once hold at most ``memory_limit`` bytes of staged + codes (or three batches, if larger), even when sparse row selection leaves + one job per tile. Completion is ordered, so output does not depend on + concurrency. + + Examples + -------- + .. code-block:: python + + from lance.vector import find_duplicate_pairs + + with find_duplicate_pairs(dataset, "embedding", 0.05) as pairs: + for batch in pairs: + consume(batch) + + See Also + -------- + find_duplicate_pairs_in_partition : Execute one segment/partition. + """ + return dataset._ds.find_duplicate_pairs( + column, + distance_threshold, + memory_limit=memory_limit, + max_concurrency=max_concurrency, + ) + + +def find_duplicate_pairs_in_partition( + dataset: LanceDataset, + column: str, + segment_id: Union[str, uuid.UUID], + partition_id: int, + distance_threshold: float, + *, + memory_limit: Optional[int] = None, + max_concurrency: Optional[int] = None, +) -> pa.RecordBatchReader: + """Stream duplicate pairs from one physical index segment and partition. + + Uses the same output schema, distances and ordering as + :func:`find_duplicate_pairs`, and returns exactly that function's output + for this segment and partition. ``segment_id`` is a physical index UUID, + not an index name or fragment ID. ``partition_id`` is local to that segment. + Other dataset fragments need not be indexed for this scoped operation; rows + they hold are not paired. + Distributed callers must open the same dataset version on every worker. + ``memory_limit`` and ``max_concurrency`` have the same meaning and + defaults as in :func:`find_duplicate_pairs` and apply to this invocation. + + Examples + -------- + .. code-block:: python + + from lance.vector import find_duplicate_pairs_in_partition + + segment_id = dataset.describe_indices()[0].segments[0].uuid + with find_duplicate_pairs_in_partition( + dataset, "embedding", segment_id, 0, 0.05 + ) as pairs: + for batch in pairs: + consume(batch) + """ + return dataset._ds.find_duplicate_pairs_in_partition( + column, + str(segment_id), + partition_id, + distance_threshold, + memory_limit=memory_limit, + max_concurrency=max_concurrency, + ) + + +# ============================================================================= +# Hamming Distance Clustering +# ============================================================================= + + +def hamming_clustering_for_ivf_partition( + dataset: "LanceDataset", + index_name: str, + partition_id: int, + hamming_threshold: int, + *, + index_segments: Optional[Iterable[Union[str, uuid.UUID]]] = None, +) -> pa.RecordBatchReader: + """ + Perform hamming clustering on a partition of an IVF_FLAT index. + + Loads a partition from every segment of an IVF_FLAT index on a hash + column, computes pairwise hamming distances between all hashes in the + combined partition, filters by threshold, and clusters the results using + union-find. All segments of the logical index must share the same global + IVF centroids; an error is raised if they do not. + + Parameters + ---------- + dataset : LanceDataset + The Lance dataset containing the hash column with an IVF_FLAT index. + index_name : str + Name of the IVF_FLAT index on the hash column + partition_id : int + The partition ID within the IVF_FLAT index + hamming_threshold : int + Maximum hamming distance to consider as similar + index_segments : iterable of str or uuid.UUID, optional + If specified, only these physical index segment UUIDs of the named + logical index contribute rows. Use + :meth:`LanceDataset.describe_indices` to obtain segment UUIDs from + ``IndexDescription.segments``. Defaults to all segments. + + Returns + ------- + pa.RecordBatchReader + A reader yielding batches with columns: + + - 'representative': uint64 - The representative row ID for each cluster + - 'duplicates': list - List of duplicate row IDs in each cluster + """ + return dataset._ds.hamming_clustering_for_ivf_partition( + index_name, + partition_id, + hamming_threshold, + _normalize_index_segment_ids(index_segments), + ) + + +def get_ivf_partition_info( + dataset: "LanceDataset", + index_name: str, + *, + index_segments: Optional[Iterable[Union[str, uuid.UUID]]] = None, +) -> List[dict]: + """ + Get partition information for an IVF_FLAT index. + + Partition sizes are aggregated across all segments of the logical index + unless a subset is selected via ``index_segments``. + + Parameters + ---------- + dataset : LanceDataset + The Lance dataset containing the hash column with an IVF_FLAT index. + index_name : str + Name of the IVF_FLAT index + index_segments : iterable of str or uuid.UUID, optional + If specified, only these physical index segment UUIDs of the named + logical index contribute to the sizes. Defaults to all segments. + + Returns + ------- + list[dict] + List of partition info dicts with 'partition_id' and 'size' + """ + return dataset._ds.get_ivf_partition_info( + index_name, _normalize_index_segment_ids(index_segments) + ) + + +def hamming_clustering_for_sample( + dataset: "LanceDataset", + column: str, + sample_size: Optional[int] = None, + hamming_threshold: int = 10, +) -> pa.RecordBatchReader: + """ + Perform pairwise hamming distance clustering on a sample of the dataset. + + Randomly samples rows from the dataset, computes pairwise hamming distances + between all hashes in the sample, filters by threshold, and clusters the + results using union-find. + + Parameters + ---------- + dataset : LanceDataset + The Lance dataset containing the hash column. + column : str + Name of the hash column (must be FixedSizeList where N is a + positive multiple of 8 bytes) + sample_size : int, optional + Number of rows to sample. If None, uses all rows. + hamming_threshold : int, default 10 + Maximum hamming distance to consider as similar + + Returns + ------- + pa.RecordBatchReader + A reader yielding batches with columns: + + - 'representative': uint64 - The representative row ID for each cluster + - 'duplicates': list - List of duplicate row IDs in each cluster + """ + return dataset._ds.hamming_clustering_for_sample( + column, sample_size, hamming_threshold + ) + + +def hamming_clustering_for_range( + dataset: "LanceDataset", + column: str, + fragment_id: int, + start_row: int, + num_rows: int, + hamming_threshold: int = 10, +) -> pa.RecordBatchReader: + """ + Perform pairwise hamming distance clustering on a contiguous range of rows. + + Reads a contiguous range of rows from a specific fragment, computes pairwise + hamming distances between all hashes in the range, filters by threshold, + and clusters the results using union-find. + + Unlike sampling, this reads sequential rows which is useful for distributed + processing where each worker handles a specific range of a fragment. + + Parameters + ---------- + dataset : LanceDataset + The Lance dataset containing the hash column. + column : str + Name of the hash column (must be FixedSizeList where N is a + positive multiple of 8 bytes) + fragment_id : int + The fragment ID to read from + start_row : int + The starting row offset within the fragment + num_rows : int + Number of rows to read from the start position + hamming_threshold : int, default 10 + Maximum hamming distance to consider as similar + + Returns + ------- + pa.RecordBatchReader + A reader yielding batches with columns: + + - 'representative': uint64 - The representative row ID for each cluster + - 'duplicates': list - List of duplicate row IDs in each cluster + """ + return dataset._ds.hamming_clustering_for_range( + column, fragment_id, start_row, num_rows, hamming_threshold + ) diff --git a/python/python/tests/compat/compat_decorator.py b/python/python/tests/compat/compat_decorator.py index cb9aa10c573..3b6c039cea3 100644 --- a/python/python/tests/compat/compat_decorator.py +++ b/python/python/tests/compat/compat_decorator.py @@ -10,18 +10,18 @@ import inspect import json +import os import subprocess import sys import urllib.request -from functools import lru_cache -from typing import List +from contextlib import contextmanager +from typing import Any, Dict, List, Optional import pytest from packaging.version import Version -@lru_cache(maxsize=1) -def pylance_stable_versions() -> List[Version]: +def _fetch_stable_versions() -> List[Version]: """Fetches and returns a sorted list of stable pylance versions from PyPI.""" try: with urllib.request.urlopen( @@ -65,12 +65,10 @@ def key(v: Version): return major_versions -@lru_cache(maxsize=1) -def last_beta_release(): +def _fetch_last_beta_release(): """Returns the latest beta version available on fury.io. Uses pip to query the fury.io index for pre-release versions of pylance. - Results are cached to avoid repeated network calls. """ try: # Use pip index to get versions from fury.io @@ -123,10 +121,45 @@ def last_beta_release(): return None -VERSIONS = recent_major_versions(3) -LAST_BETA_RELEASE = last_beta_release() -if LAST_BETA_RELEASE is not None: - VERSIONS.append(LAST_BETA_RELEASE) +_SNAPSHOT: Optional[Dict[str, Any]] = None + + +def version_snapshot() -> Dict[str, Any]: + """The set of published pylance releases these tests are built from. + + Every process collecting these tests has to agree on this, because it + decides the `version` parameters and pytest-xdist aborts a run whose workers + collected different tests. Resolving it takes two network queries, so it is + resolved once per process -- and once per run on the xdist controller, then + handed down to the workers by `pytest_configure_node` in the root tests + conftest. + """ + global _SNAPSHOT + if _SNAPSHOT is None: + _SNAPSHOT = { + "stable": [str(v) for v in _fetch_stable_versions()], + "beta": _fetch_last_beta_release(), + } + return _SNAPSHOT + + +def use_version_snapshot(snapshot: Dict[str, Any]) -> None: + global _SNAPSHOT + _SNAPSHOT = snapshot + + +def pylance_stable_versions() -> List[Version]: + """Sorted stable pylance versions published to PyPI.""" + return [Version(v) for v in version_snapshot()["stable"]] + + +def compat_versions() -> List[str]: + """The pylance versions every compat test is parametrized over.""" + versions = recent_major_versions(3) + beta = version_snapshot()["beta"] + if beta is not None: + versions.append(beta) + return versions class UpgradeDowngradeTest: @@ -147,6 +180,46 @@ 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_write_after_current_write(self, version: str) -> bool: + """Return True to skip the old-version write 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. @@ -168,7 +241,7 @@ def compat_test(min_version: str = "0.16.0"): Parameters ---------- versions : list of str, optional - List of Lance versions to test against. Defaults to VERSIONS. + List of Lance versions to test against. Defaults to `compat_versions()`. Example ------- @@ -191,8 +264,18 @@ def check_write(self): # Write data pass """ - version = set([min_version, *VERSIONS]) - versions = [v for v in version if Version(v) >= Version(min_version)] + # Sorted rather than taken straight off the set: set iteration order for + # strings depends on PYTHONHASHSEED, which differs per process, so every + # pytest-xdist worker would otherwise collect these parameters in its own + # order and xdist rejects the run as an inconsistent collection. + versions = sorted( + ( + v + for v in {min_version, *compat_versions()} + if Version(v) >= Version(min_version) + ), + key=Version, + ) def decorator(cls): # Extract existing parametrize marks from the class @@ -285,12 +368,20 @@ 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") + if not obj.skip_read_after_current_write(version): + venv.execute_method(obj, "check_read", obj.compat_env(version, "check_read")) + if not obj.skip_write_after_current_write(version): + venv.execute_method(obj, "check_write", obj.compat_env(version, "check_write")) ''' else: # upgrade_downgrade func_body = f''' @@ -298,18 +389,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/compat_sequence.py b/python/python/tests/compat/compat_sequence.py new file mode 100644 index 00000000000..40330c6a1e0 --- /dev/null +++ b/python/python/tests/compat/compat_sequence.py @@ -0,0 +1,529 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Cross-version index maintenance-sequence search. + +Runs on the same per-ref venv substrate as the rest of this package: venv_factory +(venv_manager.py) provisions one venv per ref, so the *setup* half of a sequence runs +under `from_ref` and the *exercise* half under `to_ref` (the version split). After each +run an oracle checks that the reader did not panic and that an index query agrees with a +full (unindexed) scan. This *discovers* cross-version regressions (e.g. ENT-1662) +without hand-coding the triggering sequence. + +The scenario is parameterized by index *kind* so every scalar index type gets the same +aged-lifecycle, cross-version treatment. The scalar oracle runs the same predicate twice +-- normally and with use_scalar_index=False (lance ignores the index) -- and requires +the results to match. If the two query plans are identical the index wasn't used, so the +comparison is skipped rather than failed (uninformative, not a regression). FTS has no +"ignore the index" mode to diff against, so its oracle reconstructs ground truth from a +full scan: tokenize every live row, then require an FTS search for a spread of sampled +terms to return exactly the rows that contain them. The FTS scenarios run under both +on-disk format versions (1 and 2), which take different merge paths. New Lance versions +pin this with the create-index `format_version` parameter; old Lance versions still use +`LANCE_FTS_FORMAT_VERSION`. + +IVF_PQ uses a separate bounded grammar because exhaustively applying the scalar grammar +to a trained vector index would be prohibitively expensive. It covers every ordered pair +of vector/scalar maintenance operations plus a few deeper lifecycle sequences. Its +oracle compares scalar-prefiltered ANN results with an exact, index-free KNN scan and +requires at least 0.5 recall, as well as checking the scalar filter exactly. + +The op vocabulary and bounds are deliberately small so the search is runnable. Scalar +and FTS cases are exhaustive over their maintenance grammar up to the configured length; +vector cases cover every ordered pair plus the curated deeper lifecycles above. +""" + +import itertools +import os +import shutil +from pathlib import Path + +ROWS_PER_WRITE = 200 +VECTOR_ROWS_PER_WRITE = 512 +VECTOR_DIM = 8 +VECTOR_K = 10 +VECTOR_KIND = "IVF_PQ" +VECTOR_INDEX_NAME = "vector_idx" +VECTOR_SCALAR_INDEX_NAME = "scalar_idx" + +SETUP_TAIL_OPS = ["D", "C", "W"] +EXERCISE_OPS = ["W", "D", "C", "Oa", "Om", "Od"] +VECTOR_OPS = ("W", "D", "C", "Os", "Ov", "Om") + +# All ordered operation pairs are searched below. These longer cases preserve the +# state combinations that motivated the old recurring test without growing as 6**N. +VECTOR_CRITICAL_SEQUENCES = ( + ("W", "Ov", "W", "Ov", "W"), # two vector deltas, then unindexed rows + ("W", "Os", "W", "Ov", "Om"), + ("D", "C", "W", "Ov", "Om"), + ("W", "D", "Os", "C", "Ov"), + ("W", "Ov", "D", "C", "Om"), +) + +OP_NAMES = { + "W": "write rows", + "I": "create index", + "D": "delete rows", + "C": "compact", + "Oa": "optimize (append)", + "Om": "optimize (merge)", + "Od": "optimize", + "Os": "optimize scalar index (append)", + "Ov": "optimize vector index (append)", +} + + +def describe(kind, from_ref, to_ref, setup_ops, exercise_ops, fts_version=None): + """A plain-English description of a scenario for failure output.""" + writer = ", then ".join(OP_NAMES[o] for o in ["W", "I", *setup_ops]) + reader = ", then ".join(OP_NAMES[o] for o in exercise_ops) + tag = f" (fts fmt v{fts_version})" if fts_version is not None else "" + return f"{kind}{tag} ({from_ref} -> {to_ref}): writer [{writer}]; reader [{reader}]" + + +# Index kinds covered by the maintenance-sequence search. +SCALAR_KINDS = ["BTREE", "BITMAP", "LABEL_LIST", "NGRAM", "ZONEMAP", "BLOOMFILTER"] +ALL_KINDS = ["INVERTED", *SCALAR_KINDS, VECTOR_KIND] + + +class IndexScenario: + """A picklable, kind-parameterized scenario run across a version split.""" + + def __init__(self, kind, path, setup_ops, exercise_ops, fts_version=None): + self.kind = kind + self.path = str(path) + self.setup_ops = list(setup_ops) + self.exercise_ops = list(exercise_ops) + self.fts_version = fts_version + self.next_idx = 0 + + # --- in-venv helpers (only lance + pyarrow available) --- + def _open(self): + import lance + + session = lance.Session(index_cache_size_bytes=0, metadata_cache_size_bytes=0) + return lance.dataset(self.path, session=session) + + def _batch(self, a, b): + import pyarrow as pa + + idx = list(range(a, b)) + if self.kind == VECTOR_KIND: + # Deterministic pseudo-random vectors keep every appended batch in the + # training distribution without depending on numpy or process RNG state. + flat = [] + for i in idx: + state = ((i + 1) * 2654435761) & 0xFFFFFFFF + for _ in range(VECTOR_DIM): + state = (state * 1664525 + 1013904223) & 0xFFFFFFFF + flat.append(state / 4294967296.0) + vector = pa.FixedSizeListArray.from_arrays( + pa.array(flat, type=pa.float32()), VECTOR_DIM + ) + return pa.table({"idx": idx, "vector": vector}) + if self.kind == "INVERTED": + # Each row's text mixes tokens of different frequency: a unique term, a + # mid-frequency bucket (~1/7 of rows), and one shared by every row. Sampling + # across that spread exercises postings of varied length. + return pa.table( + {"idx": idx, "key": [f"term{i} bucket{i % 7} shared" for i in idx]} + ) + if self.kind == "LABEL_LIST": + return pa.table({"idx": idx, "key": [[f"l{i % 8}"] for i in idx]}) + if self.kind == "NGRAM": + return pa.table({"idx": idx, "key": [f"w{i % 50}x" for i in idx]}) + # BTREE / BITMAP / ZONEMAP / BLOOMFILTER: integer column + card = 8 if self.kind == "BITMAP" else 50 + key = [i if self.kind == "ZONEMAP" else i % card for i in idx] + return pa.table({"idx": idx, "key": key}) + + def _index_type(self): + return "INVERTED" if self.kind == "INVERTED" else self.kind + + def _oracle_pred(self): + if self.kind == "LABEL_LIST": + return "array_has_any(key, ['l3'])" + if self.kind == "NGRAM": + return "contains(key, 'w3x')" + if self.kind == "ZONEMAP": + return "key >= 100 AND key < 300" + return "key == 3" # BTREE / BITMAP / BLOOMFILTER + + # --- ops --- + def _op_W(self): + import lance + + num_rows = VECTOR_ROWS_PER_WRITE if self.kind == VECTOR_KIND else ROWS_PER_WRITE + a, b = self.next_idx, self.next_idx + num_rows + self.next_idx = b + tbl = self._batch(a, b) + if not os.path.exists(self.path): + lance.write_dataset(tbl, self.path) # single fragment + else: + self._open().insert(tbl) + + def _op_I(self): + if self.kind == VECTOR_KIND: + self._open().create_scalar_index( + "idx", "BTREE", name=VECTOR_SCALAR_INDEX_NAME + ) + self._open().create_index( + "vector", + index_type="IVF_PQ", + name=VECTOR_INDEX_NAME, + num_partitions=2, + num_sub_vectors=2, + ) + return + kwargs = {"with_position": True} if self.kind == "INVERTED" else {} + if self.kind == "INVERTED" and self.fts_version is not None: + kwargs["format_version"] = int(self.fts_version) + self._open().create_scalar_index("key", self._index_type(), **kwargs) + + def _op_D(self): + # Partial-range delete inside the id space so compaction rewrites and remaps the + # index per-row. + if self.next_idx == 0: + return + lo, hi = self.next_idx // 4, self.next_idx // 2 + if hi > lo: + self._open().delete(f"idx >= {lo} AND idx < {hi}") + + def _op_C(self): + self._open().optimize.compact_files() + + def _op_Oa(self): + self._open().optimize.optimize_indices(num_indices_to_merge=0) + + def _op_Om(self): + kwargs = {"num_indices_to_merge": 10} + if self.kind == VECTOR_KIND: + kwargs = { + "num_indices_to_merge": 1, + "index_names": [VECTOR_INDEX_NAME], + } + self._open().optimize.optimize_indices(**kwargs) + + def _op_Od(self): + self._open().optimize.optimize_indices() + + def _op_Os(self): + self._open().optimize.optimize_indices( + num_indices_to_merge=0, index_names=[VECTOR_SCALAR_INDEX_NAME] + ) + + def _op_Ov(self): + self._open().optimize.optimize_indices( + num_indices_to_merge=0, index_names=[VECTOR_INDEX_NAME] + ) + + def _run(self, ops): + for op in ops: + getattr(self, f"_op_{op}")() + + # --- methods invoked across the version split --- + def setup(self): + shutil.rmtree(self.path, ignore_errors=True) + self.next_idx = 0 + self._run(["W", "I"] + self.setup_ops) + return self.next_idx + + def exercise_and_check(self): + self._run(self.exercise_ops) + ds = self._open() + if self.kind == VECTOR_KIND: + self._check_vector_prefilter(ds) + return + if self.kind == "INVERTED": + # Differential oracle: rebuild the token -> rows map from a full (unindexed) + # scan, then require an FTS search for a spread of sampled terms to return + # exactly those rows. Catches a merge that drops or misassigns postings, not + # just a row-count drift. (Tokens here are alphanumeric and space-separated, + # so a whitespace split reproduces lance's tokenization.) + rows = ds.to_table(columns=["idx", "key"]) + idxs = rows.column("idx").to_pylist() + texts = rows.column("key").to_pylist() + truth = {} + for i, text in zip(idxs, texts): + for tok in text.split(): + truth.setdefault(tok, set()).add(i) + if not truth: + return # everything deleted; nothing to search + vocab = sorted(truth) + # A spread across the vocabulary plus the most common term. + sample = set(vocab[:: max(1, len(vocab) // 6)]) + sample.add(max(truth, key=lambda t: len(truth[t]))) + for term in sorted(sample): + hit = ds.to_table(full_text_query={"query": term, "columns": ["key"]}) + got = set(hit.column("idx").to_pylist()) + want = truth[term] + assert got == want, ( + f"FTS('{term}'): index returned {len(got)} rows, corpus has " + f"{len(want)} (missing {sorted(want - got)[:5]}, " + f"extra {sorted(got - want)[:5]})" + ) + return + # Same column/predicate, index on vs forced off: use_scalar_index=False makes + # lance ignore the index, so the plans differ iff the index is used. If they are + # identical the index wasn't consulted here (the planner chose a scan after + # deletes), so the comparison is vacuous -- skip rather than compare two scans. + pred = self._oracle_pred() + plan_index = ds.scanner(filter=pred).explain_plan(True) + plan_scan = ds.scanner(filter=pred, use_scalar_index=False).explain_plan(True) + if plan_index == plan_scan: + return + got = ds.to_table(filter=pred).num_rows + expected = ds.to_table(filter=pred, use_scalar_index=False).num_rows + assert got == expected, ( + f"{self.kind}: index gave {got} rows, full scan {expected}, for '{pred}'" + ) + + def _check_vector_prefilter(self, ds): + """Check both BTREE filtering and IVF_PQ recall against index-free scans.""" + # Both ranges avoid the deterministic delete window. The first is always in + # the original index while the newest range may be an unindexed append, so + # the same query covers the indexed + unindexed prefilter path. + filter_rows = VECTOR_ROWS_PER_WRITE // 8 + lo = self.next_idx - filter_rows + pred = f"idx < {filter_rows} OR (idx >= {lo} AND idx < {self.next_idx})" + + filtered_scan = ds.to_table( + columns=["idx", "vector"], filter=pred, use_scalar_index=False + ) + filtered_rows = sorted( + zip( + filtered_scan.column("idx").to_pylist(), + filtered_scan.column("vector").to_pylist(), + ) + ) + filtered_ids = [idx for idx, _ in filtered_rows] + filtered_id_set = set(filtered_ids) + assert len(filtered_ids) == len(filtered_id_set), ( + f"BTREE prefilter returned duplicate row ids for '{pred}'" + ) + assert len(filtered_ids) >= VECTOR_K, ( + f"not enough live rows ({len(filtered_ids)}) for vector oracle '{pred}'" + ) + + scalar_plan = ds.scanner(filter=pred).explain_plan(True) + scan_plan = ds.scanner(filter=pred, use_scalar_index=False).explain_plan(True) + scalar_markers = ("ScalarIndexQuery", "MaterializeIndex") + assert any(marker in scalar_plan for marker in scalar_markers), ( + f"BTREE index was not used for '{pred}':\n{scalar_plan}" + ) + assert not any(marker in scan_plan for marker in scalar_markers), ( + f"BTREE index disabling was ignored for '{pred}':\n{scan_plan}" + ) + scalar_ids = ds.to_table(columns=["idx"], filter=pred).column("idx").to_pylist() + assert len(scalar_ids) == len(set(scalar_ids)), ( + f"BTREE returned duplicate row ids for '{pred}'" + ) + assert set(scalar_ids) == filtered_id_set, ( + f"BTREE returned {len(scalar_ids)} rows, full scan returned " + f"{len(filtered_ids)}, for '{pred}'" + ) + + query_positions = (0, len(filtered_ids) // 2, len(filtered_ids) - 1) + vectors = [vector for _, vector in filtered_rows] + for query_position in query_positions: + query = vectors[query_position] + indexed_nearest = { + "column": "vector", + "q": query, + "k": VECTOR_K, + "nprobes": 2, + "refine_factor": 10, + } + exact_nearest = { + "column": "vector", + "q": query, + "k": VECTOR_K, + "use_index": False, + } + + ann_plan = ds.scanner( + nearest=indexed_nearest, filter=pred, prefilter=True + ).explain_plan(True) + exact_plan = ds.scanner( + nearest=exact_nearest, + filter=pred, + prefilter=True, + use_scalar_index=False, + ).explain_plan(True) + ann_markers = ("ANNSubIndex", "ANNIvfPartition") + assert any(marker in ann_plan for marker in ann_markers), ( + f"IVF_PQ index was not used by vector search:\n{ann_plan}" + ) + assert not any(marker in exact_plan for marker in ann_markers), ( + f"IVF_PQ index disabling was ignored:\n{exact_plan}" + ) + assert any(marker in ann_plan for marker in scalar_markers), ( + f"BTREE index was not used by vector prefilter:\n{ann_plan}" + ) + assert not any(marker in exact_plan for marker in scalar_markers), ( + f"BTREE index disabling was ignored by exact prefilter:\n{exact_plan}" + ) + + got = ( + ds.to_table( + columns=["idx", "_distance"], + nearest=indexed_nearest, + filter=pred, + prefilter=True, + ) + .column("idx") + .to_pylist() + ) + expected = ( + ds.to_table( + columns=["idx", "_distance"], + nearest=exact_nearest, + filter=pred, + prefilter=True, + use_scalar_index=False, + ) + .column("idx") + .to_pylist() + ) + + assert len(got) == len(set(got)), "IVF_PQ search returned duplicate row ids" + assert len(expected) == len(set(expected)), ( + "exact vector search returned duplicate row ids" + ) + assert set(got) <= filtered_id_set, ( + f"IVF_PQ prefilter returned ids outside '{pred}': " + f"{sorted(set(got) - filtered_id_set)[:5]}" + ) + assert set(expected) <= filtered_id_set, ( + f"exact prefilter returned ids outside '{pred}': " + f"{sorted(set(expected) - filtered_id_set)[:5]}" + ) + assert len(got) == len(expected) == VECTOR_K, ( + f"IVF_PQ returned {len(got)} rows, exact search returned " + f"{len(expected)}" + ) + recall = len(set(got) & set(expected)) / VECTOR_K + assert recall >= 0.5, ( + f"IVF_PQ prefilter recall@{VECTOR_K}={recall:.3f}; " + f"expected at least 0.5 (got={got}, exact={expected})" + ) + + +def generate(max_length): + """Yield every (setup_ops, exercise_ops) whose combined length is 1..max_length, + breadth-first by total length (shorter first). `max_length` is the number of + maintenance ops after the implicit write + create-index, split between the writer + (setup) and reader (exercise) at every position. The order is neutral, so finding a + bug is a real search, not a sorted shortcut. The space grows fast with max_length, + so deeper bugs (ENT-1662 needs length 5) cost more to reach.""" + for total in range(1, max_length + 1): + for setup_len in range(total): # exercise gets total - setup_len >= 1 + for s in itertools.product(SETUP_TAIL_OPS, repeat=setup_len): + for e in itertools.product(EXERCISE_OPS, repeat=total - setup_len): + yield list(s), list(e) + + +def generate_vector(max_length): + """Yield a bounded IVF_PQ + BTREE maintenance search space. + + Every ordered pair of operations is covered on both sides of the version split. + A small set of deeper cases captures multi-delta, unindexed-row, delete, compact, + and merge interactions without expanding the full operation grammar to 6**N. + """ + seen = set() + for total in range(1, min(max_length, 2) + 1): + for sequence in itertools.product(VECTOR_OPS, repeat=total): + for setup_len in range(total): + case = (sequence[:setup_len], sequence[setup_len:]) + if case not in seen: + seen.add(case) + yield list(case[0]), list(case[1]) + for sequence in VECTOR_CRITICAL_SEQUENCES: + if len(sequence) > max_length: + continue + for setup_len in range(len(sequence)): + case = (sequence[:setup_len], sequence[setup_len:]) + if case not in seen: + seen.add(case) + yield list(case[0]), list(case[1]) + + +def search( + venv_factory, + from_ref, + to_ref, + base_path, + kind, + max_length=4, + shard=0, + num_shards=1, + stop_on_first=True, + fts_version=None, +): + """Search index-maintenance sequences up to `max_length` ops for one `kind`, across + (from_ref -> to_ref). Runs only scenarios in this shard (i % num_shards == shard) so + the space can be split across parallel workers. For INVERTED, `fts_version` ("1" or + "2") pins the on-disk FTS format on both sides. New Lance versions receive this + through the create-index parameter and old Lance versions receive it through + LANCE_FTS_FORMAT_VERSION. Both are Fst token sets and exercise distinct merge paths. + Returns failures; stops on the first when `stop_on_first`.""" + from_venv = venv_factory.get_venv(from_ref) + to_venv = venv_factory.get_venv(to_ref) + env = {} + if kind == "INVERTED" and fts_version is not None: + env["LANCE_FTS_FORMAT_VERSION"] = str(fts_version) + base = Path(base_path) + failures = [] + # Each setup's aged dataset is built once under from_ref and snapshotted; every + # exercise for that setup runs on a *copy* of it (a dir copy is far cheaper + # than rebuilding the index). Cached per shard, keyed by the setup ops. + snapshots = {} # tuple(setup) -> (snapshot_path, next_idx), or None if setup failed + try: + cases = ( + generate_vector(max_length) if kind == VECTOR_KIND else generate(max_length) + ) + for i, (setup_tail, exercise) in enumerate(cases): + if i % num_shards != shard: + continue + key = tuple(setup_tail) + if key not in snapshots: + snap = base / f"snap_{kind}_{len(snapshots)}" + shutil.rmtree(snap, ignore_errors=True) + builder = IndexScenario(kind, snap, setup_tail, [], fts_version) + try: + next_idx = from_venv.execute_method(builder, "setup", env) + snapshots[key] = (snap, next_idx) + except Exception as e: + label = describe( + kind, from_ref, to_ref, setup_tail, [], fts_version + ) + err = str(e).strip() + failures.append({"run": i, "sequence": label, "error": err}) + snapshots[key] = None + shutil.rmtree(snap, ignore_errors=True) + if stop_on_first: + break + entry = snapshots[key] + if entry is None: + continue # setup failed; skip its exercises + snap, next_idx = entry + ex_path = base / f"ex_{kind}_{i}" + shutil.rmtree(ex_path, ignore_errors=True) + shutil.copytree(snap, ex_path) + scenario = IndexScenario(kind, ex_path, setup_tail, exercise, fts_version) + scenario.next_idx = next_idx + label = describe(kind, from_ref, to_ref, setup_tail, exercise, fts_version) + try: + to_venv.execute_method(scenario, "exercise_and_check", env) + except Exception as e: + error = str(e).strip() + failures.append({"run": i, "sequence": label, "error": error}) + if stop_on_first: + break + finally: + shutil.rmtree(ex_path, ignore_errors=True) + finally: + for entry in snapshots.values(): + if entry is not None: + shutil.rmtree(entry[0], ignore_errors=True) + return failures diff --git a/python/python/tests/compat/test_file_formats.py b/python/python/tests/compat/test_file_formats.py index f65c8611ff6..90c11719fbf 100644 --- a/python/python/tests/compat/test_file_formats.py +++ b/python/python/tests/compat/test_file_formats.py @@ -11,6 +11,7 @@ from pathlib import Path import lance +import pyarrow as pa import pytest from lance.file import LanceFileReader, LanceFileWriter @@ -18,7 +19,104 @@ UpgradeDowngradeTest, compat_test, ) -from .util import build_basic_types, build_large +from .util import build_basic_types, build_large, safe_data_storage_version + + +class ManagedBlobCompatibility: + """Methods run in released clients through the existing compat venv runner.""" + + def __init__(self, path: Path): + self.path = path + + def create(self): + field = lance.blob_field("blob").with_metadata( + { + "lance-encoding:blob-inline-size-threshold": "8", + "lance-encoding:blob-dedicated-size-threshold": "24", + } + ) + table = pa.Table.from_arrays( + [lance.blob_array([b"p" * 16, b"d" * 32, None, b""])], + schema=pa.schema([field]), + ) + lance.write_dataset( + table, + self.path, + data_storage_version="2.2", + max_rows_per_file=2, + max_rows_per_group=2, + ) + + def check_unflagged(self): + dataset = lance.dataset(self.path) + assert dataset.count_rows() == 4 + assert [ + row["blob"]["kind"] if row["blob"] else None + for row in dataset.to_table().to_pylist() + ] == [1, 2, None, 0] + + def check_fenced(self): + with pytest.raises(ValueError, match="cannot be read by this version"): + lance.dataset(self.path) + # Opening for append must also honor the table fence before publishing. + table = pa.Table.from_arrays( + [lance.blob_array([b"inline"])], + schema=pa.schema([lance.blob_field("blob")]), + ) + with pytest.raises(OSError, match="cannot be (read|written) by this version"): + lance.write_dataset( + table, self.path, mode="append", data_storage_version="2.2" + ) + # The fence applies to snapshots. Historical unflagged reads remain valid; + # this must not be mistaken for permission to run old maintenance clients. + assert lance.dataset(self.path, version=1).count_rows() == 4 + + +@pytest.mark.compat +@pytest.mark.parametrize("version", ["11.0.0", "13.0.0"]) +def test_managed_blob_activation_and_restore_fence(venv_factory, tmp_path, version): + case = ManagedBlobCompatibility(tmp_path / "blobs.lance") + released = venv_factory.get_venv(version) + released.execute_method(case, "create") + dataset = lance.dataset(case.path) + dataset.update_config({"test": "metadata-only"}) + released.execute_method(case, "check_unflagged") + dataset.optimize.compact_files( + target_rows_per_fragment=100, data_storage_version="2.2" + ) + assert dataset.to_table(blob_handling="all_binary")["blob"].to_pylist() == [ + b"p" * 16, + b"d" * 32, + None, + b"", + ] + version_before = dataset.version + released.execute_method(case, "check_fenced") + assert lance.dataset(case.path).version == version_before + old = lance.dataset(case.path, version=1) + old.restore() + released.execute_method(case, "check_fenced") + assert lance.dataset(case.path).to_table(blob_handling="all_binary")[ + "blob" + ].to_pylist() == [ + b"p" * 16, + b"d" * 32, + None, + b"", + ] + + +@pytest.mark.parametrize( + ("lance_version", "expected_storage_version"), + [ + ("0.36.0", "2.0"), + ("0.38.0", "2.1"), + ("0.39.0", "2.1"), + ("11.0.0", "2.1"), + ], +) +def test_safe_data_storage_version(lance_version, expected_storage_version): + assert safe_data_storage_version(lance_version) == expected_storage_version # We start testing against the first release where 2.1 was stable. Before that @@ -48,6 +146,32 @@ def check_write(self): writer.write_batch(build_basic_types()) +# File format 2.2 is not in the stable 2.0.x line; gate this on the first +# available pre-release that includes 2.2 support. +@compat_test(min_version="4.0.0b1") +class BasicTypes2_2(UpgradeDowngradeTest): + """Test file format 2.2 compatibility with basic data types.""" + + def __init__(self, path: Path): + self.path = path + + def create(self): + batch = build_basic_types() + with LanceFileWriter( + str(self.path), version="2.2", schema=batch.schema + ) as writer: + writer.write_batch(batch) + + def check_read(self): + reader = LanceFileReader(str(self.path)) + table = reader.read_all().to_table() + assert table == build_basic_types() + + def check_write(self): + with LanceFileWriter(str(self.path), version="2.2") as writer: + writer.write_batch(build_basic_types()) + + @compat_test(min_version="0.16.0") @pytest.mark.parametrize( "data_factory,name", diff --git a/python/python/tests/compat/test_index_sequence.py b/python/python/tests/compat/test_index_sequence.py new file mode 100644 index 00000000000..0ab1fd65869 --- /dev/null +++ b/python/python/tests/compat/test_index_sequence.py @@ -0,0 +1,138 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Cross-version index maintenance-sequence search, wired as a compat test. + +For each index kind, generate maintenance-op sequences from a small grammar, run the +setup half under an older writer ref and the exercise half under a newer reader, and +check after each that the reader did not panic and that an index query matches a full +scan. Discovers cross-version regressions (e.g. ENT-1662, under INVERTED) with no +hand-coded sequence. + +Refs and max length are environment-driven so the suite can run between two refs +(versions, commits, or branches): COMPAT_FROM_REF / COMPAT_TO_REF / COMPAT_MAX_LENGTH / +COMPAT_VECTOR_MAX_LENGTH / COMPAT_KINDS (comma-separated subset of kinds) / +COMPAT_SHARDS (split each scalar/FTS kind's search into this many cases so pytest-xdist +(`-n auto`) parallelizes them across cores) / COMPAT_VECTOR_SHARDS (the bounded IVF_PQ +search uses fewer shards to avoid repeatedly training the same small index). +""" + +import os +from itertools import product + +import pytest + +from .compat_decorator import pylance_stable_versions +from .compat_sequence import ( + ALL_KINDS, + VECTOR_KIND, + VECTOR_OPS, + generate_vector, + search, +) + + +def test_vector_sequence_generation_is_bounded_and_covers_high_risk_orders(): + cases = list(generate_vector(max_length=5)) + combined = [tuple(setup + exercise) for setup, exercise in cases] + + assert cases + assert len(cases) < 128 + assert len(cases) == len(set((tuple(s), tuple(e)) for s, e in cases)) + assert all(exercise for _, exercise in cases) + assert all(1 <= len(sequence) <= 5 for sequence in combined) + + split_cases = {(tuple(setup), tuple(exercise)) for setup, exercise in cases} + for pair in product(VECTOR_OPS, repeat=2): + assert ((), pair) in split_cases + assert (pair[:1], pair[1:]) in split_cases + + two_delta_then_unindexed = ("W", "Ov", "W", "Ov", "W") + assert { + (tuple(setup), tuple(exercise)) + for setup, exercise in cases + if tuple(setup + exercise) == two_delta_then_unindexed + } == { + (two_delta_then_unindexed[:split], two_delta_then_unindexed[split:]) + for split in range(len(two_delta_then_unindexed)) + } + + +def _default_refs(): + """The two most recent published stable releases (older -> newer).""" + versions = pylance_stable_versions() + if len(versions) >= 2: + return str(versions[-2]), str(versions[-1]) + return "6.0.1", "7.0.0" # fallback if PyPI is unreachable + + +_default_from, _default_to = _default_refs() +FROM_REF = os.environ.get("COMPAT_FROM_REF") or _default_from +TO_REF = os.environ.get("COMPAT_TO_REF") or _default_to +MAX_LENGTH = int(os.environ.get("COMPAT_MAX_LENGTH", "4")) +VECTOR_MAX_LENGTH = int(os.environ.get("COMPAT_VECTOR_MAX_LENGTH", str(MAX_LENGTH))) +KINDS = os.environ.get("COMPAT_KINDS", ",".join(ALL_KINDS)).split(",") +# Many small shards (default 4x cores) so xdist's dynamic scheduler keeps every worker +# busy and an oversubscribed `-n` has work to overlap. +NUM_SHARDS = int(os.environ.get("COMPAT_SHARDS", str((os.cpu_count() or 1) * 4))) +# Training IVF_PQ once per generic shard would multiply total work without expanding +# coverage. Four cases still parallelize the bounded vector search while retaining +# snapshot reuse within each case. +VECTOR_NUM_SHARDS = int(os.environ.get("COMPAT_VECTOR_SHARDS", str(min(NUM_SHARDS, 4)))) + + +def _cases(): + """(kind, fts_version) cases. FTS runs under both on-disk formats (v1, v2); the + scalar kinds are format-agnostic and run once.""" + cases = [] + for kind in KINDS: + if kind == "INVERTED": + cases.extend([("INVERTED", "1"), ("INVERTED", "2")]) + else: + cases.append((kind, None)) + return cases + + +def _search_cases(): + cases = [] + for kind, fts_version in _cases(): + num_shards = VECTOR_NUM_SHARDS if kind == VECTOR_KIND else NUM_SHARDS + kind_id = kind if fts_version is None else f"{kind}-fmtv{fts_version}" + cases.extend( + pytest.param( + kind, + fts_version, + shard, + num_shards, + id=f"{kind_id}-shard{shard}", + ) + for shard in range(num_shards) + ) + return cases + + +SEARCH_CASES = _search_cases() + + +@pytest.mark.compat +@pytest.mark.parametrize("kind,fts_version,shard,num_shards", SEARCH_CASES) +def test_index_maintenance_sequence_search( + venv_factory, tmp_path, kind, fts_version, shard, num_shards +): + max_length = VECTOR_MAX_LENGTH if kind == VECTOR_KIND else MAX_LENGTH + failures = search( + venv_factory, + FROM_REF, + TO_REF, + tmp_path, + kind, + max_length=max_length, + shard=shard, + num_shards=num_shards, + fts_version=fts_version, + ) + # First line is the failure itself so it shows in pytest's bottom summary; the rest + # (if more than one) appears in the failure body. + assert not failures, "\n".join( + f"{f['sequence']} ==> {f['error']}" for f in failures + ) diff --git a/python/python/tests/compat/test_scalar_indices.py b/python/python/tests/compat/test_scalar_indices.py index 5d42a837bdc..850c1212532 100644 --- a/python/python/tests/compat/test_scalar_indices.py +++ b/python/python/tests/compat/test_scalar_indices.py @@ -9,6 +9,7 @@ and written by other versions. """ +import os import shutil from pathlib import Path @@ -19,6 +20,7 @@ UpgradeDowngradeTest, compat_test, ) +from .util import safe_data_storage_version @compat_test(min_version="0.30.0") @@ -40,7 +42,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 +99,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 +153,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): @@ -177,16 +194,26 @@ def __init__(self, path: Path): self.path = path def create(self): - """Create dataset with ZONEMAP and BLOOMFILTER indices.""" + """Create dataset with ZONEMAP and BLOOMFILTER indices. + + The zonemap column contains nulls at rows 0 and 500 so that IS NULL + queries can be verified across version boundaries. + """ shutil.rmtree(self.path, ignore_errors=True) + zonemap_values = [None if i in (0, 500) else i for i in range(1000)] data = pa.table( { "idx": pa.array(range(1000)), - "zonemap": pa.array(range(1000)), + "zonemap": pa.array(zonemap_values, type=pa.int64()), "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") @@ -194,11 +221,22 @@ def check_read(self): """Verify ZONEMAP and BLOOMFILTER indices can be queried.""" ds = lance.dataset(self.path) - # Test ZONEMAP + # Test ZONEMAP equality table = ds.to_table(filter="zonemap == 7") assert table.num_rows == 1 assert table.column("idx").to_pylist() == [7] + # Test ZONEMAP IS NULL — two nulls were inserted at rows 0 and 500. + # Older versions without a null bitmap fall back to a zone scan, which + # is still correct; newer versions may return an exact result. + table = ds.to_table(filter="zonemap IS NULL") + if 1000 in table.column("idx").to_pylist(): + # After write, there are 3 NULLs + assert table.num_rows == 3 + else: + # Before write, there are 2 NULLs + assert table.num_rows == 2 + # Test BLOOMFILTER table = ds.to_table(filter="bloomfilter == 7") assert table.num_rows == 1 @@ -210,7 +248,7 @@ def check_write(self): data = pa.table( { "idx": pa.array([1000]), - "zonemap": pa.array([1000]), + "zonemap": pa.array([None], type=pa.int64()), "bloomfilter": pa.array([1000]), } ) @@ -218,6 +256,16 @@ def check_write(self): ds.optimize.optimize_indices() ds.optimize.compact_files() + # IS NULL must still return results after the index is updated and + # files are compacted. The newly inserted null must be found + # regardless of which version handles the seed-based index update. + table = ds.to_table(filter="zonemap IS NULL") + assert table.num_rows >= 1 + + def skip_downgrade(self, version: str) -> bool: + # In 0.X the zonemap index did not properly handle NULL in filters + return version.startswith("0.") + @compat_test(min_version="0.36.0") class JsonIndex(UpgradeDowngradeTest): @@ -237,7 +285,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,16 +341,27 @@ def create(self): ), } ) - dataset = lance.write_dataset(data, self.path, max_rows_per_file=100) - dataset.create_scalar_index("text", "INVERTED") + dataset = lance.write_dataset( + data, + self.path, + max_rows_per_file=100, + data_storage_version=safe_data_storage_version(self.compat_version), + ) + kwargs = {"with_position": True} + # Downgrade reads use older wheels, so current-created FTS indexes must + # stay on the legacy posting block layout. + if os.environ.get("LANCE_COMPAT_FTS_LEGACY_BLOCK_SIZE") == "1": + kwargs["block_size"] = 128 + dataset.create_scalar_index("text", "INVERTED", format_version=1, **kwargs) 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 +377,21 @@ 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_COMPAT_FTS_LEGACY_BLOCK_SIZE": "1", + "LANCE_FTS_FORMAT_VERSION": "1", + } + if method_name == "check_write": + return {"LANCE_FTS_FORMAT_VERSION": "2"} + return {} + + def compat_env(self, version: str, method_name: str) -> dict[str, str]: + if method_name in {"create", "check_write"}: + return {"LANCE_FTS_FORMAT_VERSION": "1"} + return {} diff --git a/python/python/tests/compat/test_vector_indices.py b/python/python/tests/compat/test_vector_indices.py index b58ded4f5ff..e97d6be8bf6 100644 --- a/python/python/tests/compat/test_vector_indices.py +++ b/python/python/tests/compat/test_vector_indices.py @@ -8,10 +8,12 @@ can be read and written by other versions. """ +import os import shutil from pathlib import Path import lance +import numpy as np import pyarrow as pa import pyarrow.compute as pc @@ -19,6 +21,7 @@ UpgradeDowngradeTest, compat_test, ) +from .util import safe_data_storage_version @compat_test(min_version="0.29.1.beta2") @@ -43,7 +46,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", @@ -65,6 +72,17 @@ def check_read(self): ) assert result.num_rows == 4 + if hasattr(ds, "describe_indices"): + indices = ds.describe_indices() + assert len(indices) >= 1 + name = indices[0].name + elif self.compat_version >= "0.39.0": + indices = ds.list_indices() + assert len(indices) >= 1 + name = indices[0]["name"] + stats = ds.stats.index_stats(name) + assert stats["num_indexed_rows"] > 0 + def check_write(self): """Verify can insert vectors and rebuild index.""" ds = lance.dataset(self.path) @@ -108,7 +126,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", @@ -130,6 +152,18 @@ def check_read(self): ) assert result.num_rows == 4 + if hasattr(ds, "describe_indices"): + indices = ds.describe_indices() + assert len(indices) >= 1 + name = indices[0].name + else: + indices = ds.list_indices() + assert len(indices) >= 1 + name = indices[0]["name"] + + stats = ds.stats.index_stats(name) + assert stats["num_indexed_rows"] > 0 + def check_write(self): """Verify can insert vectors and rebuild index.""" ds = lance.dataset(self.path) @@ -173,7 +207,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", @@ -195,6 +233,18 @@ def check_read(self): ) assert result.num_rows == 4 + if hasattr(ds, "describe_indices"): + indices = ds.describe_indices() + assert len(indices) >= 1 + name = indices[0].name + else: + indices = ds.list_indices() + assert len(indices) >= 1 + name = indices[0]["name"] + + stats = ds.stats.index_stats(name) + assert stats["num_indexed_rows"] > 0 + def check_write(self): """Verify can insert vectors and rebuild index.""" ds = lance.dataset(self.path) @@ -210,3 +260,99 @@ def check_write(self): ds.insert(data) ds.optimize.optimize_indices() ds.optimize.compact_files() + + +@compat_test(min_version="4.0.0-beta.8") +class IvfRqVectorIndex(UpgradeDowngradeTest): + """Test IVF_RQ vector index compatibility. V2 was introduced in v4.0.0-beta.8""" + + def __init__(self, path: Path): + self.path = path + + def current_env(self, method_name: str): + if method_name == "check_read": + return {"LANCE_COMPAT_CURRENT_RUNTIME": "1"} + return {} + + def skip_read_after_current_write(self, version: str) -> bool: + # Newly written IVF_RQ indexes carry raw-query estimator metadata and + # split-code schema that older runtimes cannot query or optimize safely. + # The upgrade_downgrade variant still covers old 1-bit residual-query + # indexes being read and rewritten by the current runtime. + return True + + def skip_write_after_current_write(self, version: str) -> bool: + return True + + def create(self): + """Create dataset with IVF_RQ vector index.""" + shutil.rmtree(self.path, ignore_errors=True) + ndims = 32 + nvecs = 512 + + data = pa.table( + { + "id": pa.array(range(nvecs)), + "vec": pa.FixedSizeListArray.from_arrays( + pc.random(ndims * nvecs).cast(pa.float32()), ndims + ), + } + ) + + dataset = lance.write_dataset( + data, + self.path, + data_storage_version=safe_data_storage_version(self.compat_version), + ) + dataset.create_index( + "vec", + "IVF_RQ", + num_partitions=4, + num_bits=1, + ) + + def check_read(self): + """Verify vector query can run (indexed or brute-force fallback).""" + ds = lance.dataset(self.path) + q = np.random.random(32).astype(np.float32) + result = ds.to_table( + nearest={ + "q": q, + "k": 4, + "column": "vec", + } + ) + assert result.num_rows == 4 + + if hasattr(ds, "describe_indices"): + indices = ds.describe_indices() + assert len(indices) >= 1 + name = indices[0].name + else: + indices = ds.list_indices() + assert len(indices) >= 1 + name = indices[0].name + + stats = ds.stats.index_stats(name) + assert stats["num_indexed_rows"] > 0 + if os.environ.get("LANCE_COMPAT_CURRENT_RUNTIME") == "1": + # Old 1-bit IVF_RQ indexes do not have split ex-code columns. + # The successful query above verifies the current reader does not + # require them. + sub_index = stats["indices"][0]["sub_index"] + assert sub_index["num_bits"] == 1 + + def check_write(self): + """Verify can insert vectors and run optimize workflows.""" + ds = lance.dataset(self.path) + data = pa.table( + { + "id": pa.array([1000]), + "vec": pa.FixedSizeListArray.from_arrays( + pc.random(32).cast(pa.float32()), 32 + ), + } + ) + ds.insert(data) + ds.optimize.optimize_indices() + ds.optimize.compact_files() diff --git a/python/python/tests/compat/test_venv_manager.py b/python/python/tests/compat/test_venv_manager.py new file mode 100644 index 00000000000..18a576bf088 --- /dev/null +++ b/python/python/tests/compat/test_venv_manager.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +import subprocess + +import pytest + +from . import venv_manager +from .venv_manager import _lance_namespace_dependency, _pip_install + + +@pytest.mark.parametrize( + ("version", "expected"), + [ + ("2.0.1", "lance-namespace<0.7"), + ("4.0.0b1", "lance-namespace<0.7"), + ("6.0.0b5", "lance-namespace>=0.7.2,<0.8"), + ("6.0.0", "lance-namespace>=0.7.2,<0.8"), + ("7.2.0b5", "lance-namespace>=0.8.0,<0.9"), + ("7.2.0", "lance-namespace>=0.8.0,<0.9"), + ("12.0.0b5", "lance-namespace>=0.8.0,<0.9"), + ("12.0.0b6", "lance-namespace>=0.11.1,<0.12"), + ("12.0.0", "lance-namespace>=0.11.1,<0.12"), + ], +) +def test_lance_namespace_dependency(version: str, expected: str): + assert _lance_namespace_dependency(version) == expected + + +@pytest.fixture +def no_sleep(monkeypatch): + sleeps: list[float] = [] + monkeypatch.setattr(venv_manager.time, "sleep", sleeps.append) + return sleeps + + +def test_pip_install_failure_includes_pip_output(monkeypatch, no_sleep): + calls: list[list[str]] = [] + + def failing_run(cmd, **kwargs): + calls.append(cmd) + raise subprocess.CalledProcessError( + 2, cmd, output="resolving deps", stderr="No matching distribution found" + ) + + monkeypatch.setattr(venv_manager.subprocess, "run", failing_run) + with pytest.raises(RuntimeError) as exc_info: + _pip_install("python", ["pylance==0.38.0"]) + # The error must surface pip's captured output, which CalledProcessError hides. + message = str(exc_info.value) + assert "No matching distribution found" in message + assert "resolving deps" in message + assert "pylance==0.38.0" in message + assert len(calls) == venv_manager._PIP_INSTALL_ATTEMPTS + assert len(no_sleep) == venv_manager._PIP_INSTALL_ATTEMPTS - 1 + + +def test_pip_install_retries_transient_failure(monkeypatch, no_sleep): + calls: list[list[str]] = [] + + def flaky_run(cmd, **kwargs): + calls.append(cmd) + if len(calls) == 1: + raise subprocess.CalledProcessError( + 2, cmd, output="", stderr="ReadTimeoutError: pypi.fury.io" + ) + + monkeypatch.setattr(venv_manager.subprocess, "run", flaky_run) + _pip_install("python", ["pylance==0.38.0"]) + assert len(calls) == 2 + assert len(no_sleep) == 1 + + +def test_pip_install_succeeds_first_try_without_sleeping(monkeypatch, no_sleep): + calls: list[list[str]] = [] + monkeypatch.setattr( + venv_manager.subprocess, "run", lambda cmd, **kwargs: calls.append(cmd) + ) + _pip_install("python", ["pytest"]) + assert calls == [["python", "-m", "pip", "install", "--quiet", "pytest"]] + assert no_sleep == [] diff --git a/python/python/tests/compat/util.py b/python/python/tests/compat/util.py index f20bc93f23f..33cc80a32cc 100644 --- a/python/python/tests/compat/util.py +++ b/python/python/tests/compat/util.py @@ -9,6 +9,22 @@ import pyarrow as pa +def safe_data_storage_version(version_str): + """Return a data_storage_version safe for the given lance version. + + Versions before 0.38 use "2.0"; newer released versions use "2.1". + + Do not return a moving alias here: compatibility tests create data with the + current Lance build and then read it with the requested older release. + """ + parts = version_str.split(".") + major = int(parts[0]) + minor = int(parts[1]) if len(parts) > 1 else 0 + if major == 0 and minor < 38: + return "2.0" + return "2.1" + + def build_basic_types(): schema = pa.schema( [ diff --git a/python/python/tests/compat/venv_manager.py b/python/python/tests/compat/venv_manager.py index 996422aea34..6b1e3b85e2d 100644 --- a/python/python/tests/compat/venv_manager.py +++ b/python/python/tests/compat/venv_manager.py @@ -8,13 +8,158 @@ with specific Lance versions installed. """ +import contextlib +import glob import os import pickle +import re +import shutil import struct import subprocess import sys +import time from pathlib import Path -from typing import Any, Optional +from typing import Any, Optional, Union + +import pytest +from packaging.version import InvalidVersion, Version + +try: + import fcntl +except ImportError: # pragma: no cover - non-POSIX + fcntl = None + + +@contextlib.contextmanager +def _venv_lock(lock_path: Path): + """Hold an exclusive lock so parallel workers don't race creating the same venv.""" + lock_path.parent.mkdir(parents=True, exist_ok=True) + with open(lock_path, "w") as handle: + if fcntl is not None: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + if fcntl is not None: + fcntl.flock(handle, fcntl.LOCK_UN) + + +_PIP_INSTALL_ATTEMPTS = 3 +_PIP_RETRY_BACKOFF_SECONDS = 2.0 + + +def _pip_install(python: Union[str, Path], args: list[str]) -> None: + """Run ``pip install`` with output captured, retrying transient failures. + + pip installs hit the network (PyPI / fury.io) and a single flaky download + should not fail a whole test run, so retry a bounded number of times with a + short backoff. On final failure raise an error that includes pip's + stdout/stderr, which a bare CalledProcessError from ``capture_output=True`` + would hide from the test log. + """ + cmd = [str(python), "-m", "pip", "install", "--quiet", *args] + for attempt in range(1, _PIP_INSTALL_ATTEMPTS + 1): + try: + subprocess.run(cmd, check=True, capture_output=True, text=True) + return + except subprocess.CalledProcessError as exc: # noqa: PERF203 + if attempt == _PIP_INSTALL_ATTEMPTS: + raise RuntimeError( + f"pip install failed after {attempt} attempts " + f"(exit status {exc.returncode}): {cmd}\n" + f"stdout:\n{exc.stdout}\n" + f"stderr:\n{exc.stderr}" + ) from exc + time.sleep(_PIP_RETRY_BACKOFF_SECONDS * attempt) + + +NAMESPACE_0_6_DEPENDENCY = "lance-namespace<0.7" +NAMESPACE_0_7_DEPENDENCY = "lance-namespace>=0.7.2,<0.8" +NAMESPACE_0_8_DEPENDENCY = "lance-namespace>=0.8.0,<0.9" +NAMESPACE_0_11_DEPENDENCY = "lance-namespace>=0.11.1,<0.12" + + +def _lance_namespace_dependency(pylance_version: str) -> str: + # 12.0.0b5 is the last release published while pylance still pinned + # lance-namespace <0.9; releases cut after that carry the 0.11 range. + if Version(pylance_version) > Version("12.0.0b5"): + return NAMESPACE_0_11_DEPENDENCY + if Version(pylance_version) >= Version("7.2.0b5"): + return NAMESPACE_0_8_DEPENDENCY + if Version(pylance_version) >= Version("6.0.0b0"): + return NAMESPACE_0_7_DEPENDENCY + return NAMESPACE_0_6_DEPENDENCY + + +def _is_release_version(ref: str) -> bool: + """A ref is treated as a published release (install a wheel) if it parses as a + version; anything else (commit sha, branch, tag) is built from source.""" + try: + Version(ref) + return True + except InvalidVersion: + return False + + +def _prebuilt_wheel_for(ref: str) -> Optional[str]: + """A prebuilt wheel to install for `ref` instead of building it from source. + + When CI has already built a ref (e.g. the PR head, built once by the Python build + job), COMPAT_PREBUILT_REF names that ref and COMPAT_PREBUILT_WHEEL points at the + wheel (a path or glob). Lets the PR workflow reuse that wheel rather than rebuilding + the reader. Returns None when no prebuilt wheel applies to `ref`. + """ + if os.environ.get("COMPAT_PREBUILT_REF") != ref: + return None + pattern = os.environ.get("COMPAT_PREBUILT_WHEEL") + if not pattern: + return None + matches = sorted(glob.glob(pattern)) + if not matches: + raise FileNotFoundError( + f"COMPAT_PREBUILT_WHEEL={pattern!r} matched no wheel for ref {ref!r}" + ) + return matches[0] + + +def _repo_root() -> Path: + """Lance source checkout holding this test file (used to build refs from source).""" + # .../python/python/tests/compat/venv_manager.py -> repo root is parents[4] + return Path(__file__).resolve().parents[4] + + +def _safe(ref: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]", "_", ref) + + +# Explicit skip whitelist for known (lance_version, python_version) incompatibilities. +# Each entry is (max_lance_version_inclusive, min_python_version_inclusive). +# A test is skipped when the tested lance version <= max_lance AND the current +# Python >= min_python. Add entries here only after verifying the incompatibility +# is a runtime/ABI issue rather than a format regression — anything NOT listed +# will surface as a test failure so new problems are visible. +_COMPAT_SKIP: list[tuple[str, tuple[int, int]]] = [ + # lance 0.22.x abi3 wheel was built with old PyO3 (<0.23) that crashes on + # Python 3.14+ due to removed internal CPython APIs. + ("0.22.0", (3, 14)), +] + + +def _skip_reason(lance_version: str) -> Optional[str]: + """Return a skip reason if this lance/Python combo is whitelisted, else None.""" + try: + ver = Version(lance_version) + except InvalidVersion: + return None + py = sys.version_info[:2] + for max_lance, min_python in _COMPAT_SKIP: + if ver <= Version(max_lance) and py >= min_python: + py_str = f"{py[0]}.{py[1]}" + return ( + f"Lance {lance_version} + Python {py_str}: whitelisted skip " + f"(see _COMPAT_SKIP in venv_manager.py)" + ) + return None class VenvExecutor: @@ -38,6 +183,8 @@ def __init__(self, version: str, venv_path: Path, persistent: bool = False): self.persistent = persistent self._created = False self._subprocess: Optional[subprocess.Popen] = None + self._stderr_path: Optional[Path] = None + self._stderr_file = None @property def python_path(self) -> Path: @@ -45,74 +192,132 @@ def python_path(self) -> Path: return self.venv_path / "Scripts" / "python.exe" return self.venv_path / "bin" / "python" + @property + def _marker_path(self) -> Path: + return self.venv_path / ".compat_ref" + + @staticmethod + def _python_version_tag() -> str: + return f"{sys.version_info.major}.{sys.version_info.minor}" + def _validate_venv(self) -> bool: - """Check if existing venv is valid and has correct Lance version.""" - if not self.venv_path.exists(): - return False + """A cached venv is reusable if it exists, its recorded ref matches, and it was + built with the same Python major.minor as the current interpreter. + The marker file format is two lines: `\\n`. + Old single-line markers (no Python version) are treated as stale so the venv + is rebuilt — this handles cached venvs from a different Python installation.""" if not self.python_path.exists(): return False - - # Check if pylance is installed with correct version try: - result = subprocess.run( - [str(self.python_path), "-m", "pip", "show", "pylance"], - capture_output=True, - text=True, - timeout=5, - ) - if result.returncode != 0: - return False - - # Parse version from output - for line in result.stdout.splitlines(): - if line.startswith("Version:"): - installed_version = line.split(":", 1)[1].strip() - return installed_version == self.version - - except Exception: + lines = self._marker_path.read_text().strip().splitlines() + except OSError: return False - - return False + if not lines or lines[0] != self.version: + return False + # Require a Python version line; single-line markers are stale. + if len(lines) < 2 or lines[1] != self._python_version_tag(): + return False + return True def create(self): """Create the virtual environment and install the specified Lance version.""" if self._created: return - - # Check if persistent venv already exists and is valid if self.persistent and self._validate_venv(): self._created = True return - # Create virtual environment - subprocess.run( - [sys.executable, "-m", "venv", str(self.venv_path)], - check=True, - capture_output=True, - ) + # Lock so parallel workers don't build the same venv at once; re-check in the + # lock since another worker may have just finished it. + with _venv_lock(self.venv_path.parent / f".lock_{_safe(self.version)}"): + if not self._validate_venv(): + if self.venv_path.exists(): + shutil.rmtree(self.venv_path) # drop any partial build + subprocess.run( + [sys.executable, "-m", "venv", str(self.venv_path)], + check=True, + capture_output=True, + ) + # Prefer a wheel CI already built for this ref; else a published + # release installs its wheel; else build the ref (commit/branch/tag) + # from source -- so two arbitrary refs can be compared and only the + # ones without a wheel pay a build. + prebuilt = _prebuilt_wheel_for(self.version) + if prebuilt is not None: + self._install_wheel(prebuilt) + elif _is_release_version(self.version): + self._install_release_wheel() + else: + self._build_from_source() + self._marker_path.write_text( + f"{self.version}\n{self._python_version_tag()}" + ) + self._created = True - # Install specific pylance version and pytest - subprocess.run( + def _install_wheel(self, wheel: str): + _pip_install(self.python_path, [wheel, "pytest"]) + + def _install_release_wheel(self): + _pip_install( + self.python_path, [ - str(self.python_path), - "-m", - "pip", - "install", - "--quiet", "--pre", "--extra-index-url", "https://pypi.fury.io/lance-format/", "--extra-index-url", "https://pypi.fury.io/lancedb/", f"pylance=={self.version}", + # Older Lance wheels (e.g. 2.0.1, 4.0.0b1) import + # CreateEmptyTableRequest from lance_namespace, which was + # removed in lance-namespace 0.7.0. Pin to <0.7 so old wheels + # resolve a compatible transitive dep. + _lance_namespace_dependency(self.version), "pytest", ], + ) + + def _build_from_source(self): + """Build a wheel for an arbitrary git ref via a worktree + maturin, then install + it. The worktree/build is cached by ref so it is paid at most once.""" + py = str(self.python_path) + src = self.venv_path.parent / f"src_{_safe(self.version)}" + if not src.exists(): + subprocess.run( + [ + "git", + "-C", + str(_repo_root()), + "worktree", + "add", + "--detach", + str(src), + self.version, + ], + check=True, + capture_output=True, + ) + _pip_install(py, ["maturin", "pytest", "pyarrow"]) + wheels = src / "target" / "compat-wheels" + subprocess.run( + [ + py, + "-m", + "maturin", + "build", + "--release", + "--interpreter", + py, + "-m", + str(src / "python" / "Cargo.toml"), + "--out", + str(wheels), + ], check=True, capture_output=True, ) - - self._created = True + wheel = next(wheels.glob("pylance-*.whl")) + _pip_install(py, [str(wheel)]) def _ensure_subprocess(self): """Ensure the persistent subprocess is running.""" @@ -123,19 +328,43 @@ def _ensure_subprocess(self): # Start persistent subprocess runner_script = Path(__file__).parent / "venv_runner.py" - # Set PYTHONPATH to include the tests directory + # Set PYTHONPATH so the subprocess can import compat test modules. + # pytest adds the `tests/` directory (the first ancestor without __init__.py) + # to sys.path, so test modules are imported as `compat.`. env = os.environ.copy() tests_dir = Path(__file__).parent.parent env["PYTHONPATH"] = str(tests_dir) + env.setdefault("RUST_BACKTRACE", "full") + # Capture stderr to a file so a Rust panic (which crashes the runner) can be + # surfaced in the error instead of an opaque "broken pipe". + self._stderr_path = self.venv_path / ".runner_stderr.log" + self._stderr_file = open(self._stderr_path, "w") self._subprocess = subprocess.Popen( [str(self.python_path), "-u", str(runner_script)], stdin=subprocess.PIPE, stdout=subprocess.PIPE, - stderr=None, # Inherit stderr to see timing messages + stderr=self._stderr_file, env=env, ) + def _last_panic(self) -> str: + """Pull the panic message from the runner's captured stderr, if any.""" + try: + text = self._stderr_path.read_text() + except (OSError, AttributeError): + return "" + lines = text.splitlines() + for i, line in enumerate(lines): + if "panicked at" in line: + # Compact the long path to just "builder.rs:962:57" + loc = line.split("panicked at", 1)[1].strip().rstrip(":") + loc = loc.rsplit("/", 1)[-1] + msg = lines[i + 1].strip() if i + 1 < len(lines) else "" + return f"panic at {loc}: {msg}" if msg else f"panic at {loc}" + tail = [line.strip() for line in lines if line.strip()] + return tail[-1] if tail else "" + def _send_message(self, obj: Any): """Send a length-prefixed pickled message to subprocess.""" data = pickle.dumps(obj) @@ -146,23 +375,29 @@ def _send_message(self, obj: Any): def _receive_message(self) -> Any: """Receive a length-prefixed pickled message from subprocess.""" - # Read 4-byte length header + # Short reads mean the subprocess closed stdout (usually a crash); raise + # EOFError so the caller can surface the panic from captured stderr. length_bytes = self._subprocess.stdout.read(4) if len(length_bytes) < 4: - raise RuntimeError("Failed to read message length from subprocess") + raise EOFError("subprocess closed stdout before sending a message length") length = struct.unpack(">I", length_bytes)[0] # Read message data data = self._subprocess.stdout.read(length) if len(data) < length: - raise RuntimeError( - f"Incomplete message: expected {length} bytes, got {len(data)}" + raise EOFError( + f"incomplete message: expected {length} bytes, got {len(data)}" ) 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. @@ -189,11 +424,15 @@ def execute_method(self, obj: Any, method_name: str) -> Any: if not self._created: raise RuntimeError("Virtual environment not created. Call create() first.") + reason = _skip_reason(self.version) + if reason: + pytest.skip(reason) + # 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() @@ -210,11 +449,17 @@ def execute_method(self, obj: Any, method_name: str) -> Any: raise RuntimeError(error_msg) except (BrokenPipeError, EOFError, struct.error) as e: - # Subprocess died or communication failed - raise RuntimeError( - f"Communication with venv subprocess failed (Lance {self.version}):\n" - f"Error: {e}" - ) + # Subprocess died (usually a Rust panic); flush it, then surface that. + returncode = "unknown" + if self._subprocess is not None: + try: + self._subprocess.wait(timeout=2) + returncode = str(self._subprocess.returncode) + except Exception: + pass + panic = self._last_panic() + detail = panic or f"subprocess communication failed: {e}" + raise RuntimeError(f"Lance {self.version} (exit={returncode}): {detail}") def cleanup(self): """Remove the virtual environment directory and terminate subprocess.""" @@ -232,8 +477,6 @@ def cleanup(self): # Remove venv directory if self.venv_path.exists(): - import shutil - shutil.rmtree(self.venv_path) self._created = False @@ -271,7 +514,7 @@ def get_venv(self, version: str) -> VenvExecutor: Executor for the specified version """ if version not in self.venvs: - venv_path = self.base_path / f"venv_{version}" + venv_path = self.base_path / f"venv_{_safe(version)}" executor = VenvExecutor(version, venv_path, persistent=self.persistent) executor.create() self.venvs[version] = executor 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/conftest.py b/python/python/tests/conftest.py index 49a6eeaa490..c9d8911fd2b 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: @@ -66,8 +102,41 @@ def pytest_configure(config): "compat: mark tests that run upgrade/downgrade compatibility checks", ) + workerinput = getattr(config, "workerinput", None) + if workerinput is not None: + from compat.compat_decorator import use_version_snapshot + use_version_snapshot(workerinput["lance_compat_versions"]) + + +def pytest_configure_node(node): + """Resolve the compat version list once, on the xdist controller. + + The compat tests are parametrized over the pylance releases published to + PyPI and fury.io, and collecting `python/tests` imports them whether or not + --run-compat was passed. Left to itself every worker queries for that list + while collecting, so a request that times out or a release that lands + mid-run gives one worker a different parameter set, and xdist aborts the + whole run with "Different tests were collected". + """ + from compat.compat_decorator import version_snapshot + + node.workerinput["lance_compat_versions"] = version_snapshot() + + +# tryfirst because xdist reads xdist_group off each item to build its scheduling +# groups before ordinary pytest_collection_modifyitems hooks run; a mark added +# later is silently ignored rather than rejected. +@pytest.hookimpl(tryfirst=True) def pytest_collection_modifyitems(config, items): + # The lindera fixture unzips a dictionary into the checked-out models tree and + # removes it again on teardown, and the tokenizer configs name that path + # relative to the repo, so it cannot be relocated per worker. Pinning these + # tests to one xdist worker keeps a teardown in one worker from deleting the + # dictionary another is still reading. Without -n it changes nothing. + for item in items: + if "lindera_ipadic" in getattr(item, "fixturenames", ()): + item.add_marker(pytest.mark.xdist_group("lindera")) if not config.getoption("--run-integration"): disable_items_with_mark(items, "integration", "--run-integration not specified") if not config.getoption("--run-slow"): diff --git a/python/python/tests/models/lindera/README.md b/python/python/tests/models/lindera/README.md index b567e52884c..8e19d8d2522 100644 --- a/python/python/tests/models/lindera/README.md +++ b/python/python/tests/models/lindera/README.md @@ -15,22 +15,25 @@ tar xvf mecab-ipadic-2.7.0-20070801.tar.gz - Edit matrix.def, reset last column(weight) into zero, except first row. +- Create `metadata.json` for the IPADIC schema. Lindera 3.x requires this file + when building and loading dictionaries. + - build ```bash -lindera build --dictionary-kind=ipadic mecab-ipadic-2.7.0-20070801 main +lindera build --src mecab-ipadic-2.7.0-20070801 --dest main --metadata metadata.json ``` - build user dict ```bash -lindera build --build-user-dictionary --dictionary-kind=ipadic user_dict/userdict.csv user_dict2 +lindera build --user --src user_dict/userdic.csv --dest user_dict2 --metadata metadata.json ``` ## Version Compatibility **Important**: The binary user dictionary format (`userdic.bin`) is version-specific and needs to be regenerated when upgrading lindera versions. -- Current version: lindera 0.44.0 -- Last regenerated: 2025-09-09 +- Current version: lindera 3.0.7 +- Last regenerated: 2026-05-08 - Binary format changes between versions will cause deserialization errors diff --git a/python/python/tests/models/lindera/invalid_dict/config.yml b/python/python/tests/models/lindera/invalid_dict/config.yml index c5c6c4028f1..e3ef56934a6 100644 --- a/python/python/tests/models/lindera/invalid_dict/config.yml +++ b/python/python/tests/models/lindera/invalid_dict/config.yml @@ -1,7 +1,4 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" - user_dictionary: - path: "./python/tests/models/lindera/invalid_dict/invalid.bin" - kind: "ipadic" + dictionary: "./python/tests/models/lindera/ipadic/main" + user_dictionary: "./python/tests/models/lindera/invalid_dict/invalid.bin" diff --git a/python/python/tests/models/lindera/invalid_dict2/config.yml b/python/python/tests/models/lindera/invalid_dict2/config.yml index a91cf728758..2d5a11587b7 100644 --- a/python/python/tests/models/lindera/invalid_dict2/config.yml +++ b/python/python/tests/models/lindera/invalid_dict2/config.yml @@ -1,7 +1,4 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" - user_dictionary: - path: "./python/tests/models/lindera/invalid_dict2/ipadic_simple_userdic.bin" - kind: "ipadic" + dictionary: "./python/tests/models/lindera/ipadic/main" + user_dictionary: "./python/tests/models/lindera/invalid_dict2/ipadic_simple_userdic.bin" diff --git a/python/python/tests/models/lindera/ipadic/config.yml b/python/python/tests/models/lindera/ipadic/config.yml index 6b3f0af40aa..922768bdb66 100644 --- a/python/python/tests/models/lindera/ipadic/config.yml +++ b/python/python/tests/models/lindera/ipadic/config.yml @@ -1,4 +1,3 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" \ No newline at end of file + dictionary: "./python/tests/models/lindera/ipadic/main" diff --git a/python/python/tests/models/lindera/ipadic/main.zip b/python/python/tests/models/lindera/ipadic/main.zip index 4db1bab9390..ee6791dc408 100644 Binary files a/python/python/tests/models/lindera/ipadic/main.zip and b/python/python/tests/models/lindera/ipadic/main.zip differ diff --git a/python/python/tests/models/lindera/load_config_fallback/config_env.yml b/python/python/tests/models/lindera/load_config_fallback/config_env.yml index b1efb27c00d..922768bdb66 100644 --- a/python/python/tests/models/lindera/load_config_fallback/config_env.yml +++ b/python/python/tests/models/lindera/load_config_fallback/config_env.yml @@ -1,4 +1,3 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" + dictionary: "./python/tests/models/lindera/ipadic/main" diff --git a/python/python/tests/models/lindera/load_config_priority/config.yml b/python/python/tests/models/lindera/load_config_priority/config.yml index 8fcbd28abe3..e689d36dcb9 100644 --- a/python/python/tests/models/lindera/load_config_priority/config.yml +++ b/python/python/tests/models/lindera/load_config_priority/config.yml @@ -1,10 +1,9 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" + dictionary: "./python/tests/models/lindera/ipadic/main" character_filters: - kind: mapping args: mapping: - �田: ���� \ No newline at end of file + �田: ���� diff --git a/python/python/tests/models/lindera/load_config_priority/config_env.yml b/python/python/tests/models/lindera/load_config_priority/config_env.yml index b1efb27c00d..922768bdb66 100644 --- a/python/python/tests/models/lindera/load_config_priority/config_env.yml +++ b/python/python/tests/models/lindera/load_config_priority/config_env.yml @@ -1,4 +1,3 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" + dictionary: "./python/tests/models/lindera/ipadic/main" diff --git a/python/python/tests/models/lindera/user_dict/config.yml b/python/python/tests/models/lindera/user_dict/config.yml index 9ee5ba9acee..18fc5851c82 100644 --- a/python/python/tests/models/lindera/user_dict/config.yml +++ b/python/python/tests/models/lindera/user_dict/config.yml @@ -1,7 +1,4 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" - user_dictionary: - path: "./python/tests/models/lindera/user_dict/userdic.csv" - kind: "ipadic" + dictionary: "./python/tests/models/lindera/ipadic/main" + user_dictionary: "./python/tests/models/lindera/user_dict/userdic.csv" diff --git a/python/python/tests/models/lindera/user_dict2/config.yml b/python/python/tests/models/lindera/user_dict2/config.yml index 70209980002..bb993c5ccfc 100644 --- a/python/python/tests/models/lindera/user_dict2/config.yml +++ b/python/python/tests/models/lindera/user_dict2/config.yml @@ -1,7 +1,4 @@ segmenter: mode: "normal" - dictionary: - path: "./python/tests/models/lindera/ipadic/main" - user_dictionary: - path: "./python/tests/models/lindera/user_dict2/userdic.bin" - kind: "ipadic" + dictionary: "./python/tests/models/lindera/ipadic/main" + user_dictionary: "./python/tests/models/lindera/user_dict2/userdic.bin" diff --git a/python/python/tests/models/lindera/user_dict2/userdic.bin b/python/python/tests/models/lindera/user_dict2/userdic.bin index bed08ec9e12..41d2c4820b5 100644 Binary files a/python/python/tests/models/lindera/user_dict2/userdic.bin and b/python/python/tests/models/lindera/user_dict2/userdic.bin differ diff --git a/python/python/tests/recurring/test_recurring.py b/python/python/tests/recurring/test_recurring.py deleted file mode 100644 index ab39269528a..00000000000 --- a/python/python/tests/recurring/test_recurring.py +++ /dev/null @@ -1,231 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright The Lance Authors - -# Recurring tests that runs all operations on a large dataset, -# these operations are ran in random order repeated 10 times - -import abc -import itertools -from datetime import timedelta -from typing import Optional - -import lance -import numpy as np -import pyarrow as pa -import pytest - -# For testing, use smaller numbers to make tests run faster -# In production, you might want to use: NUM_ROWS = 1_000_000 -NUM_ROWS = 1_000_000 -BATCH_SIZE = 1_000 -DIM = 32 - -schema = pa.schema( - [ - pa.field("id", pa.int64()), - pa.field("vector", pa.list_(pa.float32(), DIM)), - pa.field("text", pa.string()), - ] -) -words = ["hello", "world", "this", "is", "a", "test", "sentence"] - - -def random_text(num_words: int) -> str: - return " ".join(np.random.choice(words, num_words)) - - -def random_batch(start_id: int, batch_size: int) -> pa.Table: - return pa.Table.from_arrays( - [ - pa.array(np.arange(start_id, start_id + batch_size)), - pa.array(np.random.rand(batch_size, DIM).tolist()), - pa.array( - [random_text(np.random.randint(1, 10)) for _ in range(batch_size)] - ), - ], - schema=schema, - ) - - -def create_or_load_dataset(dataset_name: str, kwargs: dict): - uri = f"tests/recurring/{dataset_name}" - - # Try to open existing dataset first - try: - ds = lance.dataset(uri) - if ds.count_rows() > 0: - return ds - except Exception: - pass - - # Create new dataset with initial data - initial_batch = random_batch(0, BATCH_SIZE) - ds = lance.write_dataset(initial_batch, uri, schema=schema, mode="overwrite") - - # Add remaining data - for i in range(BATCH_SIZE, NUM_ROWS, BATCH_SIZE): - batch = random_batch(i, BATCH_SIZE) - ds.insert(batch) - - # Create indices - ds.create_scalar_index("id", index_type="BTREE", replace=True) - ds.create_index( - "vector", - index_type="IVF_PQ", - metric="cosine", - num_partitions=128, - num_sub_vectors=DIM // 8, - replace=True, - ) - - # Note: FTS index creation is async, but we'll handle this differently for pytest - # For now, we'll skip the async part and create it synchronously if possible - try: - ds.create_scalar_index( - "text", - index_type="INVERTED", - with_position=kwargs.get("with_position", False), - replace=True, - ) - except Exception as e: - print(f"Warning: Could not create FTS index: {e}") - - return ds - - -class Operation(abc.ABC): - @abc.abstractmethod - def read_only(self) -> bool: ... - - @abc.abstractmethod - def run(self, ds: lance.LanceDataset): ... - - -class ReadOnlyOperation(Operation): - def read_only(self) -> bool: - return True - - -class WriteOperation(Operation): - def read_only(self) -> bool: - return False - - -class Append(WriteOperation): - def run(self, ds: lance.LanceDataset): - batch = random_batch(ds.count_rows(), BATCH_SIZE) - ds.insert(batch) - - -class Delete(WriteOperation): - def __init__(self, delete_num_rows: int = 100): - self.delete_num_rows = delete_num_rows - - def run(self, ds: lance.LanceDataset): - num_rows = ds.count_rows() - to_delete = np.random.randint(0, num_rows, self.delete_num_rows) - to_delete = ", ".join([str(v) for v in to_delete]) - ds.delete(f"id IN ({to_delete})") - - -class Optimize(WriteOperation): - def __init__(self, num_indices_to_merge: int, column: str): - self.num_indices_to_merge = num_indices_to_merge - self.column = column - - def run(self, ds: lance.LanceDataset): - ds.optimize.optimize_indices( - num_indices_to_merge=self.num_indices_to_merge, - index_names=[f"{self.column}_idx"], - ) - - -class Compact(WriteOperation): - def run(self, ds: lance.LanceDataset): - ds.optimize.compact_files() - - -class VectorSearch(ReadOnlyOperation): - def __init__(self, filter: Optional[str] = None): - self.filter = filter - - def run(self, ds: lance.LanceDataset): - stats = ds.stats.index_stats("vector_idx") - if stats is None: - print("No vector index found") - return - query_vector = np.random.rand(DIM).tolist() - query = ds.scanner( - nearest={ - "q": query_vector, - "k": 10, - "column": "vector", - }, - filter=self.filter, - ) - query.analyze_plan() - - -class FullTextSearch(ReadOnlyOperation): - def __init__(self, has_position: bool, filter: Optional[str] = None): - self.has_position = has_position - self.filter = filter - - def run(self, ds: lance.LanceDataset): - stats = ds.stats.index_stats("text_idx") - if stats is None: - print("No text index found") - return - query_text = random_text(np.random.randint(1, 10)) - self.do_query(ds, query_text) - - if self.has_position: - query_text = f'"{query_text}"' - self.do_query(ds, query_text) - - def do_query(self, ds: lance.LanceDataset, query_text: str): - query: lance.LanceScanner = ds.scanner( - full_text_query=query_text, - filter=self.filter, - limit=10, - ) - query.analyze_plan() - - -@pytest.mark.recurring -@pytest.mark.parametrize("with_position", [True]) -def test_all_permutations(with_position): - """Test all operations on dataset without FTS position tracking""" - dataset_name = f"test_table_with_position_{with_position}" - ds = create_or_load_dataset(dataset_name, {"with_position": with_position}) - - write_operations = [ - Append(), - Delete(delete_num_rows=1000), - Optimize(num_indices_to_merge=0, column="id"), - Optimize(num_indices_to_merge=0, column="vector"), # delta index - Optimize(num_indices_to_merge=1, column="vector"), # merge index - Optimize(num_indices_to_merge=0, column="text"), - Compact(), - ] - - read_only_operations = [ - # Read only operations - VectorSearch(), - VectorSearch(filter="id >= 1000 and id < 8000"), - FullTextSearch(has_position=False), - FullTextSearch(has_position=False, filter="id >= 1000 and id < 8000"), - ] - - for permutation in itertools.permutations(range(len(write_operations))): - for idx in permutation: - write_operation = write_operations[idx] - print(f"Running {write_operation.__class__.__name__}") - write_operation.run(ds) - ds.cleanup_old_versions(older_than=timedelta(seconds=0)) - - # write operation changed the status of the table, - # then we need to run all read only operations after it - for read_only_operation in read_only_operations: - print(f"Running {read_only_operation.__class__.__name__}") - read_only_operation.run(ds) diff --git a/python/python/tests/test_arrow.py b/python/python/tests/test_arrow.py index 4215866c2d5..a0be09846e1 100644 --- a/python/python/tests/test_arrow.py +++ b/python/python/tests/test_arrow.py @@ -7,10 +7,11 @@ from pathlib import Path import lance +import lance.arrow import numpy as np import pandas as pd import pyarrow as pa -import pytest +import pytest # pyright: ignore[reportMissingImports] from lance.arrow import ( BFloat16, BFloat16Array, @@ -48,6 +49,16 @@ def test_bf16_value(): assert not any(comparison for comparison in should_be_false) +def test_bf16_from_bytes_roundtrip(): + assert BFloat16.from_bytes(b"\xc0\x3f") == BFloat16(1.5) + + +@pytest.mark.parametrize("bad", [b"", b"\x00", b"\x00\x00\x00", b"\x00" * 4]) +def test_bf16_from_bytes_invalid_length_raises(bad): + with pytest.raises(ValueError, match="expected 2 bytes"): + BFloat16.from_bytes(bad) + + def test_bf16_repr(): data = [1.1, None, 3.4] arr = bfloat16_array(data) @@ -262,8 +273,6 @@ def test_image_uri_arrays(tmp_path: Path, png_uris): def test_image_tensor_arrays(tmp_path: Path, png_uris): - tf = pytest.importorskip("tensorflow") - n = 10 encoded_image_array = ImageURIArray.from_uris(png_uris).read_uris() @@ -286,22 +295,22 @@ def test_image_tensor_arrays(tmp_path: Path, png_uris): assert tensor_image_array.storage.type == pa.list_(pa.uint8(), 4) assert tensor_image_array[2].as_py() == [42, 42, 42, 255] - test_tensor = tf.constant( - np.array([42, 42, 42, 255] * n, dtype=np.uint8).reshape((n, 1, 1, 4)) - ) + test_tensor = np.array([42, 42, 42, 255] * n, dtype=np.uint8).reshape((n, 1, 1, 4)) assert test_tensor.shape == (n, 1, 1, 4) - assert tf.math.reduce_all( - tf.convert_to_tensor(tensor_image_array.to_numpy()) == test_tensor - ) + assert np.array_equal(tensor_image_array.to_numpy(), test_tensor) assert tensor_image_array.to_encoded().to_tensor() == tensor_image_array def png_encoder(images): - import tensorflow as tf + import io - encoded_images = ( - tf.io.encode_png(x).numpy() for x in tf.convert_to_tensor(images) - ) + from PIL import Image # pyright: ignore[reportMissingImports] + + encoded_images = [] + for image in images: + with io.BytesIO() as buf: + Image.fromarray(image).save(buf, format="PNG") + encoded_images.append(buf.getvalue()) return pa.array(encoded_images, type=pa.binary()) assert tensor_image_array.to_encoded(png_encoder).to_tensor() == tensor_image_array @@ -313,20 +322,18 @@ def png_encoder(images): uris = [str(Path(x)) for x in uris] encoded_image_array = ImageArray.from_array(uris).read_uris() - with pytest.raises( - tf.errors.InvalidArgumentError, match="Shapes of all inputs must match" - ): + with pytest.raises(ValueError, match="all input arrays must have the same shape"): encoded_image_array.to_tensor() pattern = r"(object at) 0x[\w\d]+(:?>)" repl = r"\1 0x..\2" - assert re.sub(pattern, repl, encoded_image_array.__repr__()) == ( - "\n" - "[, ..]\n" + repr_ = re.sub(pattern, repl, encoded_image_array.__repr__()) + assert repr_.startswith("\n[= Bitmap([1, 2]) + assert b > Bitmap([1]) + assert not b > Bitmap([1, 2]) + assert b.isdisjoint([3, 4]) + assert not b.isdisjoint([2]) + + +def test_ordering_against_incompatible_type_raises(): + # Unlike `==`, an ordering comparison against a non-set has no meaning. + with pytest.raises(TypeError): + Bitmap([1]) < 5 + + +def test_repr(): + assert repr(Bitmap([1, 2, 3])) == "Bitmap({1, 2, 3})" + + +def test_repr_truncates_long_bitmaps(): + assert repr(Bitmap(range(25))) == ( + "Bitmap({0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, " + "18, 19, ...}, len=25)" + ) + + +def test_pickle_round_trip(): + b = Bitmap(range(10_000)) + loaded = pickle.loads(pickle.dumps(b)) + assert loaded == b + + +def test_add_discard_update(): + b = Bitmap([1, 2, 3]) + b.add(4) + assert 4 in b + b.discard(2) + assert 2 not in b + b.discard(2) # discarding an absent value is a no-op + b.update([10, 11]) + assert {10, 11}.issubset(set(b)) + + +def test_remove_pop_clear(): + b = Bitmap([1, 2, 3]) + b.remove(2) + assert 2 not in b + with pytest.raises(KeyError): + b.remove(2) + assert b.pop() == 1 # the smallest value + b.clear() + assert len(b) == 0 + with pytest.raises(KeyError): + b.pop() + + +@pytest.mark.parametrize( + ("op", "expected"), + [ + ("__iand__", {2, 3}), + ("__ior__", {1, 2, 3, 4}), + ("__isub__", {1}), + ("__ixor__", {1, 4}), + ], +) +def test_in_place_set_operators(op, expected): + b = Bitmap([1, 2, 3]) + shared = Bitmap(b) + + getattr(b, op)([2, 3, 4]) + + assert b == expected + # In-place mutation still copies on write, so the alias is untouched. + assert shared == {1, 2, 3} + + +def test_copy_is_independent(): + original = Bitmap([1, 2, 3]) + copy = original.copy() + + copy.add(4) + + assert 4 not in original + assert copy == {1, 2, 3, 4} + + +def test_mutation_is_copy_on_write(): + original = Bitmap([1, 2, 3]) + other = Bitmap(original) + + other.add(4) + + assert 4 not in original + assert 4 in other + assert original == Bitmap([1, 2, 3]) diff --git a/python/python/tests/test_blob.py b/python/python/tests/test_blob.py index 69e8a0b75a5..65893cdc532 100644 --- a/python/python/tests/test_blob.py +++ b/python/python/tests/test_blob.py @@ -1,10 +1,187 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import gc +import importlib +import io +import queue +import random +import subprocess +import sys +import tarfile +import textwrap +import threading +import uuid +import zipfile +from pathlib import Path + import lance +import pandas as pd import pyarrow as pa import pytest -from lance import BlobColumn +from lance import Blob, BlobColumn, BlobFile, DatasetBasePath +from lance.blob import BlobType +from lance.file import LanceFileSession +from lance.fragment import write_fragments + +lance_dataset_module = importlib.import_module("lance.dataset") + +LEGACY_BLOB_STORAGE_VERSION = "2.1" + + +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 _commit_blob_fragments(dataset_uri, schema, fragments, initial_bases=None): + operation = lance.LanceOperation.Overwrite( + schema, + fragments, + initial_bases=initial_bases, + ) + return lance.LanceDataset.commit(dataset_uri, operation) + + +def _external_blob_table(blob_path, payload=b"hello"): + blob_path.parent.mkdir(parents=True, exist_ok=True) + blob_path.write_bytes(payload) + return pa.table({"blob": lance.blob_array([blob_path.as_uri()])}) + + +def _blob_sidecar_path(data_dir, data_file_key, blob_id): + sidecar_name = f"{int(f'{blob_id:032b}'[::-1], 2):032b}.blob" + return data_dir / data_file_key / sidecar_name + + +def _complete_blob_table(ids, values): + schema = pa.schema([pa.field("id", pa.int64()), lance.blob_field("blob")]) + return pa.Table.from_arrays( + [pa.array(ids, type=pa.int64()), lance.blob_array(values)], + schema=schema, + ) + + +def _complete_blob_storage_table(data, uri, position, size): + storage = pa.StructArray.from_arrays( + [ + pa.array([data], type=pa.large_binary()), + pa.array([uri], type=pa.utf8()), + pa.array([position], type=pa.uint64()), + pa.array([size], type=pa.uint64()), + ], + names=["data", "uri", "position", "size"], + ) + blobs = pa.ExtensionArray.from_storage(BlobType(), storage) + return pa.Table.from_arrays([blobs], schema=pa.schema([lance.blob_field("blob")])) + + +def _assert_complete_blob_schema(dataset): + blob_type = dataset.schema.field("blob").type + assert isinstance(blob_type, pa.ExtensionType) + assert blob_type.extension_name == "lance.blob.v2" + assert [field.name for field in blob_type.storage_type] == [ + "data", + "uri", + "position", + "size", + ] + + +def _assert_blob_storage_type_equal(actual, expected): + assert isinstance(actual, pa.StructType) + assert isinstance(expected, pa.StructType) + assert len(actual) == len(expected) + for actual_field, expected_field in zip(actual, expected): + assert actual_field.equals(expected_field, check_metadata=True) + + +def _inline_blob_array(storage_type, value): + arrays = [ + pa.array([value], type=pa.large_binary()), + pa.array([None], type=pa.utf8()), + ] + if len(storage_type) == 4: + arrays.extend( + [ + pa.array([None], type=pa.uint64()), + pa.array([None], type=pa.uint64()), + ] + ) + storage = pa.StructArray.from_arrays(arrays, fields=list(storage_type)) + blob_type = BlobType.__arrow_ext_deserialize__(storage_type, b"") + return pa.ExtensionArray.from_storage(blob_type, storage) + + +def _add_columns_blob_v2_values(tmp_path): + external_base = tmp_path / "external_base" + external_blob = external_base / "external_blob.bin" + external_blob.parent.mkdir(parents=True, exist_ok=True) + external_blob.write_bytes(b"external") + + payloads = [ + b"inline", + b"p" * (64 * 1024 + 1024), + b"d" * (4 * 1024 * 1024 + 1024), + b"external", + ] + values = [payloads[0], payloads[1], payloads[2], external_blob.as_uri()] + initial_bases = [DatasetBasePath(external_base.as_uri(), name="external", id=1)] + return values, payloads, initial_bases + + +def _assert_blob_v2_add_columns_result(dataset, column, payloads): + desc = dataset.to_table(columns=[column]).column(column).chunk(0) + + assert desc.field("kind").to_pylist() == [0, 4, 4, 3] + assert desc.field("blob_id").to_pylist()[3] == 1 + assert desc.field("blob_uri").to_pylist()[3] == "external_blob.bin" + + blobs = dataset.take_blobs(column, indices=range(len(payloads))) + assert [blob.readall() for blob in blobs] == payloads + + +def _dataset_file_set(dataset_path): + return { + path.relative_to(dataset_path) + for path in dataset_path.rglob("*") + if path.is_file() + } + + +def _write_two_fragment_blob_v2_seed_dataset(tmp_path, name): + values, payloads, initial_bases = _add_columns_blob_v2_values(tmp_path) + dataset_path = tmp_path / name + ds = lance.write_dataset( + pa.table({"id": range(8)}), + dataset_path, + data_storage_version="2.2", + initial_bases=initial_bases, + max_rows_per_file=4, + max_rows_per_group=4, + ) + return ds, dataset_path, values, payloads + + +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(): @@ -40,7 +217,11 @@ def test_blob_descriptions(tmp_path): ] ), ) - ds = lance.write_dataset(table, tmp_path / "test_ds") + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) # These positions may be surprising but lance pads buffers to 64-byte boundaries expected_positions = pa.array([0, 64, 128], pa.uint64()) expected_sizes = pa.array([3, 3, 3], pa.uint64()) @@ -63,12 +244,77 @@ def test_scan_blob_as_binary(tmp_path): ] ), ) - ds = lance.write_dataset(table, tmp_path / "test_ds") + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) tbl = ds.scanner(columns=["blobs"], blob_handling="all_binary").to_table() assert tbl.column("blobs").to_pylist() == values +def test_sql_blob_as_binary(tmp_path): + values = [b"foo", b"bar", b"baz"] + table = pa.table( + [pa.array(values, pa.large_binary())], + schema=pa.schema( + [ + pa.field( + "blobs", pa.large_binary(), metadata={"lance-encoding:blob": "true"} + ) + ] + ), + ) + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) + + batches = ( + ds.sql("SELECT blobs FROM dataset") + .blob_handling("all_binary") + .build() + .to_batch_records() + ) + assert pa.Table.from_batches(batches).column("blobs").to_pylist() == values + + +def test_v2_0_blob_descriptor_projection_and_reads(tmp_path): + values = [b"abc", b"defgh", b"ijklmnop"] + blob_field = pa.field( + "blob", pa.large_binary(), metadata={"lance-encoding:blob": "true"} + ) + schema = pa.schema([pa.field("id", pa.int32()), blob_field]) + table = pa.table( + {"id": [0, 1, 2], "blob": values}, + schema=schema, + ) + ds = lance.write_dataset(table, tmp_path / "test_ds", data_storage_version="2.0") + + for blob_handling in [None, "all_descriptions", "blobs_descriptions"]: + kwargs = {} if blob_handling is None else {"blob_handling": blob_handling} + descriptions = ds.scanner(columns=["blob"], **kwargs).to_table().column("blob") + chunk = descriptions.chunk(0) + assert chunk.type == pa.struct( + [ + pa.field("position", pa.uint64()), + pa.field("size", pa.uint64()), + ] + ) + assert chunk.field("size").to_pylist() == [3, 5, 8] + + tbl = ds.scanner(columns=["blob"], blob_handling="all_binary").to_table() + assert tbl.column("blob").to_pylist() == values + assert [blob.read() for blob in ds.take_blobs("blob", indices=[0, 1, 2])] == values + assert ds.read_blobs("blob", indices=[0, 1, 2]) == [ + (0, b"abc"), + (1, b"defgh"), + (2, b"ijklmnop"), + ] + + def test_fragment_scan_blob_as_binary(tmp_path): values = [b"foo", b"bar", b"baz"] arr = pa.array(values, pa.large_binary()) @@ -82,7 +328,11 @@ def test_fragment_scan_blob_as_binary(tmp_path): ] ), ) - ds = lance.write_dataset(table, tmp_path / "test_ds") + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) fragment = ds.get_fragments()[0] @@ -108,7 +358,11 @@ def dataset_with_blobs(tmp_path): ] ), ) - ds = lance.write_dataset(table, tmp_path / "test_ds") + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) values = pa.array([b"qux", b"quux", b"corge"], pa.large_binary()) idx = pa.array([3, 4, 5], pa.uint64()) @@ -127,6 +381,44 @@ def dataset_with_blobs(tmp_path): return ds +@pytest.fixture +def dataset_with_mixed_blob_v2(tmp_path): + external_blob = tmp_path / "external.bin" + external_blob.write_bytes(b"external") + payloads = [b"in", b"packed!!", b"dedicated payload", b"external", None, b""] + values = [ + payloads[0], + payloads[1], + payloads[2], + external_blob.as_uri(), + None, + payloads[5], + ] + schema = pa.schema( + [ + lance.blob_field( + "blobs", + inline_size_threshold=4, + dedicated_size_threshold=12, + ), + pa.field("idx", pa.uint64()), + ] + ) + table = pa.table( + {"blobs": lance.blob_array(values), "idx": range(len(values))}, schema=schema + ) + dataset = lance.write_dataset( + table, + tmp_path / "mixed_blob_v2", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + max_rows_per_file=2, + max_rows_per_group=2, + ) + assert len(dataset.get_fragments()) == 3 + return dataset, payloads + + def test_blob_files(dataset_with_blobs): row_ids = ( dataset_with_blobs.to_table(columns=[], with_row_id=True) @@ -140,6 +432,47 @@ def test_blob_files(dataset_with_blobs): assert f.read() == expected +def test_blob_files_close_no_shutdown_panic(tmp_path): + script = textwrap.dedent( + f""" + import pyarrow as pa + import lance + + table = pa.table( + [pa.array([b"foo", b"bar"], pa.large_binary())], + schema=pa.schema( + [ + pa.field( + "blob", + pa.large_binary(), + metadata={{"lance-encoding:blob": "true"}}, + ) + ] + ), + ) + ds = lance.write_dataset( + table, + {str(tmp_path / "ds")!r}, + data_storage_version={LEGACY_BLOB_STORAGE_VERSION!r}, + ) + row_ids = ds.to_table(columns=[], with_row_id=True).column("_rowid").to_pylist() + blobs = ds.take_blobs("blob", ids=row_ids) + for blob in blobs: + blob.close() + print("done") + """ + ) + result = subprocess.run( + [sys.executable, "-c", script], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + assert "interpreter_lifecycle.rs" not in result.stderr + assert "The Python interpreter is not initialized" not in result.stderr + + def test_blob_files_by_address(dataset_with_blobs): addresses = ( dataset_with_blobs.to_table(columns=[], with_row_address=True) @@ -171,6 +504,7 @@ def test_blob_files_by_address_with_stable_row_ids(tmp_path): ds = lance.write_dataset( table, tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, enable_stable_row_ids=True, ) @@ -205,6 +539,300 @@ 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) + + +@pytest.mark.parametrize("selection_kind", ["ids", "addresses", "indices"]) +@pytest.mark.parametrize( + "selected_indices", + [ + [0, 5], + [0, 5, 4], + [5], + [0, 5, 1], + [4, 4, 5], + ], +) +def test_blob_selection_apis_preserve_nulls_and_empty_values( + dataset_with_mixed_blob_v2, selection_kind, selected_indices +): + dataset, payloads = dataset_with_mixed_blob_v2 + if selection_kind == "ids": + all_selectors = _blob_row_ids(dataset) + elif selection_kind == "addresses": + all_selectors = _blob_row_addresses(dataset) + else: + all_selectors = list(range(len(payloads))) + selectors = [all_selectors[index] for index in selected_indices] + kwargs = {selection_kind: selectors} + expected = [payloads[index] for index in selected_indices] + + blob_files = dataset.take_blobs("blobs", **kwargs) + assert [None if blob is None else blob.readall() for blob in blob_files] == expected + + blobs = dataset.read_blobs("blobs", **kwargs, preserve_order=True) + assert [data for _, data in blobs] == expected + + +@pytest.mark.parametrize("selection_kind", ["ids", "addresses", "indices"]) +def test_read_blob_ranges_mixed_sources_preserves_request_identity( + dataset_with_mixed_blob_v2, selection_kind +): + dataset, payloads = dataset_with_mixed_blob_v2 + selected_indices = [3, 1, 1, 0, 2, 4, 5] + ranges = [(1, 3), (2, 4), (0, 4), (1, 1), (5, 0), (0, 0), (0, 0)] + addresses = _blob_row_addresses(dataset) + if selection_kind == "ids": + all_selectors = _blob_row_ids(dataset) + elif selection_kind == "addresses": + all_selectors = addresses + else: + all_selectors = list(range(len(payloads))) + selectors = [all_selectors[index] for index in selected_indices] + requests = [ + (row, offset, length) for row, (offset, length) in zip(selectors, ranges) + ] + + results = dataset.read_blob_ranges( + "blobs", + requests, + selector=selection_kind, + io_buffer_size=1024, + preserve_order=True, + ) + + expected = [] + for request_index, (row_index, (offset, length)) in enumerate( + zip(selected_indices, ranges) + ): + payload = payloads[row_index] + expected.append( + ( + request_index, + addresses[row_index], + None if payload is None else payload[offset : offset + length], + ) + ) + assert results == expected + + +def test_blob_selection_apis_preserve_blob_v1_nulls_and_empty_values(tmp_path): + schema = pa.schema( + [ + pa.field( + "blobs", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ), + pa.field("idx", pa.uint64()), + ] + ) + dataset = lance.write_dataset( + pa.table({"blobs": [None, b"", b"abc"], "idx": range(3)}, schema=schema), + tmp_path / "blob_v1_nulls", + data_storage_version="2.0", + ) + addresses = _blob_row_addresses(dataset) + + blob_files = dataset.take_blobs("blobs", indices=[0, 1, 2]) + assert [None if blob is None else blob.readall() for blob in blob_files] == [ + None, + b"", + b"abc", + ] + + blobs = dataset.read_blobs("blobs", indices=[0, 1, 2]) + assert [data for _, data in blobs] == [None, b"", b"abc"] + + results = dataset.read_blob_ranges( + "blobs", + [(0, 0, 0), (0, 0, 1), (1, 0, 0), (2, 1, 1)], + selector="indices", + ) + + assert results == [ + (0, addresses[0], None), + (1, addresses[0], None), + (2, addresses[1], b""), + (3, addresses[2], b"b"), + ] + + +def test_read_blob_ranges_without_preserve_order_keeps_request_identity( + dataset_with_mixed_blob_v2, +): + dataset, _ = dataset_with_mixed_blob_v2 + results = dataset.read_blob_ranges( + "blobs", + [(3, 0, 1), (4, 0, 1), (1, 1, 2), (0, 0, 2)], + selector="indices", + preserve_order=False, + ) + + assert sorted(results) == sorted( + [ + (0, _blob_row_addresses(dataset)[3], b"e"), + (1, _blob_row_addresses(dataset)[4], None), + (2, _blob_row_addresses(dataset)[1], b"ac"), + (3, _blob_row_addresses(dataset)[0], b"in"), + ] + ) + + +@pytest.mark.parametrize( + ("requests", "message"), + [ + ([(0, 2**64 - 1, 2)], "offset \\+ length overflowed"), + ([(0, 0, 4)], "exceeds blob size"), + ], +) +def test_read_blob_ranges_rejects_invalid_ranges( + dataset_with_mixed_blob_v2, requests, message +): + dataset, _ = dataset_with_mixed_blob_v2 + + with pytest.raises(ValueError, match=message): + dataset.read_blob_ranges("blobs", requests, selector="indices") + + +def test_read_blob_ranges_rejects_invalid_selector(dataset_with_mixed_blob_v2): + dataset, _ = dataset_with_mixed_blob_v2 + + with pytest.raises(ValueError, match="selector must be one of"): + dataset.read_blob_ranges("blobs", [], selector="offsets") + + +def test_read_blob_ranges_accepts_empty_batch(dataset_with_mixed_blob_v2): + dataset, _ = dataset_with_mixed_blob_v2 + + assert dataset.read_blob_ranges("blobs", [], selector="indices") == [] + + def test_blob_file_seek(tmp_path, dataset_with_blobs): row_ids = ( dataset_with_blobs.to_table(columns=[], with_row_id=True) @@ -215,6 +843,95 @@ def test_blob_file_seek(tmp_path, dataset_with_blobs): with blobs[1] as f: assert f.seek(1) == 1 assert f.read(1) == b"a" + assert f.seek(-1, io.SEEK_CUR) == 1 + assert f.seek(-1, io.SEEK_END) == 2 + + +@pytest.mark.parametrize( + "whence", + [ + pytest.param(io.SEEK_SET, id="set"), + pytest.param(io.SEEK_CUR, id="cur"), + pytest.param(io.SEEK_END, id="end"), + ], +) +def test_blob_file_negative_seek_raises_value_error(dataset_with_blobs, whence): + blob = dataset_with_blobs.take_blobs("blobs", indices=[1])[0] + offset = -(blob.size() + 1) if whence == io.SEEK_END else -1 + with pytest.raises(ValueError, match="negative seek value -1"): + blob.seek(offset, whence) + assert blob.tell() == 0 + + +def test_blob_file_negative_seek_does_not_move_cursor(dataset_with_blobs): + blob = dataset_with_blobs.take_blobs("blobs", indices=[1])[0] + blob.seek(2) + with pytest.raises(ValueError, match="negative seek value -1"): + blob.seek(-1) + assert blob.tell() == 2 + + +def test_blob_file_read_past_eof_leaves_cursor(dataset_with_blobs): + with dataset_with_blobs.take_blobs("blobs", indices=[1])[0] as blob: + past_eof = blob.size() + 1 + assert blob.seek(past_eof) == past_eof + assert blob.read() == b"" + assert blob.tell() == past_eof + + +@pytest.mark.parametrize( + "ranges", + [ + pytest.param([], id="empty_list"), + pytest.param([(0, 0), (4, 0)], id="empty_ranges"), + pytest.param([(2, 1), (0, 1), (1, 1)], id="non_monotonic"), + pytest.param([(1, 2), (0, 4), (1, 2), (0, 0), (2, 2)], id="dup_overlap"), + ], +) +def test_blob_file_read_ranges_matches_read_range(dataset_with_blobs, ranges): + row_ids = _blob_row_ids(dataset_with_blobs) + blobs = dataset_with_blobs.take_blobs("blobs", ids=row_ids) + with blobs[4] as f: + expected = [f.read_range(offset, length) for offset, length in ranges] + assert f.read_ranges(ranges) == expected + + +def test_blob_file_read_ranges_preserves_input_order(dataset_with_blobs): + row_ids = _blob_row_ids(dataset_with_blobs) + blobs = dataset_with_blobs.take_blobs("blobs", ids=row_ids) + with blobs[1] as f: + assert f.read_ranges([(2, 1), (0, 1), (1, 2)]) == [b"r", b"b", b"ar"] + + +def test_blob_file_read_ranges_does_not_change_cursor(dataset_with_blobs): + row_ids = _blob_row_ids(dataset_with_blobs) + blobs = dataset_with_blobs.take_blobs("blobs", ids=row_ids) + with blobs[1] as f: + assert f.tell() == 0 + assert f.read_ranges([(0, 3)]) == [b"bar"] + assert f.tell() == 0 + f.seek(2) + assert f.read_ranges([(2, 1), (0, 3)]) == [b"r", b"bar"] + assert f.tell() == 2 + assert f.read(1) == b"r" + + +@pytest.mark.parametrize( + ("ranges", "message"), + [ + pytest.param([(2**64 - 1, 2)], "offset \\+ length overflowed", id="overflow"), + pytest.param([(0, 1), (1, 100)], "exceeds blob size", id="out_of_bounds"), + ], +) +def test_blob_file_read_ranges_rejects_invalid_ranges( + dataset_with_blobs, ranges, message +): + row_ids = _blob_row_ids(dataset_with_blobs) + blobs = dataset_with_blobs.take_blobs("blobs", ids=row_ids) + with blobs[0] as f: + with pytest.raises(ValueError, match=message): + f.read_ranges(ranges) + assert f.tell() == 0 def test_null_blobs(tmp_path): @@ -232,11 +949,14 @@ def test_null_blobs(tmp_path): ] ), ) - ds = lance.write_dataset(table, tmp_path / "test_ds") + ds = lance.write_dataset( + table, + tmp_path / "test_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) blobs = ds.take_blobs("blob", ids=range(100)) - for blob in blobs: - assert blob.size() == 0 + assert blobs == [None] * 100 ds.insert(pa.table({"id": pa.array(range(100, 200), pa.uint64())})) @@ -250,8 +970,7 @@ def test_null_blobs(tmp_path): for blob_col in ["blob", "more_blob"]: blobs = ds.take_blobs(blob_col, indices=range(100, 200)) - for blob in blobs: - assert blob.size() == 0 + assert blobs == [None] * 100 blobs = ds.to_table(columns=[blob_col]) for blob in blobs.column(blob_col): @@ -280,55 +999,2192 @@ def test_blob_file_read_middle(tmp_path, dataset_with_blobs): assert f.read(1) == b"r" -def test_take_deleted_blob(tmp_path, dataset_with_blobs): - row_ids = ( - dataset_with_blobs.to_table(columns=[], with_row_id=True) - .column("_rowid") - .to_pylist() - ) - dataset_with_blobs.delete("idx = 1") +def test_blob_file_buffer_size_configures_inner(): + inner = _CountingBlobInner(b"x") + blob = BlobFile(inner, buffer_size=64 * 1024) - with pytest.raises( - NotImplementedError, - match="A take operation that includes row addresses must not target deleted", - ): - dataset_with_blobs.take_blobs("blobs", ids=row_ids) + assert isinstance(blob, io.BufferedIOBase) + assert inner.buffer_size == 64 * 1024 -def test_scan_blob(tmp_path, dataset_with_blobs): - ds = dataset_with_blobs.scanner(filter="idx = 2").to_table() - assert ds.num_rows == 1 +def test_blob_file_buffer_size_zero_configures_inner(): + inner = _CountingBlobInner(b"x") + BlobFile(inner, buffer_size=0) + assert inner.buffer_size == 0 -def test_blob_extension_write_inline(tmp_path): - table = pa.table({"blob": lance.blob_array([b"foo", b"bar"])}) +def test_blob_file_read_range_does_not_issue_sequential_read(): + payload = bytes(range(256)) + inner = _CountingBlobInner(payload) + blob = BlobFile(inner, buffer_size=64) + + assert blob.read_range(1, 3) == payload[1:4] + assert blob.tell() == 0 + assert inner.read_sizes == [] + assert blob.read(2) == payload[:2] + + +def test_blob_file_read_range_does_not_move_cursor(): + payload = bytes(range(256)) * 4 + inner = _CountingBlobInner(payload) + blob = BlobFile(inner, buffer_size=64) + + assert blob.read(8) == payload[:8] + assert blob.tell() == 8 + assert blob.read_range(20, 5) == payload[20:25] + assert blob.tell() == 8 + assert blob.read(4) == payload[8:12] + + +@pytest.mark.parametrize("buffer_size", [32, 0]) +def test_blob_file_text_wrapper_readline(buffer_size): + payload = b"hello\nworld\n" + blob = BlobFile(_CountingBlobInner(payload), buffer_size=buffer_size) + wrapper = io.TextIOWrapper(blob, encoding="utf-8") + + assert wrapper.readline() == "hello\n" + assert wrapper.readline() == "world\n" + + +def test_take_blobs_sequential_small_reads_reuse_prefetch(tmp_path): + payload = bytes(range(256)) * 160 ds = lance.write_dataset( - table, - tmp_path / "test_ds_v2", + _complete_blob_table([0], [payload]), + tmp_path / "seq_prefetch", data_storage_version="2.2", ) + blob = ds.take_blobs("blob", indices=[0], buffer_size=32 * 1024)[0] - desc = ds.to_table(columns=["blob"]).column("blob").chunk(0) - assert pa.types.is_struct(desc.type) + assert _read_in_8k_chunks(blob) == payload + assert blob.inner._range_submission_count() == 2 - blobs = ds.take_blobs("blob", indices=[0, 1]) - with blobs[0] as f: - assert f.read() == b"foo" +def test_take_blobs_buffer_size_zero_fetches_each_sequential_read(tmp_path): + payload = b"x" * (24 * 1024) + ds = lance.write_dataset( + _complete_blob_table([0], [payload]), + tmp_path / "seq_unbuffered", + data_storage_version="2.2", + ) + blob = ds.take_blobs("blob", indices=[0], buffer_size=0)[0] -def test_blob_extension_write_external(tmp_path): - blob_path = tmp_path / "external_blob.bin" - blob_path.write_bytes(b"hello") - uri = blob_path.as_uri() + assert _read_in_8k_chunks(blob) == payload + assert blob.inner._range_submission_count() == 3 - table = pa.table({"blob": lance.blob_array([uri])}) + +def test_take_blobs_seek_inside_prefetch_does_not_refetch(tmp_path): + payload = bytes((i * 31) % 256 for i in range(40 * 1024)) ds = lance.write_dataset( - table, - tmp_path / "test_ds_v2_external", + _complete_blob_table([0], [payload]), + tmp_path / "seek_inside", data_storage_version="2.2", ) + blob = ds.take_blobs("blob", indices=[0], buffer_size=32 * 1024)[0] - blob = ds.take_blobs("blob", indices=[0])[0] - assert blob.size() == 5 - with blob as f: - assert f.read() == b"hello" + assert blob.read(100) == payload[:100] + after_fill = blob.inner._range_submission_count() + blob.seek(1000) + assert blob.read(100) == payload[1000:1100] + assert blob.inner._range_submission_count() == after_fill + + +def test_take_blobs_seek_outside_prefetch_issues_new_range_submission(tmp_path): + payload = random.Random(0).randbytes(40 * 1024) + ds = lance.write_dataset( + _complete_blob_table([0], [payload]), + tmp_path / "seek_outside", + data_storage_version="2.2", + ) + blob = ds.take_blobs("blob", indices=[0], buffer_size=32 * 1024)[0] + + assert blob.read(100) == payload[:100] + after_fill = blob.inner._range_submission_count() + blob.seek(33 * 1024) + assert blob.read(100) == payload[33 * 1024 : 33 * 1024 + 100] + assert blob.inner._range_submission_count() == after_fill + 1 + + +def test_take_blobs_read_fills_across_buffer_boundary(tmp_path): + payload = bytes(range(40)) + ds = lance.write_dataset( + _complete_blob_table([0], [payload]), + tmp_path / "read_boundary", + data_storage_version="2.2", + ) + blob = ds.take_blobs("blob", indices=[0], buffer_size=16)[0] + assert blob.read(4) == payload[:4] + assert blob.read(20) == payload[4:24] + assert blob.tell() == 24 + + blob = ds.take_blobs("blob", indices=[0], buffer_size=16)[0] + assert blob.read(4) == payload[:4] + buf = bytearray(20) + assert blob.readinto(buf) == 20 + assert bytes(buf) == payload[4:24] + + blob = ds.take_blobs("blob", indices=[0], buffer_size=16)[0] + assert blob.read(4) == payload[:4] + assert blob.read1(20) == payload[4:24] + assert blob.tell() == 24 + + +def test_take_blobs_text_wrapper_readline(tmp_path): + payload = b"hello\nworld\n" + ds = lance.write_dataset( + _complete_blob_table([0], [payload]), + tmp_path / "text_wrapper", + data_storage_version="2.2", + ) + blob = ds.take_blobs("blob", indices=[0])[0] + wrapper = io.TextIOWrapper(blob, encoding="utf-8") + + assert wrapper.readline() == "hello\n" + assert wrapper.readline() == "world\n" + + +def test_blob_file_readinto_readonly_does_not_advance_cursor(): + inner = _CountingBlobInner(b"abcdef") + blob = BlobFile(inner, buffer_size=0) + + with pytest.raises(TypeError, match="read-write"): + blob.readinto(b"xx") + assert blob.tell() == 0 + assert inner.read_sizes == [] + + +def test_blob_file_empty_read_after_close_raises(): + blob = BlobFile(_CountingBlobInner(b"abcdef"), buffer_size=0) + blob.close() + + with pytest.raises(ValueError, match="read of closed file"): + blob.read(0) + with pytest.raises(ValueError, match="readinto of closed file"): + blob.readinto(bytearray()) + + +@pytest.mark.parametrize( + ("buffer_size", "match"), + [ + pytest.param(-1, "non-negative", id="negative"), + pytest.param(True, "must be an int", id="bool"), + pytest.param(1.5, "must be an int", id="float"), + ], +) +def test_blob_file_rejects_invalid_buffer_size(buffer_size, match): + with pytest.raises((TypeError, ValueError), match=match): + BlobFile(_CountingBlobInner(b"x"), buffer_size=buffer_size) + + +def test_take_blobs_buffer_size_zero_reads_payload(dataset_with_blobs): + blob = dataset_with_blobs.take_blobs("blobs", indices=[1], buffer_size=0)[0] + assert isinstance(blob, BlobFile) + assert blob.read() == b"bar" + + +def test_take_blobs_rejects_invalid_buffer_size_for_empty_selection(dataset_with_blobs): + with pytest.raises(ValueError, match="non-negative"): + dataset_with_blobs.take_blobs("blobs", indices=[], buffer_size=-1) + + +def test_blob_file_zipfile_reads_payload(tmp_path): + archive = io.BytesIO() + with zipfile.ZipFile(archive, "w") as zf: + zf.writestr("hello.txt", "hello blob") + payload = archive.getvalue() + + table = pa.table( + {"blob": lance.blob_array([payload])}, + schema=pa.schema([lance.blob_field("blob")]), + ) + ds = lance.write_dataset( + table, + tmp_path / "zip_blob", + data_storage_version="2.2", + ) + blob = ds.take_blobs("blob", indices=[0])[0] + with zipfile.ZipFile(blob) as zf: + assert zf.read("hello.txt") == b"hello blob" + + +def test_take_deleted_blob(tmp_path, dataset_with_blobs): + row_ids = ( + dataset_with_blobs.to_table(columns=[], with_row_id=True) + .column("_rowid") + .to_pylist() + ) + dataset_with_blobs.delete("idx = 1") + + with pytest.raises( + NotImplementedError, + match="A take operation that includes row addresses must not target deleted", + ): + dataset_with_blobs.take_blobs("blobs", ids=row_ids) + + +def test_scan_blob(tmp_path, dataset_with_blobs): + ds = dataset_with_blobs.scanner(filter="idx = 2").to_table() + assert ds.num_rows == 1 + + +def test_blob_extension_write_inline(tmp_path): + table = pa.table({"blob": lance.blob_array([b"foo", b"bar"])}) + ds = lance.write_dataset( + table, + tmp_path / "test_ds_v2", + data_storage_version="2.2", + ) + + desc = ds.to_table(columns=["blob"]).column("blob").chunk(0) + assert pa.types.is_struct(desc.type) + + blobs = ds.take_blobs("blob", indices=[0, 1]) + with blobs[0] as f: + assert f.read() == b"foo" + + +def test_complete_blob_schema_survives_create(tmp_path): + dataset_path = tmp_path / "complete_blob_create" + ds = lance.write_dataset( + _complete_blob_table([0], [b"created"]), + dataset_path, + data_storage_version="2.2", + ) + _assert_complete_blob_schema(ds) + assert ds.to_table(blob_handling="all_binary")["blob"].to_pylist() == [b"created"] + + +def test_complete_blob_schema_survives_append(tmp_path): + dataset_path = tmp_path / "complete_blob_append" + lance.write_dataset( + _complete_blob_table([0], [b"initial"]), + dataset_path, + data_storage_version="2.2", + ) + + lance.write_dataset( + _complete_blob_table([1], [b"appended"]), dataset_path, mode="append" + ) + ds = lance.dataset(dataset_path) + _assert_complete_blob_schema(ds) + result = ds.to_table(blob_handling="all_binary").sort_by("id") + assert result["id"].to_pylist() == [0, 1] + assert result["blob"].to_pylist() == [b"initial", b"appended"] + + +def test_complete_blob_schema_survives_merge_insert(tmp_path): + dataset_path = tmp_path / "complete_blob_merge_insert" + ds = lance.write_dataset( + _complete_blob_table([0, 1], [b"zero", b"initial"]), + dataset_path, + data_storage_version="2.2", + ) + + ( + ds.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(_complete_blob_table([1, 2], [b"updated", b"inserted"])) + ) + ds = lance.dataset(dataset_path) + _assert_complete_blob_schema(ds) + + result = ds.to_table(blob_handling="all_binary").sort_by("id") + assert result["id"].to_pylist() == [0, 1, 2] + assert result["blob"].to_pylist() == [b"zero", b"updated", b"inserted"] + + +@pytest.mark.parametrize( + ("use_uri", "position", "size"), + [ + pytest.param(True, 3, None, id="missing-size"), + pytest.param(False, 3, 2, id="range-without-uri"), + ], +) +def test_complete_blob_rows_reject_invalid_ranges(tmp_path, use_uri, position, size): + source = tmp_path / "source.bin" + source.write_bytes(b"0123456789") + storage = pa.StructArray.from_arrays( + [ + pa.array([None], type=pa.large_binary()), + pa.array([source.as_uri() if use_uri else None], type=pa.utf8()), + pa.array([position], type=pa.uint64()), + pa.array([size], type=pa.uint64()), + ], + names=["data", "uri", "position", "size"], + ) + blobs = pa.ExtensionArray.from_storage(BlobType(), storage) + table = pa.Table.from_arrays([blobs], schema=pa.schema([lance.blob_field("blob")])) + + with pytest.raises(OSError, match="position|size|uri"): + lance.write_dataset( + table, + tmp_path / "dataset", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + ) + + +@pytest.mark.parametrize("mode", ["reference", "ingest"]) +def test_complete_blob_rows_reject_zero_size_range(tmp_path, mode): + source = tmp_path / "source.bin" + source.write_bytes(b"0123456789") + table = _complete_blob_storage_table(None, source.as_uri(), 3, 0) + + with pytest.raises(OSError, match="greater than zero"): + lance.write_dataset( + table, + tmp_path / "dataset", + data_storage_version="2.2", + external_blob_mode=mode, + allow_external_blob_outside_bases=mode == "reference", + ) + + +@pytest.mark.parametrize( + ("data", "use_uri"), + [ + pytest.param(b"small", True, id="both-small"), + pytest.param(b"x" * 70_000, True, id="both-packed"), + pytest.param(None, False, id="neither"), + ], +) +def test_complete_blob_rows_reject_invalid_representation(tmp_path, data, use_uri): + source = tmp_path / "source.bin" + source.write_bytes(b"external") + table = _complete_blob_storage_table( + data, source.as_uri() if use_uri else None, None, None + ) + + with pytest.raises(OSError, match="data|uri"): + lance.write_dataset( + table, + tmp_path / "dataset", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + ) + + +@pytest.mark.parametrize("mode", ["reference", "ingest"]) +def test_empty_external_object_without_range(tmp_path, mode): + source = tmp_path / "empty.bin" + source.write_bytes(b"") + + dataset = lance.write_dataset( + pa.table({"blob": lance.blob_array([source.as_uri()])}), + tmp_path / "dataset", + data_storage_version="2.2", + external_blob_mode=mode, + allow_external_blob_outside_bases=mode == "reference", + ) + + with dataset.take_blobs("blob", indices=[0])[0] as blob: + assert blob.read() == b"" + + +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + pytest.param({}, "must set `data` or `uri`", id="neither"), + pytest.param( + {"data": b"data", "uri": "file:///source.bin"}, + "both data and uri", + id="both", + ), + pytest.param( + {"uri": "file:///source.bin", "position": 3, "size": 0}, + "greater than zero", + id="zero-size", + ), + ], +) +def test_blob_rejects_invalid_logical_value(kwargs, message): + with pytest.raises(ValueError, match=message): + Blob(**kwargs) + + +@pytest.mark.parametrize( + "storage_type", + [ + pytest.param( + pa.struct( + [ + pa.field("data", pa.large_binary()), + pa.field("uri", pa.utf8()), + ] + ), + id="minimal", + ), + pytest.param(BlobType().storage_type, id="complete-nullable-range"), + pytest.param( + pa.struct( + [ + pa.field( + "data", + pa.large_binary(), + metadata={b"source": b"preserved"}, + ), + pa.field("uri", pa.utf8()), + pa.field("position", pa.uint64(), nullable=False), + pa.field("size", pa.uint64(), nullable=False), + ] + ), + id="complete-required-range-with-metadata", + ), + ], +) +def test_blob_type_preserves_storage_type_through_arrow_ipc(storage_type): + blob_type = BlobType.__arrow_ext_deserialize__(storage_type, b"") + schema = pa.schema([pa.field("blob", blob_type)]) + sink = pa.BufferOutputStream() + with pa.ipc.new_stream(sink, schema): + pass + + restored_type = pa.ipc.open_stream(sink.getvalue()).schema.field("blob").type + + assert isinstance(restored_type, BlobType) + _assert_blob_storage_type_equal(restored_type.storage_type, storage_type) + + +@pytest.mark.parametrize( + "storage_type", + [ + pytest.param(pa.int32(), id="not-struct"), + pytest.param( + pa.struct( + [ + pa.field("data", pa.large_binary()), + pa.field("uri", pa.utf8()), + pa.field("position", pa.uint64()), + ] + ), + id="incomplete-range-shape", + ), + pytest.param( + pa.struct( + [ + pa.field("data", pa.large_binary(), nullable=False), + pa.field("uri", pa.utf8()), + ] + ), + id="required-data", + ), + pytest.param( + pa.struct( + [ + pa.field("data", pa.large_binary()), + pa.field("uri", pa.utf8()), + pa.field("position", pa.uint64()), + pa.field("size", pa.int64()), + ] + ), + id="wrong-range-type", + ), + ], +) +def test_blob_type_rejects_invalid_storage_type(storage_type): + with pytest.raises(TypeError, match="BlobType storage"): + BlobType.__arrow_ext_deserialize__(storage_type, b"") + + +@pytest.mark.parametrize( + ("initial_storage_type", "append_storage_type"), + [ + pytest.param( + pa.struct( + [ + pa.field("data", pa.large_binary()), + pa.field("uri", pa.utf8()), + ] + ), + BlobType().storage_type, + id="complete-to-minimal", + ), + pytest.param( + BlobType().storage_type, + pa.struct( + [ + pa.field("data", pa.large_binary()), + pa.field("uri", pa.utf8()), + ] + ), + id="minimal-to-complete", + ), + ], +) +def test_blob_v2_append_accepts_mixed_logical_shapes( + tmp_path, initial_storage_type, append_storage_type +): + dataset_path = tmp_path / "mixed_blob_shapes" + initial = pa.Table.from_arrays( + [_inline_blob_array(initial_storage_type, b"initial")], names=["blob"] + ) + append = pa.Table.from_arrays( + [_inline_blob_array(append_storage_type, b"appended")], names=["blob"] + ) + + lance.write_dataset(initial, dataset_path, data_storage_version="2.2") + lance.write_dataset(append, dataset_path, mode="append") + dataset = lance.dataset(dataset_path) + + _assert_blob_storage_type_equal( + dataset.schema.field("blob").type.storage_type, + initial_storage_type, + ) + blobs = dataset.take_blobs("blob", indices=[0, 1]) + assert [blob.readall() for blob in blobs] == [b"initial", b"appended"] + + +def test_blob_field_threshold_metadata(): + field = lance.blob_field( + "blob", + inline_size_threshold=16 * 1024, + dedicated_size_threshold=2 * 1024 * 1024, + pack_file_size_threshold=512 * 1024 * 1024, + ) + + assert field.metadata[b"lance-encoding:blob-inline-size-threshold"] == b"16384" + assert field.metadata[b"lance-encoding:blob-dedicated-size-threshold"] == b"2097152" + assert ( + field.metadata[b"lance-encoding:blob-pack-file-size-threshold"] == b"536870912" + ) + + +@pytest.mark.parametrize( + ("kwargs", "error", "message"), + [ + pytest.param( + {"inline_size_threshold": -1}, + ValueError, + "inline_size_threshold must be non-negative", + id="negative_inline", + ), + pytest.param( + {"dedicated_size_threshold": 0}, + ValueError, + "dedicated_size_threshold must be positive", + id="zero_dedicated", + ), + pytest.param( + {"dedicated_size_threshold": -1}, + ValueError, + "dedicated_size_threshold must be positive", + id="negative_dedicated", + ), + pytest.param( + {"inline_size_threshold": True}, + TypeError, + "inline_size_threshold must be an int", + id="bool_inline", + ), + pytest.param( + {"dedicated_size_threshold": True}, + TypeError, + "dedicated_size_threshold must be an int", + id="bool_dedicated", + ), + pytest.param( + {"inline_size_threshold": 1.5}, + TypeError, + "inline_size_threshold must be an int", + id="float_inline", + ), + pytest.param( + {"inline_size_threshold": 2**100}, + OverflowError, + "inline_size_threshold must fit in a Rust usize", + id="overflow_inline", + ), + pytest.param( + {"dedicated_size_threshold": 2**100}, + OverflowError, + "dedicated_size_threshold must fit in a Rust usize", + id="overflow_dedicated", + ), + pytest.param( + {"pack_file_size_threshold": 0}, + ValueError, + "pack_file_size_threshold must be positive", + id="zero_pack_file", + ), + pytest.param( + {"pack_file_size_threshold": -1}, + ValueError, + "pack_file_size_threshold must be positive", + id="negative_pack_file", + ), + pytest.param( + {"pack_file_size_threshold": True}, + TypeError, + "pack_file_size_threshold must be an int", + id="bool_pack_file", + ), + pytest.param( + {"pack_file_size_threshold": 2**100}, + OverflowError, + "pack_file_size_threshold must fit in a Rust usize", + id="overflow_pack_file", + ), + ], +) +def test_blob_field_rejects_invalid_thresholds(kwargs, error, message): + with pytest.raises(error, match=message): + lance.blob_field("blob", **kwargs) + + +def test_blob_extension_inline_threshold_per_column(tmp_path): + payload = b"x" * 2048 + schema = pa.schema( + [ + lance.blob_field("inline_blob", inline_size_threshold=4096), + lance.blob_field("packed_blob", inline_size_threshold=1024), + ] + ) + table = pa.table( + { + "inline_blob": lance.blob_array([payload]), + "packed_blob": lance.blob_array([payload]), + }, + schema=schema, + ) + ds = lance.write_dataset( + table, + tmp_path / "test_ds_v2_inline_threshold_per_column", + data_storage_version="2.2", + ) + + desc = ds.to_table(columns=["inline_blob", "packed_blob"]) + assert desc.column("inline_blob").chunk(0).field("kind").to_pylist() == [0] + assert desc.column("packed_blob").chunk(0).field("kind").to_pylist() == [4] + + +def test_blob_extension_threshold_metadata_persists_after_reopen(tmp_path): + dataset_path = tmp_path / "test_ds_v2_threshold_metadata_persists" + schema = pa.schema([lance.blob_field("blob", inline_size_threshold=1024)]) + table = pa.table({"blob": lance.blob_array([b"x"])}, schema=schema) + + lance.write_dataset(table, dataset_path, data_storage_version="2.2") + reopened = lance.dataset(dataset_path) + + assert ( + reopened.schema.field("blob").metadata[ + b"lance-encoding:blob-inline-size-threshold" + ] + == b"1024" + ) + + +def test_blob_extension_append_rejects_explicit_threshold_mismatch(tmp_path): + dataset_path = tmp_path / "test_ds_v2_append_threshold_mismatch" + initial_schema = pa.schema([lance.blob_field("blob", inline_size_threshold=4096)]) + initial = pa.table( + {"blob": lance.blob_array([b"x" * 2048])}, + schema=initial_schema, + ) + lance.write_dataset(initial, dataset_path, data_storage_version="2.2") + + append_schema = pa.schema([lance.blob_field("blob", inline_size_threshold=1024)]) + append = pa.table( + {"blob": lance.blob_array([b"x" * 2048])}, + schema=append_schema, + ) + + with pytest.raises( + OSError, match="Cannot append data with blob threshold metadata" + ): + lance.write_dataset(append, dataset_path, mode="append") + + +def test_blob_extension_pack_file_threshold_metadata_persists_after_reopen( + tmp_path: Path, +): + dataset_path = tmp_path / "test_ds_v2_pack_file_threshold_persists" + threshold = 512 * 1024 * 1024 + schema = pa.schema([lance.blob_field("blob", pack_file_size_threshold=threshold)]) + table = pa.table({"blob": lance.blob_array([b"x"])}, schema=schema) + + lance.write_dataset(table, dataset_path, data_storage_version="2.2") + reopened = lance.dataset(dataset_path) + + assert ( + reopened.schema.field("blob").metadata[ + b"lance-encoding:blob-pack-file-size-threshold" + ] + == str(threshold).encode() + ) + + +def test_blob_extension_append_rejects_pack_file_threshold_mismatch(tmp_path: Path): + dataset_path = tmp_path / "test_ds_v2_append_pack_file_mismatch" + initial_schema = pa.schema( + [lance.blob_field("blob", pack_file_size_threshold=512 * 1024 * 1024)] + ) + initial = pa.table( + {"blob": lance.blob_array([b"x" * 2048])}, + schema=initial_schema, + ) + lance.write_dataset(initial, dataset_path, data_storage_version="2.2") + + append_schema = pa.schema( + [lance.blob_field("blob", pack_file_size_threshold=256 * 1024 * 1024)] + ) + append = pa.table( + {"blob": lance.blob_array([b"x" * 2048])}, + schema=append_schema, + ) + + with pytest.raises( + OSError, match="Cannot append data with blob threshold metadata" + ): + lance.write_dataset(append, dataset_path, mode="append") + + +def test_blob_extension_dedicated_threshold_precedes_inline_threshold(tmp_path): + payload = b"x" * 2048 + schema = pa.schema( + [ + lance.blob_field( + "blob", + inline_size_threshold=4096, + dedicated_size_threshold=1024, + ) + ] + ) + table = pa.table({"blob": lance.blob_array([payload])}, schema=schema) + ds = lance.write_dataset( + table, + tmp_path / "test_ds_v2_dedicated_precedes_inline", + data_storage_version="2.2", + ) + + desc = ds.to_table(columns=["blob"]).column("blob").chunk(0) + assert desc.field("kind").to_pylist() == [4] + + +def test_blob_extension_write_external(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", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + ) + + blob = ds.take_blobs("blob", indices=[0])[0] + assert blob.size() == 5 + with blob as f: + assert f.read() == b"hello" + + +@pytest.mark.parametrize( + ("position", "size"), + [ + pytest.param(None, None, id="explicit_none"), + pytest.param(1, 3, id="slice"), + ], +) +def test_blob_from_uri_accepts_optional_slice_metadata(position, size): + blob = Blob.from_uri("file:///tmp/blob.bin", position=position, size=size) + + assert blob.uri == "file:///tmp/blob.bin" + assert blob.position == position + assert blob.size == size + + +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_add_columns_record_batch_reader_all_kinds(tmp_path): + values, payloads, initial_bases = _add_columns_blob_v2_values(tmp_path) + ds = lance.write_dataset( + pa.table({"id": range(4)}), + tmp_path / "test_add_columns_reader_blob_v2", + data_storage_version="2.2", + initial_bases=initial_bases, + ) + + ds.add_columns(pa.table({"blob": lance.blob_array(values)}).to_reader()) + + _assert_blob_v2_add_columns_result(ds, "blob", payloads) + + +@pytest.mark.parametrize( + "failure_mode", + [ + pytest.param("raises_after_first_fragment", id="reader_raises_mid_stream"), + pytest.param("wrong_schema", id="reader_yields_wrong_schema"), + pytest.param("too_many_rows", id="reader_produces_too_many_rows"), + ], +) +def test_blob_extension_add_columns_record_batch_reader_failure_cleans_files( + tmp_path, + failure_mode, +): + ds, dataset_path, values, payloads = _write_two_fragment_blob_v2_seed_dataset( + tmp_path, + f"test_add_columns_reader_blob_v2_fail_cleanup_{failure_mode}", + ) + external_blob_path = tmp_path / "external_base" / "external_blob.bin" + files_before = _dataset_file_set(dataset_path) + + schema = pa.schema([lance.blob_field("blob")]) + first_fragment_batch = pa.record_batch([lance.blob_array(values)], schema=schema) + second_fragment_batch = pa.record_batch([lance.blob_array(values)], schema=schema) + + if failure_mode == "raises_after_first_fragment": + match = "reader failed after first fragment" + + def failing_reader(): + yield first_fragment_batch + raise RuntimeError("reader failed after first fragment") + + elif failure_mode == "wrong_schema": + match = "field names" + + def failing_reader(): + yield first_fragment_batch + yield pa.record_batch([pa.array(range(4))], ["not_blob"]) + + else: + match = "Stream produced more values than expected for dataset" + + def failing_reader(): + yield first_fragment_batch + yield second_fragment_batch + yield pa.record_batch([lance.blob_array([payloads[0]])], schema=schema) + + with pytest.raises(OSError, match=match): + ds.add_columns(failing_reader(), reader_schema=schema) + + assert ds.version == 1 + files_after = _dataset_file_set(dataset_path) + assert files_before <= files_after + orphans = files_after - files_before + assert orphans and all( + p.parts[0] == "_blobs" and p.suffix == ".blob" for p in orphans + ) + # Independent payloads from failed writes follow the existing orphan policy. + # No concurrent writer is running here, so immediate unverified GC is safe. + ds.cleanup_old_versions(delete_unverified=True) + assert _dataset_file_set(dataset_path) == files_before + assert external_blob_path.exists() + + +def test_blob_extension_add_columns_batch_udf_failure_cleans_files(tmp_path): + ds, dataset_path, values, _ = _write_two_fragment_blob_v2_seed_dataset( + tmp_path, + "test_add_columns_udf_blob_v2_fail_cleanup", + ) + external_blob_path = tmp_path / "external_base" / "external_blob.bin" + files_before = _dataset_file_set(dataset_path) + call_count = 0 + + @lance.batch_udf(output_schema=pa.schema([lance.blob_field("blob")])) + def fail_on_second_fragment(batch): + nonlocal call_count + call_count += 1 + if call_count == 2: + raise RuntimeError("udf failed after first fragment") + blob_values = [values[row.as_py() % len(values)] for row in batch["id"]] + return pa.record_batch( + [lance.blob_array(blob_values)], + ["blob"], + ) + + with pytest.raises(OSError, match="udf failed after first fragment"): + ds.add_columns(fail_on_second_fragment, read_columns=["id"], batch_size=4) + + assert call_count == 2 + assert ds.version == 1 + files_after = _dataset_file_set(dataset_path) + assert files_before <= files_after + orphans = files_after - files_before + assert orphans and all( + p.parts[0] == "_blobs" and p.suffix == ".blob" for p in orphans + ) + # Independent payloads from failed writes follow the existing orphan policy. + # No concurrent writer is running here, so immediate unverified GC is safe. + ds.cleanup_old_versions(delete_unverified=True) + assert _dataset_file_set(dataset_path) == files_before + assert external_blob_path.exists() + + +def test_blob_extension_add_columns_batch_udf_all_kinds(tmp_path): + values, payloads, initial_bases = _add_columns_blob_v2_values(tmp_path) + ds = lance.write_dataset( + pa.table({"id": range(4)}), + tmp_path / "test_add_columns_udf_blob_v2", + data_storage_version="2.2", + initial_bases=initial_bases, + ) + + @lance.batch_udf(output_schema=pa.schema([lance.blob_field("blob")])) + def make_blob_column(batch): + return pa.record_batch( + [lance.blob_array([values[row.as_py()] for row in batch["id"]])], + ["blob"], + ) + + ds.add_columns(make_blob_column, read_columns=["id"]) + + _assert_blob_v2_add_columns_result(ds, "blob", payloads) + + +def test_blob_extension_add_columns_all_nulls_blob_v2(tmp_path): + ds = lance.write_dataset( + pa.table({"id": range(4)}), + tmp_path / "test_add_columns_all_nulls_blob_v2", + data_storage_version="2.2", + ) + + ds.add_columns(lance.blob_field("blob")) + + assert ds.to_table(columns=["blob"]).column("blob").to_pylist() == [None] * 4 + assert ds.take_blobs("blob", indices=range(4)) == [None] * 4 + assert [data for _, data in ds.read_blobs("blob", indices=range(4))] == [None] * 4 + ranges = ds.read_blob_ranges( + "blob", + [(index, 0, 1) for index in range(4)], + selector="indices", + ) + assert [data for _, _, data in ranges] == [None] * 4 + + +def test_blob_descriptor_array_builder_writes_prepared_packed_blob_for_data_replacement( + tmp_path, +): + dataset_uri = tmp_path / "test_blob_descriptor_array_builder_data_replacement" + logical_schema = pa.schema( + [ + pa.field("id", pa.uint32(), nullable=False), + lance.blob_field("blob"), + ] + ) + initial = pa.table( + [ + pa.array([0], type=pa.uint32()), + lance.blob_array([b"initial"]), + ], + schema=logical_schema, + ) + ds = lance.write_dataset(initial, dataset_uri, data_storage_version="2.2") + + file_id = str(uuid.uuid4()) + data_file_name = f"{file_id}.lance" + blob_id = 1 + blob_path = _blob_sidecar_path(dataset_uri / "data", file_id, blob_id) + + files = LanceFileSession(dataset_uri / "data") + blob_writer = lance.BlobDescriptorArrayBuilder("blob") + packed = files.open_packed_blob_writer(data_file_name, blob_id) + assert packed.path.endswith(blob_path.relative_to(dataset_uri).as_posix()) + packed.write_blob(b"replacement") + blob_writer.extend(packed.finish()) + + physical_schema = pa.schema( + [ + pa.field("id", pa.uint32(), nullable=False), + blob_writer.field, + ] + ) + replacement = pa.record_batch( + [ + pa.array([1], type=pa.uint32()), + blob_writer.finish(), + ], + schema=physical_schema, + ) + + with files.open_writer( + data_file_name, schema=physical_schema, version="2.2" + ) as file_writer: + file_writer.write_batch(replacement) + + data_file = lance.fragment.DataFile.create(ds, data_file_name) + operation = lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(0, data_file)] + ) + ds = lance.LanceDataset.commit(ds, operation, read_version=ds.version) + + assert ds.to_table(columns=["id"]).column("id").to_pylist() == [1] + blobs = ds.take_blobs("blob", indices=[0]) + assert len(blobs) == 1 + assert blobs[0].readall() == b"replacement" + + +@pytest.mark.parametrize( + "payload", + [ + pytest.param(b"payload", id="bytes"), + pytest.param(bytearray(b"payload"), id="bytearray"), + pytest.param(memoryview(b"payload"), id="memoryview"), + pytest.param(list(b"payload"), id="integer_sequence"), + ], +) +def test_packed_blob_writer_scalar_buffer_inputs(tmp_path, payload): + file_id = str(uuid.uuid4()) + blob_id = 7 + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + + packed.write_blob(payload) + descriptors = packed.finish() + + assert [repr(descriptor) for descriptor in descriptors] == [ + "Packed { blob_id: 7, offset: 0, size: 7 }" + ] + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"payload" + + +@pytest.mark.parametrize( + "array_type", [pa.binary(), pa.large_binary(), pa.binary_view()] +) +@pytest.mark.parametrize("as_chunked", [False, True], ids=["array", "chunked_array"]) +@pytest.mark.parametrize( + "values,slice_offset,slice_length,expected_values,expected_data", + [ + pytest.param( + [b"prefix", b"a", None, b"", b"bc", b"suffix"], + 1, + 4, + [b"a", None, b"", b"bc"], + b"abc", + id="interleaved_null", + ), + pytest.param( + [b"prefix", b"a", b"", b"bc", b"suffix"], + 1, + 3, + [b"a", b"", b"bc"], + b"abc", + id="all_valid", + ), + pytest.param( + [b"prefix", None, None, b"suffix"], + 1, + 2, + [None, None], + b"", + id="all_null", + ), + pytest.param( + [b"prefix", b"suffix"], + 1, + 0, + [], + b"", + id="empty", + ), + ], +) +def test_packed_blob_writer_bulk_arrow_array( + tmp_path, + array_type, + as_chunked, + values, + slice_offset, + slice_length, + expected_values, + expected_data, +): + file_id = str(uuid.uuid4()) + data_file_name = f"{file_id}.lance" + blob_id = 7 + payloads = pa.array(values, type=array_type).slice(slice_offset, slice_length) + if as_chunked: + split_at = max(1, len(payloads) // 2) + payloads = pa.chunked_array( + [payloads.slice(0, split_at), payloads.slice(split_at)] + ) + + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(data_file_name, blob_id) + with pytest.raises(ValueError, match="available after finish_array"): + packed.field + packed.write_blobs(payloads) + descriptors = packed.finish_array("image_bytes") + descriptor_field = packed.field + + expected_descriptors = [] + position = 0 + for value in expected_values: + if value is None: + expected_descriptors.append(None) + else: + expected_descriptors.append( + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": len(value), + "position": position, + } + ) + position += len(value) + + assert descriptors.to_pylist() == expected_descriptors + assert descriptor_field == lance.BlobDescriptorArrayBuilder("image_bytes").field + assert descriptor_field.metadata[b"ARROW:extension:name"] == b"lance.blob.v2" + assert pa.record_batch( + [descriptors], schema=pa.schema([descriptor_field]) + ).num_rows == len(expected_values) + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == expected_data + + +@pytest.mark.parametrize( + "array_type,offset_type", + [ + pytest.param(pa.binary(), pa.int32(), id="binary"), + pytest.param(pa.large_binary(), pa.int64(), id="large_binary"), + ], +) +def test_packed_blob_writer_bulk_excludes_physical_null_bytes( + tmp_path, array_type, offset_type +): + offsets = pa.array([0, 1, 5, 5, 7], type=offset_type).buffers()[1] + payloads = pa.Array.from_buffers( + array_type, + 4, + [ + pa.py_buffer(bytes([0b00001101])), + offsets, + pa.py_buffer(b"aJUNKbc"), + ], + ) + file_id = str(uuid.uuid4()) + blob_id = 7 + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + + packed.write_blobs(payloads) + descriptors = packed.finish_array("image_bytes") + + assert descriptors.to_pylist() == [ + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": 1, + "position": 0, + }, + None, + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": 0, + "position": 1, + }, + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": 2, + "position": 1, + }, + ] + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"abc" + + +@pytest.mark.parametrize( + "array_type,offset_type", + [ + pytest.param(pa.binary(), pa.int32(), id="binary"), + pytest.param(pa.large_binary(), pa.int64(), id="large_binary"), + ], +) +@pytest.mark.parametrize("as_chunked", [False, True], ids=["array", "chunked_array"]) +def test_packed_blob_writer_bulk_rejects_non_monotonic_offsets( + tmp_path, array_type, offset_type, as_chunked +): + offsets = pa.array([0, 2, 1, 2], type=offset_type).buffers()[1] + malformed = pa.Array.from_buffers( + array_type, + 3, + [None, offsets, pa.py_buffer(b"ab")], + ) + payloads = malformed + expected_context = "Packed blob payload array" + if as_chunked: + payloads = pa.chunked_array([pa.array([b"valid"], type=array_type), malformed]) + expected_context = "Packed blob payload chunk 1" + + file_id = str(uuid.uuid4()) + blob_id = 7 + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + + with pytest.raises(ValueError, match="invalid Arrow data") as error: + packed.write_blobs(payloads) + assert expected_context in str(error.value) + assert "non-monotonic offset" in str(error.value) + + packed.write_blob(b"still usable") + descriptors = packed.finish_array("blob") + assert len(descriptors) == 1 + assert ( + _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"still usable" + ) + + +def test_packed_blob_writer_mixed_calls_preserve_legacy_finish_alignment(tmp_path): + file_id = str(uuid.uuid4()) + blob_id = 7 + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + + packed.write_blob(b"s") + packed.write_blobs(pa.array([b"a", None, b""], type=pa.binary())) + packed.write_blobs(pa.array([None, b"bc"], type=pa.large_binary())) + descriptors = packed.finish() + + assert [repr(descriptor) for descriptor in descriptors] == [ + "Packed { blob_id: 7, offset: 0, size: 1 }", + "Packed { blob_id: 7, offset: 1, size: 1 }", + "Null", + "Packed { blob_id: 7, offset: 2, size: 0 }", + "Null", + "Packed { blob_id: 7, offset: 2, size: 2 }", + ] + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"sabc" + + +@pytest.mark.parametrize( + "payloads", + [ + pytest.param(pa.array([1, 2], type=pa.int32()), id="array"), + pytest.param(pa.chunked_array([[1], [2]], type=pa.int32()), id="chunked_array"), + pytest.param([b"not an Arrow array"], id="python_list"), + ], +) +def test_packed_blob_writer_bulk_rejects_non_binary_array(tmp_path, payloads): + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer("data-file.lance", 1) + + with pytest.raises(ValueError, match="Binary") as error: + packed.write_blobs(payloads) + if isinstance(payloads, pa.Array): + assert "chunk" not in str(error.value) + + packed.write_blob(b"still usable") + assert len(packed.finish_array("blob")) == 1 + + +@pytest.mark.parametrize( + "open_writer", + [ + pytest.param("open_packed_blob_writer", id="packed"), + pytest.param("open_dedicated_blob_writer", id="dedicated"), + ], +) +def test_failed_blob_writer_traceback_can_be_released_on_another_thread( + tmp_path, monkeypatch, open_writer +): + failures = queue.Queue() + + def fail_with_live_writer(): + files = LanceFileSession(tmp_path) + writer = getattr(files, open_writer)("data-file.lance", 1) + assert writer.blob_id == 1 + try: + raise OSError("simulated object-store write failure") + except OSError as error: + # The traceback retains this frame and its local writer, matching a + # writer-thread failure handed to an owning thread for propagation. + failures.put(error) + + writer_thread = threading.Thread(target=fail_with_live_writer) + writer_thread.start() + writer_thread.join(timeout=10) + assert not writer_thread.is_alive() + + unraisable = [] + monkeypatch.setattr(sys, "unraisablehook", unraisable.append) + error = failures.get_nowait() + assert str(error) == "simulated object-store write failure" + del error + gc.collect() + + assert unraisable == [] + + +def test_blob_extension_write_fragments_external_denied_by_default(tmp_path): + blob_path = tmp_path / "external_blob.bin" + + table = _external_blob_table(blob_path) + with pytest.raises(OSError, match="outside registered external bases"): + write_fragments( + table, + tmp_path / "test_fragments_v2_external_denied", + data_storage_version="2.2", + external_blob_mode="reference", + ) + + +def test_blob_extension_write_fragments_external_outside_base_allowed(tmp_path): + blob_path = tmp_path / "external_blob.bin" + dataset_uri = tmp_path / "test_fragments_v2_external_allowed" + + table = _external_blob_table(blob_path) + fragments = write_fragments( + table, + dataset_uri, + data_storage_version="2.2", + external_blob_mode="reference", + allow_external_blob_outside_bases=True, + ) + ds = _commit_blob_fragments(dataset_uri, table.schema, fragments) + + 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_fragments_transaction_external_outside_base_allowed( + tmp_path, +): + blob_path = tmp_path / "external_blob.bin" + dataset_uri = tmp_path / "test_fragments_transaction_v2_external_allowed" + + table = _external_blob_table(blob_path) + transaction = write_fragments( + table, + dataset_uri, + mode="create", + return_transaction=True, + data_storage_version="2.2", + external_blob_mode="reference", + allow_external_blob_outside_bases=True, + ) + ds = lance.LanceDataset.commit(dataset_uri, transaction) + + 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_fragments_external_registered_base(tmp_path): + external_base = tmp_path / "external_base" + blob_path = external_base / "external_blob.bin" + dataset_uri = tmp_path / "test_fragments_v2_external_registered_base" + + table = _external_blob_table(blob_path) + initial_bases = [DatasetBasePath(external_base.as_uri(), name="external", id=1)] + fragments = write_fragments( + table, + dataset_uri, + mode="create", + data_storage_version="2.2", + external_blob_mode="reference", + initial_bases=initial_bases, + ) + ds = _commit_blob_fragments( + dataset_uri, + table.schema, + fragments, + initial_bases=initial_bases, + ) + + 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_fragments_external_ingest(tmp_path): + blob_path = tmp_path / "external_blob.bin" + dataset_uri = tmp_path / "test_fragments_v2_external_ingest" + + table = _external_blob_table(blob_path) + fragments = write_fragments( + table, + dataset_uri, + data_storage_version="2.2", + external_blob_mode="ingest", + ) + ds = _commit_blob_fragments(dataset_uri, table.schema, fragments) + + 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_fragments_transaction_external_ingest(tmp_path): + blob_path = tmp_path / "external_blob.bin" + dataset_uri = tmp_path / "test_fragments_transaction_v2_external_ingest" + + table = _external_blob_table(blob_path) + transaction = write_fragments( + table, + dataset_uri, + mode="create", + return_transaction=True, + data_storage_version="2.2", + external_blob_mode="ingest", + ) + ds = lance.LanceDataset.commit(dataset_uri, transaction) + + 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_fragments_external_ingest_rejects_reference_only_options( + tmp_path, +): + blob_path = tmp_path / "external_blob.bin" + message = ( + "allow_external_blob_outside_bases only applies when " + 'external_blob_mode="reference"' + ) + + table = _external_blob_table(blob_path) + with pytest.raises(OSError, match=message): + write_fragments( + table, + tmp_path / "test_fragments_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"] + payloads = [b"alpha", b"bravo", b"charlie"] + + # Build a tar container with three distinct binary entries. + 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)) + + # Re-open the tar to obtain offsets and sizes for each member. + 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", + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + ) + + 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 + + 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" + 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"), + [ + (b"inline", True), + (b"p" * (64 * 1024 + 1024), True), + (b"d" * (4 * 1024 * 1024 + 1024), True), + (b"x" * (64 * 1024 + 1024), False), + ], + ids=["inline", "packed", "dedicated", "packed_data_only_base"], +) +def test_blob_extension_take_blobs_multi_base(payload, is_dataset_root, tmp_path): + base_path = tmp_path / "blob_base" + base_path.mkdir(parents=True, exist_ok=True) + table = pa.table({"blob": lance.blob_array([payload])}) + + ds = lance.write_dataset( + table, + tmp_path / "primary_ds", + mode="create", + data_storage_version="2.2", + initial_bases=[ + DatasetBasePath( + str(base_path), name="blob_base", is_dataset_root=is_dataset_root + ) + ], + target_bases=["blob_base"], + ) + + fragments = list(ds.get_fragments()) + assert len(fragments) == 1 + data_file = fragments[0].data_files()[0] + assert data_file.base_id is not None + + blobs = ds.take_blobs("blob", indices=[0]) + assert len(blobs) == 1 + 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): + 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", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) + + +@pytest.fixture +def dataset_for_pandas_no_blob_tests(tmp_path): + table = pa.table( + { + "id": pa.array([1, 2, 3], pa.int64()), + "name": pa.array(["one", "two", "three"], pa.string()), + "bin": pa.array([b"x", b"y", b"z"], pa.large_binary()), + } + ) + return lance.write_dataset(table, tmp_path / "no_blob_pandas_ds") + + +def _lazy_blob_values(dataset): + values = dataset.scanner(columns=["blob"]).to_pandas(blob_mode="lazy")["blob"] + return [None if value is None else value.readall() for value in values] + + +@pytest.mark.parametrize( + "values, has_sidecar", + [ + pytest.param([b"", b"payload", None], False, id="leading_empty"), + pytest.param([b"", b"", b""], False, id="all_empty"), + pytest.param( + [b"payload", b"", None, b"tail", b""], + False, + id="payload_empty_null", + ), + pytest.param( + [b"", b"p" * (64 * 1024 + 1024), None, b""], + True, + id="sidecar_backed", + ), + ], +) +def test_blob_v2_lazy_preserves_empty_and_null(tmp_path, values, has_sidecar): + dataset_path = tmp_path / "blob_v2_lazy_empty" + schema = pa.schema([lance.blob_field("blob")]) + dataset = lance.write_dataset( + pa.Table.from_arrays([lance.blob_array(values)], schema=schema), + dataset_path, + data_storage_version="2.2", + ) + + descriptions = dataset.to_table(columns=["blob"]).column("blob").to_pylist() + assert [description is None for description in descriptions] == [ + value is None for value in values + ] + if values[0] == b"": + assert descriptions[0]["position"] == 0 + assert descriptions[0]["size"] == 0 + if has_sidecar: + assert any( + description is not None and description["kind"] == 4 + for description in descriptions + ) + assert any(path.suffix == ".blob" for path in _dataset_file_set(dataset_path)) + + assert _lazy_blob_values(dataset) == values + + +def test_blob_v1_lazy_preserves_empty_and_null_sentinels(tmp_path): + values = [b"", None, b"payload"] + schema = pa.schema( + [ + pa.field( + "blob", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ) + ] + ) + dataset = lance.write_dataset( + pa.Table.from_arrays( + [pa.array(values, type=pa.large_binary())], + schema=schema, + ), + tmp_path / "blob_v1_lazy_empty", + data_storage_version="2.0", + ) + + descriptions = dataset.to_table(columns=["blob"]).column("blob").to_pylist() + assert descriptions[0] == {"position": 0, "size": 0} + assert descriptions[1] == {"position": 1, "size": 0} + assert _lazy_blob_values(dataset) == values + + +@pytest.mark.parametrize("source", ["dataset", "scanner", "fragment"]) +def test_to_pandas_without_blobs_matches_arrow_with_kwargs( + dataset_for_pandas_no_blob_tests, + source, +): + kwargs = {"types_mapper": pd.ArrowDtype} + ds = dataset_for_pandas_no_blob_tests + + if source == "dataset": + actual = ds.to_pandas(**kwargs) + expected = ds.to_table().to_pandas(**kwargs) + elif source == "scanner": + scanner = ds.scanner( + columns=["id", "name"], + filter="id >= 2", + limit=2, + offset=0, + batch_size=1, + ) + actual = scanner.to_pandas(**kwargs) + expected = scanner.to_table().to_pandas(**kwargs) + else: + fragment = ds.get_fragments()[0] + actual = fragment.to_pandas( + columns=["id", "name"], + filter="id >= 2", + limit=2, + offset=0, + batch_size=1, + **kwargs, + ) + expected = fragment.to_table( + columns=["id", "name"], + filter="id >= 2", + limit=2, + offset=0, + ).to_pandas(**kwargs) + + pd.testing.assert_frame_equal(actual, expected) + assert actual.dtypes["id"] == pd.ArrowDtype(pa.int64()) + + +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] + + +@pytest.mark.parametrize("source", ["dataset", "scanner", "fragment"]) +def test_to_pandas_blob_scan_parameters(dataset_for_pandas_blob_tests, source): + ds = dataset_for_pandas_blob_tests + scan_kwargs = { + "columns": ["id", "blob"], + "filter": "id > 1", + "limit": 1, + "offset": 1, + "batch_size": 1, + } + + if source == "dataset": + df = ds.to_pandas(**scan_kwargs, blob_mode="bytes") + elif source == "scanner": + df = ds.scanner(**scan_kwargs).to_pandas(blob_mode="bytes") + else: + df = ds.get_fragments()[0].to_pandas(**scan_kwargs, blob_mode="bytes") + + assert list(df.columns) == ["id", "blob"] + assert df["id"].tolist() == [3] + assert df["blob"].tolist() == [b"world"] + + +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"], batch_size=1, 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") + + +@pytest.fixture +def dataset_with_nested_blobs(tmp_path): + blob_field = pa.field( + "blob", pa.large_binary(), metadata={"lance-encoding:blob": "true"} + ) + info_type = pa.struct([pa.field("name", pa.string()), blob_field]) + info_array = pa.array( + [ + {"name": "a", "blob": b"foo"}, + {"name": "b", "blob": None}, + {"name": "c", "blob": b"baz"}, + ], + type=info_type, + ) + table = pa.table( + [pa.array([1, 2, 3], pa.int64()), info_array], + schema=pa.schema([pa.field("id", pa.int64()), pa.field("info", info_type)]), + ) + return lance.write_dataset( + table, + tmp_path / "nested_blob_ds", + data_storage_version=LEGACY_BLOB_STORAGE_VERSION, + ) + + +def test_to_pandas_returns_blob_file_handles_for_nested_fields( + dataset_with_nested_blobs, +): + df = dataset_with_nested_blobs.to_pandas() + row0, row1, row2 = df["info"].tolist() + + assert row0["blob"].readall() == b"foo" + assert row1["blob"] is None + assert row2["blob"].readall() == b"baz" + + +def test_to_pandas_reads_nested_blob_bytes_directly(dataset_with_nested_blobs): + rows = dataset_with_nested_blobs.to_pandas(blob_mode="bytes")["info"].tolist() + + assert [r["blob"] for r in rows] == [b"foo", None, b"baz"] + + +def test_to_pandas_returns_descriptors_for_nested_fields(dataset_with_nested_blobs): + descriptions_df = dataset_with_nested_blobs.to_pandas(blob_mode="descriptions") + table_df = dataset_with_nested_blobs.to_table().to_pandas() + + assert descriptions_df.equals(table_df) + + +def test_take_blobs_resolves_nested_field_path(dataset_with_nested_blobs): + blobs = dataset_with_nested_blobs.take_blobs("info.blob", indices=[0, 2]) + + with blobs[0] as f: + assert f.read() == b"foo" + with blobs[1] as f: + assert f.read() == b"baz" + + +def test_read_blobs_resolves_nested_field_path(dataset_with_nested_blobs): + results = dataset_with_nested_blobs.read_blobs("info.blob", indices=[0, 2]) + + assert [data for _, data in results] == [b"foo", b"baz"] + + +def test_write_nested_blob_v2_and_take_by_field_path(tmp_path): + packed = b"x" * (70 * 1024) + blob_field = lance.blob_field("blob") + info_fields = [pa.field("name", pa.string()), blob_field] + info_type = pa.struct(info_fields) + info_array = pa.StructArray.from_arrays( + [pa.array(["a", "b", "c"]), lance.blob_array([b"foo", packed, None])], + fields=info_fields, + ) + table = pa.table( + [info_array], + schema=pa.schema([pa.field("info", info_type)]), + ) + + dataset = lance.write_dataset( + table, + tmp_path / "nested_blob_v2", + data_storage_version="2.2", + ) + + desc = dataset.to_table(columns=["info.blob"]).column("info.blob").chunk(0) + assert desc.field("kind").to_pylist()[:2] == [0, 4] + + blobs = dataset.take_blobs("info.blob", indices=[0, 1]) + with blobs[0] as f: + assert f.read() == b"foo" + with blobs[1] as f: + assert f.read() == packed + + assert dataset.take_blobs("info.blob", indices=[2]) == [None] + + +def test_write_list_struct_nested_blob_v2(tmp_path): + payload_fields = [ + lance.blob_field("blob"), + pa.field("format", pa.string()), + ] + payload_values = pa.StructArray.from_arrays( + [ + lance.blob_array([b"first", b"second", None]), + pa.array(["bin", "bin", "bin"]), + ], + fields=payload_fields, + ) + media_fields = [ + pa.field("type", pa.string()), + pa.field("payload", payload_values.type), + ] + media_values = pa.StructArray.from_arrays( + [pa.array(["image", "image", "image"]), payload_values], + fields=media_fields, + ) + media = pa.ListArray.from_arrays(pa.array([0, 2, 3]), media_values) + table = pa.table({"media": media}) + + dataset = lance.write_dataset( + table, + tmp_path / "list_struct_nested_blob_v2", + data_storage_version="2.2", + ) + + media = ( + dataset.scanner(columns=["media"], blob_handling="all_binary") + .to_table() + .column("media") + .combine_chunks() + ) + blobs = media.values.field("payload").field("blob") + assert blobs.to_pylist() == [b"first", b"second", None] + + +def test_to_pandas_returns_blob_files_for_projected_nested_fields( + dataset_with_nested_blobs, +): + images = ( + dataset_with_nested_blobs.scanner(columns=["info.blob"]) + .to_pandas()["info.blob"] + .tolist() + ) + + assert images[0].readall() == b"foo" + assert images[1] is None + assert images[2].readall() == b"baz" + + +def test_to_pandas_reads_bytes_for_projected_nested_fields(dataset_with_nested_blobs): + df = dataset_with_nested_blobs.scanner(columns=["info.blob"]).to_pandas( + blob_mode="bytes" + ) + + assert df["info.blob"].tolist() == [b"foo", None, b"baz"] + + +def test_to_pandas_returns_blob_files_when_nested_field_is_aliased( + dataset_with_nested_blobs, +): + images = ( + dataset_with_nested_blobs.scanner(columns={"my_img": "info.blob"}) + .to_pandas()["my_img"] + .tolist() + ) + + assert images[0].readall() == b"foo" + assert images[1] is None + assert images[2].readall() == b"baz" + + +@pytest.mark.parametrize("as_chunked", [False, True], ids=["array", "chunked_array"]) +def test_packed_blob_writer_bulk_binary_view(tmp_path, as_chunked): + file_id = str(uuid.uuid4()) + blob_id = 7 + values = [b"hello", None, b"", b"world"] + payloads = pa.array(values, type=pa.binary_view()) + if as_chunked: + payloads = pa.chunked_array([payloads.slice(0, 2), payloads.slice(2)]) + + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + packed.write_blobs(payloads) + descriptors = packed.finish_array("image_bytes") + + expected_descriptors = [] + position = 0 + for value in values: + if value is None: + expected_descriptors.append(None) + else: + expected_descriptors.append( + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": len(value), + "position": position, + } + ) + position += len(value) + + assert descriptors.to_pylist() == expected_descriptors + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"helloworld" + + +@pytest.mark.parametrize("as_chunked", [False, True], ids=["array", "chunked_array"]) +def test_packed_blob_writer_bulk_fixed_size_binary(tmp_path, as_chunked): + file_id = str(uuid.uuid4()) + blob_id = 7 + values = [b"word", None, b"test"] + payloads = pa.array(values, type=pa.binary(4)) + if as_chunked: + payloads = pa.chunked_array([payloads.slice(0, 2), payloads.slice(2)]) + + files = LanceFileSession(tmp_path) + packed = files.open_packed_blob_writer(f"{file_id}.lance", blob_id) + packed.write_blobs(payloads) + descriptors = packed.finish_array("image_bytes") + + expected_descriptors = [] + position = 0 + for value in values: + if value is None: + expected_descriptors.append(None) + else: + expected_descriptors.append( + { + "kind": 1, + "data": None, + "uri": None, + "blob_id": blob_id, + "blob_size": len(value), + "position": position, + } + ) + position += len(value) + + assert descriptors.to_pylist() == expected_descriptors + assert _blob_sidecar_path(tmp_path, file_id, blob_id).read_bytes() == b"wordtest" + + +class _CountingBlobInner: + def __init__(self, payload: bytes): + self._payload = payload + self._pos = 0 + self._closed = False + self.buffer_size: int | None = None + self.read_sizes: list[int] = [] + + def set_buffer_size(self, buffer_size: int) -> None: + self.buffer_size = buffer_size + + def close(self) -> None: + self._closed = True + + def is_closed(self) -> bool: + return self._closed + + def seek(self, position: int) -> None: + self._pos = position + + def tell(self) -> int: + return self._pos + + def size(self) -> int: + return len(self._payload) + + def readall(self) -> bytes: + data = self._payload[self._pos :] + if data: + self.read_sizes.append(len(data)) + self._pos = len(self._payload) + return data + + def read_range(self, offset: int, length: int) -> bytes: + return self._payload[offset : offset + length] + + def read_ranges(self, ranges: list[tuple[int, int]]) -> list[bytes]: + return [self.read_range(offset, length) for offset, length in ranges] + + def read_into(self, b: bytearray) -> int: + if self._pos >= len(self._payload): + return 0 + bytes_read = min(len(b), len(self._payload) - self._pos) + self.read_sizes.append(bytes_read) + b[:bytes_read] = self._payload[self._pos : self._pos + bytes_read] + self._pos += bytes_read + return bytes_read + + +def _read_in_8k_chunks(blob: BlobFile) -> bytes: + chunks = [] + while True: + chunk = blob.read(8192) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) diff --git a/python/python/tests/test_coerce_query_vector.py b/python/python/tests/test_coerce_query_vector.py new file mode 100644 index 00000000000..7a5d341f8e0 --- /dev/null +++ b/python/python/tests/test_coerce_query_vector.py @@ -0,0 +1,76 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Tests for _coerce_query_vector to ensure invalid input types +raise TypeError instead of falling into the numpy conversion branch.""" + +import numpy as np +import pyarrow as pa +import pytest +from lance.dataset import _coerce_query_vector + + +class TestCoerceQueryVectorInvalidTypes: + """Non-vector inputs should raise TypeError, not numpy ValueError.""" + + def test_string_raises_typeerror(self): + with pytest.raises(TypeError, match="query vector must be list-like"): + _coerce_query_vector("not a vector") + + def test_integer_raises_typeerror(self): + with pytest.raises( + TypeError, match="Query vectors should be an array of floats" + ): + _coerce_query_vector(42) + + def test_object_raises_typeerror(self): + """A random object that is not array-like should raise TypeError.""" + + class NotAVector: + pass + + with pytest.raises( + TypeError, match="Query vectors should be an array of floats" + ): + _coerce_query_vector(NotAVector()) + + def test_none_raises_typeerror(self): + with pytest.raises( + TypeError, match="Query vectors should be an array of floats" + ): + _coerce_query_vector(None) + + +class TestCoerceQueryVectorValidTypes: + """Valid vector inputs should be coerced successfully.""" + + def test_list_of_floats(self): + result, dim = _coerce_query_vector([1.0, 2.0, 3.0]) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 + + def test_tuple_of_floats(self): + result, dim = _coerce_query_vector((1.0, 2.0, 3.0)) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 + + def test_numpy_array(self): + result, dim = _coerce_query_vector(np.array([1.0, 2.0, 3.0])) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 + + def test_pa_float_array(self): + result, dim = _coerce_query_vector(pa.array([1.0, 2.0, 3.0])) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 + + def test_pa_int_array_cast_to_float(self): + result, dim = _coerce_query_vector(pa.array([1, 2, 3])) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 + + def test_pa_chunked_array(self): + chunked = pa.chunked_array([[1.0, 2.0, 3.0]]) + result, dim = _coerce_query_vector(chunked) + assert isinstance(result, pa.FloatingPointArray) + assert dim == 3 diff --git a/python/python/tests/test_column_names.py b/python/python/tests/test_column_names.py index 5ec38085896..646309ede96 100644 --- a/python/python/tests/test_column_names.py +++ b/python/python/tests/test_column_names.py @@ -84,10 +84,10 @@ def test_scalar_index_with_mixed_case(self, mixed_case_dataset): """Scalar index creation should work with mixed-case column names.""" mixed_case_dataset.create_scalar_index("userId", index_type="BTREE") - indices = mixed_case_dataset.list_indices() + indices = mixed_case_dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["userId"] - assert indices[0]["name"] == "userId_idx" + assert indices[0].field_names == ["userId"] + assert indices[0].name == "userId_idx" # Query using the indexed column result = mixed_case_dataset.to_table(filter="userId = 50") @@ -206,7 +206,7 @@ def test_scalar_index_on_each_case_variant(self, tmp_path, case_variant_table): # Create separate datasets for each test to avoid index conflicts ds1 = lance.write_dataset(case_variant_table, tmp_path / "ds1") ds1.create_scalar_index("camelCase", index_type="BTREE") - assert ds1.list_indices()[0]["fields"] == ["camelCase"] + assert ds1.describe_indices()[0].field_names == ["camelCase"] # Query camelCase=50 should return row 50 (where CamelCase=49, CAMELCASE=0) result = ds1.to_table(filter="camelCase = 50") @@ -221,7 +221,7 @@ def test_scalar_index_on_each_case_variant(self, tmp_path, case_variant_table): # Test CamelCase index ds2 = lance.write_dataset(case_variant_table, tmp_path / "ds2") ds2.create_scalar_index("CamelCase", index_type="BTREE") - assert ds2.list_indices()[0]["fields"] == ["CamelCase"] + assert ds2.describe_indices()[0].field_names == ["CamelCase"] # Query CamelCase=50 should return row 49 (where camelCase=49, CAMELCASE=99) result = ds2.to_table(filter="CamelCase = 50") @@ -236,7 +236,7 @@ def test_scalar_index_on_each_case_variant(self, tmp_path, case_variant_table): # Test CAMELCASE index ds3 = lance.write_dataset(case_variant_table, tmp_path / "ds3") ds3.create_scalar_index("CAMELCASE", index_type="BTREE") - assert ds3.list_indices()[0]["fields"] == ["CAMELCASE"] + assert ds3.describe_indices()[0].field_names == ["CAMELCASE"] # Query CAMELCASE=50 should return row 0 (where camelCase=0, CamelCase=99) result = ds3.to_table(filter="CAMELCASE = 50") @@ -347,11 +347,10 @@ def test_scalar_index_with_special_chars(self, special_char_dataset): # Column name is used directly without SQL parsing special_char_dataset.create_scalar_index("user-id", index_type="BTREE") - indices = special_char_dataset.list_indices() + indices = special_char_dataset.describe_indices() assert len(indices) == 1 - # Field with special chars is returned in quoted format for SQL compatibility - assert indices[0]["fields"] == ["`user-id`"] - assert indices[0]["name"] == "user-id_idx" + assert indices[0].field_names == ["user-id"] + assert indices[0].name == "user-id_idx" # Query using the indexed column (requires backticks in filter) result = special_char_dataset.to_table(filter="`user-id` = 50") @@ -460,10 +459,10 @@ def test_scalar_index_with_nested_mixed_case(self, nested_mixed_case_dataset): "MetaData.userId", index_type="BTREE" ) - indices = nested_mixed_case_dataset.list_indices() + indices = nested_mixed_case_dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["MetaData.userId"] - assert indices[0]["name"] == "MetaData.userId_idx" + assert indices[0].name == "MetaData.userId_idx" + assert indices[0].field_names == ["MetaData.userId"] # Query using the indexed column result = nested_mixed_case_dataset.to_table(filter="MetaData.userId = 50") @@ -482,10 +481,10 @@ def test_scalar_index_on_top_level_mixed_case(self, nested_mixed_case_dataset): """Scalar index on top-level mixed-case column works.""" nested_mixed_case_dataset.create_scalar_index("rowId", index_type="BTREE") - indices = nested_mixed_case_dataset.list_indices() + indices = nested_mixed_case_dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["rowId"] - assert indices[0]["name"] == "rowId_idx" + assert indices[0].name == "rowId_idx" + assert indices[0].field_names == ["rowId"] result = nested_mixed_case_dataset.to_table(filter="rowId = 50") assert result.num_rows == 1 @@ -509,10 +508,11 @@ def test_scalar_index_with_lowercased_nested_path(self, nested_mixed_case_datase "metadata.userid", index_type="BTREE" ) - indices = nested_mixed_case_dataset.list_indices() + indices = nested_mixed_case_dataset.describe_indices() assert len(indices) == 1 # Should store with correct case from schema - assert indices[0]["fields"] == ["MetaData.userId"] + assert indices[0].name == "MetaData.userId_idx" + assert indices[0].field_names == ["MetaData.userId"] # Query should also work with correct case result = nested_mixed_case_dataset.to_table(filter="MetaData.userId = 50") @@ -574,11 +574,10 @@ def test_scalar_index_with_nested_special_chars(self, nested_special_char_datase "`meta-data`.`user-id`", index_type="BTREE" ) - indices = nested_special_char_dataset.list_indices() + indices = nested_special_char_dataset.describe_indices() assert len(indices) == 1 - # Fields with special chars are returned in quoted format for SQL compatibility - assert indices[0]["fields"] == ["`meta-data`.`user-id`"] - assert indices[0]["name"] == "meta-data.user-id_idx" + assert indices[0].field_names == ["meta-data.user-id"] + assert indices[0].name == "meta-data.user-id_idx" # Query using the indexed column (backticks required in filter) result = nested_special_char_dataset.to_table( @@ -599,10 +598,9 @@ def test_scalar_index_on_top_level_special_chars(self, nested_special_char_datas """Scalar index on top-level special char column works.""" nested_special_char_dataset.create_scalar_index("`row-id`", index_type="BTREE") - indices = nested_special_char_dataset.list_indices() + indices = nested_special_char_dataset.describe_indices() assert len(indices) == 1 - # Field with special chars is returned in quoted format for SQL compatibility - assert indices[0]["fields"] == ["`row-id`"] + assert indices[0].field_names == ["row-id"] result = nested_special_char_dataset.to_table(filter="`row-id` = 50") assert result.num_rows == 1 diff --git a/python/python/tests/test_commit_index.py b/python/python/tests/test_commit_index.py index c5d4f3ca9d1..a6515431592 100644 --- a/python/python/tests/test_commit_index.py +++ b/python/python/tests/test_commit_index.py @@ -52,7 +52,7 @@ def _get_field_id_by_name(lance_schema, field_name): def test_commit_index(dataset_with_index, test_table, tmp_path): from lance.dataset import Index - index_id = dataset_with_index.list_indices()[0]["uuid"] + index_id = dataset_with_index.describe_indices()[0].segments[0].uuid # Create a new dataset without index dataset_without_index = lance.write_dataset( @@ -90,13 +90,13 @@ def test_commit_index(dataset_with_index, test_table, tmp_path): read_version=dataset_without_index.version, ) - # Verify that both datasets have the index - assert len(dataset_with_index.list_indices()) == 1 - assert len(dataset_without_index.list_indices()) == 1 + # Verify the manually committed index matches the original index stats + stats_with = dataset_with_index.stats.index_stats("meta_idx") + stats_without = dataset_without_index.stats.index_stats("meta_idx") - assert ( - dataset_without_index.list_indices()[0] == dataset_with_index.list_indices()[0] - ) + assert stats_without["name"] == stats_with["name"] + assert stats_without["index_type"] == stats_with["index_type"] + assert stats_without["num_indexed_rows"] == stats_with["num_indexed_rows"] # Check if the index is used in scans for dataset in [dataset_with_index, dataset_without_index]: @@ -105,3 +105,235 @@ 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 + + +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 + + +def test_index_covering_fields_roundtrip(dataset_with_index, test_table, tmp_path): + """A non-empty covering declaration must survive both the transaction + round trip (transaction.rs) and the describe_indices round trip + (indices.rs).""" + from lance.dataset import Index + + index_id = dataset_with_index.describe_indices()[0].segments[0].uuid + + # Create a new dataset without index + dataset_without_index = lance.write_dataset( + test_table, tmp_path / "dataset_without_index" + ) + + # Copy the index 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) + + meta_id = _get_field_id_by_name(dataset_without_index.lance_schema, "meta") + price_id = _get_field_id_by_name(dataset_without_index.lance_schema, "price") + + # `covering_fields` must be a non-empty, non-total suffix of `fields`: + # [price_id] is the trailing entry of [meta_id, price_id], leaving + # meta_id as the column the index is keyed on. + index = Index( + uuid=index_id, + name="meta_idx", + fields=[meta_id, price_id], + dataset_version=dataset_without_index.version, + fragment_ids=set( + [f.fragment_id for f in dataset_without_index.get_fragments()] + ), + index_version=0, + covering_fields=[price_id], + ) + + 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, + ) + + # Exercises transaction.rs: the Index <-> IndexMetadata PyO3 conversion. + committed_txn = dataset_without_index.get_transactions(1)[0] + committed_index = committed_txn.operation.new_indices[0] + assert committed_index.covering_fields == [price_id] + + # Exercises indices.rs: the IndexMetadata -> PyIndexSegmentDescription + # conversion used by describe_indices(). + segment = dataset_without_index.describe_indices()[0].segments[0] + assert segment.covering_fields == [price_id] + + +def test_commit_index_rejects_invalid_covering_fields(dataset_with_index, tmp_path): + """The invariant is enforced at commit, not at construction. + + LanceOperation.CreateIndex is a plain dataclass; it only reaches Rust at + commit(), so a construction-time gate would miss this path entirely. + """ + from lance.dataset import Index + + index_id = dataset_with_index.describe_indices()[0].segments[0].uuid + field_id = _get_field_id_by_name(dataset_with_index.lance_schema, "meta") + + # Constructing it is fine -- this is a dataclass, nothing is validated. + index = Index( + uuid=index_id, + name="meta_idx", + fields=[field_id], + dataset_version=dataset_with_index.version, + fragment_ids=set([f.fragment_id for f in dataset_with_index.get_fragments()]), + index_version=0, + covering_fields=[field_id], # covers the only field -- degenerate + ) + + create_index_op = lance.LanceOperation.CreateIndex( + new_indices=[index], + removed_indices=[], + ) + + with pytest.raises(OSError, match="at least one field must remain indexed"): + lance.LanceDataset.commit( + dataset_with_index.uri, + create_index_op, + read_version=dataset_with_index.version, + ) + + +def test_commit_index_accepts_bitmap_fragment_ids(dataset_with_index): + """An `Index` read back from Rust carries a `Bitmap` fragment_ids, and must + commit again as-is rather than having to be converted back to a `set`.""" + from lance.bitmap import Bitmap + + index = dataset_with_index.get_transactions(1)[0].operation.new_indices[0] + assert isinstance(index.fragment_ids, Bitmap) + fragment_ids = set(index.fragment_ids) + assert fragment_ids == {f.fragment_id for f in dataset_with_index.get_fragments()} + + dataset = lance.LanceDataset.commit( + dataset_with_index.uri, + lance.LanceOperation.CreateIndex(new_indices=[index], removed_indices=[]), + read_version=dataset_with_index.version, + ) + + segment = dataset.describe_indices()[0].segments[0] + assert set(segment.fragment_ids) == fragment_ids + assert dataset.stats.index_stats("meta_idx")["num_indexed_rows"] == 1000 diff --git a/python/python/tests/test_compat_version_snapshot.py b/python/python/tests/test_compat_version_snapshot.py new file mode 100644 index 00000000000..ce2ccc3d405 --- /dev/null +++ b/python/python/tests/test_compat_version_snapshot.py @@ -0,0 +1,106 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Coverage for the version snapshot the compat tests are parametrized over.""" + +import shutil +import subprocess +import sys +from pathlib import Path + +TESTS_DIR = Path(__file__).resolve().parent + +# Returns an extra release on gw1 only, so a worker that resolves the list itself +# parametrizes differently from the controller and from its sibling. +STUB_PLUGIN = """ +import os +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from packaging.version import Version + +from compat import compat_decorator + + +def _fetch_stable_versions(): + extra = ["8.0.0"] if os.environ.get("PYTEST_XDIST_WORKER") == "gw1" else [] + return sorted(Version(v) for v in ["6.0.0", "7.0.0", *extra]) + + +def _fetch_last_beta_release(): + return "7.1.0b1" + + +compat_decorator._fetch_stable_versions = _fetch_stable_versions +compat_decorator._fetch_last_beta_release = _fetch_last_beta_release +""" + +# Not run with --run-compat: the generated cases stay collected but skipped, which +# is what makes their ids part of the collection xdist compares between workers. +INNER_TEST = """ +import os + +from compat import compat_decorator +from compat.compat_decorator import UpgradeDowngradeTest, compat_test + +SEEDED_AT_IMPORT = compat_decorator._SNAPSHOT is not None + + +@compat_test() +class Sample(UpgradeDowngradeTest): + def __init__(self, path): + self.path = path + + +def test_snapshot_arrived_before_collection(): + assert os.environ["PYTEST_XDIST_WORKER"] + assert SEEDED_AT_IMPORT, "worker resolved the version list itself" +""" + + +def test_workers_share_the_controller_version_snapshot(tmp_path): + """Every xdist worker parametrizes on the releases the controller resolved. + + The compat suite discovers pylance releases over the network, so a worker + left to query for them itself can collect a different parameter set than its + siblings and abort the run. This pins both halves of the fix: that the + snapshot reaches the worker at all, and that it arrives before collection + imports any test module. + """ + root = tmp_path / "inner" + (root / "compat").mkdir(parents=True) + shutil.copy(TESTS_DIR / "conftest.py", root / "conftest.py") + shutil.copy( + TESTS_DIR / "compat" / "compat_decorator.py", + root / "compat" / "compat_decorator.py", + ) + (root / "compat" / "__init__.py").touch() + (root / "stubnet.py").write_text(STUB_PLUGIN) + (root / "test_snapshot.py").write_text(INNER_TEST) + + result = subprocess.run( + [ + sys.executable, + "-m", + "pytest", + "-q", + "-p", + "no:cacheprovider", + "-p", + "stubnet", + "-n", + "2", + "--dist", + "loadgroup", + ], + cwd=root, + capture_output=True, + text=True, + ) + output = result.stdout + result.stderr + + assert "Different tests were collected" not in output, output + assert "8.0.0" not in output, output + assert result.returncode == 0, output diff --git a/python/python/tests/test_count_pushdown.py b/python/python/tests/test_count_pushdown.py new file mode 100644 index 00000000000..896bd629fc9 --- /dev/null +++ b/python/python/tests/test_count_pushdown.py @@ -0,0 +1,178 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""End-to-end tests for count-from-mask pushdown. + +The optimizer rule under test (`CountPushdown`) rewrites +``SELECT COUNT(*) ... WHERE indexed_col v`` into +``AggregateExec(Final) → CountFromMaskExec → ScalarIndexExec`` when the +index covers every dataset fragment, or splits into a Union of a pushdown +branch over the indexed fragments and a scan branch over the rest when +coverage is partial. This is category 1 (count-from-mask) of the four +aggregate-acceleration categories; the other three (mask-to-answer, +zone-aware, dimension-keyed) are not implemented yet. + +Each test exercises a different state of the dataset (clean, with deletions, +with updates that introduce unindexed fragments, with a fully-deleted indexed +fragment) and asserts: + + 1. The returned count matches the ground truth (correctness), and + 2. The plan routes through ``CountFromMaskExec`` (the rule fired). + +For the cases where the index covers the whole dataset, the tests also assert +no ``LanceRead`` is present in the plan — proof that the count is being +answered from index metadata, not by scanning column data. The happy-path +test additionally re-runs the query and asserts the second call performs no +I/O. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import lance +import pyarrow as pa + +if TYPE_CHECKING: + from pathlib import Path + + +# -------------------------------------------------------------------------- +# Helpers +# -------------------------------------------------------------------------- + +# 4 fragments × 25 rows = 100 rows; values 0..99 in `x`. +NUM_FRAGMENTS = 4 +ROWS_PER_FRAGMENT = 25 +NUM_ROWS = NUM_FRAGMENTS * ROWS_PER_FRAGMENT # 100 + + +def _make_dataset(tmp_path: Path) -> lance.LanceDataset: + """Build a 4-fragment dataset with a BTREE index on `x`.""" + table = pa.table({"x": pa.array(range(NUM_ROWS), pa.int64())}) + dataset = lance.write_dataset( + table, + tmp_path / "ds", + max_rows_per_file=ROWS_PER_FRAGMENT, + ) + assert len(dataset.get_fragments()) == NUM_FRAGMENTS + dataset.create_scalar_index("x", "BTREE") + return dataset + + +def _filtered_count_plan(dataset: lance.LanceDataset, filter: str) -> str: + """Return the ``analyze_plan(count_rows=True)`` output for a filtered + ``COUNT(*)`` — the same plan ``count_rows(filter=…)`` actually executes.""" + return dataset.scanner(columns=[], with_row_id=True, filter=filter).analyze_plan( + count_rows=True + ) + + +def _assert_pushdown_fired(plan: str) -> None: + assert "CountFromMask" in plan, f"expected CountFromMaskExec in plan, got:\n{plan}" + + +def _assert_no_column_scan(plan: str) -> None: + """Stricter: no LanceRead anywhere. Only applies when the index covers + every dataset fragment (no partial-coverage split branch).""" + assert "LanceRead" not in plan, ( + f"unexpected LanceRead in plan — column data was scanned:\n{plan}" + ) + + +# -------------------------------------------------------------------------- +# Tests +# -------------------------------------------------------------------------- + + +def test_filtered_count_with_scalar_index(tmp_path: Path): + """Happy path: filtered count on an indexed column, run twice. + + The second call must perform zero I/O — proof the rule routed the count + through the index/deletion-mask metadata both times and the second call + re-used the cache. The check uses ``dataset.io_stats_incremental()`` + rather than parsing the plan's ``bytes_read=…`` so we get a direct + accounting of every object-store read the dataset performed during the + second call, not just what the plan happens to surface. + """ + dataset = _make_dataset(tmp_path) + filter = "x < 50" + expected = 50 + + # Verify the rule fires for this shape. + _assert_pushdown_fired(_filtered_count_plan(dataset, filter)) + _assert_no_column_scan(_filtered_count_plan(dataset, filter)) + + # First call warms the index + deletion-mask caches. + assert dataset.count_rows(filter=filter) == expected + # Reset counters so the next snapshot only reflects the second call. + dataset.io_stats_incremental() + + # Second call: must do zero I/O. + assert dataset.count_rows(filter=filter) == expected + stats = dataset.io_stats_incremental() + assert stats.read_iops == 0, f"expected 0 read_iops, got {stats.read_iops}" + assert stats.read_bytes == 0, f"expected 0 read_bytes, got {stats.read_bytes}" + + +def test_filtered_count_with_deleted_rows(tmp_path: Path): + """Some matching rows are deleted — the count must reflect the deletions. + + Deletions don't change fragment coverage, so the index still covers every + dataset fragment and the rule emits a single pushdown branch (no scan). + """ + dataset = _make_dataset(tmp_path) + # Delete three rows that match the filter (x < 50). + dataset.delete("x = 10 OR x = 20 OR x = 30") + plan = _filtered_count_plan(dataset, "x < 50") + _assert_pushdown_fired(plan) + _assert_no_column_scan(plan) + assert dataset.count_rows(filter="x < 50") == 50 - 3 + + +def test_filtered_count_with_updated_rows(tmp_path: Path): + """Updates move rows in/out of the filter set. + + Before: x < 50 ⇒ 50 rows match (values 0..49). + After: + - x = 5 → x = 100 (one row leaves the matched set) + - x = 7 → x = 101 (another row leaves) + - x = 60 → x = 8 (a row joins the matched set) + - x = 70 → x = 9 (another joins) + + Net change: −2 + 2 = 0, so the final count is still 50, but the + underlying row identities have shifted. Each update is materialized as + a delete + insert into a new fragment in Lance — the new fragments are + not in the index's coverage, so the optimizer rule emits a split plan: + pushdown for the originally-indexed fragments, plus a scan branch for + the rewritten fragments. The final count must still be correct. + """ + dataset = _make_dataset(tmp_path) + dataset.update({"x": "100"}, where="x = 5") + dataset.update({"x": "101"}, where="x = 7") + dataset.update({"x": "8"}, where="x = 60") + dataset.update({"x": "9"}, where="x = 70") + + plan = _filtered_count_plan(dataset, "x < 50") + _assert_pushdown_fired(plan) + # 50 originally matching − 2 that left + 2 that joined = 50. + assert dataset.count_rows(filter="x < 50") == 50 + + +def test_filtered_count_with_whole_fragment_deleted(tmp_path: Path): + """Delete every row in one indexed fragment. + + Fragment 0 covers x ∈ [0, 25). Deleting all of those rows removes 25 + matches of `x < 50`, dropping the count from 50 to 25. + + Lance retires the now-empty fragment, so the dataset has 3 fragments + while the index still claims 4 — the index is a strict *superset* of + the dataset, which is safe (the extra index entries simply don't + apply). The rule emits a single pushdown branch (no scan needed). + """ + dataset = _make_dataset(tmp_path) + dataset.delete("x < 25") + plan = _filtered_count_plan(dataset, "x < 50") + _assert_pushdown_fired(plan) + _assert_no_column_scan(plan) + assert dataset.count_rows(filter="x < 50") == 25 diff --git a/python/python/tests/test_create_empty_index.py b/python/python/tests/test_create_empty_index.py index 047cbb16e59..39a2580b2ff 100644 --- a/python/python/tests/test_create_empty_index.py +++ b/python/python/tests/test_create_empty_index.py @@ -16,10 +16,10 @@ def test_create_empty_scalar_index(): dataset.create_scalar_index("id", "BTREE", train=False) # Verify index exists and has correct stats - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["type"] == "BTree" - stats = dataset.stats.index_stats(indices[0]["name"]) + assert indices[0].index_type == "BTree" + stats = dataset.stats.index_stats(indices[0].name) assert stats["num_indexed_rows"] == 0 assert stats["num_unindexed_rows"] == dataset.count_rows() @@ -31,17 +31,31 @@ def test_create_empty_vector_index(): data = pa.table({"vector": vectors}) dataset = lance.write_dataset(data, "memory://") - # Currently, vector indices with train=False are not supported - try: - dataset.create_index( - "vector", "IVF_PQ", num_partitions=10, num_sub_vectors=8, train=False - ) - # If we get here, the implementation has been added (unexpected for now) - assert False, ( - "Expected NotImplementedError for train=False on vector index, " - "but succeeded" - ) - except NotImplementedError as e: - # Expected error for unimplemented functionality - error_msg = str(e).lower() - assert "not yet implemented" in error_msg or "not implemented" in error_msg + dataset.create_index( + "vector", "IVF_PQ", num_partitions=10, num_sub_vectors=8, train=False + ) + + # Same shape the scalar case reports: listed, covering nothing yet. + indices = dataset.describe_indices() + assert len(indices) == 1 + stats = dataset.stats.index_stats(indices[0].name) + assert stats["num_indexed_rows"] == 0 + assert stats["num_unindexed_rows"] == dataset.count_rows() + + +def test_create_vector_index_below_the_row_floor(): + """A table too small to train the quantizer takes the index anyway.""" + dim = 32 + values = pc.random(100 * dim).cast(pa.float32()) + vectors = pa.FixedSizeListArray.from_arrays(values, dim) + data = pa.table({"vector": vectors}) + dataset = lance.write_dataset(data, "memory://") + + # 100 vectors cannot train a 256-code codebook, and train defaults to True. + dataset.create_index("vector", "IVF_PQ", num_partitions=10, num_sub_vectors=8) + + indices = dataset.describe_indices() + assert len(indices) == 1 + stats = dataset.stats.index_stats(indices[0].name) + assert stats["num_indexed_rows"] == 0 + assert stats["num_unindexed_rows"] == dataset.count_rows() diff --git a/python/python/tests/test_custom_registry.py b/python/python/tests/test_custom_registry.py new file mode 100644 index 00000000000..9e38f47f693 --- /dev/null +++ b/python/python/tests/test_custom_registry.py @@ -0,0 +1,167 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Smoke test for the runtime object-store scheme registration hook. + +Exercises the pyo3 additions in ``python/src/object_store.rs`` and the new +``store_registry=`` parameter on ``Session(...)``. Registers the built-in +``MemoryStoreProvider`` under a made-up scheme (``test-mem``) and proves that +``lance.write_dataset(...)`` and ``lance.dataset(...)`` route reads and writes +through the registry-selected store rather than the default built-in scheme +resolver. + +The full Python-to-Rust ``ObjectStoreProvider`` callable bridge is not exercised +here — that is a follow-up. See the module docstring in +``python/src/object_store.rs``. +""" + +import lance +import pyarrow as pa +import pytest +from lance.lance import ( + _ObjectStoreProvider, + _ObjectStoreRegistry, + _Session, +) + + +def _make_registry_and_session(scheme: str) -> tuple[_ObjectStoreRegistry, _Session]: + """Return a fresh registry with ``scheme`` bound to an in-memory provider, + plus a Session that consults it. + """ + registry = _ObjectStoreRegistry() + provider = _ObjectStoreProvider.memory() + registry.register_provider(scheme, provider) + session = _Session(store_registry=registry) + return registry, session + + +def test_custom_scheme_registration_roundtrip(): + """Register ``test-mem`` and round-trip a small table through it. + + Note on lifetimes: ``ObjectStoreRegistry`` caches active stores under + ``Weak``. Every call to ``MemoryStoreProvider::new_store`` + allocates a fresh ``InMemory`` backend, so the writer's dataset handle + must stay alive across the read to keep the same in-memory store visible + from the reader. + """ + _registry, session = _make_registry_and_session("test-mem") + + table = pa.table( + { + "i": pa.array([1, 2, 3, 4, 5], type=pa.int64()), + "s": pa.array(["a", "b", "c", "d", "e"], type=pa.string()), + } + ) + uri = "test-mem://cache/dataset.lance" + + # Keep the write-side dataset alive across the read so the shared + # ObjectStore held by the registry's Weak cache remains upgradeable. + written = lance.write_dataset(table, uri, session=session) + assert written.count_rows() == 5 + + read_ds = lance.dataset(uri, session=session) + round_trip = read_ds.to_table() + + assert round_trip.equals(table), ( + "round-trip through test-mem:// scheme did not match the written table" + ) + assert read_ds.count_rows() == 5 + + +def test_registry_repr_and_reuse(): + """Registry ``__repr__`` reports cache stats, and reusing a registered + scheme with the same params returns a cached store rather than a new one. + """ + registry, session = _make_registry_and_session("test-mem-reuse") + + table = pa.table({"i": pa.array([1, 2, 3], type=pa.int64())}) + uri = "test-mem-reuse://cache/reuse.lance" + + written = lance.write_dataset(table, uri, session=session) + + stats_after_write = repr(registry) + assert "active_stores=" in stats_after_write + assert "hits=" in stats_after_write + assert "misses=" in stats_after_write + + # Re-open the same URI with the same session. This should hit the + # registry's active-stores cache (weak ref still upgradeable because + # ``written`` holds a strong ref). + _reopened = lance.dataset(uri, session=session) + stats_after_reopen = repr(registry) + + # We do not assert exact hit counts — the registry accounts for both the + # write- and read-path resolutions — but the reuse call must not have + # produced additional active-store entries. + _ = stats_after_reopen # kept for post-mortem debugging when running verbose + _ = written # keep the write handle alive until the assertion above passes + + +def test_missing_scheme_raises_helpful_error(): + """A URI whose scheme is neither built-in nor registered must raise, and + the error message must name the missing scheme. + """ + # Fresh registry with nothing custom registered. + session = _Session(store_registry=_ObjectStoreRegistry()) + + with pytest.raises(Exception) as excinfo: # OSError or lance.LanceError + lance.dataset("no-such-scheme://foo/bar.lance", session=session) + + assert "no-such-scheme" in str(excinfo.value), ( + "expected the missing scheme name in the error, got: " + str(excinfo.value) + ) + + +def test_register_provider_rejects_empty_scheme(): + """Empty schemes are rejected at registration time.""" + registry = _ObjectStoreRegistry() + with pytest.raises(ValueError): + registry.register_provider("", _ObjectStoreProvider.memory()) + + +def test_from_capsule_roundtrip(): + """Exercise the ``PyCapsule`` handoff end to end with an in-process producer. + + ``_memory_capsule()`` mirrors what an external, ABI-compatible wheel emits: + a ``PyCapsule`` named ``lance_object_store_provider`` holding an + ``Arc``. ``from_capsule`` must accept it (name + check passes), adopt the provider, and the result must be registrable and + usable for a read/write round-trip — covering the clone-on-adopt and the + capsule's own drop when it is garbage-collected. + """ + registry = _ObjectStoreRegistry() + capsule = _ObjectStoreProvider._memory_capsule() + provider = _ObjectStoreProvider.from_capsule(capsule) + # Drop our reference to the capsule; the adopted Arc must keep the provider + # alive independently of the capsule. + del capsule + registry.register_provider("test-cap", provider) + session = _Session(store_registry=registry) + + table = pa.table({"i": pa.array([1, 2, 3, 4], type=pa.int64())}) + uri = "test-cap://cache/cap.lance" + written = lance.write_dataset(table, uri, session=session) + assert written.count_rows() == 4 + + read_ds = lance.dataset(uri, session=session) + assert read_ds.to_table().equals(table) + + +def test_from_capsule_rejects_wrong_name(): + """A capsule whose name does not match is rejected before its pointer is + ever dereferenced (regression for the ``pointer_checked(None)`` bug, which + rejected *every* correctly-named capsule). + """ + import ctypes + + make = ctypes.pythonapi.PyCapsule_New + make.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_void_p] + make.restype = ctypes.py_object + # A capsule with the wrong name and a dummy pointer. The name check must + # fail first, so the bogus pointer is never read. + storage = (ctypes.c_void_p * 2)() + capsule = make(ctypes.addressof(storage), b"not-a-lance-provider", None) + + with pytest.raises(ValueError): + _ObjectStoreProvider.from_capsule(capsule) diff --git a/python/python/tests/test_datagen.py b/python/python/tests/test_datagen.py index bdc205a3187..219075eaae0 100644 --- a/python/python/tests/test_datagen.py +++ b/python/python/tests/test_datagen.py @@ -7,6 +7,14 @@ import pyarrow as pa import pytest +SCHEMA = pa.schema( + [ + pa.field("int", pa.int64()), + pa.field("vector", pa.list_(pa.float32(), 128)), + ] +) +BYTES_PER_ROW = 8 + 128 * 4 + @pytest.mark.skipif(datagen.is_datagen_supported(), reason="datagen is supported") def test_import_error(): @@ -17,19 +25,29 @@ def test_import_error(): @pytest.mark.skipif(not datagen.is_datagen_supported(), reason="datagen not supported") -def test_rand_batches(): - import lance._datagen as datagen +def test_rand_batches_by_bytes(): + reader = datagen.rand_batches(SCHEMA, batch_size_bytes=16 * 1024, num_batches=10) - schema = pa.schema( - [ - pa.field("int", pa.int64()), - pa.field("vector", pa.list_(pa.float32(), 128)), - ] - ) + batches = list(reader) + assert len(batches) == 10 + for batch in batches: + assert batch.num_rows == math.ceil(16 * 1024 / BYTES_PER_ROW) + assert batch.schema == SCHEMA - batches = datagen.rand_batches(schema, batch_size_bytes=16 * 1024, num_batches=10) - assert len(batches) == 10 +@pytest.mark.skipif(not datagen.is_datagen_supported(), reason="datagen not supported") +@pytest.mark.parametrize("rows_per_batch", [1, 100]) +def test_rand_batches_by_rows(rows_per_batch): + reader = datagen.rand_batches(SCHEMA, rows_per_batch=rows_per_batch, num_batches=3) + + batches = list(reader) + assert len(batches) == 3 for batch in batches: - assert batch.num_rows == math.ceil(16 * 1024 / (129 * 4)) - assert batch.schema == schema + assert batch.num_rows == rows_per_batch + assert batch.schema == SCHEMA + + +@pytest.mark.skipif(not datagen.is_datagen_supported(), reason="datagen not supported") +def test_rand_batches_rejects_both_sizes(): + with pytest.raises(ValueError, match="mutually exclusive"): + datagen.rand_batches(SCHEMA, batch_size_bytes=16 * 1024, rows_per_batch=100) diff --git a/python/python/tests/test_dataset.py b/python/python/tests/test_dataset.py index c5915aad4a3..ba71b562526 100644 --- a/python/python/tests/test_dataset.py +++ b/python/python/tests/test_dataset.py @@ -3,6 +3,7 @@ import base64 import contextlib +import importlib import os import pickle import platform @@ -12,7 +13,7 @@ import uuid from datetime import date, datetime, timedelta from pathlib import Path -from typing import List +from typing import List, Optional from unittest import mock import lance @@ -28,15 +29,23 @@ import pytest from helper import ProgressForTest from lance._dataset.sharded_batch_iterator import ShardedBatchIterator -from lance.commit import CommitConflictError from lance.dataset import LANCE_COMMIT_MESSAGE_KEY, AutoCleanupConfig from lance.debug import format_fragment -from lance.file import stable_version +from lance.file import LanceFileWriter, stable_version from lance.schema import LanceSchema from lance.util import validate_vector_index +BaseModel = pytest.importorskip("pydantic").BaseModel + # Various valid inputs for write_dataset input_schema = pa.schema([pa.field("a", pa.float64()), pa.field("b", pa.int64())]) + + +class _InputModel(BaseModel): + a: float + b: int + + input_data = [ # (schema, data) (None, pa.table({"a": [1.0, 2.0], "b": [20, 30]})), @@ -59,6 +68,8 @@ ).to_batches() ), ), + # Pydantic model instances are auto-converted + (None, [_InputModel(a=1.0, b=20), _InputModel(a=2.0, b=30)]), ] @@ -66,7 +77,138 @@ def test_input_data(tmp_path: Path, schema, data): base_dir = tmp_path / "test" dataset = lance.write_dataset(data, base_dir, schema=schema) - assert dataset.to_table() == input_data[0][1] + expected = input_data[0][1] + # Pydantic model instances derive their schema from the model class, so + # required (non-Optional) fields are correctly non-nullable -- stricter + # than, but castable to, the nullable reference schema below. + assert dataset.to_table().cast(expected.schema) == expected + + +def test_from_pydantic_model(tmp_path: Path): + class UserRecord(BaseModel): + name: str + score: float + + data = [UserRecord(name="alice", score=0.9), UserRecord(name="bob", score=0.8)] + uri = str(tmp_path / "user_record") + ds = lance.LanceDataset.from_pydantic_model(UserRecord, data, uri=uri) + + table = ds.to_table() + assert table.num_rows == 2 + assert table.schema.names == ["name", "score"] + assert table.column("name").to_pylist() == ["alice", "bob"] + assert table.column("score").to_pylist() == [0.9, 0.8] + + +def test_from_pydantic_model_rejects_non_model_class(tmp_path: Path): + uri = str(tmp_path / "not_a_model") + with pytest.raises(TypeError, match="BaseModel subclass"): + lance.LanceDataset.from_pydantic_model(dict, [{"a": 1}], uri=uri) + + +class _OtherRecord(BaseModel): + x: int + + +@pytest.mark.parametrize( + "bad_item", + [{"name": "eve", "score": 1.0}, _OtherRecord(x=1)], + ids=["dict", "different_model"], +) +def test_from_pydantic_model_rejects_invalid_item(tmp_path: Path, bad_item): + """A dict or an instance of a different model slipped into `data` must be + rejected up front with the offending index, rather than either reaching + model_to_dict() and raising an opaque AttributeError (dict case) or being + silently serialized against the first item's schema (different-model + case).""" + + class UserRecord(BaseModel): + name: str + score: float + + data = [UserRecord(name="alice", score=0.9), bad_item] + uri = str(tmp_path / "invalid_item") + with pytest.raises(TypeError, match=r"data\[1\]"): + lance.LanceDataset.from_pydantic_model(UserRecord, data, uri=uri) + + +@pytest.mark.parametrize( + "bad_item", + [{"name": "eve"}, _OtherRecord(x=1)], + ids=["dict", "different_model"], +) +def test_write_dataset_pydantic_list_rejects_invalid_item(tmp_path: Path, bad_item): + """Same guard as from_pydantic_model(), but via the plain write_dataset() + entry point (the _coerce_reader() Pydantic branch in types.py).""" + + class Record(BaseModel): + name: str + + data = [Record(name="alice"), bad_item] + uri = str(tmp_path / "invalid_item_write_dataset") + with pytest.raises(TypeError, match=r"data\[1\]"): + lance.write_dataset(data, uri) + + +def test_from_pydantic_model_rejects_non_list_data(tmp_path: Path): + """A generator (or other non-list iterable) must be rejected up front -- + otherwise it gets drained by item validation and the caller silently + writes a 0-row table instead of getting an error.""" + + class Record(BaseModel): + name: str + + data = (Record(name=n) for n in ["alice", "bob"]) + uri = str(tmp_path / "generator_input") + with pytest.raises(TypeError, match="must be provided as a list"): + lance.LanceDataset.from_pydantic_model(Record, data, uri=uri) + + +class _RecordSubclass(_OtherRecord): + y: int = 0 + + +@pytest.mark.parametrize( + "entry_point", + ["from_pydantic_model", "write_dataset"], +) +def test_pydantic_list_rejects_subclass_instance(tmp_path: Path, entry_point): + """A subclass instance must not be accepted in place of the exact model + class: the schema is derived from the declared model class, but a + subclass instance may carry extra/overridden fields that model_to_dict() + would then try to serialize against that schema.""" + + data = [_OtherRecord(x=1), _RecordSubclass(x=2, y=3)] + uri = str(tmp_path / f"subclass_rejected_{entry_point}") + with pytest.raises(TypeError, match=r"data\[1\].*exactly"): + if entry_point == "from_pydantic_model": + lance.LanceDataset.from_pydantic_model(_OtherRecord, data, uri=uri) + else: + lance.write_dataset(data, uri) + + +def test_write_dataset_pydantic_optional_field_typed_correctly_when_all_none( + tmp_path: Path, +): + """Regression test: passing a list of Pydantic instances directly to + write_dataset() (not via from_pydantic_model()) must still derive the + schema from the model class, not from the batch's row data -- otherwise + an Optional field that happens to be None for every row in the batch + gets typed as Arrow's literal null type instead of its real type.""" + + class Record(BaseModel): + name: str + tag: Optional[str] = None + + data = [Record(name="alice"), Record(name="bob")] + uri = str(tmp_path / "all_none_optional") + dataset = lance.write_dataset(data, uri) + + assert dataset.schema.field("tag").type == pa.string() + assert dataset.schema.field("tag").nullable is True + + dataset.insert([Record(name="carol", tag="vip")]) + assert dataset.to_table().column("tag").to_pylist() == [None, None, "vip"] def test_roundtrip_types(tmp_path: Path): @@ -93,6 +235,25 @@ def test_roundtrip_types(tmp_path: Path): assert dataset.to_table() == table +@pytest.mark.parametrize("data_storage_version", ["legacy", "stable", "2.1"]) +def test_write_zero_dimension_fixed_size_list( + tmp_path: Path, data_storage_version: str +): + # Zero-dimension fixed-size lists must be rejected with a clean error + # instead of a divide-by-zero panic (#5102) + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("vec", pa.list_(pa.float32(), 0)), + ] + ) + table = pa.table({"id": [1], "vec": [[]]}, schema=schema) + with pytest.raises(OSError, match="dimension must be a positive integer"): + lance.write_dataset( + table, tmp_path / "ds.lance", data_storage_version=data_storage_version + ) + + def test_dataset_overwrite(tmp_path: Path): table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) base_dir = tmp_path / "test" @@ -288,18 +449,42 @@ def test_schema_metadata(tmp_path: Path): assert ds.schema.field("b").metadata == {b"thisis": b"b"} +def test_get_version(tmp_path: Path): + lance.write_dataset(pa.table({"a": range(10)}), tmp_path) + lance.write_dataset(pa.table({"a": range(5)}), tmp_path, mode="append") + dataset = lance.dataset(tmp_path) + dataset.delete("a < 3") + + info = dataset.get_version() + assert info == dataset.versions()[-1] + assert info["version"] == dataset.version + assert isinstance(info["timestamp"], datetime) + assert info["metadata"]["total_fragments"] == "2" + assert info["metadata"]["total_rows"] == "9" + assert info["metadata"]["total_deletion_files"] == "2" + assert info["metadata"]["total_deletion_file_rows"] == "6" + + assert dataset.checkout_version(1).get_version() == dataset.versions()[0] + + def test_versions(tmp_path: Path): table1 = pa.Table.from_pylist([{"a": 1, "b": 2}, {"a": 10, "b": 20}]) base_dir = tmp_path / "test" lance.write_dataset(table1, base_dir) - assert len(lance.dataset(base_dir).versions()) == 1 + dataset = lance.dataset(base_dir) + assert len(dataset.versions()) == 1 + assert dataset.version_refs() == [{"version": 1}] + assert dataset.latest_version == dataset.version_refs()[-1]["version"] table2 = pa.Table.from_pylist([{"s": "one"}, {"s": "two"}]) time.sleep(1) lance.write_dataset(table2, base_dir, mode="overwrite") - assert len(lance.dataset(base_dir).versions()) == 2 + dataset = lance.dataset(base_dir) + assert len(dataset.versions()) == 2 + assert dataset.version_refs() == [{"version": 1}, {"version": 2}] + assert dataset.latest_version == dataset.version_refs()[-1]["version"] v1, v2 = lance.dataset(base_dir).versions() assert v1["version"] == 1 @@ -329,6 +514,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)}) @@ -419,18 +609,50 @@ def test_enable_stable_row_ids(tmp_path: Path): assert table_after["_rowaddr"][3].as_py() == (2 << 32) + 3 +@pytest.mark.parametrize("enable_stable_row_ids", [True, False]) +@pytest.mark.parametrize( + "rows", + [[{"a": 1}, {"a": 2}], []], + ids=["non_empty", "empty"], +) +def test_has_stable_row_ids_property(tmp_path: Path, enable_stable_row_ids: bool, rows): + schema = pa.schema([pa.field("a", pa.int64())]) + table = pa.Table.from_pylist(rows, schema=schema) + + path = tmp_path / f"stable_row_ids_{enable_stable_row_ids}_{len(rows)}" + lance.write_dataset( + table, + path, + enable_stable_row_ids=enable_stable_row_ids, + ) + ds = lance.dataset(path) + + assert ds.count_rows() == len(rows) + assert len(ds.get_fragments()) == (0 if len(rows) == 0 else 1) + assert ds.has_stable_row_ids is enable_stable_row_ids + + +def _list_manifests(versions_dir): + # Ignore the version hint file, which is not a manifest. + return [ + name + for name in os.listdir(versions_dir) + if not name.startswith("latest_version_hint") + ] + + def test_v2_manifest_paths(tmp_path: Path): lance.write_dataset( pa.table({"a": range(100)}), tmp_path, enable_v2_manifest_paths=True ) - manifest_path = os.listdir(tmp_path / "_versions") + manifest_path = _list_manifests(tmp_path / "_versions") assert len(manifest_path) == 1 assert re.match(r"\d{20}\.manifest", manifest_path[0]) def test_default_v2_manifest_paths(tmp_path: Path): lance.write_dataset(pa.table({"a": range(100)}), tmp_path) - manifest_path = os.listdir(tmp_path / "_versions") + manifest_path = _list_manifests(tmp_path / "_versions") assert len(manifest_path) == 1 assert re.match(r"\d{20}\.manifest", manifest_path[0]) @@ -440,12 +662,12 @@ def test_v2_manifest_paths_migration(tmp_path: Path): lance.write_dataset( pa.table({"a": range(100)}), tmp_path, enable_v2_manifest_paths=False ) - manifest_path = os.listdir(tmp_path / "_versions") + manifest_path = _list_manifests(tmp_path / "_versions") assert manifest_path == ["1.manifest"] # Migrate to v2 manifest paths lance.dataset(tmp_path).migrate_manifest_paths_v2() - manifest_path = os.listdir(tmp_path / "_versions") + manifest_path = _list_manifests(tmp_path / "_versions") assert len(manifest_path) == 1 assert re.match(r"\d{20}\.manifest", manifest_path[0]) @@ -466,7 +688,15 @@ def test_tag(tmp_path: Path): ds.tags.delete("tag1") ds.tags.create("tag1", 1) + ds.tags.replace_metadata("tag1", {"description": "first tag"}) + tag1_meta = ds.tags.list()["tag1"] + assert tag1_meta["created_at"] is not None + assert isinstance(tag1_meta["created_at"], datetime) + assert tag1_meta["updated_at"] is not None + assert isinstance(tag1_meta["updated_at"], datetime) + assert tag1_meta["created_at"] == tag1_meta["updated_at"] assert len(ds.tags.list()) == 1 + assert ds.tags.list()["tag1"]["metadata"] == {"description": "first tag"} with pytest.raises(ValueError): ds.tags.create("tag1", 1) @@ -500,13 +730,41 @@ def test_tag(tmp_path: Path): ): ds.tags.update("tag3", 1) + tag1_meta = ds.tags.list()["tag1"] + tag1_created_at = tag1_meta["created_at"] + tag1_updated_at = tag1_meta["updated_at"] + assert tag1_created_at is not None + assert tag1_updated_at is not None + ds.tags.replace_metadata("tag1", {"description": "updated tag"}) + ds = lance.dataset(base_dir, "tag1") + assert ds.version == 1 + replaced_tag1_meta = ds.tags.list()["tag1"] + assert replaced_tag1_meta["metadata"] == {"description": "updated tag"} + assert replaced_tag1_meta["updated_at"] == tag1_updated_at + + ds.tags.replace_metadata("tag1", {"owner": "ml-team"}) + replaced_again_tag1_meta = ds.tags.list()["tag1"] + assert replaced_again_tag1_meta["metadata"] == {"owner": "ml-team"} + assert replaced_again_tag1_meta["updated_at"] == tag1_updated_at + ds.tags.update("tag1", 2) + updated_tag1_meta = ds.tags.list()["tag1"] + assert updated_tag1_meta["created_at"] == tag1_created_at + assert updated_tag1_meta["updated_at"] is not None + assert updated_tag1_meta["updated_at"] >= tag1_updated_at + ds = lance.dataset(base_dir, "tag1") + assert ds.version == 2 + assert ds.tags.list()["tag1"]["metadata"] == {"owner": "ml-team"} + + ds.tags.replace_metadata("tag1", {}) ds = lance.dataset(base_dir, "tag1") assert ds.version == 2 + assert ds.tags.list()["tag1"]["metadata"] == {} ds.tags.update("tag1", 1) ds = lance.dataset(base_dir, "tag1") assert ds.version == 1 + assert ds.tags.list()["tag1"]["metadata"] == {} version = ds.tags.get_version("tag1") assert version == 1 @@ -553,6 +811,11 @@ def test_tag_order(tmp_path: Path): tags_asc = ds.tags.list_ordered(order="asc") assert len(tags_asc) == 3 + first_tag = tags_asc[0][1] + assert first_tag["created_at"] is not None + assert isinstance(first_tag["created_at"], datetime) + assert first_tag["updated_at"] is not None + assert isinstance(first_tag["updated_at"], datetime) tag_names_asc = [t[0] for t in tags_asc] assert tag_names_asc == sorted(expected_tags.keys()), ( f"Unexpected ascending order: {tag_names_asc}" @@ -603,6 +866,51 @@ def test_take(tmp_path: Path): assert table2 == table1 +@pytest.mark.parametrize("data_storage_version", ["legacy", "stable"]) +def test_slice(tmp_path: Path, data_storage_version: str): + table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) + base_dir = tmp_path / "test" + lance.write_dataset( + table, base_dir, data_storage_version=data_storage_version, max_rows_per_file=10 + ) + dataset = lance.dataset(base_dir) + + # Equivalent to take(range(start, end)) + assert dataset.slice(10, 20) == dataset.take(list(range(10, 20))) + + # Basic range within a single fragment + assert dataset.slice(5, 8) == table.slice(5, 3) + + # Range spanning multiple fragments + assert dataset.slice(5, 25) == table.slice(5, 20) + + # Skipping entire fragments + assert dataset.slice(50, 75) == table.slice(50, 25) + + # Full dataset + assert dataset.slice(0, 100) == table.slice(0, 100) + + # Empty range (start == end) + assert dataset.slice(10, 10) == table.slice(10, 0) + + # Range extending past the end of the dataset + assert dataset.slice(90, 1000) == table.slice(90, 10) + + # Range entirely past the end of the dataset + assert dataset.slice(100, 110) == table.slice(100, 0) + + # With column projection + assert dataset.slice(10, 20, columns=["a"]) == table.select(["a"]).slice(10, 10) + + # Invalid start + with pytest.raises(ValueError, match="start must be non-negative"): + dataset.slice(-1, 10) + + # end < start + with pytest.raises(ValueError, match="must be >= start"): + dataset.slice(10, 5) + + def test_take_rowid_rowaddr(tmp_path: Path): sample_size = 10 table1 = pa.table({"a": range(1000), "b": range(1000)}) @@ -867,6 +1175,49 @@ def test_take_with_projection(tmp_path: Path): assert table3 == table2 +def test_take_with_json_column(tmp_path: Path): + """Test that take/take_rows return JSON columns in Arrow JSON format (Utf8). + + Previously, take would return lance.json (LargeBinary) instead of + arrow.json (Utf8), which is the user-facing format. + """ + json_type = pa.json_() + data = pa.table( + { + "id": pa.array(range(10), type=pa.int64()), + "meta": pa.array( + [f'{{"val":{i}}}' for i in range(10)], + type=json_type, + ), + } + ) + base_dir = tmp_path / "test_take_json" + lance.write_dataset(data, base_dir) + dataset = lance.dataset(base_dir) + + # Dataset.take should return arrow.json type + result = dataset.take([2, 5, 8]) + meta_field = result.schema.field("meta") + assert meta_field.type == pa.utf8() or meta_field.type == pa.json_(), ( + f"Expected arrow.json (Utf8), got {meta_field.type}" + ) + metas = result.column("meta").to_pylist() + assert metas[0] == '{"val":2}' + assert metas[1] == '{"val":5}' + assert metas[2] == '{"val":8}' + + # Dataset._take_rows should also return arrow.json type + result = dataset._take_rows([0, 3, 9]) + meta_field = result.schema.field("meta") + assert meta_field.type == pa.utf8() or meta_field.type == pa.json_(), ( + f"Expected arrow.json (Utf8), got {meta_field.type}" + ) + metas = result.column("meta").to_pylist() + assert metas[0] == '{"val":0}' + assert metas[1] == '{"val":3}' + assert metas[2] == '{"val":9}' + + def test_filter(tmp_path: Path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" @@ -1168,19 +1519,12 @@ def test_count_rows_via_scanner(tmp_path: Path): ds = lance.write_dataset(pa.table({"a": range(100), "b": range(100)}), tmp_path) assert ds.scanner(filter="a < 50", columns=[], with_row_id=True).count_rows() == 50 - - with pytest.raises( - ValueError, match="should not be called on a plan selecting columns" - ): - ds.scanner(filter="a < 50", columns=["a"], with_row_id=True).count_rows() - - with pytest.raises( - ValueError, match="should not be called on a plan selecting columns" - ): - ds.scanner(with_row_id=True).count_rows() - - with pytest.raises(ValueError, match="with_row_id is false"): - ds.scanner(columns=[]).count_rows() + assert ( + ds.scanner(filter="a < 50", columns=["a"], with_row_id=True).count_rows() == 50 + ) + assert ds.scanner(with_row_id=True).count_rows() == 100 + assert ds.scanner(columns=[]).count_rows() == 100 + assert ds.scanner().count_rows() == 100 def test_select_none(tmp_path: Path): @@ -1276,13 +1620,20 @@ def test_get_fragments(tmp_path: Path): def test_pickle_fragment(tmp_path: Path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" - lance.write_dataset(table, base_dir) + storage_options = {"allow_http": "true"} + lance.write_dataset(table, base_dir, storage_options=storage_options) - dataset = lance.dataset(base_dir) + dataset = lance.dataset(base_dir, storage_options=storage_options) fragment = dataset.get_fragments()[0] - pickled = pickle.dumps(fragment) + with mock.patch.object( + lance.LanceDataset, + "__init__", + side_effect=AssertionError("pickling reopened the dataset"), + ): + pickled = pickle.dumps(fragment) unpickled = pickle.loads(pickled) + assert unpickled._ds._storage_options == storage_options assert fragment.to_table() == unpickled.to_table() @@ -1317,6 +1668,45 @@ def test_cleanup_old_versions(tmp_path): assert stats.old_versions == 1 +def test_explain_cleanup_old_versions(tmp_path): + table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) + base_dir = tmp_path / "test" + lance.write_dataset(table, base_dir) + time.sleep(0.1) + moment = datetime.now() + lance.write_dataset(table, base_dir, mode="overwrite") + + dataset = lance.dataset(base_dir) + before_versions = len(dataset.versions()) + + explanation = dataset.explain_cleanup_old_versions( + older_than=(datetime.now() - moment), + include_files=True, + max_files=1000, + ) + + assert explanation.read_version == dataset.version + assert explanation.stats.bytes_removed > 0 + assert explanation.stats.old_versions == 1 + assert explanation.candidate_files + assert not explanation.candidate_files_truncated + assert len(dataset.versions()) == before_versions + + summary = dataset.explain_cleanup_old_versions(older_than=(datetime.now() - moment)) + assert summary.stats.old_versions == explanation.stats.old_versions + assert summary.candidate_files == [] + + with pytest.raises(ValueError, match="max_files must be positive"): + dataset.explain_cleanup_old_versions( + older_than=(datetime.now() - moment), + max_files=0, + ) + + stats = dataset.cleanup_old_versions(older_than=(datetime.now() - moment)) + assert stats.bytes_removed == explanation.stats.bytes_removed + assert stats.old_versions == explanation.stats.old_versions + + def test_cleanup_error_when_tagged_old_versions(tmp_path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" @@ -1393,12 +1783,38 @@ def test_cleanup_with_retain_versions(tmp_path: Path): ds = lance.write_dataset(table, base_dir, mode="append") assert len(ds.versions()) == 4 + with pytest.raises(OSError, match="retain_versions must be greater than 0, got 0"): + ds.cleanup_old_versions(retain_versions=0) + assert len(ds.versions()) == 4 + stats = ds.cleanup_old_versions(retain_versions=3) assert stats.old_versions == 1 + assert stats.data_files_removed == 1 + assert stats.transaction_files_removed == 1 + assert stats.index_files_removed == 0 + assert stats.deletion_files_removed == 0 assert len(ds.versions()) == 3 assert ds.count_rows() == len(ds.to_table()) +def test_cleanup_specific_versions(tmp_path: Path): + base_dir = tmp_path / "cleanup_specific_versions" + table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) + lance.write_dataset(table, base_dir, mode="create") + time.sleep(0.05) + lance.write_dataset(table, base_dir, mode="overwrite") + time.sleep(0.05) + lance.write_dataset(table, base_dir, mode="overwrite") + time.sleep(0.05) + ds = lance.write_dataset(table, base_dir, mode="append") + + assert [v["version"] for v in ds.versions()] == [1, 2, 3, 4] + + stats = ds.cleanup_old_versions(versions=[2]) + assert stats.old_versions == 1 + assert [v["version"] for v in ds.versions()] == [1, 3, 4] + + def test_cleanup_with_older_than_and_retain_versions(tmp_path: Path): base_dir = tmp_path / "cleanup_policy" table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) @@ -1419,6 +1835,27 @@ def test_cleanup_with_older_than_and_retain_versions(tmp_path: Path): assert ds.count_rows() == len(ds.to_table()) +def _wait_until_latest_version_is_older_than(dataset, older_than_seconds): + latest_timestamp = dataset.versions()[-1]["timestamp"] + threshold = latest_timestamp + timedelta(seconds=older_than_seconds) + deadline = time.monotonic() + older_than_seconds + 1 + + while True: + now = ( + datetime.now(latest_timestamp.tzinfo) + if latest_timestamp.tzinfo is not None + else datetime.now() + ) + remaining = (threshold - now).total_seconds() + if remaining < 0: + return + + timeout_remaining = deadline - time.monotonic() + if timeout_remaining <= 0: + pytest.fail("latest dataset version did not pass the cleanup age threshold") + time.sleep(min(remaining + 0.05, timeout_remaining)) + + def test_auto_cleanup(tmp_path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" @@ -1433,11 +1870,11 @@ def test_auto_cleanup(tmp_path): lance.write_dataset(table, base_dir, mode="append") lance.write_dataset(table, base_dir, mode="append") - time.sleep(5) + dataset = lance.dataset(base_dir) + _wait_until_latest_version_is_older_than(dataset, 1) # trigger cleanup lance.write_dataset(table, base_dir, mode="append") - dataset = lance.dataset(base_dir) assert len(dataset.versions()) == 2 @@ -1452,7 +1889,7 @@ def test_config_update_auto_cleanup(tmp_path): lance.write_dataset(table, base_dir, mode="append") lance.write_dataset(table, base_dir, mode="append") - time.sleep(5) + _wait_until_latest_version_is_older_than(ds, 0.001) # trigger cleanup lance.write_dataset(table, base_dir, mode="append") @@ -1488,12 +1925,13 @@ def test_auto_cleanup_invalid(tmp_path): table, base_dir, auto_cleanup_options=auto_cleanup_options, mode="append" ) - time.sleep(3) + dataset = lance.dataset(base_dir) + assert "lance.auto_cleanup.interval" not in dataset.config() + assert "lance.auto_cleanup.older_than" not in dataset.config() lance.write_dataset( table, base_dir, auto_cleanup_options=auto_cleanup_options, mode="append" ) - dataset = lance.dataset(base_dir) assert len(dataset.versions()) == 4 @@ -1511,7 +1949,7 @@ def test_enable_disable_auto_cleanup(tmp_path): lance.write_dataset(table, base_dir, mode="append") lance.write_dataset(table, base_dir, mode="append") - time.sleep(5) + _wait_until_latest_version_is_older_than(ds, 1) # trigger cleanup lance.write_dataset(table, base_dir, mode="append") @@ -1519,12 +1957,14 @@ def test_enable_disable_auto_cleanup(tmp_path): # this is a transactional commit, so will increase a version ds.optimize.disable_auto_cleanup() + assert "lance.auto_cleanup.interval" not in ds.config() + assert "lance.auto_cleanup.older_than" not in ds.config() lance.write_dataset(table, base_dir, mode="append") lance.write_dataset(table, base_dir, mode="append") lance.write_dataset(table, base_dir, mode="append") - time.sleep(5) + _wait_until_latest_version_is_older_than(ds, 1) # wait to see if cleanup would be trigger lance.write_dataset(table, base_dir, mode="append") @@ -1532,6 +1972,36 @@ def test_enable_disable_auto_cleanup(tmp_path): assert len(ds.versions()) == 7 +def test_cleanup_with_rate_limit(tmp_path): + """Test that cleanup_old_versions works with delete_rate_limit parameter.""" + table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) + base_dir = tmp_path / "test" + + lance.write_dataset(table, base_dir, mode="create") + lance.write_dataset(table, base_dir, mode="overwrite") + lance.write_dataset(table, base_dir, mode="overwrite") + lance.write_dataset(table, base_dir, mode="overwrite") + + dataset = lance.dataset(base_dir) + latest_version_timestamp = dataset.versions()[-1]["timestamp"] + now = ( + datetime.now(latest_version_timestamp.tzinfo) + if latest_version_timestamp.tzinfo is not None + else datetime.now() + ) + + start = time.time_ns() + # Cleanup with a rate limit should still remove old versions correctly + stats = dataset.cleanup_old_versions( + older_than=(now - latest_version_timestamp), delete_rate_limit=1 + ) + finished = time.time_ns() + + assert stats.old_versions == 3 + assert stats.bytes_removed > 0 + assert (finished - start) >= 2_000_000_000 # 2s + + def test_create_from_commit(tmp_path: Path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" @@ -1555,10 +2025,57 @@ def test_strict_overwrite(tmp_path: Path): ) with pytest.raises( OSError, match=f"Commit conflict for version {dataset_v1.version + 1}" - ): + ) as exc_info: lance.LanceDataset.commit( base_dir, operation, read_version=dataset_v1.version, max_retries=0 ) + # CommitConflict means commit-step retries were exhausted; it is safe to retry. + assert exc_info.value.retryable is True + + +def test_commit_timeout(tmp_path: Path): + from datetime import timedelta + + table = pa.Table.from_pydict({"a": range(10)}) + base_dir = tmp_path / "timeout" + dataset = lance.write_dataset(table, base_dir) + + fragment = lance.fragment.LanceFragment.create(base_dir, table) + append = lance.LanceOperation.Append([fragment]) + + # A zero duration reaches Rust and is rejected as invalid input. + with pytest.raises(OSError, match="non-zero"): + lance.LanceDataset.commit( + dataset, append, read_version=1, commit_timeout=timedelta(0) + ) + + # A negative duration is rejected by PyO3's timedelta -> Duration conversion. + with pytest.raises(ValueError): + lance.LanceDataset.commit( + dataset, append, read_version=1, commit_timeout=timedelta(seconds=-1) + ) + + # None disables the timeout. + dataset_no_timeout = lance.LanceDataset.commit( + dataset, append, read_version=1, commit_timeout=None + ) + assert dataset_no_timeout.version == dataset.version + 1 + + # Explicit positive timeout works. + fragment2 = lance.fragment.LanceFragment.create(base_dir, table) + append2 = lance.LanceOperation.Append([fragment2]) + dataset_with_timeout = lance.LanceDataset.commit( + dataset_no_timeout, + append2, + read_version=dataset_no_timeout.version, + commit_timeout=timedelta(minutes=1), + ) + assert dataset_with_timeout.version == dataset_no_timeout.version + 1 + + # Timeout *firing* behavior is covered by the Rust test + # `test_commit_timeout_triggers`, which uses a throttled store for a + # reliable trigger; reproducing it from Python without exposing + # throttling would be flaky on fast runners. def test_append_with_commit(tmp_path: Path): @@ -1603,6 +2120,7 @@ def test_commit_batch_append(): result = lance.LanceDataset.commit_batch(dataset, [txn2, txn3]) dataset = result["dataset"] assert dataset.version == 2 + assert dataset.checkout_version(1).version == 1 assert len(dataset.get_fragments()) == 3 assert dataset.to_table() == pa.concat_tables([data1, data2, data3]) merged_txn = result["merged"] @@ -1682,6 +2200,52 @@ def test_merge_insert_with_commit(): ) +def test_update_with_commit_updated_fragment_offsets(): + table = pa.table({"id": range(10), "updated": [False] * 10}) + dataset = lance.write_dataset(table, "memory://test") + + updates = pa.Table.from_pylist([{"id": 1, "updated": True}]) + transaction, _ = ( + dataset.merge_insert(on="id") + .when_matched_update_all() + .execute_uncommitted(updates) + ) + assert transaction.operation.updated_fragment_offsets is None + + # Portable RoaringBitmap serializations of {1, 3, 5, 7} and {0, 2, 100000}. + offsets = { + 0: ( + b"\x3a\x30\x00\x00\x01\x00\x00\x00\x00\x00\x03\x00" + b"\x10\x00\x00\x00\x01\x00\x03\x00\x05\x00\x07\x00" + ), + 2: ( + b"\x3a\x30\x00\x00\x02\x00\x00\x00\x00\x00\x01\x00\x01\x00\x00" + b"\x00\x18\x00\x00\x00\x1c\x00\x00\x00\x00\x00\x02\x00\xa0\x86" + ), + } + transaction.operation.updated_fragment_offsets = offsets + + dataset = lance.LanceDataset.commit(dataset, transaction) + read_back = dataset.read_transaction(dataset.version) + assert read_back.operation.updated_fragment_offsets == offsets + + +def test_update_with_commit_rejects_invalid_offset_bytes(): + table = pa.table({"id": range(10), "updated": [False] * 10}) + dataset = lance.write_dataset(table, "memory://test") + + updates = pa.Table.from_pylist([{"id": 1, "updated": True}]) + transaction, _ = ( + dataset.merge_insert(on="id") + .when_matched_update_all() + .execute_uncommitted(updates) + ) + transaction.operation.updated_fragment_offsets = {0: b"not a bitmap"} + + with pytest.raises(ValueError, match="RoaringBitmap"): + lance.LanceDataset.commit(dataset, transaction) + + def test_merge_with_commit(tmp_path: Path): table = pa.Table.from_pydict({"a": range(100), "b": range(100)}) base_dir = tmp_path / "test" @@ -1705,22 +2269,84 @@ def test_merge_with_commit(tmp_path: Path): assert tbl == expected -def test_merge_with_schema_holes(tmp_path: Path): - # Create table with 3 cols - table = pa.table({"a": range(10)}) - dataset = lance.write_dataset(table, tmp_path) - dataset.add_columns({"b": "a + 1"}) - dataset.add_columns({"c": "a + 2"}) - # Delete the middle column to create a hole in the field ids - dataset.drop_columns(["b"]) +@pytest.mark.parametrize( + ("delete_predicate", "expected_ids"), + [ + pytest.param("id < 50", list(range(50, 150)), id="leading"), + pytest.param( + "id >= 50 AND id < 100", + list(range(50)) + list(range(100, 150)), + id="middle", + ), + pytest.param("id >= 100", list(range(100)), id="trailing"), + ], +) +def test_merge_columns_with_deleted_batch_commit( + tmp_path: Path, delete_predicate: str, expected_ids: list +): + # A fully deleted read batch must still contribute its rows to the new data + # file, otherwise the fragment's data files disagree on the physical row + # count. The deleted run is placed at the start, middle, and end because the + # updater can only borrow a placeholder row from a batch that has live rows. + base_dir = tmp_path / "test" + table = pa.table({"id": range(150), "value": range(150)}) + dataset = lance.write_dataset(table, base_dir, max_rows_per_file=200) - fragment = dataset.get_fragments()[0] - merged, schema = fragment.merge_columns( - lambda _: pa.RecordBatch.from_pydict({"d": range(10, 20)}) - ) + dataset.delete(delete_predicate) + assert dataset.count_rows() == 100 - merge = lance.LanceOperation.Merge([merged], schema) - dataset = lance.LanceDataset.commit(tmp_path, merge, read_version=dataset.version) + merged_frags = [] + schema = None + for frag in dataset.get_fragments(): + live_ids = frag.scanner(columns=["id"]).to_table()["id"].to_pylist() + right_table = pa.table( + {"merged": pa.array([row_id * 10 for row_id in live_ids], pa.int64())}, + schema=pa.schema([pa.field("merged", pa.int64(), nullable=False)]), + ) + merged, schema = frag.merge_columns(right_table, batch_size=50) + merged_frags.append(merged) + + dataset = lance.LanceDataset.commit( + dataset.uri, + lance.LanceOperation.Merge(merged_frags, schema), + read_version=dataset.version, + ) + dataset.validate() + + assert dataset.to_table() == pa.table( + { + "id": expected_ids, + "value": expected_ids, + "merged": [row_id * 10 for row_id in expected_ids], + }, + schema=pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("value", pa.int64()), + # The blanks written for the deleted rows are copies of a live row, + # so the merged column stays non-nullable end to end. + pa.field("merged", pa.int64(), nullable=False), + ] + ), + ) + + +def test_merge_with_schema_holes(tmp_path: Path): + # Create table with 3 cols + table = pa.table({"a": range(10)}) + dataset = lance.write_dataset(table, tmp_path) + dataset.add_columns({"b": "a + 1"}) + dataset.add_columns({"c": "a + 2"}) + # Delete the middle column to create a hole in the field ids + dataset.drop_columns(["b"]) + + fragment = dataset.get_fragments()[0] + merged, schema = fragment.merge_columns( + lambda _: pa.RecordBatch.from_pydict({"d": range(10, 20)}) + ) + + merge = lance.LanceOperation.Merge([merged], schema) + dataset = lance.LanceDataset.commit(tmp_path, merge, read_version=dataset.version) dataset.validate() @@ -1883,8 +2509,13 @@ def test_deletion_file(tmp_path: Path): assert re.match( "_deletions/0-1-[0-9]{1,32}.arrow", new_fragment.deletion_file.path(0) ) - operation = lance.LanceOperation.Overwrite(table.schema, [new_fragment]) - dataset = lance.LanceDataset.commit(base_dir, operation) + # Delete, not Overwrite: the deletion file belongs to fragment 0 of this + # dataset, and an overwrite's fragments are newly written ones that get fresh + # ids, which a deletion file cannot follow. + operation = lance.LanceOperation.Delete([new_fragment], [], "a < 10") + dataset = lance.LanceDataset.commit( + base_dir, operation, read_version=dataset.version + ) assert dataset.count_rows() == 90 @@ -1940,7 +2571,7 @@ def test_merge_data_legacy(tmp_path: Path): # rejects partial data for non-nullable types new_tab = pa.table({"a": range(40), "c": range(40)}) - with pytest.raises(OSError, match=r"Join produced null values for type: Int64"): + with pytest.raises(OSError, match=r"Column 'c' has null values of type: Int64"): dataset.merge(new_tab, "a") @@ -2049,6 +2680,38 @@ def test_delete_data(tmp_path: Path): assert dataset.count_rows() == 0 +@pytest.mark.parametrize( + "predicate, remaining_ids", + [ + pytest.param(pc.field("s") != "C:\\temp", [0, 3], id="escaped_string"), + pytest.param( + pc.field("s").isin(["C:\\temp", 'say "hi"']), + [2, 3], + id="isin_and_quote", + ), + pytest.param(~(pc.field("s") == "plain"), [2, 3], id="negation"), + pytest.param(pc.field("s").is_null(), [0, 1, 2], id="is_null"), + ], +) +def test_delete_pyarrow_expression(tmp_path: Path, predicate, remaining_ids): + data = pa.table( + { + "id": [0, 1, 2, 3], + "vec": pa.array( + [[0.0], [1.0], [2.0], [3.0]], type=pa.list_(pa.float32(), 1) + ), + "s": ["C:\\temp", 'say "hi"', "plain", None], + } + ) + dataset = lance.write_dataset(data, tmp_path, max_rows_per_file=2) + assert len(dataset.get_fragments()) == 2 + + deleted_rows = len(data) - len(remaining_ids) + assert dataset.count_rows(filter=predicate) == deleted_rows + assert dataset.delete(predicate) == {"num_deleted_rows": deleted_rows} + assert sorted(dataset.to_table()["id"].to_pylist()) == remaining_ids + + def check_merge_stats(merge_dict, expected): assert ( merge_dict["num_inserted_rows"], @@ -2172,6 +2835,29 @@ def test_merge_insert(tmp_path: Path): check_merge_stats(merge_dict, (None, None, None)) +@pytest.mark.parametrize("materialized", [True, False]) +def test_merge_insert_input_kinds(tmp_path: Path, materialized: bool): + # A materialized pa.Table is routed through the in-memory (MemTable) path, + # while a RecordBatchReader is routed through the streaming path. Both must + # produce identical results. + schema = pa.schema([pa.field("id", pa.int64()), pa.field("value", pa.int64())]) + base = pa.table({"id": range(5), "value": [0] * 5}, schema=schema) + new = pa.table({"id": [1, 2, 5, 6], "value": [10, 20, 50, 60]}, schema=schema) + + dataset = lance.write_dataset(base, tmp_path / "dataset", mode="create") + source = new if materialized else new.to_reader() + + dataset.merge_insert( + "id" + ).when_matched_update_all().when_not_matched_insert_all().execute(source) + + result = dataset.to_table().sort_by("id").to_pydict() + assert result == { + "id": [0, 1, 2, 3, 4, 5, 6], + "value": [0, 10, 20, 0, 0, 50, 60], + } + + def test_merge_insert_subcols(tmp_path: Path): initial_data = pa.table( { @@ -2184,7 +2870,6 @@ def test_merge_insert_subcols(tmp_path: Path): dataset = lance.write_dataset( initial_data, tmp_path / "dataset", max_rows_per_file=5 ) - original_fragments = dataset.get_fragments() new_values = pa.table( { @@ -2194,6 +2879,12 @@ def test_merge_insert_subcols(tmp_path: Path): ) (dataset.merge_insert("a").when_matched_update_all().execute(new_values)) + # Partial-schema upserts run through the v2 `FullSchemaMergeInsertExec` + # path (see Rust issue #6442). We only assert the semantic outcome here + # — the exact fragment / file layout is an implementation detail of the + # v1 RewriteColumns optimization that no longer applies. Column `c` + # (not in the source) must retain its original values for the updated + # rows. expected = pa.table( { "a": range(10), @@ -2203,26 +2894,155 @@ def test_merge_insert_subcols(tmp_path: Path): ) assert dataset.to_table().sort_by("a") == expected - # First fragment has new file - fragments = dataset.get_fragments() - assert fragments[0].fragment_id == original_fragments[0].fragment_id - assert fragments[1].fragment_id == original_fragments[1].fragment_id + new_values = pa.table( + { + "a": range(9, 12), + "b": range(30, 33), + } + ) + ( + dataset.merge_insert("a") + .when_not_matched_insert_all() + .when_matched_update_all() + .execute(new_values) + ) + + assert dataset.count_rows() == 12 + # Newly inserted rows (keys 10, 11) get NULL for column `c` because + # the source did not provide a value for it and `c` is nullable. + expected = pa.table( + { + "a": range(0, 12), + "b": [0, 1, 2, 20, 21, 5, 6, 7, 8, 30, 31, 32], + "c": list(range(10, 20)) + [None] * 2, + } + ) + assert dataset.to_table().sort_by("a") == expected + - assert len(fragments[0].data_files()) == 2 - assert ( - fragments[0].data_files()[0].path == original_fragments[0].data_files()[0].path +@pytest.mark.parametrize("container", ["struct", "list"]) +def test_merge_insert_subcols_preserves_nested_blob(tmp_path: Path, container: str): + blob_field = lance.blob_field("blob") + blob_values = lance.blob_array([b"one", b"two"]) + if container == "struct": + nested_values = pa.StructArray.from_arrays( + [blob_values], + fields=[blob_field], + ) + expected_nested = [{"blob": b"one"}, {"blob": b"two"}] + else: + nested_values = pa.ListArray.from_arrays( + pa.array([0, 1, 2], type=pa.int32()), + blob_values, + type=pa.list_(blob_field), + ) + expected_nested = [[b"one"], [b"two"]] + + dataset_uri = tmp_path / f"partial_nested_blob_{container}" + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array([1, 2]), + "nested": nested_values, + "other": pa.array([10, 20]), + } + ), + dataset_uri, + data_storage_version="2.2", + ) + source = pa.table({"id": pa.array([2]), "other": pa.array([200])}) + + dataset.merge_insert("id").when_matched_update_all().execute(source) + + result = ( + lance.dataset(dataset_uri).to_table(blob_handling="all_binary").sort_by("id") + ) + assert result["other"].to_pylist() == [10, 200] + assert result["nested"].to_pylist() == expected_nested + + +@pytest.mark.parametrize( + "base_version,patch_version", + [("2.0", "2.2"), ("2.2", "2.0"), ("2.1", "2.3"), ("2.3", "2.1")], +) +def test_merge_insert_subcols_in_place(tmp_path: Path, base_version, patch_version): + """`write_mode("rewrite_columns")` patches the source columns into the + fragments that already hold the matched rows. + + Compare with `test_merge_insert_subcols`, which runs the same merge in the + default `"auto"` mode and gets whole rows rewritten into a new fragment + instead. + """ + initial_data = pa.table( + { + "a": range(10), + "b": range(10), + "c": range(10, 20), + } + ) + # Split across two fragments + dataset = lance.write_dataset( + initial_data, + tmp_path / "dataset", + max_rows_per_file=5, + data_storage_version=base_version, + ) + fragments_before = [f.fragment_id for f in dataset.get_fragments()] + + new_values = pa.table( + { + "a": range(3, 5), + "b": range(20, 22), + } + ) + ( + dataset.merge_insert("a") + .when_matched_update_all() + .write_mode("rewrite_columns") + .data_storage_version(patch_version) + .execute(new_values) + ) + + # No fragment is added, removed, or renumbered, and column `c` (absent from + # the source) is neither read nor written. + assert [f.fragment_id for f in dataset.get_fragments()] == fragments_before + expected = pa.table( + { + "a": range(10), + "b": [0, 1, 2, 20, 21, 5, 6, 7, 8, 9], + "c": range(10, 20), + } ) - assert len(fragments[1].data_files()) == 1 - assert str(fragments[1].data_files()[0]) == str( - original_fragments[1].data_files()[0] + dataset = lance.dataset(dataset.uri) + assert { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } == {base_version, patch_version} + assert dataset.to_table().sort_by("a") == expected + assert dataset.to_table(columns=["c", "b", "a"], filter="b >= 20") == ( + expected.filter(pc.greater_equal(expected["b"], 20)).select(["c", "b", "a"]) ) + assert dataset.take([9, 3, 0, 4, 3]) == expected.take([9, 3, 0, 4, 3]) + # Patching columns cannot add rows, so asking for it explicitly on a merge + # that also inserts is rejected rather than quietly rewriting whole rows. new_values = pa.table( { "a": range(9, 12), "b": range(30, 33), } ) + with pytest.raises(OSError, match="adds rows, which patching cannot do"): + ( + dataset.merge_insert("a") + .when_not_matched_insert_all() + .when_matched_update_all() + .write_mode("rewrite_columns") + .execute(new_values) + ) + + # The same merge under the default mode picks the row-rewrite sink. ( dataset.merge_insert("a") .when_not_matched_insert_all() @@ -2241,6 +3061,146 @@ def test_merge_insert_subcols(tmp_path: Path): assert dataset.to_table().sort_by("a") == expected +def test_merge_insert_full_fragment_rewrite_json_e2e(tmp_path: Path): + """End-to-end test: merge_insert with JSON columns where ALL rows are updated. + + This exercises the "all rows updated" fast path in handle_fragment which + bypasses the Updater and writes directly. Without proper JSON conversion, + Utf8 data (i32 offsets) is written with a LargeBinary schema (i64 offsets), + causing a decoder panic on subsequent reads: + "the offset of the new Buffer cannot exceed the existing Length: + slice offset=0 Length=N selfLen=N/2" + + Conditions to trigger the fast path: + 1. Subschema update (not all columns provided) + 2. ALL rows in a fragment are matched/updated + 3. data_storage_version = 2.2 + """ + import json + + json_type = pa.json_() + + # Create dataset with v2.2 storage format + initial_data = pa.table( + { + "unique_id": pa.array([f"id_{i}" for i in range(5)], type=pa.utf8()), + "coarse": pa.array( + [f'{{"original":{i}}}' for i in range(5)], + type=json_type, + ), + "score": pa.array(range(5), type=pa.int64()), + } + ) + dataset = lance.write_dataset( + initial_data, + tmp_path / "e2e_json_rewrite", + data_storage_version="2.2", + ) + assert dataset.count_rows() == 5 + assert len(dataset.get_fragments()) == 1 + + # Subschema merge_insert: only provide [unique_id, coarse], update ALL rows + # This triggers: subschema → v1 path → all rows matched → fast path + update_data = pa.table( + { + "unique_id": pa.array([f"id_{i}" for i in range(5)], type=pa.utf8()), + "coarse": pa.array( + [f'{{"updated":true,"id":{i}}}' for i in range(5)], + type=json_type, + ), + } + ) + dataset.merge_insert("unique_id").when_matched_update_all().execute(update_data) + + # Critical: read back should NOT panic + result = dataset.to_table().sort_by("unique_id") + assert result.num_rows == 5 + + # Verify score column (not in update) is preserved + scores = result.column("score").to_pylist() + assert scores == [0, 1, 2, 3, 4] + + # Verify JSON column was updated correctly + coarse_values = result.column("coarse").to_pylist() + for i, val in enumerate(coarse_values): + parsed = json.loads(val) if isinstance(val, str) else val + assert parsed.get("updated") is True, ( + f"Row {i} should have updated coarse, got {val}" + ) + + # Verify take also works (different read path) + take_result = dataset.take([0, 2, 4]) + assert take_result.num_rows == 3 + take_coarse = take_result.column("coarse").to_pylist() + for val in take_coarse: + parsed = json.loads(val) if isinstance(val, str) else val + assert parsed.get("updated") is True + + # Verify sample also works + sample_result = dataset.sample(3) + assert sample_result.num_rows == 3 + + +def test_merge_insert_subcols_with_json_column(tmp_path: Path): + """Test merge_insert with subschema update on a JSON extension type column. + + Previously this would fail with: + 'Incorrect datatype for StructArray field, expected Utf8 got LargeBinary' + because the update_fragments path didn't handle the Arrow JSON ↔ Lance JSON + type mismatch during interleave. + """ + import json + + json_type = pa.json_() + initial_data = pa.table( + { + "id": pa.array([1, 2, 3, 4, 5], type=pa.int64()), + "name": pa.array(["a", "b", "c", "d", "e"], type=pa.utf8()), + "score": pa.array([10, 20, 30, 40, 50], type=pa.int64()), + "meta": pa.array( + ['{"x":1}', '{"x":2}', '{"x":3}', '{"x":4}', '{"x":5}'], + type=json_type, + ), + } + ) + dataset = lance.write_dataset(initial_data, tmp_path / "merge_json_subcols") + + # Subschema update: only provide id (key) + meta (JSON column to update) + new_values = pa.table( + { + "id": pa.array([2, 4], type=pa.int64()), + "meta": pa.array( + ['{"updated":true,"id":2}', '{"updated":true,"id":4}'], + type=json_type, + ), + } + ) + + # This should NOT raise a type mismatch error + dataset.merge_insert("id").when_matched_update_all().execute(new_values) + + # Verify results + result = dataset.to_table().sort_by("id") + ids = result.column("id").to_pylist() + scores = result.column("score").to_pylist() + metas = result.column("meta").to_pylist() + + # Score column (not in update) should be preserved + assert scores == [10, 20, 30, 40, 50] + + # Meta column should be updated for id=2 and id=4 + for id_val, meta_val in zip(ids, metas): + parsed = json.loads(meta_val) if isinstance(meta_val, str) else meta_val + if id_val in (2, 4): + assert parsed.get("updated") is True, ( + f"id={id_val} should have updated meta, got {meta_val}" + ) + else: + assert "x" in str(parsed), ( + f"id={id_val} should have original meta, got {meta_val}" + ) + + def test_merge_insert_defaults_to_pk_when_on_omitted(tmp_path): base_dir = tmp_path / "merge_insert_pk_default" @@ -2459,6 +3419,42 @@ def test_merge_insert_multiple_keys(tmp_path: Path): check_merge_stats(merge_dict, (0, 350, 0)) +def test_indexed_merge_insert_deduplicates_cross_batch_candidates(tmp_path: Path): + target = pa.table( + { + "a": [1, 1, 2, 2], + "b": [10, 20, 10, 20], + "value": [110, 120, 210, 220], + } + ) + dataset = lance.write_dataset(target, tmp_path / "dataset") + dataset.create_scalar_index("a", "BTREE") + dataset.create_scalar_index("b", "BTREE") + + first = pa.RecordBatch.from_pydict( + {"a": [1], "b": [10], "value": [901]}, schema=target.schema + ) + # The per-column index probe for this batch also reaches (1, 10), which + # was already emitted for the first batch. The exact join filters that + # over-match, but the indexed scan must not read the target row twice. + second = pa.RecordBatch.from_pydict( + {"a": [1, 2], "b": [20, 10], "value": [902, 903]}, + schema=target.schema, + ) + source = pa.RecordBatchReader.from_batches(target.schema, [first, second]) + + stats = dataset.merge_insert(["a", "b"]).when_matched_update_all().execute(source) + + check_merge_stats(stats, (0, 3, 0)) + assert ( + dataset.to_table().sort_by([("a", "ascending"), ("b", "ascending")]).to_pydict() + ) == { + "a": [1, 1, 2, 2], + "b": [10, 20, 10, 20], + "value": [901, 902, 903, 220], + } + + def test_merge_insert_vector_column(tmp_path: Path): table = pa.Table.from_pydict( { @@ -2658,6 +3654,74 @@ def test_merge_insert_large(): ) +def test_merge_insert_large_rows(): + # Verify that merge_insert succeeds when individual batches exceed the + # DataFusion memory pool size (100 MiB by default). DataFusion's sort + # cannot spill a single batch that is larger than the pool, so the + # HardCapBatchSizeExec node must rechunk before the sort. + # + # The sort path (update_fragments) is only used for partial/subcolumn + # updates, so the source must have a subset of the target columns. + # + # One batch of ~400 MiB which exceeds the 300 MiB memory pool. + # Without HardCapBatchSizeExec rechunking it before the sort, the sort + # would fail because it cannot handle a single batch larger than the pool. + os.environ["LANCE_MEM_POOL_SIZE"] = str(1024 * 1024 * 1024) + rows_per_batch = 2000 + num_batches = 1 + nrows = rows_per_batch * num_batches + # ~200 KiB per row → ~400 MiB per batch + blob_size = 200 * 1024 + + # Create dataset with id, blob, and an extra column so the source is partial. + full_schema = pa.schema( + [("id", pa.int64()), ("blob", pa.large_binary()), ("extra", pa.int32())] + ) + + def make_full_batches(): + for i in range(num_batches): + start = i * rows_per_batch + ids = pa.array(range(start, start + rows_per_batch), type=pa.int64()) + blobs = pa.array( + [b"x" * blob_size] * rows_per_batch, type=pa.large_binary() + ) + extras = pa.array(range(start, start + rows_per_batch), type=pa.int32()) + yield pa.record_batch([ids, blobs, extras], schema=full_schema) + + reader = pa.RecordBatchReader.from_batches(full_schema, make_full_batches()) + ds = lance.write_dataset(reader, "memory://") + + # Partial update: only update the blob column (omit extra). + # This triggers the update_fragments path which uses the sort. + partial_schema = pa.schema([("id", pa.int64()), ("blob", pa.large_binary())]) + + def make_partial_batches(): + for i in range(num_batches): + start = i * rows_per_batch + ids = pa.array(range(start, start + rows_per_batch), type=pa.int64()) + blobs = pa.array( + [b"y" * blob_size] * rows_per_batch, type=pa.large_binary() + ) + yield pa.record_batch([ids, blobs], schema=partial_schema) + + new_reader = pa.RecordBatchReader.from_batches( + partial_schema, make_partial_batches() + ) + + stats = ds.merge_insert(on="id").when_matched_update_all().execute(new_reader) + + assert stats["num_updated_rows"] == nrows + assert stats["num_inserted_rows"] == 0 + + result = ds.to_table() + assert result.num_rows == nrows + # Spot-check that the blobs were updated and extra column preserved. + assert result.column("blob")[0].as_py() == b"y" * blob_size + assert result.column("extra")[0].as_py() == 0 + + del os.environ["LANCE_MEM_POOL_SIZE"] + + def test_merge_insert_empty_index(): # Reported in https://github.com/lancedb/lancedb/issues/2285 empty_table = pa.table({"id": pa.array([], type=pa.float64())}) @@ -2727,6 +3791,42 @@ def test_merge_insert_explain_analyze_plan(): assert "num_files_written" in analysis +def test_merge_insert_analyze_plan_matches_execute_routing(): + """analyze_plan must report the plan the given source would actually run. + + execute() wraps a materialized source in an in-memory table, which reports + exact statistics; a stream reports none. DataFusion picks the collected side of + the join from those statistics and from the two sides' sizes, so the same merge + plans differently depending on which one it is handed. analyze_plan used to + coerce every input to a stream, so it reported the stream's plan whatever it + was given. + """ + data = pa.table({"id": range(64), "value": [i * 10 for i in range(64)]}) + dataset = lance.write_dataset(data, "memory://test-merge-analyze-routing") + + def builder(): + return ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + ) + + # Two source rows against the target's 64 keeps the source the smaller side, + # which is what lets the join collect it. Raise it above 64 and the join + # collects the target instead and the join type stays Right. + source = pa.table({"id": [1, 100], "value": [999, 999]}) + + materialized = builder().analyze_plan(source) + assert "DataSourceExec" in materialized, materialized + assert "StreamingTableExec" not in materialized, materialized + assert "join_type=Left" in materialized, materialized + + streaming = builder().analyze_plan(source.to_reader()) + assert "StreamingTableExec" in streaming, streaming + assert "DataSourceExec" not in streaming, streaming + assert "join_type=Right" in streaming, streaming + + def test_merge_insert_use_index(): """Test that use_index parameter controls whether indices are used.""" data = pa.table({"id": range(100), "value": [i * 10 for i in range(100)]}) @@ -2935,29 +4035,147 @@ def test_update_dataset(tmp_path: Path): check_update_stats(update_dict, (100,)) -def test_update_dataset_all_types(tmp_path: Path): - table = pa.table( +@pytest.mark.parametrize( + "where", + [ + pytest.param(pc.field("p") == "C:\\data\\a.csv", id="escaped_string"), + pytest.param(pc.field("p").isin(["C:\\data\\a.csv"]), id="isin"), + ], +) +def test_update_pyarrow_expression(tmp_path: Path, where): + data = pa.table( + {"id": [0, 1, 2], "p": ["C:\\data\\a.csv", "x", "y"], "n": [0, 0, 0]} + ) + dataset = lance.write_dataset(data, tmp_path, max_rows_per_file=2) + assert len(dataset.get_fragments()) == 2 + + assert dataset.update({"n": "1"}, where=where) == {"num_rows_updated": 1} + assert dataset.to_table().sort_by("id")["n"].to_pylist() == [1, 0, 0] + + +def test_update_dataset_scanner_after_stable_row_id_update(tmp_path: Path): + dataset = lance.write_dataset( + pa.table( + { + "name_1": pa.array(["1", "4", "7"]), + "name_2": pa.array(["2", "5", "8"]), + "name_3": pa.array(["3", "6", "9"]), + } + ), + tmp_path / "dataset", + enable_stable_row_ids=True, + ) + + update_dict = dataset.update(updates=dict(name_3="'xxxx'"), where="name_1 = '7'") + check_update_stats(update_dict, (1,)) + + expected = pa.table( { - "int32": pa.array([1], pa.int32()), - "int64": pa.array([1], pa.int64()), - "uint32": pa.array([1], pa.uint32()), - "string": pa.array(["foo"], pa.string()), - "large_string": pa.array(["foo"], pa.large_string()), - "float32": pa.array([1.0], pa.float32()), - "float64": pa.array([1.0], pa.float64()), - "bool": pa.array([True], pa.bool_()), - "date32": pa.array([date(2021, 1, 1)], pa.date32()), - "timestamp_ns": pa.array([datetime(2021, 1, 1)], pa.timestamp("ns")), - "timestamp_ms": pa.array([datetime(2021, 1, 1)], pa.timestamp("ms")), - "vec_f32": pa.array([[1.0, 2.0]], pa.list_(pa.float32(), 2)), - "vec_f64": pa.array([[1.0, 2.0]], pa.list_(pa.float64(), 2)), + "name_1": pa.array(["1", "4", "7"]), + "name_2": pa.array(["2", "5", "8"]), + "name_3": pa.array(["3", "6", "xxxx"]), } ) + actual = dataset.to_table().sort_by("name_1") + assert actual == expected - dataset = lance.write_dataset(table, tmp_path) + scanner_table = dataset.scanner(limit=10).to_table().sort_by("name_1") + assert scanner_table == expected + assert scanner_table == actual - # One update with all matching types - update_dict = dataset.update( + +def test_update_by_rowid(tmp_path: Path): + nrows = 30 + table = pa.table( + { + "id": pa.array(range(nrows), pa.int64()), + "name": pa.array(["foo"] * nrows, pa.string()), + } + ) + dataset = lance.write_dataset( + table, + tmp_path / "dataset", + max_rows_per_file=10, + enable_stable_row_ids=True, + ) + + orig = dataset.to_table(columns=["id"], with_row_id=True) + target_idx = 5 + target_row_id = orig.column("_rowid")[target_idx].as_py() + target_id = orig.column("id")[target_idx].as_py() + + update_dict = dataset.update( + updates=dict(name="'updated'"), + where=f"_rowid = {target_row_id}", + ) + check_update_stats(update_dict, (1,)) + + actual = dataset.to_table().sort_by("id") + for row in actual.to_pylist(): + if row["id"] == target_id: + assert row["name"] == "updated" + else: + assert row["name"] == "foo" + + +def test_update_by_rowid_in_list(tmp_path: Path): + nrows = 30 + table = pa.table( + { + "id": pa.array(range(nrows), pa.int64()), + "name": pa.array(["foo"] * nrows, pa.string()), + } + ) + dataset = lance.write_dataset( + table, + tmp_path / "dataset", + max_rows_per_file=10, + enable_stable_row_ids=True, + ) + + orig = dataset.to_table(columns=["id"], with_row_id=True) + target_indices = [3, 7, 15] + target_row_ids = [orig.column("_rowid")[i].as_py() for i in target_indices] + target_ids = {orig.column("id")[i].as_py() for i in target_indices} + + in_list = ", ".join(str(rid) for rid in target_row_ids) + update_dict = dataset.update( + updates=dict(name="'updated'"), + where=f"_rowid IN ({in_list})", + ) + check_update_stats(update_dict, (3,)) + + actual = dataset.to_table().sort_by("id") + for row in actual.to_pylist(): + if row["id"] in target_ids: + assert row["name"] == "updated" + else: + assert row["name"] == "foo" + + +def test_update_dataset_all_types(tmp_path: Path): + table = pa.table( + { + "int32": pa.array([1], pa.int32()), + "int64": pa.array([1], pa.int64()), + "uint32": pa.array([1], pa.uint32()), + "string": pa.array(["foo"], pa.string()), + "large_string": pa.array(["foo"], pa.large_string()), + "float32": pa.array([1.0], pa.float32()), + "float64": pa.array([1.0], pa.float64()), + "bool": pa.array([True], pa.bool_()), + "date32": pa.array([date(2021, 1, 1)], pa.date32()), + "timestamp_ns": pa.array([datetime(2021, 1, 1)], pa.timestamp("ns")), + "timestamp_ms": pa.array([datetime(2021, 1, 1)], pa.timestamp("ms")), + "vec_f32": pa.array([[1.0, 2.0]], pa.list_(pa.float32(), 2)), + "vec_f64": pa.array([[1.0, 2.0]], pa.list_(pa.float64(), 2)), + } + ) + + dataset = lance.write_dataset(table, tmp_path) + + # One update with all matching types + update_dict = dataset.update( dict( int32="2", int64="2", @@ -3068,6 +4286,267 @@ def test_update_with_retry_parameters(tmp_path: Path): check_update_stats(update_dict, (10,)) +@pytest.mark.parametrize( + "version", [None, "2.0", "2.1", "2.2", "2.3", "stable", "next"] +) +def test_update_exact_data_storage_version(tmp_path: Path, version): + dataset = lance.write_dataset( + pa.table({"id": [1, 2], "value": [10, 20]}), + tmp_path / "dataset", + data_storage_version="2.1", + max_rows_per_file=1, + ) + + dataset.update({"value": "value + 1"}, data_storage_version=version) + + files = [ + file for fragment in dataset.get_fragments() for file in fragment.metadata.files + ] + exact = {None: "2.1", "stable": "2.2", "next": "2.3"}.get(version, version) + assert { + f"{file.file_major_version}.{file.file_minor_version}" for file in files + } == {exact} + assert dataset.data_storage_version == "2.1" + assert dataset.to_table().sort_by("id").to_pydict() == { + "id": [1, 2], + "value": [11, 21], + } + + +@pytest.mark.parametrize( + "version", [None, "2.0", "2.1", "2.2", "2.3", "stable", "next"] +) +def test_merge_insert_exact_data_storage_version(tmp_path: Path, version): + dataset = lance.write_dataset( + pa.table({"id": [1, 2], "value": [10, 20]}), + tmp_path / "dataset", + data_storage_version="2.1", + max_rows_per_file=1, + ) + + builder = ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + ) + if version is not None: + builder.data_storage_version(version) + builder.execute(pa.table({"id": [2, 3], "value": [21, 30]})) + + versions = { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } + exact = {None: "2.1", "stable": "2.2", "next": "2.3"}.get(version, version) + assert versions == {"2.1", exact} + assert dataset.data_storage_version == "2.1" + assert dataset.to_table().sort_by("id").to_pydict() == { + "id": [1, 2, 3], + "value": [10, 21, 30], + } + + +def _assert_mixed_version_reads(dataset, expected): + expected = expected.sort_by("id") + ordered = dataset.to_table(scan_in_order=True) + assert ordered.sort_by("id") == expected + assert dataset.to_table(columns=["value", "id"]).sort_by("id") == ( + expected.select(["value", "id"]) + ) + assert dataset.to_table(filter="value >= 50").sort_by("id") == expected.filter( + pc.greater_equal(expected["value"], 50) + ) + assert dataset.to_table(filter="value IS NULL").sort_by("id") == expected.filter( + pc.is_null(expected["value"]) + ) + # Updates can move rows to new fragments. Use scan order only for positional + # addressing; the full scan above is independently checked against the oracle. + indices = [len(ordered) - 1, 0, len(ordered) // 2, 0] + assert dataset.take(indices) == ordered.take(indices) + assert dataset.take(indices, columns=["value", "id"]) == ordered.take( + indices + ).select(["value", "id"]) + + +@pytest.mark.parametrize( + "versions", + [ + ("2.0", "2.1", "2.2", "2.3"), + ("2.1", "2.2", "2.3", "2.0"), + ("2.2", "2.3", "2.0", "2.1"), + ("2.3", "2.0", "2.1", "2.2"), + ], +) +@pytest.mark.parametrize("mode", ["reencode", "try_binary_copy"]) +def test_mixed_version_mutations_preserve_data(tmp_path: Path, versions, mode): + data = pa.table( + { + "id": range(8), + "value": pa.array([10, None, 30, 40, 50, 60, None, 80], pa.int64()), + "label": ["zero", None, "", "你好", "four", "five", "six", "seven"], + "items": pa.array( + [[0], None, [], [None, 3], [4, 5], [], [6], [7]], + pa.list_(pa.int64()), + ), + } + ) + uri = tmp_path / "dataset" + dataset = lance.write_dataset( + data.slice(0, 4), uri, max_rows_per_file=2, data_storage_version=versions[0] + ) + _assert_mixed_version_reads(lance.dataset(uri), data.slice(0, 4)) + dataset = lance.write_dataset( + data.slice(4), + dataset, + mode="append", + max_rows_per_file=2, + data_storage_version=versions[1], + ) + assert { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } == set(versions[:2]) + _assert_mixed_version_reads(lance.dataset(uri), data) + + expected_rows = data.to_pylist() + dataset.update( + {"value": "coalesce(value, 0) + 100"}, + where="id IN (1, 4)", + data_storage_version=versions[2], + ) + expected_rows[1]["value"] = 100 + expected_rows[4]["value"] = 150 + _assert_mixed_version_reads( + lance.dataset(uri), pa.Table.from_pylist(expected_rows, schema=data.schema) + ) + + merged_rows = [ + {"id": 2, "value": None, "label": "changed", "items": [9, None]}, + {"id": 8, "value": 800, "label": "new", "items": []}, + ] + ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .data_storage_version(versions[3]) + .execute(pa.Table.from_pylist(merged_rows, schema=data.schema)) + ) + expected_rows[2] = merged_rows[0] + expected_rows.append(merged_rows[1]) + _assert_mixed_version_reads( + lance.dataset(uri), pa.Table.from_pylist(expected_rows, schema=data.schema) + ) + + dataset.delete("id IN (0, 6)") + expected = pa.Table.from_pylist( + [row for row in expected_rows if row["id"] not in (0, 6)], schema=data.schema + ) + _assert_mixed_version_reads(lance.dataset(uri), expected) + + retained = dataset.get_fragments()[0].metadata + metrics = dataset.optimize.compact_files( + target_rows_per_fragment=32, + excluded_fragment_ids=[retained.id], + data_storage_version=versions[0], + compaction_mode=mode, + ) + assert metrics.fragments_removed > 0 + assert retained in [fragment.metadata for fragment in dataset.get_fragments()] + _assert_mixed_version_reads(lance.dataset(uri), expected) + + dataset.optimize.compact_files( + target_rows_per_fragment=32, + data_storage_version=versions[1], + compaction_mode=mode, + ) + assert len(dataset.get_fragments()) == 1 + _assert_mixed_version_reads(lance.dataset(uri), expected) + + +@pytest.mark.parametrize("copy_kind", ["shallow_clone", "deep_clone", "branch"]) +def test_mixed_version_snapshot_data_isolation(tmp_path: Path, copy_kind): + uri = tmp_path / "source" + expected = pa.table( + {"id": range(8), "value": pa.array([10, None, 30, 40, 50, 60, None, 80])} + ) + for i, version in enumerate(["2.0", "2.1", "2.2", "2.3"]): + dataset = lance.write_dataset( + expected.slice(i * 2, 2), + uri, + mode="create" if i == 0 else "append", + data_storage_version=version, + ) + snapshot = dataset.version + assert { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } == {"2.0", "2.1", "2.2", "2.3"} + _assert_mixed_version_reads(lance.dataset(uri), expected) + if copy_kind == "branch": + copied = dataset.create_branch("copy", snapshot) + else: + copied = getattr(dataset, copy_kind)(tmp_path / "copy", snapshot) + _assert_mixed_version_reads(lance.dataset(copied.uri), expected) + + copied.update({"value": "999"}, where="id = 3", data_storage_version="2.1") + copy_rows = expected.to_pylist() + copy_rows[3]["value"] = 999 + copy_expected = pa.Table.from_pylist(copy_rows, schema=expected.schema) + _assert_mixed_version_reads(lance.dataset(copied.uri), copy_expected) + _assert_mixed_version_reads(lance.dataset(uri), expected) + + dataset.delete("id IN (0, 7)") + dataset.optimize.compact_files(data_storage_version="2.2") + changed_version = dataset.version + changed = expected.slice(1, 6) + _assert_mixed_version_reads(lance.dataset(uri), changed) + _assert_mixed_version_reads(lance.dataset(uri, version=snapshot), expected) + _assert_mixed_version_reads(lance.dataset(copied.uri), copy_expected) + + restored = dataset.checkout_version(snapshot) + restored.restore() + _assert_mixed_version_reads(lance.dataset(uri), expected) + _assert_mixed_version_reads(lance.dataset(uri, version=changed_version), changed) + appended = pa.table({"id": [8], "value": [90]}, schema=expected.schema) + lance.write_dataset(appended, restored, mode="append", data_storage_version="2.3") + _assert_mixed_version_reads( + lance.dataset(uri), pa.concat_tables([expected, appended]) + ) + _assert_mixed_version_reads(lance.dataset(copied.uri), copy_expected) + + +@pytest.mark.parametrize("operation", ["update", "merge_insert", "compaction"]) +@pytest.mark.parametrize("version", ["invalid", "legacy"]) +def test_operation_rejects_invalid_data_storage_version( + tmp_path: Path, operation, version +): + data = pa.table({"id": [1, 2], "value": [10, 20]}) + dataset = lance.write_dataset( + data, tmp_path / "dataset", data_storage_version="2.1" + ) + # The core validates both the selector and the V1/V2 boundary before committing. + error = OSError if operation == "compaction" and version == "legacy" else ValueError + message = ( + "Unknown Lance storage version: invalid" + if version == "invalid" + else "V1 and V2 storage versions cannot be mixed" + ) + with pytest.raises(error, match=message): + if operation == "update": + dataset.update({"value": "value + 1"}, data_storage_version=version) + elif operation == "merge_insert": + dataset.merge_insert("id").data_storage_version( + version + ).when_matched_update_all().execute(data) + else: + dataset.optimize.compact_files(data_storage_version=version) + assert dataset.version == 1 + assert dataset.to_table() == data + + def test_scan_with_batch_size(tmp_path: Path): base_dir = tmp_path / "dataset" df = pd.DataFrame({"a": range(10000), "b": range(10000)}) @@ -3650,10 +5129,14 @@ def commit_lock(_version: int): lance.write_dataset( pa.table({"a": range(100)}), tmp_path / "test2", commit_lock=commit_lock ) + assert lance.dataset(tmp_path / "test2").count_rows() == 100 + + # Import only after the generic error case to verify users need not import it first. + commit_module = importlib.import_module("lance.commit") @contextlib.contextmanager def commit_lock(_version: int): - raise CommitConflictError() + raise commit_module.CommitConflictError() with pytest.raises(Exception, match="CommitConflictError"): lance.write_dataset( @@ -4077,10 +5560,14 @@ def test_legacy_dataset(tmp_path: Path): fragment = list(dataset.get_fragments())[0] assert "major_version: 2" not in format_fragment(fragment.metadata, dataset) - # Append will write v1 if dataset was originally created with v1 - dataset = lance.write_dataset( - table, tmp_path, data_storage_version="stable", mode="append" - ) + # Explicit targets cannot cross the V1/V2 boundary. Omitting the target + # continues to use the dataset default. + with pytest.raises(OSError, match="V1 and V2 storage versions cannot be mixed"): + lance.write_dataset( + table, tmp_path, data_storage_version="stable", mode="append" + ) + assert lance.dataset(tmp_path).version == dataset.version + dataset = lance.write_dataset(table, tmp_path, mode="append") fragment = list(dataset.get_fragments())[1] assert "major_version: 2" not in format_fragment(fragment.metadata, dataset) @@ -4096,9 +5583,12 @@ def test_legacy_dataset(tmp_path: Path): assert len(dataset.get_fragments()) == 0 - dataset = lance.write_dataset( - table, tmp_path, data_storage_version="legacy", mode="append" - ) + with pytest.raises(OSError, match="V1 and V2 storage versions cannot be mixed"): + lance.write_dataset( + table, tmp_path, data_storage_version="legacy", mode="append" + ) + assert lance.dataset(tmp_path).version == dataset.version + dataset = lance.write_dataset(table, tmp_path, mode="append") fragment = list(dataset.get_fragments())[0] assert "major_version: 2" in format_fragment(fragment.metadata, dataset) @@ -4114,9 +5604,12 @@ def test_legacy_dataset(tmp_path: Path): assert len(dataset.get_fragments()) == 0 - dataset = lance.write_dataset( - table, tmp_path, data_storage_version="stable", mode="append" - ) + with pytest.raises(OSError, match="V1 and V2 storage versions cannot be mixed"): + lance.write_dataset( + table, tmp_path, data_storage_version="stable", mode="append" + ) + assert lance.dataset(tmp_path).version == dataset.version + dataset = lance.write_dataset(table, tmp_path, mode="append") fragment = list(dataset.get_fragments())[0] assert "major_version: 2" not in format_fragment(fragment.metadata, dataset) @@ -4134,16 +5627,20 @@ def test_late_materialization_param(tmp_path: Path): ) filt = "filter % 2 == 0" - assert "(values)" in dataset.scanner( - filter=filt, late_materialization=None - ).explain_plan(True) + # A late-materialized column is fetched by a row-stream read + # (`projection=[values], source=stream`); an eager column appears in the + # scan projection + late = "projection=[values], source=stream" + assert late in dataset.scanner(filter=filt, late_materialization=None).explain_plan( + True + ) assert ", values" in dataset.scanner( filter=filt, late_materialization=False ).explain_plan(True) - assert "(values)" in dataset.scanner( - filter=filt, late_materialization=True - ).explain_plan(True) - assert "(values)" in dataset.scanner( + assert late in dataset.scanner(filter=filt, late_materialization=True).explain_plan( + True + ) + assert late in dataset.scanner( filter=filt, late_materialization=["values"] ).explain_plan(True) assert ", values" in dataset.scanner( @@ -4183,6 +5680,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) @@ -4201,6 +5737,22 @@ def test_use_scalar_index(tmp_path: Path): ).explain_plan(True) +def test_fast_search_scalar_index_skips_unindexed_fragments(tmp_path: Path): + table = pa.table({"filter": range(100)}) + dataset = lance.write_dataset(table, tmp_path, max_rows_per_file=100) + dataset.create_scalar_index("filter", "BTREE") + dataset = lance.write_dataset( + pa.table({"filter": range(100, 110)}), tmp_path, mode="append" + ) + + normal = dataset.to_table(filter="filter >= 95") + fast = dataset.to_table(filter="filter >= 95", fast_search=True) + + assert normal.num_rows == 15 + assert fast.num_rows == 5 + assert sorted(fast.column("filter").to_pylist()) == list(range(95, 100)) + + EXPECTED_DEFAULT_STORAGE_VERSION = stable_version() EXPECTED_MAJOR_VERSION = int(stable_version().split(".")[0]) EXPECTED_MINOR_VERSION = int(stable_version().split(".")[1]) @@ -4319,6 +5871,69 @@ def test_dataset_drop(tmp_path: Path): lance.LanceDataset.drop(tmp_path) +def test_dataset_drop_rejects_non_dataset_directory(tmp_path: Path): + warehouse = tmp_path / "warehouse" + lance.write_dataset(pa.table({"x": [0]}), warehouse / "t.lance") + + # Pointing at the parent of a dataset must not wipe out the whole warehouse. + with pytest.raises(ValueError, match="no readable Lance manifest"): + lance.LanceDataset.drop(warehouse) + assert (warehouse / "t.lance").exists() + + lance.LanceDataset.drop(warehouse / "t.lance") + assert not (warehouse / "t.lance").exists() + + +@pytest.mark.parametrize( + "entries", + [ + # A storage root holding a "data" prefix is indistinguishable from the + # leftovers of a write that died before committing. + ["data/0.lance"], + # A file merely sitting under _versions/, or merely named like a manifest, is + # not evidence that a dataset was ever committed here. + ["_versions/README", "reports/q1.csv"], + ["_versions/1.manifest", "reports/q1.csv"], + ], +) +def test_dataset_drop_rejects_paths_without_readable_manifest( + tmp_path: Path, entries: list +): + storage_root = tmp_path / "storage_root" + for entry in entries: + path = storage_root / entry + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"irreplaceable") + + with pytest.raises(ValueError, match="no readable Lance manifest"): + lance.LanceDataset.drop(storage_root) + for entry in entries: + assert (storage_root / entry).exists() + + +def test_dataset_drop_allows_create_over_uncommitted_leftovers(tmp_path: Path): + # Refusing to drop data files without a manifest costs nothing, because those + # leftovers do not stop the dataset from being created and then dropped. + dataset_dir = tmp_path / "t.lance" + (dataset_dir / "data").mkdir(parents=True) + (dataset_dir / "data" / "0.lance").write_bytes(b"partial") + + lance.write_dataset(pa.table({"x": [0]}), dataset_dir) + lance.LanceDataset.drop(dataset_dir) + assert not dataset_dir.exists() + + +def test_dataset_drop_allows_dataset_with_unmanaged_files(tmp_path: Path): + # Cleanup deliberately preserves unmanaged files under a dataset root, so their + # presence must not stop a drop either. + dataset_dir = tmp_path / "t.lance" + lance.write_dataset(pa.table({"x": [0]}), dataset_dir) + (dataset_dir / "notes.txt").write_text("kept next to the dataset") + + lance.LanceDataset.drop(dataset_dir) + assert not dataset_dir.exists() + + def test_dataset_schema(tmp_path: Path): table = pa.table({"x": [0]}) ds = lance.write_dataset(table, str(tmp_path)) # noqa: F841 @@ -4355,6 +5970,657 @@ def test_data_replacement(tmp_path: Path): assert tbl == expected +def _write_overlay_file( + dataset, + base_dir: Path, + name: str, + batch: pa.Table, + fields: List[int], + *, + version: Optional[str] = None, +): + """Write an overlay value file (one value column per covered field, no key + column) and return a DataFile mapping its columns to the given dataset + `fields`. Omission uses the exact version of the first base data file.""" + base_df = dataset.get_fragments()[0].metadata.files[0] + version = version or f"{base_df.file_major_version}.{base_df.file_minor_version}" + path = base_dir / "data" / name + with LanceFileWriter(str(path), version=version) as writer: + writer.write_batch(batch) + major, minor = map(int, version.split(".")) + return lance.fragment.DataFile( + path=name, + fields=fields, + column_indices=list(range(len(fields))), + file_major_version=major, + file_minor_version=minor, + file_size_bytes=os.path.getsize(path), + ) + + +@pytest.fixture +def enable_unstable_data_overlay_files(monkeypatch): + monkeypatch.setenv("LANCE_ENABLE_UNSTABLE_DATA_OVERLAY_FILES", "1") + + +@pytest.mark.parametrize("overlay_version", ["2.2", "2.3"]) +@pytest.mark.parametrize("mode", ["reencode", "try_binary_copy", "force_binary_copy"]) +def test_mixed_version_overlays_preserve_data( + tmp_path: Path, enable_unstable_data_overlay_files, overlay_version, mode +): + uri = tmp_path / "dataset" + data = pa.table({"id": range(6), "value": [10, 20, 30, 40, 50, 60]}) + dataset = lance.write_dataset(data.slice(0, 3), uri, data_storage_version="2.0") + dataset = lance.write_dataset( + data.slice(3), dataset, mode="append", data_storage_version="2.1" + ) + # Only the second fragment has overlays. The newer overlay replaces one + # covered value with NULL, while a different updated row is later deleted. + for name, offsets, values in [ + ("older.lance", [0, 1, 2], [400, 500, 600]), + ("newer.lance", [0, 2], [None, 999]), + ]: + file = _write_overlay_file( + dataset, + uri, + name, + pa.table({"value": pa.array(values, pa.int64())}), + fields=[1], + version=overlay_version, + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 1, [lance.LanceOperation.DataOverlayFile(file, offsets=offsets)] + ) + ] + ), + read_version=dataset.version, + ) + expected = pa.table({"id": range(6), "value": [10, 20, 30, None, 500, 999]}) + _assert_mixed_version_reads(lance.dataset(uri), expected) + dataset.delete("value = 500") + expected = expected.filter(pc.not_equal(expected["id"], 4)) + _assert_mixed_version_reads(lance.dataset(uri), expected) + before = dataset.version + options = dict( + target_rows_per_fragment=32, + data_storage_version="2.2", + compaction_mode=mode, + ) + if mode == "force_binary_copy": + with pytest.raises(OSError, match="binary copy"): + dataset.optimize.compact_files(**options) + assert lance.dataset(uri).version == before + else: + dataset.optimize.compact_files(**options) + assert len(dataset.get_fragments()) == 1 + assert not dataset.get_fragments()[0].metadata.overlays + _assert_mixed_version_reads(lance.dataset(uri), expected) + + +def test_data_overlay_dense(tmp_path: Path, enable_unstable_data_overlay_files): + base_dir = tmp_path / "test" + table = pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ) + dataset = lance.write_dataset(table, base_dir) + + # Overlay `val` at physical offsets {1, 4} with new values. + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([111, 444], pa.int32())}), + fields=[1], + ) + assert data_file.fields == [1] # `val` is field id 1 + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=[1, 4]) + op = lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ) + dataset = lance.LanceDataset.commit(dataset, op, read_version=dataset.version) + + result = dataset.to_table() + assert result.column("val").to_pylist() == [0, 111, 20, 30, 444, 50, 60, 70, 80, 90] + # The unrelated `id` column is untouched. + assert result.column("id").to_pylist() == list(range(10)) + + +def test_data_overlay_newest_wins(tmp_path: Path, enable_unstable_data_overlay_files): + base_dir = tmp_path / "test" + table = pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ) + dataset = lance.write_dataset(table, base_dir) + + older = _write_overlay_file( + dataset, + base_dir, + "older.lance", + pa.table({"val": pa.array([111, 444], pa.int32())}), + fields=[1], + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, + [lance.LanceOperation.DataOverlayFile(older, offsets=[1, 4])], + ) + ] + ), + read_version=dataset.version, + ) + # A newer overlay re-covers offset 1; it must win there. + newer = _write_overlay_file( + dataset, + base_dir, + "newer.lance", + pa.table({"val": pa.array([999], pa.int32())}), + fields=[1], + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, [lance.LanceOperation.DataOverlayFile(newer, offsets=[1])] + ) + ] + ), + read_version=dataset.version, + ) + + val = dataset.to_table().column("val").to_pylist() + assert val[1] == 999 # newest overlay wins + assert val[4] == 444 # only the older overlay covers offset 4 + + +def test_data_overlay_sparse_per_field( + tmp_path: Path, enable_unstable_data_overlay_files +): + base_dir = tmp_path / "test" + table = pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ) + dataset = lance.write_dataset(table, base_dir) + + # Sparse overlay: `id` covers offset {2}, `val` covers offset {3}. The value + # file carries one value per field (rank 0 of each field's coverage). + data_file = _write_overlay_file( + dataset, + base_dir, + "sparse.lance", + pa.table( + { + "id": pa.array([777], pa.int32()), + "val": pa.array([330], pa.int32()), + } + ), + fields=[0, 1], + ) + assert data_file.fields == [0, 1] + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=[[2], [3]]) + op = lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ) + dataset = lance.LanceDataset.commit(dataset, op, read_version=dataset.version) + + result = dataset.to_table() + assert result.column("id").to_pylist()[2] == 777 + assert result.column("val").to_pylist()[3] == 330 + # Fields resolve independently: id at offset 3 and val at offset 2 fall through. + assert result.column("id").to_pylist()[3] == 3 + assert result.column("val").to_pylist()[2] == 20 + + +def test_data_overlay_offsets_accept_bitmap( + tmp_path: Path, enable_unstable_data_overlay_files +): + from lance.bitmap import Bitmap + + base_dir = tmp_path / "test" + table = pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ) + dataset = lance.write_dataset(table, base_dir) + + # Dense coverage as a single Bitmap. + dense_file = _write_overlay_file( + dataset, + base_dir, + "dense.lance", + pa.table({"val": pa.array([111, 444], pa.int32())}), + fields=[1], + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, + [ + lance.LanceOperation.DataOverlayFile( + dense_file, offsets=Bitmap([1, 4]) + ) + ], + ) + ] + ), + read_version=dataset.version, + ) + result = dataset.to_table() + assert result.column("val").to_pylist() == [0, 111, 20, 30, 444, 50, 60, 70, 80, 90] + + # Sparse coverage as a list of per-field iterables, mixing a Bitmap + # with a plain list. + sparse_file = _write_overlay_file( + dataset, + base_dir, + "sparse.lance", + pa.table( + { + "id": pa.array([777], pa.int32()), + "val": pa.array([330], pa.int32()), + } + ), + fields=[0, 1], + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, + [ + lance.LanceOperation.DataOverlayFile( + sparse_file, offsets=[Bitmap([2]), [3]] + ) + ], + ) + ] + ), + read_version=dataset.version, + ) + result = dataset.to_table() + assert result.column("id").to_pylist()[2] == 777 + assert result.column("val").to_pylist()[3] == 330 + + +def test_data_overlay_round_trips_through_fragment_metadata( + tmp_path: Path, enable_unstable_data_overlay_files +): + import json + + from lance.bitmap import Bitmap + + base_dir = tmp_path / "test" + table = pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ) + dataset = lance.write_dataset(table, base_dir) + + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([111, 444], pa.int32())}), + fields=[1], + ) + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=[1, 4]) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + overlay_version = dataset.version + + # Reading the fragment surfaces its overlays, stamped with the commit version. + metadata = dataset.get_fragments()[0].metadata + assert len(metadata.overlays) == 1 + assert isinstance(metadata.overlays[0].offsets, Bitmap) + assert metadata.overlays[0].offsets == [1, 4] + assert metadata.overlays[0].committed_version == overlay_version + + # The overlays survive a JSON round-trip of the metadata. + restored = lance.fragment.FragmentMetadata.from_json(json.dumps(metadata.to_json())) + assert len(restored.overlays) == 1 + assert restored.overlays[0].offsets == [1, 4] + assert restored.overlays[0].committed_version == overlay_version + + # A commit that round-trips the fragment (here an Overwrite) must keep the + # overlays, so the overlay still resolves on read instead of being dropped. + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.Overwrite(dataset.schema, [restored]), + read_version=dataset.version, + ) + result = dataset.to_table() + assert result.column("val").to_pylist() == [0, 111, 20, 30, 444, 50, 60, 70, 80, 90] + assert result.column("id").to_pylist() == list(range(10)) + + +def test_data_overlay_offsets_accept_one_shot_iterable( + tmp_path: Path, enable_unstable_data_overlay_files +): + # `offsets` is documented as an Iterable, so a generator must work. The + # dense/sparse shape probe must not consume it before the sparse form is + # resolved, or the first field's coverage would be silently dropped. + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array([0, 1, 2], pa.int32()), + "val": pa.array([0, 10, 20], pa.int32()), + } + ), + base_dir, + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "sparse.lance", + pa.table( + { + "id": pa.array([777], pa.int32()), + "val": pa.array([999], pa.int32()), + } + ), + fields=[0, 1], + ) + + overlay = lance.LanceOperation.DataOverlayFile( + data_file, offsets=(item for item in ([1], [2])) + ) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + result = dataset.to_table() + assert result.column("id").to_pylist() == [0, 777, 2] + assert result.column("val").to_pylist() == [0, 10, 999] + + +def test_data_overlay_rejects_invalid_one_shot_iterable( + tmp_path: Path, enable_unstable_data_overlay_files +): + # A one-shot iterable whose contents are invalid must be rejected at + # commit time, not committed as empty coverage that only fails at read. + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table({"val": pa.array([0, 10, 20], pa.int32())}), base_dir + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "invalid.lance", + pa.table({"val": pa.array([999], pa.int32())}), + fields=[0], + ) + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=iter([1, "bad"])) + with pytest.raises(ValueError, match="offsets must be an iterable"): + lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + +def test_data_overlay_sparse_offsets_round_trip_through_json( + tmp_path: Path, enable_unstable_data_overlay_files +): + # `to_json` has to normalize per-field Bitmaps to plain lists, the same way + # it does a dense one -- `json.dumps` cannot serialize a Bitmap. + import json + + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array(range(4), pa.int32()), + "val": pa.array([i * 10 for i in range(4)], pa.int32()), + } + ), + base_dir, + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "sparse.lance", + pa.table( + { + "id": pa.array([777], pa.int32()), + "val": pa.array([330], pa.int32()), + } + ), + fields=[0, 1], + ) + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=[[2], [3]]) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + metadata = dataset.get_fragments()[0].metadata + assert metadata.to_json()["overlays"][0]["offsets"] == [[2], [3]] + + restored = lance.fragment.FragmentMetadata.from_json(json.dumps(metadata.to_json())) + assert [list(o) for o in restored.overlays[0].offsets] == [[2], [3]] + + +@pytest.mark.parametrize( + "offsets", + [ + [1, 1], # dense + [[1, 1]], # sparse + ], + ids=["dense", "sparse"], +) +def test_data_overlay_rejects_duplicate_offsets( + tmp_path: Path, offsets, enable_unstable_data_overlay_files +): + # Offsets map positionally to value rows: the smallest covered offset to + # row 0, the next to row 1, and so on. A repeat would collapse into the + # coverage set and shift every later offset onto the wrong row, so it must + # be rejected rather than silently corrupting the mapping. + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table({"val": pa.array([0, 1, 2], pa.int32())}), base_dir + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([9, 9], pa.int32())}), + fields=[0], + ) + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=offsets) + with pytest.raises(ValueError, match="must not repeat an offset"): + lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + +@pytest.mark.parametrize( + "offsets", + [ + [-1], # dense + [[-1]], # sparse + ], + ids=["dense", "sparse"], +) +def test_data_overlay_rejects_out_of_range_offsets( + tmp_path: Path, offsets, enable_unstable_data_overlay_files +): + # The shape is unambiguous here, so the error must name the bad value + # rather than complaining about the shape. + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table({"val": pa.array([0, 1, 2], pa.int32())}), base_dir + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([9], pa.int32())}), + fields=[0], + ) + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=offsets) + with pytest.raises(ValueError, match="unsigned 32-bit integer, got -1"): + lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + +def test_data_overlay_accepts_empty_offsets( + tmp_path: Path, enable_unstable_data_overlay_files +): + # An empty iterable can't distinguish dense from sparse, and doesn't need + # to: it covers no rows either way. + base_dir = tmp_path / "test" + dataset = lance.write_dataset( + pa.table({"val": pa.array([0, 10, 20], pa.int32())}), base_dir + ) + data_file = _write_overlay_file( + dataset, + base_dir, + "empty.lance", + # A Lance file needs at least one row to carry a schema; the empty + # coverage means no row of it is ever read. + pa.table({"val": pa.array([99], pa.int32())}), + fields=[0], + ) + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=[]) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + assert dataset.to_table().column("val").to_pylist() == [0, 10, 20] + + +def test_data_overlay_rejects_invalid_offsets( + tmp_path: Path, enable_unstable_data_overlay_files +): + base_dir = tmp_path / "test" + table = pa.table({"val": pa.array([0, 1, 2], pa.int32())}) + dataset = lance.write_dataset(table, base_dir) + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([9], pa.int32())}), + fields=[0], + ) + + # offsets is neither an iterable of ints (dense) nor an iterable of int + # iterables (sparse), so the coverage shape can't be resolved. + with pytest.raises(ValueError, match="offsets must be an iterable"): + lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, + [ + lance.LanceOperation.DataOverlayFile( + data_file, offsets=[0, [1]] + ) + ], + ) + ] + ), + read_version=dataset.version, + ) + + +@pytest.mark.parametrize( + "offsets", + [ + [2, 1], # dense, descending + [[2, 1]], # sparse, descending + ], +) +def test_data_overlay_accepts_any_offset_order( + tmp_path: Path, offsets, enable_unstable_data_overlay_files +): + """Offsets are always resolved in ascending order (the smallest covered + offset maps to value-file row 0, the next-smallest to row 1, ...) + regardless of what order the caller lists them in — an out-of-order list + is accepted and resolves identically to a pre-sorted one.""" + base_dir = tmp_path / "test" + table = pa.table({"val": pa.array([0, 1, 2], pa.int32())}) + dataset = lance.write_dataset(table, base_dir) + data_file = _write_overlay_file( + dataset, + base_dir, + "ov.lance", + pa.table({"val": pa.array([100, 200], pa.int32())}), + fields=[0], + ) + + overlay = lance.LanceOperation.DataOverlayFile(data_file, offsets=offsets) + dataset = lance.LanceDataset.commit( + dataset, + lance.LanceOperation.DataOverlay( + [lance.LanceOperation.DataOverlayGroup(0, [overlay])] + ), + read_version=dataset.version, + ) + + result = dataset.to_table().column("val").to_pylist() + # rank 0 (offset 1) -> value row 0 (100); rank 1 (offset 2) -> value row 1 (200) + assert result[1] == 100 + assert result[2] == 200 + + def test_schema_project_drop_column(tmp_path: Path): table = pa.Table.from_pydict({"a": range(100, 200), "b": range(300, 400)}) base_dir = tmp_path / "test" @@ -4498,6 +6764,33 @@ def test_dataset_sql(tmp_path: Path): assert pa.Table.from_batches(complex_result) == expected_complex +def test_dataset_sql_batch_size_rows(tmp_path: Path): + table = pa.table({"id": range(50)}) + ds = lance.write_dataset(table, tmp_path / "test_sql_batch_size_rows") + + batches = list( + ds.sql("SELECT * FROM dataset").batch_size(7).build().to_stream_reader() + ) + + assert sum(batch.num_rows for batch in batches) == 50 + assert all(batch.num_rows <= 7 for batch in batches) + + +@pytest.mark.parametrize("batch_size", [0, 2**32]) +def test_dataset_sql_rejects_invalid_batch_size(tmp_path: Path, batch_size: int): + ds = lance.write_dataset( + pa.table({"id": range(3)}), tmp_path / "test_sql_invalid_batch_size" + ) + + with pytest.raises(ValueError, match="batch_size must be between 1 and 4294967295"): + ( + ds.sql("SELECT * FROM dataset") + .batch_size(batch_size) + .build() + .to_batch_records() + ) + + def test_file_reader_options(tmp_path: Path): """Test cache_repetition_index and validate_on_decode options""" # Create a dataset with large repetitive strings to test cache_repetition_index @@ -4697,6 +6990,76 @@ def test_commit_message_and_get_properties(tmp_path): ) +def test_get_transactions_on_branch(tmp_path): + table = pa.table({"a": [1]}) + dataset = lance.write_dataset(table, tmp_path) + branch = dataset.create_branch("dev") + + branch = lance.write_dataset(table, branch.uri, mode="append") + transactions = branch.get_transactions(2) + + assert len(transactions) == 2 + assert transactions[0] is not None + assert isinstance(transactions[0].operation, lance.LanceOperation.Append) + + clone_transaction = transactions[1] + assert clone_transaction is not None + assert clone_transaction.read_version == dataset.version + clone = clone_transaction.operation + assert isinstance(clone, lance.LanceOperation.Clone) + assert clone.is_shallow + assert clone.ref_name is None + assert clone.ref_version == dataset.version + assert clone.ref_path == dataset.uri + assert clone.branch_name == "dev" + + +def test_commit_deep_clone_rejected(tmp_path: Path): + source = lance.write_dataset(pa.table({"a": range(10)}), tmp_path / "source") + clone = lance.LanceOperation.Clone( + is_shallow=False, + ref_name=None, + ref_version=source.version, + ref_path=source.uri, + branch_name=None, + ) + + # Committed directly, a deep clone would reference files never copied to + # the target; LanceDataset.deep_clone copies them first. + with pytest.raises(OSError, match="deep Clone cannot be committed directly"): + lance.LanceDataset.commit(tmp_path / "target", clone, read_version=0) + + +def test_commit_with_stable_row_ids(tmp_path: Path): + """Test that commit() with enable_stable_row_ids creates stable row IDs.""" + base_uri = str(tmp_path) + table = pa.table({"a": range(10)}) + + # Create dataset via commit with Overwrite and enable_stable_row_ids + fragments = lance.fragment.write_fragments(table, base_uri) + operation = lance.LanceOperation.Overwrite(table.schema, fragments) + ds = lance.LanceDataset.commit( + base_uri, + operation, + enable_stable_row_ids=True, + ) + + # Append more data + table2 = pa.table({"a": range(10, 20)}) + fragments2 = lance.fragment.write_fragments(table2, base_uri) + ds = lance.LanceDataset.commit( + base_uri, + lance.LanceOperation.Append(fragments2), + read_version=ds.version, + ) + + # Verify row IDs are sequential (stable row IDs assign monotonic IDs) + result = ds.scanner(with_row_id=True).to_table() + assert len(result) == 20 + row_ids = [result["_rowid"][i].as_py() for i in range(20)] + assert row_ids == list(range(20)) + + def test_table_metadata_updates(tmp_path: Path): """Test table metadata incremental updates and full replacement.""" arr = pa.array([1, 2, 3]) @@ -5144,6 +7507,19 @@ def test_shallow_clone(tmp_path: Path): assert lance.dataset(clone_branch_v3).to_table() == table_v3 +def test_deep_clone(tmp_path: Path): + source_dir = tmp_path / "deep_src" + destination_dir = tmp_path / "deep_clone" + expected = pa.table({"id": [1, 2, 3], "value": ["a", "b", "c"]}) + source = lance.write_dataset(expected, source_dir) + + cloned = source.deep_clone(destination_dir, source.version) + + assert cloned.to_table() == expected + assert lance.dataset(destination_dir).to_table() == expected + assert list(destination_dir.glob("data/*.lance")) + + def test_branches(tmp_path: Path): # Step 1: create branch1 from main → append to branch1 → create branch2 from tag base_dir = tmp_path / "test_branches" @@ -5151,7 +7527,10 @@ def test_branches(tmp_path: Path): ds_main = lance.write_dataset(main_table, base_dir) branch1 = ds_main.create_branch("branch1") + ds_main.branches.replace_metadata("branch1", {"description": "branch one"}) assert branch1.version == 1 + # The dataset returned by create_branch must be fully constructed + assert branch1.checkout_version(("main", None)).version == 1 branch1_append = pa.Table.from_pydict({"a": [7, 8], "b": [9, 10]}) branch1 = lance.write_dataset(branch1_append, branch1, mode="append") assert branch1.version == 2 @@ -5169,11 +7548,19 @@ 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)) + ordered_tags = dict(branch1.tags.list_ordered()) + 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 ordered_tags["branch1_latest"]["branch"] == "branch1" + assert ordered_tags["main_latest"]["branch"] is None + 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 @@ -5199,7 +7586,14 @@ 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 + assert b1_meta["metadata"] == {"description": "branch one"} + ordered_branches = dict(ds_main.branches.list_ordered()) + assert ordered_branches["branch1"]["metadata"] == {"description": "branch one"} + assert "metadata" in ordered_branches["branch2"] try: ds_main.checkout_version("branch_not_exists") @@ -5256,4 +7650,33 @@ def test_default_scan_options_nearest(tmp_path: Path) -> None: distances = result["_distance"].to_pylist() assert distances == sorted(distances) - assert "id" in result.column_names + +def test_tracked_files(tmp_path): + table = pa.table({"x": [1, 2, 3]}) + ds = lance.write_dataset(table, tmp_path / "ds") + ds.delete("x = 2") # adds a deletion file + + reader = ds.tracked_files() + assert isinstance(reader, pa.RecordBatchReader) + + result = reader.read_all() + assert result.schema.field("version").type == pa.int64() + assert result.num_rows >= 2 # at least manifest + data file + + types = set(result.column("type").to_pylist()) + assert "manifest" in types + assert "data file" in types + assert "deletion file" in types + + +def test_all_files(tmp_path): + table = pa.table({"x": [1, 2, 3]}) + ds = lance.write_dataset(table, tmp_path / "ds") + + reader = ds.all_files() + assert isinstance(reader, pa.RecordBatchReader) + + result = reader.read_all() + assert result.schema.field("size_bytes").type == pa.int64() + assert result.num_rows >= 2 # at least manifest + data file + assert all(s > 0 for s in result.column("size_bytes").to_pylist()) diff --git a/python/python/tests/test_delta.py b/python/python/tests/test_delta.py index 589dab8dc3f..7875df6dd00 100755 --- a/python/python/tests/test_delta.py +++ b/python/python/tests/test_delta.py @@ -134,3 +134,28 @@ def test_delta_validation_errors(): "and with_end_version", ): ds.delta(end_version=2) + + +def test_delta_get_deleted_row_ids(): + table = pa.table( + { + "id": pa.array([1, 2, 3, 4], type=pa.int32()), + "val": pa.array(["a", "b", "c", "d"], type=pa.string()), + } + ) + ds = write_dataset( + table, "memory://delta_api_test_delete", enable_stable_row_ids=True + ) + row_ids = ds.to_table(columns=[], with_row_id=True).column("_rowid").to_pylist() + + ds.delete("id in (2, 3)") + + delta = ds.delta(compared_against=1) + reader = delta.get_deleted_row_ids() + + deleted = [] + for batch in reader: + assert batch.schema.names == ["_rowid"] + deleted.extend(batch.column("_rowid").to_pylist()) + + assert sorted(deleted) == sorted([row_ids[1], row_ids[2]]) diff --git a/python/python/tests/test_file.py b/python/python/tests/test_file.py index 44af6a8e2d5..9c55bca174a 100644 --- a/python/python/tests/test_file.py +++ b/python/python/tests/test_file.py @@ -40,6 +40,26 @@ def test_write_no_schema(tmp_path): assert reader.read_all().to_table() == pa.table({"a": [1, 2, 3]}) +def test_write_arrow_json_no_schema(tmp_path): + path = tmp_path / "foo.lance" + json_type = pa.json_() + json_values = pa.ExtensionArray.from_storage( + json_type, pa.array(['{"a":1}', '{"b":2}'], pa.string()) + ) + table = pa.table({"json": json_values}) + + with LanceFileWriter(str(path), version="2.1") as writer: + writer.write_batch(table) + + reader = LanceFileReader(str(path)) + result = reader.read_all().to_table() + assert result.num_rows == table.num_rows + assert result.schema.field("json").type == pa.large_binary() + assert ( + result.schema.field("json").metadata[b"ARROW:extension:name"] == b"lance.json" + ) + + def test_no_schema_no_data(tmp_path): path = tmp_path / "foo.lance" with pytest.raises( @@ -61,7 +81,7 @@ def test_schema_only(tmp_path): def test_write_with_max_page_bytes(tmp_path): path = tmp_path / "foo.lance" schema = pa.schema([pa.field("a", pa.int64())]) - for version in ["2.0", "2.1"]: + for version in ["2.0", "2.1", "2.2", "2.3"]: with LanceFileWriter( str(path), schema, max_page_bytes=1, version=version ) as writer: @@ -87,27 +107,49 @@ def test_multiple_close(tmp_path): writer = LanceFileWriter(str(path), schema) writer.write_batch(pa.table({"a": [1, 2, 3]})) writer.close() + size_bytes = writer.size_bytes + # The second close is a no-op and must not clear the recorded size writer.close() + assert writer.size_bytes == size_bytes -def test_version(tmp_path): +@pytest.mark.parametrize("num_rows", [0, 3]) +def test_size_bytes(tmp_path, num_rows): path = tmp_path / "foo.lance" schema = pa.schema([pa.field("a", pa.int64())]) + writer = LanceFileWriter(str(path), schema) + assert writer.size_bytes is None + writer.write_batch(pa.table({"a": list(range(num_rows))})) + assert writer.close() == num_rows + # Even an empty file has a footer, so the size is always positive + assert writer.size_bytes > 0 + assert writer.size_bytes == os.path.getsize(path) - with LanceFileWriter(str(path), schema, version="2.0") as writer: - writer.write_batch(pa.table({"a": [1, 2, 3]})) - reader = LanceFileReader(str(path)) - metadata = reader.metadata() - assert metadata.major_version == 0 - assert metadata.minor_version == 3 - path = tmp_path / "foo2.lance" - with LanceFileWriter(str(path), schema, version="2.1") as writer: +def test_size_bytes_with_session(tmp_path): + session = LanceFileSession(tmp_path) + with session.open_writer("foo.lance") as writer: writer.write_batch(pa.table({"a": [1, 2, 3]})) - reader = LanceFileReader(str(path)) - metadata = reader.metadata() - assert metadata.major_version == 2 - assert metadata.minor_version == 1 + assert writer.size_bytes == os.path.getsize(tmp_path / "foo.lance") + + +def test_version(tmp_path): + schema = pa.schema([pa.field("a", pa.int64())]) + cases = [ + ("foo.lance", "2.0", (0, 3)), + ("foo2.lance", "2.1", (2, 1)), + ("foo3.lance", "2.2", (2, 2)), + ("foo4.lance", "2.3", (2, 3)), + ] + + for file_name, version, (major, minor) in cases: + path = tmp_path / file_name + with LanceFileWriter(str(path), schema, version=version) as writer: + writer.write_batch(pa.table({"a": [1, 2, 3]})) + reader = LanceFileReader(str(path)) + metadata = reader.metadata() + assert metadata.major_version == major + assert metadata.minor_version == minor def test_take(tmp_path): @@ -244,9 +286,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 @@ -459,18 +502,27 @@ def test_write_read_additional_schema_metadata(tmp_path): def test_writer_maintains_order(tmp_path): - # 100Ki strings, each string is a couple of KiBs - big_strings = [f"{i}" * 1024 for i in range(100 * 1024)] - table = pa.table({"big_strings": big_strings}) - - for i in range(4): - path = tmp_path / f"foo-{i}.lance" - with LanceFileWriter(str(path)) as writer: - writer.write_batch(table) + row_ids = list(range(8)) + payloads = ["0123456789abcdef" * (64 * 1024)] + [ + f"page-{row_id}" for row_id in row_ids[1:] + ] + table = pa.table({"payload": payloads, "row_id": row_ids}) + path = tmp_path / "ordered-pages.lance" + + # Before #2836, the seven cheap pages could finish encoding before the + # expensive first page and be written out of order. + with LanceFileWriter( + str(path), + table.schema, + version="2.0", + data_cache_bytes=1, + max_page_bytes=64 * 1024, + ) as writer: + writer.write_batch(table) - reader = LanceFileReader(str(path)) - result = reader.read_all().to_table() - assert result == table + reader = LanceFileReader(str(path)) + assert [len(column.pages) for column in reader.metadata().columns] == [8, 1] + assert reader.read_all().to_table() == table def test_compression(tmp_path): @@ -736,6 +788,44 @@ def test_session_list_with_trailing_slash(tmp_path): assert files_no_slash == ["dir/file.lance"] +def test_session_list_with_delimiter(tmp_path): + """Test that LanceFileSession.list_with_delimiter() is non-recursive.""" + session = LanceFileSession(str(tmp_path)) + schema = pa.schema([pa.field("x", pa.int64())]) + + # Two top-level files and two nested subtrees. + with session.open_writer("file1.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [1]})) + with session.open_writer("file2.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [2]})) + with session.open_writer("subdir/file3.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [3]})) + with session.open_writer("subdir/nested/file4.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [4]})) + with session.open_writer("other/file5.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [5]})) + + # Listing the base path returns only the immediate children: the two + # top-level files and the two child directories (not their contents). + result = session.list_with_delimiter() + assert sorted(result.common_prefixes) == ["other", "subdir"] + assert sorted(result.objects) == ["file1.lance", "file2.lance"] + + # Listing a subdirectory descends exactly one level: the direct file and + # the nested directory, but not the file inside the nested directory. + subdir = session.list_with_delimiter("subdir") + assert subdir.common_prefixes == ["subdir/nested"] + assert subdir.objects == ["subdir/file3.lance"] + + # Trailing slash behaves the same as no trailing slash. + assert session.list_with_delimiter("subdir/") == subdir + + # A non-existent prefix yields empty results rather than erroring. + empty = session.list_with_delimiter("nonexistent") + assert empty.common_prefixes == [] + assert empty.objects == [] + + def test_session_contains(tmp_path): """Test that LanceFileSession.contains() works correctly""" session = LanceFileSession(str(tmp_path)) @@ -757,3 +847,73 @@ def test_session_contains(tmp_path): assert session.contains("subdir/nested.lance") assert not session.contains("subdir/nonexistent.lance") + + +def test_session_read_range(tmp_path): + """Test that LanceFileSession.read_range() returns the requested bytes.""" + session = LanceFileSession(str(tmp_path)) + + payload = bytes(range(256)) + local = tmp_path / "src.bin" + local.write_bytes(payload) + session.upload_file(str(local), "data/file.bin") + + # A range in the middle of the file. + assert session.read_range("data/file.bin", 10, 5) == payload[10:15] + # From the start. + assert session.read_range("data/file.bin", 0, 4) == payload[0:4] + # Up to the end. + assert session.read_range("data/file.bin", 250, 6) == payload[250:256] + # A zero-length read yields empty bytes. + assert session.read_range("data/file.bin", 100, 0) == b"" + + # Reading a missing object raises OSError (consistent with download_file). + with pytest.raises(OSError): + session.read_range("data/missing.bin", 0, 4) + + +def test_session_delete_file(tmp_path): + """Test that LanceFileSession.delete_file() removes files and is idempotent.""" + session = LanceFileSession(str(tmp_path)) + schema = pa.schema([pa.field("x", pa.int64())]) + + with session.open_writer("test.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [1]})) + with session.open_writer("subdir/nested.lance", schema=schema) as writer: + writer.write_batch(pa.table({"x": [2]})) + + # Deleting an existing file removes it. + assert session.contains("test.lance") + session.delete_file("test.lance") + assert not session.contains("test.lance") + + # Nested paths work too. + assert session.contains("subdir/nested.lance") + session.delete_file("subdir/nested.lance") + assert not session.contains("subdir/nested.lance") + + # Deleting a missing path raises OSError (consistent with download_file). + with pytest.raises(OSError): + session.delete_file("test.lance") + with pytest.raises(OSError): + session.delete_file("never_existed.lance") + + +def test_struct_null_regression(): + import lance + + # Create struct array where 2nd element is null + tag_array = pa.array(["valid", "null_struct", "valid", "valid"]) + struct_array = pa.StructArray.from_arrays( + [tag_array], + fields=[pa.field("tag", pa.string(), nullable=True)], + mask=pa.array([True, False, True, True]), # False = null struct element + ) + + # Create list containing these structs + offsets = pa.array([0, 4], type=pa.int32()) + list_array = pa.ListArray.from_arrays(offsets, struct_array) + batch = pa.record_batch([pa.array([0]), list_array], names=["id", "value"]) + + ds = lance.write_dataset(batch, "memory://", data_storage_version="2.2") + ds.to_table() diff --git a/python/python/tests/test_filter.py b/python/python/tests/test_filter.py index cb50ca07b2d..5b91904b091 100644 --- a/python/python/tests/test_filter.py +++ b/python/python/tests/test_filter.py @@ -8,6 +8,7 @@ from datetime import date, datetime, timedelta from decimal import Decimal from pathlib import Path +from zoneinfo import ZoneInfo import lance import numpy as np @@ -75,6 +76,23 @@ def test_simple_predicates(dataset): assert dataset.to_table(filter=expr) == dataset.to_table().filter(expr) +def test_pyarrow_predicate_with_default_row_id(tmp_path: Path): + table = pa.table({"uid": [1, 2, 3], "number": [10, 20, 10]}) + lance.write_dataset(table, tmp_path) + dataset = lance.dataset(tmp_path, default_scan_options={"with_row_id": True}) + + actual = dataset.to_table(filter=pc.field("number") == 10) + + expected = pa.table( + { + "uid": [1, 3], + "number": [10, 10], + "_rowid": pa.array([0, 2], pa.uint64()), + } + ) + assert actual == expected + + def test_sql_predicates(dataset): # Predicate and expected number of rows predicates_nrows = [ @@ -108,6 +126,28 @@ def test_sql_predicates(dataset): assert dataset.to_table(filter=expr).num_rows == expected_num_rows +@pytest.mark.parametrize("unit", ["s", "ms", "us"]) +@pytest.mark.parametrize("timezone", [None, "UTC", "America/New_York"]) +def test_timestamp_pyarrow_predicates(tmp_path: Path, unit: str, timezone: str | None): + # PyArrow filters reach Lance as Substrait, where the timestamp literal used to be + # decoded in the wrong unit. + tz = ZoneInfo(timezone) if timezone else None + start = datetime(2021, 1, 1, tzinfo=tz) + ts_type = pa.timestamp(unit, timezone) + table = pa.table( + {"ts": pa.array([start + timedelta(hours=i) for i in range(100)], ts_type)} + ) + dataset = lance.write_dataset(table, tmp_path / f"{unit}_{timezone}") + + cutoff = pa.scalar(start + timedelta(hours=50), ts_type) + for expr in [ + pc.field("ts") > cutoff, + pc.field("ts") < cutoff, + pc.field("ts") == cutoff, + ]: + assert dataset.to_table(filter=expr) == table.filter(expr) + + def test_sql_current_date(tmp_path: Path): table = pa.table( {"date": pa.array([date(2020, 1, 1), date(2020, 1, 2)], type=pa.date32())} @@ -323,9 +363,56 @@ def test_struct_field_order(tmp_path): assert result == expected +def test_filter_on_column_beside_struct_with_extension_type(tmp_path): + tensor_type = pa.fixed_shape_tensor(pa.float32(), (3,)) + tensor_arr = pa.ExtensionArray.from_storage( + tensor_type, + pa.FixedSizeListArray.from_arrays(pa.array([1.0, 2.0, 3.0], pa.float32()), 3), + ) + struct_arr = pa.StructArray.from_arrays([tensor_arr], names=["vec"]) + + arrow_table = pa.table( + { + "id": pa.array([1], pa.int64()), + "checkpoint": pa.array([None], pa.int64()), + "items": struct_arr, + } + ) + ds = lance.write_dataset(arrow_table, tmp_path) + + expr = pc.field("checkpoint").is_null() | (pc.field("checkpoint") == 0) + result = ds.to_table(filter=expr) + assert result["id"].to_pylist() == [1] + + +def test_filter_on_column_beside_root_extension_type(tmp_path): + """Filtering should work when the schema has a top-level extension type column. + + fixed_shape_tensor at the root level cannot be converted to a substrait type, + so it must also be replaced with a placeholder. + """ + tensor_type = pa.fixed_shape_tensor(pa.float32(), (3,)) + tensor_arr = pa.ExtensionArray.from_storage( + tensor_type, + pa.FixedSizeListArray.from_arrays(pa.array([1.0, 2.0, 3.0], pa.float32()), 3), + ) + arrow_table = pa.table( + { + "id": pa.array([1], pa.int64()), + "checkpoint": pa.array([None], pa.int64()), + "vec": tensor_arr, + } + ) + ds = lance.write_dataset(arrow_table, tmp_path) + + expr = pc.field("checkpoint").is_null() | (pc.field("checkpoint") == 0) + result = ds.to_table(filter=expr) + assert result["id"].to_pylist() == [1] + + @pytest.mark.skip( - reason="enable this in recurring test https://github.com/lance-format/lance/pull/4190" - " as it requires release mode" + reason="requires a release build; see " + "https://github.com/lance-format/lance/pull/4190" ) def test_filter_depth_limit(): column_name = "a_very_long_column_name" diff --git a/python/python/tests/test_fork.py b/python/python/tests/test_fork.py index f36e13debed..c032781623a 100644 --- a/python/python/tests/test_fork.py +++ b/python/python/tests/test_fork.py @@ -3,6 +3,7 @@ import os import sys +import traceback from pathlib import Path import lance @@ -24,14 +25,7 @@ def create_table(num_rows) -> pa.Table: ) -@pytest.mark.skipif(sys.platform == "win32", reason="Test not applicable on Windows") -def test_table_roundtrip(tmp_path: Path): - uri = tmp_path - - tbl = create_table(100) - lance.write_dataset(tbl, uri) - - os.fork() +def check_reads(uri: Path, tbl: pa.Table): dataset = lance.dataset(uri) assert dataset.uri == str(uri.absolute()) assert tbl.schema == dataset.schema @@ -42,3 +36,36 @@ def test_table_roundtrip(tmp_path: Path): table = dataset.to_table(columns=["a"], limit=20) assert len(table) == 20 + + +@pytest.mark.skipif(sys.platform == "win32", reason="Test not applicable on Windows") +def test_table_roundtrip(tmp_path: Path): + uri = tmp_path + + tbl = create_table(100) + lance.write_dataset(tbl, uri) + + child = os.fork() + if child == 0: + # The child has to leave through os._exit. Returning would run the rest + # of the pytest session a second time, and under pytest-xdist it would + # also report a second result for this test over the execnet connection + # inherited from the worker, which crashes the controller's scheduler. + status = 0 + try: + check_reads(uri, tbl) + except BaseException: + traceback.print_exc() + status = 1 + os._exit(status) + + check_reads(uri, tbl) + _, wait_status = os.waitpid(child, 0) + exitcode = os.waitstatus_to_exitcode(wait_status) + # Nothing the child raises can reach this process, so its exit status is the + # only evidence the post-fork read worked. On macOS the child dies of a + # signal before finishing that read -- long-standing behaviour that this + # test could not see while it never waited on the child at all. Checking it + # where it does hold at least keeps the Linux path honest. + if sys.platform != "darwin": + assert exitcode == 0, "reading the dataset failed in the forked child" diff --git a/python/python/tests/test_fragment.py b/python/python/tests/test_fragment.py index 40e2a69df22..8b4a317e8db 100644 --- a/python/python/tests/test_fragment.py +++ b/python/python/tests/test_fragment.py @@ -21,8 +21,8 @@ write_dataset, ) from lance.debug import format_fragment -from lance.file import LanceFileWriter -from lance.fragment import write_fragments +from lance.file import LanceFileReader, LanceFileWriter +from lance.fragment import RowIdMeta, RowIdSequence, write_fragments from lance.progress import FileSystemFragmentWriteProgress @@ -145,6 +145,75 @@ def test_write_fragments(tmp_path: Path): assert progress.complete_called == 2 +def _write_single_fragment(write_api, batches, uri, **kwargs): + schema = pa.schema([pa.field("a", pa.int64())]) + reader = pa.RecordBatchReader.from_batches( + schema, + [pa.record_batch([pa.array(values)], schema=schema) for values in batches], + ) + if write_api == "dataset": + dataset = write_dataset(reader, uri, **kwargs) + return dataset.get_fragments()[0].metadata + return write_fragments(reader, uri, **kwargs)[0] + + +@pytest.mark.parametrize("write_api", ["dataset", "fragments"]) +@pytest.mark.parametrize( + "options,batches,expected_pages", + [ + pytest.param( + {"data_cache_bytes": 1}, + [[1, 2, 3], [4, 5, 6]], + 2, + id="data-cache", + ), + pytest.param( + {"max_page_bytes": 1}, + [[1, 2, 3]], + 3, + id="max-page", + ), + ], +) +def test_write_page_options( + tmp_path: Path, write_api, options, batches, expected_pages +): + fragment = _write_single_fragment( + write_api, + batches, + tmp_path, + data_storage_version="2.0", + **options, + ) + data_file = tmp_path / "data" / fragment.files[0].path + metadata = LanceFileReader(str(data_file)).metadata() + assert len(metadata.columns[0].pages) == expected_pages + + +@pytest.mark.parametrize("write_api", ["dataset", "fragments"]) +def test_write_rejects_zero_max_page_bytes(tmp_path: Path, write_api): + with pytest.raises(OSError, match="max_page_bytes must be greater than 0, got 0"): + _write_single_fragment( + write_api, + [[1, 2, 3]], + tmp_path, + data_storage_version="2.0", + max_page_bytes=0, + ) + + +@pytest.mark.parametrize("write_api", ["dataset", "fragments"]) +def test_write_page_options_ignored_for_legacy(tmp_path: Path, write_api): + fragment = _write_single_fragment( + write_api, + [[1, 2, 3]], + tmp_path, + data_storage_version="legacy", + max_page_bytes=0, + ) + assert fragment.physical_rows == 3 + + def test_write_fragments_schema_holes(tmp_path: Path): # Create table with 3 cols data = pa.table({"a": range(3)}) @@ -279,7 +348,7 @@ def test_fragment_meta(): "file_size_bytes=100), DataFile(path='1.lance', fields=[1], column_indices=[], " "file_major_version=0, file_minor_version=0, file_size_bytes=None)], " "physical_rows=100, deletion_file=None, row_id_meta=None, " - "created_at_version_meta=None, last_updated_at_version_meta=None)" + "created_at_version_meta=None, last_updated_at_version_meta=None, overlays=[])" ) @@ -323,7 +392,7 @@ def test_mixed_fragment_versions(tmp_path): # Attempt to commit operation = lance.LanceOperation.Overwrite(ds.schema, fragments) - with pytest.raises(OSError, match="All data files must have the same version"): + with pytest.raises(OSError, match="Dataset snapshot mixes V1 and V2 data files"): lance.LanceDataset.commit(ds.uri, operation) @@ -453,6 +522,22 @@ def test_fragment_count_rows(tmp_path: Path): assert fragments[0].count_rows(pc.field("a") < 200) == 200 +def test_fragment_pyarrow_expression_filter(tmp_path: Path): + data = pa.table({"id": [0, 1, 2], "s": ["C:\\temp", "plain", "other"]}) + dataset = write_dataset(data, tmp_path, max_rows_per_file=2) + assert len(dataset.get_fragments()) == 2 + fragment = dataset.get_fragments()[0] + expression = pc.field("s") != "C:\\temp" + + assert dataset.count_rows(filter=expression) == 2 + assert fragment.count_rows(expression) == 1 + assert fragment.scanner(filter=expression).to_table()["id"].to_pylist() == [1] + assert fragment.to_table(filter=expression)["id"].to_pylist() == [1] + assert fragment.to_table(filter=pc.field("s").isin(["C:\\temp"]))[ + "id" + ].to_pylist() == [0] + + @pytest.mark.parametrize("enable_stable_row_ids", [False, True]) def test_fragment_metadata_pickle(tmp_path: Path, enable_stable_row_ids: bool): ds = write_dataset( @@ -472,9 +557,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.""" @@ -610,6 +702,139 @@ def test_fragment_update_columns_with_custom_join_key(tmp_path): assert result["name"][2] == "Chase" # id=3 should have name Chase +def test_fragment_update_columns_with_blob_v2(tmp_path): + data = pa.table( + { + "id": pa.array([1, 2, 3, 4]), + "payload": lance.blob_array([b"one", b"two", b"", None]), + } + ) + dataset_uri = tmp_path / "test_dataset_update_columns_blob_v2" + dataset = lance.write_dataset( + data, + dataset_uri, + data_storage_version="2.2", + ) + + fragment = dataset.get_fragment(0) + updated_fragment, fields_modified = fragment.update_columns( + pa.table( + { + "id": pa.array([2]), + "payload": lance.blob_array([b"NEW"]), + } + ), + left_on="id", + ) + + operation = LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + ) + updated_dataset = LanceDataset.commit( + dataset_uri, + operation, + read_version=dataset.version, + ) + + result = updated_dataset.to_table(blob_handling="all_binary") + assert result["id"].to_pylist() == [1, 2, 3, 4] + assert result["payload"].to_pylist() == [b"one", b"NEW", b"", None] + + +def test_fragment_update_columns_with_nested_blob_v2(tmp_path): + def info_array(names, payloads): + fields = [pa.field("name", pa.string()), lance.blob_field("blob")] + return pa.StructArray.from_arrays( + [pa.array(names), lance.blob_array(payloads)], fields=fields + ) + + dataset_uri = tmp_path / "test_dataset_update_columns_nested_blob_v2" + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array([1, 2]), + "info": info_array(["a", "b"], [b"one", b"two"]), + } + ), + dataset_uri, + data_storage_version="2.2", + ) + + updated_fragment, fields_modified = dataset.get_fragment(0).update_columns( + pa.table( + { + "id": pa.array([2]), + "info": info_array(["B"], [b"NEW"]), + } + ), + left_on="id", + ) + updated_dataset = LanceDataset.commit( + dataset_uri, + LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + ), + read_version=dataset.version, + ) + + info = updated_dataset.to_table(blob_handling="all_binary")["info"].combine_chunks() + assert info.field("name").to_pylist() == ["a", "B"] + assert info.field("blob").to_pylist() == [b"one", b"NEW"] + + +def test_fragment_update_columns_preserves_external_blob_v2(tmp_path): + dataset_uri = tmp_path / "test_dataset_update_columns_external_blob_v2" + external = tmp_path / "existing-payload.bin" + external.write_bytes(b"outside") + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array([1, 2]), + "payload": lance.blob_array([external.as_uri(), b"two"]), + } + ), + dataset_uri, + data_storage_version="2.2", + allow_external_blob_outside_bases=True, + ) + + updated_fragment, fields_modified = dataset.get_fragment(0).update_columns( + pa.table( + { + "id": pa.array([2]), + "payload": lance.blob_array([b"NEW"]), + } + ), + left_on="id", + ) + updated_dataset = LanceDataset.commit( + dataset_uri, + LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + ), + read_version=dataset.version, + ) + + result = updated_dataset.to_table(blob_handling="all_binary") + assert result["payload"].to_pylist() == [b"outside", b"NEW"] + + new_external = tmp_path / "new-payload.bin" + new_external.write_bytes(b"new outside") + with pytest.raises(ValueError, match="outside registered external bases"): + updated_dataset.get_fragment(0).update_columns( + pa.table( + { + "id": pa.array([2]), + "payload": lance.blob_array([new_external.as_uri()]), + } + ), + left_on="id", + ) + + def test_fragment_update_columns_with_nulls(tmp_path): """Test fragment update columns with null values.""" # Create initial dataset @@ -693,6 +918,54 @@ def test_fragment_update_columns_partial_update(tmp_path): assert result["city"] == ["NYC", "LA", "SF"] # Unchanged +def test_fragment_update_columns_with_offsets_rewrite_columns(tmp_path): + """update_columns(with_offsets=True) returns matched row offsets that a + rewrite_columns commit uses to refresh row version metadata for the + matched rows only.""" + data = pa.table( + { + "id": list(range(8)), + "value": [i * 10 for i in range(8)], + } + ) + dataset_uri = tmp_path / "test_dataset_update_columns_with_offsets" + dataset = lance.write_dataset( + data, dataset_uri, max_rows_per_file=4, enable_stable_row_ids=True + ) + assert len(dataset.get_fragments()) == 2 + + # Update two rows of the second fragment, joining on a user column. + fragment = dataset.get_fragments()[1] + update_data = pa.table({"id": [5, 7], "value": [500, 700]}) + updated_fragment, fields_modified, matched_offsets = fragment.update_columns( + update_data, left_on="id", with_offsets=True + ) + # Portable RoaringBitmap serialization of {1, 3}: ids 5 and 7 sit at those + # physical offsets within the second fragment. + assert matched_offsets == ( + b"\x3a\x30\x00\x00\x01\x00\x00\x00\x00\x00\x01\x00" + b"\x10\x00\x00\x00\x01\x00\x03\x00" + ) + + op = LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + update_mode="rewrite_columns", + updated_fragment_offsets={updated_fragment.id: matched_offsets}, + ) + updated_dataset = lance.LanceDataset.commit( + str(dataset_uri), op, read_version=dataset.version + ) + + result = updated_dataset.to_table( + columns=["id", "value", "_row_last_updated_at_version"] + ).sort_by("id") + assert result["value"].to_pylist() == [0, 10, 20, 30, 40, 500, 60, 700] + # Only the matched rows carry the commit's version. + versions = result["_row_last_updated_at_version"].to_pylist() + assert versions == [1, 1, 1, 1, 1, 2, 1, 2] + + def test_fragment_update_columns_no_match(tmp_path): """Test update when no rows match the join condition.""" # Create initial dataset @@ -717,7 +990,10 @@ def test_fragment_update_columns_no_match(tmp_path): # Get the fragment and update columns fragment = dataset.get_fragment(0) - updated_fragment, fields_modified = fragment.update_columns(update_data) + updated_fragment, fields_modified, matched_offsets = fragment.update_columns( + update_data, with_offsets=True + ) + assert matched_offsets == b"\x3a\x30\x00\x00\x00\x00\x00\x00" # Commit the changes @@ -793,3 +1069,605 @@ def test_fragment_update_columns_error_on_metadata_column(tmp_path): "metadata column" in str(exc_info.value).lower() or "cannot be updated" in str(exc_info.value).lower() ) + + +def test_fragment_delete_rows(tmp_path: Path): + # LanceFragment.delete_rows deletes by local row offset (not a predicate): + # exactly the given rows are removed, the rest survive, and the fragment is + # gone when every row is deleted. + data = pa.table({"a": range(100), "b": range(100)}) + dataset = lance.write_dataset(data, tmp_path) + frag = dataset.get_fragment(0) + + new_meta = frag.delete_rows([0, 5, 99]) + assert new_meta is not None + op = LanceOperation.Delete( + updated_fragments=[new_meta], + deleted_fragment_ids=[], + predicate="delete_rows([0, 5, 99])", + ) + dataset = lance.LanceDataset.commit(dataset.uri, op, read_version=dataset.version) + + assert dataset.count_rows() == 97 + remaining = set(dataset.to_table(columns=["a"])["a"].to_pylist()) + assert {0, 5, 99}.isdisjoint(remaining) + assert {1, 98}.issubset(remaining) + + # Deleting every row removes the fragment entirely (returns None). + frag = lance.dataset(tmp_path).get_fragment(0) + assert frag.delete_rows(range(100)) is None + + # Offsets outside the fragment's physical rows are rejected, not silently + # written to the deletion file. + frag = lance.dataset(tmp_path).get_fragment(0) + with pytest.raises(ValueError, match="out of range"): + frag.delete_rows([100]) + with pytest.raises(ValueError, match="out of range"): + frag.delete_rows([0, 50, 1000]) + + +def test_fragment_take_with_json_column(tmp_path): + """Test that FileFragment.take returns JSON columns in Arrow JSON format.""" + json_type = pa.json_() + data = pa.table( + { + "id": pa.array(range(10), type=pa.int64()), + "meta": pa.array( + [f'{{"val":{i}}}' for i in range(10)], + type=json_type, + ), + } + ) + dataset_uri = tmp_path / "test_frag_take_json" + dataset = lance.write_dataset(data, dataset_uri) + + fragment = dataset.get_fragment(0) + result = fragment.take([1, 4, 7]) + + # Should return arrow.json type (Utf8), not lance.json (LargeBinary) + meta_field = result.schema.field("meta") + assert meta_field.type == pa.utf8() or meta_field.type == pa.json_(), ( + f"Expected arrow.json (Utf8), got {meta_field.type}" + ) + + metas = result.column("meta").to_pylist() + assert metas[0] == '{"val":1}' + assert metas[1] == '{"val":4}' + assert metas[2] == '{"val":7}' + + +def test_fragment_create_with_json_column(tmp_path): + """Test that LanceFragment.create works with Arrow JSON extension type. + + Previously the single-fragment create path skipped the Arrow JSON (Utf8) -> + Lance JSON (JSONB LargeBinary) conversion that write_dataset/write_fragments + perform, so the raw UTF-8 string bytes were written into a column whose schema + declared JSONB. Reads then miss-decoded the bytes and returned garbage. + """ + json_type = pa.json_() + data = pa.table( + { + "uid": pa.array(["a", "b", "c", "d"], type=pa.utf8()), + "payload": pa.array( + ['{"x":1}', '{"x":2}', '{"y":3}', '{"y":4}'], + type=json_type, + ), + } + ) + + frag = LanceFragment.create(tmp_path, data) + operation = LanceOperation.Overwrite(data.schema, [frag]) + dataset = LanceDataset.commit(tmp_path, operation) + + result = dataset.to_table() + assert result.column("uid").to_pylist() == ["a", "b", "c", "d"] + payloads = result.column("payload").to_pylist() + assert [json.loads(p) for p in payloads] == [ + {"x": 1}, + {"x": 2}, + {"y": 3}, + {"y": 4}, + ] + + +def test_fragment_update_columns_with_json_column(tmp_path): + """Test that fragment update_columns works with Arrow JSON extension type. + + Previously this would fail with a type mismatch error because the + HashJoiner didn't convert Arrow JSON (Utf8) to Lance JSON (LargeBinary). + """ + # Create initial dataset with a JSON extension type column + json_type = pa.json_() + data = pa.table( + { + "id": pa.array([1, 2, 3, 4, 5], type=pa.int64()), + "name": pa.array(["a", "b", "c", "d", "e"], type=pa.utf8()), + "meta": pa.array( + ['{"x":1}', '{"x":2}', '{"x":3}', '{"x":4}', '{"x":5}'], + type=json_type, + ), + } + ) + dataset_uri = tmp_path / "test_update_cols_json" + dataset = lance.write_dataset(data, dataset_uri) + + # Prepare update data: update the JSON column for some rows + update_data = pa.table( + { + "_rowid": pa.array([1, 3], type=pa.uint64()), + "meta": pa.array( + ['{"updated":true,"id":2}', '{"updated":true,"id":4}'], + type=json_type, + ), + } + ) + + # This should NOT raise a type mismatch error + fragment = dataset.get_fragment(0) + updated_fragment, fields_modified = fragment.update_columns(update_data) + + assert len(fields_modified) > 0 + + # Commit and verify + op = LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + ) + updated_dataset = lance.LanceDataset.commit( + str(dataset_uri), op, read_version=dataset.version + ) + + result = updated_dataset.to_table() + ids = result.column("id").to_pylist() + metas = result.column("meta").to_pylist() + + for i, (id_val, meta_val) in enumerate(zip(ids, metas)): + meta = json.loads(meta_val) if isinstance(meta_val, str) else meta_val + if id_val == 2 or id_val == 4: + assert "updated" in meta_val or meta.get("updated") is True, ( + f"id={id_val} should be updated, got {meta_val}" + ) + else: + assert "x" in meta_val or "x" in str(meta), ( + f"id={id_val} should have original value, got {meta_val}" + ) + + +def test_row_id_sequence_from_range(): + # A step-of-one range is the compact case and must not materialize its ids. + sequence = RowIdSequence(range(10)) + + assert len(sequence) == 10 + assert sequence.to_pyarrow() == pa.array(range(10), type=pa.uint64()) + assert sequence.to_pyarrow().type == pa.uint64() + assert list(sequence) == list(range(10)) + + +@pytest.mark.parametrize( + "row_ids", + [ + pytest.param(pa.array([1, 2, 3]), id="pyarrow_array"), + pytest.param(pa.array([1, 2, 3], type=pa.uint64()), id="pyarrow_uint64_array"), + pytest.param(pa.array([1, 2, 3], type=pa.int8()), id="pyarrow_int8_array"), + pytest.param(pa.array([1, 2, 3], type=pa.uint16()), id="pyarrow_uint16_array"), + # A slice carries an offset into a larger buffer; only the slice counts. + pytest.param(pa.array([9, 1, 2, 3, 9]).slice(1, 3), id="pyarrow_sliced_array"), + pytest.param(pa.chunked_array([[1], [2, 3]]), id="pyarrow_chunked_array"), + pytest.param((x for x in [1, 2, 3]), id="generator"), + pytest.param([1, 2, 3], id="list"), + pytest.param(range(1, 4), id="range"), + pytest.param(range(3, 0, -1), id="descending_range"), + ], +) +def test_row_id_sequence_accepts_input_types(row_ids): + sequence = RowIdSequence(row_ids) + + assert sorted(sequence) == [1, 2, 3] + + +@pytest.mark.parametrize( + "row_ids", + [ + pytest.param([], id="empty_list"), + pytest.param(range(0), id="empty_range"), + pytest.param(pa.array([], type=pa.uint64()), id="empty_array"), + ], +) +def test_row_id_sequence_empty(row_ids): + sequence = RowIdSequence(row_ids) + + assert len(sequence) == 0 + assert list(sequence) == [] + assert sequence.to_pyarrow() == pa.array([], type=pa.uint64()) + + +@pytest.mark.parametrize( + "row_ids", + [ + pytest.param(list(range(4100)), id="contiguous"), + pytest.param(list(range(0, 8200, 2)), id="gapped"), + pytest.param(list(range(4100))[::-1], id="unsorted"), + ], +) +def test_row_id_sequence_iterates_large_sequences(row_ids): + # Each shape picks a different segment encoding, so all of them have to + # round-trip through iteration. + sequence = RowIdSequence(row_ids) + + assert list(sequence) == row_ids + # Each call must hand back a fresh iterator rather than a spent one. + assert list(sequence) == row_ids + + +def test_row_id_sequence_from_range_above_isize(): + # Range bounds are read as isize; beyond that the values are read one at a + # time instead, which must still cover the whole uint64 row id domain. + start = 2**63 + sequence = RowIdSequence(range(start, start + 3)) + + assert list(sequence) == [start, start + 1, start + 2] + + +def test_row_id_sequence_unsorted_round_trips(): + sequence = RowIdSequence([12, 11, 10]) + + assert list(sequence) == [12, 11, 10] + assert sequence.to_pyarrow() == pa.array([12, 11, 10], type=pa.uint64()) + + +@pytest.mark.parametrize( + "row_ids", + [ + pytest.param([1, 1, 2], id="adjacent"), + pytest.param([1, 2, 3, 1], id="separated"), + pytest.param(pa.array([5, 3, 5]), id="unsorted_array"), + ], +) +def test_row_id_sequence_rejects_duplicates(row_ids): + with pytest.raises(ValueError, match="Row ids must be unique"): + RowIdSequence(row_ids) + + +@pytest.mark.parametrize( + ("row_ids", "message"), + [ + pytest.param(pa.array([1, None, 3]), "must not be null", id="null_in_array"), + pytest.param(pa.array([1.5, 2.5]), "array of integers", id="float_array"), + pytest.param(pa.array([-1, 2]), "uint64", id="negative_in_array"), + pytest.param(range(-5, 5), "non-negative", id="negative_range"), + pytest.param(5, "iterable of integers", id="not_iterable"), + ], +) +def test_row_id_sequence_rejects_invalid_input(row_ids, message): + with pytest.raises((ValueError, TypeError), match=message): + RowIdSequence(row_ids) + + +def test_row_id_sequence_metadata_round_trip(): + sequence = RowIdSequence([7, 12, 3]) + + metadata = sequence.to_inline_metadata() + assert isinstance(metadata, RowIdMeta) + assert RowIdSequence.from_inline_metadata(metadata) == sequence + + +def test_row_id_sequence_equality_and_repr(): + assert RowIdSequence(range(3)) == RowIdSequence([0, 1, 2]) + assert RowIdSequence(range(3)) != RowIdSequence([0, 1]) + # Comparing against an unrelated type is False rather than an error. + assert RowIdSequence(range(3)) != "not a sequence" + + assert repr(RowIdSequence([1, 2])) == "RowIdSequence([1, 2])" + assert repr(RowIdSequence(range(12))) == ( + "RowIdSequence([0, 1, 2, 3, 4, 5, 6, 7, 8, 9, ...], len=12)" + ) + + +def test_row_id_sequence_pickle(): + sequence = RowIdSequence([7, 12, 3]) + + assert pickle.loads(pickle.dumps(sequence)) == sequence + + +def _row_ids_by_id(dataset: LanceDataset) -> dict: + table = dataset.to_table(columns=["id"], with_row_id=True) + return dict(zip(table["id"].to_pylist(), table["_rowid"].to_pylist())) + + +def test_row_id_sequence_preserves_ids_in_manual_update(tmp_path: Path): + # An update assembled externally (delete the old row, append a replacement) + # keeps the row's identity only if the new fragment carries its row id. + dataset = write_dataset( + pa.table({"id": [1, 2, 3, 4], "v": [10, 20, 30, 40]}), + tmp_path, + max_rows_per_file=2, + enable_stable_row_ids=True, + ) + row_ids_before = _row_ids_by_id(dataset) + + updated_fragment = dataset.get_fragments()[0].delete("id = 2") + (new_fragment,) = write_fragments(pa.table({"id": [2], "v": [99]}), tmp_path) + new_fragment.row_id_meta = RowIdSequence([row_ids_before[2]]).to_inline_metadata() + + dataset = LanceDataset.commit( + tmp_path, + LanceOperation.Update( + removed_fragment_ids=[], + updated_fragments=[updated_fragment], + new_fragments=[new_fragment], + fields_modified=[], + ), + read_version=dataset.version, + ) + + assert _row_ids_by_id(dataset) == row_ids_before + assert dataset.to_table().sort_by("id").to_pydict() == { + "id": [1, 2, 3, 4], + "v": [10, 99, 30, 40], + } + + +def test_row_id_sequence_reads_back_fragment_metadata(tmp_path: Path): + dataset = write_dataset( + pa.table({"a": range(10)}), + tmp_path, + max_rows_per_file=5, + enable_stable_row_ids=True, + ) + + sequences = [ + RowIdSequence.from_inline_metadata(fragment.metadata.row_id_meta) + for fragment in dataset.get_fragments() + ] + + assert [list(sequence) for sequence in sequences] == [ + [0, 1, 2, 3, 4], + [5, 6, 7, 8, 9], + ] + + +def test_fragment_validate(tmp_path: Path): + dataset = write_dataset( + pa.table({"a": range(100), "b": range(100)}), + tmp_path, + max_rows_per_file=50, + ) + # A valid fragment validates without raising. + for fragment in dataset.get_fragments(): + assert fragment.validate() is None + + +def test_fragment_validate_across_data_files(tmp_path: Path): + # add_columns writes a second data file per fragment; validate must still + # pass (field ids increasing and unique across a fragment's data files). + dataset = write_dataset(pa.table({"a": range(100)}), tmp_path, max_rows_per_file=50) + dataset.add_columns({"b": "a + 1"}) + for fragment in dataset.get_fragments(): + assert len(fragment.data_files()) > 1 + fragment.validate() + + +def test_fragment_validate_after_delete(tmp_path: Path): + dataset = write_dataset(pa.table({"a": range(100)}), tmp_path, max_rows_per_file=50) + dataset.delete("a < 10") + # A fragment carrying a deletion vector still validates. + for fragment in dataset.get_fragments(): + fragment.validate() + + +def _dataset_with_scalar_index(tmp_path: Path) -> LanceDataset: + dataset = write_dataset( + pa.table({"val": range(10000), "other": range(10000)}), + tmp_path, + max_rows_per_file=5000, + ) + dataset.create_scalar_index("val", index_type="BTREE") + return dataset + + +def test_fragment_scanner_use_scalar_index_disables_index_query(tmp_path: Path): + # A filtered fragment scan on an indexed column plans a dataset-wide + # ScalarIndexQuery (and caches its index pages) unless the scan opts out. + dataset = _dataset_with_scalar_index(tmp_path) + fragment = dataset.get_fragments()[0] + filt = "val >= 10 AND val <= 20" + + default_plan = fragment.scanner(filter=filt, with_row_id=True).explain_plan(True) + assert "ScalarIndexQuery" in default_plan + + opted_out_plan = fragment.scanner( + filter=filt, with_row_id=True, use_scalar_index=False + ).explain_plan(True) + assert "ScalarIndexQuery" not in opted_out_plan + + +@pytest.mark.parametrize("use_scalar_index", [None, True, False]) +def test_fragment_scanner_matches_dataset_scanner(tmp_path: Path, use_scalar_index): + # The fragment scanner must build the same plan as the dataset scanner + # restricted to that single fragment. + dataset = _dataset_with_scalar_index(tmp_path) + fragment = dataset.get_fragments()[0] + filt = "val >= 10 AND val <= 20" + + frag_plan = fragment.scanner( + filter=filt, with_row_id=True, use_scalar_index=use_scalar_index + ).explain_plan(True) + dataset_plan = dataset.scanner( + fragments=[fragment], + filter=filt, + with_row_id=True, + use_scalar_index=use_scalar_index, + ).explain_plan(True) + assert frag_plan == dataset_plan + + +def _fragment_with_deletions(tmp_path: Path) -> LanceFragment: + dataset = write_dataset(pa.table({"a": range(20)}), tmp_path, max_rows_per_file=10) + dataset.delete("a < 3") + return dataset.get_fragments()[0] + + +def test_fragment_scanner_include_deleted_rows(tmp_path: Path): + fragment = _fragment_with_deletions(tmp_path) + assert fragment.physical_rows == 10 + assert fragment.num_deletions == 3 + + # By default the deleted rows are omitted. + default = fragment.to_table(with_row_id=True) + assert default.num_rows == 7 + assert default["a"].to_pylist() == list(range(3, 10)) + + # With include_deleted_rows the deleted rows are surfaced with a null _rowid. + included = fragment.scanner(with_row_id=True, include_deleted_rows=True).to_table() + assert included.num_rows == fragment.physical_rows + assert included["a"].to_pylist() == list(range(10)) + assert included["_rowid"].null_count == fragment.num_deletions + + +def test_fragment_scanner_include_deleted_rows_requires_row_id(tmp_path: Path): + fragment = _fragment_with_deletions(tmp_path) + with pytest.raises(ValueError, match="with_row_id"): + fragment.scanner(include_deleted_rows=True).to_table() + + +def test_fragment_scanner_include_deleted_rows_matches_dataset_scanner(tmp_path: Path): + dataset = write_dataset(pa.table({"a": range(20)}), tmp_path, max_rows_per_file=10) + dataset.delete("a < 3") + fragment = dataset.get_fragments()[0] + + frag_plan = fragment.scanner( + with_row_id=True, include_deleted_rows=True + ).explain_plan(True) + dataset_plan = dataset.scanner( + fragments=[fragment], with_row_id=True, include_deleted_rows=True + ).explain_plan(True) + assert frag_plan == dataset_plan + + +@pytest.mark.parametrize( + ("late_materialization", "is_late"), + [ + pytest.param(None, False, id="default"), + pytest.param(True, True, id="all_late"), + pytest.param(False, False, id="all_early"), + pytest.param(["values"], True, id="late_column"), + pytest.param(["filter"], False, id="early_column"), + ], +) +def test_fragment_scanner_late_materialization( + tmp_path: Path, late_materialization, is_late +): + # With no index, the plan shows whether `values` is fetched late (a take over + # the row stream) or early (materialized in the scan projection). + dataset = write_dataset( + pa.table({"filter": range(2000), "values": range(2000)}), + tmp_path, + data_storage_version="stable", + ) + fragment = dataset.get_fragments()[0] + + plan = fragment.scanner( + filter="filter % 2 == 0", late_materialization=late_materialization + ).explain_plan(True) + + if is_late: + assert "projection=[values], source=stream" in plan + else: + assert "projection=[filter, values]" in plan + + +def test_fragment_scanner_rejects_invalid_late_materialization(tmp_path: Path): + dataset = write_dataset(pa.table({"a": range(10)}), tmp_path) + fragment = dataset.get_fragments()[0] + + with pytest.raises( + ValueError, match="late_materialization must be a bool or a list of strings" + ): + fragment.scanner(late_materialization=123) + + +def test_fragment_scanner_io_buffer_size_forwarded(tmp_path: Path): + # io_buffer_size has no plan-visible marker, so assert it is accepted through + # both scan entry points and leaves results unchanged. + dataset = write_dataset(pa.table({"val": range(1000)}), tmp_path) + fragment = dataset.get_fragments()[0] + filt = "val < 100" + expected = fragment.to_table(filter=filt) + + assert fragment.to_table(filter=filt, io_buffer_size=4 * 1024 * 1024) == expected + + batched = pa.Table.from_batches( + list(fragment.to_batches(filter=filt, io_buffer_size=4 * 1024 * 1024)) + ) + assert batched == expected + + +def test_fragment_scanner_strict_batch_size(tmp_path: Path): + dataset = write_dataset(pa.table({"a": range(1000)}), tmp_path) + fragment = dataset.get_fragments()[0] + filt = "a % 3 == 0" + + # A filtered scan emits uneven, sub-batch_size batches by default. + loose = [b.num_rows for b in fragment.to_batches(batch_size=100, filter=filt)] + assert any(n < 100 for n in loose[:-1]) + + # strict_batch_size coalesces to exactly batch_size (except the last batch). + strict = [ + b.num_rows + for b in fragment.to_batches( + batch_size=100, filter=filt, strict_batch_size=True + ) + ] + assert all(n == 100 for n in strict[:-1]) + assert sum(strict) == sum(loose) + + +def test_fragment_scanner_batch_size_bytes(tmp_path: Path): + # A small byte budget over wide rows forces many more batches than the + # default, without changing the results. + dataset = write_dataset(pa.table({"s": ["x" * 1024] * 2000}), tmp_path) + fragment = dataset.get_fragments()[0] + + default_batches = list(fragment.to_batches()) + small_budget = list(fragment.to_batches(batch_size_bytes=64 * 1024)) + assert len(small_budget) > len(default_batches) + assert pa.Table.from_batches(small_budget) == fragment.to_table() + + +@pytest.mark.parametrize("enable_stable_row_ids", [False, True]) +def test_merge_commits_staged_new_fragments( + tmp_path: Path, enable_stable_row_ids: bool +): + # Regression test for https://github.com/lance-format/lance/issues/7702: a + # Merge that introduces staged fragments must assign fresh fragment ids + # and, on stable row id datasets, row ids at commit time. + tab = pa.table({"id": [1, 2, 3, 4], "v": [10, 20, 30, 40]}) + ds = write_dataset( + tab, + tmp_path, + max_rows_per_file=2, + enable_stable_row_ids=enable_stable_row_ids, + ) + new_frags = write_fragments(pa.table({"id": [5, 6], "v": [50, 60]}), tmp_path) + assert all(f.id == 0 for f in new_frags) + + if enable_stable_row_ids: + pre = ds.to_table(with_row_id=True) + pre_row_ids = dict(zip(pre["id"].to_pylist(), pre["_rowid"].to_pylist())) + + all_frags = [f.metadata for f in ds.get_fragments()] + list(new_frags) + merge = lance.LanceOperation.Merge(all_frags, ds.lance_schema) + ds = lance.LanceDataset.commit(tmp_path, merge, read_version=ds.version) + + frag_ids = [f.fragment_id for f in ds.get_fragments()] + assert frag_ids == [0, 1, 2] + table = ds.to_table(with_row_id=True) + assert sorted(table["id"].to_pylist()) == [1, 2, 3, 4, 5, 6] + row_ids = table["_rowid"].to_pylist() + assert len(set(row_ids)) == 6 + if enable_stable_row_ids: + assert sorted(row_ids) == list(range(6)) + assert all(f.metadata.row_id_meta is not None for f in ds.get_fragments()) + # Pre-existing rows keep their exact row ids through the merge. + post_row_ids = dict(zip(table["id"].to_pylist(), row_ids)) + assert {i: post_row_ids[i] for i in pre_row_ids} == pre_row_ids diff --git a/python/python/tests/test_fragment_typing.py b/python/python/tests/test_fragment_typing.py new file mode 100644 index 00000000000..1b33b96919e --- /dev/null +++ b/python/python/tests/test_fragment_typing.py @@ -0,0 +1,59 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Type-checking regression tests for the fragment write APIs. + +This module is part of the pyright target configured in ``pyproject.toml``, so +a regression in the annotations below fails the repository type check and not +only the runtime suite. + +It is separate from ``test_fragment.py`` because that file currently carries +pre-existing pyright diagnostics unrelated to these APIs, so it cannot join the +type-check target without a much larger cleanup. + +The results are bound to annotated locals on purpose: that pins the selected +overload, and pyright rejects the ``None`` argument if ``max_rows_per_group`` +regresses to a plain ``int``. +""" + +from pathlib import Path +from typing import TYPE_CHECKING, List + +import pyarrow as pa +from lance.fragment import FragmentMetadata, LanceFragment, write_fragments + +if TYPE_CHECKING: + from lance import Transaction + + +def test_write_fragments_accepts_none_max_rows_per_group(tmp_path: Path) -> None: + table = pa.table({"a": range(8)}) + + fragments: List[FragmentMetadata] = write_fragments( + table, str(tmp_path / "fragments"), max_rows_per_group=None + ) + assert len(fragments) == 1 + assert fragments[0].physical_rows == 8 + + +def test_write_fragments_transaction_accepts_none_max_rows_per_group( + tmp_path: Path, +) -> None: + table = pa.table({"a": range(8)}) + + transaction: "Transaction" = write_fragments( + table, + str(tmp_path / "transaction"), + max_rows_per_group=None, + return_transaction=True, + ) + assert transaction.operation is not None + + +def test_fragment_create_accepts_none_max_rows_per_group(tmp_path: Path) -> None: + table = pa.table({"a": range(8)}) + + fragment: FragmentMetadata = LanceFragment.create( + str(tmp_path / "create"), table, max_rows_per_group=None + ) + assert fragment.physical_rows == 8 diff --git a/python/python/tests/test_geo.py b/python/python/tests/test_geo.py index c011c2de3de..f7d9d7b3e1a 100644 --- a/python/python/tests/test_geo.py +++ b/python/python/tests/test_geo.py @@ -19,6 +19,19 @@ ) +def _query_point_ids(dataset: lance.LanceDataset, wkt: str) -> list[int]: + sql = f""" + SELECT id, point + FROM dataset + WHERE St_Intersects(point, ST_GeomFromText('{wkt}')) + """ + return [ + value + for batch in dataset.sql(sql).build().to_batch_records() + for value in batch.column("id").to_pylist() + ] + + def test_geo_types(tmp_path: Path): uri = str(tmp_path / "test_geo_types.lance") # Points @@ -153,3 +166,276 @@ def query(ds: lance.LanceDataset, has_index=False): table_with_index = query(ds, has_index=True) assert table_with_index == table_without_index + + +def test_rtree_segment_merge_and_commit(tmp_path: Path): + num_points = 120 + points_2d = points( + [ + np.arange(num_points, dtype=np.float64), + np.arange(num_points, dtype=np.float64), + ] + ) + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field(point("xy")).with_name("point"), + ] + ) + table = pa.Table.from_arrays( + [np.arange(num_points, dtype=np.int64), points_2d], schema=schema + ) + ds = lance.write_dataset( + table, + str(tmp_path / "segmented_rtree.lance"), + max_rows_per_file=40, + ) + fragments = ds.get_fragments() + assert len(fragments) == 3 + segments = [ + ds.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree", + fragment_ids=[fragment.fragment_id], + ) + for fragment in fragments + ] + + merged = ds.merge_existing_index_segments(segments) + assert set(merged.fragment_ids) == {fragment.fragment_id for fragment in fragments} + ds = ds.commit_existing_index_segments("point_rtree", "point", [merged]) + + sql = """ + SELECT id, point + FROM dataset + WHERE St_Intersects(point, ST_GeomFromText('LINESTRING (10 10, 110 110)')) + """ + indexed = pa.Table.from_batches(ds.sql(sql).build().to_batch_records()) + assert indexed["id"].to_pylist() == list(range(10, 111)) + explain = ( + pa.Table.from_batches( + ds.sql("EXPLAIN ANALYZE " + sql).build().to_batch_records() + ) + .to_pandas() + .to_string() + ) + assert "ScalarIndexQuery" in explain + + +def test_staged_rtree_after_rewrite_columns(tmp_path: Path): + uri = str(tmp_path / "stale_rtree.lance") + point_type = point("xy") + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field(point_type).with_name("point"), + ] + ) + dataset = lance.write_dataset( + pa.Table.from_arrays( + [ + pa.array([0, 1], type=pa.int64()), + points([np.array([0.0, 1.0]), np.array([0.0, 1.0])]), + ], + schema=schema, + ), + uri, + ) + segment = dataset.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree", + fragment_ids=[0], + ) + + update_schema = pa.schema( + [ + pa.field("_rowid", pa.uint64()), + pa.field(point_type).with_name("point"), + ] + ) + update = pa.Table.from_arrays( + [ + pa.array([0], type=pa.uint64()), + points([np.array([10.0]), np.array([10.0])]), + ], + schema=update_schema, + ) + fragment, fields = dataset.get_fragment(0).update_columns(update) + updated = lance.LanceDataset.commit( + uri, + lance.LanceOperation.Update( + updated_fragments=[fragment], + fields_modified=fields, + ), + read_version=dataset.version, + ) + + assert _query_point_ids(updated, "POINT (10 10)") == [0] + committed = dataset.commit_existing_index_segments( + "point_rtree", + "point", + [segment], + ) + assert committed.describe_indices()[0].segments[0].fragment_ids == set() + assert _query_point_ids(committed, "POINT (10 10)") == [0] + + +def test_rtree_rejects_distributed_uuid_reuse(tmp_path: Path): + uri = str(tmp_path / "uuid_reuse.lance") + num_points = 120 + point_type = point("xy") + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field(point_type).with_name("point"), + ] + ) + dataset = lance.write_dataset( + pa.Table.from_arrays( + [ + pa.array(range(num_points), type=pa.int64()), + points( + [ + np.arange(num_points, dtype=np.float64), + np.arange(num_points, dtype=np.float64), + ] + ), + ], + schema=schema, + ), + uri, + max_rows_per_file=40, + ) + dataset.create_scalar_index("point", "RTREE") + index_uuid = dataset.describe_indices()[0].segments[0].uuid + + with pytest.raises( + ValueError, + match="index_uuid is no longer accepted for RTree distributed index builds", + ): + dataset.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree_reuse", + fragment_ids=[0], + index_uuid=index_uuid, + ) + + assert _query_point_ids( + lance.dataset(uri), + "LINESTRING (100 100, 110 110)", + ) == list(range(100, 111)) + + +def test_rtree_merge_all_deleted_stable_row_ids(tmp_path: Path): + uri = str(tmp_path / "all_deleted.lance") + point_type = point("xy") + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field(point_type).with_name("point"), + ] + ) + dataset = lance.write_dataset( + pa.Table.from_arrays( + [ + pa.array([0, 1], type=pa.int64()), + points([np.array([0.0, 1.0]), np.array([0.0, 1.0])]), + ], + schema=schema, + ), + uri, + enable_stable_row_ids=True, + ) + segment = dataset.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree", + fragment_ids=[0], + ) + + dataset.delete("true") + merged = dataset.merge_existing_index_segments([segment]) + assert merged.fragment_ids == set() + committed = dataset.commit_existing_index_segments( + "point_rtree", + "point", + [merged], + ) + assert _query_point_ids(committed, "POINT (0 0)") == [] + + +def test_rtree_merge_preserves_newer_fragment_coverage(tmp_path: Path): + uri = str(tmp_path / "mixed_versions.lance") + point_type = point("xy") + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field(point_type).with_name("point"), + ] + ) + + def batch(start: int, stop: int) -> pa.Table: + values = np.arange(start, stop, dtype=np.float64) + return pa.Table.from_arrays( + [ + pa.array(range(start, stop), type=pa.int64()), + points([values, values]), + ], + schema=schema, + ) + + dataset = lance.write_dataset(batch(0, 40), uri) + first = dataset.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree", + fragment_ids=[0], + ) + dataset = lance.write_dataset(batch(40, 80), uri, mode="append") + second = dataset.create_index_uncommitted( + column="point", + index_type="RTREE", + name="point_rtree", + fragment_ids=[1], + ) + + merged = dataset.merge_existing_index_segments([first, second]) + assert merged.fragment_ids == {0, 1} + assert merged.dataset_version == dataset.version + + update_schema = pa.schema( + [ + pa.field("_rowid", pa.uint64()), + pa.field(point_type).with_name("point"), + ] + ) + update = pa.Table.from_arrays( + [ + pa.array([1 << 32], type=pa.uint64()), + points([np.array([100.0]), np.array([100.0])]), + ], + schema=update_schema, + ) + fragment, fields = dataset.get_fragment(1).update_columns(update) + dataset = lance.LanceDataset.commit( + uri, + lance.LanceOperation.Update( + updated_fragments=[fragment], + fields_modified=fields, + ), + read_version=dataset.version, + ) + + committed = dataset.commit_existing_index_segments( + "point_rtree", + "point", + [merged], + ) + assert committed.describe_indices()[0].segments[0].fragment_ids == {0} + assert _query_point_ids( + committed, + "POINT (100 100)", + ) == [40] diff --git a/python/python/tests/test_indices.py b/python/python/tests/test_indices.py index 26ab6e99162..44db637fa5c 100644 --- a/python/python/tests/test_indices.py +++ b/python/python/tests/test_indices.py @@ -8,27 +8,37 @@ import numpy as np import pyarrow as pa import pytest -from lance.file import LanceFileReader +from lance.file import LanceFileReader, LanceFileWriter from lance.indices import IndicesBuilder, IvfModel, PqModel -NUM_ROWS_PER_FRAGMENT = 10000 DIMENSION = 128 NUM_SUBVECTORS = 8 NUM_FRAGMENTS = 3 -NUM_ROWS = NUM_ROWS_PER_FRAGMENT * NUM_FRAGMENTS -NUM_PARTITIONS = round(np.sqrt(NUM_ROWS)) - - SMALL_ROWS_PER_FRAGMENT = 100 SMALL_NUM_ROWS = SMALL_ROWS_PER_FRAGMENT * NUM_FRAGMENTS - - -def make_ds(num_rows: int, rows_per_frag: int, tmpdir: pathlib.Path, dtype: str): - vectors = np.random.randn(num_rows, DIMENSION).astype(dtype) - vectors.shape = -1 +SMALL_NUM_PARTITIONS = round(np.sqrt(SMALL_NUM_ROWS)) +PQ_ROWS_PER_FRAGMENT = 512 +PQ_NUM_ROWS = PQ_ROWS_PER_FRAGMENT * NUM_FRAGMENTS +MOSTLY_NULL_ROWS_PER_FRAGMENT = 2000 +MOSTLY_NULL_NUM_ROWS = MOSTLY_NULL_ROWS_PER_FRAGMENT * NUM_FRAGMENTS +MOSTLY_NULL_NUM_PARTITIONS = round(np.sqrt(MOSTLY_NULL_NUM_ROWS)) +TRAINING_SAMPLE_RATE = 2 +TRAINING_MAX_ITERS = 2 + + +def make_ds( + num_rows: int, + rows_per_frag: int, + tmpdir: pathlib.Path, + dtype: str, + name: str = "dataset", +): + vectors = np.random.default_rng(42).standard_normal((num_rows, DIMENSION)) + vectors = vectors.astype(dtype) + vectors = vectors.reshape(-1) vectors = pa.FixedSizeListArray.from_arrays(vectors, DIMENSION) table = pa.Table.from_arrays([vectors], names=["vectors"]) - uri = str(tmpdir / "dataset") + uri = str(tmpdir / name) ds = lance.write_dataset(table, uri, max_rows_per_file=rows_per_frag) return ds @@ -38,38 +48,95 @@ def make_ds(num_rows: int, rows_per_frag: int, tmpdir: pathlib.Path, dtype: str) params=[np.float16, np.float32, np.float64], ids=["f16", "f32", "f64"], ) -def rand_dataset(tmpdir, request): - return make_ds(NUM_ROWS, NUM_ROWS_PER_FRAGMENT, tmpdir, request.param) +def small_rand_dataset(tmpdir, request): + return make_ds(SMALL_NUM_ROWS, SMALL_ROWS_PER_FRAGMENT, tmpdir, request.param) + + +@pytest.fixture +def small_float32_dataset(tmpdir): + return make_ds(SMALL_NUM_ROWS, SMALL_ROWS_PER_FRAGMENT, tmpdir, np.float32) @pytest.fixture( params=[np.float16, np.float32, np.float64], ids=["f16", "f32", "f64"], ) -def small_rand_dataset(tmpdir, request): - return make_ds(SMALL_NUM_ROWS, SMALL_ROWS_PER_FRAGMENT, tmpdir, request.param) +def pq_rand_dataset(tmpdir, request): + return make_ds( + PQ_NUM_ROWS, + PQ_ROWS_PER_FRAGMENT, + tmpdir, + request.param, + name="pq_dataset", + ) @pytest.fixture -def mostly_null_dataset(tmpdir, request): - vectors = np.random.randn(NUM_ROWS, DIMENSION).astype(np.float32) - vectors.shape = -1 - vectors = pa.FixedSizeListArray.from_arrays(vectors, DIMENSION) - vectors = vectors.to_pylist() - vectors = [vec if i % 10 == 0 else None for i, vec in enumerate(vectors)] - vectors = pa.array(vectors, pa.list_(pa.float32(), DIMENSION)) +def pq_float32_dataset(tmpdir): + return make_ds( + PQ_NUM_ROWS, + PQ_ROWS_PER_FRAGMENT, + tmpdir, + np.float32, + name="pq_dataset", + ) + + +@pytest.fixture +def mostly_null_dataset(tmpdir): + values = np.random.default_rng(42).standard_normal(MOSTLY_NULL_NUM_ROWS * DIMENSION) + values = pa.array(values.astype(np.float32)) + null_mask = pa.array(np.arange(MOSTLY_NULL_NUM_ROWS) % 10 != 0) + vectors = pa.FixedSizeListArray.from_arrays( + values, + DIMENSION, + mask=null_mask, + ) table = pa.Table.from_arrays([vectors], names=["vectors"]) uri = str(tmpdir / "nulls_dataset") - ds = lance.write_dataset(table, uri, max_rows_per_file=NUM_ROWS_PER_FRAGMENT) + ds = lance.write_dataset( + table, + uri, + max_rows_per_file=MOSTLY_NULL_ROWS_PER_FRAGMENT, + ) return ds -def test_ivf_centroids(tmpdir, rand_dataset): - ivf = IndicesBuilder(rand_dataset, "vectors").train_ivf(sample_rate=16) +def make_multivector_dataset(tmpdir): + dimension = 4 + vector_type = pa.list_(pa.list_(pa.float32(), dimension)) + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("embeddings", vector_type), + ] + ) + table = pa.Table.from_pylist( + [ + {"id": 1, "embeddings": [[0.1, 0.2, 0.3, 0.4], [0.2, 0.3, 0.4, 0.5]]}, + {"id": 2, "embeddings": [[0.8, 0.7, 0.6, 0.5]]}, + {"id": 3, "embeddings": [[0.3, 0.2, 0.1, 0.0], [0.9, 0.8, 0.7, 0.6]]}, + {"id": 4, "embeddings": [[0.4, 0.1, 0.2, 0.3]]}, + ], + schema=schema, + ) + ds = lance.write_dataset( + table, + pathlib.Path(tmpdir) / "multivector_fragment_ivf", + max_rows_per_file=2, + ) + return ds, dimension + + +def test_ivf_centroids(tmpdir, small_rand_dataset): + ivf = IndicesBuilder(small_rand_dataset, "vectors").train_ivf( + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + ) assert ivf.distance_type == "l2" - assert len(ivf.centroids) == NUM_PARTITIONS + assert len(ivf.centroids) == SMALL_NUM_PARTITIONS ivf.save(str(tmpdir / "ivf")) reloaded = IvfModel.load(str(tmpdir / "ivf")) @@ -77,43 +144,84 @@ def test_ivf_centroids(tmpdir, rand_dataset): assert ivf.centroids == reloaded.centroids +def test_ivf_centroids_hamming(tmpdir): + num_rows = SMALL_NUM_ROWS + vectors = np.random.default_rng(42).integers( + 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=SMALL_ROWS_PER_FRAGMENT) + + ivf = IndicesBuilder(ds, "vectors").train_ivf( + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + 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( - sample_rate=16, distance_type=distance_type + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + distance_type=distance_type, ) assert ivf.distance_type == distance_type - assert len(ivf.centroids) == NUM_PARTITIONS + assert len(ivf.centroids) == MOSTLY_NULL_NUM_PARTITIONS @pytest.mark.cuda -def test_ivf_centroids_cuda(rand_dataset): - ivf = IndicesBuilder(rand_dataset, "vectors").train_ivf( - sample_rate=16, accelerator="cuda" +def test_ivf_centroids_cuda(small_rand_dataset): + ivf = IndicesBuilder(small_rand_dataset, "vectors").train_ivf( + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + accelerator="cuda", ) assert ivf.distance_type == "l2" - # Can't use NUM_PARTITIONS here because + # Can't use SMALL_NUM_PARTITIONS here because # CUDA uses math.ceil and CPU uses round to calc. num_partitions - assert len(ivf.centroids) == math.ceil(np.sqrt(NUM_ROWS)) + assert len(ivf.centroids) == math.ceil(np.sqrt(SMALL_NUM_ROWS)) @pytest.mark.cuda @pytest.mark.parametrize("distance_type", ["l2", "cosine", "dot"]) def test_ivf_centroids_mostly_null_cuda(mostly_null_dataset, distance_type): ivf = IndicesBuilder(mostly_null_dataset, "vectors").train_ivf( - sample_rate=16, accelerator="cuda", distance_type=distance_type + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + accelerator="cuda", + distance_type=distance_type, ) assert ivf.distance_type == distance_type - assert len(ivf.centroids) == NUM_PARTITIONS + assert len(ivf.centroids) == MOSTLY_NULL_NUM_PARTITIONS -def test_ivf_centroids_distance_type(tmpdir, rand_dataset): +def test_ivf_centroids_distance_type(tmpdir, small_float32_dataset): def check(distance_type): - ivf = IndicesBuilder(rand_dataset, "vectors").train_ivf( - sample_rate=16, distance_type=distance_type + ivf = IndicesBuilder(small_float32_dataset, "vectors").train_ivf( + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + distance_type=distance_type, ) assert ivf.distance_type == distance_type ivf.save(str(tmpdir / "ivf")) @@ -125,31 +233,44 @@ def check(distance_type): check("dot") -def test_num_partitions(rand_dataset): - ivf = IndicesBuilder(rand_dataset, "vectors").train_ivf( - sample_rate=16, num_partitions=10 +def test_num_partitions(small_float32_dataset): + ivf = IndicesBuilder(small_float32_dataset, "vectors").train_ivf( + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + num_partitions=10, ) assert ivf.num_partitions == 10 @pytest.fixture -def rand_ivf(rand_dataset): - dtype = rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() - centroids = np.random.rand(DIMENSION * 100).astype(dtype) +def small_rand_ivf(small_rand_dataset): + dtype = small_rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() + centroids = np.random.default_rng(42).random(DIMENSION * 100).astype(dtype) centroids = pa.FixedSizeListArray.from_arrays(centroids, DIMENSION) return IvfModel(centroids, "l2") @pytest.fixture -def small_rand_ivf(small_rand_dataset): - dtype = small_rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() - centroids = np.random.rand(DIMENSION * 100).astype(dtype) +def pq_rand_ivf(pq_rand_dataset): + dtype = pq_rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() + centroids = np.random.default_rng(42).random(DIMENSION * 100).astype(dtype) centroids = pa.FixedSizeListArray.from_arrays(centroids, DIMENSION) return IvfModel(centroids, "l2") -def test_gen_pq(tmpdir, rand_dataset, rand_ivf): - pq = IndicesBuilder(rand_dataset, "vectors").train_pq(rand_ivf, sample_rate=2) +@pytest.fixture +def small_float32_ivf(small_float32_dataset): + centroids = np.random.default_rng(42).random(DIMENSION * 100).astype(np.float32) + centroids = pa.FixedSizeListArray.from_arrays(centroids, DIMENSION) + return IvfModel(centroids, "l2") + + +def test_gen_pq(tmpdir, pq_rand_dataset, pq_rand_ivf): + pq = IndicesBuilder(pq_rand_dataset, "vectors").train_pq( + pq_rand_ivf, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + ) assert pq.dimension == DIMENSION assert pq.num_subvectors == NUM_SUBVECTORS @@ -158,32 +279,211 @@ def test_gen_pq(tmpdir, rand_dataset, rand_ivf): assert pq.dimension == reloaded.dimension assert pq.codebook == reloaded.codebook + pq_4bit = IndicesBuilder(pq_rand_dataset, "vectors").train_pq( + pq_rand_ivf, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + num_bits=4, + ) + assert pq_4bit.num_bits == 4 + assert len(pq_4bit.codebook) == 16 + + pq_4bit.save(str(tmpdir / "pq_4bit")) + reloaded = PqModel.load(str(tmpdir / "pq_4bit")) + assert reloaded.num_bits == 4 + + legacy_pq_uri = str(tmpdir / "legacy_pq") + with LanceFileWriter( + legacy_pq_uri, + pa.schema( + [pa.field("codebook", pq.codebook.type)], + metadata={b"num_subvectors": str(pq.num_subvectors).encode()}, + ), + ) as writer: + writer.write_batch(pa.table([pq.codebook], names=["codebook"])) + assert PqModel.load(legacy_pq_uri).num_bits == 8 + + +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 = vectors.reshape(-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=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + fragment_ids=[fragment_ids[0]], + ) + second_ivf = IndicesBuilder(ds, "vectors").train_ivf( + num_partitions=1, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + 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_ivf_centroids_multivector_fragment_ids(tmpdir): + ds, dimension = make_multivector_dataset(tmpdir) + builder = IndicesBuilder(ds, "embeddings") + assert builder.dimension == dimension + + centroids = pa.FixedSizeListArray.from_arrays( + pa.array( + [ + 0.1, + 0.2, + 0.3, + 0.4, + 0.8, + 0.7, + 0.6, + 0.5, + ], + type=pa.float32(), + ), + dimension, + ) + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + + index = ds.create_index_uncommitted( + "embeddings", + index_type="IVF_HNSW_SQ", + metric="cosine", + num_partitions=2, + fragment_ids=fragment_ids, + index_uuid="00000000-0000-4000-8000-000000000001", + ivf_centroids=centroids, + ) + + from lance.bitmap import Bitmap + + assert index.uuid == "00000000-0000-4000-8000-000000000001" + assert isinstance(index.fragment_ids, Bitmap) + assert index.fragment_ids == set(fragment_ids) + assert index.name == "embeddings_idx" + + +def test_indices_builder_multivector_distributed_dimensions(tmpdir, monkeypatch): + ds, dimension = make_multivector_dataset(tmpdir) + builder = IndicesBuilder(ds, "embeddings") + centroids = pa.FixedSizeListArray.from_arrays( + pa.array([0.1, 0.2, 0.3, 0.4], type=pa.float32()), + dimension, + ) + codebook = pa.FixedSizeListArray.from_arrays( + pa.array([0.1, 0.2, 0.3, 0.4], type=pa.float32()), + dimension, + ) + ivf = IvfModel(centroids, "l2") + pq = PqModel(2, codebook) + + from lance.lance import indices + + captured_dimensions = {} + + def train_pq_model(*args, **kwargs): + captured_dimensions["train_pq"] = args[2] + return codebook + + def transform_vectors(*args): + captured_dimensions["transform_vectors"] = args[2] + + def load_shuffled_vectors(*args): + captured_dimensions["load_shuffled_vectors"] = args[6] + + monkeypatch.setattr(indices, "train_pq_model", train_pq_model) + monkeypatch.setattr(indices, "transform_vectors", transform_vectors) + monkeypatch.setattr(indices, "load_shuffled_vectors", load_shuffled_vectors) + monkeypatch.setattr(builder, "_verify_pq_sample_rate", lambda *args: None) + + builder.train_pq(ivf, num_subvectors=2, sample_rate=2) + builder.transform_vectors(ivf, pq, str(pathlib.Path(tmpdir) / "transformed")) + builder.load_shuffled_vectors(["sorted"], str(tmpdir), ivf, pq) -def test_pq_invalid_sub_vectors(tmpdir, rand_dataset, rand_ivf): + assert captured_dimensions == { + "train_pq": dimension, + "transform_vectors": dimension, + "load_shuffled_vectors": dimension, + } + + +def test_pq_fragment_ids(pq_float32_dataset): + fragment_id = pq_float32_dataset.get_fragments()[0].fragment_id + ivf = IndicesBuilder(pq_float32_dataset, "vectors").train_ivf( + num_partitions=4, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + fragment_ids=[fragment_id], + ) + + pq = IndicesBuilder(pq_float32_dataset, "vectors").train_pq( + ivf, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + fragment_ids=[fragment_id], + ) + + assert pq.dimension == DIMENSION + assert pq.num_subvectors == NUM_SUBVECTORS + + +def test_pq_invalid_sub_vectors( + small_float32_dataset, + small_float32_ivf, +): with pytest.raises( ValueError, match="must be divisible by num_subvectors .* without remainder", ): - IndicesBuilder(rand_dataset, "vectors").train_pq( - rand_ivf, sample_rate=2, num_subvectors=5 + IndicesBuilder(small_float32_dataset, "vectors").train_pq( + small_float32_ivf, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + num_subvectors=5, ) def test_gen_pq_mostly_null(mostly_null_dataset): - centroids = np.random.rand(DIMENSION * 100).astype(np.float32) + centroids = np.random.default_rng(42).random(DIMENSION * 100).astype(np.float32) centroids = pa.FixedSizeListArray.from_arrays(centroids, DIMENSION) ivf = IvfModel(centroids, "l2") - pq = IndicesBuilder(mostly_null_dataset, "vectors").train_pq(ivf, sample_rate=2) + pq = IndicesBuilder(mostly_null_dataset, "vectors").train_pq( + ivf, + sample_rate=TRAINING_SAMPLE_RATE, + max_iters=TRAINING_MAX_ITERS, + ) assert pq.dimension == DIMENSION assert pq.num_subvectors == NUM_SUBVECTORS @pytest.mark.cuda -def test_assign_partitions(rand_dataset, rand_ivf): - builder = IndicesBuilder(rand_dataset, "vectors") +def test_assign_partitions(small_rand_dataset, small_rand_ivf): + builder = IndicesBuilder(small_rand_dataset, "vectors") - partitions_uri = builder.assign_ivf_partitions(rand_ivf, accelerator="cuda") + partitions_uri = builder.assign_ivf_partitions(small_rand_ivf, accelerator="cuda") partitions = lance.dataset(partitions_uri) found_row_ids = set() @@ -194,13 +494,13 @@ def test_assign_partitions(rand_dataset, rand_ivf): part_ids = batch["partition"] for part_id in part_ids: assert part_id.as_py() < 100 - assert len(found_row_ids) == rand_dataset.count_rows() + assert len(found_row_ids) == small_rand_dataset.count_rows() @pytest.mark.cuda @pytest.mark.parametrize("distance_type", ["l2", "cosine", "dot"]) def test_assign_partitions_mostly_null(mostly_null_dataset, distance_type): - centroids = np.random.rand(DIMENSION * 100).astype(np.float32) + centroids = np.random.default_rng(42).random(DIMENSION * 100).astype(np.float32) centroids = pa.FixedSizeListArray.from_arrays(centroids, DIMENSION) ivf = IvfModel(centroids, distance_type) @@ -223,7 +523,7 @@ def test_assign_partitions_mostly_null(mostly_null_dataset, distance_type): @pytest.fixture def small_rand_pq(small_rand_dataset, small_rand_ivf): dtype = small_rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() - codebook = np.random.rand(DIMENSION * 256).astype(dtype) + codebook = np.random.default_rng(42).random(DIMENSION * 256).astype(dtype) codebook = pa.FixedSizeListArray.from_arrays(codebook, DIMENSION) pq = PqModel(NUM_SUBVECTORS, codebook) return pq @@ -259,6 +559,23 @@ def test_vector_transform(tmpdir, small_rand_dataset, small_rand_ivf, small_rand assert reader.metadata().num_rows == SMALL_NUM_ROWS +def test_vector_transform_mismatched_codebook( + tmpdir, small_rand_dataset, small_rand_ivf +): + # The dimension comes from the column and the codebook from the caller, and + # the codebook is sliced with column-derived offsets. + dtype = small_rand_dataset.schema.field("vectors").type.value_type.to_pandas_dtype() + codebook = np.random.default_rng(42).random(DIMENSION * 256 * 2).astype(dtype) + codebook = pa.FixedSizeListArray.from_arrays(codebook, DIMENSION) + pq = PqModel(NUM_SUBVECTORS, codebook) + + builder = IndicesBuilder(small_rand_dataset, "vectors") + with pytest.raises(ValueError, match="PQ codebook has"): + builder.transform_vectors( + small_rand_ivf, pq, str(tmpdir / "transformed"), fragments=None + ) + + @pytest.mark.cuda def test_vector_transform_with_precomputed_partitions( tmpdir, small_rand_dataset, small_rand_ivf, small_rand_pq @@ -347,6 +664,6 @@ def test_load_shuffled_vectors( ) final_ds = lance.dataset(str(tmpdir / "dataset")) - assert final_ds.has_index - assert final_ds.list_indices()[0]["fields"] == ["vectors"] - assert len(final_ds.list_indices()[0]["fragment_ids"]) == NUM_FRAGMENTS + stats = final_ds.stats.index_stats("vectors_idx") + assert stats["name"] == "vectors_idx" + assert stats["num_indexed_fragments"] == NUM_FRAGMENTS diff --git a/python/python/tests/test_ingestion.py b/python/python/tests/test_ingestion.py index 9f5ab8b53c2..af0c78b9b2f 100644 --- a/python/python/tests/test_ingestion.py +++ b/python/python/tests/test_ingestion.py @@ -60,7 +60,11 @@ def cannot_write(data, dataset, schema=None): schema=outer_nullable, data_storage_version=data_storage_version, ) - can_write({"point": [None]}, dataset, outer_nullable) + if data_storage_version == "2.0": + with pytest.raises(OSError, match="does not encode struct validity"): + can_write({"point": [None]}, dataset, outer_nullable) + else: + can_write({"point": [None]}, dataset, outer_nullable) cannot_write({"point": [{"x": None}]}, dataset, outer_nullable) inner_nullable = pa.schema( 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 diff --git a/python/python/tests/test_ivf_centroids.py b/python/python/tests/test_ivf_centroids.py index 01c2463ab62..34827c330ce 100644 --- a/python/python/tests/test_ivf_centroids.py +++ b/python/python/tests/test_ivf_centroids.py @@ -8,7 +8,10 @@ def test_ivf_centroids_exposed(tmp_path): """Verify that centroids for an IVF-based index are exposed via both""" - dim, rows, parts = 4, 256, 8 + # A partition is trained only when the data can give it a 256-code + # codebook's worth of vectors, so the fixture covers every partition. + dim, parts = 4, 8 + rows = parts * 256 vecs = pa.array( np.random.randn(rows, dim).tolist(), type=pa.list_(pa.float32(), dim), diff --git a/python/python/tests/test_json.py b/python/python/tests/test_json.py index d68e0ca539a..7a6a11883dd 100644 --- a/python/python/tests/test_json.py +++ b/python/python/tests/test_json.py @@ -4,9 +4,30 @@ import json import tempfile from pathlib import Path +from typing import Union import lance import pyarrow as pa +import pytest + + +def check_json_type(ds: Union[lance.LanceDataset, pa.Table], col_name: str): + # TODO: In the future it should be possible to verify + # the logical type of a column. + + schema = ds.schema + field = schema.field(col_name) + assert field.type == pa.json_() + + +def check_nested_json_type(ds: Union[lance.LanceDataset, pa.Table]): + media_field = ds.schema.field("media") + extra_field = media_field.type.value_type.field("extra") + assert extra_field.type == pa.json_() + + +def nested_extra_value(table: pa.Table): + return table.to_pylist()[0]["media"][0]["extra"] def test_json_basic_write_read(): @@ -44,29 +65,84 @@ def test_json_basic_write_read(): logical_schema = dataset.schema assert len(logical_schema) == 2 assert logical_schema.field("id").type == pa.int32() - logical_field = logical_schema.field("data") - assert ( - str(logical_field.type) == "extension" - or logical_field.type == pa.utf8() - ) + check_json_type(dataset, "data") # Read data back result_table = dataset.to_table() # Check that data is returned as Arrow JSON for Python - result_field = result_table.schema.field("data") - # PyArrow extension types print as extension but - # the storage type is utf8 - assert ( - str(result_field.type) == "extension" - or result_field.type == pa.utf8() - ) + check_json_type(result_table, "data") # Verify data assert result_table.num_rows == 5 assert result_table.column("id").to_pylist() == [1, 2, 3, 4, 5] +@pytest.mark.parametrize("version", ["2.1", "2.2", "2.3"]) +def test_nested_json_large_binary_cast_roundtrip(tmp_path: Path, version: str): + storage_schema = pa.schema( + [ + pa.field( + "media", + pa.list_( + pa.struct( + [ + pa.field("uri", pa.utf8(), nullable=False), + pa.field("extra", pa.large_binary()), + ] + ) + ), + ) + ] + ) + extension_schema = pa.schema( + [ + pa.field( + "media", + pa.list_( + pa.struct( + [ + pa.field("uri", pa.utf8(), nullable=False), + pa.field("extra", pa.json_()), + ] + ) + ), + ) + ] + ) + table = pa.Table.from_pylist( + [ + { + "media": [ + { + "uri": "s3://bucket/a.mp4", + "extra": b'{"codec": "h264"}', + } + ] + } + ], + schema=storage_schema, + ).cast(extension_schema) + + dataset_path = tmp_path / f"nested_json_{version.replace('.', '_')}.lance" + lance.write_dataset(table, dataset_path, data_storage_version=version) + dataset = lance.dataset(dataset_path) + + check_nested_json_type(dataset) + + scanned = dataset.to_table() + check_nested_json_type(scanned) + assert json.loads(nested_extra_value(scanned)) == {"codec": "h264"} + + taken = dataset.take([0]) + check_nested_json_type(taken) + assert json.loads(nested_extra_value(taken)) == {"codec": "h264"} + + empty = dataset.take([]) + assert empty.num_rows == 0 + check_nested_json_type(empty) + + def test_json_with_other_types(): """Test JSON type alongside other data types.""" @@ -467,8 +543,7 @@ def test_json_filter_append_missing_json_cast(tmp_path: Path): lance.write_dataset(initial_table, dataset_path) dataset = lance.dataset(dataset_path) schema = dataset.schema - field = schema.field("article_metadata") - assert str(field.type) == "extension" or field.type == pa.utf8() + check_json_type(dataset, "article_metadata") append_table = pa.table( { @@ -513,6 +588,68 @@ def test_json_filter_append_missing_json_cast(tmp_path: Path): ] +def test_json_with_compaction(tmp_path: Path): + """Test that JSON data survives compaction across fragments.""" + + dataset_path = tmp_path / "json_compaction.lance" + + # Write first fragment + table1 = pa.table( + { + "id": pa.array([1, 2, 3], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"name": "Alice", "score": 10}), + json.dumps({"name": "Bob", "score": 20}), + json.dumps({"name": "Charlie", "score": 30}), + ], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table1, dataset_path) + + # Write second fragment + table2 = pa.table( + { + "id": pa.array([4, 5], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"name": "David", "score": 40}), + json.dumps({"name": "Eve", "score": 50}), + ], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table2, dataset_path, mode="append") + + dataset = lance.dataset(dataset_path) + assert len(dataset.get_fragments()) == 2 + + # Run compaction + dataset.optimize.compact_files() + dataset = lance.dataset(dataset_path) + assert len(dataset.get_fragments()) == 1 + + # Verify data is intact + result = dataset.to_table() + assert result.num_rows == 5 + assert result.column("id").to_pylist() == [1, 2, 3, 4, 5] + + # Verify JSON type is preserved + check_json_type(dataset, "data") + + # Verify JSON functions still work after compaction + result = dataset.to_table(filter="json_get_string(data, 'name') = 'Alice'") + assert result.num_rows == 1 + assert result["id"][0].as_py() == 1 + + result = dataset.to_table(filter="json_get_int(data, 'score') > 25") + assert result.num_rows == 3 + assert result["id"].to_pylist() == [3, 4, 5] + + def test_json_limit_offset_batch_transfer_preserves_extension_metadata(tmp_path: Path): """Ensure JSON extension metadata survives limit/offset scans. @@ -567,3 +704,191 @@ def test_json_limit_offset_batch_transfer_preserves_extension_metadata(tmp_path: # Ensure JSON functions still recognize the column as JSON. assert dest.to_table(filter="json_get(meta, 'i') IS NOT NULL").num_rows == num_rows + + +def test_json_append(tmp_path: Path): + """Test appending JSON data to an existing dataset.""" + + dataset_path = tmp_path / "json_append.lance" + + # Write initial data + table1 = pa.table( + { + "id": pa.array([1, 2], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"color": "red", "count": 1}), + json.dumps({"color": "blue", "count": 2}), + ], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table1, dataset_path) + + # Append more data + table2 = pa.table( + { + "id": pa.array([3, 4, 5], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"color": "green", "count": 3}), + json.dumps({"color": "yellow", "count": 4}), + None, + ], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table2, dataset_path, mode="append") + + dataset = lance.dataset(dataset_path) + assert dataset.count_rows() == 5 + + # Verify JSON type is preserved + check_json_type(dataset, "data") + + # Verify all data is readable + result = dataset.to_table() + assert result.column("id").to_pylist() == [1, 2, 3, 4, 5] + + # Verify null handling + data_col = result.column("data") + assert data_col.null_count == 1 + assert data_col.is_null().to_pylist() == [False, False, False, False, True] + + # Verify JSON functions work across both fragments + result = dataset.to_table(filter="json_get_string(data, 'color') = 'green'") + assert result.num_rows == 1 + assert result["id"][0].as_py() == 3 + + result = dataset.to_table(filter="json_get_int(data, 'count') >= 2") + assert result.num_rows == 3 + assert result["id"].to_pylist() == [2, 3, 4] + + +def test_json_add_columns(tmp_path: Path): + """Test adding a JSON column to an existing dataset via add_columns.""" + + dataset_path = tmp_path / "json_add_col.lance" + + # Create a dataset without a JSON column + table = pa.table( + { + "id": pa.array([1, 2, 3], type=pa.int32()), + "name": pa.array(["Alice", "Bob", "Charlie"], type=pa.string()), + } + ) + dataset = lance.write_dataset(table, dataset_path) + + # Add a JSON column using a record batch reader + names = table.column("name").to_pylist() + json_values = [json.dumps({"greeting": f"hello {n}"}) for n in names] + new_col = pa.record_batch([pa.array(json_values, type=pa.json_())], ["metadata"]) + reader_schema = pa.schema([pa.field("metadata", pa.json_())]) + + dataset.add_columns(iter([new_col]), reader_schema=reader_schema) + dataset = lance.dataset(dataset_path) + + # Verify the new column exists and has the right type + assert dataset.schema.names == ["id", "name", "metadata"] + check_json_type(dataset, "metadata") + + # Verify data round-trips + result = dataset.to_table() + assert result.num_rows == 3 + metadata_values = result.column("metadata").to_pylist() + for name, val in zip(names, metadata_values): + assert json.loads(val) == {"greeting": f"hello {name}"} + + result = dataset.to_table( + filter="json_get_string(metadata, 'greeting') = 'hello Alice'" + ) + assert result.num_rows == 1 + assert result["id"][0].as_py() == 1 + + +def test_json_merge_insert(tmp_path: Path): + """Test merge_insert with JSON data.""" + + dataset_path = tmp_path / "json_merge_insert.lance" + + # Create initial dataset + table = pa.table( + { + "id": pa.array([1, 2, 3], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"name": "Alice", "score": 10}), + json.dumps({"name": "Bob", "score": 20}), + json.dumps({"name": "Charlie", "score": 30}), + ], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table, dataset_path) + + # Merge insert: update id=2, insert id=4 + new_data = pa.table( + { + "id": pa.array([2, 4], type=pa.int32()), + "data": pa.array( + [ + json.dumps({"name": "Bob", "score": 99}), + json.dumps({"name": "David", "score": 40}), + ], + type=pa.json_(), + ), + } + ) + + dataset = lance.dataset(dataset_path) + dataset.merge_insert( + "id" + ).when_matched_update_all().when_not_matched_insert_all().execute(new_data) + dataset = lance.dataset(dataset_path) + + # Verify row count + assert dataset.count_rows() == 4 + + # Verify JSON type preserved + check_json_type(dataset, "data") + + # Verify data is readable + result = dataset.to_table() + assert sorted(result.column("id").to_pylist()) == [1, 2, 3, 4] + + result = dataset.to_table(filter="json_get_int(data, 'score') >= 35") + assert result.num_rows == 2 + + +def test_json_fragment_session_take(tmp_path: Path): + """FragmentSession.take must return JSON columns as pa.json_(), not raw JSONB.""" + + dataset_path = tmp_path / "json_fragment_session.lance" + + table = pa.table( + { + "id": pa.array([1, 2, 3, 4, 5], type=pa.int32()), + "data": pa.array( + [json.dumps({"name": "row", "score": v}) for v in range(5)], + type=pa.json_(), + ), + } + ) + lance.write_dataset(table, dataset_path) + + dataset = lance.dataset(dataset_path) + fragment = dataset.get_fragments()[0] + session = fragment.open_session(columns=["id", "data"]) + result = session.take([0, 2, 4]) + + # JSON column must be the logical pa.json_() type, not raw LargeBinary JSONB. + assert result.schema.field("data").type == pa.json_() + assert result.column("id").to_pylist() == [1, 3, 5] + assert [json.loads(v) for v in result.column("data").to_pylist()] == [ + {"name": "row", "score": 0}, + {"name": "row", "score": 2}, + {"name": "row", "score": 4}, + ] diff --git a/python/python/tests/test_kmeans.py b/python/python/tests/test_kmeans.py index e992019f24b..f5ddfd9d8d7 100644 --- a/python/python/tests/test_kmeans.py +++ b/python/python/tests/test_kmeans.py @@ -26,6 +26,8 @@ def test_kmeans_dot(): kmeans = lance.util.KMeans(32, metric_type="dot") data = np.random.randn(1000, 128).astype(np.float32) kmeans.fit(data) + norms = np.linalg.norm(kmeans.centroids.to_numpy_ndarray(), axis=1) + np.testing.assert_allclose(norms, 1.0, rtol=1e-5) def test_precomputed_kmeans(): diff --git a/python/python/tests/test_lance.py b/python/python/tests/test_lance.py index 0162e370665..4ba9281823d 100644 --- a/python/python/tests/test_lance.py +++ b/python/python/tests/test_lance.py @@ -248,6 +248,74 @@ def test_io_counters(tmp_path): assert lance.bytes_read_counter() > starting_bytes +def test_simd_info(): + info = lance.simd_info() + assert info["tier"] in ( + "none", + "sse", + "avx", + "avx_fma", + "avx2", + "avx512", + "avx512_fp16", + "neon", + "lsx", + "lasx", + ) + assert isinstance(info["target_arch"], str) and info["target_arch"] + if info["target_arch"] == "x86_64": + # The x86_64 ABI mandates SSE2. + assert "sse2" in info["host_features"] + else: + assert info["host_features"] == [] + + +@pytest.mark.parametrize( + ("query", "options", "expected"), + [ + ("the cats and dogs", {}, [("cat", 0), ("dog", 2)]), + ( + "the skip alpha", + {"stem": False, "custom_stop_words": ["skip"]}, + [("the", 0), ("alpha", 2)], + ), + ( + "getUserName", + {"analyzer": "code", "split_identifiers": True}, + [ + ("getusername", 0), + ("get", 0), + ("user", 1), + ("name", 2), + ], + ), + ( + "a::b", + {"analyzer": "code", "index_operators": True}, + [("a", 0), ("::", 1), ("b", 2)], + ), + ], +) +def test_tokenize_fts_query(query, options, expected): + tokens = lance.tokenize(query, **options) + assert [(token.text, token.position) for token in tokens] == expected + + +def test_tokenize_fts_query_validates_analyzer_options(): + with pytest.raises(ValueError, match="code analyzer flags require analyzer='code'"): + lance.tokenize("getUserName", split_identifiers=True) + + with pytest.raises(ValueError, match="unknown base tokenizer"): + lance.tokenize("hello", base_tokenizer="unknown") + + +def test_tokenize_fts_query_can_disable_max_token_length(): + long_token = "x" * 41 + assert lance.tokenize(long_token, stem=False) == [] + unlimited_tokens = lance.tokenize(long_token, max_token_length=None, stem=False) + assert [token.text for token in unlimited_tokens] == [long_token] + + @pytest.mark.parametrize( "row_param, column_name", [("with_row_id", "_rowid"), ("with_row_address", "_rowaddr")], diff --git a/python/python/tests/test_log.py b/python/python/tests/test_log.py index b00fe5813a0..1eb02957044 100644 --- a/python/python/tests/test_log.py +++ b/python/python/tests/test_log.py @@ -148,6 +148,36 @@ def test_lance_log_file_with_directory_creation(tmp_path): assert len(log_content.strip()) > 0, "Log file is empty" +@pytest.mark.skipif( + sys.platform == "win32", + reason="subprocess does not work correctly in CI on Windows", +) +def test_lance_log_filters_trace_event_targets(tmp_path): + log_file = tmp_path / "lance_rust.log" + + result = subprocess.run( + [ + sys.executable, + "-c", + "import lance; import pyarrow as pa; " + "lance.write_dataset(pa.table({'x': range(10)}), 'memory://test')", + ], + capture_output=True, + env={ + "LANCE_LOG": "warn,lance::events::dataset_events=info", + "LANCE_LOG_FILE": str(log_file), + }, + ) + + assert result.returncode == 0, f"Command failed: {result.stderr.decode()}" + + log_content = log_file.read_text() + assert "lance::events::dataset_events" in log_content + assert 'target="lance::dataset_events"' in log_content + assert "lance::events::file_audit" not in log_content + assert "lance::file_audit" not in log_content + + @pytest.mark.skipif( sys.platform == "win32", reason="subprocess does not work correctly in CI on Windows", diff --git a/python/python/tests/test_mem_wal.py b/python/python/tests/test_mem_wal.py new file mode 100644 index 00000000000..d42b0e72749 --- /dev/null +++ b/python/python/tests/test_mem_wal.py @@ -0,0 +1,632 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors +import math +import os +import time +import uuid + +import lance +import pyarrow as pa +import pytest +from lance.mem_wal import ( + CompactedSsTable, + LsmPointLookupPlanner, + LsmScanner, + LsmVectorSearchPlanner, + ShardingField, + ShardingSpec, + ShardSnapshot, + evaluate_sharding_spec, +) +from lance.schema import LanceSchema + +_PK_META = {"lance-schema:unenforced-primary-key": "true"} +_LOOKUP_SCHEMA = pa.schema( + [ + pa.field("id", pa.int64(), nullable=False, metadata=_PK_META), + pa.field("name", pa.utf8()), + ] +) +_APPEND_ONLY_SCHEMA = pa.schema( + [ + pa.field("id", pa.int64(), nullable=False), + pa.field("name", pa.utf8()), + ] +) + + +def _lookup_table(ids, prefix: str) -> pa.Table: + """Build a table where name = '{prefix}_{id}' for each id.""" + return pa.table( + { + "id": pa.array(ids, pa.int64()), + "name": pa.array([f"{prefix}_{i}" for i in ids], pa.utf8()), + }, + schema=_LOOKUP_SCHEMA, + ) + + +def _append_only_table(ids, prefix: str) -> pa.Table: + """Build a table without primary-key metadata.""" + return pa.table( + { + "id": pa.array(ids, pa.int64()), + "name": pa.array([f"{prefix}_{i}" for i in ids], pa.utf8()), + }, + schema=_APPEND_ONLY_SCHEMA, + ) + + +def _write_sstable(base_path: str, shard_id: str, gen_folder: str, data: pa.Table): + """Write an SSTable Lance dataset at the expected sub-path. + + The collector resolves SSTable paths as: + {base_dataset_path}/_mem_wal/{shard_id}/{gen_folder} + + Production flush also writes a primary-key dedup sidecar (`_pk_index/`) that + the LSM scanner opens to dedup across generations; stage it here too so the + SSTable faithfully matches what flush produces. + """ + from lance.lance import _write_pk_sidecar + + gen_path = os.path.join(base_path, "_mem_wal", shard_id, gen_folder) + lance.write_dataset(data, gen_path, schema=_LOOKUP_SCHEMA) + _write_pk_sidecar(gen_path, data, ["id"]) + + +def test_mark_sstables_as_compacted(tmp_path): + ds_path = str(tmp_path / "base") + shard_id = str(uuid.uuid4()) + dataset = lance.write_dataset( + _lookup_table([1, 2, 3], "base"), ds_path, schema=_LOOKUP_SCHEMA + ) + dataset.initialize_mem_wal() + + ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .mark_sstables_as_compacted([CompactedSsTable(shard_id, 1)]) + .execute(_lookup_table([2, 4], "compacted")) + ) + + assert dataset.count_rows() == 4 + + +def test_point_lookup_with_memtables(tmp_path): + """ + Lookup against a base table that has one SSTable containing an + update. The SSTable version must win over the base table version. + + Setup + ----- + base : ids [1, 2, 3] names ["base_1", "base_2", "base_3"] + gen_1 : ids [2] names ["gen1_2"] â†� update to id=2 + + ShardSnapshot: sstable(gen=1, path="gen_1"), current_generation=2 + """ + ds_path = str(tmp_path / "base") + shard_id = str(uuid.uuid4()) + + # --- Base dataset --- + base_ds = lance.write_dataset( + _lookup_table([1, 2, 3], "base"), ds_path, schema=_LOOKUP_SCHEMA + ) + base_ds.initialize_mem_wal() + + # --- SSTable: overwrites id=2 --- + _write_sstable(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) + + # --- ShardSnapshot describing the SSTable state --- + snap = ShardSnapshot(shard_id).with_sstable(1, "gen_1").with_current_generation(2) + + planner = LsmPointLookupPlanner(base_ds, [snap]) + assert not hasattr(planner, "lookup") + + # id=2 must return the SSTable version + plan = planner.plan_lookup(pa.array([2], type=pa.int64())) + assert plan.schema.names == ["id", "name"] + assert plan.dataset_schema.names == ["id", "name"] + explained = plan.explain() + assert "Take" in explained or "Scan" in explained or "LanceRead" in explained + result = plan.to_table() + assert len(result) == 1, "Expected exactly one row for id=2" + assert result.column("name")[0].as_py() == "gen1_2", ( + "SSTable must win over base table" + ) + + # id=1 is only in the base table + result_base = planner.plan_lookup(pa.array([1], type=pa.int64())).to_table() + assert len(result_base) == 1 + assert result_base.column("name")[0].as_py() == "base_1" + + # id=99 does not exist anywhere + result_miss = planner.plan_lookup(pa.array([99], type=pa.int64())).to_table() + assert len(result_miss) == 0, "Non-existent key must return empty result" + + +def test_lsm_scanner_with_memtables(tmp_path): + """ + Full-scan via LsmScanner.from_snapshots deduplicates rows across LSM + levels: each primary key appears exactly once, from its newest level. + + base : ids [1, 2, 3] names ["base_1", "base_2", "base_3"] + gen_1 : ids [2] names ["gen1_2"] â†� overwrites id=2 + + Expected result: 3 unique rows — id=2 from gen_1, id=1 and id=3 from base. + """ + ds_path = str(tmp_path / "base") + shard_id = str(uuid.uuid4()) + + base_ds = lance.write_dataset( + _lookup_table([1, 2, 3], "base"), ds_path, schema=_LOOKUP_SCHEMA + ) + base_ds.initialize_mem_wal() + + _write_sstable(ds_path, shard_id, "gen_1", _lookup_table([2], "gen1")) + + snap = ShardSnapshot(shard_id).with_sstable(1, "gen_1").with_current_generation(2) + + scanner = LsmScanner.from_snapshots(base_ds, [snap]) + table = scanner.to_table() + + assert len(table) == 3, f"Expected 3 deduplicated rows, got {len(table)}" + name_by_id = {row["id"]: row["name"] for row in table.to_pylist()} + + assert name_by_id[1] == "base_1" + assert name_by_id[2] == "gen1_2", "SSTable gen must overwrite base for id=2" + assert name_by_id[3] == "base_3" + + offset_table = ( + LsmScanner.from_snapshots(base_ds, [snap]).limit(None, offset=1).to_table() + ) + assert len(offset_table) == 2, "Offset-only LSM scan should not require a limit" + + +def test_shard_writer_lsm_scanner_includes_own_sstables(tmp_path): + ds_path = str(tmp_path / "base") + shard_id = str(uuid.uuid4()) + ds = lance.write_dataset(_lookup_table([0], "base"), ds_path, schema=_LOOKUP_SCHEMA) + ds.initialize_mem_wal() + + with ds.mem_wal_writer( + shard_id, + durable_write=True, + max_wal_buffer_size=1, + max_wal_flush_interval_ms=10, + max_memtable_batches=1, + ) as writer: + writer.put(_lookup_table([1, 2], "writer")) + + deadline = time.time() + 5 + while True: + table = writer.lsm_scanner().to_table() + name_by_id = {row["id"]: row["name"] for row in table.to_pylist()} + if name_by_id.get(1) == "writer_1" and name_by_id.get(2) == "writer_2": + break + if time.time() >= deadline: + assert False, "writer.lsm_scanner() did not include SSTable writer rows" + time.sleep(0.05) + + +def test_shard_writer_delete_binding_masks_base_row(tmp_path): + ds_path = str(tmp_path / "base") + shard_id = str(uuid.uuid4()) + ds = lance.write_dataset( + _lookup_table([1, 2, 3], "base"), ds_path, schema=_LOOKUP_SCHEMA + ) + ds.initialize_mem_wal() + + delete_keys = pa.table({"id": pa.array([2], type=pa.int64())}) + + with ds.mem_wal_writer( + shard_id, + durable_write=True, + max_wal_buffer_size=1, + max_wal_flush_interval_ms=10, + ) as writer: + writer.put(_lookup_table([4], "writer")) + writer.delete(delete_keys) + table = writer.lsm_scanner().to_table() + + rows = {row["id"]: row["name"] for row in table.to_pylist()} + assert rows[1] == "base_1" + assert 2 not in rows, "deleted base row should be masked by the tombstone" + assert rows[3] == "base_3" + assert rows[4] == "writer_4" + + +_VDIM = 4 # matches Rust test fixture dimension + + +def _vector_search_schema(): + """Schema for vector-search tests: id (int32 PK) + vector column.""" + pk_meta = {"lance-schema:unenforced-primary-key": "true"} + return pa.schema( + [ + pa.field("id", pa.int32(), nullable=False, metadata=pk_meta), + pa.field("vector", pa.list_(pa.float32(), _VDIM)), + ] + ) + + +def _vector_search_table(ids): + """Build a table with deterministic vectors matching the Rust fixture. + + For id=N the vector is [N*0.1, N*0.1+0.1, N*0.1+0.2, N*0.1+0.3]. + """ + flat = [] + for i in ids: + base = i * 0.1 + flat.extend([base, base + 0.1, base + 0.2, base + 0.3]) + storage = pa.array(flat, type=pa.float32()) + vectors = pa.FixedSizeListArray.from_arrays(storage, _VDIM) + return pa.table( + {"id": pa.array(ids, pa.int32()), "vector": vectors}, + schema=_vector_search_schema(), + ) + + +def test_lsm_vector_search_filter_binding(tmp_path): + ds_path = str(tmp_path / "vec") + ds = lance.write_dataset( + _vector_search_table(range(400)), ds_path, schema=_vector_search_schema() + ) + ds.create_index( + "vector", + index_type="IVF_PQ", + name="vector_idx", + num_partitions=2, + num_sub_vectors=2, + ) + ds.initialize_mem_wal(maintained_indexes=["vector_idx"]) + + planner = LsmVectorSearchPlanner(ds, [], "vector", filter="id >= 200") + query_id = 250 + query = pa.array([25.0, 25.1, 25.2, 25.3], type=pa.float32()) + table = planner.plan_search(query, k=20, nprobes=2, columns=["id"]).to_table() + + ids = table.column("id").to_pylist() + assert ids, "filtered vector search should return at least one row" + assert min(ids) >= 200 + assert query_id in ids, f"filtered vector search recall missed id={query_id}: {ids}" + + with pytest.raises(ValueError, match="k must be positive"): + planner.plan_search(query, k=0, nprobes=2, columns=["id"]) + with pytest.raises(ValueError, match="nprobes must be positive"): + planner.plan_search(query, k=20, nprobes=0, columns=["id"]) + with pytest.raises(ValueError, match="overfetch_factor must be finite"): + planner.plan_search( + query, k=20, nprobes=2, columns=["id"], overfetch_factor=math.inf + ) + + +VECTOR_DIM = 32 +ROWS_PER_BATCH = 50 +NUM_WRITE_ROUNDS = 3 +BATCHES_PER_ROUND = 3 + + +def _e2e_schema(): + """Schema for the e2e test: id (PK), vector, text.""" + pk_meta = {"lance-schema:unenforced-primary-key": "true"} + return pa.schema( + [ + pa.field("id", pa.int64(), nullable=False, metadata=pk_meta), + pa.field("vector", pa.list_(pa.float32(), VECTOR_DIM)), + pa.field("text", pa.utf8()), + ] + ) + + +def _e2e_batch(schema, start_id: int, num_rows: int) -> pa.RecordBatch: + """Deterministic RecordBatch for the e2e test.""" + ids = list(range(start_id, start_id + num_rows)) + flat_vecs = [ + math.sin((start_id + i) * 0.1 + d * 0.01) + for i in range(num_rows) + for d in range(VECTOR_DIM) + ] + storage = pa.array(flat_vecs, type=pa.float32()) + vector_array = pa.FixedSizeListArray.from_arrays(storage, VECTOR_DIM) + texts = [f"Sample text for row {start_id + i}" for i in range(num_rows)] + return pa.record_batch( + { + "id": pa.array(ids, pa.int64()), + "vector": vector_array, + "text": pa.array(texts, pa.utf8()), + }, + schema=schema, + ) + + +def test_shard_writer_e2e_correctness(tmp_path): + """ + End-to-end correctness test for ShardWriter covering: + - Multi-round writes that trigger WAL and MemTable flushes + - File-system layout verification (_mem_wal//wal/ and manifest/) + - SSTable data readable via LsmScanner + - New writer created after close can write and scan correctly + + Mirrors Rust test: shard_writer_tests::test_shard_writer_e2e_correctness + """ + schema = _e2e_schema() + ds_path = str(tmp_path / "ds") + + # 500 seed rows so BTree index training succeeds + initial_batch = _e2e_batch(schema, start_id=0, num_rows=500) + ds = lance.write_dataset( + pa.Table.from_batches([initial_batch]), ds_path, schema=schema + ) + + ds.create_scalar_index("id", "BTREE", name="id_btree") + ds.initialize_mem_wal(maintained_indexes=["id_btree"]) + + # Small buffers to trigger WAL and MemTable flushes during the test + shard_id = str(uuid.uuid4()) + writer = ds.mem_wal_writer( + shard_id, + durable_write=True, + max_wal_buffer_size=10 * 1024, # 10 KB + max_wal_flush_interval_ms=50, + max_memtable_size=80, # flush after ~80 rows + ) + + total_rows_written = 0 + for _round in range(NUM_WRITE_ROUNDS): + start_id = 500 + total_rows_written + for i in range(BATCHES_PER_ROUND): + batch = _e2e_batch(schema, start_id + i * ROWS_PER_BATCH, ROWS_PER_BATCH) + writer.put(pa.Table.from_batches([batch])) + total_rows_written += BATCHES_PER_ROUND * ROWS_PER_BATCH + time.sleep(0.15) # allow async WAL/memtable flush + + writer.close() + + # === Stats === + stats = writer.stats() + assert stats["put_count"] == NUM_WRITE_ROUNDS * BATCHES_PER_ROUND + assert stats["wal_flush_count"] >= 1, "Expected at least one WAL flush" + + closed_memtable_stats = writer.memtable_stats() + assert closed_memtable_stats["row_count"] == 0 + assert closed_memtable_stats["batch_count"] == 0 + assert closed_memtable_stats["generation"] >= 1 + assert "frozen_count" in closed_memtable_stats + # close() flushes every frozen memtable, so nothing is still owed to flush. + assert closed_memtable_stats["frozen_bytes"] == 0 + + # === File-system layout === + mem_wal_dir = os.path.join(ds_path, "_mem_wal", shard_id) + assert os.path.isdir(mem_wal_dir), f"MemWAL directory missing: {mem_wal_dir}" + + wal_dir = os.path.join(mem_wal_dir, "wal") + assert os.path.isdir(wal_dir), "WAL sub-directory missing" + wal_files = os.listdir(wal_dir) + assert len(wal_files) >= 1 + assert all(f.endswith(".arrow") for f in wal_files), ( + f"All WAL files should have .arrow extension, got: {wal_files}" + ) + + manifest_dir = os.path.join(mem_wal_dir, "manifest") + assert os.path.isdir(manifest_dir), "Manifest sub-directory missing" + assert len(os.listdir(manifest_dir)) >= 1 + + # === Generation counter advanced === + mt_stats = writer.memtable_stats() + assert mt_stats["generation"] >= 1 + + # === New writer: write and read back via active MemTable scanner === + ds2 = lance.dataset(ds_path) + shard_id2 = str(uuid.uuid4()) + with ds2.mem_wal_writer(shard_id2, durable_write=False) as writer2: + verify_batch = _e2e_batch(schema, start_id=10000, num_rows=10) + writer2.put(pa.Table.from_batches([verify_batch])) + result = writer2.lsm_scanner().to_table() + + assert len(result) >= 10 + new_ids = result.column("id").to_pylist() + assert 10000 in new_ids + assert 10009 in new_ids + + +# === initialize_mem_wal: sharding, maintained indexes, writer defaults === + + +def _mem_wal_dataset(tmp_path, name: str = "ds"): + """A base dataset with an unenforced primary key, ready for MemWAL init.""" + ds_path = str(tmp_path / name) + return lance.write_dataset( + _lookup_table([1, 2, 3], "base"), ds_path, schema=_LOOKUP_SCHEMA + ) + + +def test_initialize_mem_wal_manual(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal() + + details = ds.mem_wal_index_details() + assert details is not None + assert details["num_shards"] == 0 + assert details["sharding_specs"] == [] + assert details["maintained_indexes"] == [] + assert details["maintain_all_indexes"] is True + assert details["writer_config_defaults"] == {} + + +def test_initialize_mem_wal_unsharded(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal(unsharded=True) + + details = ds.mem_wal_index_details() + assert details["num_shards"] == 1 + specs = details["sharding_specs"] + assert len(specs) == 1 + assert specs[0]["fields"][0]["transform"] == "unsharded" + + +def test_initialize_mem_wal_bucket_sharding(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal(bucket_column="name", num_buckets=8) + + details = ds.mem_wal_index_details() + assert details["num_shards"] == 8 + field = details["sharding_specs"][0]["fields"][0] + assert field["transform"] == "bucket" + assert "expression" in field + assert field["parameters"]["num_buckets"] == "8" + assert len(field["source_ids"]) == 1 + + batch = _lookup_table([1, 2, 3], "base").to_batches()[0] + result = evaluate_sharding_spec( + batch, details["sharding_specs"][0], LanceSchema.from_pyarrow(batch.schema) + ) + assert result.column_names == [field["field_id"]] + assert result.num_rows == batch.num_rows + + +def test_initialize_mem_wal_bucket_sharding_without_primary_key(tmp_path): + ds_path = str(tmp_path / "append_only") + ds = lance.write_dataset( + _append_only_table([1, 2, 3], "base"), + ds_path, + schema=_APPEND_ONLY_SCHEMA, + ) + ds.initialize_mem_wal(bucket_column="id", num_buckets=8) + + details = ds.mem_wal_index_details() + assert details["num_shards"] == 8 + field = details["sharding_specs"][0]["fields"][0] + assert field["transform"] == "bucket" + assert field["parameters"]["num_buckets"] == "8" + + +def test_initialize_mem_wal_identity_sharding(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal(identity_column="name") + + details = ds.mem_wal_index_details() + # identity sharding has an open-ended shard count + assert details["num_shards"] == 0 + field = details["sharding_specs"][0]["fields"][0] + assert field["transform"] == "identity" + assert field["result_type"] == "utf8" + + +def test_initialize_mem_wal_maintained_indexes(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.create_scalar_index("id", "BTREE", name="id_btree") + ds.initialize_mem_wal(maintained_indexes=["id_btree"]) + + details = ds.mem_wal_index_details() + assert details["maintained_indexes"] == ["id_btree"] + assert details["maintain_all_indexes"] is False + + +def test_initialize_mem_wal_maintains_none_and_all_differently(tmp_path): + # Both leave maintained_indexes empty, so the flag is the only thing + # separating "every index the table has" from "no index at all". + ds = _mem_wal_dataset(tmp_path) + ds.create_scalar_index("id", "BTREE", name="id_btree") + ds.initialize_mem_wal(maintained_indexes=[]) + + details = ds.mem_wal_index_details() + assert details["maintained_indexes"] == [] + assert details["maintain_all_indexes"] is False + + all_ds = _mem_wal_dataset(tmp_path, "all") + all_ds.create_scalar_index("id", "BTREE", name="id_btree") + all_ds.initialize_mem_wal() + + all_details = all_ds.mem_wal_index_details() + assert all_details["maintained_indexes"] == [] + assert all_details["maintain_all_indexes"] is True + + +def test_initialize_mem_wal_writer_config_defaults(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal( + durable_write=False, + max_memtable_size=8 * 1024 * 1024, + max_wal_flush_interval_ms=250, + ) + + defaults = ds.mem_wal_index_details()["writer_config_defaults"] + assert defaults["durable_write"] == "false" + assert defaults["max_memtable_size"] == str(8 * 1024 * 1024) + # Duration knobs are recorded in milliseconds with a `_ms` suffix. + assert defaults["max_wal_flush_interval_ms"] == "250" + # Every ShardWriterConfig tunable is recorded once any default is set. + assert "enable_memtable" in defaults + + +def test_initialize_mem_wal_no_writer_config_defaults_when_unset(tmp_path): + ds = _mem_wal_dataset(tmp_path) + ds.initialize_mem_wal(bucket_column="id", num_buckets=4) + assert ds.mem_wal_index_details()["writer_config_defaults"] == {} + + +def test_initialize_mem_wal_rejects_conflicting_sharding(tmp_path): + ds = _mem_wal_dataset(tmp_path) + with pytest.raises(ValueError, match="at most one"): + ds.initialize_mem_wal(unsharded=True, bucket_column="id", num_buckets=8) + + +def test_initialize_mem_wal_rejects_partial_bucket(tmp_path): + ds = _mem_wal_dataset(tmp_path) + with pytest.raises(ValueError, match="num_buckets"): + ds.initialize_mem_wal(bucket_column="id") + + +def test_evaluate_sharding_spec_python_binding(): + batch = pa.record_batch( + [pa.array([1, 2, None, 3], type=pa.int32())], + names=["id"], + ) + spec = ShardingSpec( + 1, + [ + ShardingField( + field_id="bucket", + source_ids=[0], + transform="bucket", + result_type="int32", + parameters={"num_buckets": "8"}, + ) + ], + ) + + result = evaluate_sharding_spec(batch, spec, LanceSchema.from_pyarrow(batch.schema)) + + assert result.column_names == ["bucket"] + assert result.column(0).to_pylist() == [2, 7, 0, 1] + + +def test_evaluate_sharding_spec_python_binding_column_parameter(): + batch = pa.record_batch( + [pa.array(["a", "b", None], type=pa.utf8())], + names=["key"], + ) + spec = { + "spec_id": 1, + "fields": [ + { + "field_id": "key_bucket", + "source_ids": [0], + "transform": "bucket", + "expression": None, + "result_type": "int32", + "parameters": {"num_buckets": "8"}, + } + ], + } + + result = evaluate_sharding_spec(batch, spec, LanceSchema.from_pyarrow(batch.schema)) + + assert result.column_names == ["key_bucket"] + assert result.column(0).to_pylist() == [1, 5, 0] + + +def test_mem_wal_index_details_none_before_init(tmp_path): + ds = _mem_wal_dataset(tmp_path) + assert ds.mem_wal_index_details() is None diff --git a/python/python/tests/test_memory_leaks.py b/python/python/tests/test_memory_leaks.py index 9a0d8356882..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,19 +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.list_indices(): - if name := idx.get("name"): - d.stats.index_stats(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]) diff --git a/python/python/tests/test_multi_base.py b/python/python/tests/test_multi_base.py index baa437b4fc6..7e045e9b9dc 100644 --- a/python/python/tests/test_multi_base.py +++ b/python/python/tests/test_multi_base.py @@ -82,6 +82,60 @@ def test_multi_base_create_and_read(self): data.sort_values("id").reset_index(drop=True), ) + def test_write_target_all_bases(self): + """target_all_bases=True rotates across primary and all initial bases.""" + data = self.create_test_data(300) + + dataset = lance.write_dataset( + data, + self.primary_uri, + mode="create", + initial_bases=[ + DatasetBasePath(self.path1_uri, name="path1"), + DatasetBasePath(self.path2_uri, name="path2"), + ], + target_all_bases=True, + max_rows_per_file=100, + ) + + base_ids = [f.data_files()[0].base_id for f in dataset.get_fragments()] + assert base_ids == [None, 1, 2] + assert len(dataset.to_table()) == 300 + + def test_base_scoped_storage_options(self): + """base_. storage options flow through write and read.""" + data = self.create_test_data(200) + + # Local stores ignore these options; this verifies base-scoped entries + # are resolved per base without breaking the write or read path. + storage_options = { + "shared_option": "shared", + "base_1.scoped_option": "base1-value", + } + + dataset = lance.write_dataset( + data, + self.primary_uri, + mode="create", + initial_bases=[DatasetBasePath(self.path1_uri, name="path1")], + target_bases=["path1"], + max_rows_per_file=100, + storage_options=storage_options, + ) + assert dataset.count_rows() == 200 + + # Data files land in the scoped base, not the primary path + assert list(Path(self.path1_uri).glob("**/*.lance")) + assert not list((Path(self.primary_uri) / "data").glob("*.lance")) + + # Reopen with the same flat options and read through the base store + dataset = lance.dataset(self.primary_uri, storage_options=storage_options) + result = dataset.to_table().to_pandas() + pd.testing.assert_frame_equal( + result.sort_values("id").reset_index(drop=True), + data.sort_values("id").reset_index(drop=True), + ) + def test_multi_base_append_mode(self): """Test appending data to a multi-base dataset.""" # Create initial dataset @@ -160,7 +214,7 @@ def test_multi_base_overwrite_mode_inherits_bases(self): assert actual_ids == expected_ids # Verify base_paths are preserved from initial dataset - base_paths = updated_dataset._ds.base_paths() + base_paths = updated_dataset.base_paths() assert len(base_paths) == 2 assert any(bp.name == "path1" for bp in base_paths.values()) assert any(bp.name == "path2" for bp in base_paths.values()) @@ -182,16 +236,14 @@ def test_multi_base_overwrite_mode_primary_path_default(self): max_rows_per_file=50, ) - # Overwrite without specifying target - should use primary path - # This clears the old base_paths and writes to primary path only + # Overwrite without a target writes new files to primary storage while + # preserving the registered base paths. overwrite_data = self.create_test_data(75, id_offset=200) updated_dataset = lance.write_dataset( overwrite_data, self.primary_uri, mode="overwrite", - # No target_bases specified - data goes to primary path - # Old bases (path1, path2) are NOT preserved max_rows_per_file=25, ) @@ -205,7 +257,7 @@ def test_multi_base_overwrite_mode_primary_path_default(self): assert actual_ids == expected_ids # Verify base_paths are preserved from previous manifest - base_paths = updated_dataset._ds.base_paths() + base_paths = updated_dataset.base_paths() # Old path1 and path2 ARE preserved in manifest assert len(base_paths) == 2 assert any(bp.name == "path1" for bp in base_paths.values()) @@ -272,7 +324,7 @@ def test_multi_base_append_mode_primary_path_default(self): assert actual_ids == expected_ids # Verify base_paths are preserved from previous manifest - base_paths = updated_dataset._ds.base_paths() + base_paths = updated_dataset.base_paths() assert len(base_paths) == 2 assert any(bp.name == "path1" for bp in base_paths.values()) assert any(bp.name == "path2" for bp in base_paths.values()) @@ -320,7 +372,7 @@ def test_multi_base_is_dataset_root_flag(self): ) # Verify base_paths configuration - base_paths = dataset._ds.base_paths() + base_paths = dataset.base_paths() assert len(base_paths) == 2 # Find path1 and path2 in base_paths @@ -365,7 +417,7 @@ def test_multi_base_target_by_path_uri(self): ) # Get the base_paths to find the actual path URI for path2 - base_paths = dataset._ds.base_paths() + base_paths = dataset.base_paths() path2_base = None for base_path in base_paths.values(): if base_path.name == "path2": @@ -396,7 +448,7 @@ def test_multi_base_target_by_path_uri(self): # Verify that new fragments are in path2 (not primary or path1) fragments = list(updated_dataset.get_fragments()) - base_paths_updated = updated_dataset._ds.base_paths() + base_paths_updated = updated_dataset.base_paths() path1_fragments = 0 path2_fragments = 0 @@ -788,37 +840,104 @@ def test_add_bases_then_scan(self): assert len(result) == 10 def test_add_bases_verify_base_paths(self): - """Test that get_base_paths returns added bases.""" - # Create dataset + """Test that base_paths exposes every registered base by ID.""" data = pd.DataFrame({"id": range(10), "value": range(10)}) + dataset = lance.write_dataset(data, self.primary_uri, mode="create") + assert dataset.base_paths() == {} + + dataset.add_bases( + [ + DatasetBasePath(self.new_base1, name="new_base1", is_dataset_root=True), + DatasetBasePath(self.new_base2), + ] + ) + + base_paths = dataset.base_paths() + assert len(base_paths) == 2 + assert all(base_id == base.id for base_id, base in base_paths.items()) + + bases_by_path = {base.path: base for base in base_paths.values()} + assert set(bases_by_path) == {self.new_base1, self.new_base2} + + named_base = bases_by_path[self.new_base1] + assert named_base.name == "new_base1" + assert named_base.is_dataset_root is True + + unnamed_base = bases_by_path[self.new_base2] + assert unnamed_base.name is None + assert unnamed_base.is_dataset_root is False + assert all(base.path != self.primary_uri for base in base_paths.values()) + assert all(not hasattr(base, "storage_options") for base in base_paths.values()) + + @pytest.mark.parametrize( + ("attribute", "value"), + [ + pytest.param("id", 99, id="id"), + pytest.param("name", "changed", id="name"), + pytest.param("path", "changed", id="path"), + pytest.param("is_dataset_root", False, id="is_dataset_root"), + ], + ) + def test_base_paths_results_are_isolated(self, attribute, value): + """Returned mappings are detached and base attributes are read-only.""" dataset = lance.write_dataset( - data, + pd.DataFrame({"id": range(10)}), self.primary_uri, mode="create", - initial_bases=[DatasetBasePath(self.initial_base, name="initial_base")], - target_bases=["initial_base"], + initial_bases=[ + DatasetBasePath( + self.initial_base, + name="initial_base", + is_dataset_root=True, + ) + ], ) - # Add new bases - dataset = lance.dataset(self.primary_uri) - dataset.add_bases( - [ - DatasetBasePath(self.new_base1, name="new_base1"), - DatasetBasePath(self.new_base2, name="new_base2"), - ] + base_paths = dataset.base_paths() + assert len(base_paths) == 1 + base = next(iter(base_paths.values())) + assert base_paths[base.id] is base + base_paths.clear() + + assert len(dataset.base_paths()) == 1 + with pytest.raises(AttributeError): + setattr(base, attribute, value) + + del dataset + assert base.id == 1 + assert base.name == "initial_base" + assert base.path == self.initial_base + assert base.is_dataset_root is True + + def test_base_paths_uses_current_snapshot(self): + """Base enumeration does not implicitly refresh an open dataset.""" + data = pd.DataFrame({"id": range(10)}) + stale_dataset = lance.write_dataset( + data, + self.primary_uri, + mode="create", + initial_bases=[DatasetBasePath(self.initial_base, name="initial_base")], ) + old_base_paths = stale_dataset.base_paths() - # Get base paths - base_paths = dataset._ds.base_paths() + latest_dataset = lance.dataset(self.primary_uri) + latest_dataset.add_bases([DatasetBasePath(self.new_base1, name="new_base1")]) - # Should have 3 bases now (initial + 2 new) - assert len(base_paths) == 3 + assert {base.name for base in old_base_paths.values()} == {"initial_base"} + assert {base.name for base in stale_dataset.base_paths().values()} == { + "initial_base" + } + assert {base.name for base in latest_dataset.base_paths().values()} == { + "initial_base", + "new_base1", + } - # Check that all bases are present - names = [bp.name for bp in base_paths.values()] - assert "initial_base" in names - assert "new_base1" in names - assert "new_base2" in names + stale_dataset.checkout_latest() + assert {base.name for base in stale_dataset.base_paths().values()} == { + "initial_base", + "new_base1", + } + assert {base.name for base in old_base_paths.values()} == {"initial_base"} def test_add_bases_large_data_distribution(self): """Test adding bases and distributing large amounts of data.""" @@ -939,7 +1058,7 @@ def test_add_bases_with_transaction_properties(self): ) # Verify the base was added - base_paths = dataset._ds.base_paths() + base_paths = dataset.base_paths() names = [bp.name for bp in base_paths.values()] assert "new_base1" in names @@ -1031,6 +1150,7 @@ def test_write_fragments_with_target_bases(self): dataset, mode="append", target_bases=["base2"], + base_store_params={self.base2_uri: {}}, max_rows_per_file=25, ) @@ -1054,6 +1174,42 @@ def test_write_fragments_with_target_bases(self): data_files = list(base2_path.glob("**/*.lance")) assert len(data_files) > 0, "Expected data files in base2" + def test_write_fragments_with_base_store_params(self): + """Test write_fragments inherits base_store_params from LanceDataset.""" + initial_data = pd.DataFrame({"id": range(10), "value": range(10)}) + + dataset = lance.write_dataset( + initial_data, + self.primary_uri, + mode="create", + initial_bases=[ + DatasetBasePath(self.base1_uri, name="base1"), + DatasetBasePath(self.base2_uri, name="base2"), + ], + target_bases=["base1"], + base_store_params={self.base2_uri: {}}, + max_rows_per_file=10, + ) + + fragment_data = pd.DataFrame({"id": range(10, 20), "value": range(10, 20)}) + fragments = write_fragments( + pa.Table.from_pandas(fragment_data), + dataset, + mode="append", + target_bases=["base2"], + max_rows_per_file=10, + ) + + operation = lance.LanceOperation.Append(fragments) + dataset = lance.LanceDataset.commit( + dataset, operation, read_version=dataset.version + ) + + result = dataset.to_table().to_pandas() + assert len(result) == 20 + assert set(result["id"]) == set(range(20)) + assert list(Path(self.base2_uri).glob("**/*.lance")) + def test_write_fragments_transaction_with_target_bases(self): """Test write_fragments with return_transaction and target_bases.""" # Create initial dataset @@ -1196,7 +1352,7 @@ def test_write_fragments_create_mode_with_initial_bases(self): assert set(result["id"]) == set(range(20)) # Verify base paths are registered - base_paths = dataset._ds.base_paths() + base_paths = dataset.base_paths() assert len(base_paths) == 2 # 2 bases (base1, base2) # Check that our named bases are registered base_names = [bp.name for bp in base_paths.values() if bp.name is not None] @@ -1211,3 +1367,307 @@ def test_write_fragments_create_mode_with_initial_bases(self): dataset_root = Path(dataset_uri) data_files_root = list(dataset_root.glob("*.lance")) assert len(data_files_root) == 0, "Should not have data files in root" + + +class TestDataReplacementWithBases: + """DataReplacement must preserve DataFile.base_id so replacement files can + live in (and resolve against) a storage base other than the dataset root.""" + + def setup_method(self): + self.test_dir = tempfile.mkdtemp() + self.primary_uri = str(Path(self.test_dir) / "primary") + self.base1_uri = str(Path(self.test_dir) / "base1") + Path(self.base1_uri).mkdir(parents=True, exist_ok=True) + + def teardown_method(self): + if hasattr(self, "test_dir"): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def _make_two_base_dataset(self) -> "lance.LanceDataset": + """Fragment 0 at the dataset root, fragment 1 in base1; column a.""" + ds = lance.write_dataset( + pa.table({"a": list(range(8))}), + self.primary_uri, + max_rows_per_file=8, + ) + ds.add_bases([DatasetBasePath(self.base1_uri, name="b1", is_dataset_root=True)]) + return lance.write_dataset( + pa.table({"a": list(range(8, 16))}), + self.primary_uri, + mode="append", + max_rows_per_file=8, + target_bases=["b1"], + ) + + def _write_bare_file(self, data_dir: str, data: pa.Table) -> str: + from lance.file import LanceFileWriter + + name = f"{uuid.uuid4()}.lance" + Path(data_dir).mkdir(parents=True, exist_ok=True) + with LanceFileWriter(f"{data_dir}/{name}") as writer: + writer.write_batch(data) + return name + + def _b_data_file(self, name: str, base_id=None) -> "lance.fragment.DataFile": + from lance.file import stable_version + from lance.fragment import DataFile + + return DataFile( + path=name, + fields=[1], # field id of column "b" (a=0, b=1) + column_indices=[0], + file_major_version=int(stable_version().split(".")[0]), + file_minor_version=int(stable_version().split(".")[1]), + base_id=base_id, + ) + + def test_data_replacement_new_column_into_base(self): + """The all-NULL-column special case: the new column's data file for a + base fragment is written into that base and must keep its base_id.""" + ds = self._make_two_base_dataset() + ds.add_columns(pa.field("b", pa.int32())) + ds = lance.dataset(self.primary_uri) + + root_name = self._write_bare_file( + f"{self.primary_uri}/data", + pa.table({"b": pa.array([x * 10 for x in range(8)], pa.int32())}), + ) + base_name = self._write_bare_file( + f"{self.base1_uri}/data", + pa.table({"b": pa.array([x * 10 for x in range(8, 16)], pa.int32())}), + ) + + op = lance.LanceOperation.DataReplacement( + [ + lance.LanceOperation.DataReplacementGroup( + 0, self._b_data_file(root_name) + ), + lance.LanceOperation.DataReplacementGroup( + 1, self._b_data_file(base_name, base_id=1) + ), + ] + ) + ds = lance.LanceDataset.commit(self.primary_uri, op, read_version=ds.version) + + frags = ds.get_fragments() + root_files = {f.path: f.base_id for f in frags[0].data_files()} + base_files = {f.path: f.base_id for f in frags[1].data_files()} + assert root_files[root_name] is None + assert base_files[base_name] == 1 + + table = ds.to_table() + assert table.column("b").to_pylist() == [x * 10 for x in range(16)] + + def test_data_replacement_replace_existing_file_in_base(self): + """The replace-existing-file branch must take the new file's base_id.""" + ds = self._make_two_base_dataset() + ds.add_columns(pa.field("b", pa.int32())) + ds = lance.dataset(self.primary_uri) + + first = self._write_bare_file( + f"{self.base1_uri}/data", + pa.table({"b": pa.array([0] * 8, pa.int32())}), + ) + op = lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(1, self._b_data_file(first, 1))] + ) + ds = lance.LanceDataset.commit(self.primary_uri, op, read_version=ds.version) + + # Replace the same column file again, still in base1. + second = self._write_bare_file( + f"{self.base1_uri}/data", + pa.table({"b": pa.array([x * 10 for x in range(8, 16)], pa.int32())}), + ) + op = lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(1, self._b_data_file(second, 1))] + ) + ds = lance.LanceDataset.commit(self.primary_uri, op, read_version=ds.version) + + base_files = {f.path: f.base_id for f in ds.get_fragments()[1].data_files()} + assert base_files[second] == 1 + assert first not in base_files + assert ds.to_table().column("b").to_pylist()[8:] == [ + x * 10 for x in range(8, 16) + ] + + +class TestMergeInsertMultiBase: + """Test merge insert on multi-base datasets.""" + + def setup_method(self): + """Set up test directories for each test.""" + self.test_dir = tempfile.mkdtemp() + self.primary_uri = str(Path(self.test_dir) / "primary") + self.base1_uri = str(Path(self.test_dir) / "base1") + self.base2_uri = str(Path(self.test_dir) / "base2") + for uri in [self.primary_uri, self.base1_uri, self.base2_uri]: + Path(uri).mkdir(parents=True, exist_ok=True) + + def teardown_method(self): + """Clean up test directories after each test.""" + if hasattr(self, "test_dir"): + shutil.rmtree(self.test_dir, ignore_errors=True) + + def create_dataset(self): + """Dataset with two registered bases and initial data in base1.""" + initial_data = pd.DataFrame( + { + "id": range(100), + "value": [f"initial_{i}" for i in range(100)], + } + ) + return lance.write_dataset( + initial_data, + self.primary_uri, + mode="create", + initial_bases=[ + DatasetBasePath(self.base1_uri, name="base1"), + DatasetBasePath(self.base2_uri, name="base2"), + ], + target_bases=["base1"], + max_rows_per_file=50, + ) + + def base_name_of(self, dataset, data_file): + base_paths = dataset.base_paths() + if data_file.base_id is None: + return None + return base_paths[data_file.base_id].name + + def test_merge_insert_without_target_bases(self): + """Merge insert on a multi-base dataset writes to primary by default.""" + dataset = self.create_dataset() + + new_data = pd.DataFrame( + { + "id": range(50, 150), + "value": [f"updated_{i}" for i in range(50, 150)], + } + ) + stats = ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .execute(new_data) + ) + assert stats["num_updated_rows"] == 50 + assert stats["num_inserted_rows"] == 50 + + result = dataset.to_table().to_pandas().sort_values("id") + assert len(result) == 150 + assert list(result[result["id"] >= 50]["value"]) == [ + f"updated_{i}" for i in range(50, 150) + ] + + # New fragments (merge output) are in primary storage. + max_initial_fragment_id = 1 # 100 rows / 50 per file -> fragments 0, 1 + for fragment in dataset.get_fragments(): + is_initial = fragment.fragment_id <= max_initial_fragment_id + for data_file in fragment.data_files(): + expected = "base1" if is_initial else None + assert self.base_name_of(dataset, data_file) == expected + + def test_merge_insert_with_target_bases(self): + """Merge insert routes new fragments to the requested base.""" + dataset = self.create_dataset() + + new_data = pd.DataFrame( + { + "id": range(50, 150), + "value": [f"updated_{i}" for i in range(50, 150)], + } + ) + stats = ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .target_bases(["base2"]) + .execute(new_data) + ) + assert stats["num_updated_rows"] == 50 + assert stats["num_inserted_rows"] == 50 + + result = dataset.to_table().to_pandas().sort_values("id") + assert len(result) == 150 + assert list(result[result["id"] >= 50]["value"]) == [ + f"updated_{i}" for i in range(50, 150) + ] + + merge_files = 0 + for fragment in dataset.get_fragments(): + if fragment.fragment_id > 1: + for data_file in fragment.data_files(): + assert self.base_name_of(dataset, data_file) == "base2" + merge_files += 1 + assert merge_files > 0 + assert list(Path(self.base2_uri).glob("**/*.lance")) + + # The routed dataset stays readable from a fresh instance. + reloaded = lance.dataset(self.primary_uri) + assert reloaded.count_rows() == 150 + + def test_merge_insert_with_unknown_target_base(self): + """Merge insert referencing an unregistered base fails.""" + dataset = self.create_dataset() + + new_data = pd.DataFrame({"id": [1], "value": ["updated_1"]}) + with pytest.raises(Exception, match="not found in available bases"): + ( + dataset.merge_insert("id") + .when_matched_update_all() + .when_not_matched_insert_all() + .target_bases(["nonexistent"]) + .execute(new_data) + ) + + def test_merge_insert_target_primary_via_uri(self): + """The dataset URI in target_bases selects primary storage.""" + dataset = self.create_dataset() + + new_data = pd.DataFrame({"id": [200], "value": ["inserted_200"]}) + ( + dataset.merge_insert("id") + .when_not_matched_insert_all() + .target_bases([dataset.uri, "base2"]) + .execute(new_data) + ) + assert dataset.count_rows() == 101 + + # The single new file lands in the first slot: primary storage. + for fragment in dataset.get_fragments(): + if fragment.fragment_id > 1: + for data_file in fragment.data_files(): + assert self.base_name_of(dataset, data_file) is None + + def test_merge_insert_target_all_bases(self): + """target_all_bases spreads new files across all bases, primary first.""" + dataset = self.create_dataset() + + new_data = pd.DataFrame({"id": [300], "value": ["inserted_300"]}) + ( + dataset.merge_insert("id") + .when_not_matched_insert_all() + .target_all_bases() + .execute(new_data) + ) + assert dataset.count_rows() == 101 + # A single new file lands in the first slot: primary storage. + newest = max(f.fragment_id for f in dataset.get_fragments()) + for fragment in dataset.get_fragments(): + if fragment.fragment_id == newest: + for data_file in fragment.data_files(): + assert self.base_name_of(dataset, data_file) is None + + new_data = pd.DataFrame({"id": [301], "value": ["inserted_301"]}) + ( + dataset.merge_insert("id") + .when_not_matched_insert_all() + .target_all_bases(include_primary=False) + .execute(new_data) + ) + assert dataset.count_rows() == 102 + newest = max(f.fragment_id for f in dataset.get_fragments()) + for fragment in dataset.get_fragments(): + if fragment.fragment_id == newest: + for data_file in fragment.data_files(): + assert self.base_name_of(dataset, data_file) == "base1" diff --git a/python/python/tests/test_namespace_dir.py b/python/python/tests/test_namespace_dir.py index 74871facf6d..78a81bd455c 100644 --- a/python/python/tests/test_namespace_dir.py +++ b/python/python/tests/test_namespace_dir.py @@ -8,31 +8,207 @@ 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 subprocess +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 ( - CreateEmptyTableRequest, + CountTableRowsRequest, + CountTableRowsResponse, CreateNamespaceRequest, + CreateNamespaceResponse, + CreateTableBranchRequest, + CreateTableBranchResponse, + CreateTableIndexRequest, + CreateTableIndexResponse, CreateTableRequest, + CreateTableResponse, + CreateTableVersionRequest, + CreateTableVersionResponse, + DeclareTableRequest, + DeclareTableResponse, + DeleteTableBranchRequest, + DeleteTableBranchResponse, DeregisterTableRequest, + DeregisterTableResponse, DescribeNamespaceRequest, + DescribeNamespaceResponse, + DescribeTableIndexStatsRequest, DescribeTableRequest, + DescribeTableResponse, + DescribeTableVersionRequest, + DescribeTableVersionResponse, DropNamespaceRequest, + DropNamespaceResponse, DropTableRequest, + DropTableResponse, + InsertIntoTableRequest, + InsertIntoTableResponse, ListNamespacesRequest, + ListNamespacesResponse, + ListTableBranchesRequest, + ListTableBranchesResponse, + ListTableIndicesRequest, + ListTableIndicesResponse, ListTablesRequest, + ListTablesResponse, + ListTableVersionsRequest, + ListTableVersionsResponse, NamespaceExistsRequest, + NamespaceExistsResponse, + QueryTableRequest, + QueryTableResponse, RegisterTableRequest, + RegisterTableResponse, TableExistsRequest, + TableExistsResponse, connect, ) +from lance_namespace.errors import ( + InvalidInputError, + NamespaceNotEmptyError, + NamespaceNotFoundError, + TableBranchAlreadyExistsError, + TableBranchNotFoundError, + TableNotFoundError, +) + + +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 + ) -> NamespaceExistsResponse: + 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) -> TableExistsResponse: + 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 create_table_branch( + self, request: CreateTableBranchRequest + ) -> CreateTableBranchResponse: + return self._inner.create_table_branch(request) + + def list_table_branches( + self, request: ListTableBranchesRequest + ) -> ListTableBranchesResponse: + return self._inner.list_table_branches(request) + + def delete_table_branch( + self, request: DeleteTableBranchRequest + ) -> DeleteTableBranchResponse: + return self._inner.delete_table_branch(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 + ) -> CountTableRowsResponse: + 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) -> QueryTableResponse: + # 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() def create_test_data(): @@ -56,39 +232,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 - ns = connect("dir", {"root": f"file://{tmpdir}"}) - yield ns + # Use high commit_retries for concurrent operation tests + ns_client = connect( + "dir", {"root": f"file://{tmpdir}", "commit_retries": "2147483647"} + ) + 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 @@ -97,40 +291,40 @@ 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(Exception) as exc_info: - memory_namespace.create_table(create_req, b"") + with pytest.raises(InvalidInputError) as exc_info: + 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) # Test with empty ID create_req = CreateTableRequest(id=[]) - with pytest.raises(Exception): - memory_namespace.create_table(create_req, ipc_data) + with pytest.raises(InvalidInputError): + 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 @@ -140,52 +334,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 @@ -194,104 +388,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(Exception) as exc_info: - 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 + with pytest.raises(TableNotFoundError): + 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): - """Test checking if a non-existent table exists.""" + 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(Exception): - memory_namespace.table_exists(exists_req) + with pytest.raises(TableNotFoundError): + 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(Exception): - memory_namespace.table_exists(exists_req) + with pytest.raises(TableNotFoundError): + 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) @@ -306,11 +496,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) @@ -320,15 +510,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) @@ -344,17 +534,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) @@ -363,83 +553,161 @@ 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(Exception) as exc_info: - temp_namespace.register_table(register_req) + with pytest.raises(InvalidInputError) as exc_info: + 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(Exception) as exc_info: - temp_namespace.register_table(register_req) + with pytest.raises(InvalidInputError) as exc_info: + 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(Exception) as exc_info: - temp_namespace.register_table(register_req) + with pytest.raises(InvalidInputError) as exc_info: + temp_ns_client.register_table(register_req) assert "Path traversal is not allowed" in str(exc_info.value) - def test_create_empty_table(self, memory_namespace): - """Test creating an empty table.""" - # Create parent namespace + +class TestTableBranchOperations: + """Branch CRUD through the python bindings - mirrors the Rust branch + CRUD tests.""" + + def test_branch_crud_round_trip(self, temp_ns_client): create_ns_req = CreateNamespaceRequest(id=["workspace"]) - memory_namespace.create_namespace(create_ns_req) + temp_ns_client.create_namespace(create_ns_req) + ipc_data = table_to_ipc_bytes(create_test_data()) + table_id = ["workspace", "branched_table"] + temp_ns_client.create_table(CreateTableRequest(id=table_id), ipc_data) - # Create empty table with schema - schema = pa.schema( - [ - pa.field("id", pa.int64()), - pa.field("name", pa.string()), - ] + temp_ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="dev") ) + listed = temp_ns_client.list_table_branches( + ListTableBranchesRequest(id=table_id) + ) + assert "dev" in listed.branches + assert listed.branches["dev"].parent_version == 1 - create_req = CreateEmptyTableRequest( - id=["workspace", "empty_table"], schema=schema + # Duplicate creation and deleting a missing branch surface the typed + # branch errors (codes 23 and 22), not InternalError. + temp_ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="dev2") ) - response = memory_namespace.create_empty_table(create_req) + with pytest.raises(TableBranchAlreadyExistsError): + temp_ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="dev2") + ) - assert response is not None - assert response.location is not None + temp_ns_client.delete_table_branch( + DeleteTableBranchRequest(id=table_id, name="dev") + ) + listed = temp_ns_client.list_table_branches( + ListTableBranchesRequest(id=table_id) + ) + assert "dev" not in listed.branches + with pytest.raises(TableBranchNotFoundError): + temp_ns_client.delete_table_branch( + DeleteTableBranchRequest(id=table_id, name="dev") + ) + + def test_create_branch_from_other_branch(self, temp_ns_client): + """Forking from a non-main source branch records the right parent.""" + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + temp_ns_client.create_namespace(create_ns_req) + ipc_data = table_to_ipc_bytes(create_test_data()) + table_id = ["workspace", "fork_table"] + temp_ns_client.create_table(CreateTableRequest(id=table_id), ipc_data) + + temp_ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="dev") + ) + temp_ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="child", from_branch="dev") + ) + listed = temp_ns_client.list_table_branches( + ListTableBranchesRequest(id=table_id) + ) + assert listed.branches["child"].parent_branch == "dev" + + +class _ForeignCodeError(Exception): + """Not a LanceNamespaceError, but carries the same integer code as + TABLE_NOT_FOUND.""" + + code = 4 - # Verify table exists - exists_req = TableExistsRequest(id=["workspace", "empty_table"]) - memory_namespace.table_exists(exists_req) + +class _RaisingNamespace(LanceNamespace): + """A namespace whose describe_table raises the configured exception.""" + + def __init__(self, exc: Exception): + self._exc = exc + + def namespace_id(self) -> str: + return "raising" + + def describe_table(self, request: DescribeTableRequest) -> DescribeTableResponse: + raise self._exc + + +class TestPythonNamespaceErrorMapping: + """The Rust adapter must trust the `code` attribute only on the + lance_namespace exception hierarchy.""" + + def test_namespace_error_identity_preserved(self): + ns = _RaisingNamespace(TableNotFoundError("no such table")) + with pytest.raises(TableNotFoundError, match="no such table"): + lance.dataset(namespace_client=ns, table_id=["t"]) + + # Branch error codes (22/23) survive the round trip too. + ns = _RaisingNamespace(TableBranchNotFoundError("no such branch")) + with pytest.raises(TableBranchNotFoundError, match="no such branch"): + lance.dataset(namespace_client=ns, table_id=["t"]) + + def test_foreign_code_attribute_not_trusted(self): + # The foreign exception must surface as itself, not be reinterpreted + # as a namespace error via its `code` attribute. + ns = _RaisingNamespace(_ForeignCodeError("boom")) + with pytest.raises(_ForeignCodeError, match="boom"): + lance.dataset(namespace_client=ns, table_id=["t"]) 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) @@ -447,11 +715,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 @@ -459,43 +727,50 @@ 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(Exception): - memory_namespace.table_exists(exists_req) + with pytest.raises(TableNotFoundError): + memory_ns_client.table_exists(exists_req) - def test_empty_table_in_child_namespace(self, memory_namespace): - """Test creating an empty table in a child 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) - # Create empty table - schema = pa.schema([pa.field("id", pa.int64())]) - create_req = CreateEmptyTableRequest( - id=["test_ns", "empty_table"], schema=schema - ) - memory_namespace.create_empty_table(create_req) + # Declare table + declare_req = DeclareTableRequest(id=["test_ns", "declared_table"]) + memory_ns_client.declare_table(declare_req) # Verify table exists - exists_req = TableExistsRequest(id=["test_ns", "empty_table"]) - memory_namespace.table_exists(exists_req) + exists_req = TableExistsRequest(id=["test_ns", "declared_table"]) + memory_ns_client.table_exists(exists_req) + + describe_req = DescribeTableRequest(id=["test_ns", "declared_table"]) + describe_resp = memory_ns_client.describe_table(describe_req) + assert describe_resp.is_only_declared is None + + describe_req = DescribeTableRequest( + id=["test_ns", "declared_table"], check_declared=True + ) + describe_resp = memory_ns_client.describe_table(describe_req) + assert describe_resp.is_only_declared is True class TestDeeplyNestedNamespaces: @@ -504,14 +779,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"]) ) @@ -519,17 +794,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 = { @@ -538,11 +813,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" @@ -552,120 +827,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(Exception) as exc_info: - memory_namespace.drop_namespace(drop_req) + with pytest.raises(NamespaceNotEmptyError): + memory_ns_client.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): + 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_ns_client): + """Test that a non-existent namespace raises NamespaceNotFoundError.""" + exists_req = NamespaceExistsRequest(id=["nonexistent"]) - def test_drop_empty_namespace(self, memory_namespace): + with pytest.raises(NamespaceNotFoundError): + memory_ns_client.namespace_exists(exists_req) + + 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 @@ -675,42 +951,48 @@ 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}", "manifest_enabled": "true", "dir_listing_enabled": "true", + "dir_listing_to_manifest_migration_enabled": "true", } # 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}", @@ -718,5 +1000,713 @@ def test_connect_with_storage_options(self): } # This should work without errors - ns = connect("dir", properties) - assert isinstance(ns, lance.namespace.DirectoryNamespace) + inner_ns_client = connect("dir", properties) + assert isinstance(inner_ns_client, lance.namespace.DirectoryNamespace) + + # Wrap if testing CustomNamespace + ns_client = _wrap_if_custom(inner_ns_client, use_custom) + + # Verify it can be used for basic operations + create_ns_req = CreateNamespaceRequest(id=["test_ns"]) + ns_client.create_namespace(create_ns_req) + + +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.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: + - Rust: test_external_manifest_store_invokes_namespace_apis + - Java: testExternalManifestStoreInvokesNamespaceApis + + It verifies: + 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: + # 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) + 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_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 = 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 + # 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 _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_client=ns_client, table_id=table_id, mode="append" + ) + assert ds.count_rows() == 4 + assert len(ds.versions()) == 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 = _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 ( + _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 = _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 ( + _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" + + +def test_dataset_namespace_open_does_not_pass_version_to_describe_table(): + """Dataset versions are applied to dataset open, not namespace describe_table.""" + + class VersionRejectingNamespace(CustomNamespace): + def __init__(self, inner: lance.namespace.DirectoryNamespace): + super().__init__(inner) + self.describe_versions = [] + + def describe_table( + self, request: DescribeTableRequest + ) -> DescribeTableResponse: + self.describe_versions.append(request.version) + assert request.version is None + return super().describe_table(request) + + with tempfile.TemporaryDirectory() as tmpdir: + inner_ns_client = lance.namespace.DirectoryNamespace(root=tmpdir) + ns_client = VersionRejectingNamespace(inner_ns_client) + table_id = ["test_table"] + + table1 = pa.Table.from_pylist([{"a": 1}, {"a": 2}]) + ds = lance.write_dataset( + table1, namespace_client=ns_client, table_id=table_id, mode="create" + ) + assert ds.count_rows() == 2 + assert ds.version == 1 + + table2 = pa.Table.from_pylist([{"a": 3}]) + ds = lance.write_dataset( + table2, namespace_client=ns_client, table_id=table_id, mode="append" + ) + assert ds.count_rows() == 3 + assert ds.version == 2 + + version_one = lance.dataset( + namespace_client=ns_client, table_id=table_id, version=1 + ) + assert version_one.count_rows() == 2 + assert version_one.version == 1 + assert ns_client.describe_versions + assert all(version is None for version in ns_client.describe_versions) + + +@pytest.mark.skipif( + sys.platform == "win32", + reason="Windows file locking prevents reliable concurrent filesystem operations", +) +class TestConcurrentOperations: + """Tests for concurrent table operations. + + 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_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_ns_client.create_namespace(create_ns_req) + + num_tables = 10 + success_count = 0 + fail_count = 0 + lock = Lock() + + def create_and_drop_table(table_index): + nonlocal success_count, fail_count + try: + table_name = f"concurrent_table_{table_index}" + table_id = ["test_ns", table_name] + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + + # Create table + create_req = CreateTableRequest(id=table_id) + temp_ns_client.create_table(create_req, ipc_data) + + # Drop table + drop_req = DropTableRequest(id=table_id) + temp_ns_client.drop_table(drop_req) + + with lock: + success_count += 1 + except Exception as e: + with lock: + fail_count += 1 + raise e + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_tables) as executor: + futures = [ + executor.submit(create_and_drop_table, i) for i in range(num_tables) + ] + concurrent.futures.wait(futures) + + assert success_count == num_tables, ( + f"Expected {num_tables} successes, got {success_count}" + ) + assert fail_count == 0, f"Expected 0 failures, got {fail_count}" + + # Verify all tables are dropped + list_req = ListTablesRequest(id=["test_ns"]) + response = temp_ns_client.list_tables(list_req) + assert len(response.tables) == 0, "All tables should be dropped" + + @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_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_client.create_namespace(create_ns_req) + + num_tables = 10 + success_count = 0 + fail_count = 0 + lock = Lock() + + def create_and_drop_table(table_index): + nonlocal success_count, fail_count + try: + # Each thread creates its own namespace client instance + # Use high commit_retries to handle version collisions + 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] + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + + # Create table + create_req = CreateTableRequest(id=table_id) + local_ns_client.create_table(create_req, ipc_data) + + # Drop table + drop_req = DropTableRequest(id=table_id) + local_ns_client.drop_table(drop_req) + + with lock: + success_count += 1 + except Exception as e: + with lock: + fail_count += 1 + raise e + + with concurrent.futures.ThreadPoolExecutor( + max_workers=num_tables + ) as executor: + futures = [ + executor.submit(create_and_drop_table, i) for i in range(num_tables) + ] + concurrent.futures.wait(futures) + + assert success_count == num_tables, ( + f"Expected {num_tables} successes, got {success_count}" + ) + assert fail_count == 0, f"Expected 0 failures, got {fail_count}" + + # 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_client.list_tables(list_req) + assert len(response.tables) == 0, "All tables should be dropped" + + @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_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_client.create_namespace(create_ns_req) + + num_tables = 10 + + # Phase 1: Create all tables concurrently using separate namespace instances + create_success_count = 0 + create_fail_count = 0 + create_lock = Lock() + + def create_table(table_index): + nonlocal create_success_count, create_fail_count + try: + # Use high commit_retries to handle version collisions + 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] + table_data = create_test_data() + ipc_data = table_to_ipc_bytes(table_data) + + create_req = CreateTableRequest(id=table_id) + local_ns_client.create_table(create_req, ipc_data) + + with create_lock: + create_success_count += 1 + except Exception as e: + with create_lock: + create_fail_count += 1 + raise e + + with concurrent.futures.ThreadPoolExecutor( + max_workers=num_tables + ) as executor: + futures = [executor.submit(create_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + assert create_success_count == num_tables, ( + f"All creates should succeed, got {create_success_count}" + ) + + # Phase 2: Drop all tables concurrently using NEW namespace instances + drop_success_count = 0 + drop_fail_count = 0 + drop_lock = Lock() + + def drop_table(table_index): + nonlocal drop_success_count, drop_fail_count + try: + # Use high commit_retries to handle version collisions + 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) + local_ns_client.drop_table(drop_req) + + with drop_lock: + drop_success_count += 1 + except Exception as e: + with drop_lock: + drop_fail_count += 1 + raise e + + with concurrent.futures.ThreadPoolExecutor( + max_workers=num_tables + ) as executor: + futures = [executor.submit(drop_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + assert drop_success_count == num_tables, ( + f"All drops should succeed, got {drop_success_count}" + ) + assert drop_fail_count == 0, f"No drops should fail, got {drop_fail_count}" + + # Verify all tables are dropped + 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_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).count + 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).count + 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).count + 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).data + 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).data + reader = pa.ipc.open_file(pa.BufferReader(result_bytes)) + result_table = reader.read_all() + assert result_table.num_rows == 2 # Alice and Charlie + + +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"] + + +def test_import_lance_does_not_load_namespace_client(): + # The generated REST client is expensive to import and only needed when a + # namespace is actually used, so `import lance` must not pull it in. + code = ( + "import sys, lance; " + "assert 'lance_namespace' not in sys.modules, sorted(sys.modules); " + "assert 'lance.namespace' not in sys.modules; " + "lance.LanceNamespace; lance.DescribeTableRequest; " + "assert 'lance_namespace' in sys.modules" + ) + subprocess.run([sys.executable, "-c", code], check=True) diff --git a/python/python/tests/test_namespace_integration.py b/python/python/tests/test_namespace_integration.py index 30489496e38..ea2c8f08c4d 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,20 +17,146 @@ 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 ( - CreateEmptyTableRequest, - CreateEmptyTableResponse, DeclareTableRequest, - DeclareTableResponse, DescribeTableRequest, - DescribeTableResponse, + DirectoryNamespace, LanceNamespace, ) +from lance_namespace import ( + CreateNamespaceRequest, + CreateNamespaceResponse, + CreateTableBranchRequest, + CreateTableBranchResponse, + CreateTableRequest, + CreateTableResponse, + CreateTableVersionRequest, + CreateTableVersionResponse, + DeclareTableResponse, + DeregisterTableRequest, + DeregisterTableResponse, + DescribeNamespaceRequest, + DescribeNamespaceResponse, + DescribeTableResponse, + DescribeTableVersionRequest, + DescribeTableVersionResponse, + DropNamespaceRequest, + DropNamespaceResponse, + DropTableRequest, + DropTableResponse, + ListNamespacesRequest, + ListNamespacesResponse, + ListTablesRequest, + ListTablesResponse, + ListTableVersionsRequest, + ListTableVersionsResponse, + NamespaceExistsRequest, + NamespaceExistsResponse, + RegisterTableRequest, + RegisterTableResponse, + TableExistsRequest, + TableExistsResponse, +) + + +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 + ) -> NamespaceExistsResponse: + 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) -> TableExistsResponse: + 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 create_table_branch( + self, request: CreateTableBranchRequest + ) -> CreateTableBranchResponse: + return self._inner.create_table_branch(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 = { @@ -76,264 +205,264 @@ 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 get_create_call_count(self) -> int: - with self.lock: - return self.create_call_count - - 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 create_empty_table( - self, request: CreateEmptyTableRequest - ) -> CreateEmptyTableResponse: - with self.lock: - self.create_call_count += 1 - count = self.create_call_count - - response = self.inner.create_empty_table(request) - response.storage_options = self._modify_storage_options( - response.storage_options, count - ) - - return response - - def declare_table(self, request: DeclareTableRequest) -> DeclareTableResponse: - with self.lock: - self.create_call_count += 1 - count = self.create_call_count +def create_tracking_namespace( + bucket_name: str, + storage_options: dict, + credential_expires_in_seconds: int = 60, + use_custom: bool = False, + managed_versioning: 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 + managed_versioning: If True, enable the manifest catalog so table versions + are tracked by the namespace and commits route through + create_table_version + + 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" + + dir_props = {f"storage.{k}": v for k, v in storage_options_with_refresh.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" + + # 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 + ) + if managed_versioning: + dir_props["manifest_enabled"] = "true" + dir_props["table_version_tracking_enabled"] = "true" + dir_props["table_version_storage_enabled"] = "true" - response = self.inner.declare_table(request) - response.storage_options = self._modify_storage_options( - response.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._modify_storage_options( - response.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, table_id=table_id, mode="create" + table1, + 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, table_id=table_id, mode="create" + table1, + 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, table_id=table_id, mode="append" + table2, + 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() @@ -341,133 +470,224 @@ 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, table_id=table_id, mode="create" + table1, + 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, table_id=table_id, mode="create" + table1, + 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 +@pytest.mark.parametrize("use_custom", [False, True], ids=["DirectoryNS", "CustomNS"]) +def test_namespace_managed_branches(s3_bucket: str, use_custom: bool): + """Branches on a managed-versioning table over S3. + + Branch commits must route through the catalog (create_table_version) and + leave main's chain untouched. A cross-branch checkout at an overlapping + version number must resolve the requested chain: branch version numbers + continue from the fork point, so the same number exists on both chains + with different data. + """ + storage_options = copy.deepcopy(CONFIG) + + 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, + managed_versioning=True, + ) + + table_name = uuid.uuid4().hex + table_id = ["test_ns", table_name] + + def commit_count() -> int: + return inner_ns_client.retrieve_ops_metrics().get("create_table_version", 0) + + lance.write_dataset( + pa.Table.from_pylist([{"a": 1}]), + namespace_client=ns_client, + table_id=table_id, + mode="create", + storage_options=storage_options, + ) + ds = lance.write_dataset( + pa.Table.from_pylist([{"a": 2}]), + namespace_client=ns_client, + table_id=table_id, + mode="append", + storage_options=storage_options, + ) + assert commit_count() >= 2 + + ns_client.create_table_branch( + CreateTableBranchRequest(id=table_id, name="dev", from_version=2) + ) + + dev = ds.checkout_version(("dev", None)) + commits_before_branch_append = commit_count() + dev = lance.write_dataset( + pa.Table.from_pylist([{"a": 3}]), + dev, + mode="append", + storage_options=storage_options, + ) + assert commit_count() == commits_before_branch_append + 1 + assert sorted(dev.to_table()["a"].to_pylist()) == [1, 2, 3] + + # Diverge main to the same version number as dev's tip. + ds = lance.write_dataset( + pa.Table.from_pylist([{"a": 100}]), + namespace_client=ns_client, + table_id=table_id, + mode="append", + storage_options=storage_options, + ) + assert sorted(ds.to_table()["a"].to_pylist()) == [1, 2, 100] + + on_dev = ds.checkout_version(("dev", 3)) + assert sorted(on_dev.to_table()["a"].to_pylist()) == [1, 2, 3] + back_on_main = dev.checkout_version(("main", None)) + assert sorted(back_on_main.to_table()["a"].to_pylist()) == [1, 2, 100] + + fresh = lance.dataset( + namespace_client=ns_client, + table_id=table_id, + storage_options=storage_options, + ) + assert sorted(fresh.to_table()["a"].to_pylist()) == [1, 2, 100] @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 @@ -476,7 +696,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}]) @@ -484,7 +705,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}]) @@ -492,7 +714,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 @@ -503,7 +726,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 @@ -521,50 +745,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, table_id=table_id, mode="create" + table1, + 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) ) - namespace_storage_options = describe_response.storage_options - - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) + 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) file_uri = f"s3://{s3_bucket}/{table_name}_file_test.lance" schema = pa.schema([pa.field("x", pa.int64()), pa.field("y", pa.int64())]) @@ -572,8 +800,9 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer = LanceFileWriter( file_uri, schema=schema, - storage_options=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) batch = pa.RecordBatch.from_pydict({"x": [1, 2, 3], "y": [4, 5, 6]}, schema=schema) @@ -585,13 +814,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=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) result = reader.read_all(batch_size=1024) result_table = result.to_table() @@ -609,8 +839,9 @@ def test_file_writer_with_storage_options_provider(s3_bucket: str): writer2 = LanceFileWriter( file_uri2, schema=schema, - storage_options=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) batch3 = pa.RecordBatch.from_pydict( @@ -619,13 +850,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=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) result2 = reader2.read_all(batch_size=1024) result_table2 = result2.to_table() @@ -635,17 +867,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}]) @@ -653,52 +886,57 @@ 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_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) ) - namespace_storage_options = describe_response.storage_options - - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) + merged_options = dict(storage_options) + if describe_response.storage_options: + merged_options.update(describe_response.storage_options) 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, - 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) writer.close() # Get fresh credentials for reading - describe_response = namespace.describe_table( + describe_response = ns_client.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() + initial_describe_count = get_describe_call_count(inner_ns_client) # First read should work without needing refresh reader = LanceFileReader( file_uri, - storage_options=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + 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 @@ -709,7 +947,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 @@ -720,8 +958,9 @@ 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_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) result2 = reader2.read_all(batch_size=1024) result_table2 = result2.to_table() @@ -729,22 +968,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}]) @@ -752,26 +992,29 @@ 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_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) ) - namespace_storage_options = describe_response.storage_options - - provider = LanceNamespaceStorageOptionsProvider( - namespace=namespace, table_id=table_id - ) + 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) - # Create session with storage_options_provider + # Create session with namespace_client session = LanceFileSession( f"s3://{s3_bucket}/{table_name}_session", - storage_options=namespace_storage_options, - storage_options_provider=provider, + storage_options=merged_options, + namespace_client=ns_client, + table_id=table_id, ) # Test contains method @@ -792,7 +1035,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 @@ -808,7 +1051,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 @@ -825,7 +1068,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 @@ -836,5 +1079,303 @@ 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 + + +def create_test_table_data(): + """Create test PyArrow table data for concurrent tests.""" + return pa.Table.from_pylist( + [ + {"id": 1, "name": "Alice", "age": 30}, + {"id": 2, "name": "Bob", "age": 25}, + {"id": 3, "name": "Charlie", "age": 35}, + ] + ) + + +def table_to_ipc_bytes(table): + """Convert PyArrow table to IPC bytes.""" + import io + + sink = io.BytesIO() + with pa.ipc.RecordBatchStreamWriter(sink, table.schema) as writer: + writer.write_table(table) + return sink.getvalue() + + +@pytest.mark.integration +@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 + """ + 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}" + 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() + table_id = ["test_ns", table_name] + + # Create table using lance.write_dataset + ds = lance.write_dataset( + table_data, + namespace_client=ns_client, + table_id=table_id, + mode="create", + storage_options=storage_options, + ) + assert ds is not None + assert ds.count_rows() == 3 + + # Drop table + drop_req = DropTableRequest(id=table_id) + 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): + ns_client.table_exists(exists_req) + + +@pytest.mark.integration +@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 + + 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" + 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"]) + ns_client.create_namespace(create_ns_req) + + num_tables = 10 + success_count = 0 + fail_count = 0 + lock = Lock() + + def create_and_drop_table(table_index): + nonlocal success_count, fail_count + try: + table_name = f"s3_concurrent_table_{table_index}" + table_data = create_test_table_data() + table_id = ["test_ns", table_name] + ipc_data = table_to_ipc_bytes(table_data) + + # Create table using atomic create_table API + create_req = CreateTableRequest(id=table_id) + ns_client.create_table(create_req, ipc_data) + + # Drop table + drop_req = DropTableRequest(id=table_id) + ns_client.drop_table(drop_req) + + with lock: + success_count += 1 + except Exception: + with lock: + fail_count += 1 + raise + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_tables) as executor: + futures = [executor.submit(create_and_drop_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + # All operations must succeed with very high retry count + assert success_count == num_tables, ( + f"Expected {num_tables} successes, got {success_count}" + ) + assert fail_count == 0, f"Expected 0 failures, got {fail_count}" + + +@pytest.mark.integration +@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 + + 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()} + base_dir_props["root"] = f"s3://{s3_bucket}/{test_prefix}" + # Very high retry count to guarantee all operations succeed + base_dir_props["commit_retries"] = "2147483647" + + # Initialize namespace first with a single instance to ensure __manifest + # table is created and parent namespace exists before concurrent operations + 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_client.create_namespace(create_ns_req) + + num_tables = 10 + success_count = 0 + fail_count = 0 + lock = Lock() + + def create_and_drop_table(table_index): + nonlocal success_count, fail_count + try: + # 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() + table_id = ["test_ns", table_name] + ipc_data = table_to_ipc_bytes(table_data) + + # Create table using atomic create_table API + create_req = CreateTableRequest(id=table_id) + local_ns_client.create_table(create_req, ipc_data) + + # Drop table + drop_req = DropTableRequest(id=table_id) + local_ns_client.drop_table(drop_req) + + with lock: + success_count += 1 + except Exception: + with lock: + fail_count += 1 + raise + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_tables) as executor: + futures = [executor.submit(create_and_drop_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + # All operations must succeed with very high retry count + assert success_count == num_tables, ( + f"Expected {num_tables} successes, got {success_count}" + ) + assert fail_count == 0, f"Expected 0 failures, got {fail_count}" + + # Verify remaining state is consistent (no corruption) + 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_client.list_tables(list_req) # Should not error + + +@pytest.mark.integration +@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 + + 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()} + base_dir_props["root"] = f"s3://{s3_bucket}/{test_prefix}" + # Very high retry count to guarantee all operations succeed + base_dir_props["commit_retries"] = "2147483647" + + # Initialize namespace first with a single instance to ensure __manifest + # table is created and parent namespace exists before concurrent operations + 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_client.create_namespace(create_ns_req) + + num_tables = 10 + + # Phase 1: Create all tables concurrently using separate namespace client instances + create_success_count = 0 + create_fail_count = 0 + create_lock = Lock() + + def create_table(table_index): + nonlocal create_success_count, create_fail_count + table_name = f"s3_cross_instance_table_{table_index}" + try: + 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] + ipc_data = table_to_ipc_bytes(table_data) + + # Create table using atomic create_table API + create_req = CreateTableRequest(id=table_id) + local_ns_client.create_table(create_req, ipc_data) + + with create_lock: + create_success_count += 1 + except Exception: + with create_lock: + create_fail_count += 1 + raise + + with concurrent.futures.ThreadPoolExecutor(max_workers=num_tables) as executor: + futures = [executor.submit(create_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + # All creates must succeed with very high retry count + assert create_success_count == num_tables, ( + f"Expected {num_tables} create successes, got {create_success_count}" + ) + assert create_fail_count == 0, ( + f"Expected 0 create failures, got {create_fail_count}" + ) + + # Phase 2: Drop all tables using NEW namespace instances + drop_success_count = 0 + drop_fail_count = 0 + drop_lock = Lock() + + def drop_table(table_index): + nonlocal drop_success_count, drop_fail_count + try: + 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) + local_ns_client.drop_table(drop_req) + + with drop_lock: + drop_success_count += 1 + except Exception: + with drop_lock: + drop_fail_count += 1 + raise + + # Drop all tables + with concurrent.futures.ThreadPoolExecutor(max_workers=num_tables) as executor: + futures = [executor.submit(drop_table, i) for i in range(num_tables)] + concurrent.futures.wait(futures) + + # All drops must succeed with very high retry count + assert drop_success_count == num_tables, ( + f"Expected {num_tables} drop successes, got {drop_success_count}" + ) + assert drop_fail_count == 0, f"Expected 0 drop failures, got {drop_fail_count}" + + # Verify remaining state is consistent (no corruption) + 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_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 de1a57ace8d..140d9168c05 100644 --- a/python/python/tests/test_namespace_rest.py +++ b/python/python/tests/test_namespace_rest.py @@ -17,9 +17,9 @@ import pyarrow as pa import pytest from lance_namespace import ( - CreateEmptyTableRequest, CreateNamespaceRequest, CreateTableRequest, + DeclareTableRequest, DeregisterTableRequest, DescribeNamespaceRequest, DescribeTableRequest, @@ -268,32 +268,6 @@ def test_drop_table(self, rest_namespace): with pytest.raises(Exception): rest_namespace.table_exists(exists_req) - def test_create_empty_table(self, rest_namespace): - """Test creating an empty table.""" - # Create parent namespace - create_ns_req = CreateNamespaceRequest(id=["workspace"]) - rest_namespace.create_namespace(create_ns_req) - - # Create empty table with schema - schema = pa.schema( - [ - pa.field("id", pa.int64()), - pa.field("name", pa.string()), - ] - ) - - create_req = CreateEmptyTableRequest( - id=["workspace", "empty_table"], schema=schema - ) - response = rest_namespace.create_empty_table(create_req) - - assert response is not None - assert response.location is not None - - # Verify table exists - exists_req = TableExistsRequest(id=["workspace", "empty_table"]) - rest_namespace.table_exists(exists_req) - def test_deregister_table(self, rest_namespace): """Test deregistering a table.""" # Create parent namespace @@ -405,6 +379,39 @@ def test_register_table_rejects_path_traversal(self, rest_namespace): rest_namespace.register_table(register_req) assert "Path traversal is not allowed" in str(exc_info.value) + def test_rename_table(self, rest_namespace): + """Test renaming a table.""" + # Create parent namespace + create_ns_req = CreateNamespaceRequest(id=["workspace"]) + rest_namespace.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"]) + rest_namespace.create_table(create_req, ipc_data) + + # TODO: underlying dir namespace doesn't support rename yet... + + # # Rename the table + # rename_req = RenameTableRequest( + # id=["workspace", "test_table"], + # new_namespace_id=["workspace"], + # new_table_name="test_table_renamed", + # ) + + # response = rest_namespace.rename_table(rename_req) + # assert response is not None + + # # Verify table with old name no longer exists + # exists_req = TableExistsRequest(id=["workspace", "test_table"]) + # with pytest.raises(Exception): + # rest_namespace.table_exists(exists_req) + + # # Verify table with new name exists + # exists_req = TableExistsRequest(id=["workspace", "test_table_renamed"]) + # rest_namespace.table_exists(exists_req) + class TestChildNamespaceOperations: """Tests for operations in child namespaces - mirrors DirectoryNamespace tests.""" @@ -453,21 +460,18 @@ def test_drop_table_in_child_namespace(self, rest_namespace): with pytest.raises(Exception): rest_namespace.table_exists(exists_req) - def test_empty_table_in_child_namespace(self, rest_namespace): - """Test creating an empty table in a child namespace.""" + def test_declared_table_in_child_namespace(self, rest_namespace): + """Test declaring a table in a child namespace.""" # Create child namespace create_ns_req = CreateNamespaceRequest(id=["test_ns"]) rest_namespace.create_namespace(create_ns_req) - # Create empty table - schema = pa.schema([pa.field("id", pa.int64())]) - create_req = CreateEmptyTableRequest( - id=["test_ns", "empty_table"], schema=schema - ) - rest_namespace.create_empty_table(create_req) + # Declare table + declare_req = DeclareTableRequest(id=["test_ns", "declared_table"]) + rest_namespace.declare_table(declare_req) # Verify table exists - exists_req = TableExistsRequest(id=["test_ns", "empty_table"]) + exists_req = TableExistsRequest(id=["test_ns", "declared_table"]) rest_namespace.table_exists(exists_req) @@ -646,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" @@ -671,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 @@ -701,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 @@ -730,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}", @@ -739,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_optimize.py b/python/python/tests/test_optimize.py index 1f23f3bac48..34199956c1e 100644 --- a/python/python/tests/test_optimize.py +++ b/python/python/tests/test_optimize.py @@ -1,5 +1,6 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import json import pickle import random import re @@ -9,6 +10,7 @@ import lance import numpy as np import pyarrow as pa +import pytest from lance.lance import Compaction from lance.optimize import RewriteResult from lance.vector import vec_to_table @@ -26,6 +28,9 @@ def test_dataset_optimize(tmp_path: Path): target_rows_per_fragment=1000, materialize_deletions=False, num_threads=1, + # Loose source budgets: all fragments still compact in one run. + max_source_rows=100_000, + max_source_bytes=1024 * 1024 * 1024, ) assert metrics.fragments_removed == 10 @@ -36,6 +41,122 @@ def test_dataset_optimize(tmp_path: Path): assert dataset.version == 3 +@pytest.mark.parametrize("mode", ["reencode", "try_binary_copy", "force_binary_copy"]) +def test_compact_files_exact_data_storage_version(tmp_path: Path, mode): + data = pa.table({"id": range(4)}) + dataset = lance.write_dataset( + data, tmp_path / "dataset", max_rows_per_file=2, data_storage_version="2.1" + ) + options = dict( + target_rows_per_fragment=4, data_storage_version="2.2", compaction_mode=mode + ) + if mode == "force_binary_copy": + with pytest.raises(OSError, match="target is 2.2.*uses 2.1"): + dataset.optimize.compact_files(**options) + assert dataset.version == 1 + else: + dataset.optimize.compact_files(**options) + assert { + (file.file_major_version, file.file_minor_version) + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } == {(2, 2)} + assert dataset.to_table() == data + assert dataset.data_storage_version == "2.1" + + +@pytest.mark.parametrize("version,expected", [(None, "2.2"), ("2.0", "2.0")]) +def test_compaction_version_config_precedence(tmp_path: Path, version, expected): + dataset = lance.write_dataset( + pa.table({"id": range(4)}), + tmp_path / "dataset", + max_rows_per_file=2, + data_storage_version="2.1", + ) + dataset.update_config({"lance.compaction.data_storage_version": "2.2"}) + options = dict(data_storage_version=version) + plan = Compaction.plan(dataset, options) + assert json.loads(plan.json())["options"]["data_storage_version"] == expected + dataset.optimize.compact_files(**options) + assert { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in dataset.get_fragments() + for file in fragment.metadata.files + } == {expected} + assert dataset.data_storage_version == "2.1" + + +def test_dataset_optimize_excluded_fragment_ids(tmp_path: Path): + dataset = lance.write_dataset( + pa.table({"a": range(800)}), + tmp_path / "dataset", + max_rows_per_file=200, + ) + fragments = dataset.get_fragments() + + metrics = dataset.optimize.compact_files( + target_rows_per_fragment=400, + excluded_fragment_ids=[1, 1, 999], + num_threads=1, + ) + + assert metrics.fragments_removed == 2 + remaining_fragment_ids = { + fragment.fragment_id for fragment in dataset.get_fragments() + } + assert fragments[1].fragment_id in remaining_fragment_ids + + +def test_compact_files_source_budgets(tmp_path: Path): + base_dir = tmp_path / "dataset" + data = pa.table({"a": range(1000), "b": range(1000)}) + dataset = lance.write_dataset(data, base_dir, max_rows_per_file=100) + assert len(dataset.get_fragments()) == 10 + + # A row budget of 250 admits the first two 100-row fragments only, so the + # run is incremental instead of compacting all 10 at once. + metrics = dataset.optimize.compact_files( + target_rows_per_fragment=200, + num_threads=1, + max_source_rows=250, + ) + assert metrics.fragments_removed == 2 + assert metrics.fragments_added == 1 + + # The budgets are hard upper bounds: a budget smaller than a single task + # produces an empty plan and compaction is a no-op. + version_before = dataset.version + metrics = dataset.optimize.compact_files( + target_rows_per_fragment=200, + num_threads=1, + max_source_bytes=1, + ) + assert metrics.fragments_removed == 0 + assert dataset.version == version_before + + with pytest.raises(OSError, match="must be greater than 0"): + dataset.optimize.compact_files(max_source_rows=0) + + +def test_compact_files_max_source_fragments(tmp_path: Path): + rows_per_fragment = 256 * 1024 + dataset = lance.write_dataset( + pa.table({"a": pa.nulls(10 * rows_per_fragment)}), + tmp_path / "dataset", + max_rows_per_file=rows_per_fragment, + ) + assert len(dataset.get_fragments()) == 10 + + metrics = dataset.optimize.compact_files( + max_source_fragments=4, + num_threads=1, + ) + + assert metrics.fragments_removed == 4 + assert metrics.fragments_added == 1 + assert len(dataset.get_fragments()) == 7 + + def test_blob_compaction(tmp_path: Path): base_dir = tmp_path / "blob_dataset" blob_field = pa.field( @@ -56,7 +177,7 @@ def test_blob_compaction(tmp_path: Path): base_dir, schema=schema, max_rows_per_file=1, - data_storage_version="stable", + data_storage_version="2.1", ) assert len(dataset.get_fragments()) == 2 @@ -68,34 +189,151 @@ def test_blob_compaction(tmp_path: Path): assert contents == blobs +def test_blob_compaction_with_nested_json_sibling(tmp_path: Path): + dataset_uri = tmp_path / "nested_blob_json" + info_fields = [lance.blob_field("blob"), pa.field("meta", pa.json_())] + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("info", pa.struct(info_fields)), + ] + ) + for index, row_id in enumerate([1, 2]): + info = pa.StructArray.from_arrays( + [ + lance.blob_array([f"blob-{row_id}".encode()]), + pa.array([json.dumps({"row": row_id})], type=pa.json_()), + ], + fields=info_fields, + ) + lance.write_dataset( + pa.Table.from_arrays([pa.array([row_id]), info], schema=schema), + dataset_uri, + mode="create" if index == 0 else "append", + data_storage_version="2.2", + ) + + dataset = lance.dataset(dataset_uri) + dataset.optimize.compact_files(num_threads=1) + + assert len(dataset.get_fragments()) == 1 + assert [data for _, data in dataset.read_blobs("info.blob", indices=[0, 1])] == [ + b"blob-1", + b"blob-2", + ] + assert [ + json.loads(value) + for value in dataset.to_table(columns=["info.meta"])["info.meta"].to_pylist() + ] == [{"row": 1}, {"row": 2}] + + +@pytest.mark.parametrize("storage_version", ["2.0", "2.1", "2.2"]) +def test_blob_compaction_preserves_null_empty_and_read_parity( + tmp_path: Path, storage_version: str +): + base_dir = tmp_path / f"blob_dataset_{storage_version}" + if storage_version == "2.2": + blob_field = lance.blob_field("blob") + else: + blob_field = pa.field( + "blob", + pa.large_binary(), + metadata={"lance-encoding:blob": "true"}, + ) + schema = pa.schema([pa.field("id", pa.int64()), blob_field]) + + def make_blob_array(values): + if storage_version == "2.2": + return lance.blob_array(values) + return pa.array(values, type=pa.large_binary()) + + for index, (ids, values) in enumerate( + [ + ([1, 2], [b"P1", None]), + ([3, 4, 5], [b"P3", None, b""]), + ] + ): + lance.write_dataset( + pa.Table.from_arrays( + [ + pa.array(ids, type=pa.int64()), + make_blob_array(values), + ], + schema=schema, + ), + base_dir, + mode="create" if index == 0 else "append", + data_storage_version=storage_version, + ) + + dataset = lance.dataset(base_dir) + dataset.delete("id = 2") + dataset.optimize.compact_files(num_threads=1) + + expected = {1: b"P1", 3: b"P3", 4: None, 5: b""} + binary_table = dataset.to_table(columns=["id", "blob"], blob_handling="all_binary") + scan_values = dict( + zip( + binary_table.column("id").to_pylist(), + binary_table.column("blob").to_pylist(), + ) + ) + + ids = dataset.to_table(columns=["id"]).column("id").to_pylist() + blob_files = dataset.take_blobs("blob", indices=range(len(ids))) + take_values = { + row_id: None if blob_file is None else blob_file.readall() + for row_id, blob_file in zip(ids, blob_files) + } + + assert take_values == expected + assert scan_values == take_values + + descriptor_table = dataset.to_table(columns=["id", "blob"]) + descriptions = dict( + zip( + descriptor_table.column("id").to_pylist(), + descriptor_table.column("blob").to_pylist(), + ) + ) + if storage_version == "2.0": + assert descriptions[4] == {"position": 1, "size": 0} + else: + assert descriptions[4] is None + assert descriptions[5] is not None + assert descriptions[5]["size"] == 0 + + def test_optimize_max_bytes(tmp_path: Path): base_dir = tmp_path / "dataset" arr = pa.array(range(4 * 1024 * 1024)) 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 +353,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, @@ -296,30 +534,160 @@ def test_index_remapping_multiple_rewrite_tasks(tmp_path: Path): fragments = list(ds.get_fragments()) assert len(fragments) == 2 - index = ds.list_indices()[0] - index_frag_ids = list(index["fragment_ids"]) + index = ds.describe_indices()[0] + index_frag_ids = list(index.segments[0].fragment_ids) frag_ids = [frag.fragment_id for frag in fragments] assert len(index_frag_ids) == 1 assert index_frag_ids[0] in frag_ids -def test_dataset_distributed_optimize(tmp_path: Path): +def test_defer_index_remap(tmp_path: Path): base_dir = tmp_path / "dataset" - data = pa.table({"a": range(800), "b": range(800)}) + data = pa.table({"i": range(6_000), "val": range(6_000)}) + dataset = lance.write_dataset(data, base_dir, max_rows_per_file=1_000) + dataset.create_scalar_index("i", "BTREE") + options = dict( + target_rows_per_fragment=2_000, defer_index_remap=True, num_threads=1 + ) + + dataset.delete("i < 500") + dataset.optimize.compact_files(**options) + + dataset = lance.dataset(base_dir) + indices = dataset.describe_indices() + assert any(idx.name == "__lance_frag_reuse" for idx in indices) + + +@pytest.mark.parametrize("use_commit_options", [True, False]) +def test_defer_index_remap_via_commit_options(tmp_path: Path, use_commit_options: bool): + """Compaction.commit respects defer_index_remap passed in options. + + When options={"defer_index_remap": True} is supplied to Compaction.commit + the __lance_frag_reuse system index must appear in describe_indices(). + When the option is omitted (default) no such system index is written. + """ + base_dir = tmp_path / f"dataset_commit_opts_{use_commit_options}" + data = pa.table({"i": range(6_000), "val": range(6_000)}) + dataset = lance.write_dataset(data, base_dir, max_rows_per_file=1_000) + dataset.create_scalar_index("i", "BTREE") + dataset.delete("i < 500") - dataset = lance.write_dataset(data, base_dir, max_rows_per_file=200) + plan = Compaction.plan( + dataset, + options=dict(target_rows_per_fragment=2_000, num_threads=1), + ) + rewrites = [task.execute(dataset) for task in plan.tasks] + + if use_commit_options: + Compaction.commit(dataset, rewrites, options={"defer_index_remap": True}) + else: + Compaction.commit(dataset, rewrites) + + dataset = lance.dataset(base_dir) + indices = dataset.describe_indices() + has_frag_reuse = any(idx.name == "__lance_frag_reuse" for idx in indices) + + if use_commit_options: + assert has_frag_reuse, ( + "expected __lance_frag_reuse system index when defer_index_remap=True " + "is passed to Compaction.commit" + ) + else: + assert not has_frag_reuse, ( + "did not expect __lance_frag_reuse system index when options is omitted " + "from Compaction.commit" + ) + + +@pytest.mark.filterwarnings("ignore::DeprecationWarning") +def test_describe_indices_matches_list_indices_for_frag_reuse(tmp_path: Path): + """describe_indices() and list_indices() must agree on the index_type + string for every index, including the __lance_frag_reuse system index + that defer_index_remap produces. + + list_indices() is a wrapper over describe_indices(), so the two must stay + in sync. System indices are identified by name via infer_system_index_type() + in rust/lance/src/index.rs::IndexDescriptionImpl::try_new. + """ + base_dir = tmp_path / "dataset" + data = pa.table({"i": range(6_000), "val": range(6_000)}) + dataset = lance.write_dataset(data, base_dir, max_rows_per_file=1_000) + dataset.create_scalar_index("i", "BTREE") + dataset.delete("i < 500") + dataset.optimize.compact_files( + target_rows_per_fragment=2_000, defer_index_remap=True, num_threads=1 + ) + + dataset = lance.dataset(base_dir) + described = {d.name: d.index_type for d in dataset.describe_indices()} + listed = {idx["name"]: idx["type"] for idx in dataset.list_indices()} + + assert "__lance_frag_reuse" in listed, ( + "test precondition: defer_index_remap should produce a frag-reuse index" + ) + assert described == listed, ( + "describe_indices and list_indices disagree on index_type:\n" + f" describe_indices: {described}\n" + f" list_indices: {listed}" + ) + + +@pytest.mark.parametrize( + "version", [None, "2.0", "2.1", "2.2", "2.3", "stable", "next"] +) +def test_dataset_distributed_optimize(tmp_path: Path, version): + base_dir = tmp_path / "dataset" + data = pa.table({"a": range(8), "b": range(8)}) + + dataset = lance.write_dataset( + data, + base_dir, + max_rows_per_file=2, + data_storage_version="2.1", + ) fragments = dataset.get_fragments() assert len(fragments) == 4 plan = Compaction.plan( - dataset, options=dict(target_rows_per_fragment=400, num_threads=1) + dataset, + options=dict( + target_rows_per_fragment=4, + num_threads=1, + data_storage_version=version, + ), ) assert plan.read_version == 1 assert plan.num_tasks() == 2 assert plan.tasks[0].fragments == [frag.metadata for frag in fragments[0:2]] assert plan.tasks[1].fragments == [frag.metadata for frag in fragments[2:4]] assert repr(plan) == "CompactionPlan(read_version=1, tasks=<2 compaction tasks>)" + + excluded_plan = Compaction.plan( + dataset, + options=dict( + target_rows_per_fragment=4, + excluded_fragment_ids=[1, 1, 999], + num_threads=1, + ), + ) + assert excluded_plan.num_tasks() == 1 + assert excluded_plan.tasks[0].fragments == [ + frag.metadata for frag in fragments[2:4] + ] + assert pickle.loads(pickle.dumps(excluded_plan)) == excluded_plan + + none_plan = Compaction.plan( + dataset, + options=dict( + target_rows_per_fragment=4, + excluded_fragment_ids=None, + num_threads=1, + data_storage_version=version, + ), + ) + assert none_plan == plan + # Plan can be pickled assert pickle.loads(pickle.dumps(plan)) == plan @@ -327,24 +695,32 @@ def test_dataset_distributed_optimize(tmp_path: Path): task = pickle.loads(pickled_task) assert task == plan.tasks[0] - result1 = plan.tasks[0].execute(dataset) - result1.metrics.fragments_removed == 2 - result1.metrics.fragments_added == 1 + result1 = task.execute(dataset) + assert result1.metrics.fragments_removed == 2 + assert result1.metrics.fragments_added == 1 pickled_result = pickle.dumps(result1) result = pickle.loads(pickled_result) assert isinstance(result, RewriteResult) assert result == result1 + exact = {None: "2.1", "stable": "2.2", "next": "2.3"}.get(version, version) + assert { + f"{file.file_major_version}.{file.file_minor_version}" + for fragment in result.new_fragments + for file in fragment.files + } == {exact} assert re.match( r"RewriteResult\(read_version=1, new_fragments=\[.+\], old_fragments=\[.+\]\)", repr(result), ) - metrics = Compaction.commit(dataset, [result1]) + metrics = Compaction.commit(dataset, [result]) assert metrics.fragments_removed == 2 assert metrics.fragments_added == 1 # Compaction occurs in two transactions so it increments the version by 2. assert dataset.version == 3 + assert dataset.data_storage_version == "2.1" + assert dataset.to_table().sort_by("a") == data def test_migration_via_fragment_apis(tmp_path): @@ -393,6 +769,95 @@ def test_migration_via_fragment_apis(tmp_path): assert ds2.data_storage_version == "2.0" +def test_optimize_indices_second_call_is_noop(tmp_path: Path): + """A second optimize_indices call when nothing has changed since the first + must not write any new files to the dataset directory.""" + base_dir = tmp_path / "dataset" + + def assert_optimize_is_noop(dataset: lance.LanceDataset): + version_before = dataset.version + files_before = { + path.relative_to(base_dir) for path in base_dir.rglob("*") if path.is_file() + } + + dataset.optimize.optimize_indices() + + files_after = { + path.relative_to(base_dir) for path in base_dir.rglob("*") if path.is_file() + } + assert dataset.version == version_before + assert files_after == files_before + + n = 1024 + rng = np.random.default_rng(0) + vectors = rng.standard_normal((n, 8)).astype(np.float32) + table = pa.table( + { + "id": pa.array(range(n), type=pa.int64()), + "category": pa.array([f"cat{i % 4}" for i in range(n)]), + "tags": pa.array([[f"t{i % 3}", f"t{(i + 1) % 3}"] for i in range(n)]), + "doc": pa.array([f"hello world document {i}" for i in range(n)]), + "name": pa.array([f"name_{i:05d}" for i in range(n)]), + "value": pa.array(range(n), type=pa.int64()), + "bloom_val": pa.array(range(n), type=pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1), type=pa.float32()), 8 + ), + } + ) + dataset = lance.write_dataset(table, base_dir) + + dataset.create_scalar_index("id", index_type="BTREE") + dataset.create_scalar_index("category", index_type="BITMAP") + dataset.create_scalar_index("tags", index_type="LABEL_LIST") + dataset.create_scalar_index("doc", index_type="INVERTED") + dataset.create_scalar_index("name", index_type="NGRAM") + dataset.create_scalar_index("value", index_type="ZONEMAP") + dataset.create_scalar_index("bloom_val", index_type="BLOOMFILTER") + # num_partitions=1 keeps this dataset balanced: the auto-rebalance check + # in merge_indices only finds join candidates when num_partitions > 1, and + # 1024 + 128 rows is well below the split threshold. Without this, the + # rebalance heuristic would keep finding work on the small partitions. + dataset.create_index( + "vector", index_type="IVF_PQ", num_partitions=1, num_sub_vectors=2 + ) + + extra_rows = 128 + extra_vectors = rng.standard_normal((extra_rows, 8)).astype(np.float32) + extra = pa.table( + { + "id": pa.array(range(n, n + extra_rows), type=pa.int64()), + "category": pa.array([f"cat{i % 4}" for i in range(extra_rows)]), + "tags": pa.array([[f"t{i % 3}"] for i in range(extra_rows)]), + "doc": pa.array([f"goodbye world document {i}" for i in range(extra_rows)]), + "name": pa.array([f"add_{i:05d}" for i in range(extra_rows)]), + "value": pa.array(range(n, n + extra_rows), type=pa.int64()), + "bloom_val": pa.array(range(n, n + extra_rows), type=pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(extra_vectors.reshape(-1), type=pa.float32()), 8 + ), + } + ) + dataset = lance.write_dataset(extra, base_dir, mode="append") + + # First optimize: should pull the new fragment into each index. + dataset.optimize.optimize_indices() + assert_optimize_is_noop(dataset) + + # Consolidate the vector delta so every fragment has the same physical index + # coverage; compaction intentionally avoids mixing fragments that do not. + dataset.optimize.optimize_indices(num_indices_to_merge=10) + + # Compaction invalidates the old fragment coverage, so one optimize is needed + # to rebuild each index. Further calls must return to the same steady state. + compaction = dataset.optimize.compact_files( + target_rows_per_fragment=2 * (n + extra_rows), num_threads=1 + ) + assert compaction.fragments_removed > 0 + dataset.optimize.optimize_indices() + assert_optimize_is_noop(dataset) + + def test_compaction_generates_rewrite_transaction(tmp_path: Path): # Create a small dataset with multiple fragments base_dir = tmp_path / "rewrite_txn" @@ -409,3 +874,38 @@ def test_compaction_generates_rewrite_transaction(tmp_path: Path): t is not None and t.operation.__class__.__name__ == "Rewrite" for t in transactions ) + + +def test_remap_row_addrs(tmp_path: Path): + # Dataset.remap_row_addrs follows rows across a compaction via the + # fragment-reuse index: an address valid before the compaction maps to the + # row's new address after it. None when there is no fragment-reuse index. + base_dir = tmp_path / "dataset" + data = pa.table({"id": range(1_000), "v": range(1_000)}) + ds = lance.write_dataset(data, base_dir, max_rows_per_file=100) # 10 fragments + + # No fragment-reuse index yet -> None (nothing to remap against). + addrs = pa.array([0, 1 << 32, (5 << 32) | 7], pa.uint64()) + assert ds.remap_row_addrs(addrs) is None + + before = ds.scanner(columns=["id"], with_row_address=True).to_table() + old = dict(zip(before["id"].to_pylist(), before["_rowaddr"].to_pylist())) + + # A deferred-remap compaction records a fragment-reuse index only when it + # rewrites data an index covers, so index a column first. + ds.create_scalar_index("id", "BTREE") + + ds.optimize.compact_files( + target_rows_per_fragment=1_000, defer_index_remap=True, num_threads=1 + ) + ds = lance.dataset(base_dir) + assert any(idx.name == "__lance_frag_reuse" for idx in ds.describe_indices()) + + after = ds.scanner(columns=["id"], with_row_address=True).to_table() + new = dict(zip(after["id"].to_pylist(), after["_rowaddr"].to_pylist())) + + sample = [0, 137, 999] + remapped = ds.remap_row_addrs( + pa.array([old[i] for i in sample], pa.uint64()) + ).to_pylist() + assert remapped == [new[i] for i in sample] diff --git a/python/python/tests/test_otel.py b/python/python/tests/test_otel.py new file mode 100644 index 00000000000..a96e0125f56 --- /dev/null +++ b/python/python/tests/test_otel.py @@ -0,0 +1,93 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +import lance +import pyarrow as pa +import pytest + +# The metrics recorder is process-global and installed once, so the whole +# bridge is exercised in a single test to avoid cross-test global-state coupling. + + +def _metrics_by_name(reader): + data = reader.get_metrics_data() + result = {} + for resource_metrics in data.resource_metrics: + for scope_metrics in resource_metrics.scope_metrics: + for metric in scope_metrics.metrics: + result[metric.name] = metric + return result + + +def test_instrument_lance_metrics_exports_object_store_metrics(tmp_path): + pytest.importorskip("opentelemetry.sdk.metrics") + from lance.otel import instrument_lance_metrics + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import InMemoryMetricReader + + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + assert instrument_lance_metrics(provider) + + # The catalog is populated once the recorder is installed. + from lance.lance import lance_metrics_catalog + + catalog = {desc.name: desc for desc in lance_metrics_catalog()} + assert "lance_object_store_requests_total" in catalog + assert catalog["lance_object_store_request_duration_seconds"].kind == "histogram" + # Gauges and the retryable counter are described too, so they surface in the + # export even when a plain write doesn't happen to emit them. + assert catalog["lance_object_store_retryable_responses_total"].kind == "counter" + assert catalog["lance_object_store_in_flight_requests"].kind == "gauge" + + # Generate object store activity on the local filesystem (scheme "file"). + table = pa.table({"id": pa.array(range(256))}) + dataset = lance.write_dataset(table, str(tmp_path / "ds.lance")) + assert dataset.to_table().num_rows == 256 + + metrics = _metrics_by_name(reader) + + requests = metrics["lance_object_store_requests_total"] + points = list(requests.data.data_points) + assert points, "expected at least one request data point" + # The `base` label carries the store scheme ("file") by default. + assert all("base" in p.attributes and "operation" in p.attributes for p in points) + assert sum(p.value for p in points) > 0 + + # Histograms are decomposed into bucket / count / sum observable counters. + bucket = metrics["lance_object_store_request_duration_seconds_bucket"] + bucket_points = list(bucket.data.data_points) + assert bucket_points + assert all("le" in p.attributes for p in bucket_points) + # The implicit +Inf bucket must be present and is the cumulative maximum. + assert any(p.attributes["le"] == "+Inf" for p in bucket_points) + + count = metrics["lance_object_store_request_duration_seconds_count"] + assert sum(p.value for p in count.data.data_points) > 0 + + # The `_sum` instrument must also be wired and report positive latency. + duration_sum = metrics["lance_object_store_request_duration_seconds_sum"] + assert sum(p.value for p in duration_sum.data.data_points) > 0 + + +def test_snapshot_empty_before_install_is_safe(): + # snapshot is callable regardless of installation state and never raises. + from lance.lance import snapshot_lance_metrics + + assert isinstance(snapshot_lance_metrics(), list) + + +def test_instrument_warns_when_recorder_unavailable(monkeypatch): + # A foreign `metrics` recorder already installed -> register returns False; + # instrument_lance_metrics must warn and return False without instrumenting. + pytest.importorskip("opentelemetry.sdk.metrics") + import lance.otel as otel + from opentelemetry.sdk.metrics import MeterProvider + from opentelemetry.sdk.metrics.export import InMemoryMetricReader + + monkeypatch.setattr(otel, "register_lance_metrics_recorder", lambda: False) + + reader = InMemoryMetricReader() + provider = MeterProvider(metric_readers=[reader]) + with pytest.warns(UserWarning, match="recorder"): + assert otel.instrument_lance_metrics(provider) is False diff --git a/python/python/tests/test_pydantic.py b/python/python/tests/test_pydantic.py new file mode 100644 index 00000000000..3db60885f6b --- /dev/null +++ b/python/python/tests/test_pydantic.py @@ -0,0 +1,177 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +from enum import Enum +from typing import List, Optional + +import pyarrow as pa +import pytest + +BaseModel = pytest.importorskip("pydantic").BaseModel +Field = pytest.importorskip("pydantic").Field + +from lance.pydantic import ( # noqa: E402 + MultiVector, + Vector, + is_nullable, + pydantic_to_schema, +) + + +def test_scalar_and_optional_and_list_unchanged(): + class Simple(BaseModel): + name: str + score: float + tag: Optional[str] = None + values: List[int] + + schema = pydantic_to_schema(Simple) + assert schema == pa.schema( + [ + pa.field("name", pa.utf8(), False), + pa.field("score", pa.float64(), False), + pa.field("tag", pa.utf8(), True), + pa.field("values", pa.list_(pa.int64()), False), + ] + ) + + +def test_nested_model_becomes_struct(): + class Address(BaseModel): + city: str + zip_code: str + + class Person(BaseModel): + name: str + address: Address + + schema = pydantic_to_schema(Person) + address_type = pa.struct( + [ + pa.field("city", pa.utf8(), False), + pa.field("zip_code", pa.utf8(), False), + ] + ) + assert schema == pa.schema( + [ + pa.field("name", pa.utf8(), False), + pa.field("address", address_type, False), + ] + ) + + +def test_string_enum_is_dictionary_encoded(): + class Color(str, Enum): + RED = "red" + GREEN = "green" + + class Item(BaseModel): + color: Color + + schema = pydantic_to_schema(Item) + assert schema.field("color").type == pa.dictionary(pa.int32(), pa.utf8()) + + +def test_int_enum_uses_native_type(): + class Priority(int, Enum): + LOW = 0 + HIGH = 1 + + class Task(BaseModel): + priority: Priority + + schema = pydantic_to_schema(Task) + assert schema.field("priority").type == pa.int64() + + +def test_vector_field(): + class Doc(BaseModel): + id: int + embedding: Vector(8) + + schema = pydantic_to_schema(Doc) + assert schema.field("embedding").type == pa.list_(pa.float32(), 8) + assert schema.field("embedding").nullable is True + + +def test_vector_field_not_nullable(): + class Doc(BaseModel): + id: int + embedding: Vector(8, nullable=False) + + schema = pydantic_to_schema(Doc) + assert schema.field("embedding").nullable is False + + +def test_multi_vector_field(): + class Doc(BaseModel): + id: int + embeddings: MultiVector(4) + + schema = pydantic_to_schema(Doc) + assert schema.field("embeddings").type == pa.list_(pa.list_(pa.float32(), 4)) + + +def test_tz_aware_datetime_field(): + from datetime import datetime + + class Event(BaseModel): + occurred_at: datetime = Field(json_schema_extra={"tz": "UTC"}) + + schema = pydantic_to_schema(Event) + assert schema.field("occurred_at").type == pa.timestamp("us", tz="UTC") + + +def test_naive_datetime_field(): + from datetime import datetime + + class Event(BaseModel): + occurred_at: datetime + + schema = pydantic_to_schema(Event) + assert schema.field("occurred_at").type == pa.timestamp("us", tz=None) + + +def test_defaulted_non_optional_field_is_not_nullable(): + """A plain default value (without Optional) should not make a field + nullable -- this matches lancedb's behavior, tightened from lance's + previous default-implies-nullable inference.""" + + class Counter(BaseModel): + count: int = 0 + + schema = pydantic_to_schema(Counter) + assert schema.field("count").nullable is False + + field_info = Counter.model_fields["count"] + assert is_nullable(field_info) is False + + +def test_optional_field_is_nullable(): + class Counter(BaseModel): + count: Optional[int] = None + + schema = pydantic_to_schema(Counter) + assert schema.field("count").nullable is True + + +def test_v1_style_field_nullable_vector_overrides_allow_none(): + """Simulates a Pydantic v1 ModelField (no `.annotation` attribute; type + is read off `.outer_type_`, nullability off `.allow_none`) to verify a + nullable Vector/MultiVector annotation overrides `allow_none=False` + under v1, matching the v2 behavior tested in test_vector_field above.""" + + class FakeV1Field: + def __init__(self, outer_type_, allow_none): + self.outer_type_ = outer_type_ + self.allow_none = allow_none + + nullable_vector = Vector(8, nullable=True) + assert is_nullable(FakeV1Field(nullable_vector, allow_none=False)) is True + + non_nullable_vector = Vector(8, nullable=False) + assert is_nullable(FakeV1Field(non_nullable_vector, allow_none=False)) is False + + # Plain (non-Vector) types still fall back to `allow_none`. + assert is_nullable(FakeV1Field(str, allow_none=True)) is True + assert is_nullable(FakeV1Field(str, allow_none=False)) is False diff --git a/python/python/tests/test_row_addr_prefilter.py b/python/python/tests/test_row_addr_prefilter.py new file mode 100644 index 00000000000..aef647e150f --- /dev/null +++ b/python/python/tests/test_row_addr_prefilter.py @@ -0,0 +1,318 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""End-to-end tests for the external row-address prefilter. + +``row_addr_allowlist`` / ``row_addr_blocklist`` restrict a scan to a set of row +addresses supplied by the caller, rather than to rows a filter expression +selects. The mask is applied before ranking, so a KNN or full-text search +computes top-k over the surviving rows instead of trimming the result +afterwards -- the two differ whenever k is smaller than the candidate set. + +Each test asserts against ``_rowid`` ground truth so a mask that is silently +dropped (which would return every row) fails rather than passing by accident. +""" + +from __future__ import annotations + +import os +from typing import TYPE_CHECKING + +import lance +import numpy as np +import pyarrow as pa +import pytest +from lance.dataset import ScannerBuilder, serialize_row_addrs +from lance.file import LanceFileWriter + +if TYPE_CHECKING: + from pathlib import Path + +N = 256 +DIM = 8 + + +def _write(tmp_path: Path, with_index: bool = False) -> lance.LanceDataset: + rng = np.random.default_rng(1234) + vectors = rng.standard_normal((N, DIM)).astype(np.float32) + tbl = pa.table( + { + "id": pa.array(range(N), pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1), pa.float32()), DIM + ), + "text": pa.array([f"row {i} lorem ipsum" for i in range(N)]), + } + ) + ds = lance.write_dataset(tbl, str(tmp_path / "t.lance"), mode="overwrite") + if with_index: + # IVF_FLAT with both probe bounds set to num_partitions is exact, so the + # masked result can be compared against brute force without recall slack. + ds.create_index("vector", index_type="IVF_FLAT", num_partitions=4, metric="l2") + return ds + + +def _rowids(ds: lance.LanceDataset) -> list[int]: + return ds.to_table(with_row_id=True)["_rowid"].to_pylist() + + +def test_serialize_row_addrs_round_trips_through_a_scan(tmp_path: Path) -> None: + ds = _write(tmp_path) + addrs = _rowids(ds) + want = addrs[3:9] + + got = ds.scanner( + with_row_id=True, row_addr_allowlist=serialize_row_addrs(want) + ).to_table() + assert got["_rowid"].to_pylist() == want + + +def test_allowlist_and_blocklist_combine(tmp_path: Path) -> None: + ds = _write(tmp_path) + addrs = _rowids(ds) + + allow, block = addrs[:10], addrs[5:15] + got = ds.scanner( + with_row_id=True, + row_addr_allowlist=serialize_row_addrs(allow), + row_addr_blocklist=serialize_row_addrs(block), + ).to_table() + assert got["_rowid"].to_pylist() == addrs[:5] + + # Block alone excludes and leaves everything else. + got = ds.scanner( + with_row_id=True, row_addr_blocklist=serialize_row_addrs(addrs[:5]) + ).to_table() + assert got["_rowid"].to_pylist() == addrs[5:] + + +def test_no_mask_reads_everything(tmp_path: Path) -> None: + # Guards the "no mask" vs "empty mask" distinction: omitting both must not + # be read as an allowlist of nothing. + ds = _write(tmp_path) + assert ds.scanner().to_table().num_rows == N + + +def test_empty_allowlist_selects_nothing(tmp_path: Path) -> None: + ds = _write(tmp_path) + got = ds.scanner(row_addr_allowlist=serialize_row_addrs([])).to_table() + assert got.num_rows == 0 + + +def test_mask_composes_with_a_filter(tmp_path: Path) -> None: + ds = _write(tmp_path) + addrs = _rowids(ds) + got = ds.scanner( + columns=["id"], + filter="id % 2 == 0", + row_addr_allowlist=serialize_row_addrs(addrs[:20]), + ).to_table() + assert got["id"].to_pylist() == [i for i in range(20) if i % 2 == 0] + + +def test_builder_setter_matches_the_kwarg(tmp_path: Path) -> None: + ds = _write(tmp_path) + blob = serialize_row_addrs(_rowids(ds)[2:7]) + from_kwarg = ds.scanner(with_row_id=True, row_addr_allowlist=blob).to_table() + from_builder = ( + ScannerBuilder(ds) + .with_row_id(True) + .row_addr_prefilter(allowlist=blob) + .to_scanner() + .to_table() + ) + assert from_kwarg["_rowid"].to_pylist() == from_builder["_rowid"].to_pylist() + + +@pytest.mark.parametrize("with_index", [False, True]) +def test_knn_topk_is_computed_over_masked_rows( + tmp_path: Path, with_index: bool +) -> None: + # The point of a prefilter: with k=5 and a 10-row mask, post-filtering a + # global top-5 would usually return fewer than 5 (often 0) rows. + ds = _write(tmp_path, with_index=with_index) + addrs = _rowids(ds) + allowed = addrs[100:110] + query = np.zeros(DIM, dtype=np.float32) + + got = ds.scanner( + nearest={ + "column": "vector", + "q": query, + "k": 5, + "minimum_nprobes": 4, + "maximum_nprobes": 4, + }, + with_row_id=True, + row_addr_allowlist=serialize_row_addrs(allowed), + ).to_table() + + assert got.num_rows == 5 + assert set(got["_rowid"].to_pylist()) <= set(allowed) + + # Exactly the 5 nearest *within* the mask, not the global 5 intersected. + vectors = np.stack( + [np.asarray(v) for v in ds.to_table(columns=["vector"])["vector"].to_pylist()] + ) + by_addr = dict(zip(addrs, vectors)) + expect = sorted(allowed, key=lambda a: np.linalg.norm(by_addr[a] - query))[:5] + assert sorted(got["_rowid"].to_pylist()) == sorted(expect) + + +def test_knn_blocklist_excludes_the_nearest(tmp_path: Path) -> None: + ds = _write(tmp_path) + query = np.zeros(DIM, dtype=np.float32) + unmasked = ( + ds.scanner(nearest={"column": "vector", "q": query, "k": 3}, with_row_id=True) + .to_table()["_rowid"] + .to_pylist() + ) + + got = ds.scanner( + nearest={"column": "vector", "q": query, "k": 3}, + with_row_id=True, + row_addr_blocklist=serialize_row_addrs(unmasked[:1]), + ).to_table() + + assert got.num_rows == 3 # refilled, not truncated + assert unmasked[0] not in got["_rowid"].to_pylist() + + +def test_full_text_search_honors_the_mask(tmp_path: Path) -> None: + ds = _write(tmp_path) + ds.create_scalar_index("text", index_type="INVERTED") + addrs = _rowids(ds) + allowed = addrs[50:60] + + got = ds.scanner( + full_text_query="lorem", + with_row_id=True, + row_addr_allowlist=serialize_row_addrs(allowed), + limit=5, + ).to_table() + + assert got.num_rows == 5 + assert set(got["_rowid"].to_pylist()) <= set(allowed) + + +def test_rejects_a_malformed_mask(tmp_path: Path) -> None: + ds = _write(tmp_path) + with pytest.raises(Exception, match="(?i)row address mask|invalid"): + ds.scanner(row_addr_allowlist=b"not a treemap").to_table() + + +def _overlay( + ds, base_dir: Path, name: str, batch: pa.Table, fields: list[int], offsets +): + """Commit a data overlay covering `offsets` of fragment 0. + + An overlay committed after an index makes the indexed values stale, so the + planner replays those rows through a separate take. That replay is a second + row source, and it has to honor the caller's mask like every other one. + """ + path = base_dir / "data" / name + with LanceFileWriter(str(path)) as writer: + writer.write_batch(batch) + base_df = ds.get_fragments()[0].metadata.files[0] + data_file = lance.fragment.DataFile( + path=name, + fields=fields, + column_indices=list(range(len(fields))), + file_major_version=base_df.file_major_version, + file_minor_version=base_df.file_minor_version, + file_size_bytes=os.path.getsize(path), + ) + op = lance.LanceOperation.DataOverlay( + [ + lance.LanceOperation.DataOverlayGroup( + 0, [lance.LanceOperation.DataOverlayFile(data_file, offsets=offsets)] + ) + ] + ) + return lance.LanceDataset.commit(ds, op, read_version=ds.version) + + +def test_overlay_stale_replay_scan_respects_mask(tmp_path: Path) -> None: + base_dir = tmp_path / "ov_scan" + ds = lance.write_dataset( + pa.table( + { + "id": pa.array(range(10), pa.int32()), + "val": pa.array([i * 10 for i in range(10)], pa.int32()), + } + ), + base_dir, + ) + # Index first, then overlay: offset 1 now reads 999 while the index still + # says 10, so `val = 999` can only be answered by the stale replay. + ds.create_scalar_index("val", index_type="BTREE") + ds = _overlay( + ds, + base_dir, + "ov.lance", + pa.table({"val": pa.array([999], pa.int32())}), + fields=[1], + offsets=[1], + ) + + base = ds.scanner(filter="val = 999", with_row_id=True).to_table() + assert base.num_rows == 1, "fixture did not produce a stale replay" + stale_addr = base["_rowid"].to_pylist()[0] + + got = ds.scanner( + filter="val = 999", row_addr_allowlist=serialize_row_addrs([]) + ).to_table() + assert got.num_rows == 0, "stale replay must not return rows the mask excludes" + + got = ds.scanner( + filter="val = 999", + with_row_id=True, + row_addr_allowlist=serialize_row_addrs([stale_addr]), + ).to_table() + assert got["_rowid"].to_pylist() == [stale_addr] + + +def test_overlay_stale_replay_ann_respects_mask(tmp_path: Path) -> None: + base_dir = tmp_path / "ov_ann" + rng = np.random.default_rng(7) + vectors = rng.standard_normal((N, DIM)).astype(np.float32) + ds = lance.write_dataset( + pa.table( + { + "id": pa.array(range(N), pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1), pa.float32()), DIM + ), + } + ), + base_dir, + ) + ds.create_index("vector", index_type="IVF_FLAT", num_partitions=4, metric="l2") + + # Move two rows onto the query point after indexing. The ANN index still has + # their old vectors, so they can only surface through the stale replay. + query = np.zeros(DIM, dtype=np.float32) + moved = pa.FixedSizeListArray.from_arrays( + pa.array(np.zeros(2 * DIM, dtype=np.float32), pa.float32()), DIM + ) + ds = _overlay( + ds, + base_dir, + "ov_vec.lance", + pa.table({"vector": moved}), + fields=[1], + offsets=[3, 7], + ) + + base = ds.scanner( + nearest={"column": "vector", "q": query, "k": 5}, with_row_id=True + ).to_table() + assert base.num_rows > 0, "fixture did not produce ANN results" + + got = ds.scanner( + nearest={"column": "vector", "q": query, "k": 5}, + row_addr_allowlist=serialize_row_addrs([]), + ).to_table() + assert got.num_rows == 0, ( + "the ANN stale replay must not return rows the mask excludes" + ) diff --git a/python/python/tests/test_s3_ddb.py b/python/python/tests/test_s3_ddb.py index b9c9e4be6c0..5289dd176fb 100644 --- a/python/python/tests/test_s3_ddb.py +++ b/python/python/tests/test_s3_ddb.py @@ -212,6 +212,58 @@ def writh_dataset_with_start_barrier(): assert lance.dataset(table_dir).count_rows() == expected_version * 2 +@pytest.mark.integration +def test_s3_ddb_branches(s3_bucket: str, ddb_table: str): + """Branches on a table committed through the DynamoDB external manifest + store. + + The DDB store keys version chains by base uri, so each branch chain must + get its own entries via its branch-qualified path. Both chains are given + the same version number with diverged data so a wrong-chain resolution + cannot pass silently. + """ + storage_options = copy.deepcopy(CONFIG) + table_name = uuid.uuid4().hex + table_dir = f"s3+ddb://{s3_bucket}/{table_name}?ddbTableName={ddb_table}" + + # main: v1 (a=1), v2 (a=2) + lance.write_dataset( + pa.Table.from_pylist([{"a": 1}]), table_dir, storage_options=storage_options + ) + ds = lance.write_dataset( + pa.Table.from_pylist([{"a": 2}]), + table_dir, + mode="append", + storage_options=storage_options, + ) + + # Fork "dev" at v2 and commit on it, then diverge main to the same + # version number. + dev = ds.create_branch("dev", 2) + dev = lance.write_dataset( + pa.Table.from_pylist([{"a": 3}]), + dev, + mode="append", + storage_options=storage_options, + ) + ds = lance.write_dataset( + pa.Table.from_pylist([{"a": 100}]), + table_dir, + mode="append", + storage_options=storage_options, + ) + + assert sorted(dev.to_table()["a"].to_pylist()) == [1, 2, 3] + assert sorted(ds.to_table()["a"].to_pylist()) == [1, 2, 100] + + # Cross-branch checkout at the overlapping version number resolves each + # chain's own data. + on_dev = ds.checkout_version(("dev", 3)) + assert sorted(on_dev.to_table()["a"].to_pylist()) == [1, 2, 3] + back_on_main = dev.checkout_version(("main", None)) + assert sorted(back_on_main.to_table()["a"].to_pylist()) == [1, 2, 100] + + @pytest.mark.integration def test_s3_unsafe(s3_bucket: str): storage_options = copy.deepcopy(CONFIG) @@ -263,6 +315,29 @@ def test_file_writer_reader(s3_bucket: str): == global_buffer_text ) + # The writer reports the size of the object it just wrote, so callers do not + # need a second client to stat it. + s3 = get_boto3_client("s3", endpoint_url=CONFIG["aws_endpoint"]) + head = s3.head_object(Bucket=s3_bucket, Key="foo.lance") + assert writer.size_bytes == head["ContentLength"] + + +@pytest.mark.integration +def test_file_writer_size_bytes_multipart(s3_bucket: str): + # Large enough to exceed the single-PUT threshold, so the write goes through + # the multipart path where the reported size is filled in on completion. + storage_options = copy.deepcopy(CONFIG) + del storage_options["dynamodb_endpoint"] + table = pa.table({"a": pa.array(range(4 * 1024 * 1024), type=pa.int64())}) + file_path = f"s3://{s3_bucket}/multipart.lance" + with LanceFileWriter(str(file_path), storage_options=storage_options) as writer: + writer.write_batch(table) + + s3 = get_boto3_client("s3", endpoint_url=CONFIG["aws_endpoint"]) + head = s3.head_object(Bucket=s3_bucket, Key="multipart.lance") + assert head["ContentLength"] > 5 * 1024 * 1024 + assert writer.size_bytes == head["ContentLength"] + @pytest.mark.integration def test_file_session_upload_download(s3_bucket: str, tmp_path): diff --git a/python/python/tests/test_scalar_index.py b/python/python/tests/test_scalar_index.py index 75ec01d9a82..ef01a775809 100644 --- a/python/python/tests/test_scalar_index.py +++ b/python/python/tests/test_scalar_index.py @@ -1,25 +1,36 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import collections.abc import json import os import random import re import shutil import string +import subprocess +import sys +import uuid import zipfile from datetime import date, datetime, timedelta +from decimal import Decimal from pathlib import Path import lance import numpy as np import pyarrow as pa import pytest +from conftest import ProgressRecorder, progress_event_tags, stage_progress_values +from lance.dataset import ScannerBuilder from lance.indices import IndexConfig from lance.query import ( BooleanQuery, BoostQuery, + CombinedFieldsQuery, + DocumentGranularity, + FullTextOperator, MatchQuery, + MinHashQuery, MultiMatchQuery, Occur, PhraseQuery, @@ -93,87 +104,72 @@ def data_table(indexed_dataset: lance.LanceDataset): return indexed_dataset.scanner().to_table() -@pytest.fixture -def btree_comparison_datasets(tmp_path): - """Setup datasets for B-tree comparison tests""" - # Test configuration - num_fragments = 3 - rows_per_fragment = 10000 - total_rows = num_fragments * rows_per_fragment - - # Create dataset for fragment-level indexing - fragment_ds = generate_multi_fragment_dataset( - tmp_path / "fragment", - num_fragments=num_fragments, - rows_per_fragment=rows_per_fragment, - ) - - # Create dataset for complete indexing (same data structure) - complete_ds = generate_multi_fragment_dataset( - tmp_path / "complete", - num_fragments=num_fragments, - rows_per_fragment=rows_per_fragment, - ) - - import uuid - - # Build fragment-level B-tree index - fragment_index_id = str(uuid.uuid4()) - fragment_index_name = "fragment_btree_precise_test" +def _commit_segmented_btree_index(dataset, column, index_name): + segments = [ + dataset.create_index_uncommitted( + column=column, + index_type="BTREE", + name=index_name, + fragment_ids=[fragment.fragment_id], + ) + for fragment in dataset.get_fragments() + ] + return dataset.commit_existing_index_segments(index_name, column, segments) - fragments = fragment_ds.get_fragments() - fragment_ids = [fragment.fragment_id for fragment in fragments] - # Create fragment-level indices - for fragment in fragments: - fragment_id = fragment.fragment_id +def test_create_scalar_index_rejects_invalid_uuid(tmp_path): + """Invalid UUID strings passed to create_scalar_index and merge_index_metadata + must surface as a Python ValueError at the FFI boundary.""" + data = pa.table({"id": pa.array(range(100), type=pa.int64())}) + dataset = lance.write_dataset(data, tmp_path / "ds") - fragment_ds.create_scalar_index( + with pytest.raises(ValueError, match="Invalid UUID"): + dataset.create_scalar_index( column="id", index_type="BTREE", - name=fragment_index_name, - replace=False, - index_uuid=fragment_index_id, - fragment_ids=[fragment_id], + index_uuid="not-a-uuid", ) - # Merge fragment indices - fragment_ds.merge_index_metadata(fragment_index_id, index_type="BTREE") + with pytest.raises(ValueError, match="Invalid UUID"): + dataset.merge_index_metadata("also-not-a-uuid", index_type="BTREE") - # Create Index object for fragment-based index - from lance.dataset import Index - field_id = fragment_ds.schema.get_field_index("id") +@pytest.fixture +def btree_comparison_datasets(tmp_path): + """Setup datasets for B-tree comparison tests""" + num_fragments = 3 + rows_per_fragment = 100 + total_rows = num_fragments * rows_per_fragment - fragment_index = Index( - uuid=fragment_index_id, - name=fragment_index_name, - fields=[field_id], - dataset_version=fragment_ds.version, - fragment_ids=set(fragment_ids), - index_version=0, + fragment_path = tmp_path / "fragment" + fragment_ds = generate_multi_fragment_dataset( + fragment_path, + num_fragments=num_fragments, + rows_per_fragment=rows_per_fragment, ) - # Commit fragment-based index - create_fragment_index_op = lance.LanceOperation.CreateIndex( - new_indices=[fragment_index], - removed_indices=[], + complete_path = tmp_path / "complete" + shutil.copytree(fragment_path, complete_path) + complete_ds = lance.dataset(complete_path) + fragment_count = len(fragment_ds.get_fragments()) + complete_count = len(complete_ds.get_fragments()) + assert fragment_count == num_fragments, ( + f"Expected {num_fragments} segmented fragments, got {fragment_count}" + ) + assert complete_count == num_fragments, ( + f"Expected {num_fragments} complete-index fragments, got {complete_count}" ) - fragment_ds_committed = lance.LanceDataset.commit( - fragment_ds.uri, - create_fragment_index_op, - read_version=fragment_ds.version, + fragment_ds_committed = _commit_segmented_btree_index( + fragment_ds, "id", "fragment_btree_precise_test" ) - # Build complete B-tree index complete_index_name = f"complete_btree_{uuid.uuid4().hex[:8]}" complete_ds.create_scalar_index( column="id", index_type="BTREE", name=complete_index_name, ) - # Reload the dataset to get the indexed version complete_ds = lance.dataset(complete_ds.uri) return { @@ -184,14 +180,170 @@ def btree_comparison_datasets(tmp_path): } -def test_load_indices(indexed_dataset: lance.LanceDataset): - indices = indexed_dataset.list_indices() - vec_idx = next(idx for idx in indices if idx["type"] == "IVF_PQ") - scalar_idx = next(idx for idx in indices if idx["type"] == "BTree") +def test_describe_indices_vector_and_scalar(indexed_dataset: lance.LanceDataset): + indices = indexed_dataset.describe_indices() + vec_idx = next(idx for idx in indices if "VectorIndex" in idx.type_url) + scalar_idx = next(idx for idx in indices if idx.index_type == "BTree") assert vec_idx is not None assert scalar_idx is not None +def test_list_indices_characterization(indexed_dataset: lance.LanceDataset): + """Lock down the backwards-compatible shape of the deprecated list_indices(). + + list_indices() returns a list of plain dicts (one per index segment), not + Index dataclasses. This characterization test guards the dict keys and + values so the deprecated method stays backwards compatible. + """ + from lance.bitmap import Bitmap + + with pytest.warns(DeprecationWarning): + indices = indexed_dataset.list_indices() + + assert len(indices) == 2 + by_name = {idx["name"]: idx for idx in indices} + assert set(by_name) == {"vector_idx", "meta_idx"} + + expected_keys = { + "name", + "type", + "uuid", + "fields", + "version", + "fragment_ids", + "base_id", + } + for idx in indices: + assert set(idx) == expected_keys + assert isinstance(idx["uuid"], str) and len(idx["uuid"]) > 0 + assert isinstance(idx["fields"], list) + # `fragment_ids` is a Bitmap rather than a builtin `set`, so the + # compatibility that matters is that it still answers the abstract + # check and still supports set algebra. + assert isinstance(idx["fragment_ids"], Bitmap) + assert isinstance(idx["fragment_ids"], collections.abc.Set) + assert idx["fragment_ids"] & {0} == {0} + assert idx["fragment_ids"] - {0} == set() + assert isinstance(idx["version"], int) + assert idx["type"] != "Unknown" + assert idx["base_id"] is None + + vector_idx = by_name["vector_idx"] + assert vector_idx["type"] == "IVF_PQ" + assert vector_idx["fields"] == ["vector"] + assert vector_idx["fragment_ids"] == {0} + + meta_idx = by_name["meta_idx"] + assert meta_idx["type"] == "BTree" + assert meta_idx["fields"] == ["meta"] + assert meta_idx["fragment_ids"] == {0} + + +def test_list_indices_nested_field_path(tmp_path): + """list_indices() reports nested fields as full dotted paths.""" + schema = pa.schema( + [ + pa.field("id", pa.int64()), + pa.field("meta", pa.struct([pa.field("lang", pa.string())])), + ] + ) + data = pa.table( + { + "id": [1, 2, 3], + "meta": [{"lang": "en"}, {"lang": "fr"}, {"lang": "en"}], + }, + schema=schema, + ) + ds = lance.write_dataset(data, tmp_path) + ds.create_scalar_index(column="meta.lang", index_type="BTREE") + + with pytest.warns(DeprecationWarning): + indices = ds.list_indices() + + assert len(indices) == 1 + assert indices[0]["fields"] == ["meta.lang"] + + +def _commit_index(ds, index): + """Commit a single raw Index entry via the CreateIndex operation.""" + return lance.LanceDataset.commit( + ds.uri, + lance.LanceOperation.CreateIndex(new_indices=[index], removed_indices=[]), + read_version=ds.version, + ) + + +def test_list_indices_index_without_details(tmp_path): + """An index whose manifest entry has no index details (e.g. committed by an + older writer) is still reported on a best-effort basis: describe_indices() + does not error, and the type is reported as "Unknown".""" + from lance.dataset import Index + + data = pa.table({"id": range(100), "val": range(100)}) + ds = lance.write_dataset(data, tmp_path) + + field_id = ds.schema.get_field_index("id") + fragment_ids = {f.fragment_id for f in ds.get_fragments()} + ds = _commit_index( + ds, + Index( + uuid=str(uuid.uuid4()), + name="legacy_idx", + fields=[field_id], + dataset_version=ds.version, + fragment_ids=fragment_ids, + index_version=0, + ), + ) + + described = ds.describe_indices() + assert len(described) == 1 + assert described[0].name == "legacy_idx" + assert described[0].index_type == "Unknown" + assert described[0].type_url == "" + + with pytest.warns(DeprecationWarning): + listed = ds.list_indices() + assert len(listed) == 1 + assert listed[0]["name"] == "legacy_idx" + assert listed[0]["type"] == "Unknown" + + +def test_list_indices_legacy_vector_index_without_details(tmp_path): + """A legacy vector index predates VectorIndexDetails: it has no index + details but stores a monolithic index file. Its type is recognized as + "Vector" from the index file rather than reported as "Unknown".""" + from lance.dataset import Index, IndexFile + + data = pa.table({"id": range(100), "val": range(100)}) + ds = lance.write_dataset(data, tmp_path) + + field_id = ds.schema.get_field_index("id") + fragment_ids = {f.fragment_id for f in ds.get_fragments()} + ds = _commit_index( + ds, + Index( + uuid=str(uuid.uuid4()), + name="legacy_vector_idx", + fields=[field_id], + dataset_version=ds.version, + fragment_ids=fragment_ids, + index_version=0, + # "index.idx" is the legacy monolithic index file name; its presence + # is how a pre-details vector index is recognized. + files=[IndexFile(path="index.idx", size_bytes=0)], + ), + ) + + described = ds.describe_indices() + assert len(described) == 1 + assert described[0].index_type == "Vector" + + with pytest.warns(DeprecationWarning): + listed = ds.list_indices() + assert listed[0]["type"] == "Vector" + + def test_indexed_scalar_scan(indexed_dataset: lance.LanceDataset, data_table: pa.Table): sample_meta = data_table["meta"][50] expected_price = data_table["price"][50] @@ -262,6 +414,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 max(tokenize_progress) == 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. @@ -395,13 +648,13 @@ def make_fts_search(ds): plan = make_vec_search(ds).explain_plan() assert "ScalarIndexQuery" in plan assert "KNNVectorDistance" not in plan - assert "LanceRead" not in plan + assert "num_fragments" not in plan # no scan; the take prints as LanceRead assert make_vec_search(ds).to_table().num_rows == 6 plan = make_fts_search(ds).explain_plan() assert "ScalarIndexQuery" in plan assert "KNNVectorDistance" not in plan - assert "LanceRead" not in plan + assert "num_fragments" not in plan # no scan; the take prints as LanceRead assert make_fts_search(ds).to_table().num_rows == 6 # Add new data (including 6 more results) @@ -419,7 +672,10 @@ def make_fts_search(ds): assert "ScalarIndexQuery" in plan assert "MaterializeIndex" not in plan assert "FlatMatchQuery" in plan - assert "LanceScan" in plan + # Flat FTS now reads via FilteredReadExec (prints as `LanceRead`) so the + # BTree on `id` pushes into the unindexed-fragment scan too. + assert "LanceRead" in plan + assert "LanceScan" not in plan assert make_fts_search(ds).to_table().num_rows == 12 # Update vector index but NOT scalar index @@ -460,24 +716,87 @@ def test_indexed_vector_scan_postfilter( assert scanner.to_table().num_rows == 0 -def test_fixed_size_binary(tmp_path): - arr = pa.array([b"0123012301230123", b"2345234523452345"], pa.uuid()) +@pytest.mark.parametrize( + "index_type, data_type, values, filter_expr", + [ + pytest.param( + "BTREE", + pa.uuid(), + [b"0123012301230123", b"2345234523452345"], + ( + "value = arrow_cast(0x32333435323334353233343532333435, " + "'FixedSizeBinary(16)')" + ), + id="btree-fixed-size-binary", + ), + *[ + pytest.param( + index_type, + data_type, + values, + filter_expr, + id=f"{index_type.lower()}-{type_name}", + ) + for type_name, data_type, values, filter_expr in [ + ( + "large-string", + pa.large_string(), + ["alpha", "beta", "gamma"], + "value = 'beta'", + ), + ( + "binary", + pa.binary(), + [b"alpha", b"beta", b"gamma"], + "value = arrow_cast(0x62657461, 'Binary')", + ), + ( + "large-binary", + pa.large_binary(), + [b"alpha", b"beta", b"gamma"], + "value = arrow_cast(0x62657461, 'LargeBinary')", + ), + ( + "decimal128", + pa.decimal128(10, 2), + [Decimal("1.00"), Decimal("2.00"), Decimal("3.00")], + "value = arrow_cast(2.00, 'Decimal128(10, 2)')", + ), + ( + "decimal256", + pa.decimal256(76, 2), + [Decimal("1.00"), Decimal("2.00"), Decimal("3.00")], + "value = arrow_cast(2.00, 'Decimal256(76, 2)')", + ), + ( + "duration", + pa.duration("ms"), + [1, 2, 3], + "value = arrow_cast(2, 'Duration(Millisecond)')", + ), + ] + for index_type in ["BTREE", "BITMAP", "ZONEMAP"] + ], + ], +) +def test_scalar_index_types(tmp_path, index_type, data_type, values, filter_expr): + values = pa.array(values, type=data_type) + ds = lance.write_dataset(pa.table({"value": values}), tmp_path) - ds = lance.write_dataset(pa.table({"uuid": arr}), tmp_path) + ds.create_scalar_index("value", index_type) - ds.create_scalar_index("uuid", "BTREE") + scanner = ds.scanner(filter=filter_expr) + assert "ScalarIndexQuery" in scanner.explain_plan() + assert scanner.to_table()["value"].to_pylist() == values.slice(1, 1).to_pylist() - query = ( - "uuid = arrow_cast(0x32333435323334353233343532333435, 'FixedSizeBinary(16)')" - ) - assert ( - "ScalarIndexQuery: query=[uuid = 32333435323334353233...]@uuid_idx" - in ds.scanner(filter=query).explain_plan() + fragment_id = ds.get_fragments()[0].fragment_id + segment = ds.create_index_uncommitted( + column="value", + index_type=index_type, + name=f"{index_type.lower()}_segment_idx", + fragment_ids=[fragment_id], ) - - table = ds.scanner(filter=query).to_table() - assert table.num_rows == 1 - assert table.column("uuid").to_pylist() == arr.slice(1, 1).to_pylist() + assert segment.fragment_ids == {fragment_id} def test_index_take_batch_size(tmp_path): @@ -548,9 +867,13 @@ def test_lance_mem_pool_env_var(tmp_path): @pytest.mark.parametrize("with_position", [True, False]) -def test_full_text_search(dataset, with_position): +@pytest.mark.parametrize("base_tokenizer", ["simple", "icu"]) +def test_full_text_search(dataset, with_position, base_tokenizer): dataset.create_scalar_index( - "doc", index_type="INVERTED", with_position=with_position + "doc", + index_type="INVERTED", + with_position=with_position, + base_tokenizer=base_tokenizer, ) row = dataset.take(indices=[0], columns=["doc"]) query = row.column(0)[0].as_py() @@ -570,6 +893,341 @@ def test_full_text_search(dataset, with_position): ) +@pytest.mark.parametrize("block_size", [128, 256]) +def test_code_analyzer_does_not_split_identifiers_by_default(tmp_path, block_size): + table = pa.table({"code": ["GetUserName", "GetUserEmail", "user"]}) + ds = lance.write_dataset(table, tmp_path) + ds.create_scalar_index( + "code", + index_type="INVERTED", + analyzer="code", + block_size=block_size, + ) + assert ds.describe_indices()[0].segments[0].index_version == 3 + + results = ds.to_table( + columns=["code"], + full_text_query=MatchQuery("user", "code"), + ) + assert results["code"].to_pylist() == ["user"] + + stats = ds.stats.index_stats("code_idx")["indices"][0] + params = stats["params"] + assert "analyzer" not in params + assert params["base_tokenizer"] == "code" + assert params["split_identifiers"] is False + + +def test_code_analyzer_full_text_search_with_identifier_splitting(tmp_path): + table = pa.table( + { + "code": [ + "getUserName", + "set_user_name", + "user-name", + "username", + "other", + ] + } + ) + ds = lance.write_dataset(table, tmp_path) + ds.create_scalar_index( + "code", + index_type="INVERTED", + analyzer="code", + split_identifiers=True, + ) + + results = ds.to_table( + columns=["code"], + full_text_query=MatchQuery("user", "code"), + ) + assert set(results["code"].to_pylist()) == { + "getUserName", + "set_user_name", + "user-name", + } + + stats = ds.stats.index_stats("code_idx")["indices"][0] + params = stats["params"] + assert "analyzer" not in params + assert params["base_tokenizer"] == "code" + assert params["split_identifiers"] is True + assert params["split_on_numerics"] is True + assert params["preserve_original"] is True + assert params["stem"] is False + assert params["remove_stop_words"] is False + + +def test_code_analyzer_operator_search_matches_rust_turbofish(tmp_path): + table = pa.table( + { + "path": ["turbofish.rs", "comparison.rs"], + "code": ["value.parse::()", "value.parse()"], + } + ) + ds = lance.write_dataset(table, tmp_path) + ds.create_scalar_index( + "code", + index_type="INVERTED", + analyzer="code", + index_operators=True, + ) + + results = ds.to_table( + columns=["path"], + full_text_query=MatchQuery("::", "code", operator=FullTextOperator.OR), + ) + assert results["path"].to_pylist() == ["turbofish.rs"] + + +def test_code_analyzer_exact_identifier_survives_grouped_top_k(tmp_path): + table = pa.table( + { + "path": ["split_0.rs", "split_1.rs", "split_2.rs", "exact.rs"], + "code": ["get user name", "get user name", "get user name", "getUserName"], + } + ) + ds = lance.write_dataset(table, tmp_path) + ds.create_scalar_index( + "code", + index_type="INVERTED", + analyzer="code", + split_identifiers=True, + ) + + results = ds.scanner( + columns=["path", "_score"], + full_text_query=MatchQuery( + "getUserName", "code", operator=FullTextOperator.AND + ), + limit=1, + ).to_table() + assert results["path"].to_pylist() == ["exact.rs"] + + +def test_code_analyzer_flags_require_code_analyzer(tmp_path): + table = pa.table({"text": ["getUserName"]}) + ds = lance.write_dataset(table, tmp_path) + + with pytest.raises(ValueError, match="code analyzer flags require analyzer='code'"): + ds.create_scalar_index( + "text", + index_type="INVERTED", + split_identifiers=True, + ) + + +def test_code_analyzer_requires_fts_v3(tmp_path): + table = pa.table({"code": ["getUserName"]}) + ds = lance.write_dataset(table, tmp_path) + + with pytest.raises(ValueError, match="requires FTS format_version=3"): + ds.create_scalar_index( + "code", + index_type="INVERTED", + analyzer="code", + format_version=2, + ) + + +def test_code_analyzer_complex_code_constructs(tmp_path): + table = pa.table( + { + "path": [ + "edge/trait.rs", + "edge/impl.rs", + "edge/fn_pointer.rs", + "edge/unit_result.rs", + "edge/hrtb.rs", + "edge/associated.rs", + "edge/operators.rs", + ], + "code": [ + """ +pub trait EdgeAsyncRepository<'a, T: Send + Sync> +where + T: TryFrom<&'a str, Error = EdgeParseError>, +{ + type Output<'b>: Iterator> + where + Self: 'b; + + async fn fetch_by_key( + &'a self, + key: [u8; N], + ) -> Result, EdgeRepoError>; +} +""", + """ +impl<'a, T, S> EdgeAsyncRepository<'a, T> for EdgeStore +where + T: TryFrom<&'a str, Error = EdgeParseError> + Clone + Send + Sync, + S: EdgeBackend + ?Sized, +{ + type Output<'b> = std::vec::IntoIter> where Self: 'b; + + async fn fetch_by_key( + &'a self, + key: [u8; N], + ) -> Result, EdgeRepoError> { + self.backend.fetch::(key).await + } +} +""", + """ +pub fn build_edge_handler( + factory: F, +) -> impl Fn() -> Result, EdgeError> +where + F: FnOnce() -> Result + Send + 'static, + T: Default + Send + Sync + 'static, +{ + move || factory().map(EdgeHandler::new) +} +""", + """ +pub fn edge_unit_result_callback() -> Result<()> { + Ok(()) +} +""", + """ +pub fn edge_higher_ranked<'a, T>( + visitor: impl for<'b> Fn(&'b T) -> Result<&'b str, EdgeVisitError>, + value: &'a T, +) -> Result<&'a str, EdgeVisitError> { + visitor(value) +} +""", + """ +pub fn edge_collect_stream(items: I) -> Result, E::Error> +where + I: IntoIterator, + E: EdgeExtract, +{ + items.into_iter().map(E::extract).collect() +} +""", + """ +pub fn edge_operator_arrow() -> Result { + let variant = EdgeModule::EdgeVariant; + if variant != EdgeModule::Default && EdgeMask::enabled() { + return Ok(EdgeArrow::new(variant)); + } + Err(EdgeError::empty()) +} +""", + ], + } + ) + table = table.append_column("code_ops", table["code"]) + ds = lance.write_dataset(table, tmp_path) + ds.create_scalar_index("code", index_type="INVERTED", analyzer="code") + ds.create_scalar_index( + "code_ops", + index_type="INVERTED", + analyzer="code", + index_operators=True, + ) + + ds.insert( + pa.table( + { + "path": ["edge/flat_unindexed.rs"], + "code": [ + """ +pub async fn edge_flat_generic_return() -> Result +where + T: TryFrom + Send, + E: Into, +{ + T::try_from(String::new()).map_err(Into::into) +} +""" + ], + "code_ops": [ + """ +pub async fn edge_flat_operator() -> Result { + EdgeFlat::try_new() -> Result +} +""" + ], + } + ) + ) + ds = lance.dataset(tmp_path) + + def assert_search(column, query, expected_path, operator=FullTextOperator.AND): + result = ds.scanner( + columns=["path", "_score"], + full_text_query=MatchQuery(query, column, operator=operator), + limit=50, + ).to_table() + assert expected_path in result["path"].to_pylist() + + assert_search( + "code", + "EdgeAsyncRepository fetch_by_key TryFrom EdgeRepoError", + "edge/trait.rs", + ) + assert_search( + "code", + "EdgeStore fetch_by_key const usize where Result", + "edge/impl.rs", + ) + assert_search( + "code", + "build_edge_handler FnOnce Result EdgeHandler", + "edge/fn_pointer.rs", + ) + assert_search( + "code", + "edge_unit_result_callback fn () -> Result", + "edge/unit_result.rs", + ) + assert_search( + "code", + "edge_higher_ranked for Fn EdgeVisitError Result", + "edge/hrtb.rs", + ) + assert_search( + "code", + "edge_collect_stream IntoIterator Item Error Result", + "edge/associated.rs", + ) + assert_search( + "code", + "edge_flat_generic_return TryFrom EdgeFlatError Result", + "edge/flat_unindexed.rs", + ) + assert_search( + "code_ops", + "edge_operator_arrow -> Result", + "edge/operators.rs", + ) + assert_search( + "code_ops", + "EdgeModule :: EdgeVariant !=", + "edge/operators.rs", + ) + assert_search( + "code_ops", + "edge_flat_operator -> Result EdgeFlatError", + "edge/flat_unindexed.rs", + ) + + default_operator_results = ds.scanner( + columns=["path", "_score"], + full_text_query=MatchQuery("->", "code", operator=FullTextOperator.OR), + ).to_table() + operator_results = ds.scanner( + columns=["path", "_score"], + full_text_query=MatchQuery("->", "code_ops", operator=FullTextOperator.OR), + ).to_table() + assert default_operator_results.num_rows == 0 + assert operator_results.num_rows > 0 + + def test_unindexed_full_text_search_on_empty_index(tmp_path): # Create fts index on empty table. schema = pa.schema({"text": pa.string()}) @@ -635,6 +1293,51 @@ def test_fts_custom_stop_words(tmp_path): assert len(results["_rowid"].to_pylist()) == 1 +def test_fts_stop_words_respect_language_for_simple_tokenizer(tmp_path): + data = pa.table({"text": ["the lance data", "çš„ lance data"]}) + ds = lance.write_dataset(data, tmp_path, mode="overwrite") + ds.create_scalar_index( + "text", + "INVERTED", + base_tokenizer="simple", + stem=False, + ) + + results = ds.to_table(full_text_query="the", with_row_id=True) + assert results.num_rows == 0 + + results = ds.to_table(full_text_query="çš„", with_row_id=True) + assert results["text"].to_pylist() == ["çš„ lance data"] + + +def test_fts_icu_stop_words_are_all_or_none(tmp_path): + data = pa.table({"text": ["the çš„ lance data", "useful data"]}) + ds = lance.write_dataset(data, tmp_path / "enabled", mode="overwrite") + ds.create_scalar_index( + "text", + "INVERTED", + base_tokenizer="icu", + stem=False, + remove_stop_words=True, + ) + + assert ds.to_table(full_text_query="the", with_row_id=True).num_rows == 0 + assert ds.to_table(full_text_query="çš„", with_row_id=True).num_rows == 0 + assert ds.to_table(full_text_query="lance", with_row_id=True).num_rows == 1 + + ds = lance.write_dataset(data, tmp_path / "disabled", mode="overwrite") + ds.create_scalar_index( + "text", + "INVERTED", + base_tokenizer="icu", + stem=False, + remove_stop_words=False, + ) + + assert ds.to_table(full_text_query="the", with_row_id=True).num_rows == 1 + assert ds.to_table(full_text_query="çš„", with_row_id=True).num_rows == 1 + + def test_rowid_order(dataset): dataset.create_scalar_index("doc", index_type="INVERTED", with_position=False) results = dataset.scanner( @@ -665,7 +1368,40 @@ def test_filter_with_fts_index(dataset): def test_create_scalar_index_fts_alias(dataset): dataset.create_scalar_index("doc", index_type="FTS", with_position=False) - assert any(idx["type"] == "Inverted" for idx in dataset.list_indices()) + assert any(idx.index_type == "Inverted" for idx in dataset.describe_indices()) + + +def test_create_scalar_index_fts_block_size(dataset): + dataset.create_scalar_index( + "doc", index_type="INVERTED", with_position=False, block_size=256 + ) + indices = dataset.describe_indices() + doc_index = next(index for index in indices if index.name == "doc_idx") + assert doc_index.segments[0].index_version == 3 + + row = dataset.take(indices=[0], columns=["doc"]) + query = row.column(0)[0].as_py().split(" ")[0] + results = dataset.scanner(columns=["doc"], full_text_query=query).to_table() + assert results.num_rows > 0 + + with pytest.raises(ValueError, match="block_size"): + dataset.create_scalar_index( + "doc", index_type="INVERTED", name="doc_invalid_129", block_size=129 + ) + + with pytest.raises(ValueError, match="block_size"): + dataset.create_scalar_index( + "doc", index_type="INVERTED", name="doc_invalid_512", block_size=512 + ) + + with pytest.raises(ValueError, match="block_size=256"): + dataset.create_scalar_index( + "doc", + index_type="INVERTED", + name="doc_invalid_v2_256", + block_size=256, + format_version=2, + ) def test_multi_index_create(tmp_path): @@ -677,24 +1413,23 @@ def test_multi_index_create(tmp_path): "ints", index_type="BITMAP", name="ints_bitmap_idx", replace=True ) - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 2 - assert indices[0]["name"] == "ints_idx" - assert indices[0]["type"] == "BTree" - assert indices[1]["name"] == "ints_bitmap_idx" - assert indices[1]["type"] == "Bitmap" + idx_by_name = {idx.name: idx for idx in indices} + assert idx_by_name["ints_idx"].index_type == "BTree" + assert idx_by_name["ints_bitmap_idx"].index_type == "Bitmap" # Test that we can drop one of the indices dataset.drop_index("ints_idx") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["name"] == "ints_bitmap_idx" - assert indices[0]["type"] == "Bitmap" + assert indices[0].name == "ints_bitmap_idx" + assert indices[0].index_type == "Bitmap" # Test that we can drop the last index dataset.drop_index("ints_bitmap_idx") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 0 @@ -738,7 +1473,8 @@ def test_ngram_fts(tmp_path): ) -def test_fts_fts(tmp_path): +@pytest.mark.parametrize("base_tokenizer", ["simple", "icu"]) +def test_fts_fts(tmp_path, base_tokenizer): # Tests creating two FTS indices with the same name but different parameters dataset = lance.write_dataset( pa.table( @@ -753,7 +1489,11 @@ def test_fts_fts(tmp_path): tmp_path, ) dataset.create_scalar_index( - "text", "INVERTED", with_position=True, remove_stop_words=False + "text", + "INVERTED", + with_position=True, + remove_stop_words=False, + base_tokenizer=base_tokenizer, ) results = dataset.to_table(full_text_query='"was a puppy"', prefilter=True) @@ -763,7 +1503,11 @@ def test_fts_fts(tmp_path): assert results.num_rows == 3 dataset.create_scalar_index( - "text", "INVERTED", name="no_pos_idx", with_position=False + "text", + "INVERTED", + name="no_pos_idx", + with_position=False, + base_tokenizer=base_tokenizer, ) # There is no way to currently specify which index to use. Instead @@ -811,14 +1555,23 @@ def test_indexed_filter_with_fts_index(tmp_path): def test_fts_ngram_tokenizer(tmp_path): - data = pa.table({"text": ["hello world", "lance database", "lance is cool"]}) - ds = lance.write_dataset(data, tmp_path) + data = pa.table( + {"text": ["hello world", "lance database", "lance is cool", "theatre", "other"]} + ) + ds = lance.write_dataset(data, tmp_path, max_rows_per_file=2) ds.create_scalar_index("text", index_type="INVERTED", base_tokenizer="ngram") results = ds.to_table(full_text_query="lan") assert results.num_rows == 2 assert set(results["text"].to_pylist()) == {"lance database", "lance is cool"} + results = ds.to_table(full_text_query="the") + assert set(results["text"].to_pylist()) == {"theatre", "other"} + + params = ds.stats.index_stats("text_idx")["indices"][0]["params"] + assert params["stem"] is False + assert params["remove_stop_words"] is False + results = ds.to_table(full_text_query="nce") # spellchecker:disable-line assert results.num_rows == 2 assert set(results["text"].to_pylist()) == {"lance database", "lance is cool"} @@ -850,7 +1603,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" @@ -865,9 +1623,60 @@ 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_optimize_num_indices_to_merge(tmp_path): + def append_rows(rows): + return lance.write_dataset(pa.table(rows), tmp_path, mode="append") -def test_fts_score(tmp_path): + def num_indices(ds): + return ds.stats.index_stats("text_idx")["num_indices"] + + ds = lance.write_dataset( + pa.table( + { + "id": [0, 1], + "text": ["alpha base phrase", "beta base phrase"], + } + ), + tmp_path, + ) + ds.create_scalar_index("text", index_type="INVERTED", with_position=True) + assert num_indices(ds) == 1 + + ds = append_rows({"id": [2], "text": ["gamma delta phrase"]}) + ds.optimize.optimize_indices(num_indices_to_merge=0) + assert num_indices(ds) == 2 + + ds = append_rows({"id": [3], "text": ["epsilon zeta phrase"]}) + ds.optimize.optimize_indices(num_indices_to_merge=1) + assert num_indices(ds) == 2 + assert ds.to_table(full_text_query="epsilon")["id"].to_pylist() == [3] + + ds = append_rows({"id": [4], "text": ["eta theta phrase"]}) + ds.optimize.optimize_indices(num_indices_to_merge=0) + assert num_indices(ds) == 3 + + ds.optimize.optimize_indices(num_indices_to_merge=2) + assert num_indices(ds) == 2 + assert ds.to_table(full_text_query=PhraseQuery("eta theta", "text"))[ + "id" + ].to_pylist() == [4] + + ds = append_rows({"id": [5], "text": ["iota kappa phrase"]}) + ds.optimize.optimize_indices(num_indices_to_merge=0) + assert num_indices(ds) == 3 + + ds.optimize.optimize_indices(num_indices_to_merge=10) + assert num_indices(ds) == 1 + assert ds.to_table(full_text_query="alpha")["id"].to_pylist() == [0] + assert ds.to_table(full_text_query="iota")["id"].to_pylist() == [5] + + +@pytest.mark.parametrize("base_tokenizer", ["simple", "icu"]) +def test_fts_score(tmp_path, base_tokenizer): # the number of tokens matters for scoring, # make a table that all docs have the same number of tokens data = pa.table( @@ -877,7 +1686,7 @@ def test_fts_score(tmp_path): } ) ds = lance.write_dataset(data, tmp_path) - ds.create_scalar_index("text", "INVERTED") + ds.create_scalar_index("text", "INVERTED", base_tokenizer=base_tokenizer) results = ds.to_table(full_text_query="lance search text") assert results.num_rows == 3 @@ -889,7 +1698,7 @@ def test_fts_score(tmp_path): "text", "lance search text", tmp_path, - index_params={"with_position": False}, + index_params={"with_position": False, "base_tokenizer": base_tokenizer}, ) @@ -973,6 +1782,136 @@ def test_fts_on_list(tmp_path): assert results.num_rows == 6 +@pytest.mark.parametrize( + "list_type", + [ + pa.list_(pa.string()), + pa.list_(pa.large_string()), + pa.large_list(pa.string()), + pa.large_list(pa.large_string()), + ], +) +def test_fts_on_list_elements(tmp_path, list_type): + data = pa.table( + { + "id": pa.array([0, 1, 2]), + "tags": pa.array( + [ + ["alpha beta", "gamma alpha", None, "", "delta"], + ["beta", "gamma"], + None, + ], + type=list_type, + ), + } + ) + ds = lance.write_dataset(data, tmp_path) + + def hits(table): + return sorted(zip(table["id"].to_pylist(), table["_doc_index"].to_pylist())) + + list_element = DocumentGranularity.LIST_ELEMENT + query = MatchQuery("alpha", "tags", document_granularity=list_element) + flat = ds.to_table(full_text_query=query) + assert hits(flat) == [(0, [0]), (0, [1])] + assert pa.types.is_list(flat.schema.field("_doc_index").type) + assert flat.schema.field("_doc_index").type.value_type == pa.uint32() + assert hits( + ds.to_table( + full_text_query=MatchQuery( + "delta", "tags", document_granularity=list_element + ) + ) + ) == [(0, [4])] + + ds.create_scalar_index( + "tags", + "INVERTED", + with_position=True, + document_granularity=list_element, + ) + ds.create_scalar_index( + "tags", + IndexConfig(index_type="inverted", parameters={"with_position": True}), + document_granularity=list_element, + ) + inferred = ds.to_table(full_text_query=MatchQuery("alpha", "tags")) + assert hits(inferred) == [(0, [0]), (0, [1])] + with pytest.raises(ValueError, match=r"requested Row.*ListElement"): + ds.to_table( + full_text_query=MatchQuery( + "alpha", "tags", document_granularity=DocumentGranularity.ROW + ) + ) + + ds.create_scalar_index("tags", "INVERTED", with_position=True) + index_names = {index.name for index in ds.describe_indices()} + assert {"tags_idx", "tags_list_element_idx"}.issubset(index_names) + row_auto = ds.to_table(full_text_query="alpha") + assert row_auto["id"].to_pylist() == [0] + assert "_doc_index" not in row_auto.column_names + with pytest.raises(ValueError, match=r"ambiguous.*document_granularity"): + ds.to_table(full_text_query=MatchQuery("alpha", "tags")) + indexed = ds.to_table(full_text_query=query) + assert hits(indexed) == [(0, [0]), (0, [1])] + assert pa.types.is_list(indexed.schema.field("_doc_index").type) + assert indexed.schema.field("_doc_index").type.value_type == pa.uint32() + filtered = ds.to_table(full_text_query=query, filter="id = 0", prefilter=True) + assert hits(filtered) == [(0, [0]), (0, [1])] + assert hits( + ds.to_table( + full_text_query=MatchQuery( + "delta", "tags", document_granularity=list_element + ) + ) + ) == [(0, [4])] + + phrase = ds.to_table( + full_text_query=PhraseQuery( + "beta gamma", "tags", document_granularity=list_element + ) + ) + assert phrase.num_rows == 0 + assert hits( + ds.to_table( + full_text_query=PhraseQuery( + "alpha beta", "tags", document_granularity=list_element + ) + ) + ) == [(0, [0])] + row_phrase = ds.to_table( + full_text_query=PhraseQuery( + "beta gamma", + "tags", + document_granularity=DocumentGranularity.ROW, + ) + ) + assert sorted(row_phrase["id"].to_pylist()) == [0, 1] + assert "_doc_index" not in row_phrase.column_names + + ds.insert( + pa.table( + { + "id": pa.array([3]), + "tags": pa.array([["alpha", "alpha again"]], type=list_type), + } + ) + ) + assert hits(ds.to_table(full_text_query=query)) == [ + (0, [0]), + (0, [1]), + (3, [0]), + (3, [1]), + ] + + with pytest.raises(RuntimeError, match=r"tags\[\*\]"): + ds.create_scalar_index( + "tags[*]", + "INVERTED", + document_granularity=list_element, + ) + + def test_fts_fuzzy_query(tmp_path): data = pa.table( { @@ -1157,6 +2096,65 @@ def test_fts_multi_match_query(tmp_path): ) +@pytest.mark.parametrize( + "operator,combined_ids,multi_ids", + [ + # combined_fields treats the columns as one virtual field, so AND matches + # when each term appears in any field (rows 0 and 1); best_fields AND only + # matches row 1, where a single field holds both terms. + (FullTextOperator.AND, {0, 1}, {1}), + (FullTextOperator.OR, {0, 1, 2}, {0, 1, 2}), + ], +) +def test_fts_combined_fields_query(tmp_path, operator, combined_ids, multi_ids): + data = pa.table( + { + "id": [0, 1, 2], + # row 0 splits the terms across fields, row 1 has both in one field, + # row 2 has only "john". + "title": ["john", "john smith", "john"], + "body": ["smith", "foo", "alice"], + } + ) + ds = lance.write_dataset(data, tmp_path) + ds.create_scalar_index("title", "INVERTED") + ds.create_scalar_index("body", "INVERTED") + + def ids(query): + return set(ds.to_table(full_text_query=query, columns=["id"])["id"].to_pylist()) + + assert ( + ids(CombinedFieldsQuery("john smith", ["title", "body"], operator=operator)) + == combined_ids + ) + assert ( + ids(MultiMatchQuery("john smith", ["title", "body"], operator=operator)) + == multi_ids + ) + + +def test_fts_combined_fields_boost_validation(tmp_path): + # Per-column boosts must be >= 1 (Lucene CombinedFieldQuery constraint), and + # the boost count must match the column count. + data = pa.table({"title": ["hello"], "body": ["world"]}) + ds = lance.write_dataset(data, tmp_path) + ds.create_scalar_index("title", "INVERTED") + ds.create_scalar_index("body", "INVERTED") + + with pytest.raises(ValueError, match="boost"): + CombinedFieldsQuery("hello", ["title", "body"], boosts=[0.5, 1.0]) + with pytest.raises(ValueError): + CombinedFieldsQuery("hello", ["title", "body"], boosts=[1.0]) + + # Fractional weights >= 1 are accepted. + result = ds.to_table( + full_text_query=CombinedFieldsQuery( + "hello", ["title", "body"], boosts=[1.5, 1.0] + ) + ) + assert result.num_rows == 1 + + def test_fts_boolean_query(tmp_path): data = pa.table( { @@ -1273,6 +2271,23 @@ def test_fts_deleted_rows(tmp_path): assert results.num_rows == 2 +def test_fts_deleted_rows_with_stable_row_ids(tmp_path): + # Regression test: stable-row-id prefiltering must not leak deleted rows. + data = pa.table( + { + "text": [f"dup_{i}" for i in range(200)], + "category": [["A", "B", "C", "D", "E"][i % 5] for i in range(200)], + } + ) + ds = lance.write_dataset(data, tmp_path, enable_stable_row_ids=True) + ds.create_scalar_index("text", "INVERTED") + + assert ds.to_table(full_text_query="dup", prefilter=True).num_rows == 200 + + ds.delete("category = 'A'") + assert ds.to_table(full_text_query="dup", prefilter=True).num_rows == 160 + + def test_index_after_merge_insert(tmp_path): # This regresses a defect where a horizontal merge insert was not taking modified # fragments out of the index if the column is modified. @@ -1467,28 +2482,93 @@ def test_lindera_ipadic_jp_tokenizer_bin_user_dict(tmp_path, lindera_ipadic): ) assert len(results) == 0 results = ds.to_table( - full_text_query="æˆ�田国際空港", - prefilter=True, - with_row_id=True, + full_text_query="æˆ�田国際空港", + prefilter=True, + with_row_id=True, + ) + assert results["_rowid"].to_pylist() == [0] + + +def test_jieba_tokenizer(tmp_path): + set_language_model_path() + data = pa.table( + { + "text": ["我们都有光明的å‰�途", "光明的å‰�途"], + } + ) + ds = lance.write_dataset(data, tmp_path, mode="overwrite") + ds.create_scalar_index("text", "INVERTED", base_tokenizer="jieba/default") + results = ds.to_table( + full_text_query="我们", + prefilter=True, + with_row_id=True, + ) + assert results["_rowid"].to_pylist() == [0] + + +def test_icu_tokenizer(tmp_path): + data = pa.table( + { + "text": ["Hello, ã�“ã‚“ã�«ã�¡ã�¯ä¸–界!", "Hello, ã�“ã‚“ã�«ã�¡ã�¯!"], + } + ) + ds = lance.write_dataset(data, tmp_path, mode="overwrite") + ds.create_scalar_index("text", "INVERTED", base_tokenizer="icu") + results = ds.to_table( + full_text_query="世界", + prefilter=True, + with_row_id=True, + ) + assert results["_rowid"].to_pylist() == [0] + + +def test_icu_tokenizer_split_on_non_alphanumeric_default(tmp_path): + data = pa.table({"text": ["hello_world"]}) + ds = lance.write_dataset(data, tmp_path, mode="overwrite") + ds.create_scalar_index( + "text", + "INVERTED", + base_tokenizer="icu", + stem=False, + remove_stop_words=False, + ) + + results = ds.to_table(full_text_query="hello", prefilter=True, with_row_id=True) + assert results.num_rows == 0 + + results = ds.to_table( + full_text_query="hello_world", prefilter=True, with_row_id=True ) assert results["_rowid"].to_pylist() == [0] -def test_jieba_tokenizer(tmp_path): - set_language_model_path() +def test_icu_tokenizer_split_on_non_alphanumeric(tmp_path): data = pa.table( { - "text": ["我们都有光明的å‰�途", "光明的å‰�途"], + "text": [ + "hello_world ã�“ã‚“ã�«ã�¡ã�¯ä¸–界", + "alpha.beta", + ], } ) ds = lance.write_dataset(data, tmp_path, mode="overwrite") - ds.create_scalar_index("text", "INVERTED", base_tokenizer="jieba/default") - results = ds.to_table( - full_text_query="我们", - prefilter=True, - with_row_id=True, + ds.create_scalar_index( + "text", + "INVERTED", + base_tokenizer="icu/split", + stem=False, + remove_stop_words=False, ) - assert results["_rowid"].to_pylist() == [0] + + for query, expected_row_ids in [ + ("hello", [0]), + ("world", [0]), + ("世界", [0]), + ("alpha", [1]), + ("beta", [1]), + ]: + results = ds.to_table(full_text_query=query, prefilter=True, with_row_id=True) + assert results["_rowid"].to_pylist() == expected_row_ids def test_jieba_invalid_user_dict_tokenizer(tmp_path): @@ -1549,9 +2629,9 @@ def test_bitmap_index(tmp_path: Path): ) dataset = lance.write_dataset(tbl, tmp_path / "dataset") dataset.create_scalar_index("a", index_type="BITMAP") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["type"] == "Bitmap" + assert indices[0].index_type == "Bitmap" def test_bitmap_empty_range(tmp_path: Path): @@ -1629,9 +2709,9 @@ def test_ngram_index(tmp_path: Path): def test_with(tbl: pa.Table): dataset = lance.write_dataset(tbl, tmp_path / "dataset", mode="overwrite") dataset.create_scalar_index("words", index_type="NGRAM") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["type"] == "NGram" + assert indices[0].index_type == "NGram" scan_plan = dataset.scanner(filter="contains(words, 'apple')").explain_plan( True @@ -1683,7 +2763,7 @@ def test_zonemap_index(tmp_path: Path): tbl = pa.Table.from_arrays([pa.array([i for i in range(8193)])], names=["values"]) dataset = lance.write_dataset(tbl, tmp_path / "dataset") dataset.create_scalar_index("values", index_type="ZONEMAP") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 # Get detailed index statistics @@ -1738,6 +2818,87 @@ def scan_stats_callback(stats: lance.ScanStatistics): assert small_bytes_read < large_bytes_read +@pytest.mark.parametrize("index_type", ["ZONEMAP", "BLOOMFILTER"]) +def test_address_domain_index_with_stable_row_ids(tmp_path: Path, index_type): + """Regression test for issue #7434. + + Address-domain scalar indices (zonemap, bloom filter) report matches as + physical row addresses. On a stable-row-id dataset a row's stable id differs + from its physical address in every fragment except fragment 0, so the index + result must be translated back to the row-id domain before it prefilters the + scan. Without that translation the index silently drops matching rows in + fragments other than fragment 0 (often returning an empty result). + """ + + # A single value "a" is placed in non-adjacent fragments (0, 2, 4) so its + # matching rows span multiple fragments and diverge from fragment 0. + def block(v, n): + return [v] * n + + vals = ( + block("a", 5_000) + + block("b", 5_000) + + block("a", 5_000) + + block("c", 5_000) + + block("a", 5_000) + ) + tbl = pa.table({"category": pa.array(vals, pa.string()), "id": range(len(vals))}) + ds = lance.write_dataset( + tbl, tmp_path, max_rows_per_file=5_000, enable_stable_row_ids=True + ) + assert ds.has_stable_row_ids + assert len(ds.get_fragments()) == 5 + + true_count = ds.scanner( + filter="category = 'a'", use_scalar_index=False + ).count_rows() + assert true_count == 15_000 + + ds.create_scalar_index("category", index_type=index_type, replace=True) + + # The index must be consulted and must return the same rows as a full scan. + assert "ScalarIndexQuery" in ds.scanner(filter="category = 'a'").explain_plan() + indexed = ds.to_table(filter="category = 'a'", columns=["id"]) + assert indexed.num_rows == true_count + assert sorted(indexed["id"].to_pylist()) == sorted( + ds.to_table(filter="category = 'a'", columns=["id"], use_scalar_index=False)[ + "id" + ].to_pylist() + ) + + +def test_zonemap_with_stable_row_ids_after_compaction(tmp_path: Path): + """Zonemap results stay correct after a compaction relocates rows under + stable row ids (physical address != stable id for the surviving rows).""" + ds = lance.write_dataset( + pa.table({"x": range(0, 5_000)}), + tmp_path, + max_rows_per_file=5_000, + enable_stable_row_ids=True, + ) + ds = lance.write_dataset( + pa.table({"x": range(5_000, 10_000)}), + tmp_path, + mode="append", + max_rows_per_file=5_000, + enable_stable_row_ids=True, + ) + # Delete part of the first fragment, then compact so surviving rows keep + # their small stable ids but move to a freshly numbered fragment. + ds.delete("x >= 1000 AND x < 2000") + ds.optimize.compact_files(target_rows_per_fragment=100_000) + ds = lance.dataset(tmp_path) + assert len(ds.get_fragments()) == 1 + + ds.create_scalar_index("x", index_type="ZONEMAP") + + filter_expr = "x >= 6000 AND x <= 6500" + assert "ScalarIndexQuery" in ds.scanner(filter=filter_expr).explain_plan() + expected = ds.to_table(filter=filter_expr, use_scalar_index=False)["x"].to_pylist() + actual = ds.to_table(filter=filter_expr)["x"].to_pylist() + assert sorted(actual) == sorted(expected) == list(range(6000, 6501)) + + def test_zonemap_deletion_handling(tmp_path: Path): """Test zonemap deletion handling""" data = pa.table( @@ -1767,6 +2928,33 @@ def test_zonemap_deletion_handling(tmp_path: Path): assert ids == [0, 2, 4, 6, 8] +@pytest.mark.parametrize("index_type", ["ZONEMAP", "BLOOMFILTER"]) +def test_address_domain_index_not_query_with_stable_row_ids(tmp_path: Path, index_type): + """Regression test: != queries return correct results on stable-row-id datasets. + + Address-domain indices (zonemap, bloom filter) search returns physical row + addresses. Translation to row IDs happens at the Query leaf before the NOT + node is evaluated, so the NOT operates on a correctly translated AllowList. + Without the address-to-row-id translation the AllowList contains wrong IDs, + and the subsequent NOT excludes the wrong rows. + """ + vals = list(range(5_000)) + list(range(5_000, 10_000)) + tbl = pa.table({"x": vals}) + ds = lance.write_dataset( + tbl, tmp_path, max_rows_per_file=5_000, enable_stable_row_ids=True + ) + assert ds.has_stable_row_ids + + ds.create_scalar_index("x", index_type=index_type, replace=True) + + # Without address translation the NOT excludes the wrong rows, producing an + # incorrect result set rather than crashing. + expected = ds.to_table(filter="x != 42", use_scalar_index=False)["x"].to_pylist() + actual = ds.to_table(filter="x != 42")["x"].to_pylist() + assert sorted(actual) == sorted(expected) + assert len(actual) == 9_999 + + def test_zonemap_index_remapping(tmp_path: Path): """Test zonemap index remapping after compaction and optimization""" # Create a dataset with 5 fragments by writing data in chunks @@ -1779,9 +2967,9 @@ def test_zonemap_index_remapping(tmp_path: Path): # Train a zone map index dataset.create_scalar_index("values", index_type="ZONEMAP") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["type"] == "ZoneMap" + assert indices[0].index_type == "ZoneMap" # Confirm the zone map index is used if you search the dataset scanner = dataset.scanner(filter="values > 2500", prefilter=True) @@ -1795,7 +2983,7 @@ def test_zonemap_index_remapping(tmp_path: Path): # Run compaction to merge fragments compaction = dataset.optimize.compact_files(target_rows_per_fragment=2000) assert compaction.fragments_removed == 5 - assert len(dataset.get_fragments()) == 3 + assert len(dataset.get_fragments()) == 2 # Check if the zone map index is no longer being used scanner = dataset.scanner(filter="values > 2500", prefilter=True) @@ -1823,12 +3011,60 @@ def test_zonemap_index_remapping(tmp_path: Path): assert result.num_rows == 501 # 1000..1500 inclusive +def test_zonemap_fsl_column(tmp_path: Path): + """Zone map can be created on a FixedSizeList column and accelerates IS NULL.""" + dim = 8 + n = 1000 + rng = np.random.default_rng(42) + vectors = rng.standard_normal((n, dim)).astype(np.float32) + vec_type = pa.list_(pa.float32(), dim) + # Every 10th row is null + vec_list = [None if i % 10 == 0 else v.tolist() for i, v in enumerate(vectors)] + tbl = pa.table({"vec": pa.array(vec_list, type=vec_type), "id": pa.array(range(n))}) + ds = lance.write_dataset(tbl, tmp_path) + ds.create_scalar_index("vec", index_type="ZONEMAP") + + scanner = ds.scanner(filter="vec IS NULL", prefilter=True) + plan = scanner.explain_plan() + assert "ScalarIndexQuery" in plan + result = scanner.to_table() + assert result.num_rows == 100 # every 10th row is null + + +def test_vector_and_zonemap_on_fsl_column(tmp_path: Path): + """Vector index and zone map can coexist on the same FSL column.""" + dim = 16 + n = 2000 + rng = np.random.default_rng(0) + vectors = rng.standard_normal((n, dim)).astype(np.float32) + vec_type = pa.list_(pa.float32(), dim) + # Every 20th row is null + vec_list = [None if i % 20 == 0 else v.tolist() for i, v in enumerate(vectors)] + tbl = pa.table({"vec": pa.array(vec_list, type=vec_type), "id": pa.array(range(n))}) + ds = lance.write_dataset(tbl, tmp_path) + + ds.create_index("vec", index_type="IVF_PQ", num_partitions=4, num_sub_vectors=2) + ds.create_scalar_index("vec", index_type="ZONEMAP") + + # Vector search still works + query = vectors[5] + result = ds.scanner(nearest={"column": "vec", "q": query, "k": 10}).to_table() + assert result.num_rows == 10 + + # IS NULL is zone-map-accelerated + scanner = ds.scanner(filter="vec IS NULL", prefilter=True) + plan = scanner.explain_plan() + assert "ScalarIndexQuery" in plan + null_result = scanner.to_table() + assert null_result.num_rows == 100 # every 20th row is null + + def test_bloomfilter_index(tmp_path: Path): """Test create bloomfilter index""" tbl = pa.Table.from_arrays([pa.array([i for i in range(10000)])], names=["values"]) dataset = lance.write_dataset(tbl, tmp_path / "dataset") dataset.create_scalar_index("values", index_type="BLOOMFILTER") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 # Get detailed index statistics @@ -1885,6 +3121,71 @@ def test_bloomfilter_deletion_handling(tmp_path: Path): assert ids == [0, 2, 4, 6, 8] +def test_minhash_lsh_index(): + base = "the quick brown fox jumps over the lazy dog and runs away very fast" + near = "the quick brown fox jumps over the lazy dog and runs away very quickly" + texts = [ + base, + near, + "completely unrelated sentence about columnar storage in lance files", + base, + None, + "another unrelated row that talks about vector indices and recall", + ] + tbl = pa.table({"id": list(range(len(texts))), "text": texts}) + ds = lance.write_dataset(tbl, "memory://minhash", max_rows_per_file=2) + assert len(ds.get_fragments()) == 3 + ds.create_scalar_index( + "text", + IndexConfig( + index_type="minhashlsh", + parameters={"num_hashes": 64, "num_bands": 16, "shingle_size": 2}, + ), + ) + stats = ds.stats.index_stats("text_idx") + assert stats["index_type"] == "MinHashLsh" + assert stats["indices"][0]["num_docs"] == 5 + assert stats["indices"][0]["num_hashes"] == 64 + + query = MinHashQuery(base, "text") + plan = ds.scanner(nearest=query, limit=3).explain_plan() + assert "MinHashSearch: column=text, limit=3" in plan + + result = ds.to_table(nearest=query, limit=3, columns=["id"]) + assert result.column_names == ["id", "_distance"] + assert result["id"].to_pylist() == [0, 3, 1] + distances = result["_distance"].to_pylist() + assert distances[0] == 0.0 and distances[1] == 0.0 + assert 0.0 < distances[2] < 0.5 + + # limit defaults to 10, filters prefilter the candidates + assert ds.to_table(nearest=query).num_rows == 3 + filtered = ds.to_table(nearest=query, limit=3, filter="id > 0", prefilter=True) + assert filtered["id"].to_pylist() == [3, 1] + + with pytest.raises(Exception, match="No MinHash LSH index found for column id"): + ds.to_table(nearest=MinHashQuery(base, "id"), limit=3) + + # Rows appended after the index was built are scored on the fly + ds = lance.write_dataset( + pa.table({"id": [6, 7], "text": [base, "nothing alike"]}), + ds, + mode="append", + ) + plan = ds.scanner(nearest=query, limit=3).explain_plan() + assert "MinHashFlatSearch" in plan + assert ds.to_table(nearest=query, limit=3, columns=["id"])["id"].to_pylist() == [ + 0, + 3, + 6, + ] + assert ds.to_table(nearest=query, limit=3, columns=["id"], fast_search=True)[ + "id" + ].to_pylist() == [0, 3, 1] + with pytest.raises(TypeError): + ScannerBuilder(ds).minhash_search("not a query") # type: ignore[arg-type] + + def test_json_index(): vals = ['{"x": 7, "y": 10}', '{"x": 11, "y": 22}', '{"y": 0}', '{"x": 10}'] tbl = pa.table({"jsons": pa.array(vals, pa.json_())}) @@ -1905,6 +3206,36 @@ def test_json_index(): ) +def test_json_index_non_exact_floats(): + # A JSON-path btree index is trained on the value extracted from the JSON + # column, not on the raw column itself, so the index build must sort by + # the extracted value rather than assuming the raw column's order matches + # it. Without that, range/equality queries silently miss rows whenever + # the extracted floats are not exactly representable in float64 (#7485). + vals = ['{"latitude": 10.5}', '{"latitude": 40.1}', '{"latitude": -3.2}'] + tbl = pa.table({"data": pa.array(vals, pa.json_())}) + ds = lance.write_dataset(tbl, "memory://test") + ds.create_scalar_index( + "data", + IndexConfig( + index_type="json", + parameters={"target_index_type": "btree", "path": "latitude"}, + ), + ) + + for filter in [ + "json_get_float(data, 'latitude') > 0", + "json_get_float(data, 'latitude') >= 10.5", + "json_get_float(data, 'latitude') = 40.1", + "json_get_float(data, 'latitude') = 10.5", + "json_get_float(data, 'latitude') < 100", + ]: + assert "ScalarIndexQuery" in ds.scanner(filter=filter).explain_plan() + assert ds.to_table(filter=filter) == ds.to_table( + filter=filter, use_scalar_index=False + ), filter + + def test_null_handling(): tbl = pa.table( { @@ -2016,9 +3347,31 @@ def test_label_list_index(tmp_path: Path): tbl = pa.Table.from_arrays([tag_list], names=["tags"]) dataset = lance.write_dataset(tbl, tmp_path / "dataset") dataset.create_scalar_index("tags", index_type="LABEL_LIST") - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["type"] == "LabelList" + assert indices[0].index_type == "LabelList" + + +@pytest.mark.parametrize("stable_row_ids", [False, True]) +def test_label_list_update_removes_old_labels(tmp_path: Path, stable_row_ids): + dataset = lance.write_dataset( + pa.table({"labels": [["old"], ["keep"]]}), + tmp_path, + enable_stable_row_ids=stable_row_ids, + max_rows_per_file=1, + max_rows_per_group=1, + ) + dataset.create_scalar_index("labels", "LABEL_LIST") + predicate = "array_has_any(labels, ['old'])" + dataset.update({"labels": "['new']"}, where=predicate) + assert dataset.to_table(filter=predicate).num_rows == 0 + + dataset.optimize.optimize_indices() + + assert dataset.to_table(filter=predicate, use_scalar_index=False).num_rows == 0 + assert dataset.to_table(filter=predicate).num_rows == 0 + assert dataset.to_table(filter="array_has_any(labels, ['new'])").num_rows == 1 + assert dataset.to_table(filter="array_has_any(labels, ['keep'])").num_rows == 1 def test_label_list_index_array_contains(tmp_path: Path): @@ -2054,6 +3407,120 @@ def test_label_list_index_array_contains(tmp_path: Path): assert "ScalarIndexQuery" not in explain +def test_label_list_index_empty_list_filters(tmp_path: Path): + """Empty list filters should not panic and should match pre-index results.""" + tbl = pa.table({"labels": [["foo"], ["bar"], ["foo", None], [None], [], None]}) + dataset = lance.write_dataset(tbl, tmp_path / "dataset") + + filters = [ + "array_has_any(labels, [])", + "array_has_all(labels, [])", + "NOT array_has_all(labels, [])", + "NOT array_has_any(labels, [])", + ] + expected = {f: dataset.to_table(filter=f).num_rows for f in filters} + + dataset.create_scalar_index("labels", index_type="LABEL_LIST") + + for f in filters: + assert dataset.to_table(filter=f).num_rows == expected[f] + + +def test_label_list_index_null_element_match(tmp_path: Path): + """Covers NULL elements inside non-NULL lists (list itself is never NULL).""" + tbl = pa.table( + {"labels": [["foo", None], ["foo"], ["bar", None], [None], ["bar"], []]} + ) + dataset = lance.write_dataset(tbl, tmp_path / "dataset") + + filters = [ + "array_has_any(labels, ['foo'])", + "array_has_all(labels, ['foo'])", + "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 + } + + dataset.create_scalar_index("labels", index_type="LABEL_LIST") + + actual = { + f: dataset.to_table(filter=f).column("labels").to_pylist() for f in filters + } + assert actual == expected + + +def test_label_list_index_null_list_match(tmp_path: Path): + """Covers NULL lists (list itself is NULL, elements are not NULL).""" + tbl = pa.table({"labels": [["foo"], ["bar"], None, []]}) + dataset = lance.write_dataset(tbl, tmp_path / "dataset") + + filters = [ + "array_has_any(labels, ['foo'])", + "array_has_all(labels, ['foo'])", + "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 + } + + dataset.create_scalar_index("labels", index_type="LABEL_LIST") + + actual = { + f: dataset.to_table(filter=f).column("labels").to_pylist() for f in filters + } + assert actual == expected + + +def test_label_list_index_null_literal_filters(tmp_path: Path): + """Ensure filters with NULL literal needles produce consistent results with scan.""" + tbl = pa.table( + {"labels": [["foo", None], ["bar", None], [None], ["foo"], ["bar"], []]} + ) + dataset = lance.write_dataset(tbl, tmp_path / "dataset") + + filters = [ + "array_has_any(labels, [NULL])", + "array_has_all(labels, [NULL])", + "array_contains(labels, NULL)", + "NOT array_has_any(labels, [NULL])", + "NOT array_has_all(labels, [NULL])", + "NOT array_contains(labels, NULL)", + ] + expected = { + f: dataset.to_table(filter=f).column("labels").to_pylist() for f in filters + } + + dataset.create_scalar_index("labels", index_type="LABEL_LIST") + + actual = { + f: dataset.to_table(filter=f).column("labels").to_pylist() for f in filters + } + assert actual == expected + + +def test_label_list_index_explain_null_literals(tmp_path: Path): + tbl = pa.table({"labels": [["foo", None], ["foo"]]}) + dataset = lance.write_dataset(tbl, tmp_path / "dataset") + dataset.create_scalar_index("labels", index_type="LABEL_LIST") + + # explain_plan should not panic when list literals include NULLs. + for expr in [ + "array_has_any(labels, [NULL])", + "array_has_all(labels, [NULL])", + "array_has_any(labels, ['foo', NULL])", + "array_has_all(labels, ['foo', NULL])", + ]: + explain = dataset.scanner(filter=expr).explain_plan() + assert isinstance(explain, str) + + def test_create_index_empty_dataset(tmp_path: Path): # Creating an index on an empty dataset is (currently) not terribly useful but # we shouldn't return strange errors. @@ -2100,8 +3567,8 @@ def test_searches(): test_searches() # Make sure fetching index stats on empty index is ok - for idx in ds.list_indices(): - ds.stats.index_stats(idx["name"]) + for idx in ds.describe_indices(): + ds.stats.index_stats(idx.name) # Make sure updating empty indices is ok ds.optimize.optimize_indices() @@ -2166,94 +3633,294 @@ def test_drop_index(tmp_path): } ) ds = lance.write_dataset(test_table, tmp_path) - ds.create_scalar_index("btree", index_type="BTREE") - ds.create_scalar_index("bitmap", index_type="BITMAP") - ds.create_scalar_index("fts", index_type="INVERTED") - ds.create_scalar_index("ngram", index_type="NGRAM") + ds.create_scalar_index("btree", index_type="BTREE") + ds.create_scalar_index("bitmap", index_type="BITMAP") + ds.create_scalar_index("fts", index_type="INVERTED") + ds.create_scalar_index("ngram", index_type="NGRAM") + + assert len(ds.describe_indices()) == 4 + + # Attempt to drop index (name does not exist) + with pytest.raises(RuntimeError, match="index not found"): + ds.drop_index("nonexistent_name") + + for idx in ds.describe_indices(): + idx_name = idx.name + ds.drop_index(idx_name) + + assert len(ds.describe_indices()) == 0 + + # Ensure we can still search columns + assert ds.to_table(filter="btree = 1").num_rows == 1 + assert ds.to_table(filter="bitmap = 1").num_rows == 1 + assert ds.to_table(filter="fts = 'a'").num_rows == test_table_size + assert ds.to_table(filter="contains(ngram, 'a')").num_rows == test_table_size + + +def test_index_prewarm(tmp_path: Path): + scan_stats = None + + def scan_stats_callback(stats: lance.ScanStatistics): + nonlocal scan_stats + scan_stats = stats + + test_table_size = 100 + test_table = pa.table( + { + "fts": ["word" for _ in range(test_table_size)], + } + ) + + # Write index, cache should not be populated + ds = lance.write_dataset(test_table, tmp_path) + ds.create_scalar_index("fts", index_type="INVERTED") + ds.scanner( + scan_stats_callback=scan_stats_callback, full_text_query="word" + ).to_table() + assert scan_stats.parts_loaded > 0 + + # Fresh load, no prewarm, cache should not be populated + ds = lance.dataset(tmp_path) + ds.scanner( + scan_stats_callback=scan_stats_callback, full_text_query="word" + ).to_table() + assert scan_stats.parts_loaded > 0 + + # Prewarm index, cache should be populated + ds = lance.dataset(tmp_path) + ds.prewarm_index("fts_idx") + ds.scanner( + scan_stats_callback=scan_stats_callback, full_text_query="word" + ).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 + + segment_uuid = ds.describe_indices()[0].segments[0].uuid + ds = lance.dataset(phrase_path) + ds.prewarm_index("fts_idx", with_position=True, index_segments=[segment_uuid]) + 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 + + def scan_stats_callback(stats: lance.ScanStatistics): + nonlocal scan_stats + scan_stats = stats + + test_table_size = 100 + test_table = pa.table( + { + "id": list(range(test_table_size)), + } + ) + ds = lance.write_dataset(test_table, tmp_path) + ds.create_scalar_index("id", index_type="BTREE") + ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() + assert scan_stats.parts_loaded > 0 + + ds = lance.dataset(tmp_path) + ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() + assert scan_stats.parts_loaded > 0 + + ds = lance.dataset(tmp_path) + ds.prewarm_index("id_idx") + ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() + assert scan_stats.parts_loaded == 0 + + +def test_btree_index_cache_hit_miss_stats(tmp_path: Path): + """Cold scan reports index cache misses; warm scan reports hits. + + ScanStatistics.index_cache_{hits,misses} are populated at page-level cache + boundaries. On a freshly-loaded dataset the BTree page fetch must be a + miss; a second scan against the same in-memory Dataset re-uses the cached + page and therefore reports a hit with zero misses. + """ + scan_stats = None + + def scan_stats_callback(stats: lance.ScanStatistics): + nonlocal scan_stats + scan_stats = stats + + test_table = pa.table({"val": list(range(1000))}) + ds = lance.write_dataset(test_table, tmp_path) + ds.create_scalar_index("val", index_type="BTREE") + + # Reopen so the session cache starts cold. A single-key point lookup on + # a small dataset resolves to exactly one BTree page, so cold/warm counts + # are deterministic 1/0 and 0/1. + ds = lance.dataset(tmp_path) + ds.scanner(filter="val = 42", scan_stats_callback=scan_stats_callback).to_table() + assert scan_stats is not None + assert scan_stats.index_cache_misses == 1 + assert scan_stats.index_cache_hits == 0 + + # Same Dataset, warm cache — no new page loads, only hits. + ds.scanner(filter="val = 42", scan_stats_callback=scan_stats_callback).to_table() + assert scan_stats.index_cache_hits == 1 + assert scan_stats.index_cache_misses == 0 + + +def test_bitmap_index_cache_hit_miss_stats(tmp_path: Path): + """Bitmap Range/IN queries report cold misses and warm hits; a value + that is not in the index never reaches the loader and must not count. + + Guards against the ``BitmapIndex::search`` regressions where the + ``Range`` / ``IsIn`` branches used to drop the ``MetricsCollector`` (so + every lookup was silently ``0/0``), and where an equality on a value + absent from ``index_map`` recorded a spurious miss before short-circuiting + to the empty result. + """ + scan_stats = None + + def scan_stats_callback(stats: lance.ScanStatistics): + nonlocal scan_stats + scan_stats = stats + + test_table = pa.table({"color": ["red", "green", "blue", "yellow"] * 25}) + ds = lance.write_dataset(test_table, tmp_path) + ds.create_scalar_index("color", index_type="BITMAP") - assert len(ds.list_indices()) == 4 + # Reopen so the session cache starts cold. + ds = lance.dataset(tmp_path) + ds.scanner( + filter="color IN ('red', 'blue')", scan_stats_callback=scan_stats_callback + ).to_table() + assert scan_stats is not None + assert scan_stats.index_cache_misses == 2 + assert scan_stats.index_cache_hits == 0 - # Attempt to drop index (name does not exist) - with pytest.raises(RuntimeError, match="index not found"): - ds.drop_index("nonexistent_name") + ds.scanner( + filter="color IN ('red', 'blue')", scan_stats_callback=scan_stats_callback + ).to_table() + assert scan_stats.index_cache_hits == 2 + assert scan_stats.index_cache_misses == 0 - for idx in ds.list_indices(): - idx_name = idx["name"] - ds.drop_index(idx_name) + # A value that is not in the index short-circuits before the loader and + # must not touch either counter. + ds.scanner( + filter="color = 'purple'", scan_stats_callback=scan_stats_callback + ).to_table() + assert scan_stats.index_cache_hits == 0 + assert scan_stats.index_cache_misses == 0 - assert len(ds.list_indices()) == 0 - # Ensure we can still search columns - assert ds.to_table(filter="btree = 1").num_rows == 1 - assert ds.to_table(filter="bitmap = 1").num_rows == 1 - assert ds.to_table(filter="fts = 'a'").num_rows == test_table_size - assert ds.to_table(filter="contains(ngram, 'a')").num_rows == test_table_size +def test_phrase_query_cache_hit_miss_stats(tmp_path: Path): + """Phrase-query fallback populates ``PositionKey``; that boundary must + show up in per-query cache statistics. + Guards against ``read_positions`` silently using the non-metric + ``get_or_insert_with_key`` API — before this fix, a warm phrase query + would report zero hits for the phrase-position cache slot even though + the loader was skipped. -def test_index_prewarm(tmp_path: Path): + The cold path can already record a few hits (``bm25_stats_for_terms`` + populates ``PostingMetadataKey``, which is then re-read on the + posting-list path as a cross-boundary hit), so the cold assertion is + ``misses > hits`` rather than a strict zero. + """ scan_stats = None def scan_stats_callback(stats: lance.ScanStatistics): nonlocal scan_stats scan_stats = stats - test_table_size = 100 test_table = pa.table( - { - "fts": ["word" for _ in range(test_table_size)], - } + {"text": ["quick brown fox jumps over lazy dog" for _ in range(50)]} ) - - # Write index, cache should not be populated ds = lance.write_dataset(test_table, tmp_path) - ds.create_scalar_index("fts", index_type="INVERTED") - ds.scanner( - scan_stats_callback=scan_stats_callback, full_text_query="word" - ).to_table() - assert scan_stats.parts_loaded > 0 + ds.create_scalar_index("text", index_type="INVERTED", with_position=True) - # Fresh load, no prewarm, cache should not be populated ds = lance.dataset(tmp_path) ds.scanner( - scan_stats_callback=scan_stats_callback, full_text_query="word" + scan_stats_callback=scan_stats_callback, + full_text_query='"quick brown"', ).to_table() - assert scan_stats.parts_loaded > 0 + assert scan_stats is not None + assert scan_stats.index_cache_misses > 0 + assert scan_stats.index_cache_misses > scan_stats.index_cache_hits - # Prewarm index, cache should be populated - ds = lance.dataset(tmp_path) - ds.prewarm_index("fts_idx") ds.scanner( - scan_stats_callback=scan_stats_callback, full_text_query="word" + scan_stats_callback=scan_stats_callback, + full_text_query='"quick brown"', ).to_table() - assert scan_stats.parts_loaded == 0 + assert scan_stats.index_cache_hits > 0 + assert scan_stats.index_cache_misses == 0 -def test_btree_prewarm(tmp_path: Path): +def test_fts_index_cache_hit_miss_stats(tmp_path: Path): + """Cold FTS scan reports misses; warm FTS scan reports hits. + + Guards the wrapper-forwarding fix in ``FtsIndexMetrics``: previously the + two new cache-hit/miss trait methods had default no-op implementations + that swallowed FTS-side events, so cache activity was reported as ``0/0`` + even for hot inverted-index scans. + + The cold path can still record a few hits when the same cache key is + read across boundaries in one query (e.g. ``bm25_stats_for_terms`` + populates ``PostingMetadataKey`` before ``posting_list`` re-reads it), + so the cold assertion is ``misses > hits`` rather than a strict zero. + """ scan_stats = None def scan_stats_callback(stats: lance.ScanStatistics): nonlocal scan_stats scan_stats = stats - test_table_size = 100 - test_table = pa.table( - { - "id": list(range(test_table_size)), - } - ) + test_table = pa.table({"fts": ["word" for _ in range(100)]}) ds = lance.write_dataset(test_table, tmp_path) - ds.create_scalar_index("id", index_type="BTREE") - ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() - assert scan_stats.parts_loaded > 0 + ds.create_scalar_index("fts", index_type="INVERTED") + # Reopen so the session cache starts cold. ds = lance.dataset(tmp_path) - ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() - assert scan_stats.parts_loaded > 0 + ds.scanner( + scan_stats_callback=scan_stats_callback, full_text_query="word" + ).to_table() + assert scan_stats is not None + assert scan_stats.index_cache_misses > 0 + assert scan_stats.index_cache_misses > scan_stats.index_cache_hits - ds = lance.dataset(tmp_path) - ds.prewarm_index("id_idx") - ds.scanner(scan_stats_callback=scan_stats_callback, filter="id>0").to_table() - assert scan_stats.parts_loaded == 0 + # Same Dataset, warm cache — posting-list / metadata reads must now hit. + ds.scanner( + scan_stats_callback=scan_stats_callback, full_text_query="word" + ).to_table() + assert scan_stats.index_cache_hits > 0 + assert scan_stats.index_cache_misses == 0 def test_fts_backward_v0_27_0(tmp_path: Path): @@ -2281,6 +3948,14 @@ def test_fts_backward_v0_27_0(tmp_path: Path): "frodo was a happy puppy", } + # Requiring both disjoint terms advances "happy" past its final document while + # "tail" remains live. Legacy WAND must terminate without reading the exhausted + # posting. + results = ds.to_table( + full_text_query=MatchQuery("happy tail", "text", operator=FullTextOperator.AND) + ) + assert results.num_rows == 0 + data = pa.table( { "text": [ @@ -2829,6 +4504,45 @@ def generate_coherent_text(): return ds +def generate_multi_fragment_bitmap_dataset( + tmp_path, num_fragments=4, rows_per_fragment=40 +): + """ + Generate a multi-fragment dataset with a low-cardinality integer column + suitable for distributed bitmap index tests. + """ + + def make_mock_bitmap_table(start_id: int) -> pa.Table: + ids = list(range(start_id, start_id + rows_per_fragment)) + return pa.table( + { + "id": pa.array(ids, type=pa.int32()), + "category": pa.array([row_id % 5 for row_id in ids], type=pa.int32()), + } + ) + + ds = lance.write_dataset( + make_mock_bitmap_table(0), + tmp_path, + max_rows_per_file=rows_per_fragment, + ) + + for fragment_idx in range(1, num_fragments): + ds = lance.write_dataset( + make_mock_bitmap_table(fragment_idx * rows_per_fragment), + tmp_path, + mode="append", + max_rows_per_file=rows_per_fragment, + ) + + fragments = ds.get_fragments() + assert len(fragments) == num_fragments, ( + f"Expected {num_fragments} fragments, got {len(fragments)}" + ) + + return ds + + # ============================================================================ # Distributed FTS Index Unit Tests # ============================================================================ @@ -2849,20 +4563,10 @@ def test_build_distributed_fts_index_basic(tmp_path): ) # Verify the index was created - indices = distributed_ds.list_indices() - assert len(indices) > 0, "No indices found after distributed index creation" - - # Find our distributed index - distributed_index = None - for idx in indices: - if "distributed" in idx["name"]: - distributed_index = idx - break - - assert distributed_index is not None, "Distributed index not found" - assert distributed_index["type"] == "Inverted", ( - f"Expected Inverted index, got {distributed_index['type']}" - ) + index_name = "text_distributed_idx" + stats = distributed_ds.stats.index_stats(index_name) + assert stats["name"] == index_name + assert stats["index_type"] == "Inverted" # Test that the index works for searching results = distributed_ds.scanner( @@ -2873,6 +4577,52 @@ def test_build_distributed_fts_index_basic(tmp_path): assert results.num_rows > 0, "No results found for search term 'frodo'" +@pytest.mark.parametrize("index_type", ["INVERTED", "FTS"]) +def test_segment_fts(tmp_path, index_type): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=3, rows_per_fragment=100 + ) + + index_name = f"text_{index_type.lower()}_segment_idx" + segments = [ + ds.create_index_uncommitted( + column="text", + index_type=index_type, + name=index_name, + fragment_ids=[fragment.fragment_id], + with_position=False, + remove_stop_words=False, + ) + for fragment in ds.get_fragments() + ] + committed_ds = ds.commit_existing_index_segments(index_name, "text", segments) + + query = MatchQuery("frodo", "text") + results_without_index = committed_ds.scanner( + full_text_query=query, + columns=["id", "text"], + use_scalar_index=False, + ).to_table() + results_with_index = committed_ds.scanner( + full_text_query=query, + columns=["id", "text"], + use_scalar_index=True, + ).to_table() + + compare_fts_results(results_without_index, results_with_index) + assert any( + idx.name == index_name and idx.index_type == "Inverted" + for idx in committed_ds.describe_indices() + ) + assert ( + "FlatMatchQuery" + not in committed_ds.scanner( + full_text_query=query, + use_scalar_index=True, + ).explain_plan() + ) + + def test_compare_fts_results_identical(tmp_path): """ Test compare_fts_results function with identical results. @@ -3277,19 +5027,9 @@ def test_distribute_fts_index_build(tmp_path): ) # Verify the index was created and is functional - indices = ds_committed.list_indices() - assert len(indices) > 0, "No indices found after commit" - - # Find our index - our_index = None - for idx in indices: - if idx["name"] == index_name: - our_index = idx - break - assert our_index is not None, f"Index '{index_name}' not found in indices list" - assert our_index["type"] == "Inverted", ( - f"Expected Inverted index, got {our_index['type']}" - ) + stats = ds_committed.stats.index_stats(index_name) + assert stats["name"] == index_name + assert stats["index_type"] == "Inverted" # Test that the index works for searching # Get a sample text from the dataset to search for @@ -3357,10 +5097,10 @@ def test_backward_compatibility_no_fragment_ids(tmp_path): ) # Verify the index was created - indices = ds.list_indices() + indices = ds.describe_indices() assert len(indices) == 1 - assert indices[0]["name"] == "full_dataset_idx" - assert indices[0]["type"] == "Inverted" + assert indices[0].name == "full_dataset_idx" + assert indices[0].index_type == "Inverted" # Test that the index works sample_data = ds.take([0], columns=["text"]) @@ -3381,10 +5121,10 @@ def test_backward_compatibility_changed_index_protos(tmp_path): shutil.copytree(path, tmp_path, dirs_exist_ok=True) ds = lance.dataset(tmp_path) - indices = ds.list_indices() + indices = ds.describe_indices() assert len(indices) == 1 - assert indices[0]["name"] == "x_idx" - assert indices[0]["type"] == "BTree" + assert indices[0].name == "x_idx" + assert indices[0].index_type == "BTree" results = ds.scanner(filter="x = 100").to_table() assert results.num_rows == 1 @@ -3393,149 +5133,376 @@ def test_backward_compatibility_changed_index_protos(tmp_path): def test_distribute_btree_index_build(tmp_path): """ - Test distributed B-tree index build similar to test_distribute_fts_index_build. - This test creates B-tree indices on individual fragments and then - commits them as a single index. + Test distributed B-tree index build with segmented index commit. + This test creates B-tree segments on individual fragments and then + commits them as a single logical index. """ - # Generate test dataset with multiple fragments ds = generate_multi_fragment_dataset( tmp_path, num_fragments=4, rows_per_fragment=10000 ) - import uuid - - index_id = str(uuid.uuid4()) index_name = "btree_multiple_fragment_idx" + ds_committed = _commit_segmented_btree_index(ds, "id", index_name) - fragments = ds.get_fragments() - fragment_ids = [fragment.fragment_id for fragment in fragments] + stats = ds_committed.stats.index_stats(index_name) + assert stats["name"] == index_name + assert stats["index_type"] == "BTree" - for fragment in ds.get_fragments(): - fragment_id = fragment.fragment_id + test_id = 100 + results = ds_committed.scanner( + filter=f"id = {test_id}", + columns=["id", "text"], + ).to_table() - # Create B-tree scalar index for each fragment - # Use the same index_name for all fragments (like in FTS test) - ds.create_scalar_index( - column="id", # Use integer column for B-tree - index_type="BTREE", - name=index_name, - replace=False, - index_uuid=index_id, - fragment_ids=[fragment_id], - ) + assert results.num_rows == 1, f"No results found for id = {test_id}" - # test that the dataset should be searchable - # when the index not committed yet - # Test that the index works for searching - # Test exact equality queries - test_id = 100 # Should be in first fragment - results = ds.scanner( + results_range = ds_committed.scanner( + filter="id >= 200 AND id < 800", + columns=["id", "text"], + ).to_table() + + assert results_range.num_rows > 0, "No results found for range query" + + reference_ds = generate_multi_fragment_dataset( + tmp_path / "reference", num_fragments=4, rows_per_fragment=10000 + ) + + reference_ds.create_scalar_index( + column="id", + index_type="BTREE", + name="reference_btree_idx", + ) + + reference_results = reference_ds.scanner( filter=f"id = {test_id}", columns=["id", "text"], ).to_table() - assert results.num_rows == 1, f"No results found for id = {test_id}" + assert results.num_rows == reference_results.num_rows, ( + f"Distributed index returned {results.num_rows} results, " + f"but complete index returned {reference_results.num_rows} results" + ) - # Merge the B-tree index metadata - ds.merge_index_metadata(index_id, index_type="BTREE") + reference_range_results = reference_ds.scanner( + filter="id >= 200 AND id < 800", + columns=["id", "text"], + ).to_table() - # Create an Index object using the new dataclass format - from lance.dataset import Index + assert results_range.num_rows == reference_range_results.num_rows, ( + f"Distributed index range query returned {results_range.num_rows} results, " + f"but complete index returned {reference_range_results.num_rows} results" + ) - # Get the schema field for the indexed column - field_id = ds.schema.get_field_index("id") - index = Index( - uuid=index_id, - name=index_name, - fields=[field_id], # Use field index instead of field object - dataset_version=ds.version, - fragment_ids=set(fragment_ids), - index_version=0, +def test_bitmap_uncommitted_segments_can_be_committed_from_python(tmp_path): + dataset_path = tmp_path / "bitmap_segments.lance" + ds = generate_multi_fragment_bitmap_dataset( + dataset_path, num_fragments=4, rows_per_fragment=40 + ) + + index_name = "bitmap_segment_idx" + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + fragment_groups = [ + fragment_ids[idx : idx + 2] for idx in range(0, len(fragment_ids), 2) + ] + assert len(fragment_groups) >= 2 + + staged_segments = [ + ds.create_index_uncommitted( + column="category", + index_type="BITMAP", + name=index_name, + fragment_ids=fragment_group, + ) + for fragment_group in fragment_groups + ] + + assert len({segment.uuid for segment in staged_segments}) == len(staged_segments) + for segment, fragment_group in zip(staged_segments, fragment_groups): + assert segment.fragment_ids == set(fragment_group) + assert any(file.path == "bitmap_page_lookup.lance" for file in segment.files) + assert all(not file.path.startswith("part_") for file in segment.files) + + merged_segment = ds.merge_existing_index_segments(staged_segments) + assert merged_segment.uuid not in {segment.uuid for segment in staged_segments} + assert merged_segment.fragment_ids == set(fragment_ids) + assert any(file.path == "bitmap_page_lookup.lance" for file in merged_segment.files) + assert all(not file.path.startswith("part_") for file in merged_segment.files) + + ds = ds.commit_existing_index_segments(index_name, "category", [merged_segment]) + descriptions = {index.name: index for index in ds.describe_indices()} + assert len(descriptions[index_name].segments) == 1 + + filter_expr = "category = 3" + without_index = ds.scanner( + filter=filter_expr, + columns=["id", "category"], + use_scalar_index=False, + ).to_table() + with_index = ds.scanner( + filter=filter_expr, + columns=["id", "category"], + use_scalar_index=True, + ).to_table() + + assert with_index.num_rows == without_index.num_rows + assert with_index["id"].to_pylist() == without_index["id"].to_pylist() + assert set(with_index["category"].to_pylist()) == {3} + assert ( + "ScalarIndexQuery" + in ds.scanner(filter=filter_expr, use_scalar_index=True).explain_plan() + ) + + +def test_ngram_segment_merge_and_commit_from_python(tmp_path): + ds = lance.write_dataset( + pa.table( + { + "text": [ + "alpha needle", + None, + "beta needle", + "gamma stack", + "delta needle", + "", + ] + } + ), + tmp_path, + max_rows_per_file=2, + ) + index_name = "text_ngram_segments" + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + staged_segments = [ + ds.create_index_uncommitted( + column="text", + index_type="NGRAM", + name=index_name, + fragment_ids=[fragment_id], + ) + for fragment_id in fragment_ids + ] + source_version = staged_segments[0].dataset_version + + for segment, fragment_id in zip(staged_segments, fragment_ids): + assert segment.fragment_ids == {fragment_id} + assert any(file.path == "ngram_postings.lance" for file in segment.files) + + merged_segment = ds.merge_existing_index_segments(staged_segments) + assert merged_segment.dataset_version == source_version + assert merged_segment.fragment_ids == set(fragment_ids) + assert any(file.path == "ngram_postings.lance" for file in merged_segment.files) + + ds.insert(pa.table({"text": ["new stack"]})) + assert ds.version > source_version + ds = ds.commit_existing_index_segments(index_name, "text", [merged_segment]) + descriptions = {index.name: index for index in ds.describe_indices()} + assert descriptions[index_name].index_type == "NGram" + assert len(descriptions[index_name].segments) == 1 + assert ( + descriptions[index_name].segments[0].dataset_version_at_last_update + == source_version + ) + assert ds.count_rows("contains(text, 'needle')") == 3 + assert ds.count_rows("text IS NULL") == 1 + + +@pytest.mark.parametrize( + "label_type", + [pa.list_(pa.string()), pa.large_list(pa.string())], + ids=["list", "large_list"], +) +def test_label_list_segment_index(tmp_path, label_type): + rows_per_fragment = 8 + ds = lance.write_dataset( + pa.table( + { + "id": pa.array(range(rows_per_fragment * 4), type=pa.int32()), + "labels": pa.array( + [ + ["distributed"] if row_id % 2 == 0 else ["other"] + for row_id in range(rows_per_fragment * 4) + ], + type=label_type, + ), + } + ), + tmp_path, + max_rows_per_file=rows_per_fragment, ) - # Create the index operation - create_index_op = lance.LanceOperation.CreateIndex( - new_indices=[index], - removed_indices=[], + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + assert len(fragment_ids) == 4 + + with pytest.raises(ValueError, match="create_index_uncommitted"): + ds.create_scalar_index( + column="labels", + index_type="LABEL_LIST", + fragment_ids=[fragment_ids[0]], + ) + + index_name = "labels_segment_idx" + segments = [ + ds.create_index_uncommitted( + column="labels", + index_type="LABEL_LIST", + name=index_name, + fragment_ids=[fragment_id], + ) + for fragment_id in fragment_ids + ] + + merged_segment = ds.merge_existing_index_segments(segments) + ds = ds.commit_existing_index_segments(index_name, "labels", [merged_segment]) + + filter_expr = "array_has_any(labels, ['distributed'])" + without_index = ds.scanner( + filter=filter_expr, + columns=["id", "labels"], + use_scalar_index=False, + ).to_table() + with_index = ds.scanner( + filter=filter_expr, + columns=["id", "labels"], + use_scalar_index=True, + ).to_table() + + assert with_index.equals(without_index) + assert ( + "ScalarIndexQuery" + in ds.scanner(filter=filter_expr, use_scalar_index=True).explain_plan() ) - # Commit the index - ds_committed = lance.LanceDataset.commit( - ds.uri, - create_index_op, - read_version=ds.version, + +def test_zonemap_fragment_ids_parameter_validation(tmp_path): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=2, rows_per_fragment=100 ) - # Verify the index was created and is functional - indices = ds_committed.list_indices() - assert len(indices) > 0, "No indices found after commit" + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + with pytest.raises(ValueError, match="create_index_uncommitted"): + ds.create_scalar_index( + column="id", + index_type="ZONEMAP", + fragment_ids=[fragment_ids[0]], + ) - # Find our index - our_index = None - for idx in indices: - if idx["name"] == index_name: - our_index = idx - break - assert our_index is not None, f"Index '{index_name}' not found in indices list" - assert our_index["type"] == "BTree", ( - f"Expected BTree index, got {our_index['type']}" +def test_zonemap_segment_merge_and_commit_from_python(tmp_path): + rows_per_fragment = 20_000 + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=4, rows_per_fragment=rows_per_fragment ) - # Test that the index works for searching - # Test exact equality queries - test_id = 100 # Should be in first fragment - results = ds_committed.scanner( - filter=f"id = {test_id}", + index_name = "id_zonemap_segments" + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + staged_segments = [ + ds.create_index_uncommitted( + column="id", + index_type="ZONEMAP", + name=index_name, + fragment_ids=[fragment_id], + ) + for fragment_id in fragment_ids + ] + + assert len({segment.uuid for segment in staged_segments}) == len(staged_segments) + for segment, fragment_id in zip(staged_segments, fragment_ids): + files = segment.files + assert files is not None + assert segment.fragment_ids == {fragment_id} + assert any(file.path == "zonemap.lance" for file in files) + assert all(not file.path.startswith("part_") for file in files) + + merged_segment = ds.merge_existing_index_segments(staged_segments) + merged_files = merged_segment.files + assert merged_files is not None + assert merged_segment.uuid not in {segment.uuid for segment in staged_segments} + assert merged_segment.fragment_ids == set(fragment_ids) + assert any(file.path == "zonemap.lance" for file in merged_files) + assert all(not file.path.startswith("part_") for file in merged_files) + + ds = ds.commit_existing_index_segments(index_name, "id", [merged_segment]) + descriptions = {index.name: index for index in ds.describe_indices()} + assert descriptions[index_name].index_type == "ZoneMap" + assert len(descriptions[index_name].segments) == 1 + + filter_expr = "id >= 8200 AND id < 8300" + without_index = ds.scanner( + filter=filter_expr, columns=["id", "text"], + use_scalar_index=False, ).to_table() - - assert results.num_rows == 1, f"No results found for id = {test_id}" - - # Test range queries across fragments - results_range = ds_committed.scanner( - filter="id >= 200 AND id < 800", + with_index = ds.scanner( + filter=filter_expr, columns=["id", "text"], + use_scalar_index=True, ).to_table() - assert results_range.num_rows > 0, "No results found for range query" - - # Compare with complete index results to ensure consistency - # Create a reference dataset with complete index - reference_ds = generate_multi_fragment_dataset( - tmp_path / "reference", num_fragments=4, rows_per_fragment=10000 + assert with_index.num_rows == without_index.num_rows + assert with_index["id"].to_pylist() == without_index["id"].to_pylist() + assert ( + "ScalarIndexQuery" + in ds.scanner(filter=filter_expr, use_scalar_index=True).explain_plan() ) - # Create complete B-tree index for comparison - reference_ds.create_scalar_index( - column="id", - index_type="BTREE", - name="reference_btree_idx", + +def test_bloomfilter_segment_merge_and_commit_from_python(tmp_path): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=3, rows_per_fragment=100 ) - # Compare exact query results - reference_results = reference_ds.scanner( - filter=f"id = {test_id}", - columns=["id", "text"], - ).to_table() + index_name = "id_bloomfilter_segments" + fragment_ids = [fragment.fragment_id for fragment in ds.get_fragments()] + staged_segments = [ + ds.create_index_uncommitted( + column="id", + index_type="BLOOMFILTER", + name=index_name, + fragment_ids=[fragment_id], + ) + for fragment_id in fragment_ids + ] - assert results.num_rows == reference_results.num_rows, ( - f"Distributed index returned {results.num_rows} results, " - f"but complete index returned {reference_results.num_rows} results" - ) + for segment, fragment_id in zip(staged_segments, fragment_ids): + assert segment.fragment_ids == {fragment_id} + assert any(file.path == "bloomfilter.lance" for file in segment.files) - # Compare range query results - reference_range_results = reference_ds.scanner( - filter="id >= 200 AND id < 800", + merged_segment = ds.merge_existing_index_segments(staged_segments) + assert merged_segment.fragment_ids == set(fragment_ids) + assert any(file.path == "bloomfilter.lance" for file in merged_segment.files) + + ds = ds.commit_existing_index_segments(index_name, "id", [merged_segment]) + descriptions = {index.name: index for index in ds.describe_indices()} + assert descriptions[index_name].index_type == "BloomFilter" + assert len(descriptions[index_name].segments) == 1 + + filter_expr = "id = 117" + without_index = ds.scanner( + filter=filter_expr, + columns=["id", "text"], + use_scalar_index=False, + ).to_table() + with_index = ds.scanner( + filter=filter_expr, columns=["id", "text"], + use_scalar_index=True, ).to_table() + assert with_index.to_pydict() == without_index.to_pydict() + assert ( + "ScalarIndexQuery" + in ds.scanner(filter=filter_expr, use_scalar_index=True).explain_plan() + ) - assert results_range.num_rows == reference_range_results.num_rows, ( - f"Distributed index range query returned {results_range.num_rows} results, " - f"but complete index returned {reference_range_results.num_rows} results" + +def test_merge_index_metadata_btree_soft_break(tmp_path): + ds = generate_multi_fragment_dataset( + tmp_path, num_fragments=2, rows_per_fragment=100 ) + with pytest.raises(ValueError, match="no longer supports merge_index_metadata"): + ds.merge_index_metadata(str(uuid.uuid4()), index_type="BTREE") + def test_btree_fragment_ids_parameter_validation(tmp_path): """ @@ -3545,103 +5512,128 @@ def test_btree_fragment_ids_parameter_validation(tmp_path): tmp_path, num_fragments=2, rows_per_fragment=10000 ) - # Test with valid fragment IDs fragments = ds.get_fragments() valid_fragment_id = fragments[0].fragment_id - # This should work without errors - ds.create_scalar_index( - column="id", - index_type="BTREE", - fragment_ids=[valid_fragment_id], - ) - - # Test with invalid fragment ID (should handle gracefully) - try: + # create_scalar_index no longer accepts fragment_ids for BTREE; distributed + # builds must go through the segmented create_index_uncommitted path. + with pytest.raises(ValueError, match="create_index_uncommitted"): ds.create_scalar_index( column="id", index_type="BTREE", - fragment_ids=[999999], # Non-existent fragment ID + fragment_ids=[valid_fragment_id], ) - except Exception as e: - # It's acceptable for this to fail with an appropriate error - print(f"Expected error for invalid fragment ID: {e}") + with pytest.raises(ValueError, match="index_uuid is no longer accepted"): + ds.create_index_uncommitted( + column="id", + index_type="BTREE", + fragment_ids=[valid_fragment_id], + index_uuid=str(uuid.uuid4()), + ) -@pytest.mark.parametrize( - "test_name,filter_expr", - [ - # Test 1: Boundary values at fragment edges - ("First value", "id = 0"), - ("Fragment 0 last value", "id = 9999"), - ("Fragment 1 first value", "id = 10000"), - ("Fragment 1 last value", "id = 19999"), - ("Fragment 2 first value", "id = 20000"), - ("Last value", "id = 29999"), - # Test 2: Values in the middle of fragments - ("Fragment 0 middle", "id = 5000"), - ("Fragment 1 middle", "id = 15000"), - ("Fragment 2 middle", "id = 25000"), - # Test 3: Range queries within single fragments - ("Range within fragment 0", "id >= 10 AND id < 20"), - ("Range within fragment 1", "id >= 10010 AND id < 10020"), - ("Range within fragment 2", "id >= 20010 AND id < 20020"), - # Test 4: Range queries spanning multiple fragments - ("Cross fragment 0-1", "id >= 9995 AND id < 10005"), - ("Cross fragment 1-2", "id >= 19995 AND id < 20005"), - ("Cross all fragments", "id >= 5000 AND id < 25000"), - # Test 5: Edge cases - ("Non-existent small value", "id = -1"), - ("Non-existent large value", "id = 30100"), - ("Large range", "id >= 0 AND id < 30000"), - # Test 6: Comparison operators - ("Less than boundary", "id < 10000"), - ("Greater than boundary", "id > 19999"), - ("Less than or equal", "id <= 10050"), - ("Greater than or equal", "id >= 10050"), - ], -) -def test_btree_query_comparison_parametrized( - btree_comparison_datasets, test_name, filter_expr -): + # Building one uncommitted segment for a valid fragment should work and + # return the segment metadata without committing it. + segment = ds.create_index_uncommitted( + column="id", + index_type="BTREE", + fragment_ids=[valid_fragment_id], + ) + assert segment.fragment_ids == {valid_fragment_id} + + +def test_btree_query_comparison(btree_comparison_datasets): """ - Parametrized B-tree index query comparison test + B-tree index query comparison test covering representative query shapes. - Convert the original loop test to parametrized test, - each test case runs independently + Compares segmented fragment-built BTree results with a complete BTree index. """ fragment_ds = btree_comparison_datasets["fragment_ds"] complete_ds = btree_comparison_datasets["complete_ds"] + rows_per_fragment = btree_comparison_datasets["rows_per_fragment"] + total_rows = btree_comparison_datasets["total_rows"] + fragment_starts = [idx * rows_per_fragment for idx in range(3)] + fragment_ends = [start + rows_per_fragment - 1 for start in fragment_starts] + fragment_middles = [start + rows_per_fragment // 2 for start in fragment_starts] + range_start_offset = rows_per_fragment // 10 + range_end_offset = range_start_offset * 2 + cross_fragment_margin = rows_per_fragment // 20 + + cases = [ + # Boundary values at fragment edges + ("First value", f"id = {fragment_starts[0]}"), + ("Fragment 0 last value", f"id = {fragment_ends[0]}"), + ("Fragment 1 first value", f"id = {fragment_starts[1]}"), + ("Fragment 1 last value", f"id = {fragment_ends[1]}"), + ("Fragment 2 first value", f"id = {fragment_starts[2]}"), + ("Last value", f"id = {total_rows - 1}"), + # Values in the middle of fragments + ("Fragment 0 middle", f"id = {fragment_middles[0]}"), + ("Fragment 1 middle", f"id = {fragment_middles[1]}"), + ("Fragment 2 middle", f"id = {fragment_middles[2]}"), + # Range queries within single fragments + ( + "Range within fragment 0", + f"id >= {fragment_starts[0] + range_start_offset} " + f"AND id < {fragment_starts[0] + range_end_offset}", + ), + ( + "Range within fragment 1", + f"id >= {fragment_starts[1] + range_start_offset} " + f"AND id < {fragment_starts[1] + range_end_offset}", + ), + ( + "Range within fragment 2", + f"id >= {fragment_starts[2] + range_start_offset} " + f"AND id < {fragment_starts[2] + range_end_offset}", + ), + # Range queries spanning multiple fragments + ( + "Cross fragment 0-1", + f"id >= {fragment_ends[0] - cross_fragment_margin + 1} " + f"AND id < {fragment_starts[1] + cross_fragment_margin}", + ), + ( + "Cross fragment 1-2", + f"id >= {fragment_ends[1] - cross_fragment_margin + 1} " + f"AND id < {fragment_starts[2] + cross_fragment_margin}", + ), + ( + "Cross all fragments", + f"id >= {fragment_middles[0]} AND id < {fragment_middles[2]}", + ), + # Missing values and the full indexed range + ("Non-existent small value", f"id = {fragment_starts[0] - 1}"), + ( + "Non-existent large value", + f"id = {total_rows + rows_per_fragment}", + ), + ( + "Large range", + f"id >= {fragment_starts[0]} AND id < {total_rows}", + ), + # Comparison operators + ("Less than boundary", f"id < {fragment_starts[1]}"), + ("Greater than boundary", f"id > {fragment_ends[1]}"), + ("Less than or equal", f"id <= {fragment_middles[1]}"), + ("Greater than or equal", f"id >= {fragment_middles[1]}"), + ] - # Query fragment-based index - fragment_results = fragment_ds.scanner( - filter=filter_expr, - columns=["id", "text"], - ).to_table() - - # Query complete index - complete_results = complete_ds.scanner( - filter=filter_expr, - columns=["id", "text"], - ).to_table() - - # Compare row counts - assert fragment_results.num_rows == complete_results.num_rows, ( - f"Test '{test_name}' failed: Fragment index " - f"returned {fragment_results.num_rows} rows, " - f"but complete index returned {complete_results.num_rows}" - f" rows for filter: {filter_expr}" - ) - - # Compare actual results if there are any - if fragment_results.num_rows > 0: - # Sort both results by id for comparison - fragment_ids = sorted(fragment_results.column("id").to_pylist()) - complete_ids = sorted(complete_results.column("id").to_pylist()) - - assert fragment_ids == complete_ids, ( - f"Test '{test_name}' failed: Fragment index " - f"and complete index returned different results for filter: {filter_expr}" + for test_name, filter_expr in cases: + fragment_results = fragment_ds.scanner( + filter=filter_expr, + columns=["id", "text"], + ).to_table() + complete_results = complete_ds.scanner( + filter=filter_expr, + columns=["id", "text"], + ).to_table() + + fragment_results = fragment_results.sort_by([("id", "ascending")]) + complete_results = complete_results.sort_by([("id", "ascending")]) + assert fragment_results.equals(complete_results), ( + f"Test '{test_name}' failed: segmented and complete BTree indexes returned " + f"different results for filter: {filter_expr}" ) @@ -3820,6 +5812,12 @@ def scan_stats_callback(stats: lance.ScanStatistics): assert isinstance(scan_stats.parts_loaded, int) assert isinstance(scan_stats.index_comparisons, int) assert isinstance(scan_stats.all_counts, dict) + assert isinstance(scan_stats.all_times, dict) + for key, value in scan_stats.all_times.items(): + assert isinstance(key, str) + assert isinstance(value, int) + assert value >= 0 + assert "all_times=" in repr(scan_stats) # Verify we got some I/O activity assert scan_stats.iops > 0, "Expected some I/O operations" @@ -3869,10 +5867,10 @@ def test_nested_field_btree_index(tmp_path): dataset.create_scalar_index(column="meta.lang", index_type="BTREE") # Verify index was created - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["meta.lang"] - assert indices[0]["type"] == "BTree" + assert indices[0].field_names == ["meta.lang"] + assert indices[0].index_type == "BTree" # Test query using the index - filter for English language result = dataset.scanner(filter="meta.lang = 'en'").to_table() @@ -3970,10 +5968,10 @@ def test_nested_field_fts_index(tmp_path): ds.create_scalar_index("data.text", index_type="INVERTED", with_position=False) # Verify index was created - indices = ds.list_indices() + indices = ds.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["data.text"] - assert indices[0]["type"] == "Inverted" + assert indices[0].field_names == ["data.text"] + assert indices[0].index_type == "Inverted" # Test full text search on nested field results = ds.to_table(full_text_query="lance") @@ -4022,6 +6020,77 @@ def test_nested_field_fts_index(tmp_path): assert results.num_rows == 50 +def test_multiple_nested_field_fts_indices_e2e(tmp_path): + """Test FTS queries against multiple indexed nested string fields.""" + + def make_table(ids, text_values, summary_values): + return pa.table( + { + "id": ids, + "data": pa.StructArray.from_arrays( + [ + pa.array(text_values, type=pa.string()), + pa.array(summary_values, type=pa.string()), + ], + names=["text", "summary"], + ), + } + ) + + def result_ids(query): + return sorted(ds.to_table(full_text_query=query)["id"].to_pylist()) + + ds = lance.write_dataset( + make_table( + [0, 1, 2, 3], + [ + "lance nested alpha", + "plain text", + None, + "phrase target here", + ], + [ + "metadata only", + "database nested beta", + "lance beta", + "other", + ], + ), + tmp_path, + ) + + ds.create_scalar_index("data.text", index_type="INVERTED", with_position=True) + ds.create_scalar_index("data.summary", index_type="INVERTED", with_position=False) + + indexed_fields = { + tuple(index.field_names) + for index in ds.describe_indices() + if index.index_type == "Inverted" + } + assert indexed_fields == {("data.text",), ("data.summary",)} + + assert result_ids(MatchQuery("alpha", "data.text")) == [0] + assert result_ids(MatchQuery("beta", "data.summary")) == [1, 2] + assert result_ids("lance") == [0, 2] + assert result_ids(MultiMatchQuery("nested", ["data.text", "data.summary"])) == [ + 0, + 1, + ] + assert result_ids(PhraseQuery("phrase target", "data.text")) == [3] + + ds = lance.write_dataset( + make_table( + [4, 5], + ["fresh lance append", "plain append"], + ["other", "fresh beta append"], + ), + tmp_path, + mode="append", + ) + + assert result_ids("fresh") == [4, 5] + + def test_nested_field_bitmap_index(tmp_path): """Test BITMAP index creation and querying on nested fields""" # Create dataset with nested categorical field @@ -4044,10 +6113,10 @@ def test_nested_field_bitmap_index(tmp_path): ds.create_scalar_index("attributes.color", index_type="BITMAP") # Verify index was created - indices = ds.list_indices() + indices = ds.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["attributes.color"] - assert indices[0]["type"] == "Bitmap" + assert indices[0].field_names == ["attributes.color"] + assert indices[0].index_type == "Bitmap" # Test equality query results = ds.to_table(filter="attributes.color = 'red'", prefilter=True) @@ -4154,7 +6223,11 @@ def test_json_inverted_match_query(tmp_path): assert results.num_rows == 1 -def test_describe_indices(tmp_path): +@pytest.mark.parametrize( + ("format_version", "expected_format_version"), + [(1, 1), (2, 2), (3, 3), ("v1", 1), ("v2", 2), ("v3", 3)], +) +def test_describe_indices(tmp_path, format_version, expected_format_version): data = pa.table( { "id": range(100), @@ -4170,7 +6243,7 @@ def test_describe_indices(tmp_path): } ) ds = lance.write_dataset(data, tmp_path) - ds.create_scalar_index("text", index_type="INVERTED") + ds.create_scalar_index("text", index_type="INVERTED", format_version=format_version) indices = ds.describe_indices() assert len(indices) == 1 @@ -4184,9 +6257,13 @@ 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 == expected_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 + 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 @@ -4198,7 +6275,6 @@ def test_describe_indices(tmp_path): assert details["lower_case"] assert details["stem"] assert details["remove_stop_words"] - assert details["custom_stop_words"] is None assert details["ascii_folding"] assert details["min_ngram_length"] == 3 assert details["max_ngram_length"] == 3 @@ -4267,9 +6343,260 @@ 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") indices = ds.describe_indices() for index in indices: assert index.num_rows_indexed == 50 + + +def _run_fts_format_creation_probe( + tmp_path, env_value, creation_options=None, expected_format_version=None +): + script = """ +import json +import sys + +import lance +import pyarrow as pa + +dataset = lance.write_dataset( + pa.table({"text": ["document about lance database"]}), sys.argv[1] +) +dataset.create_scalar_index( + "text", index_type="INVERTED", **json.loads(sys.argv[2]) +) +expected_format_version = json.loads(sys.argv[3]) +if expected_format_version is not None: + actual_format_version = dataset.describe_indices()[0].segments[0].index_version + assert actual_format_version == expected_format_version +""" + env = os.environ.copy() + if env_value is None: + env.pop("LANCE_FTS_FORMAT_VERSION", None) + else: + env["LANCE_FTS_FORMAT_VERSION"] = env_value + return subprocess.run( + [ + sys.executable, + "-c", + script, + str(tmp_path), + json.dumps(creation_options or {}), + json.dumps(expected_format_version), + ], + capture_output=True, + env=env, + text=True, + ) + + +@pytest.mark.parametrize( + ("env_value", "creation_options", "expected_format_version"), + [ + ("1", {}, 1), + ("2", {}, 2), + ("3", {}, 3), + ("3", {"block_size": 256}, 3), + ], +) +def test_create_inverted_index_uses_env_format_version( + tmp_path, env_value, creation_options, expected_format_version +): + result = _run_fts_format_creation_probe( + tmp_path, + env_value, + creation_options, + expected_format_version, + ) + + assert result.returncode == 0, result.stderr + + +def test_create_inverted_index_explicit_format_version_overrides_env(tmp_path): + result = _run_fts_format_creation_probe( + tmp_path, + "invalid", + {"format_version": 1}, + 1, + ) + + assert result.returncode == 0, result.stderr + + +def test_create_text_inverted_index_defaults_to_v2_without_env(tmp_path): + result = _run_fts_format_creation_probe(tmp_path, None, expected_format_version=2) + + assert result.returncode == 0, result.stderr + + +def test_create_inverted_index_rejects_invalid_env_format_version(tmp_path): + result = _run_fts_format_creation_probe(tmp_path, "invalid") + + assert result.returncode != 0 + assert "LANCE_FTS_FORMAT_VERSION" in result.stderr + assert "invalid" in result.stderr + + +def test_create_inverted_index_rejects_invalid_format_version(tmp_path): + data = pa.table({"text": ["document about lance database"]}) + ds = lance.write_dataset(data, tmp_path) + + with pytest.raises(ValueError, match="unsupported FTS format version"): + ds.create_scalar_index("text", index_type="INVERTED", format_version="v5") + + with pytest.raises(ValueError, match="unsupported FTS format version"): + ds.create_scalar_index("text", index_type="INVERTED", format_version="v4") + + +def test_vector_filter_fts_search(tmp_path): + # Create test data + ids = list(range(1, 301)) + vectors = [[float(i)] * 4 for i in ids] + + # Create text data: + # "text " for ids 1-255, 299, 300, + # "noop " for 256-298, + texts = [] + for i in ids: + if i <= 255: + texts.append(f"text {i}") + elif i <= 298: + texts.append(f"noop {i}") + else: + texts.append(f"text {i}") + + categories = [] + for i in ids: + if i % 3 == 1: + categories.append("literature") + elif i % 3 == 2: + categories.append("science") + else: + categories.append("geography") + + table = pa.table( + { + "id": ids, + "vector": pa.array(vectors, type=pa.list_(pa.float32(), 4)), + "text": texts, + "category": categories, + } + ) + + # Write dataset and create indices + ds = lance.write_dataset(table, tmp_path) + + ds = ds.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=2, + num_sub_vectors=4, + ) + ds.create_scalar_index("text", index_type="INVERTED", with_position=True) + + # Create vector_query + vector_query = { + "column": "vector", + "q": np.array([300, 300, 300, 300], dtype=np.float32), + "k": 5, + "minimum_nprobes": 20, + "use_index": True, + } + + # Case 1: search with prefilter=true, query_filter=vector([300,300,300,300]) + scanner = ds.scanner( + prefilter=False, nearest=vector_query, filter=MatchQuery("text", "text") + ) + result = scanner.to_table() + # The approximate IVF_PQ index can return the two nearest "text" matches + # (299 and 300) in either order, so assert the set of ids, not the order. + assert sorted(result["id"].to_pylist()) == [299, 300] + + # Case 2: search with prefilter=true, search_filter=match("text"), + # filter="category='geography'" + scanner = ds.scanner( + prefilter=True, + nearest=vector_query, + filter={ + "expr_filter": "category='geography'", + "search_filter": MatchQuery("text", "text"), + }, + ) + result = scanner.to_table() + assert [300, 255, 252, 249, 246] == result["id"].to_pylist() + + # Case 3: search with prefilter=false, search_filter=match("text") + scanner = ds.scanner( + prefilter=False, + nearest=vector_query, + filter=MatchQuery("text", "text"), + ) + result = scanner.to_table() + # The approximate IVF_PQ index can return the two nearest "text" matches + # (299 and 300) in either order, so assert the set of ids, not the order. + assert sorted(result["id"].to_pylist()) == [299, 300] + + # Case 4: search with prefilter=false, search_filter=match("text"), + # filter="category='geography'" + scanner = ds.scanner( + prefilter=False, + nearest=vector_query, + filter={ + "expr_filter": "category='geography'", + "search_filter": MatchQuery("text", "text"), + }, + ) + result = scanner.to_table() + assert [300] == result["id"].to_pylist() + + # Case 5: search with prefilter=false, search_filter=phrase("text") + scanner = ds.scanner( + prefilter=False, + nearest=vector_query, + filter=PhraseQuery("text", "text"), + ) + with pytest.raises(ValueError): + scanner.to_table() + + # Case 6: search with prefilter=false, search_filter=phrase("text") + scanner = ds.scanner( + prefilter=False, + nearest=vector_query, + filter={ + "expr_filter": "category='geography'", + "search_filter": PhraseQuery("text", "text"), + }, + ) + with pytest.raises(ValueError): + scanner.to_table() + + +@pytest.mark.parametrize("index_type", ["BTREE", "BITMAP", "ZONEMAP"]) +def test_large_string_scalar_index(tmp_path, index_type): + """large_string (LargeUtf8) must be accepted by BTREE, BITMAP, and ZONEMAP.""" + table = pa.table( + { + "id": pa.array([1, 2, 3], pa.int32()), + "category": pa.array(["alpha", "beta", "alpha"], pa.large_string()), + } + ) + ds = lance.write_dataset(table, tmp_path) + assert ds.schema.field("category").type == pa.large_string() + + # Must not raise TypeError + ds.create_scalar_index("category", index_type=index_type) + + indices = ds.describe_indices() + assert any("category" in idx.field_names for idx in indices), ( + f"{index_type} index for large_string column not found in describe_indices()" + ) + + result = ds.scanner(filter="category = 'alpha'").to_table() + assert result.num_rows == 2 + assert set(result.column("id").to_pylist()) == {1, 3} diff --git a/python/python/tests/test_schema.py b/python/python/tests/test_schema.py index fcff283ebe2..67ba402a116 100644 --- a/python/python/tests/test_schema.py +++ b/python/python/tests/test_schema.py @@ -3,11 +3,28 @@ import pickle from pathlib import Path +from typing import TYPE_CHECKING, Optional import lance import pyarrow as pa +import pytest # pyright: ignore[reportMissingImports] from lance.schema import LanceSchema +if TYPE_CHECKING: + from typing import assert_type + + from lance.lance.schema import LanceField + + def _check_field_lookup_types(schema: LanceSchema) -> None: + """Static-only guard: both lookups return an optional field. + + ``LanceField`` exists only as a stub type -- ``lance.lance`` is a + compiled extension that re-exports ``LanceSchema`` alone -- so these + assertions cannot run, but pyright checks them. + """ + assert_type(schema.field("x"), Optional[LanceField]) + assert_type(schema.field_case_insensitive("x"), Optional[LanceField]) + def test_lance_schema(tmp_path: Path): # Include nested fields to test the reconstruction of the schema @@ -54,9 +71,61 @@ def test_lance_schema(tmp_path: Path): assert l_children[0].id() == 5 # Changing column name does not change the id - dataset.alter_columns({"path": "s.a", "name": "new_name"}) + # alter_columns is variadic, but its parameter is annotated + # Iterable[AlterColumn] rather than AlterColumn, so a single alteration + # does not type check. Unrelated to this file; suppressed rather than + # fixed here to keep the change focused. + dataset.alter_columns( + {"path": "s.a", "name": "new_name"} # pyright: ignore[reportArgumentType] + ) schema = dataset.lance_schema fields = schema.fields() s_fields = fields[1].children() assert s_fields[0].name() == "new_name" assert s_fields[0].id() == 2 + + +def test_lance_schema_from_protos_rejects_missing_parent(): + # name (field 2): child; id (field 3): 7; parent_id (field 4): 42; + # logical_type (field 5): int32. + field_proto = b"\x12\x05child\x18\x07\x20\x2a\x2a\x05int32" + + with pytest.raises( + ValueError, + match="Field 'child' \\(id=7\\) references parent id 42", + ): + LanceSchema._from_protos("{}", field_proto) + + +def test_lance_schema_field_lookup(tmp_path: Path): + dataset = lance.write_dataset( + pa.table({"x": range(2), "s": [{"a": 1}, {"a": 2}]}), tmp_path + ) + schema = dataset.lance_schema + + field = schema.field("x") + assert field is not None + assert field.name() == "x" + + # Dotted paths address nested fields; a miss returns None rather than + # raising, which is what the Optional return type encodes. + nested = schema.field("s.a") + assert nested is not None + assert nested.name() == "a" + assert schema.field("does_not_exist") is None + + +def test_lance_schema_field_case_insensitive(tmp_path: Path): + dataset = lance.write_dataset(pa.table({"MixedCase": range(2)}), tmp_path) + schema = dataset.lance_schema + + exact = schema.field_case_insensitive("MixedCase") + assert exact is not None + assert exact.name() == "MixedCase" + + # Falls back to a case-insensitive match, preserving the original casing. + relaxed = schema.field_case_insensitive("mixedcase") + assert relaxed is not None + assert relaxed.name() == "MixedCase" + + assert schema.field_case_insensitive("does_not_exist") is None diff --git a/python/python/tests/test_schema_evolution.py b/python/python/tests/test_schema_evolution.py index 6560d8c7e7d..abd89e0cded 100644 --- a/python/python/tests/test_schema_evolution.py +++ b/python/python/tests/test_schema_evolution.py @@ -6,6 +6,8 @@ from pathlib import Path import lance +import lance.dependencies as dependencies +import lance.udf as udf_module import numpy as np import pandas as pd import pyarrow as pa @@ -13,6 +15,7 @@ import pytest from lance import LanceDataset from lance.file import LanceFileReader, LanceFileWriter +from lance.fragment import write_fragments def test_drop_columns(tmp_path: Path): @@ -37,12 +40,12 @@ def test_drop_columns(tmp_path: Path): "c": pa.int64(), } ) - assert len(dataset.list_indices()) == 1 + assert len(dataset.describe_indices()) == 1 # Drop vector column, index is dropped dataset.drop_columns(["a"]) assert dataset.schema == pa.schema({"c": pa.int64()}) - assert len(dataset.list_indices()) == 0 + assert len(dataset.describe_indices()) == 0 # Can't drop all columns with pytest.raises(ValueError): @@ -287,6 +290,37 @@ def mapper(batch: pa.RecordBatch): check_add_columns(dataset, expected, use_fragments, mapper) +@pytest.mark.parametrize("use_batch_udf", [False, True]) +def test_add_columns_arrow_udf_without_pandas_dependency( + tmp_path: Path, monkeypatch, use_batch_udf +): + table = pa.table({"caption": ["a Shutterstock photo", "clean"]}) + dataset = lance.write_dataset(table, tmp_path) + + def mapper(batch: pa.RecordBatch) -> pa.RecordBatch: + flags = pc.match_substring_regex( + pc.utf8_lower(batch["caption"]), "shutterstock" + ) + return pa.record_batch([flags], names=["wm"]) + + if use_batch_udf: + mapper = lance.batch_udf()(mapper) + + # CI has pandas installed, so simulate Lance's no-pandas lazy proxy state. + # Without the guard, accessing pd.DataFrame raises ModuleNotFoundError. + monkeypatch.setattr(dependencies, "_PANDAS_AVAILABLE", False) + monkeypatch.setattr( + udf_module, + "pd", + dependencies._LazyModule("pandas", module_available=False), + ) + + dataset.add_columns(mapper, read_columns=["caption"], batch_size=64) + + expected = table.append_column("wm", pa.array([True, False])) + assert dataset.to_table() == expected + + def test_query_after_merge(tmp_path): # https://github.com/lancedb/lance/issues/1905 tab = pa.table( @@ -540,3 +574,56 @@ def test_add_cols_all_null_with_sql(tmp_path: Path): "b": pa.int32(), } ) + + +def test_merge_nullability_assertion(tmp_path: Path): + tbl = pa.table({"value": pa.array([1, 2], pa.int32())}) + lance.write_dataset(tbl, tmp_path) + written_at = lance.dataset(tmp_path).version + + # Stage an append against the original schema, then add a non-nullable + # column. The merge claims non-null, and reading it back must preserve + # the claim, or recommitting it would silently drop the barrier. + fragments = write_fragments( + pa.table({"value": pa.array([7], pa.int32())}), tmp_path, mode="append" + ) + lance.dataset(tmp_path).add_columns({"one": "1"}) + txn = lance.dataset(tmp_path).read_transaction(2) + assert txn is not None + assert txn.operation.preserves_nullability is False + + # The stale append omits the required column, so its rows would read as + # null under the merged schema; the claim refuses it. + op = lance.LanceOperation.Append(fragments) + with pytest.raises(Exception, match="preempted"): + lance.LanceDataset.commit(tmp_path, op, read_version=written_at) + + # A nullable add preserves nullability and skips the barrier. + lance.dataset(tmp_path).add_columns({"copied": "value"}) + txn = lance.dataset(tmp_path).read_transaction(3) + assert txn is not None + assert txn.operation.preserves_nullability is True + + +def test_project_nullability_assertion_round_trips(tmp_path: Path): + tbl = pa.table({"value": pa.array([1, 2], pa.int32())}) + lance.write_dataset(tbl, tmp_path) + lance.dataset(tmp_path).alter_columns({"path": "value", "nullable": False}) + + # Reading the tightening back must preserve the claim, or recommitting it + # would silently drop the concurrency barrier. + txn = lance.dataset(tmp_path).read_transaction(2) + assert txn is not None + assert txn.operation.preserves_nullability is False + + # A Python-built non-assertion must reach the barrier: race an append. + written_at = lance.dataset(tmp_path).version + appended = lance.write_dataset( + pa.table({"value": pa.array([7], pa.int32())}), tmp_path, mode="append" + ) + assert appended.version > written_at + relax = lance.LanceOperation.Project( + schema=txn.operation.schema, preserves_nullability=False + ) + with pytest.raises(Exception, match="preempted"): + lance.LanceDataset.commit(tmp_path, relax, read_version=written_at) diff --git a/python/python/tests/test_session.py b/python/python/tests/test_session.py index 18ffb6df7fa..05b8a637628 100644 --- a/python/python/tests/test_session.py +++ b/python/python/tests/test_session.py @@ -5,6 +5,7 @@ import lance import pyarrow as pa +import pytest def test_cache_size_bytes( @@ -37,3 +38,86 @@ def test_share_session(tmp_path: Path): assert ds1.session().size_bytes() == ds2.session().size_bytes() assert ds1.to_table() == ds2.to_table() + + +def test_fragment_write_with_session(tmp_path: Path): + from lance.fragment import LanceFragment, write_fragments + + data = pa.table({"a": range(10), "b": [str(i) for i in range(10)]}) + ds = lance.write_dataset(data, tmp_path) + # Drop a column so the surviving field id is non-trivial (!= 0). Appends + # that infer the schema must pick up this field id from the dataset. + ds.drop_columns(["a"]) + field_id = ds.lance_schema.field_case_insensitive("b").id() + assert field_id != 0 + + session = ds.session() + size_before = session.size_bytes() + + append_data = pa.table({"b": ["x", "y"]}) + fragments = write_fragments( + append_data, str(tmp_path), mode="append", session=session + ) + assert len(fragments) == 1 + assert fragments[0].files[0].fields == [field_id] + + fragment = LanceFragment.create( + str(tmp_path), append_data, mode="append", session=session + ) + assert fragment.files[0].fields == [field_id] + + # The manifest loads for schema inference went through the shared session. + assert session.size_bytes() > size_before + + # A LanceDataset destination always uses its own session; a different + # explicit session is rejected. + with pytest.raises(ValueError, match="not the destination dataset's own session"): + write_fragments(append_data, ds, mode="append", session=lance.Session()) + + +def test_cache_backend_uri_config(): + session = lance.Session(index_cache_backend="moka://?capacity=1048576") + + assert session.index_cache_size_bytes() == 0 + + +def test_cache_backend_dict_config(): + session = lance.Session( + index_cache_backend={ + "kind": "MOKA", + "options": {"capacity": "1048576"}, + }, + ) + + assert session.index_cache_size_bytes() == 0 + + +def test_cache_backend_rejects_size_and_backend(): + with pytest.raises( + ValueError, + match="index_cache_size_bytes and index_cache_backend are mutually exclusive", + ): + lance.Session( + index_cache_size_bytes=1024, + index_cache_backend="moka://?capacity=1048576", + ) + + +def test_cache_backend_rejects_unknown_dict_key(): + with pytest.raises(ValueError, match="unknown dict key"): + lance.Session( + index_cache_backend={ + "kind": "moka", + "capacity": "1048576", + }, + ) + + +def test_cache_backend_rejects_moka_without_capacity(): + with pytest.raises(ValueError, match="capacity is required"): + lance.Session(index_cache_backend="moka://") + + +def test_cache_backend_rejects_moka_empty_capacity(): + with pytest.raises(ValueError, match="capacity must not be empty"): + lance.Session(index_cache_backend="moka://?capacity=") diff --git a/python/python/tests/test_table_ops.py b/python/python/tests/test_table_ops.py index 4e61feff6d0..24cf3409f91 100644 --- a/python/python/tests/test_table_ops.py +++ b/python/python/tests/test_table_ops.py @@ -106,3 +106,149 @@ 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) + + +def test_commit_conflict_raises_typed_error(tmp_path: str): + """A losing commit should raise CommitConflictError, not a bare OSError.""" + from lance.commit import CommitConflictError + + table = pa.table({"a": range(100)}) + ds = lance.write_dataset(table, tmp_path) + + # Two commits based on the same version; the second must conflict. + ds2 = lance.dataset(tmp_path) + ds3 = lance.dataset(tmp_path) + + new_data_file = make_data_file(ds, [0], pa.table({"a": range(100, 200)})) + ds2.commit( + ds2.uri, + lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(0, new_data_file)] + ), + read_version=ds2.version, + ) + + new_data_file = make_data_file(ds, [0], pa.table({"a": range(200, 300)})) + with pytest.raises(CommitConflictError) as exc_info: + ds3.commit( + ds3.uri, + lance.LanceOperation.DataReplacement( + [lance.LanceOperation.DataReplacementGroup(0, new_data_file)] + ), + read_version=ds3.version, + ) + + # It must be a CommitConflictError, still catchable as OSError, and retryable. + assert isinstance(exc_info.value, OSError) + assert exc_info.value.retryable is True + + +def test_incompatible_transaction_raises_non_retryable(tmp_path: str): + """An incompatible transaction should raise a non-retryable CommitConflictError.""" + from lance.commit import CommitConflictError + + table = pa.table({"a": [1]}) + uri = tmp_path + base = lance.write_dataset(table, uri) + fragment = lance.fragment.LanceFragment.create(uri, table) + stale_append = lance.LanceOperation.Append([fragment]) + + # Overwrite the table, then try to append based on the stale version. + lance.write_dataset(table, uri, mode="overwrite") + with pytest.raises(CommitConflictError) as exc_info: + lance.LanceDataset.commit(uri, stale_append, read_version=base.version) + + assert isinstance(exc_info.value, OSError) + assert exc_info.value.retryable is False diff --git a/python/python/tests/test_table_provider.py b/python/python/tests/test_table_provider.py index d4d35556e32..2252f12aa28 100644 --- a/python/python/tests/test_table_provider.py +++ b/python/python/tests/test_table_provider.py @@ -7,7 +7,7 @@ import numpy as np import pandas as pd import pyarrow as pa -from datafusion import SessionContext, col +import pytest from lance import FFILanceTableProvider, LanceDataset @@ -19,6 +19,9 @@ def normalize(batches: list[pa.RecordBatch]) -> pa.RecordBatch: def test_table_loading(): + pytest.importorskip("datafusion") + from datafusion import SessionContext, col + lancedb_temp_path = "/tmp/test.lance" shutil.rmtree(lancedb_temp_path, ignore_errors=True) @@ -57,7 +60,8 @@ def make_ctx(): ctx.register_table("ffi_lance_table", ffi_lance_table) return ctx - result = normalize(make_ctx().table("ffi_lance_table").collect()) + ctx = make_ctx() + result = normalize(ctx.table("ffi_lance_table").collect()) assert len(result) == 1000000 assert result.num_columns == 5 @@ -74,15 +78,44 @@ def make_ctx(): pd.testing.assert_frame_equal(result.to_pandas(), expected) - result = normalize( - make_ctx().table("ffi_lance_table").filter(col("col1") == 4).collect() - ) + ctx = make_ctx() + result = normalize(ctx.table("ffi_lance_table").filter(col("col1") == 4).collect()) assert len(result) == 1 - result = normalize(make_ctx().table("ffi_lance_table").limit(1).collect()) + ctx = make_ctx() + result = normalize(ctx.table("ffi_lance_table").limit(1).collect()) assert len(result) == 1 assert result["col1"][0].as_py() == 0 - result = normalize(make_ctx().table("ffi_lance_table").limit(1, offset=1).collect()) + ctx = make_ctx() + result = normalize(ctx.table("ffi_lance_table").limit(1, offset=1).collect()) assert len(result) == 1 assert result["col1"][0].as_py() == 1 + + +def test_custom_udf_filter(tmp_path): + pytest.importorskip("datafusion") + from datafusion import SessionContext, udf + + def is_even(values: pa.Array) -> pa.Array: + return pa.array([value.as_py() % 2 == 0 for value in values], type=pa.bool_()) + + is_even_udf = udf( + is_even, + input_fields=[pa.int64()], + return_field=pa.bool_(), + volatility="stable", + name="is_even", + ) + + dataset = lance.write_dataset(pa.table({"i": [1, 2, 3, 4]}), str(tmp_path)) + provider = FFILanceTableProvider(dataset, with_row_id=True, with_row_addr=True) + + ctx = SessionContext() + ctx.register_table("numbers", provider) + ctx.register_udf(is_even_udf) + + result = normalize( + ctx.sql("SELECT i FROM numbers WHERE i = 2 AND is_even(i)").collect() + ) + assert result["i"].to_pylist() == [2] diff --git a/python/python/tests/test_tf.py b/python/python/tests/test_tf.py deleted file mode 100644 index 432be52b482..00000000000 --- a/python/python/tests/test_tf.py +++ /dev/null @@ -1,351 +0,0 @@ -# SPDX-License-Identifier: Apache-2.0 -# SPDX-FileCopyrightText: Copyright The Lance Authors - -import os -import warnings - -import lance -import numpy as np -import pandas as pd -import pyarrow as pa -import pytest -from lance.arrow import ImageArray -from lance.fragment import LanceFragment - -pytest.skip("Skip tensorflow tests", allow_module_level=True) - -try: - with warnings.catch_warnings(): - warnings.filterwarnings("ignore", category=DeprecationWarning) - import tensorflow as tf # noqa: F401 -except ImportError: - pytest.skip( - "Tensorflow is not installed. Please install tensorflow to " - + "test lance.tf module.", - allow_module_level=True, - ) - -from lance.tf.data import ( # noqa: E402 - from_lance, - from_lance_batches, - lance_fragments, - lance_take_batches, -) - - -@pytest.fixture -def tf_dataset(tmp_path): - df = pd.DataFrame( - { - "a": range(10000), - "s": [f"val-{i}" for i in range(10000)], - "vec": [[i * 0.2] * 128 for i in range(10000)], - } - ) - - schema = pa.schema( - [ - pa.field("a", pa.int64()), - pa.field("s", pa.string()), - pa.field("vec", pa.list_(pa.float32(), 128)), - ] - ) - tbl = pa.Table.from_pandas(df, schema=schema) - uri = tmp_path / "dataset.lance" - lance.write_dataset( - tbl, - uri, - schema=tbl.schema, - max_rows_per_group=100, - max_rows_per_file=1000, - ) - return uri - - -def test_fragment_dataset(tf_dataset): - ds = from_lance(tf_dataset, batch_size=100) - for idx, batch in enumerate(ds): - assert batch["a"].numpy()[0] == idx * 100 - assert batch["s"].numpy()[0] == f"val-{idx * 100}".encode("utf-8") - assert batch["a"].shape == (100,) - assert batch["vec"].shape == ( - 100, - 128, - ) # Fixed size list - - -def test_projection(tf_dataset): - ds = from_lance(tf_dataset, batch_size=100, columns=["a"]) - - for idx, batch in enumerate(ds): - assert list(batch.keys()) == ["a"] - assert batch["a"].numpy()[0] == idx * 100 - assert batch["a"].shape == (100,) - - -def test_filter(tf_dataset): - ds = from_lance(tf_dataset, batch_size=100, filter="a >= 5000") - - for idx, batch in enumerate(ds): - assert batch["a"].numpy()[0] == idx * 100 + 5000 - assert batch["a"].shape == (100,) - - -def test_namespace_table_id(monkeypatch): - calls = {} - - class DummyScanner: - def __init__(self): - self._batch = pa.record_batch([pa.array([1, 2])], names=["a"]) - self.projected_schema = self._batch.schema - - def to_batches(self): - yield self._batch - - class DummyDataset: - def scanner(self, **kwargs): - return DummyScanner() - - def fake_dataset(uri=None, **kwargs): - calls["uri"] = uri - calls["kwargs"] = kwargs - return DummyDataset() - - monkeypatch.setattr(lance, "dataset", fake_dataset) - - ns = object() - ds = from_lance( - None, - namespace=ns, - table_id=["tbl"], - ignore_namespace_table_storage_options=True, - ) - - assert calls["kwargs"]["namespace"] is ns - assert calls["kwargs"]["table_id"] == ["tbl"] - assert calls["kwargs"]["ignore_namespace_table_storage_options"] is True - - batches = list(ds) - assert [b["a"].numpy().tolist() for b in batches] == [[1, 2]] - - -def test_scan_use_tf_data(tf_dataset): - ds = tf.data.Dataset.from_lance(tf_dataset) - for idx, batch in enumerate(ds): - assert batch["a"].numpy()[0] == idx * 100 - assert batch["s"].numpy()[0] == f"val-{idx * 100}".encode("utf-8") - assert batch["a"].shape == (100,) - assert batch["vec"].shape == ( - 100, - 128, - ) # Fixed size list - - -def test_pass_fragments(tf_dataset): - # Can pass fragments directly to from_lance - dataset = lance.dataset(tf_dataset) - ds = from_lance(tf_dataset, fragments=dataset.get_fragments(), batch_size=100) - ds_default = from_lance(tf_dataset, batch_size=100) - for batch, batch_default in zip(ds, ds_default): - assert batch["a"].numpy()[0] == batch_default["a"].numpy()[0] - assert batch["a"].numpy().shape == (100,) - assert batch["vec"].shape == ( - 100, - 128, - ) - - # Can pass ids directly to from_lance - ds = from_lance(tf_dataset, fragments=[0, 1, 2], batch_size=100) - for idx, batch in enumerate(ds): - assert batch["a"].numpy()[0] == idx * 100 - assert batch["a"].numpy().shape == (100,) - - -def test_shuffle(tf_dataset): - fragments = lance_fragments(tf_dataset).shuffle(4, seed=20).take(3) - - ds = from_lance(tf_dataset, fragments=fragments, batch_size=100) - raw_ds = lance.dataset(tf_dataset) - scanner = raw_ds.scanner( - fragments=[LanceFragment(raw_ds, fid) for fid in [0, 3, 1]], batch_size=100 - ) - - for batch, raw_batch in zip(ds, scanner.to_batches()): - assert batch["a"].numpy()[0] == raw_batch.to_pydict()["a"][0] - assert batch["a"].numpy().shape == (100,) - assert batch["vec"].shape == ( - 100, - 128, - ) # Fixed size list - - -def test_dataset_batches(tf_dataset): - tf_dataset = lance.dataset(tf_dataset) - batch_size = 300 - batches = list( - from_lance_batches(tf_dataset, batch_size=batch_size).as_numpy_iterator() - ) - assert tf_dataset.count_rows() // batch_size + 1 == len(batches) - assert all(end - start == batch_size for start, end in batches[:-2]) - assert batches[-1][1] - batches[-1][0] == tf_dataset.count_rows() % batch_size - - skip = 5 - batches_skipped = list( - from_lance_batches( - tf_dataset, batch_size=batch_size, skip=skip - ).as_numpy_iterator() - ) - assert batches_skipped == batches[skip:] - - batches_shuffled = list( - from_lance_batches( - tf_dataset, batch_size=batch_size, shuffle=True, seed=42 - ).as_numpy_iterator() - ) - # make sure it does a shuffle - assert batches_shuffled != batches - batches_shuffled2 = list( - from_lance_batches( - tf_dataset, batch_size=batch_size, shuffle=True, seed=42 - ).as_numpy_iterator() - ) - # make sure the shuffle can be deterministic - assert batches_shuffled == batches_shuffled2 - - -def test_take_dataset(tf_dataset): - tf_dataset = lance.dataset(tf_dataset) - batch_ds = from_lance_batches( - tf_dataset, batch_size=100, shuffle=True, seed=42 - ).as_numpy_iterator() - lance_ds = lance_take_batches(tf_dataset, batch_ds) - lance_ds = lance_ds.unbatch().shuffle(400, seed=42).batch(100) - - for batch in lance_ds: - assert batch["a"].numpy().shape == (100,) - - batches = [(0, 200), (100, 200)] - lance_ds = lance_take_batches(tf_dataset, batches, columns=["a"]) - for (start, end), batch in zip(batches, lance_ds): - assert batch["a"].numpy().tolist() == np.arange(start, end).tolist() - assert batch.keys() == {"a"} - - -def test_var_length_list(tmp_path): - """Treat var length list as RaggedTensor.""" - df = pd.DataFrame( - { - "a": range(200), - "l": [[i] * (i % 5 + 1) for i in range(200)], - } - ) - - schema = pa.schema( - [ - pa.field("a", pa.int64()), - pa.field("l", pa.list_(pa.int32())), - ] - ) - tbl = pa.Table.from_pandas(df, schema=schema) - - uri = tmp_path / "dataset.lance" - lance.write_dataset( - tbl, - uri, - schema=tbl.schema, - ) - - output_signature = { - "a": tf.TensorSpec(shape=(None,), dtype=tf.int64), - "l": tf.RaggedTensorSpec(dtype=tf.dtypes.int32, shape=(8, None), ragged_rank=1), - } - - ds = tf.data.Dataset.from_lance( - uri, - batch_size=8, - output_signature=output_signature, - ) - for idx, batch in enumerate(ds): - assert batch["a"].numpy()[0] == idx * 8 - assert batch["l"].shape == (8, None) - assert isinstance(batch["l"], tf.RaggedTensor) - - -def test_nested_struct(tmp_path): - table = pa.table( - { - "x": pa.array( - [ - { - "a": 1, - "json": {"b": "hello", "x": b"abc"}, - }, - { - "a": 24, - "json": {"b": "world", "x": b"def"}, - }, - ] - ) - } - ) - uri = tmp_path / "dataset.lance" - dataset = lance.write_dataset(table, uri) - - ds = tf.data.Dataset.from_lance( - dataset, - batch_size=8, - ) - - for batch in ds: - tf.debugging.assert_equal(batch["x"]["a"], tf.constant([1, 24], dtype=tf.int64)) - tf.debugging.assert_equal( - batch["x"]["json"]["b"], tf.constant(["hello", "world"]) - ) - tf.debugging.assert_equal( - batch["x"]["json"]["x"], tf.constant([b"abc", b"def"]) - ) - - -def test_tensor(tmp_path): - arr = np.array([[[1, 2, 3], [4, 5, 6]], [[1, 2, 3], [4, 5, 6]]], dtype=np.float32) - table = pa.table({"x": pa.FixedShapeTensorArray.from_numpy_ndarray(arr)}) - - uri = tmp_path / "dataset.lance" - dataset = lance.write_dataset(table, uri) - ds = tf.data.Dataset.from_lance(dataset) - - for batch in ds: - assert batch["x"].shape == (2, 2, 3) - assert batch["x"].dtype == tf.float32 - assert batch["x"].numpy().tolist() == arr.tolist() - - -def test_image_types(tmp_path): - path = [os.path.join(os.path.dirname(__file__), "images/1.png")] - uris = ImageArray.from_array(path * 3) - encoded_images = uris.read_uris() - tensors = encoded_images.to_tensor() - table = pa.table( - { - "uris": uris, - "encoded_images": encoded_images, - "tensor_images": tensors, - } - ) - - uri = tmp_path / "dataset.lance" - dataset = lance.write_dataset(table, uri) - ds = tf.data.Dataset.from_lance(dataset) - - for batch in ds: - assert batch["uris"].shape == (3,) - assert batch["uris"].dtype == tf.string - assert batch["uris"].numpy().astype("str").tolist() == uris.tolist() - - assert batch["encoded_images"].shape == (3,) - assert batch["encoded_images"].dtype == tf.string - assert batch["encoded_images"].numpy().tolist() == encoded_images.tolist() - - assert batch["tensor_images"].shape == (3, 1, 1, 4) - assert batch["tensor_images"].dtype == tf.uint8 - assert batch["tensor_images"].numpy().tolist() == tensors.to_numpy().tolist() diff --git a/python/python/tests/test_udf.py b/python/python/tests/test_udf.py new file mode 100644 index 00000000000..4ba53bc862e --- /dev/null +++ b/python/python/tests/test_udf.py @@ -0,0 +1,63 @@ +# SPDX-License-Identifier: Apache-2.0 +# SPDX-FileCopyrightText: Copyright The Lance Authors + +"""Tests for the ``BatchUDF`` public contract. + +This module is in the pyright target configured in ``pyproject.toml``, so it +doubles as a strict client for the signatures: the annotated locals below fail +the repository type check if a parameter or return type is narrowed or widened +incorrectly -- narrowing ``checkpoint_file`` back to ``str``, for example, +reports + + error: Argument of type "Path" cannot be assigned to parameter + "checkpoint_file" of type "str | None" + +It cannot catch the annotations being *deleted*, because pyright infers the +same types from the function bodies. That failure mode is mypy-specific +(``no-untyped-call``); policing it would take +``reportMissingParameterType`` on ``lance/udf.py``, which needs one more +parameter annotated and a pre-existing narrowing diagnostic resolved. +""" + +from pathlib import Path + +import pyarrow as pa +from lance.udf import BatchUDF, batch_udf + + +def _add_doubled(batch: pa.RecordBatch) -> pa.RecordBatch: + doubled = [value * 2 for value in batch.column("a").to_pylist()] + return pa.RecordBatch.from_pydict({"doubled": doubled}) + + +def test_batch_udf_constructor(tmp_path: Path) -> None: + output_schema = pa.schema([pa.field("doubled", pa.int64())]) + + udf: BatchUDF = BatchUDF(_add_doubled, output_schema=output_schema) + assert udf.output_schema == output_schema + assert udf.cache is None + + # checkpoint_file accepts a Path as well as a str. + checkpointed: BatchUDF = BatchUDF( + _add_doubled, + output_schema=output_schema, + checkpoint_file=tmp_path / "checkpoint.sqlite", + ) + assert checkpointed.cache is not None + + +def test_batch_udf_decorator() -> None: + output_schema = pa.schema([pa.field("doubled", pa.int64())]) + + @batch_udf(output_schema=output_schema) + def doubled(batch: pa.RecordBatch) -> pa.RecordBatch: + return _add_doubled(batch) + + # The decorator returns a BatchUDF, not the original function. + udf: BatchUDF = doubled + assert udf.output_schema == output_schema + + # Calling it delegates straight to the wrapped function, so a UDF stays + # testable on its own. + result = udf(pa.RecordBatch.from_pydict({"a": [1, 2, 3]})) + assert result.column("doubled").to_pylist() == [2, 4, 6] diff --git a/python/python/tests/test_vector.py b/python/python/tests/test_vector.py index c02c8312f88..8dd42c9c253 100644 --- a/python/python/tests/test_vector.py +++ b/python/python/tests/test_vector.py @@ -5,7 +5,556 @@ import numpy as np import pyarrow as pa import pytest -from lance.vector import vec_to_table +from lance.vector import ( + find_duplicate_pairs, + find_duplicate_pairs_in_partition, + get_ivf_partition_info, + hamming_clustering_for_ivf_partition, + hamming_clustering_for_sample, + vec_to_table, +) + + +@pytest.mark.parametrize("metric", ["l2", "cosine", "dot"]) +@pytest.mark.parametrize("index_type", ["IVF_FLAT", "IVF_HNSW_FLAT"]) +def test_duplicate_pairs_flat(tmp_path, metric, index_type): + vectors = np.array([[1, 0], [1, 0], [0, 1], [2, 0]], dtype=np.float32) + table = pa.table({"id": range(4), "vector": pa.array(vectors.tolist())}) + table = table.set_column( + 1, "vector", pa.array(vectors.tolist(), pa.list_(pa.float32(), 2)) + ) + ds = lance.write_dataset(table, tmp_path, max_rows_per_file=2) + ds = ds.create_index( + "vector", + index_type, + metric=metric, + num_partitions=1, + ivf_centroids=np.array([[1, 0]], dtype=np.float32), + ) + ids = ds.to_table(columns=["id"], with_row_id=True).to_pylist() + positions = {r["_rowid"]: r["id"] for r in ids} + segment_id = ds.describe_indices()[0].segments[0].uuid + threshold = -0.5 if metric == "dot" else 0.5 + expected = {} + for i in range(4): + for j in range(i + 1, 4): + if metric == "l2": + d = np.sum((vectors[i] - vectors[j]) ** 2) + elif metric == "cosine": + d = 1 - np.dot(vectors[i], vectors[j]) / ( + np.linalg.norm(vectors[i]) * np.linalg.norm(vectors[j]) + ) + else: + d = 1 - np.dot(vectors[i], vectors[j]) + if d <= threshold: + expected[(i, j)] = float(d) + with find_duplicate_pairs(ds, "vector", threshold) as reader: + pairs = reader.read_all() + with find_duplicate_pairs_in_partition( + ds, "vector", segment_id, 0, threshold + ) as reader: + assert reader.read_all().equals(pairs) + with find_duplicate_pairs( + ds, "vector", threshold, max_concurrency=1, memory_limit=0 + ) as reader: + assert reader.read_all().equals(pairs) + actual = {} + for row in pairs.to_pylist(): + a, b = row["row_id_a"], row["row_id_b"] + key = tuple(sorted((positions[a], positions[b]))) + assert key not in actual + actual[key] = row["distance"] + assert actual == pytest.approx(expected) + # Exhaustive candidate recall, including exact threshold matches. + assert len(actual.keys() & expected.keys()) / len(expected) == 1.0 + ds.delete("id = 1") + with find_duplicate_pairs(ds, "vector", 100) as reader: + result = reader.read_all().to_pylist() + assert all(positions[r[k]] != 1 for r in result for k in ["row_id_a", "row_id_b"]) + assert len(result) == 3 + + +@pytest.mark.parametrize("metric", ["l2", "cosine", "dot"]) +@pytest.mark.parametrize( + "index_type,bits", + [ + ("IVF_PQ", 4), + ("IVF_PQ", 8), + ("IVF_SQ", 8), + ("IVF_HNSW_PQ", 4), + ("IVF_HNSW_SQ", 8), + ("IVF_RQ", 1), + ("IVF_RQ", 5), + ("IVF_RQ", 9), + ], +) +def test_duplicate_pairs_quantized(tmp_path, metric, index_type, bits): + rng = np.random.default_rng(7) + unique = rng.normal(size=(17, 8)).astype(np.float32) + vectors = np.repeat(unique, 2, axis=0) + table = pa.table({"vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 8))}) + ds = lance.write_dataset(table, tmp_path, max_rows_per_file=10) + params = {"num_bits": bits} + if "PQ" in index_type: + params.update( + num_sub_vectors=4, + pq_codebook=rng.normal(size=(4, 2**bits, 2)).astype(np.float32), + ) + ds = ds.create_index( + "vector", + index_type, + metric=metric, + num_partitions=1, + ivf_centroids=np.ones((1, 8), dtype=np.float32), + **params, + ) + with find_duplicate_pairs(ds, "vector", 1e6) as reader: + expected_table = reader.read_all() + # Distances and order do not depend on concurrency, spilling or the + # scoped API. + with find_duplicate_pairs( + ds, "vector", 1e6, max_concurrency=1, memory_limit=0 + ) as reader: + assert reader.read_all().equals(expected_table) + segment = ds.describe_indices()[0].segments[0].uuid + with find_duplicate_pairs_in_partition(ds, "vector", segment, 0, 1e6) as reader: + assert reader.read_all().equals(expected_table) + all_pairs = expected_table.to_pylist() + assert len(all_pairs) == len(vectors) * (len(vectors) - 1) // 2 + keys = {tuple(sorted((r["row_id_a"], r["row_id_b"]))) for r in all_pairs} + assert len(keys) == len(all_pairs) + assert all(np.isfinite(r["distance"]) for r in all_pairs) + if metric == "cosine": + assert all(0 <= r["distance"] <= 2 for r in all_pairs) + if metric != "dot": + row_ids = ds.to_table(columns=[], with_row_id=True)["_rowid"].to_pylist() + expected = {(row_ids[i], row_ids[i + 1]) for i in range(0, len(vectors), 2)} + with find_duplicate_pairs(ds, "vector", 1e-5) as reader: + result = reader.read_all().to_pylist() + found = {tuple(sorted((r["row_id_a"], r["row_id_b"]))) for r in result} + assert len(expected & found) / len(expected) >= 0.5 + + +def test_duplicate_pairs_validation_and_snapshot(tmp_path): + table = pa.table( + {"vector": pa.array([[1.0, 0.0], None, [1.0, 0.0]], pa.list_(pa.float32(), 2))} + ) + ds = lance.write_dataset(table, tmp_path) + with pytest.raises(ValueError, match="exactly one vector index"): + find_duplicate_pairs(ds, "vector", 0) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=1, + ivf_centroids=np.ones((1, 2), dtype=np.float32), + ) + segment = ds.describe_indices()[0].segments[0].uuid + for threshold in [float("nan"), float("inf"), -float("inf")]: + with pytest.raises(ValueError, match="must be finite"): + find_duplicate_pairs(ds, "vector", threshold) + with pytest.raises(ValueError, match="partition_id"): + find_duplicate_pairs_in_partition(ds, "vector", segment, 1, 0) + with pytest.raises(ValueError, match="segment_id"): + find_duplicate_pairs_in_partition(ds, "vector", "not-a-uuid", 0, 0) + with pytest.raises(ValueError, match="not an active segment"): + find_duplicate_pairs_in_partition( + ds, "vector", "00000000-0000-0000-0000-000000000000", 0, 0 + ) + reader = find_duplicate_pairs(ds, "vector", 0) + ds.delete("true") + assert reader.read_all().num_rows == 1 + reader.close() + with find_duplicate_pairs(ds, "vector", 0) as empty: + assert empty.schema.names == ["row_id_a", "row_id_b", "distance"] + assert empty.read_all().num_rows == 0 + current = lance.write_dataset(table, tmp_path, mode="append") + with pytest.raises(ValueError, match="does not cover all fragments"): + find_duplicate_pairs(current, "vector", 0) + + +@pytest.mark.parametrize("memory_limit", [0, 256 * 1024 * 1024]) +@pytest.mark.parametrize("max_concurrency", [1, 4]) +def test_duplicate_pairs_cross_batch_order(tmp_path, memory_limit, max_concurrency): + # One anchor has matches on both sides of the vector batch boundary. + n = 8193 + vectors = np.column_stack((np.arange(n), np.ones(n))).astype(np.float32) + vectors[-1] = vectors[0] + vectors[1] = vectors[0] + vectors[2] = vectors[0] + ds = lance.write_dataset( + pa.table( + { + "id": np.arange(n), + "vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 2)), + } + ), + tmp_path, + ) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=1, + ivf_centroids=np.ones((1, 2), dtype=np.float32), + ) + # Keep the physical index boundary while limiting the number of anchors + # scored by this order/spill test. Removed IDs must not appear in pairs. + ds.delete(f"id >= 3 AND id < {n - 1}") + with find_duplicate_pairs( + ds, + "vector", + 0, + memory_limit=memory_limit, + max_concurrency=max_concurrency, + ) as reader: + # Exhaust all surviving anchors, including the physical partition tail. + batches = list(reader) + # Tile order: the (batch 0, batch 0) tile, then (batch 0, batch 1), each + # row-major and emitted as one batch per job of up to 32 anchor rows. + assert [batch.num_rows for batch in batches] == [3, 3] + pairs = pa.Table.from_batches(batches).to_pylist() + assert [(p["row_id_a"], p["row_id_b"]) for p in pairs] == [ + (0, 1), + (0, 2), + (1, 2), + (0, n - 1), + (1, n - 1), + (2, n - 1), + ] + with find_duplicate_pairs(ds, "vector", 1e10) as reader: + assert next(reader).num_rows <= 32 * 8192 + # Closing a partially consumed reader cancels further enumeration. + assert ds.count_rows() == 4 + + +@pytest.mark.parametrize("memory_limit", [0, 256 * 1024 * 1024]) +def test_duplicate_pairs_parallel_scoring_preserves_order(tmp_path, memory_limit): + # 4096-d vectors cut the 8K batch maximum to 512 rows, so 1027 rows give + # three vector batches and every 32-row job exceeds the inline threshold. + # Keeping three rows per batch makes each of the six tiles a single cheap + # CPU-pool job, and with forced spill the next batch load has to wait for + # earlier jobs to release their decoded batches. + n, batch_rows, dim = 1027, 512, 4096 + positions = np.arange(n) + vectors = np.repeat((positions % 2)[:, None], dim, axis=1).astype(np.float32) + table = pa.table( + { + "id": positions, + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1)), dim + ), + } + ) + ds = lance.write_dataset(table, tmp_path, max_rows_per_file=300) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=1, + ivf_centroids=np.ones((1, dim), dtype=np.float32), + ) + kept = [b * batch_rows + r for b in range(n // batch_rows + 1) for r in range(3)] + ds.delete(f"id NOT IN ({', '.join(map(str, kept))})") + row_ids = { + r["id"]: r["_rowid"] + for r in ds.to_table(columns=["id"], with_row_id=True).to_pylist() + } + # Tile order: anchor batch, candidate batch, then row-major positions. + expected = sorted( + ((a, b) for i, a in enumerate(kept) for b in kept[i + 1 :] if a % 2 == b % 2), + key=lambda p: (p[0] // batch_rows, p[1] // batch_rows, p[0], p[1]), + ) + with find_duplicate_pairs( + ds, "vector", 0, max_concurrency=1, memory_limit=memory_limit + ) as reader: + batches = list(reader) + # One output batch per tile: 1 pair on the diagonal, 5 off it. + assert [batch.num_rows for batch in batches] == [1, 5, 5, 1, 5, 1] + serial = pa.Table.from_batches(batches) + assert [(p["row_id_a"], p["row_id_b"]) for p in serial.to_pylist()] == [ + (row_ids[a], row_ids[b]) for a, b in expected + ] + with find_duplicate_pairs( + ds, "vector", 0, max_concurrency=8, memory_limit=memory_limit + ) as reader: + assert reader.read_all().equals(serial) + segment = ds.describe_indices()[0].segments[0].uuid + with find_duplicate_pairs_in_partition( + ds, + "vector", + segment, + 0, + 0, + max_concurrency=4, + memory_limit=memory_limit, + ) as reader: + assert reader.read_all().equals(serial) + with pytest.raises(ValueError, match="max_concurrency must be positive"): + find_duplicate_pairs(ds, "vector", 0, max_concurrency=0) + + +@pytest.mark.parametrize("bits", [4, 8]) +def test_duplicate_pairs_exhaust_pq_partition(tmp_path, bits): + # Fully consume the zero-output case that exposed per-anchor source reads. + rng = np.random.default_rng(9493) + vectors = rng.normal(size=(1025, 64)).astype(np.float32) + ds = lance.write_dataset( + pa.table( + { + "vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 64)), + } + ), + tmp_path, + ) + ds = ds.create_index( + "vector", + "IVF_PQ", + num_partitions=1, + ivf_centroids=np.zeros((1, 64), dtype=np.float32), + num_bits=bits, + num_sub_vectors=16, + pq_codebook=rng.normal(size=(16, 2**bits, 4)).astype(np.float32), + ) + with find_duplicate_pairs(ds, "vector", 0.0) as reader: + assert reader.read_all().num_rows == 0 + + +def test_duplicate_pairs_independent_segments(tmp_path): + vectors = [[1.0, 0.0]] * 4 + ds = lance.write_dataset( + pa.table({"vector": pa.array(vectors, pa.list_(pa.float32(), 2))}), + tmp_path, + max_rows_per_file=2, + ) + segments = [ + ds.create_index_uncommitted( + "vector", + "IVF_FLAT", + name="vector_idx", + train=True, + fragment_ids=[fragment.fragment_id], + num_partitions=1, + ivf_centroids=np.array([[i + 1, 0]], dtype=np.float32), + ) + for i, fragment in enumerate(ds.get_fragments()) + ] + ds = ds.commit_existing_index_segments("vector_idx", "vector", segments) + pairs = find_duplicate_pairs(ds, "vector", 0).read_all() + assert pairs.num_rows == 2 # Cross-segment pairs are intentionally omitted. + expected = [] + for segment in ds.describe_indices()[0].segments: + expected.extend( + find_duplicate_pairs_in_partition(ds, "vector", segment.uuid, 0, 0) + .read_all() + .to_pylist() + ) + assert pairs.to_pylist() == expected + + +@pytest.mark.parametrize("stable_ids", [False, True]) +def test_duplicate_pairs_segment_ownership(tmp_path, stable_ids): + ds = lance.write_dataset( + pa.table({"vector": pa.array([[1.0, 0.0]] * 4, pa.list_(pa.float32(), 2))}), + tmp_path, + max_rows_per_file=2, + enable_stable_row_ids=stable_ids, + ) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=1, + ivf_centroids=np.ones((1, 2), dtype=np.float32), + ) + fragment = ds.get_fragment(1) + row_ids = fragment.to_table(with_row_id=True)["_rowid"] + updated, fields = fragment.update_columns( + pa.table( + { + "_rowid": row_ids, + "vector": pa.array([[10.0, 0.0]] * 2, pa.list_(pa.float32(), 2)), + } + ) + ) + ds = lance.LanceDataset.commit( + ds.uri, + lance.LanceOperation.Update( + updated_fragments=[updated], + fields_modified=fields, + ), + read_version=ds.version, + ) + ds.optimize.optimize_indices(num_indices_to_merge=0) + ds = lance.dataset(ds.uri) + pairs = find_duplicate_pairs(ds, "vector", 0).read_all().to_pylist() + assert len(pairs) == 2 + current = { + r["_rowid"]: r["vector"] for r in ds.to_table(with_row_id=True).to_pylist() + } + assert all(current[p["row_id_a"]] == current[p["row_id_b"]] for p in pairs) + assert len({p[k] for p in pairs for k in ["row_id_a", "row_id_b"]}) == 4 + + +def test_duplicate_pairs_hamming(tmp_path): + ds = lance.write_dataset(_binary_vectors_table(), tmp_path) + ds = ds.create_index( + "vector", + "IVF_FLAT", + metric="hamming", + num_partitions=1, + ivf_centroids=pa.array([[0] * 4], pa.list_(pa.uint8(), 4)), + ) + pairs = find_duplicate_pairs(ds, "vector", 2).read_all().to_pylist() + assert {(p["row_id_a"], p["row_id_b"]): p["distance"] for p in pairs} == { + (0, 1): 2.0, + (1, 2): 2.0, + } + + +@pytest.mark.parametrize("index_type", ["IVF_PQ", "IVF_SQ"]) +def test_duplicate_pairs_exactly_representable_codes(tmp_path, index_type): + vectors = np.array([[0, 0, 2, 2], [3, 3, 4, 4], [8, 8, 6, 6]], dtype=np.float32) + params = {} + if index_type == "IVF_PQ": + # Every source subvector is an exact codebook entry. + codebook = np.repeat(np.arange(16, dtype=np.float32), 2).reshape(16, 2) + params = dict( + num_bits=4, num_sub_vectors=2, pq_codebook=np.stack([codebook] * 2) + ) + else: + # Endpoints of the scalar quantizer are exactly representable. + vectors = np.array([[0, 255], [255, 0], [0, 0]], dtype=np.float32) + dim = vectors.shape[1] + ds = lance.write_dataset( + pa.table( + { + "vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), dim)), + } + ), + tmp_path, + ) + ds = ds.create_index( + "vector", + index_type, + num_partitions=1, + ivf_centroids=np.zeros((1, dim), dtype=np.float32), + **params, + ) + result = find_duplicate_pairs(ds, "vector", 1e6).read_all().to_pylist() + assert len(result) == 3 + for pair in result: + expected = np.sum((vectors[pair["row_id_a"]] - vectors[pair["row_id_b"]]) ** 2) + assert pair["distance"] == pytest.approx(expected) + + +@pytest.mark.parametrize("stable_ids", [False, True]) +def test_duplicate_pairs_after_compaction(tmp_path, stable_ids): + ds = lance.write_dataset( + pa.table( + { + "id": range(4), + "vector": pa.array([[1.0, 0.0]] * 4, pa.list_(pa.float32(), 2)), + } + ), + tmp_path, + max_rows_per_file=2, + enable_stable_row_ids=stable_ids, + ) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=1, + ivf_centroids=np.ones((1, 2), dtype=np.float32), + ) + ds.delete("id = 1") + ds.optimize.compact_files( + target_rows_per_fragment=10, + defer_index_remap=not stable_ids, + materialize_deletions=True, + ) + current = ds.to_table(with_row_id=True)["_rowid"].to_pylist() + result = find_duplicate_pairs(ds, "vector", 0).read_all().to_pylist() + assert len(result) == 3 + assert {p[k] for p in result for k in ["row_id_a", "row_id_b"]} == set(current) + + +@pytest.mark.parametrize( + "index_type,bits", + [ + ("IVF_PQ", 4), + ("IVF_PQ", 8), + ("IVF_RQ", 1), + ("IVF_RQ", 5), + ("IVF_RQ", 9), + ], +) +def test_duplicate_pairs_quantized_batch_boundary(tmp_path, index_type, bits): + # A 8192-row batch followed by one row exercises partial transposed PQ + # reads and the packed RQ tail. Deleting the rows in between keeps the + # scored anchors (and the output) small while staging every row. + rng = np.random.default_rng(83) + vectors = rng.normal(size=(8193, 8)).astype(np.float32) + vectors[-1] = vectors[0] + ds = lance.write_dataset( + pa.table( + { + "id": np.arange(len(vectors)), + "vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 8)), + } + ), + tmp_path, + ) + params = {"num_bits": bits} + if index_type == "IVF_PQ": + params.update( + num_sub_vectors=4, + pq_codebook=rng.normal(size=(4, 2**bits, 2)).astype(np.float32), + ) + ds = ds.create_index( + "vector", + index_type, + num_partitions=1, + ivf_centroids=np.ones((1, 8), dtype=np.float32), + **params, + ) + ds.delete("id >= 1 AND id < 8192") + results = [] + for memory_limit in [0, 256 * 1024 * 1024]: + with find_duplicate_pairs( + ds, "vector", 1e6, memory_limit=memory_limit + ) as reader: + results.append(reader.read_all()) + # Spilled and in-memory staging give bit-identical distances. + assert results[0].equals(results[1]) + assert results[0].to_pylist() == [ + {"row_id_a": 0, "row_id_b": 8192, "distance": 0.0} + ] + + +def test_duplicate_pairs_partition_scope(tmp_path): + ds = lance.write_dataset( + pa.table( + { + "vector": pa.array( + [[0.0, 0.0], [0.1, 0.0], [10.0, 0.0], [10.1, 0.0]], + pa.list_(pa.float32(), 2), + ), + } + ), + tmp_path, + ) + ds = ds.create_index( + "vector", + "IVF_FLAT", + num_partitions=3, + ivf_centroids=np.array([[0, 0], [10, 0], [100, 0]], dtype=np.float32), + ) + pairs = find_duplicate_pairs(ds, "vector", 1e6).read_all().to_pylist() + assert {(p["row_id_a"], p["row_id_b"]) for p in pairs} == {(0, 1), (2, 3)} + segment = ds.describe_indices()[0].segments[0].uuid + assert ( + find_duplicate_pairs_in_partition(ds, "vector", segment, 2, 1e6) + .read_all() + .num_rows + == 0 + ) def test_dict(): @@ -147,3 +696,178 @@ def test_binary_vectors_invalid_metric(tmp_path): "metric": "l2", } ).to_table() + + +def _hash_table(hashes): + """Build a table with a ``hash`` column of FixedSizeList. + + ``hashes`` is a list of byte sequences, one per row. The byte width must + be a positive multiple of 8. + """ + byte_width = len(hashes[0]) + assert byte_width > 0 and byte_width % 8 == 0 + assert all(len(row) == byte_width for row in hashes) + flat = [byte for row in hashes for byte in row] + values = pa.FixedSizeListArray.from_arrays( + pa.array(flat, type=pa.uint8()), list_size=byte_width + ) + return pa.Table.from_arrays([values], names=["hash"]) + + +@pytest.mark.parametrize("byte_width", [8, 16]) +def test_hamming_clustering_for_sample(tmp_path, byte_width): + hash_a = [0] * byte_width + hash_b = [0] * (byte_width - 8) + [255] + [0] * 7 # 8 bits from hash_a + hash_c = list(range(1, byte_width + 1)) # far from both + # Rows 0,1,2 share hash_a; rows 3,4 share hash_b; row 5 is unique. + table = _hash_table([hash_a, hash_a, hash_a, hash_b, hash_b, hash_c]) + dataset = lance.write_dataset(table, tmp_path / "hashes") + + # threshold 0 => only exact-match hashes cluster together. Full scan + # (sample_size=None) yields deterministic row ids 0..5. + result = hamming_clustering_for_sample(dataset, "hash", None, 0).read_all() + + clusters = { + rep: sorted(dups) + for rep, dups in zip( + result["representative"].to_pylist(), + result["duplicates"].to_pylist(), + ) + } + # Singleton row 5 is not emitted as a cluster. + assert clusters == {0: [1, 2], 3: [4]} + + +@pytest.mark.parametrize("byte_width", [8, 16]) +def test_hamming_clustering_multi_segment(tmp_path, byte_width): + mask = (1 << 64) - 1 + + def hash_bytes(value): + if byte_width == 8: + lanes = [(value * 0x9E3779B97F4A7C15) & mask] + else: + # Adjacent logical values share the first 64-bit lane and differ in + # later lanes, so threshold-0 clustering must compare every lane. + lanes = [ + ((value // 2) * 0x9E3779B97F4A7C15) & mask, + ((value * 0xD6E8FEB86659FD93) ^ 0xA5A5A5A5A5A5A5A5) & mask, + ] + return [ + byte for lane_value in lanes for byte in lane_value.to_bytes(8, "little") + ] + + # 25 distinct hash values, two copies each; the same table is written to + # fragment 0 and appended as fragment 1. + values = [i // 2 for i in range(50)] + table = _hash_table([hash_bytes(value) for value in values]) + dataset = lance.write_dataset(table, tmp_path / "hashes") + dataset.create_index( + "hash", index_type="IVF_FLAT", num_partitions=4, metric="hamming" + ) + dataset = lance.write_dataset(table, tmp_path / "hashes", mode="append") + # Optimizing with merge disabled creates a delta segment for fragment 1. + dataset.optimize.optimize_indices(num_indices_to_merge=0) + + index = dataset.describe_indices()[0] + assert len(index.segments) == 2 + + infos = get_ivf_partition_info(dataset, index.name) + assert sum(info["size"] for info in infos) == 100 + + # All four copies of each value cluster together across both fragments. + frag1_start = 1 << 32 + clusters = [] + for info in infos: + result = hamming_clustering_for_ivf_partition( + dataset, index.name, info["partition_id"], 0 + ).read_all() + clusters.extend( + zip( + result["representative"].to_pylist(), + result["duplicates"].to_pylist(), + ) + ) + assert len(clusters) == 25 + for representative, duplicates in clusters: + assert representative < frag1_start + assert len(duplicates) == 3 + assert any(dup >= frag1_start for dup in duplicates) + + # Selecting the fragment-0 segment reproduces the single-segment scope. + first_segment = next( + segment for segment in index.segments if segment.fragment_ids == {0} + ) + infos = get_ivf_partition_info( + dataset, index.name, index_segments=[first_segment.uuid] + ) + assert sum(info["size"] for info in infos) == 50 + num_selected_clusters = 0 + for info in infos: + result = hamming_clustering_for_ivf_partition( + dataset, + index.name, + info["partition_id"], + 0, + index_segments=[first_segment.uuid], + ).read_all() + for duplicates in result["duplicates"].to_pylist(): + num_selected_clusters += 1 + assert duplicates == [dup for dup in duplicates if dup < frag1_start] + assert len(duplicates) == 1 + assert num_selected_clusters == 25 + + with pytest.raises(ValueError, match="invalid index segment uuid"): + get_ivf_partition_info(dataset, index.name, index_segments=["not-a-uuid"]) + with pytest.raises(TypeError, match="str or uuid.UUID"): + get_ivf_partition_info(dataset, index.name, index_segments=[123]) + with pytest.raises(TypeError, match="not a single"): + get_ivf_partition_info(dataset, index.name, index_segments=first_segment.uuid) + + +@pytest.mark.parametrize("index_type", ["IVF_PQ", "IVF_SQ"]) +def test_duplicate_pairs_quantized_cosine_renormalizes(tmp_path, index_type): + # A deliberately coarse quantizer distinguishes 1 - cos of the + # renormalized reconstructions from their (unbounded) squared L2. + vectors = np.array([[1, 0], [0, 1], [-1, 0], [1, 0]], dtype=np.float32) + params = {} + if index_type == "IVF_PQ": + codebook = np.tile(np.array([[2, 0], [0, 3], [-2, 0]], np.float32), (86, 1))[ + :256 + ] + params = dict(num_bits=8, num_sub_vectors=1, pq_codebook=codebook[None]) + # Codewords [2, 0] and [0, 3] are orthogonal although 13 apart. + reconstructed = np.array([[2, 0], [0, 3]], np.float64) + else: + # Bounds [-1, 1]: 0 truncates to code 127, reconstructed as -1/255. + reconstructed = np.array([[1, -1 / 255], [-1 / 255, 1]], np.float64) + a, b = reconstructed + expected = 1 - a @ b / (np.linalg.norm(a) * np.linalg.norm(b)) + ds = lance.write_dataset( + pa.table({"vector": pa.array(vectors.tolist(), pa.list_(pa.float32(), 2))}), + tmp_path, + ) + ds = ds.create_index( + "vector", + index_type, + metric="cosine", + num_partitions=1, + ivf_centroids=np.zeros((1, 2), np.float32), + **params, + ) + pairs = find_duplicate_pairs(ds, "vector", 100).read_all().to_pylist() + distances = { + tuple(sorted((p["row_id_a"], p["row_id_b"]))): p["distance"] for p in pairs + } + assert len(distances) == 6 + assert distances[(0, 1)] == pytest.approx(expected, abs=1e-6) + # Identical codes score exactly zero; opposite vectors reach the bound 2. + assert distances[(0, 3)] == 0 + assert distances[(0, 2)] == pytest.approx(2.0, abs=1e-4) + assert all(0 <= d <= 2 for d in distances.values()) + # Threshold filtering must use the same native score as enumeration. + selected = ( + find_duplicate_pairs(ds, "vector", expected - 1e-3).read_all().to_pylist() + ) + assert (0, 1) not in { + tuple(sorted((p["row_id_a"], p["row_id_b"]))) for p in selected + } diff --git a/python/python/tests/test_vector_index.py b/python/python/tests/test_vector_index.py index 71850135ae3..ef2f74f45ce 100644 --- a/python/python/tests/test_vector_index.py +++ b/python/python/tests/test_vector_index.py @@ -1,6 +1,7 @@ # SPDX-License-Identifier: Apache-2.0 # SPDX-FileCopyrightText: Copyright The Lance Authors +import json import logging import os import platform @@ -18,13 +19,74 @@ 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 Index, VectorIndexReader +from lance.dataset import VectorIndexReader from lance.indices import IndexFileVersion, IndicesBuilder -from lance.util import validate_vector_index # noqa: E402 +from lance.query import MatchQuery, PhraseQuery +from lance.util import ( # noqa: E402 + _target_partition_size_to_num_partitions, + validate_vector_index, +) from lance.vector import vec_to_table # noqa: E402 +@pytest.mark.parametrize("query_scale", [0.25, 4.0]) +def test_dot_auto_probe_overrides(tmp_path, monkeypatch, query_scale): + centroids = np.eye(16, dtype=np.float32) + vectors = np.repeat(centroids, 16, axis=0) + vectors *= np.tile(np.linspace(1, 2, 16, dtype=np.float32), 16)[:, None] + vectors += ( + np.random.default_rng(2249).normal(0, 0.001, vectors.shape).astype(np.float32) + ) + table = vec_to_table(vectors).append_column("id", pa.array(np.arange(len(vectors)))) + dataset = lance.write_dataset(table, tmp_path / "dot.lance", max_rows_per_file=32) + dataset.create_index( + "vector", "IVF_FLAT", metric="dot", num_partitions=16, ivf_centroids=centroids + ) + query = centroids[0] * query_scale + nearest = {"column": "vector", "q": query, "k": 10, "metric": "dot"} + expected = set(np.argsort(-(vectors @ query))[:10].tolist()) + monkeypatch.setenv("LANCE_AUTO_PROBE_MARGIN", "0") + monkeypatch.setenv("LANCE_AUTO_MIN_INITIAL_NPROBES", "2") + monkeypatch.setenv("LANCE_AUTO_MAX_INITIAL_NPROBES", "2") + captured = [] + result = dataset.scanner( + columns=["id"], nearest=nearest, scan_stats_callback=captured.append + ).to_table() + assert set(result["id"].to_pylist()) == expected + assert captured[0].all_counts["partitions_searched"] == 2 + # Python callbacks must expose the same nanosecond timings as native consumers. + assert captured[0].all_times["index_search_time"] > 0 + assert captured[0].all_times["ANNSubIndexExec_elapsed_compute"] > 0 + + # An initial cap must not prevent later probing when filters exhaust it. + captured.clear() + filtered = dataset.scanner( + columns=["id"], + nearest=nearest, + filter="id >= 224", + prefilter=True, + scan_stats_callback=captured.append, + ).to_table() + assert len(filtered) == 10 + assert min(filtered["id"].to_pylist()) >= 224 + assert captured[0].all_counts["partitions_searched"] > 2 + assert captured[0].all_times["index_search_time"] > 0 + assert captured[0].all_times["ANNSubIndexExec_elapsed_compute"] > 0 + + monkeypatch.setenv("LANCE_AUTO_PROBE_MARGIN", "invalid") + with pytest.raises(pa.ArrowInvalid, match="LANCE_AUTO_PROBE_MARGIN"): + dataset.to_table(columns=["id"], nearest=nearest) + # Fixed budgets and explicitly bounded Auto retain their existing semantics. + fixed = dataset.to_table(columns=["id"], nearest={**nearest, "nprobes": 16}) + assert set(fixed["id"].to_pylist()) == expected + bounded = dataset.to_table( + columns=["id"], nearest={**nearest, "maximum_nprobes": 4} + ) + assert set(bounded["id"].to_pylist()) == expected + + def create_table(nvec=1000, ndim=128, nans=0, nullify=False, dtype=np.float32): mat = np.random.randn(nvec, ndim) if nans > 0: @@ -55,18 +117,26 @@ def gen_str(n): def create_multivec_table( - nvec=1000, nvec_per_row=5, ndim=128, nans=0, nullify=False, dtype=np.float32 + nvec=1000, + nvec_per_row=5, + ndim=128, + nans=0, + nullify=False, + dtype=np.float32, + seed=None, ): - mat = np.random.randn(nvec, nvec_per_row, ndim) + rng = np.random.default_rng(seed) + text_rng = random.Random(seed) + mat = rng.standard_normal((nvec, nvec_per_row, ndim)) if nans > 0: nans_mat = np.empty((nans, ndim)) nans_mat[:] = np.nan mat = np.concatenate((mat, nans_mat), axis=0) mat = mat.astype(dtype) - price = np.random.rand(nvec + nans) * 100 + price = rng.random(nvec + nans) * 100 def gen_str(n): - return "".join(random.choices(string.ascii_letters + string.digits, k=n)) + return "".join(text_rng.choices(string.ascii_letters + string.digits, k=n)) meta = np.array([gen_str(100) for _ in range(nvec + nans)]) @@ -107,13 +177,20 @@ def indexed_dataset(tmp_path): tbl = create_table() dataset = lance.write_dataset(tbl, tmp_path) yield dataset.create_index( - "vector", index_type="IVF_PQ", num_partitions=4, num_sub_vectors=16 + "vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=16, + max_iters=2, + sample_rate=2, ) @pytest.fixture() def multivec_dataset(): - tbl = create_multivec_table() + # Keep at least 100 logical rows for the top-k assertions below. Five + # vectors per row still exercises multivector deduplication and fanout. + tbl = create_multivec_table(nvec=128, seed=42) yield lance.write_dataset(tbl, "memory://") @@ -122,8 +199,11 @@ def indexed_multivec_dataset(multivec_dataset): yield multivec_dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=1, + num_sub_vectors=4, + num_bits=4, + max_iters=2, + sample_rate=2, metric="cosine", ) @@ -179,10 +259,269 @@ def test_flat(dataset): run(dataset) +@pytest.mark.parametrize( + "queries", + [ + np.random.randn(2, 128).astype(np.float32), + np.random.randn(1, 128).astype(np.float32), + ], + ids=["two_queries", "single_query"], +) +def test_batch_flat_query_matches_repeated_single_queries(dataset, queries): + k = 5 + _assert_batch_matches_single_queries( + dataset, + queries, + k=k, + nearest_kwargs={"use_index": False}, + ) + + +@pytest.mark.parametrize("metric", ["l2", "cosine"]) +@pytest.mark.parametrize("query_count", [3, 1], ids=["three_queries", "single_query"]) +def test_batch_indexed_query_matches_repeated_single_queries( + dataset, metric, query_count +): + indexed = dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=16, + metric=metric, + ) + # Give the query vectors deliberately different magnitudes: a cosine batch + # that normalized the whole concatenated key by one global norm would scale + # them unequally and diverge from per-query single search. + scales = np.linspace(0.1, 10.0, query_count).reshape(-1, 1) + queries = (np.random.randn(query_count, 128) * scales).astype(np.float32) + k = 5 + + # nprobes covers every partition so the shared-scan batch path and the + # repeated single-query path search the same partitions deterministically. + nearest_kwargs = {"use_index": True, "nprobes": 4} + _assert_batch_matches_single_queries( + indexed, + queries, + k=k, + nearest_kwargs=nearest_kwargs, + ) + + +@pytest.mark.parametrize("query_count", [1, 2]) +@pytest.mark.parametrize("use_index", [False, True]) +def test_batch_binary_query_matches_repeated_single_queries( + tmp_path, query_count, use_index +): + vectors = np.array([[0, 0], [255, 0], [0, 255], [255, 255]], dtype=np.uint8) + dataset = lance.write_dataset( + pa.table( + { + "id": pa.array(range(len(vectors)), type=pa.int32()), + "vector": pa.array(vectors.tolist(), type=pa.list_(pa.uint8(), 2)), + } + ), + tmp_path, + ) + if use_index: + dataset = dataset.create_index( + "vector", + index_type="IVF_FLAT", + num_partitions=1, + metric="hamming", + ) + + queries = np.array([[0, 0], [255, 255]], dtype=np.uint8)[:query_count] + k = 2 + nearest_kwargs = {"metric": "hamming", "use_index": use_index} + _assert_batch_matches_single_queries( + dataset, + queries, + k=k, + nearest_kwargs=nearest_kwargs, + ) + + +def _assert_batch_matches_single_queries(ds, queries, k, nearest_kwargs): + batch = ds.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": queries, + "k": k, + **nearest_kwargs, + }, + ) + query_count = len(queries) + assert batch.num_rows == query_count * k + assert batch.column_names == ["query_index", "id", "_distance"] + query_index_field = batch.schema.field("query_index") + assert query_index_field.type == pa.int32() + assert not query_index_field.nullable + assert batch["query_index"].to_pylist() == sum( + [[i] * k for i in range(query_count)], [] + ) + + if "distance_range" in nearest_kwargs: + lo, hi = nearest_kwargs["distance_range"] + assert all(lo <= d < hi for d in batch["_distance"].to_pylist()) + + for query_index, query in enumerate(queries): + single = ds.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": query, + "k": k, + **nearest_kwargs, + }, + ) + batch_slice = batch.filter(pc.field("query_index") == query_index) + assert batch_slice["id"].to_pylist() == single["id"].to_pylist() + np.testing.assert_allclose( + batch_slice["_distance"].to_numpy(), + single["_distance"].to_numpy(), + ) + + +def test_batch_vector_search_rejects_dataset_query_index_column(tmp_path): + dim = 128 + table = create_table(nvec=80, ndim=dim) + table = table.append_column( + "query_index", + pa.array(range(80), type=pa.uint32()), + ) + ds = lance.write_dataset(table, tmp_path / "with_query_index") + + queries = np.random.randn(2, dim).astype(np.float32) + with pytest.raises(Exception, match="query_index"): + ds.to_table( + columns=["id", "query_index"], + nearest={ + "column": "vector", + "q": queries, + "k": 5, + "use_index": False, + }, + ) + + +def test_flat_1d_query_length_multiple_of_dim_is_rejected(dataset): + q = np.random.randn(256).astype(np.float32) + with pytest.raises(ValueError, match=r"256.*128"): + dataset.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": q, + "k": 5, + "use_index": False, + }, + ) + + +def test_batch_fast_search_without_index_returns_empty_with_query_index(dataset): + queries = np.random.randn(2, 128).astype(np.float32) + batch = dataset.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": queries, + "k": 5, + }, + fast_search=True, + ) + assert batch.num_rows == 0 + assert "query_index" in batch.column_names + + 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_before_completion_propagates(tmp_path): + ds = _make_sample_dataset_base( + tmp_path, "vector_progress_post_commit_error", 1500, 128 + ) + recorder = ProgressRecorder(fail_on_tag="start:train_ivf") + + 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 == ["start:train_ivf"] + assert not ds.has_index + assert ds.describe_indices() == [] + + +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. + monkeypatch.setenv("LANCE_IVF_PQ_MERGE_PARTITION_WINDOW_SIZE", "4") + monkeypatch.setenv("LANCE_IVF_PQ_MERGE_PARTITION_PREFETCH_WINDOW_COUNT", "2") + + rng = np.random.default_rng(42) + matrix = rng.standard_normal((640, 32), dtype=np.float32) + data = vec_to_table(data=matrix).append_column("id", pa.array(range(640))) + q = rng.standard_normal(32).astype(np.float32) + assert_distributed_vector_consistency( + data, + "vector", + index_type="IVF_PQ", + index_params={ + "num_partitions": 10, + "num_sub_vectors": 4, + "max_iters": 2, + }, + queries=[q], + topk=10, + world=2, + tmp_path=tmp_path, + similarity_metric="recall", + similarity_threshold=0.80, + ) + + @pytest.mark.parametrize( "fixture_name,index_type,index_params,similarity_threshold", [ @@ -200,7 +539,7 @@ def test_distributed_vector( request, fixture_name, index_type, index_params, similarity_threshold ): ds = request.getfixturevalue(fixture_name) - q = np.random.randn(128).astype(np.float32) + q = np.random.default_rng(42).standard_normal(128).astype(np.float32) assert_distributed_vector_consistency( ds.to_table(), "vector", @@ -298,20 +637,20 @@ def test_f16_cuda(tmp_path): "index_file_version", [IndexFileVersion.V3, IndexFileVersion.LEGACY] ) def test_index_with_nans(tmp_path, index_file_version): - # 1024 rows, the entire table should be sampled - tbl = create_table(nvec=1000, nans=24) + tbl = create_table(nvec=256, ndim=32, nans=8) dataset = lance.write_dataset(tbl, tmp_path) dataset = dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=1, + num_sub_vectors=4, + max_iters=2, index_file_version=index_file_version, ) idx_stats = dataset.stats.index_stats("vector_idx") assert idx_stats["indices"][0]["index_file_version"] == index_file_version - validate_vector_index(dataset, "vector") + validate_vector_index(dataset, "vector", sample_size=16) @pytest.mark.parametrize( @@ -320,22 +659,60 @@ def test_index_with_nans(tmp_path, index_file_version): def test_torch_index_with_nans(tmp_path, index_file_version): torch = pytest.importorskip("torch") - # 1024 rows, the entire table should be sampled - tbl = create_table(nvec=1000, nans=24) + # Torch PQ initialization samples 256 valid residuals. Keep a small margin + # after NaN filtering so every platform can produce a complete sample batch. + tbl = create_table(nvec=320, ndim=32, nans=8) dataset = lance.write_dataset(tbl, tmp_path) dataset = dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=1, + num_sub_vectors=4, + max_iters=2, accelerator=torch.device("cpu"), one_pass_ivfpq=True, index_file_version=index_file_version, ) idx_stats = dataset.stats.index_stats("vector_idx") assert idx_stats["indices"][0]["index_file_version"] == index_file_version - validate_vector_index(dataset, "vector") + validate_vector_index(dataset, "vector", sample_size=16) + + +def test_torch_index_nan_init_centroid(tmp_path): + """A NaN vector must never seed a centroid. + + `vector is not null` does not exclude NaN, so sampling could pick one; with + a single partition that left every residual NaN and the index build failed. + """ + torch = pytest.importorskip("torch") + from lance.torch.data import LanceDataset as TorchDataset + from lance.vector import _sample_init_centroids + + # Only the last 8 rows are finite, so any sample that keeps NaN rows seeds + # the centroid with one. + mat = np.full((32, 8), np.nan, dtype=np.float32) + mat[24:] = np.random.randn(8, 8).astype(np.float32) + dataset = lance.write_dataset(vec_to_table(data=mat), tmp_path) + assert dataset.to_table()["vector"].null_count == 0 + + ds = TorchDataset(dataset, batch_size=1, columns=["vector"], samples=32) + centroids = _sample_init_centroids(ds, 4, filter_nan=True) + assert centroids.shape[0] == 4 + assert torch.isfinite(centroids).all() + + +def test_torch_index_all_nan_rejected(tmp_path): + pytest.importorskip("torch") + from lance.torch.data import LanceDataset as TorchDataset + from lance.vector import _sample_init_centroids + + mat = np.full((16, 8), np.nan, dtype=np.float32) + dataset = lance.write_dataset(vec_to_table(data=mat), tmp_path) + + ds = TorchDataset(dataset, batch_size=1, columns=["vector"], samples=16) + with pytest.raises(ValueError, match="all null or non-finite"): + _sample_init_centroids(ds, 1, filter_nan=True) def test_index_with_no_centroid_movement(tmp_path): @@ -344,7 +721,8 @@ def test_index_with_no_centroid_movement(tmp_path): # this test makes the centroids essentially [1..] # this makes sure the early stop condition in the index building code # doesn't do divide by zero - mat = np.concatenate([np.ones((256, 32))]) + # Torch one-pass PQ emits an 8-bit codebook, which requires 256 rows. + mat = np.ones((256, 16), dtype=np.float32) tbl = vec_to_table(data=mat) @@ -354,25 +732,37 @@ def test_index_with_no_centroid_movement(tmp_path): index_type="IVF_PQ", num_partitions=1, num_sub_vectors=4, + max_iters=2, accelerator=torch.device("cpu"), ) - validate_vector_index(dataset, "vector") + validate_vector_index(dataset, "vector", sample_size=8) def test_index_with_pq_codebook(tmp_path): - tbl = create_table(nvec=1024, ndim=128) + dim = 16 + rng = np.random.default_rng(42) + # Eight-bit PQ still requires its 256 centroid training rows even when the + # initial codebook is supplied; reducing the dimension keeps this fixture small. + vectors = rng.standard_normal((256, dim), dtype=np.float32) + tbl = vec_to_table(data=vectors) dataset = lance.write_dataset(tbl, tmp_path) - pq_codebook = np.random.randn(4, 256, 128 // 4).astype(np.float32) + pq_codebook = rng.standard_normal((4, 256, dim // 4), dtype=np.float32) + ivf_centroids = rng.standard_normal((1, dim), dtype=np.float32) dataset = dataset.create_index( "vector", index_type="IVF_PQ", num_partitions=1, num_sub_vectors=4, - ivf_centroids=np.random.randn(1, 128).astype(np.float32), + max_iters=2, + ivf_centroids=ivf_centroids, pq_codebook=pq_codebook, ) - validate_vector_index(dataset, "vector", refine_factor=10, pass_threshold=0.99) + index = dataset.stats.index_stats("vector_idx") + assert index["indices"][0]["sub_index"]["nbits"] == 8 + validate_vector_index( + dataset, "vector", refine_factor=256, sample_size=8, pass_threshold=0.99 + ) pq_codebook = pa.FixedShapeTensorArray.from_numpy_ndarray(pq_codebook) @@ -381,11 +771,68 @@ def test_index_with_pq_codebook(tmp_path): index_type="IVF_PQ", num_partitions=1, num_sub_vectors=4, - ivf_centroids=np.random.randn(1, 128).astype(np.float32), + max_iters=2, + ivf_centroids=ivf_centroids, pq_codebook=pq_codebook, replace=True, ) - validate_vector_index(dataset, "vector", refine_factor=10, pass_threshold=0.99) + validate_vector_index( + dataset, "vector", refine_factor=256, sample_size=8, pass_threshold=0.99 + ) + + +def test_index_with_4bit_numpy_pq_codebook(tmp_path): + dim = 32 + rng = np.random.default_rng(42) + vectors = rng.standard_normal((32, dim), dtype=np.float32) + tbl = vec_to_table(data=vectors) + dataset = lance.write_dataset(tbl, tmp_path) + pq_codebook = rng.standard_normal((4, 16, dim // 4), dtype=np.float32) + + dataset = dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=1, + num_sub_vectors=4, + num_bits=4, + max_iters=2, + ivf_centroids=rng.standard_normal((1, dim), dtype=np.float32), + pq_codebook=pq_codebook, + ) + + index = dataset.stats.index_stats("vector_idx") + assert index["indices"][0]["sub_index"]["nbits"] == 4 + + result = dataset.to_table( + nearest={ + "column": "vector", + "q": vectors[0], + "k": 10, + } + ) + assert result.num_rows == 10 + + +def test_index_with_pq_codebook_rejects_wrong_num_bits_shape(tmp_path): + dim = 16 + rng = np.random.default_rng(42) + tbl = vec_to_table(data=rng.standard_normal((8, dim), dtype=np.float32)) + dataset = lance.write_dataset(tbl, tmp_path) + pq_codebook = rng.standard_normal((4, 256, dim // 4), dtype=np.float32) + + with pytest.raises( + ValueError, + match=r"\(sub_vectors, 16, dim\) for num_bits=4, got \(4, 256, 4\)", + ): + dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=1, + num_sub_vectors=4, + num_bits=4, + ivf_centroids=rng.standard_normal((1, dim), dtype=np.float32), + pq_codebook=pq_codebook, + ) @pytest.mark.cuda @@ -485,20 +932,23 @@ def test_create_index_unsupported_accelerator(tmp_path): def test_create_index_accelerator_fallback(tmp_path, caplog): - tbl = create_table() + tbl = create_table(nvec=64, ndim=32) dataset = lance.write_dataset(tbl, tmp_path) with caplog.at_level(logging.WARNING): dataset = dataset.create_index( "vector", index_type="IVF_HNSW_SQ", - num_partitions=4, + num_partitions=1, + max_iters=2, + max_level=2, + m=4, + ef_construction=16, accelerator="cuda", ) - indices = dataset.list_indices() - assert len(indices) == 1 - assert indices[0]["type"] == "IVF_HNSW_SQ" + stats = dataset.stats.index_stats("vector_idx") + assert stats["index_type"] == "IVF_HNSW_SQ" assert any( "does not support GPU acceleration; falling back to CPU" in record.message for record in caplog.records @@ -560,62 +1010,159 @@ def test_has_index(dataset, tmp_path): ) assert ann_ds.has_index - assert ann_ds.list_indices()[0]["fields"] == ["vector"] - - -def test_index_type(dataset, tmp_path): - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") - - ann_ds = ann_ds.create_index( - "vector", - index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, - replace=True, - ) - assert ann_ds.list_indices()[0]["type"] == "IVF_PQ" + assert ann_ds.describe_indices()[0].field_names == ["vector"] - ann_ds = ann_ds.create_index( - "vector", - index_type="IVF_HNSW_SQ", - num_partitions=4, - num_sub_vectors=16, - replace=True, - ) - assert ann_ds.list_indices()[0]["type"] == "IVF_HNSW_SQ" - ann_ds = ann_ds.create_index( +def test_index_type(tmp_path): + index_cases = [ + ("IVF_PQ", {"num_sub_vectors": 4, "num_bits": 4}), + ( + "IVF_HNSW_SQ", + {"max_level": 2, "m": 4, "ef_construction": 16}, + ), + ( + "IVF_HNSW_PQ", + { + "num_sub_vectors": 4, + "num_bits": 4, + "max_level": 2, + "m": 4, + "ef_construction": 16, + }, + ), + ( + "IVF_HNSW_FLAT", + {"max_level": 2, "m": 4, "ef_construction": 16}, + ), + ] + rng = np.random.default_rng(42) + vectors = rng.standard_normal((64, 32), dtype=np.float32) + table = vec_to_table(data=vectors).append_column("id", pa.array(range(64))) + ann_ds = lance.write_dataset(table, tmp_path / "replace_index_type") + assert not ann_ds.has_index + + for case_index, (index_type, index_options) in enumerate(index_cases): + ann_ds = ann_ds.create_index( + "vector", + index_type=index_type, + num_partitions=1, + max_iters=2, + sample_rate=2, + replace=case_index > 0, + **index_options, + ) + stats = ann_ds.stats.index_stats("vector_idx") + assert stats["index_type"] == index_type + assert stats["num_indices"] == 1 + indices = ann_ds.describe_indices() + assert len(indices) == 1 + assert indices[0].field_names == ["vector"] + + nearest = { + "column": "vector", + "q": vectors[0], + "k": 10, + "nprobes": 1, + "refine_factor": 4, + } + if "HNSW" in index_type: + nearest["ef"] = 64 + actual = ann_ds.to_table(columns=["id"], nearest=nearest) + expected = ann_ds.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": vectors[0], + "k": 10, + "use_index": False, + }, + ) + actual_ids = set(actual["id"].to_pylist()) + expected_ids = set(expected["id"].to_pylist()) + assert actual.num_rows == 10 + assert len(actual_ids) == 10 + assert len(actual_ids & expected_ids) / len(expected_ids) >= 0.5 + + +@pytest.mark.parametrize("approx_mode", ["normal", "accurate"]) +def test_ivf_rq_dot_query_scale_invariance(tmp_path, approx_mode): + rng = np.random.default_rng(20260921) + vectors = rng.normal(size=(128, 64)).astype(np.float32) + center = rng.normal(size=64).astype(np.float32) + vectors[:64] += 2 * center + vectors[64:] -= 2 * center + centroids = np.stack([2 * center, -2 * center]) + table = vec_to_table(data=vectors).append_column("id", pa.array(range(128))) + ds = lance.write_dataset(table, tmp_path / "scale.lance", max_rows_per_file=64) + ds = ds.create_index( "vector", - index_type="IVF_HNSW_PQ", - num_partitions=4, - num_sub_vectors=16, - replace=True, + "IVF_RQ", + metric="dot", + num_bits=5, + num_partitions=2, + ivf_centroids=centroids, ) - assert ann_ds.list_indices()[0]["type"] == "IVF_HNSW_PQ" - - -def test_create_dot_index(dataset, tmp_path): - assert not dataset.has_index - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") + assert len(ds.get_fragments()) == 2 + for q in vectors[[3, 97]]: + truth = set(np.argsort(-(vectors.astype(np.float64) @ q))[:10]) + for k in [10, len(vectors)]: + reference = None + for scale in [1.0, 0.125, 8.0]: + result = ds.to_table( + columns=["id", "_distance"], + nearest={ + "column": "vector", + "q": q * scale, + "k": k, + "metric": "dot", + "nprobes": 2, + "approx_mode": approx_mode, + }, + ) + ids = result["id"].to_numpy() + distances = result["_distance"].to_numpy() + assert len(set(ids[:10]) & truth) / 10 >= 0.5 + if reference is None: + reference = (ids, distances) + else: + np.testing.assert_array_equal(ids, reference[0]) + np.testing.assert_allclose( + distances, + 1 + scale * (reference[1] - 1), + rtol=2e-4, + atol=2e-4, + ) + + +def test_create_dot_index(tmp_path): + rng = np.random.default_rng(42) + table = vec_to_table(data=rng.standard_normal((64, 32), dtype=np.float32)) + ann_ds = lance.write_dataset(table, tmp_path / "indexed.lance") + assert not ann_ds.has_index ann_ds = ann_ds.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=1, + num_sub_vectors=4, + num_bits=4, + max_iters=2, metric="dot", ) assert ann_ds.has_index -def test_create_4bit_ivf_pq_index(dataset, tmp_path): - assert not dataset.has_index - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") +def test_create_4bit_ivf_pq_index(tmp_path): + rng = np.random.default_rng(42) + table = vec_to_table(data=rng.standard_normal((32, 32), dtype=np.float32)) + ann_ds = lance.write_dataset(table, tmp_path / "indexed.lance") + assert not ann_ds.has_index ann_ds = ann_ds.create_index( "vector", index_type="IVF_PQ", num_partitions=1, - num_sub_vectors=16, + num_sub_vectors=4, num_bits=4, + max_iters=2, metric="l2", ) index = ann_ds.stats.index_stats("vector_idx") @@ -653,6 +1200,12 @@ def test_create_ivf_pq_with_target_partition_size(dataset, tmp_path): assert ann_ds.stats.index_stats("vector_idx")["indices"][0]["num_partitions"] == 2 +def test_target_partition_size_to_num_partitions_clamps(): + assert _target_partition_size_to_num_partitions(1000, 1000) == 1 + assert _target_partition_size_to_num_partitions(1000, 500) == 2 + assert _target_partition_size_to_num_partitions(8192 * 5000, 8192) == 4096 + + def test_index_size_stats(tmp_path: Path): num_rows = 512 dims = 32 @@ -790,7 +1343,7 @@ def test_create_ivf_sq_index(dataset, tmp_path): index_type="IVF_SQ", num_partitions=4, ) - assert ann_ds.list_indices()[0]["fields"] == ["vector"] + assert ann_ds.describe_indices()[0].field_names == ["vector"] def test_create_ivf_rq_index(): @@ -799,22 +1352,25 @@ def test_create_ivf_rq_index(): "vector", index_type="IVF_RQ", num_partitions=4, - num_bits=1, ) - assert ds.list_indices()[0]["fields"] == ["vector"] + assert ds.describe_indices()[0].field_names == ["vector"] + stats = ds.stats.index_stats("vector_idx") + assert stats["indices"][0]["sub_index"]["num_bits"] == 5 + assert stats["indices"][0]["sub_index"]["packed"] is True - with pytest.raises( - NotImplementedError, - match="Creating empty vector indices with train=False is not yet implemented", - ): - ds.delete("id>=0") - ds = ds.create_index( - "vector", - index_type="IVF_RQ", - num_partitions=4, - num_bits=1, - replace=True, - ) + # An emptied table still takes the index; it carries its settings and + # covers nothing until there is data to train on. + ds.delete("id>=0") + ds = ds.create_index( + "vector", + index_type="IVF_RQ", + num_partitions=4, + num_bits=1, + replace=True, + ) + stats = ds.stats.index_stats("vector_idx") + assert stats["num_indexed_rows"] == 0 + assert stats["num_unindexed_rows"] == 0 zero_vectors = np.zeros((1000, 128)).astype(np.float32).tolist() tbl = pa.Table.from_pydict( @@ -840,53 +1396,149 @@ def test_create_ivf_rq_index(): assert res["_distance"].to_numpy().max() == 0.0 -def test_create_ivf_hnsw_pq_index(dataset, tmp_path): - assert not dataset.has_index - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") - ann_ds = ann_ds.create_index( +def test_build_rq_model_default_num_bits(): + from lance.lance import indices + + model = json.loads(indices.build_rq_model(dimension=8)) + assert model["num_bits"] == 5 + + +def test_create_ivf_rq_skip_transpose(): + ds = lance.write_dataset(create_table(), "memory://") + ds = ds.create_index( "vector", - index_type="IVF_HNSW_PQ", + index_type="IVF_RQ", num_partitions=4, - num_sub_vectors=16, + num_bits=1, + skip_transpose=True, ) - assert ann_ds.list_indices()[0]["fields"] == ["vector"] + stats = ds.stats.index_stats("vector_idx") + assert stats["indices"][0]["sub_index"]["packed"] is False -def test_create_ivf_hnsw_sq_index(dataset, tmp_path): - assert not dataset.has_index - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") - ann_ds = ann_ds.create_index( - "vector", - index_type="IVF_HNSW_SQ", - num_partitions=4, - num_sub_vectors=16, +def _assert_recall_at_least(ds, query, metric=None, k=10, recall_requirement=0.5): + nearest = {"column": "vector", "q": query, "k": k} + if metric is not None: + nearest["metric"] = metric + + gt_ids = ds.to_table(nearest=nearest, columns=["id"])["id"].to_numpy() + create_index_kwargs = { + "index_type": "IVF_RQ", + "num_partitions": 4, + "num_bits": 9, + } + if metric is not None: + create_index_kwargs["metric"] = metric + indexed = ds.create_index("vector", **create_index_kwargs) + result_ids = indexed.to_table(nearest=nearest, columns=["id"])["id"].to_numpy() + + assert result_ids.shape[0] == k + recall = len(set(gt_ids) & set(result_ids)) / k + assert recall >= recall_requirement, ( + f"recall={recall}, gt={gt_ids}, result={result_ids}" ) - assert ann_ds.list_indices()[0]["fields"] == ["vector"] + return indexed -def test_create_ivf_hnsw_flat_index(dataset, tmp_path): - assert not dataset.has_index - ann_ds = lance.write_dataset(dataset.to_table(), tmp_path / "indexed.lance") - ann_ds = ann_ds.create_index( +def test_create_ivf_rq_multi_bit_searches_l2_and_cosine(): + rng = np.random.default_rng(42) + mat = rng.standard_normal((1000, 128)).astype(np.float32) + tbl = vec_to_table(data=mat).append_column("id", pa.array(range(len(mat)))) + + ds = lance.write_dataset(tbl, "memory://") + ds = _assert_recall_at_least(ds, mat[0]) + stats = ds.stats.index_stats("vector_idx") + assert stats["indices"][0]["sub_index"]["num_bits"] == 9 + assert stats["indices"][0]["sub_index"]["query_estimator"] == "raw_query" + for approx_mode in ["fast", "normal", "accurate"]: + result = ds.to_table( + nearest={ + "column": "vector", + "q": mat[0], + "k": 10, + "approx_mode": approx_mode, + }, + columns=["id"], + ) + assert result.num_rows == 10 + + cosine_ds = lance.write_dataset(tbl, "memory://") + cosine_ds = _assert_recall_at_least(cosine_ds, mat[1], metric="cosine") + cosine_stats = cosine_ds.stats.index_stats("vector_idx") + assert cosine_stats["indices"][0]["sub_index"]["num_bits"] == 9 + assert cosine_stats["indices"][0]["sub_index"]["query_estimator"] == "raw_query" + + +def test_create_ivf_rq_requires_dim_divisible_by_8(): + vectors = np.zeros((1000, 30), dtype=np.float32).tolist() + tbl = pa.Table.from_pydict( + {"vector": pa.array(vectors, type=pa.list_(pa.float32(), 30))} + ) + ds = lance.write_dataset(tbl, "memory://", mode="overwrite") + + with pytest.raises( + ValueError, match="vector dimension must be divisible by 8 for IVF_RQ" + ): + ds.create_index( + "vector", + index_type="IVF_RQ", + num_partitions=4, + num_bits=1, + ) + + +def test_create_ivf_rq_mostly_null(): + ndim = 128 + nvec = 100 + nnull = 9900 + vectors = np.random.randn(nvec, ndim).astype(np.float32).tolist() + vectors += [None] * nnull + tbl = pa.table( + { + "vector": pa.array(vectors, type=pa.list_(pa.float32(), ndim)), + "id": pa.array(range(nvec + nnull), type=pa.int32()), + } + ) + ds = lance.write_dataset(tbl, "memory://") + ds = ds.create_index( "vector", - index_type="IVF_HNSW_FLAT", + index_type="IVF_RQ", num_partitions=4, - num_sub_vectors=16, + num_bits=1, + ) + + q = np.random.randn(ndim).astype(np.float32) + result = ds.to_table( + nearest={"column": "vector", "q": q, "k": 10}, ) - assert ann_ds.list_indices()[0]["fields"] == ["vector"] + assert result.num_rows == 10 def test_multivec_ann(indexed_multivec_dataset: lance.LanceDataset): - query = np.random.rand(5, 128) + rng = np.random.default_rng(42) + query = rng.random((5, 128)) results = indexed_multivec_dataset.scanner( - nearest={"column": "vector", "q": query, "k": 100} + nearest={ + "column": "vector", + "q": query, + "k": 100, + "nprobes": 1, + "refine_factor": 2, + } ).to_table() assert results.num_rows == 100 assert results["vector"].type == pa.list_(pa.list_(pa.float32(), 128)) assert len(results["vector"][0]) == 5 + ground_truth = indexed_multivec_dataset.to_table( + columns=["id"], + nearest={"column": "vector", "q": query, "k": 100, "use_index": False}, + ) + actual_ids = set(results["id"].to_pylist()) + expected_ids = set(ground_truth["id"].to_pylist()) + assert len(actual_ids & expected_ids) / len(expected_ids) >= 0.5 # query with single vector also works - query = np.random.rand(128) + query = rng.random(128) results = indexed_multivec_dataset.to_table( nearest={"column": "vector", "q": query, "k": 100} ) @@ -907,20 +1559,71 @@ def test_multivec_ann(indexed_multivec_dataset: lance.LanceDataset): ) # query with a vector that dim not match - query = np.random.rand(256) + query = rng.random(256) with pytest.raises(ValueError, match="does not match index column size"): indexed_multivec_dataset.to_table( nearest={"column": "vector", "q": query, "k": 100} ) # query with a list of vectors that some dim not match - query = [np.random.rand(128)] * 5 + [np.random.rand(256)] + query = [rng.random(128)] * 5 + [rng.random(256)] with pytest.raises(ValueError, match="All query vectors must have the same length"): indexed_multivec_dataset.to_table( nearest={"column": "vector", "q": query, "k": 100} ) +def test_multivec_search_paths(tmp_path: Path): + vector_type = pa.list_(pa.list_(pa.float32(), 2)) + query = np.array([[1.0, 0.0], [0.0, 1.0]], dtype=np.float32) + uri = tmp_path / "multivec_distance.lance" + + indexed_rows = pa.table( + { + "id": pa.array([0, 1], type=pa.int32()), + "vector": pa.array( + [ + [[1.0, 0.0], [0.0, 1.0]], + [[1.0, 0.0], [1.0, 0.0]], + ], + type=vector_type, + ), + } + ) + dataset = lance.write_dataset(indexed_rows, uri) + dataset = dataset.create_index( + "vector", + index_type="IVF_FLAT", + metric="cosine", + num_partitions=1, + ) + + unindexed_rows = pa.table( + { + "id": pa.array([2, 3], type=pa.int32()), + "vector": pa.array( + [ + [[1.0, 0.0], [0.0, 1.0]], + [[-1.0, 0.0], [0.0, -1.0]], + ], + type=vector_type, + ), + } + ) + dataset = lance.write_dataset(unindexed_rows, uri, mode="append") + + nearest = {"column": "vector", "q": query, "k": 4, "metric": "cosine"} + flat = dataset.to_table(columns=["id"], nearest={**nearest, "use_index": False}) + mixed = dataset.to_table(columns=["id"], nearest=nearest) + + dataset.optimize.optimize_indices() + fully_indexed = dataset.to_table(columns=["id"], nearest=nearest, fast_search=True) + + for result in [flat, mixed, fully_indexed]: + assert result["id"].to_pylist() == [0, 2, 1, 3] + np.testing.assert_allclose(result["_distance"].to_numpy(), [0.0, 0.0, 1.0, 2.0]) + + def test_pre_populated_ivf_centroids(dataset, tmp_path: Path): centroids = np.random.randn(5, 128).astype(np.float32) # IVF5 dataset_with_index = dataset.create_index( @@ -939,10 +1642,10 @@ def test_pre_populated_ivf_centroids(dataset, tmp_path: Path): )["id"].to_numpy() assert len(actual) == 10 - index_meta = dataset_with_index.list_indices()[0] - index_uuid = index_meta["uuid"] + index_meta = dataset_with_index.describe_indices()[0] + index_uuid = index_meta.segments[0].uuid assert len(index_uuid) == 36 - assert index_meta["fragment_ids"] == {0} + assert index_meta.segments[0].fragment_ids == {0} expected_filepath = str(tmp_path / "_indices" / index_uuid / "index.idx") if platform.system() == "Windows": @@ -987,6 +1690,65 @@ def test_pre_populated_ivf_centroids(dataset, tmp_path: Path): partition_keys = {"size"} assert all([partition_keys == set(p.keys()) for p in partitions]) + # num_partitions is deprecated in favor of target_partition_size, so + # centroids supplied without it must not be rejected. Seven clusters, so the + # assertion below tells the new index apart from the five-cluster one above + # and from the four target_partition_size would have picked. + new_centroids = np.random.randn(7, 128).astype(np.float32) + dataset_with_index = dataset.create_index( + ["vector"], + index_type="IVF_PQ", + metric="cosine", + ivf_centroids=new_centroids, + # 1000 rows / 250 = 4, so this diverges from the centroid count. + target_partition_size=250, + num_sub_vectors=8, + replace=True, + ) + stats = dataset_with_index.stats.index_stats("vector_idx") + assert stats["indices"][0]["num_partitions"] == 7 + + # A count that disagrees with an explicitly passed num_partitions is still + # rejected, and the message now names both numbers. + with pytest.raises(ValueError, match="but num_partitions=4"): + dataset.create_index( + ["vector"], + index_type="IVF_PQ", + metric="cosine", + ivf_centroids=new_centroids, + num_partitions=4, + num_sub_vectors=8, + ) + + # A zero-row array passes the 2D check, and the Rust residual step panics on + # the empty centroid buffer. + with pytest.raises(ValueError, match="at least one cluster"): + dataset.create_index( + ["vector"], + index_type="IVF_PQ", + metric="cosine", + ivf_centroids=np.empty((0, 128), dtype=np.float32), + num_sub_vectors=8, + # Otherwise the duplicate-name check intercepts first. + replace=True, + ) + + +def test_create_ivf_pq_skip_transpose(dataset, tmp_path: Path): + ds = lance.write_dataset( + dataset.to_table(), tmp_path / "indexed_skip_transpose.lance" + ) + ds = ds.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=16, + skip_transpose=True, + ) + + stats = ds.stats.index_stats("vector_idx") + assert stats["indices"][0]["sub_index"]["transposed"] is False + def test_optimize_index(dataset, tmp_path): dataset_uri = tmp_path / "dataset.lance" @@ -1208,6 +1970,24 @@ def test_knn_with_deletions(tmp_path): assert expected == [r.as_py() for r in results] +def test_create_index_warns_that_index_cache_size_is_ignored(tmp_path): + # The parameter is accepted for backwards compatibility but never reaches + # Rust, so the only honest behavior is to say so. + tbl = create_table(nvec=256, ndim=16) + dataset = lance.write_dataset(tbl, tmp_path / "test") + + with pytest.warns(DeprecationWarning, match="index_cache_size"): + dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=2, + num_sub_vectors=2, + index_cache_size=10, + ) + + assert len(dataset.describe_indices()) == 1 + + def test_index_cache_size(tmp_path): rng = np.random.default_rng(seed=42) @@ -1222,7 +2002,9 @@ def query_index(ds, ntimes, q=None): }, ) - tbl = create_table(nvec=1024, ndim=16) + # Each of the 128 partitions is trained only when the data can give it a + # 256-code codebook's worth of vectors. + tbl = create_table(nvec=128 * 256, ndim=16) dataset = lance.write_dataset(tbl, tmp_path / "test") dataset.create_index( @@ -1267,7 +2049,9 @@ def query_index(ds, ntimes, q=None): }, ) - tbl = create_table(nvec=1024, ndim=16) + # Each of the 128 partitions is trained only when the data can give it a + # 256-code codebook's worth of vectors. + tbl = create_table(nvec=128 * 256, ndim=16) dataset = lance.write_dataset(tbl, tmp_path / "test") dataset.create_index( @@ -1312,17 +2096,23 @@ def test_index_cache_size_deprecation(tmp_path): def test_f16_index(tmp_path: Path): - DIM = 64 + DIM = 32 + total = 256 uri = tmp_path / "f16data.lance" - f16_data = np.random.uniform(0, 1, 2048 * DIM).astype(np.float16) + rng = np.random.default_rng(42) + f16_data = rng.uniform(0, 1, total * DIM).astype(np.float16) fsl = pa.FixedSizeListArray.from_arrays(f16_data, DIM) tbl = pa.Table.from_pydict({"vector": fsl}) dataset = lance.write_dataset(tbl, uri) dataset.create_index( - "vector", index_type="IVF_PQ", num_partitions=4, num_sub_vectors=2 + "vector", + index_type="IVF_PQ", + num_partitions=1, + num_sub_vectors=4, + max_iters=2, ) - q = np.random.uniform(0, 1, DIM).astype(np.float16) + q = rng.uniform(0, 1, DIM).astype(np.float16) rst = dataset.to_table( nearest={ "column": "vector", @@ -1337,8 +2127,9 @@ def test_f16_index(tmp_path: Path): def test_vector_with_nans(tmp_path: Path): DIM = 32 - TOTAL = 2048 - data = np.random.uniform(0, 1, TOTAL * DIM).astype(np.float32) + TOTAL = 320 + rng = np.random.default_rng(42) + data = rng.uniform(0, 1, TOTAL * DIM).astype(np.float32) # Put the 1st vector as NaN. np.put(data, range(DIM, 2 * DIM), np.nan) @@ -1352,12 +2143,13 @@ def test_vector_with_nans(tmp_path: Path): ds = dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=2, - num_sub_vectors=2, + num_partitions=1, + num_sub_vectors=4, + max_iters=2, replace=True, ) tbl = ds.to_table( - nearest={"column": "vector", "q": data[0:DIM], "k": TOTAL, "nprobes": 2}, + nearest={"column": "vector", "q": data[0:DIM], "k": TOTAL, "nprobes": 1}, with_row_id=True, ) assert len(tbl) == TOTAL - 1 @@ -1413,32 +2205,39 @@ def test_dynamic_projection_with_vectors_index(tmp_path: Path): def test_index_cast_centroids(tmp_path): torch = pytest.importorskip("torch") - tbl = create_table(nvec=1000) + dim = 16 + rng = np.random.default_rng(42) + # Torch one-pass PQ emits an 8-bit codebook, which requires 256 rows. + tbl = vec_to_table(data=rng.standard_normal((256, dim), dtype=np.float32)) dataset = lance.write_dataset(tbl, tmp_path) dataset = dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=2, + num_sub_vectors=4, + max_iters=2, accelerator=torch.device("cpu"), ) # Get the centroids - index_name = dataset.list_indices()[0]["name"] + index_name = dataset.describe_indices()[0].name index_stats = dataset.stats.index_stats(index_name) centroids = index_stats["indices"][0]["centroids"] values = pa.array([x for arr in centroids for x in arr], pa.float32()) - centroids = pa.FixedSizeListArray.from_arrays(values, 128) + centroids = pa.FixedSizeListArray.from_arrays(values, dim) - dataset.alter_columns(dict(path="vector", data_type=pa.list_(pa.float16(), 128))) + # Cast invalidates the attached index; drop it first per the new contract. + dataset.drop_index(index_name) + dataset.alter_columns(dict(path="vector", data_type=pa.list_(pa.float16(), dim))) # centroids are f32, but the column is now f16 dataset = dataset.create_index( "vector", index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + num_partitions=2, + num_sub_vectors=4, + max_iters=2, accelerator=torch.device("cpu"), ivf_centroids=centroids, ) @@ -1506,14 +2305,14 @@ def test_fragment_scan_disallowed_on_ann_with_index_scan_prefilter(tmp_path): assert results == results_no_scalar_index -def test_load_indices(dataset): - indices = dataset.list_indices() +def test_describe_indices(dataset): + indices = dataset.describe_indices() assert len(indices) == 0 dataset.create_index( "vector", index_type="IVF_PQ", num_partitions=4, num_sub_vectors=16 ) - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 @@ -1521,9 +2320,8 @@ def test_describe_vector_index(indexed_dataset: LanceDataset): info = indexed_dataset.describe_indices()[0] assert info.name == "vector_idx" - assert info.type_url == "/lance.table.VectorIndexDetails" - # This is currently Unknown because vector indices are not yet handled by plugins - assert info.index_type == "Unknown" + assert info.type_url == "/lance.index.pb.VectorIndexDetails" + assert info.index_type == "IVF_PQ" assert info.num_rows_indexed == 1000 assert info.fields == [0] assert info.field_names == ["vector"] @@ -1533,40 +2331,256 @@ def test_describe_vector_index(indexed_dataset: LanceDataset): assert info.segments[0].index_version == 1 assert info.segments[0].created_at is not None + details = info.details + assert details["metric_type"] == "L2" + assert details["compression"]["type"] == "pq" + assert details["compression"]["num_bits"] == 8 + assert details["compression"]["num_sub_vectors"] == 16 + + +def test_describe_index_runtime_hints_stored(tmp_path): + tbl = create_table(nvec=300, ndim=16) + dataset = lance.write_dataset(tbl, tmp_path) + dataset = dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=4, + num_sub_vectors=4, + max_iters=100, + sample_rate=512, + ) + details = dataset.describe_indices()[0].details + hints = details.get("runtime_hints", {}) + assert hints.get("lance.ivf.max_iters") == "100" + assert hints.get("lance.ivf.sample_rate") == "512" + assert hints.get("lance.pq.max_iters") == "100" + assert hints.get("lance.pq.sample_rate") == "512" + def test_optimize_indices(indexed_dataset): data = create_table() indexed_dataset = lance.write_dataset(data, indexed_dataset.uri, mode="append") - indices = indexed_dataset.list_indices() - assert len(indices) == 1 + stats = indexed_dataset.stats.index_stats("vector_idx") + assert stats["num_indices"] == 1 indexed_dataset.optimize.optimize_indices(num_indices_to_merge=0) - indices = indexed_dataset.list_indices() - assert len(indices) == 2 + stats = indexed_dataset.stats.index_stats("vector_idx") + assert stats["num_indices"] == 2 -@pytest.mark.skip(reason="retrain is deprecated") -def test_retrain_indices(indexed_dataset): - data = create_table() - indexed_dataset = lance.write_dataset(data, indexed_dataset.uri, mode="append") - indices = indexed_dataset.list_indices() - assert len(indices) == 1 +@pytest.mark.parametrize("enable_stable_row_ids", [False, True]) +def test_segment_ownership_filter_precedes_partition_topk( + tmp_path, enable_stable_row_ids +): + ndim = 4 + + def table(ids, value): + vectors = np.full((len(ids), ndim), value, dtype=np.float32) + return pa.table( + { + "id": pa.array(ids, type=pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(vectors.reshape(-1), type=pa.float32()), ndim + ), + } + ) - indexed_dataset.optimize.optimize_indices(num_indices_to_merge=0) - indices = indexed_dataset.list_indices() - assert len(indices) == 2 + dataset = lance.write_dataset( + table(range(20), 1.0), + tmp_path, + mode="create", + enable_stable_row_ids=enable_stable_row_ids, + ) + dataset = lance.write_dataset( + table(range(100, 120), 0.0), dataset.uri, mode="append" + ) + dataset = dataset.create_index( + "vector", index_type="IVF_FLAT", metric="l2", num_partitions=1 + ) + + fragment = dataset.get_fragment(1) + row_ids = fragment.to_table(columns=["id"], with_row_id=True)["_rowid"].to_pylist() + update_data = pa.table( + { + "_rowid": pa.array(row_ids, type=pa.uint64()), + "vector": pa.array( + [[10.0] * ndim] * len(row_ids), type=pa.list_(pa.float32(), ndim) + ), + } + ) + updated_fragment, fields_modified = fragment.update_columns(update_data) + dataset = lance.LanceDataset.commit( + dataset.uri, + lance.LanceOperation.Update( + updated_fragments=[updated_fragment], fields_modified=fields_modified + ), + read_version=dataset.version, + ) + dataset.optimize.optimize_indices(num_indices_to_merge=0) + dataset = lance.dataset(dataset.uri) + + def assert_current_nearest_rows(): + result = dataset.to_table( + columns=["id"], + nearest={ + "column": "vector", + "q": np.zeros(ndim, dtype=np.float32), + "k": 5, + }, + ) + + assert all(row_id < 20 for row_id in result["id"].to_pylist()) + assert result["_distance"].to_pylist() == pytest.approx([4.0] * 5) + + assert_current_nearest_rows() + dataset.optimize.optimize_indices(num_indices_to_merge=2) + dataset = lance.dataset(dataset.uri) + assert_current_nearest_rows() + + +def test_no_stale_duplicate_after_partial_column_update(tmp_path): + # Regression test: updating an indexed vector column in place (via the + # low-level fragment.update_columns API + LanceOperation.Update) and then + # delta-optimizing the index must not leave a stale copy of the row in the + # original index segment. + # + # Mechanism: update_columns rewrites only the column data file, keeping the + # fragment id and row address. Committing the Update prunes the fragment + # from the old index segment's fragment_bitmap, but that segment's index + # file still physically holds the row's OLD vector. optimize_indices then + # builds a new delta segment with the NEW vector. Before the fix a KNN query + # searched both segments and returned the updated row TWICE - once with the + # stale vector (old segment) and once with the new value (delta segment). + np.random.seed(42) + ndim = 16 + + # Fragment 0: a "far" cluster bounded to [-1, 1]. No bulk vector is close to + # the query (all-10.8), so the bulk cannot crowd the stale copy out of top-k. + n_bulk = 1000 + bulk = np.random.uniform(-1, 1, (n_bulk, ndim)).astype(np.float32) + table0 = pa.table( + { + "id": pa.array(range(n_bulk), type=pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(bulk.reshape(-1), type=pa.float32()), list_size=ndim + ), + } + ) + ds = lance.write_dataset(table0, tmp_path, mode="create") + + # Fragment 1: a single row whose ORIGINAL vector (all 2.0) is closer to the + # query than any bulk vector, so its stale copy ranks well inside top-k. + orig = np.full((1, ndim), 2.0, dtype=np.float32) + table1 = pa.table( + { + "id": pa.array([10_000], type=pa.int64()), + "vector": pa.FixedSizeListArray.from_arrays( + pa.array(orig.reshape(-1), type=pa.float32()), list_size=ndim + ), + } + ) + ds = lance.write_dataset(table1, tmp_path, mode="append") + assert len(ds.get_fragments()) == 2 + + # One index segment covering BOTH fragments {0, 1}. + ds = ds.create_index( + "vector", + index_type="IVF_PQ", + metric="l2", + num_partitions=1, + num_sub_vectors=ndim, + ) + + # Overwrite fragment 1's vector in place and commit Update(fields_modified). + new_vec = [10.8] * ndim + frag = ds.get_fragment(1) + rowids = frag.to_table(columns=["id"], with_row_id=True)["_rowid"].to_pylist() + update_data = pa.table( + { + "_rowid": pa.array(rowids, type=pa.uint64()), + "vector": pa.array( + [new_vec] * len(rowids), type=pa.list_(pa.float32(), ndim) + ), + } + ) + updated_fragment, fields_modified = frag.update_columns(update_data) + op = lance.LanceOperation.Update( + updated_fragments=[updated_fragment], + fields_modified=fields_modified, + ) + ds = lance.LanceDataset.commit(ds.uri, op, read_version=ds.version) + + # Delta-optimize: appends a new segment for the updated fragment; the old + # segment is left intact, still physically holding the stale vector. + ds.optimize.optimize_indices(num_indices_to_merge=0) + ds = lance.dataset(ds.uri) + assert ds.stats.index_stats("vector_idx")["num_indices"] == 2 + + # KNN near the NEW value via the default vector search (searches all + # segments). The updated row must appear EXACTLY ONCE. + # + # This pins the filtering only. With a single partition the late search + # returns before the shared budget is consulted, so the accounting half of + # the fix is pinned by the Rust unit test + # `test_unowned_row_does_not_fill_the_shared_budget` instead. + q = np.array(new_vec, dtype=np.float32) + res = ds.to_table( + columns=["id"], + nearest={"column": "vector", "q": q, "k": 10}, + with_row_id=True, + ).to_pandas() + dupes = res[res["id"] == 10_000] + assert len(dupes) == 1, ( + f"updated row id=10000 returned {len(dupes)} times " + f"(stale index segment not masked); rowids={res['_rowid'].tolist()}" + ) + # A mask that over-restricts would drop the old segment wholesale and still + # satisfy the assertion above, so pin the full result set too. + assert len(res) == 10, f"expected a full top-10, got {len(res)} rows" + assert res["id"].is_unique, f"duplicate ids in result: {res['id'].tolist()}" + + +@pytest.mark.parametrize("retrain", [None, False, True]) +def test_retrain_indices(tmp_path, retrain): + rng = np.random.default_rng(42) + ndim = 16 + initial_vectors = rng.standard_normal((64, ndim), dtype=np.float32) + appended_vectors = rng.standard_normal((64, ndim), dtype=np.float32) + 100 + old_centroid = np.full((1, ndim), -1000, dtype=np.float32) + + indexed_dataset = lance.write_dataset(vec_to_table(initial_vectors), tmp_path) + indexed_dataset = indexed_dataset.create_index( + "vector", + index_type="IVF_FLAT", + num_partitions=1, + ivf_centroids=old_centroid, + index_file_version=IndexFileVersion.V3, + ) + indexed_dataset = lance.write_dataset( + vec_to_table(appended_vectors), indexed_dataset.uri, mode="append" + ) stats = indexed_dataset.stats.index_stats("vector_idx") - centroids = stats["indices"][0]["centroids"] - delta_centroids = stats["indices"][1]["centroids"] - assert centroids == delta_centroids + assert stats["num_indices"] == 1 - indexed_dataset.optimize.optimize_indices(retrain=True) - new_centroids = indexed_dataset.stats.index_stats("vector_idx")["indices"][0][ - "centroids" - ] - indices = indexed_dataset.list_indices() - assert len(indices) == 1 - assert centroids != new_centroids + indexed_dataset.optimize.optimize_indices(num_indices_to_merge=0) + stats = indexed_dataset.stats.index_stats("vector_idx") + assert stats["num_indices"] == 2 + assert all( + index["centroids"] == old_centroid.tolist() for index in stats["indices"] + ) + + kwargs = {} if retrain is None else {"retrain": retrain} + indexed_dataset.optimize.optimize_indices(**kwargs) + stats = indexed_dataset.stats.index_stats("vector_idx") + centroids = [index["centroids"] for index in stats["indices"]] + if retrain: + expected_centroid = np.concatenate([initial_vectors, appended_vectors]).mean( + axis=0 + ) + assert stats["num_indices"] == 1 + assert np.allclose(centroids[0][0], expected_centroid) + else: + assert all(centroid == old_centroid.tolist() for centroid in centroids) def test_no_include_deleted_rows(indexed_dataset): @@ -1583,10 +2597,10 @@ def test_no_include_deleted_rows(indexed_dataset): def test_drop_indices(indexed_dataset): - idx_name = indexed_dataset.list_indices()[0]["name"] + idx_name = indexed_dataset.describe_indices()[0].name indexed_dataset.drop_index(idx_name) - indices = indexed_dataset.list_indices() + indices = indexed_dataset.describe_indices() assert len(indices) == 0 test_vec = ( @@ -1607,7 +2621,7 @@ def test_drop_indices(indexed_dataset): def test_read_partition(indexed_dataset): - idx_name = indexed_dataset.list_indices()[0]["name"] + idx_name = indexed_dataset.describe_indices()[0].name reader = VectorIndexReader(indexed_dataset, idx_name) num_rows = indexed_dataset.count_rows() @@ -1636,6 +2650,34 @@ def test_read_partition(indexed_dataset): VectorIndexReader(indexed_dataset, "id_idx") +def test_read_partition_nested_vector_quoted_field(tmp_path): + num_rows = 1024 + dimensions = 8 + rng = np.random.default_rng(42) + values = rng.integers(0, 256, size=num_rows * dimensions, dtype=np.uint8) + vectors = pa.FixedSizeListArray.from_arrays(pa.array(values), dimensions) + nested = pa.StructArray.from_arrays([vectors], names=["embedding.v1"]) + dataset = lance.write_dataset(pa.table({"data": nested}), tmp_path) + # Match nested uint8 pHash indexes without introducing PQ training setup. + dataset = dataset.create_index( + "data.`embedding.v1`", + index_type="IVF_FLAT", + name="vector_idx", + metric="hamming", + num_partitions=4, + ) + + reader = VectorIndexReader(dataset, "vector_idx") + for with_vector in (False, True): + partitions = [ + reader.read_partition(partition_id, with_vector=with_vector) + for partition_id in range(reader.num_partitions()) + ] + + assert all("_rowid" in partition.column_names for partition in partitions) + assert sum(partition.num_rows for partition in partitions) == num_rows + + def test_vector_index_with_prefilter_and_scalar_index(indexed_dataset): uri = indexed_dataset.uri new_table = create_table() @@ -1678,12 +2720,13 @@ def test_vector_index_with_nprobes(indexed_dataset): "column": "vector", "q": np.random.randn(128), "k": 10, + "nprobes": 10, "minimum_nprobes": 7, } ).explain_plan() assert "minimum_nprobes=7" in res - assert "maximum_nprobes=None" in res + assert "maximum_nprobes=Some(10)" in res res = indexed_dataset.scanner( nearest={ @@ -1691,23 +2734,97 @@ def test_vector_index_with_nprobes(indexed_dataset): "q": np.random.randn(128), "k": 10, "minimum_nprobes": 7, - "maximum_nprobes": 10, } ).explain_plan() assert "minimum_nprobes=7" in res - assert "maximum_nprobes=Some(10)" in res + assert "maximum_nprobes=None" in res res = indexed_dataset.scanner( nearest={ "column": "vector", "q": np.random.randn(128), "k": 10, - "maximum_nprobes": 30, - } + "minimum_nprobes": 7, + "maximum_nprobes": 10, + } + ).explain_plan() + + assert "minimum_nprobes=7" in res + assert "maximum_nprobes=Some(10)" in res + + res = indexed_dataset.scanner( + nearest={ + "column": "vector", + "q": np.random.randn(128), + "k": 10, + "maximum_nprobes": 30, + } ).analyze_plan() +def test_vector_index_with_query_parallelism(indexed_dataset): + q = np.random.randn(128) + + sequential = indexed_dataset.to_table( + nearest={ + "column": "vector", + "q": q, + "k": 10, + "query_parallelism": 0, + } + ) + parallel = indexed_dataset.to_table( + nearest={ + "column": "vector", + "q": q, + "k": 10, + "query_parallelism": -1, + } + ) + + assert sequential == parallel + + +def test_vector_index_invalid_query_parallelism(indexed_dataset): + with pytest.raises(ValueError, match="query_parallelism"): + indexed_dataset.scanner( + nearest={ + "column": "vector", + "q": np.random.randn(128), + "k": 10, + "query_parallelism": -2, + } + ) + + +def test_vector_index_with_approx_mode(indexed_dataset): + q = np.random.randn(128) + + for approx_mode in ["fast", "normal", "accurate"]: + result = indexed_dataset.to_table( + nearest={ + "column": "vector", + "q": q, + "k": 10, + "approx_mode": approx_mode, + } + ) + assert len(result) == 10 + + +def test_vector_index_invalid_approx_mode(indexed_dataset): + with pytest.raises(ValueError, match="approx_mode"): + indexed_dataset.scanner( + nearest={ + "column": "vector", + "q": np.random.randn(128), + "k": 10, + "approx_mode": "hacc", + } + ) + + def test_knn_deleted_rows(tmp_path): data = create_table() ds = lance.write_dataset(data, tmp_path) @@ -1730,12 +2847,10 @@ def test_knn_deleted_rows(tmp_path): def test_nested_field_vector_index(tmp_path): - """Test vector index creation and querying on nested fields + """Test IVF_PQ indices on a vector field nested in a struct. - Note: While scalar indices work on nested fields, vector indices currently - have a limitation in the DataFusion integration layer that prevents them - from working with nested field paths. The Python validation layer now - correctly handles nested paths, but the Rust planner needs additional work. + Cover partition reads, nearest queries, appends, index optimization, and + cosine distance using the nested field path ``data.embedding``. """ # Create a dataset with nested vector field dimensions = 128 @@ -1789,9 +2904,17 @@ def test_nested_field_vector_index(tmp_path): ) # Verify index was created - indices = dataset.list_indices() + indices = dataset.describe_indices() assert len(indices) == 1 - assert indices[0]["fields"] == ["data.embedding"] + assert indices[0].field_names == ["data.embedding"] + + reader = VectorIndexReader(dataset, indices[0].name) + for with_vector in (False, True): + partition_rows = sum( + reader.read_partition(partition_id, with_vector=with_vector).num_rows + for partition_id in range(reader.num_partitions()) + ) + assert partition_rows == num_rows # Test querying with the index query_vec = vectors[0] @@ -1889,6 +3012,24 @@ def func(rs: pa.Table): run(dataset, q=np.array(q), assert_func=func) +def test_scanner_rejects_unknown_index_segments(tmp_path): + tbl = create_table() + dataset = lance.write_dataset(tbl, tmp_path) + dataset = dataset.create_index("vector", index_type="IVF_FLAT", num_partitions=4) + + with pytest.raises( + ValueError, match="with_index_segments referenced unknown index segments" + ): + dataset.scanner( + nearest={ + "column": "vector", + "q": np.random.randn(128).astype(np.float32), + "k": 10, + }, + index_segments=[uuid.uuid4()], + ).to_table() + + def test_vector_index_distance_range(tmp_path): """Ensure vector index honors distance_range.""" ndim = 128 @@ -1971,7 +3112,7 @@ def test_vector_index_distance_range(tmp_path): assert np.all(index_distances >= distance_range[0]) and np.all( index_distances < distance_range[1] ) - assert np.allclose(brute_distances, index_distances, rtol=0.0, atol=0.0) + assert np.allclose(brute_distances, index_distances, rtol=1e-5, atol=0.0) # ============================================================================= @@ -2012,38 +3153,64 @@ 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 + 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( @@ -2062,17 +3229,16 @@ def assert_distributed_vector_consistency( """Recall-only consistency check between single-machine and distributed indices. This helper keeps the original signature for compatibility but ignores - similarity_metric/similarity_threshold. It compares recall@K against a ground - truth computed via exact search (use_index=False) on the single dataset and - asserts that the recall difference between single-machine and distributed - indices is within 10%. + similarity_metric. It compares recall@K against a ground truth computed via + exact search (use_index=False), requires both indices to reach at least 0.5 + recall, and bounds their recall difference with similarity_threshold. Steps ----- 1) Write `data` to two URIs (single, distributed); ensure distributed has >=2 fragments (rewrite with max_rows_per_file if needed) 2) Build a single-machine index via `create_index` - 3) Global training (IVF/PQ) using `IndicesBuilder.prepare_global_ivfpq` when + 3) Global training (IVF/PQ) using `IndicesBuilder.prepare_global_ivf_pq` when appropriate; for IVF_FLAT/SQ variants, train IVF centroids via `IndicesBuilder.train_ivf` 4) Build the distributed index via @@ -2080,11 +3246,12 @@ def assert_distributed_vector_consistency( preprocessed artifacts 5) For each query, compute ground-truth TopK IDs using exact search (use_index=False), then compute TopK using single index and the distributed - index with consistent nearest settings (refine_factor=1; IVF uses nprobes) - 6) Compute recall for single and distributed using the provided formula and - assert the absolute difference is <= 0.10. Also print the recalls. + index with consistent nearest settings (refine_factor=100; IVF probes all + fixture partitions) + 6) Compute recall for single and distributed, require each to be >= 0.5, + and bound their absolute difference with similarity_threshold. """ - # Keep signature compatibility but ignore similarity_metric/threshold + # Keep signature compatibility but ignore the superseded metric selector. _ = similarity_metric index_params = index_params or {} @@ -2110,33 +3277,37 @@ def assert_distributed_vector_consistency( data, dist_uri, mode="overwrite", max_rows_per_file=500 ) + num_rows = single_ds.count_rows() + nparts = index_params.get("num_partitions", None) + is_pq = index_type in {"IVF_PQ", "IVF_HNSW_PQ"} + # Eight-bit PQ needs at least 256 centroids and sample_rate >= 2. + sample_rate = 2 if is_pq else min(8, num_rows // max(1, nparts or 1)) + max_iters = index_params.get("max_iters", 5) + build_params = dict(index_params) + build_params.setdefault("sample_rate", sample_rate) + build_params.setdefault("max_iters", max_iters) + # Build single-machine index single_ds = single_ds.create_index( column=column, index_type=index_type, - **index_params, + **build_params, ) # Global training / preparation for distributed build preprocessed = None builder = IndicesBuilder(single_ds, column) - nparts = index_params.get("num_partitions", None) nsub = index_params.get("num_sub_vectors", None) dist_type = index_params.get("metric", "l2") - num_rows = single_ds.count_rows() - - # Choose a safe sample_rate that satisfies IVF (nparts*sr <= rows) and PQ - # (256*sr <= rows). Minimum 2 as required by builder verification. - safe_sr_ivf = num_rows // max(1, nparts or 1) - safe_sr_pq = num_rows // 256 - safe_sr = max(2, min(safe_sr_ivf, safe_sr_pq)) - if index_type in {"IVF_PQ", "IVF_HNSW_PQ"}: + if is_pq: + assert num_rows >= 512, "8-bit PQ training requires at least 512 rows" preprocessed = builder.prepare_global_ivf_pq( nparts, nsub, distance_type=dist_type, - sample_rate=safe_sr, + sample_rate=sample_rate, + max_iters=max_iters, ) elif ( ("IVF_FLAT" in index_type) @@ -2146,7 +3317,8 @@ def assert_distributed_vector_consistency( ivf_model = builder.train_ivf( nparts, distance_type=dist_type, - sample_rate=safe_sr, + sample_rate=sample_rate, + max_iters=max_iters, ) preprocessed = {"ivf_centroids": ivf_model.centroids} @@ -2200,7 +3372,7 @@ def assert_distributed_vector_consistency( # Consistent nearest settings for index-based search nearest = {"column": column, "q": q, "k": topk, "refine_factor": 100} if "IVF" in index_type: - nearest["nprobes"] = max(16, int(index_params.get("num_partitions", 4)) * 4) + nearest["nprobes"] = int(index_params.get("num_partitions", 4)) if "HNSW" in index_type: # Ensure ef is large enough even when refine_factor multiplies k for HNSW effective_k = topk * int( @@ -2228,10 +3400,18 @@ def compute_recall(gt: np.ndarray, result: np.ndarray) -> float: rs = compute_recall(gt_ids, single_ids) rd = compute_recall(gt_ids, dist_ids) - # Assert recall difference within 10% - assert abs(rs - rd) <= 1 - similarity_threshold, ( + assert rs >= 0.5, ( + f"Single-machine {index_type} recall below 0.5: recall={rs:.3f}, " + f"num_partitions={nparts}, topk={topk}, queries={len(queries)}" + ) + assert rd >= 0.5, ( + f"Distributed {index_type} recall below 0.5: recall={rd:.3f}, " + f"num_partitions={nparts}, topk={topk}, queries={len(queries)}" + ) + max_recall_difference = 1 - similarity_threshold + assert abs(rs - rd) <= max_recall_difference, ( f"Recall difference too large: single={rs:.3f}, distributed={rd:.3f}, " - f"diff={abs(rs - rd):.3f} (> {similarity_threshold})" + f"diff={abs(rs - rd):.3f} (> {max_recall_difference:.3f})" ) # Cleanup temporary directory if used @@ -2250,463 +3430,267 @@ def _make_sample_dataset_base( max_rows_per_file: int = 500, ): """Common helper to construct sample datasets for distributed index tests.""" - mat = np.random.rand(n_rows, dim).astype(np.float32) + mat = np.random.default_rng(42).random((n_rows, dim), dtype=np.float32) ids = np.arange(n_rows) - arr = pa.array(mat.tolist(), type=pa.list_(pa.float32(), dim)) + arr = pa.FixedSizeListArray.from_arrays(pa.array(mat.reshape(-1)), dim) tbl = pa.table({"id": ids, "vector": arr}) return lance.write_dataset( tbl, tmp_path / name, max_rows_per_file=max_rows_per_file ) -def test_prepared_global_ivfpq_distributed_merge_and_search(tmp_path: Path): - ds = _make_sample_dataset_base(tmp_path, "preproc_ds", 2000, 128) - - # Global preparation - builder = IndicesBuilder(ds, "vector") - preprocessed = builder.prepare_global_ivf_pq( - num_partitions=4, - num_subvectors=4, - distance_type="l2", - sample_rate=3, - max_iters=20, +@pytest.mark.parametrize( + "index_type", + [ + "IVF_FLAT", + "IVF_PQ", + "IVF_SQ", + ], +) +def test_distributed_ivf_two_shard_build_merge_and_search(tmp_path, index_type): + dim = 32 + num_partitions = 2 + ds = _make_sample_dataset_base( + tmp_path, + f"dist_{index_type.lower()}", + n_rows=640, + dim=dim, + max_rows_per_file=320, ) + frags = ds.get_fragments() + assert len(frags) == 2 + fragment_groups = [[fragment.fragment_id] for fragment in frags] + builder = IndicesBuilder(ds, "vector") + build_kwargs = {"num_partitions": num_partitions} + if index_type == "IVF_PQ": + preprocessed = builder.prepare_global_ivf_pq( + num_partitions=num_partitions, + num_subvectors=4, + distance_type="l2", + sample_rate=2, + max_iters=2, + ) + assert set(preprocessed) == {"ivf_centroids", "pq_codebook"} + assert len(preprocessed["ivf_centroids"]) == num_partitions + assert preprocessed["ivf_centroids"].type.list_size == dim + assert len(preprocessed["pq_codebook"]) > 0 + assert preprocessed["pq_codebook"].type.list_size == dim + build_kwargs.update( + num_sub_vectors=4, + ivf_centroids=preprocessed["ivf_centroids"], + pq_codebook=preprocessed["pq_codebook"], + ) + else: + ivf_model = builder.train_ivf( + num_partitions=num_partitions, + distance_type="l2", + sample_rate=8, + max_iters=2, + ) + build_kwargs["ivf_centroids"] = ivf_model.centroids - # Distributed build using prepared centroids/codebook - ds = build_distributed_vector_index( + segments = _build_segments( ds, "vector", - index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=4, - world=2, - ivf_centroids=preprocessed["ivf_centroids"], - pq_codebook=preprocessed["pq_codebook"], + index_type, + fragment_groups, + index_name="vector_idx", + **build_kwargs, ) + assert len(segments) == 2 + ds = _commit_segments_helper(ds, segments, column="vector") - # Query sanity - q = np.random.rand(128).astype(np.float32) - results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) - assert 0 < len(results) <= 10 - + stats = ds.stats.index_stats("vector_idx") + assert stats["index_type"] == index_type + q = np.random.default_rng(43).random(dim, dtype=np.float32) + results = ds.to_table( + nearest={ + "column": "vector", + "q": q, + "k": 5, + "nprobes": num_partitions, + "refine_factor": 10, + } + ) + assert 0 < len(results) <= 5 -def test_consistency_improves_with_preprocessed_centroids(tmp_path: Path): - ds = _make_sample_dataset_base(tmp_path, "preproc_ds", 2000, 128) - builder = IndicesBuilder(ds, "vector") - pre = builder.prepare_global_ivf_pq( - num_partitions=4, - num_subvectors=16, - distance_type="l2", - sample_rate=7, - max_iters=20, +def test_commit_existing_index_segments_accepts_index_metadata(tmp_path): + ds = _make_sample_dataset_base( + tmp_path, "legacy_metadata_commit", n_rows=512, dim=32, max_rows_per_file=256 ) + frags = ds.get_fragments() + assert len(frags) == 2 - # Build single-machine index as ground truth target index - single_ds = lance.write_dataset(ds.to_table(), tmp_path / "single_ivfpq") - single_ds = single_ds.create_index( - column="vector", - index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, + ivf_model = IndicesBuilder(ds, "vector").train_ivf( + num_partitions=2, + distance_type="l2", + sample_rate=8, + max_iters=2, ) - - # Distributed with preprocessed IVF centroids - dist_pre = lance.write_dataset(ds.to_table(), tmp_path / "dist_pre") - dist_pre = build_distributed_vector_index( - dist_pre, - "vector", - index_type="IVF_PQ", - num_partitions=4, - num_sub_vectors=16, - world=2, - ivf_centroids=pre["ivf_centroids"], - pq_codebook=pre["pq_codebook"], + base_kwargs = { + "column": "vector", + "index_type": "IVF_FLAT", + "num_partitions": 2, + "ivf_centroids": ivf_model.centroids, + } + first = ds.create_index_uncommitted( + **base_kwargs, + fragment_ids=[frags[0].fragment_id], ) - - # Evaluate recall vs exact search - q = np.random.rand(128).astype(np.float32) - topk = 10 - gt = single_ds.to_table( - nearest={"column": "vector", "q": q, "k": topk, "use_index": False} + second = ds.create_index_uncommitted( + **base_kwargs, + fragment_ids=[frags[1].fragment_id], ) - res_pre = dist_pre.to_table(nearest={"column": "vector", "q": q, "k": topk}) - - gt_ids = gt["id"].to_pylist() - pre_ids = res_pre["id"].to_pylist() - def _recall(gt_ids, res_ids): - s = set(int(x) for x in gt_ids) - d = set(int(x) for x in res_ids) - return len(s & d) / max(1, len(s)) + merged = ds.merge_existing_index_segments([first, second]) + ds = ds.commit_existing_index_segments("vector_idx", "vector", [merged]) - recall_pre = _recall(gt_ids, pre_ids) + q = np.random.rand(32).astype(np.float32) + results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) + assert 0 < len(results) <= 5 - # Expect some non-zero recall with preprocessed IVF centroids - if recall_pre < 0.10: - pytest.skip( - "Distributed IVF_PQ recall below threshold in current " - "environment - known issue" - ) - assert recall_pre >= 0.10 +def test_distributed_ivf_rq_shared_rotation(tmp_path): + """Two IVF_RQ segments built on separate fragments with one shared RaBitQ rotation + merge into a single committed, queryable index. The shared ``rabitq_model`` (from + ``lance.lance.indices.build_rq_model``) is what makes the independently built + segments mergeable.""" + from lance.lance import indices -def test_metadata_merge_pq_success(tmp_path): - ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) - frags = ds.get_fragments() - assert len(frags) >= 2, "Need at least 2 fragments for distributed testing" - 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, - num_subvectors=16, - distance_type="l2", - sample_rate=7, - 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, - 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") - q = np.random.rand(128).astype(np.float32) - results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) - assert 0 < len(results) <= 10 - except ValueError as e: - raise e - - -def test_distributed_workflow_merge_and_search(tmp_path): - """End-to-end: build IVF_PQ on two groups, merge, and verify search returns - results.""" - ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) - 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:]] - builder = IndicesBuilder(ds, "vector") - pre = builder.prepare_global_ivf_pq( - num_partitions=4, - num_subvectors=4, - distance_type="l2", - sample_rate=7, - max_iters=20, + dim = 32 + ds = _make_sample_dataset_base( + tmp_path, "dist_rq_merge", n_rows=512, dim=dim, max_rows_per_file=256 ) - 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, - 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") - q = np.random.rand(128).astype(np.float32) - results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) - assert 0 < len(results) <= 10 - except ValueError as e: - raise e - - -def test_vector_merge_two_shards_success_flat(tmp_path): - ds = _make_sample_dataset_base(tmp_path, "dist_ds", 1000, 128) frags = ds.get_fragments() - assert len(frags) >= 2 - shard1 = [frags[0].fragment_id] - shard2 = [frags[1].fragment_id] - shared_uuid = str(uuid.uuid4()) + assert len(frags) == 2 - # Global preparation - builder = IndicesBuilder(ds, "vector") - preprocessed = builder.prepare_global_ivf_pq( - num_partitions=4, - num_subvectors=4, + ivf_model = IndicesBuilder(ds, "vector").train_ivf( + num_partitions=2, distance_type="l2", - sample_rate=3, - max_iters=20, + sample_rate=8, + max_iters=2, ) - - 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"], + rabitq_model = indices.build_rq_model(dimension=dim, num_bits=1) + base_kwargs = { + "column": "vector", + "index_type": "IVF_RQ", + "num_partitions": 2, + "num_bits": 1, + "ivf_centroids": ivf_model.centroids, + "rabitq_model": rabitq_model, + } + first = ds.create_index_uncommitted( + **base_kwargs, + fragment_ids=[frags[0].fragment_id], ) - ds.create_index( - column="vector", - index_type="IVF_FLAT", - fragment_ids=shard2, - index_uuid=shared_uuid, - num_partitions=4, - num_sub_vectors=128, - ivf_centroids=preprocessed["ivf_centroids"], - pq_codebook=preprocessed["pq_codebook"], + second = ds.create_index_uncommitted( + **base_kwargs, + fragment_ids=[frags[1].fragment_id], ) - ds._ds.merge_index_metadata(shared_uuid, "IVF_FLAT", None) - ds = _commit_index_helper(ds, shared_uuid, 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 - - -@pytest.mark.parametrize( - "index_type,num_sub_vectors", - [ - ("IVF_PQ", 4), - ("IVF_FLAT", 128), - ], -) -def test_distributed_ivf_parameterized(tmp_path, index_type, num_sub_vectors): - ds = _make_sample_dataset_base(tmp_path, "dist_ds", 2000, 128) - frags = ds.get_fragments() - assert len(frags) >= 2 - 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, - num_subvectors=num_sub_vectors, - distance_type="l2", - sample_rate=7, - max_iters=20, - ) - - try: - base_kwargs = dict( - column="vector", - index_type=index_type, - index_uuid=shared_uuid, - num_partitions=4, - num_sub_vectors=num_sub_vectors, - ) - - kwargs1 = dict(base_kwargs, fragment_ids=node1) - kwargs2 = dict(base_kwargs, fragment_ids=node2) - - if pre is not None: - kwargs1.update( - ivf_centroids=pre["ivf_centroids"], pq_codebook=pre["pq_codebook"] - ) - kwargs2.update( - 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") - - q = np.random.rand(128).astype(np.float32) - results = ds.to_table(nearest={"column": "vector", "q": q, "k": 10}) - assert 0 < len(results) <= 10 - except ValueError as e: - 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() + merged = ds.merge_existing_index_segments([first, second]) + ds = ds.commit_existing_index_segments("vector_idx", "vector", [merged]) - # Default index name if not provided - if index_name is None: - index_name = f"{column}_idx" + q = np.random.rand(dim).astype(np.float32) + results = ds.to_table(nearest={"column": "vector", "q": q, "k": 5}) + assert 0 < len(results) <= 5 - # 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, +def test_commit_existing_index_segments_accepts_uncommitted_vector_segments(tmp_path): + dim = 32 + ds = _make_sample_dataset_base( + tmp_path, + "segment_commit_ds", + n_rows=512, + dim=dim, + max_rows_per_file=256, ) - 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", - [ - ("IVF_PQ", 128), - ("IVF_SQ", None), - ], -) -def test_merge_two_shards_parameterized(tmp_path, index_type, num_sub_vectors): - ds = _make_sample_dataset_base(tmp_path, "dist_ds2", 2000, 128) frags = ds.get_fragments() - 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, - num_subvectors=num_sub_vectors, + assert len(frags) == 2 + ivf_model = IndicesBuilder(ds, "vector").train_ivf( + num_partitions=2, distance_type="l2", - sample_rate=7, - max_iters=20, + sample_rate=8, + max_iters=2, ) - base_kwargs = { - "column": "vector", - "index_type": index_type, - "index_uuid": shared_uuid, - "num_partitions": 4, - } + segments = [ + ds.create_index_uncommitted( + "vector", + "IVF_FLAT", + name="vector_idx", + train=True, + fragment_ids=[fragment.fragment_id], + num_partitions=2, + ivf_centroids=ivf_model.centroids, + ) + for fragment in frags + ] + + assert len(segments) == 2 + ds = ds.commit_existing_index_segments("vector_idx", "vector", segments) - # first shard - kwargs1 = dict(base_kwargs) - kwargs1["fragment_ids"] = shard1 - if num_sub_vectors is not None: - kwargs1["num_sub_vectors"] = num_sub_vectors - if pre is not None: - kwargs1["ivf_centroids"] = pre["ivf_centroids"] - # only PQ has pq_codebook - if "pq_codebook" in pre: - kwargs1["pq_codebook"] = pre["pq_codebook"] - ds.create_index(**kwargs1) - - # second shard - kwargs2 = dict(base_kwargs) - kwargs2["fragment_ids"] = shard2 - if num_sub_vectors is not None: - kwargs2["num_sub_vectors"] = num_sub_vectors - if pre is not None: - kwargs2["ivf_centroids"] = pre["ivf_centroids"] - if "pq_codebook" in pre: - kwargs2["pq_codebook"] = pre["pq_codebook"] - ds.create_index(**kwargs2) - - ds._ds.merge_index_metadata(shared_uuid, index_type, None) - ds = _commit_index_helper(ds, shared_uuid, column="vector") - - q = np.random.rand(128).astype(np.float32) + q = np.random.rand(dim).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) + dim = 32 + ds = _make_sample_dataset_base( + tmp_path, "dist_ds", n_rows=640, dim=dim, max_rows_per_file=320 + ) # 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, + num_partitions=2, + num_subvectors=4, distance_type="l2", - sample_rate=7, + sample_rate=2, + max_iters=2, ) # 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.to_table(), tmp_path / "pq_order_node1_node2", max_rows_per_file=320 ) ds_order_21 = lance.write_dataset( - ds.to_table(), tmp_path / "pq_order_node2_node1", max_rows_per_file=500 + ds.to_table(), tmp_path / "pq_order_node2_node1", max_rows_per_file=320 ) # 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)") + assert len(frags_12) == 2 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)") + assert node1_12 and node2_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)") + assert len(frags_21) == 2 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)") + assert node1_21 and node2_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=2, + num_sub_vectors=4, + 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 @@ -2715,11 +3699,8 @@ def build_distributed_ivf_pq(ds_copy, shard_order): # 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() - ] + k = 5 + queries = np.random.default_rng(43).random((3, dim), dtype=np.float32) def collect_ids_and_distances(ds_with_index): ids_per_query = [] @@ -2731,8 +3712,8 @@ def collect_ids_and_distances(ds_with_index): "column": "vector", "q": q, "k": k, - "nprobes": 16, - "refine_factor": 100, + "nprobes": 2, + "refine_factor": 10, }, ) ids_per_query.append([int(x) for x in tbl["id"].to_pylist()]) @@ -2747,3 +3728,126 @@ def collect_ids_and_distances(ds_with_index): 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)) + vectors = [[float(i)] * 4 for i in ids] + + # Create text data: + # "text " for ids 1-255, 299, 300, + # "noop " for 256-298, + texts = [] + for i in ids: + if i <= 255: + texts.append(f"text {i}") + elif i <= 298: + texts.append(f"noop {i}") + else: + texts.append(f"text {i}") + + categories = [] + for i in ids: + if i % 3 == 1: + categories.append("literature") + elif i % 3 == 2: + categories.append("science") + else: + categories.append("geography") + + table = pa.table( + { + "id": ids, + "vector": pa.array(vectors, type=pa.list_(pa.float32(), 4)), + "text": texts, + "category": categories, + } + ) + + # Write dataset and create indices + dataset = lance.write_dataset(table, tmp_path) + dataset = dataset.create_index( + "vector", + index_type="IVF_PQ", + num_partitions=2, + num_sub_vectors=4, + ) + dataset.create_scalar_index("text", index_type="INVERTED", with_position=True) + + query_vector = [300.0, 300.0, 300.0, 300.0] + + # Case 1: search with prefilter=true, query_filter=match("text") + scanner = dataset.scanner( + filter=MatchQuery("text", "text"), + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=True, + ) + + result = scanner.to_table() + ids_result = result["id"].to_pylist() + assert [300, 299, 255, 254, 253] == ids_result + + # Case 2: search with prefilter=true, search_filter=match("text"), + # filter="category='geography'" + scanner = dataset.scanner( + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=True, + filter={ + "expr_filter": "category='geography'", + "search_filter": MatchQuery("text", "text"), + }, + ) + + result = scanner.to_table() + ids_result = result["id"].to_pylist() + assert [300, 255, 252, 249, 246] == ids_result + + # Case 3: search with prefilter=false, search_filter=match("text") + scanner = dataset.scanner( + filter=MatchQuery("text", "text"), + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=False, + ) + + result = scanner.to_table() + ids_result = result["id"].to_pylist() + assert [300, 299] == ids_result + + # Case 4: search with prefilter=false, search_filter=match("text"), + # filter="category='geography'" + scanner = dataset.scanner( + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=False, + filter={ + "expr_filter": "category='geography'", + "search_filter": MatchQuery("text", "text"), + }, + ) + + result = scanner.to_table() + ids_result = result["id"].to_pylist() + assert [300] == ids_result + + # Case 5: search with prefilter=false, search_filter=phrase("text") + scanner = dataset.scanner( + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=False, + filter=PhraseQuery("text", "text"), + ) + + with pytest.raises(ValueError): + scanner.to_table() + + # Case 6: search with prefilter=false, search_filter=phrase("text") + scanner = dataset.scanner( + nearest={"column": "vector", "q": query_vector, "k": 5}, + prefilter=False, + filter={ + "expr_filter": "category='geography'", + "search_filter": PhraseQuery("text", "text"), + }, + ) + + with pytest.raises(ValueError): + scanner.to_table() diff --git a/python/python/tests/torch_tests/test_bench_utils.py b/python/python/tests/torch_tests/test_bench_utils.py index f479bb7f158..5c4943f75bb 100644 --- a/python/python/tests/torch_tests/test_bench_utils.py +++ b/python/python/tests/torch_tests/test_bench_utils.py @@ -10,7 +10,6 @@ torch = pytest.importorskip("torch") from lance.torch.bench_utils import ground_truth, sort_tensors # noqa: E402 -from lance.torch.distance import pairwise_l2 # noqa: E402 def test_sort_tensor(): @@ -31,23 +30,36 @@ def test_ground_truth(tmp_path: Path): N = 1000 NUM_QUERIES = 50 DIM = 128 + K = 20 device = "cpu" # Github action friendly. - data = np.random.rand(N * DIM).astype(np.float32) - fsl = pa.FixedSizeListArray.from_arrays(data, DIM) - data = torch.from_numpy(data.reshape((-1, DIM))).to(device) + # Keep the fixture independent of other tests that seed NumPy's global RNG. + # This seed also keeps every top-20 boundary more than 8e-3 apart. + rng = np.random.RandomState(4415) + data = rng.rand(N, DIM).astype(np.float32) + fsl = pa.FixedSizeListArray.from_arrays(data.reshape(-1), DIM) + torch_data = torch.from_numpy(data).to(device) tbl = pa.Table.from_arrays([fsl], ["vec"]) ds = lance.write_dataset(tbl, tmp_path) - idx = np.random.choice(range(N), NUM_QUERIES) - keys = data[idx, :] + idx = rng.choice(N, NUM_QUERIES) + keys = torch_data[idx, :] - gt = ground_truth(ds, "vec", keys, k=20, batch_size=128, device=device) + gt = ground_truth(ds, "vec", keys, k=K, batch_size=128, device=device) gt, _ = torch.sort(gt, dim=1) - actual_dists = pairwise_l2(keys, data) - expected, _ = torch.sort(torch.argsort(actual_dists, 1)[:, :20], dim=1) - - assert torch.allclose(expected, gt) + # Use direct float64 distances as an oracle, independent of pairwise_l2's + # float32 matrix-multiplication reduction order. + data64 = data.astype(np.float64) + expected = [] + boundary_gaps = [] + for query in data64[idx]: + distances = np.sum(np.square(data64 - query), axis=1) + row_ids = np.argsort(distances, kind="stable") + expected.append(np.sort(row_ids[:K])) + boundary_gaps.append(distances[row_ids[K]] - distances[row_ids[K - 1]]) + + assert min(boundary_gaps) > 8e-3, "fixture is too close to the top-k boundary" + np.testing.assert_array_equal(np.stack(expected), gt.cpu().numpy()) diff --git a/python/python/tests/torch_tests/test_data.py b/python/python/tests/torch_tests/test_data.py index 890a536cc9e..f997fa04e4f 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 # noqa: E402 +from lance.torch.data import ( # noqa: E402 + LanceDataset, + SafeLanceDataset, + _bf16_to_tensor, +) def test_iter_over_dataset_fixed_shape_tensor(tmp_path): @@ -293,7 +297,7 @@ def test_blob_api(tmp_path: Path): tbl = pa.Table.from_arrays([ints, vals], schema=schema) uri = tmp_path / "data.lance" - dataset = lance.write_dataset(tbl, uri) + dataset = lance.write_dataset(tbl, uri, data_storage_version="2.1") torch_ds = LanceDataset( uri, batch_size=4, dataset_options={"version": dataset.version} @@ -324,3 +328,121 @@ 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_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. + + 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)" + ) diff --git a/python/python/tests/torch_tests/test_torch_kmeans.py b/python/python/tests/torch_tests/test_torch_kmeans.py index edfb0a0329a..96bbf76c51f 100644 --- a/python/python/tests/torch_tests/test_torch_kmeans.py +++ b/python/python/tests/torch_tests/test_torch_kmeans.py @@ -10,15 +10,16 @@ torch = pytest.importorskip("torch") -from lance.torch import preferred_device # noqa: E402 from lance.torch.kmeans import KMeans # noqa: E402 from lance.vector import train_ivf_centroids_on_accelerator # noqa: E402 -@pytest.mark.skip(reason="flaky") def test_kmeans(): arr = np.array(range(128)).reshape(-1, 8).astype(np.float32) - kmeans = KMeans(4, device="cpu") + # These duplicate centroids reproduce the empty clusters that made this test + # depend on random initialization before empty-cluster recovery was fixed. + centroids = torch.from_numpy(arr[[5, 5, 13, 13]]) + kmeans = KMeans(4, centroids=centroids, device="cpu") kmeans.fit(arr) cluster_ids = kmeans.transform(arr) @@ -27,7 +28,6 @@ def test_kmeans(): assert len(cnts) == 4 # all cluster has data -@pytest.mark.skip(reason="TODO: async dataset hangs on github CI") def test_torch_kmeans_accept_torch_device(tmp_path: Path): values = pa.array(np.array(range(128)).astype(np.float32)) arr = pa.FixedSizeListArray.from_arrays(values, 8) @@ -39,7 +39,7 @@ def test_torch_kmeans_accept_torch_device(tmp_path: Path): "vector", 2, metric_type="L2", - accelerator=preferred_device(), + accelerator=torch.device("cpu"), ) diff --git a/python/src/arrow.rs b/python/src/arrow.rs index f5a51de2aaf..97b70f0e61c 100644 --- a/python/src/arrow.rs +++ b/python/src/arrow.rs @@ -18,8 +18,8 @@ use arrow::pyarrow::*; use arrow_array::RecordBatch; use arrow_schema::{DataType, Field, Schema}; use half::bf16; -use lance::arrow::bfloat16::BFloat16Array; use lance::arrow::bfloat16::BFLOAT16_EXT_NAME; +use lance::arrow::bfloat16::BFloat16Array; use lance::arrow::{ARROW_EXT_META_KEY, ARROW_EXT_NAME_KEY}; use pyo3::{exceptions::PyValueError, prelude::*, pyclass::CompareOp, types::PyType}; @@ -36,10 +36,10 @@ impl BFloat16 { #[classmethod] fn from_bytes(_cls: &Bound<'_, PyType>, bytes: &[u8]) -> PyResult { if bytes.len() != 2 { - PyValueError::new_err(format!( + return Err(PyValueError::new_err(format!( "BFloat16::from_bytes: expected 2 bytes, got {}", bytes.len() - )); + ))); } Ok(Self(bf16::from_bits(u16::from_ne_bytes([ bytes[0], bytes[1], diff --git a/python/src/bitmap.rs b/python/src/bitmap.rs new file mode 100644 index 00000000000..c7b23eccfdc --- /dev/null +++ b/python/src/bitmap.rs @@ -0,0 +1,425 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use arrow::pyarrow::FromPyArrow; +use arrow_array::{cast::AsArray, make_array}; +use arrow_cast::{CastOptions, cast_with_options}; +use arrow_data::ArrayData; +use arrow_schema::DataType; +use pyo3::basic::CompareOp; +use pyo3::exceptions::{PyKeyError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::PyBytes; +use pyo3::{IntoPyObjectExt, intern}; +use roaring::RoaringBitmap; + +/// A lazy, streaming iterator over a `Bitmap`'s values — yields one Python +/// `int` per `__next__` call rather than materializing them all up front. +#[pyclass(name = "BitmapIterator", module = "lance.bitmap")] +pub struct PyBitmapIter(roaring::bitmap::IntoIter); + +#[pymethods] +impl PyBitmapIter { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(mut slf: PyRefMut<'_, Self>) -> Option { + slf.0.next() + } +} + +/// A set of non-negative integers backed by a `RoaringBitmap`. +/// +/// Cheap to clone (an `Arc` bump) and to pass into Lance APIs that accept a +/// bitmap, since no per-value Python object is created. Mutating methods +/// (`add`, `discard`, `update`, ...) copy-on-write: cloning a `Bitmap` and +/// mutating one copy never affects the other. +/// +/// Implements the `collections.abc.MutableSet` interface — set algebra, +/// subset/superset comparisons, and in-place updates — and is registered as +/// one in `lance.bitmap`, so `isinstance(b, collections.abc.Set)` holds. +/// `isinstance(b, set)` does not: it is a distinct type, not a `set` subclass. +/// +/// Anywhere a `Bitmap` accepts another set, it also accepts any iterable of +/// ints (a `set`, `list`, `range`, generator, ...), which is why `==` against +/// a plain list is true when the values match. Order and repeats in that +/// iterable are not significant. +#[pyclass(name = "Bitmap", module = "lance.bitmap", from_py_object)] +#[derive(Clone, Debug, Default)] +pub struct PyBitmap(pub Arc); + +impl PyBitmap { + pub fn new(bitmap: RoaringBitmap) -> Self { + Self(Arc::new(bitmap)) + } +} + +/// Whether a Python value is an integer. +/// +/// Tested by `__index__` rather than `isinstance(value, int)` so that numpy +/// and other foreign integer scalars — which pyo3 also accepts — count. +pub(crate) fn is_int(value: &Bound<'_, PyAny>) -> PyResult { + value.hasattr(intern!(value.py(), "__index__")) +} + +/// Convert one Python value to a bitmap value. +/// +/// `u32::extract` alone raises a bare `OverflowError` that names neither the +/// limit nor the offending value, so an integer outside the range gets a +/// message that does. A non-integer keeps pyo3's own `TypeError`. +pub(crate) fn value_from_py(value: &Bound<'_, PyAny>) -> PyResult { + if !is_int(value)? { + return value.extract::(); + } + value.extract::().map_err(|_| { + PyValueError::new_err(format!( + "Bitmap values must fit in an unsigned 32-bit integer, got {value}" + )) + }) +} + +/// Collect any Python iterable of ints into a bitmap. +pub(crate) fn bitmap_from_iterable(values: &Bound<'_, PyAny>) -> PyResult { + values + .try_iter()? + .map(|item| value_from_py(&item?)) + .collect() +} + +/// Read an integer pyarrow array's values into a `RoaringBitmap`, without +/// going through per-value Python objects — the cast reads the array's native +/// buffer and writes another one. +fn bitmap_from_pyarrow(ob: &Bound<'_, PyAny>) -> PyResult { + let mut data = ArrayData::from_pyarrow_bound(ob)?; + if data.null_count() > 0 { + return Err(PyValueError::new_err( + "Bitmap cannot be constructed from an array containing nulls", + )); + } + // Buffers that arrive over the Arrow C data interface carry whatever + // alignment the producer gave them (e.g. a sliced `pa.py_buffer`), while + // `make_array` requires the type's native alignment and panics otherwise. + // A panic can't cross the FFI boundary as a Python exception, so copy any + // under-aligned buffer into an aligned allocation first. + data.align_buffers(); + let array = make_array(data); + if !array.data_type().is_integer() { + // Reject before casting, not after: a float array would cast + // "successfully" by truncating each value. + return Err(PyValueError::new_err(format!( + "Bitmap can only be constructed from an integer pyarrow array, got {}", + array.data_type() + ))); + } + // `safe: false` makes a negative or too-large value an error naming that + // value, rather than a silent null. + let values = cast_with_options( + &array, + &DataType::UInt32, + &CastOptions { + safe: false, + ..Default::default() + }, + ) + .map_err(|e| { + PyValueError::new_err(format!( + "Bitmap values must fit in an unsigned 32-bit integer: {e}" + )) + })?; + Ok(values + .as_primitive::() + .values() + .iter() + .copied() + .collect()) +} + +/// Coerce the right-hand side of a set operation. +/// +/// A `Bitmap` lends its `Arc` rather than being copied. `None` means "not a +/// set of ints", which the operators surface as `NotImplemented` so Python can +/// try the reflected operation before raising. +fn operand(other: &Bound<'_, PyAny>) -> Option> { + if let Ok(bitmap) = other.extract::() { + return Some(bitmap.0); + } + bitmap_from_iterable(other).ok().map(Arc::new) +} + +/// Coerce the argument of a named set method (`union`, `issubset`, ...), which +/// raises on a bad argument rather than deferring to Python, as `set` does. +fn required_operand(other: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(bitmap) = other.extract::() { + return Ok(bitmap.0); + } + bitmap_from_iterable(other).map(Arc::new) +} + +/// Run a set operator, yielding `NotImplemented` for an operand that isn't a +/// set of ints. +fn set_op( + py: Python<'_>, + other: &Bound<'_, PyAny>, + op: impl FnOnce(&RoaringBitmap) -> RoaringBitmap, +) -> PyResult> { + match operand(other) { + Some(other) => Ok(Py::new(py, PyBitmap::new(op(&other)))?.into_any()), + None => Ok(py.NotImplemented()), + } +} + +#[pymethods] +impl PyBitmap { + /// Construct a Bitmap from an iterable of non-negative ints (list, set, + /// range, generator, ...) or an integer pyarrow Array/ChunkedArray. + #[new] + #[pyo3(signature = (values=None))] + fn new_py(values: Option<&Bound<'_, PyAny>>) -> PyResult { + let Some(values) = values else { + return Ok(Self::default()); + }; + if let Ok(existing) = values.extract::() { + return Ok(existing); + } + // A pyarrow `Array` supports the `__arrow_c_array__` Arrow C Data + // Interface export directly; a `ChunkedArray` doesn't (it only + // supports the streaming `__arrow_c_stream__` form), so detect it by + // its `combine_chunks()` method and flatten it to a single `Array` + // first. + if values.hasattr("combine_chunks")? { + let combined = values.call_method0("combine_chunks")?; + return Ok(Self::new(bitmap_from_pyarrow(&combined)?)); + } + if values.hasattr("__arrow_c_array__")? { + return Ok(Self::new(bitmap_from_pyarrow(values)?)); + } + Ok(Self::new(bitmap_from_iterable(values)?)) + } + + fn __len__(&self) -> usize { + self.0.len() as usize + } + + fn __contains__(&self, value: &Bound<'_, PyAny>) -> bool { + // A value that can't be a bitmap element simply isn't in it, matching + // `set` — `"a" in {1}` is False, not a TypeError. + value.extract::().is_ok_and(|v| self.0.contains(v)) + } + + fn __iter__(&self, py: Python<'_>) -> PyResult> { + // Cloning the bitmap here is a native Rust-side copy of its compressed + // containers, not a per-value Python allocation — the point is that + // `PyBitmapIter` then streams values lazily instead of eagerly + // building a Python list of boxed ints up front. + Py::new(py, PyBitmapIter((*self.0).clone().into_iter())) + } + + pub(crate) fn __repr__(&self) -> String { + const MAX_VALUES_SHOWN: usize = 20; + let len = self.0.len(); + let values: Vec = self + .0 + .iter() + .take(MAX_VALUES_SHOWN) + .map(|v| v.to_string()) + .collect(); + if (len as usize) > MAX_VALUES_SHOWN { + format!("Bitmap({{{}, ...}}, len={})", values.join(", "), len) + } else { + format!("Bitmap({{{}}})", values.join(", ")) + } + } + + fn __richcmp__( + &self, + py: Python<'_>, + other: &Bound<'_, PyAny>, + op: CompareOp, + ) -> PyResult> { + let Some(other) = operand(other) else { + // A value that isn't a set of ints (e.g. `5`) is simply unequal, + // matching normal Python `==` semantics — it shouldn't raise just + // because the type differs. Ordering against it has no meaning, so + // defer to Python, which raises if nothing else handles it. + return match op { + CompareOp::Eq => false.into_py_any(py), + CompareOp::Ne => true.into_py_any(py), + _ => Ok(py.NotImplemented()), + }; + }; + match op { + CompareOp::Eq => (*self.0 == *other).into_py_any(py), + CompareOp::Ne => (*self.0 != *other).into_py_any(py), + CompareOp::Le => self.0.is_subset(&other).into_py_any(py), + CompareOp::Lt => { + (self.0.len() < other.len() && self.0.is_subset(&other)).into_py_any(py) + } + CompareOp::Ge => self.0.is_superset(&other).into_py_any(py), + CompareOp::Gt => { + (self.0.len() > other.len() && self.0.is_superset(&other)).into_py_any(py) + } + } + } + + fn __and__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| &*self.0 & other) + } + + fn __rand__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| other & &*self.0) + } + + fn __or__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| &*self.0 | other) + } + + fn __ror__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| other | &*self.0) + } + + fn __sub__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| &*self.0 - other) + } + + fn __rsub__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| other - &*self.0) + } + + fn __xor__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| &*self.0 ^ other) + } + + fn __rxor__(&self, py: Python<'_>, other: &Bound<'_, PyAny>) -> PyResult> { + set_op(py, other, |other| other ^ &*self.0) + } + + /// The values present in both this bitmap and `other`. + fn intersection(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::new(&*self.0 & &*required_operand(other)?)) + } + + /// The values present in either this bitmap or `other`. + fn union(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::new(&*self.0 | &*required_operand(other)?)) + } + + /// The values present in this bitmap but not in `other`. + fn difference(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::new(&*self.0 - &*required_operand(other)?)) + } + + /// The values present in exactly one of this bitmap and `other`. + fn symmetric_difference(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(Self::new(&*self.0 ^ &*required_operand(other)?)) + } + + /// Whether every value in this bitmap is also in `other`. + fn issubset(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(self.0.is_subset(&*required_operand(other)?)) + } + + /// Whether every value in `other` is also in this bitmap. + fn issuperset(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(self.0.is_superset(&*required_operand(other)?)) + } + + /// Whether this bitmap and `other` share no values. + fn isdisjoint(&self, other: &Bound<'_, PyAny>) -> PyResult { + Ok(self.0.is_disjoint(&*required_operand(other)?)) + } + + /// A `Bitmap` with the same values. Mutating either leaves the other + /// unchanged, so this shares the underlying buffer until one is written to. + fn copy(&self) -> Self { + self.clone() + } + + /// Add a value, cloning the underlying bitmap first if it is shared with + /// another `Bitmap`. + fn add(&mut self, value: u32) { + Arc::make_mut(&mut self.0).insert(value); + } + + /// Remove a value if present, cloning the underlying bitmap first if it + /// is shared with another `Bitmap`. + fn discard(&mut self, value: u32) { + Arc::make_mut(&mut self.0).remove(value); + } + + /// Remove a value, raising `KeyError` if it isn't present. + fn remove(&mut self, value: u32) -> PyResult<()> { + if Arc::make_mut(&mut self.0).remove(value) { + Ok(()) + } else { + Err(PyKeyError::new_err(value)) + } + } + + /// Remove and return the smallest value, raising `KeyError` if empty. + fn pop(&mut self) -> PyResult { + let bitmap = Arc::make_mut(&mut self.0); + let value = bitmap + .min() + .ok_or_else(|| PyKeyError::new_err("pop from an empty Bitmap"))?; + bitmap.remove(value); + Ok(value) + } + + /// Remove every value. + fn clear(&mut self) { + Arc::make_mut(&mut self.0).clear(); + } + + /// Add all values from an iterable, cloning the underlying bitmap first + /// if it is shared with another `Bitmap`. + fn update(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + let other = required_operand(values)?; + *Arc::make_mut(&mut self.0) |= &*other; + Ok(()) + } + + fn __ior__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + self.update(other) + } + + fn __iand__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + let other = required_operand(other)?; + *Arc::make_mut(&mut self.0) &= &*other; + Ok(()) + } + + fn __isub__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + let other = required_operand(other)?; + *Arc::make_mut(&mut self.0) -= &*other; + Ok(()) + } + + fn __ixor__(&mut self, other: &Bound<'_, PyAny>) -> PyResult<()> { + let other = required_operand(other)?; + *Arc::make_mut(&mut self.0) ^= &*other; + Ok(()) + } + + fn __reduce__(&self, py: Python<'_>) -> PyResult<(Py, (Py,))> { + let mut buf = Vec::new(); + self.0 + .serialize_into(&mut buf) + .map_err(|e| PyValueError::new_err(format!("Failed to serialize Bitmap: {e}")))?; + let ctor = py + .import("lance.bitmap")? + .getattr("Bitmap")? + .getattr("_from_bytes")?; + let bytes = PyBytes::new(py, &buf); + Ok((ctor.unbind(), (bytes.into(),))) + } + + #[staticmethod] + fn _from_bytes(data: &[u8]) -> PyResult { + let bitmap = RoaringBitmap::deserialize_from(data) + .map_err(|e| PyValueError::new_err(format!("Failed to deserialize Bitmap: {e}")))?; + Ok(Self::new(bitmap)) + } +} diff --git a/python/src/blob.rs b/python/src/blob.rs new file mode 100644 index 00000000000..cb5fd84323c --- /dev/null +++ b/python/src/blob.rs @@ -0,0 +1,490 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use crate::{error::PythonErrorExt, rt}; +use arrow::{ + array::{Array, ArrayRef, make_array}, + pyarrow::{FromPyArrow, ToPyArrow}, +}; +use arrow_data::ArrayData; +use arrow_schema::{DataType, Field}; +use bytes::Bytes; +use lance::{ + BlobDescriptor, BlobDescriptorArrayBuilder, BlobRange, DedicatedBlobWriter, PackedBlobWriter, +}; +use lance_arrow::iter_binary_array; +use pyo3::{ + Bound, PyErr, PyResult, + exceptions::{PyRuntimeError, PyValueError}, + pyclass, pymethods, + types::{PyAny, PyAnyMethods, PyDict, PyList, PyListMethods, PyModule, PyTypeMethods}, +}; +use std::{ + borrow::Cow, + sync::{Arc, Mutex}, +}; + +fn with_writer( + inner: &Mutex>, + writer_name: &str, + operation: impl FnOnce(&W) -> R, +) -> PyResult { + let guard = inner.lock().map_err(|_| poisoned_writer(writer_name))?; + let writer = guard.as_ref().ok_or_else(|| finished_writer(writer_name))?; + Ok(operation(writer)) +} + +fn writer_mut<'a, W>(inner: &'a mut Mutex>, writer_name: &str) -> PyResult<&'a mut W> { + inner + .get_mut() + .map_err(|_| poisoned_writer(writer_name))? + .as_mut() + .ok_or_else(|| finished_writer(writer_name)) +} + +fn take_writer(inner: &mut Mutex>, writer_name: &str) -> PyResult { + inner + .get_mut() + .map_err(|_| poisoned_writer(writer_name))? + .take() + .ok_or_else(|| finished_writer(writer_name)) +} + +fn finished_writer(writer_name: &str) -> PyErr { + PyValueError::new_err(format!("{writer_name} is already finished")) +} + +fn poisoned_writer(writer_name: &str) -> PyErr { + PyRuntimeError::new_err(format!("{writer_name} lock is poisoned")) +} + +/// Reconstruct the PyArrow equivalent of [`BlobDescriptorArrayBuilder::field`]. +/// +/// Arrow's array bridge does not carry the enclosing extension field, so this +/// rebuilds the canonical six nullable blob-v2 children and +/// `ARROW:extension:name = lance.blob.v2` metadata. +fn descriptor_field_to_pyarrow<'py>( + field: &Field, + py: pyo3::Python<'py>, +) -> PyResult> { + let pyarrow = PyModule::import(py, "pyarrow")?; + let child_fields = PyList::empty(py); + for (name, type_fn) in [ + ("kind", "uint8"), + ("data", "large_binary"), + ("uri", "utf8"), + ("blob_id", "uint32"), + ("blob_size", "uint64"), + ("position", "uint64"), + ] { + let data_type = pyarrow.getattr(type_fn)?.call0()?; + let child = pyarrow.call_method1("field", (name, data_type, true))?; + child_fields.append(child)?; + } + let data_type = pyarrow.call_method1("struct", (child_fields,))?; + let metadata = PyDict::new(py); + metadata.set_item("ARROW:extension:name", "lance.blob.v2")?; + let kwargs = PyDict::new(py); + kwargs.set_item("nullable", field.is_nullable())?; + kwargs.set_item("metadata", metadata)?; + pyarrow.call_method("field", (field.name().as_str(), data_type), Some(&kwargs)) +} + +/// Normalize inputs accepted by [`PyPackedBlobWriter::write_blobs`] into Arrow arrays. +/// +/// BinaryArray, LargeBinaryArray, BinaryViewArray, FixedSizeBinaryArray, and +/// ChunkedArray values of any binary type are accepted. Chunk boundaries, +/// nulls, and empty values remain in the arrays; each row is later passed to +/// the core writer as an optional byte slice. +fn extract_blob_payloads(payloads: &Bound<'_, PyAny>) -> PyResult> { + match ArrayData::from_pyarrow_bound(payloads) { + Ok(data) => Ok(vec![validated_blob_payload(data, None)?]), + Err(_) => { + let pyarrow = PyModule::import(payloads.py(), "pyarrow")?; + let chunked_array_type = pyarrow.getattr("ChunkedArray")?; + if !payloads.is_instance(&chunked_array_type)? { + return Err(PyValueError::new_err(format!( + "payloads must be a pyarrow BinaryArray, LargeBinaryArray, or ChunkedArray, got {}", + payloads.get_type().name()? + ))); + } + + let chunked_data_type = DataType::from_pyarrow_bound(&payloads.getattr("type")?)?; + if !chunked_data_type.is_binary() { + return Err(PyValueError::new_err(format!( + "Packed blob payloads must have a Binary Arrow type, got {chunked_data_type}" + ))); + } + + let chunks = payloads.getattr("chunks")?; + let mut arrays = Vec::with_capacity(chunks.len()?); + for (chunk_index, chunk) in chunks.try_iter()?.enumerate() { + let data = ArrayData::from_pyarrow_bound(&chunk?)?; + arrays.push(validated_blob_payload(data, Some(chunk_index))?); + } + Ok(arrays) + } + } +} + +fn validated_blob_payload(data: ArrayData, chunk_index: Option) -> PyResult { + let context = chunk_index + .map(|index| format!("Packed blob payload chunk {index}")) + .unwrap_or_else(|| "Packed blob payload array".to_string()); + if !data.data_type().is_binary() { + return Err(PyValueError::new_err(format!( + "{context} must have a Binary Arrow type, got {}", + data.data_type() + ))); + } + if data.is_empty() { + // PyArrow may export an empty slice without the values preceding its + // nonzero first offset. Normalize it because an empty array never + // observes those buffers, and Arrow validation would reject the slice. + return Ok(make_array(ArrayData::new_empty(data.data_type()))); + } + data.validate_full().map_err(|error| { + PyValueError::new_err(format!("{context} contains invalid Arrow data: {error}")) + })?; + Ok(make_array(data)) +} + +async fn write_binary_payloads(writer: &mut PackedBlobWriter, payloads: &ArrayRef) -> PyResult<()> { + let iter = iter_binary_array(payloads.as_ref()).map_err(|error| { + PyValueError::new_err(format!( + "Packed blob payloads must have a Binary Arrow type, got {}: {error}", + payloads.data_type() + )) + })?; + writer.write_packed_blobs(iter).await.infer_error() +} + +#[pyclass(name = "BlobDescriptor", skip_from_py_object)] +#[derive(Clone)] +pub struct PyBlobDescriptor { + inner: BlobDescriptor, +} + +impl From for PyBlobDescriptor { + fn from(inner: BlobDescriptor) -> Self { + Self { inner } + } +} + +#[pymethods] +impl PyBlobDescriptor { + fn __repr__(&self) -> String { + format!("{:?}", self.inner) + } +} + +#[pyclass(name = "BlobDescriptorArrayBuilder", skip_from_py_object)] +pub struct PyBlobDescriptorArrayBuilder { + field: arrow_schema::Field, + inner: Option, +} + +impl PyBlobDescriptorArrayBuilder { + fn inner_mut(&mut self) -> PyResult<&mut BlobDescriptorArrayBuilder> { + self.inner + .as_mut() + .ok_or_else(|| PyValueError::new_err("BlobDescriptorArrayBuilder is already finished")) + } +} + +#[pymethods] +impl PyBlobDescriptorArrayBuilder { + #[new] + pub fn new(column: String) -> Self { + let inner = BlobDescriptorArrayBuilder::new(column); + Self { + field: inner.field().clone(), + inner: Some(inner), + } + } + + #[getter] + pub fn field<'py>(&self, py: pyo3::Python<'py>) -> PyResult> { + descriptor_field_to_pyarrow(&self.field, py) + } + + pub fn extend_packed( + &mut self, + blob_id: u32, + offsets: Vec, + sizes: Vec, + ) -> PyResult<()> { + if offsets.len() != sizes.len() { + return Err(PyValueError::new_err(format!( + "offsets and sizes must have the same length, got {} and {}", + offsets.len(), + sizes.len() + ))); + } + let ranges = offsets + .into_iter() + .zip(sizes) + .map(|(offset, size)| BlobRange { offset, size }) + .collect::>(); + self.inner_mut()? + .extend_packed(blob_id, ranges) + .infer_error() + } + + pub fn append_dedicated(&mut self, blob_id: u32, size: u64) -> PyResult<()> { + self.inner_mut()? + .push_dedicated(blob_id, size) + .infer_error() + } + + pub fn append(&mut self, value: &Bound<'_, PyAny>) -> PyResult<()> { + let value = value.extract::>()?; + self.inner_mut()?.push(value.inner.clone()).infer_error() + } + + pub fn extend(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + let iter = values.try_iter()?; + for value in iter { + let value = value?.extract::>()?; + self.inner_mut()?.push(value.inner.clone()).infer_error()?; + } + Ok(()) + } + + pub fn append_inline(&mut self, data: Vec) -> PyResult<()> { + self.inner_mut()? + .push_inline(Bytes::from(data)) + .infer_error() + } + + pub fn append_null(&mut self) -> PyResult<()> { + self.inner_mut()?.push_null().infer_error() + } + + pub fn finish<'py>(&mut self, py: pyo3::Python<'py>) -> PyResult> { + let inner = self.inner.take().ok_or_else(|| { + PyValueError::new_err("BlobDescriptorArrayBuilder is already finished") + })?; + let column = inner.finish().infer_error()?; + column.array().to_data().to_pyarrow(py) + } +} + +#[pyclass(name = "PackedBlobWriter", skip_from_py_object)] +pub struct PyPackedBlobWriter { + field: Option, + inner: Mutex>, +} + +impl PyPackedBlobWriter { + pub(crate) async fn try_new( + object_store: Arc, + data_file_path: object_store::path::Path, + blob_id: u32, + ) -> PyResult { + let inner = + PackedBlobWriter::try_new(object_store.as_ref().clone(), data_file_path, blob_id) + .await + .infer_error()?; + Ok(Self { + field: None, + inner: Mutex::new(Some(inner)), + }) + } + + fn with_inner(&self, operation: impl FnOnce(&PackedBlobWriter) -> R) -> PyResult { + with_writer(&self.inner, "PackedBlobWriter", operation) + } + + fn inner_mut(&mut self) -> PyResult<&mut PackedBlobWriter> { + writer_mut(&mut self.inner, "PackedBlobWriter") + } + + fn take_inner(&mut self) -> PyResult { + take_writer(&mut self.inner, "PackedBlobWriter") + } +} + +#[pymethods] +impl PyPackedBlobWriter { + #[getter] + pub fn blob_id(&self) -> PyResult { + self.with_inner(PackedBlobWriter::blob_id) + } + + #[getter] + pub fn path(&self) -> PyResult { + self.with_inner(|writer| writer.path().to_string()) + } + + /// The descriptor field associated with the array returned by + /// :meth:`finish_array`. + /// + /// The field uses the name passed to ``finish_array`` and carries the + /// ``lance.blob.v2`` extension metadata. It is available only after + /// ``finish_array`` succeeds; accessing it earlier raises ``ValueError``. + #[getter] + pub fn field<'py>(&self, py: pyo3::Python<'py>) -> PyResult> { + let field = self.field.as_ref().ok_or_else(|| { + PyValueError::new_err("PackedBlobWriter field is available after finish_array") + })?; + descriptor_field_to_pyarrow(field, py) + } + + /// Append one packed blob. + /// + /// Python ``bytes`` are borrowed without copying. Other compatible byte + /// sequences use owned storage for the duration of the write. + pub fn write_blob(&mut self, data: Cow<'_, [u8]>) -> PyResult<()> { + rt().block_on(None, self.inner_mut()?.write_blob(data.as_ref()))? + .infer_error() + } + + /// Append a batch of packed blob payloads. + /// + /// Parameters + /// ---------- + /// payloads : pyarrow.BinaryArray, pyarrow.LargeBinaryArray, + /// pyarrow.BinaryViewArray, pyarrow.FixedSizeBinaryArray, or + /// pyarrow.ChunkedArray + /// A binary Arrow array. Every chunk of a chunked array must be binary. + /// Each input row produces one descriptor row, in order, across chunks + /// and repeated calls. Null rows produce null descriptors; empty but + /// non-null byte strings produce valid zero-length blobs. + /// + /// Examples + /// -------- + /// >>> import pyarrow as pa + /// >>> payloads = pa.array([b"first", None, b""], type=pa.large_binary()) + /// >>> writer.write_blobs(payloads) + /// >>> descriptors = writer.finish_array("blob") + /// >>> len(descriptors) + /// 3 + pub fn write_blobs(&mut self, payloads: &Bound<'_, PyAny>) -> PyResult<()> { + let payloads = extract_blob_payloads(payloads)?; + let result = { + let writer = self.inner_mut()?; + rt().block_on(None, async { + for payloads in payloads { + write_binary_payloads(writer, &payloads).await?; + } + Ok(()) + }) + }; + match result { + Ok(result) => result, + Err(error) => { + // KeyboardInterrupt drops the async batch future. Remove the core + // writer as well so RAII cleanup runs and a completed prefix cannot + // be reused as a new batch. + self.take_inner()?; + Err(error) + } + } + } + + pub fn finish(&mut self) -> PyResult> { + let inner = self.take_inner()?; + let values = rt().block_on(None, inner.finish())?.infer_error()?; + Ok(values.into_iter().map(Into::into).collect()) + } + + /// Finish the upload and return its blob descriptors as a PyArrow array. + /// + /// The returned ``pyarrow.StructArray`` has one row per payload previously + /// passed to :meth:`write_blob` or :meth:`write_blobs`. The writer is consumed + /// by this call. After it succeeds, :attr:`field` returns the matching + /// extension field with ``field_name`` as its name. + /// + /// Parameters + /// ---------- + /// field_name : str + /// Name for the descriptor field exposed by :attr:`field`. + /// + /// Returns + /// ------- + /// pyarrow.StructArray + /// Row-aligned blob descriptors, including null rows from bulk input. + /// + /// Examples + /// -------- + /// >>> import pyarrow as pa + /// >>> writer.write_blobs(pa.array([b"value", None])) + /// >>> descriptors = writer.finish_array("payload") + /// >>> descriptors.is_null().to_pylist() + /// [False, True] + /// >>> writer.field.name + /// 'payload' + pub fn finish_array<'py>( + &mut self, + py: pyo3::Python<'py>, + field_name: String, + ) -> PyResult> { + let inner = self.take_inner()?; + let values = rt().block_on(None, inner.finish())?.infer_error()?; + let mut builder = BlobDescriptorArrayBuilder::new(field_name); + builder.extend(values).infer_error()?; + let column = builder.finish().infer_error()?; + let (field, array) = column.into_parts(); + let array = array.to_data().to_pyarrow(py)?; + self.field = Some(field); + Ok(array) + } +} + +#[pyclass(name = "DedicatedBlobWriter", skip_from_py_object)] +pub struct PyDedicatedBlobWriter { + inner: Mutex>, +} + +impl PyDedicatedBlobWriter { + pub(crate) async fn try_new( + object_store: Arc, + data_file_path: object_store::path::Path, + blob_id: u32, + ) -> PyResult { + let inner = + DedicatedBlobWriter::try_new(object_store.as_ref().clone(), data_file_path, blob_id) + .await + .infer_error()?; + Ok(Self { + inner: Mutex::new(Some(inner)), + }) + } + + fn with_inner(&self, operation: impl FnOnce(&DedicatedBlobWriter) -> R) -> PyResult { + with_writer(&self.inner, "DedicatedBlobWriter", operation) + } + + fn inner_mut(&mut self) -> PyResult<&mut DedicatedBlobWriter> { + writer_mut(&mut self.inner, "DedicatedBlobWriter") + } + + fn take_inner(&mut self) -> PyResult { + take_writer(&mut self.inner, "DedicatedBlobWriter") + } +} + +#[pymethods] +impl PyDedicatedBlobWriter { + #[getter] + pub fn blob_id(&self) -> PyResult { + self.with_inner(DedicatedBlobWriter::blob_id) + } + + #[getter] + pub fn path(&self) -> PyResult { + self.with_inner(|writer| writer.path().to_string()) + } + + pub fn write(&mut self, data: Vec) -> PyResult<()> { + rt().block_on(None, self.inner_mut()?.write(data))? + .infer_error() + } + + pub fn finish(&mut self) -> PyResult { + let inner = self.take_inner()?; + let value = rt().block_on(None, inner.finish())?.infer_error()?; + Ok(value.into()) + } +} diff --git a/python/src/datagen.rs b/python/src/datagen.rs index b0a3c4e1b44..194ba3d6a73 100644 --- a/python/src/datagen.rs +++ b/python/src/datagen.rs @@ -1,11 +1,11 @@ use arrow::pyarrow::PyArrowType; -use arrow_array::RecordBatch; +use arrow_array::{RecordBatch, RecordBatchReader}; use arrow_schema::Schema; -use lance_datagen::{BatchCount, ByteCount}; +use lance_datagen::{BatchCount, ByteCount, RowCount}; use pyo3::{ - pyfunction, + Bound, PyResult, Python, pyfunction, types::{PyModule, PyModuleMethods}, - wrap_pyfunction, Bound, PyResult, Python, + wrap_pyfunction, }; const DEFAULT_BATCH_SIZE_BYTES: u64 = 32 * 1024; @@ -16,22 +16,44 @@ pub fn is_datagen_supported() -> bool { true } +/// Generate `batch_count` batches of random data for `schema`. +/// +/// Batch size is set either by `rows_in_batch` (exact rows per batch) or +/// `bytes_in_batch` (approximate bytes per batch); the two are mutually +/// exclusive. When neither is given the byte-based default is used. #[pyfunction] -#[pyo3(signature=(schema, batch_count=None, bytes_in_batch=None))] +#[pyo3(signature=(schema, batch_count=None, bytes_in_batch=None, rows_in_batch=None))] pub fn rand_batches( schema: PyArrowType, batch_count: Option, bytes_in_batch: Option, + rows_in_batch: Option, ) -> PyResult>> { - lance_datagen::rand(&schema.0) - .into_reader_bytes( - ByteCount::from(bytes_in_batch.unwrap_or(DEFAULT_BATCH_SIZE_BYTES)), - BatchCount::from(batch_count.unwrap_or(DEFAULT_BATCH_COUNT)), - lance_datagen::RoundingBehavior::RoundUp, - ) - .map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!("Failed to generate batches: {}", e)) - })? + if rows_in_batch.is_some() && bytes_in_batch.is_some() { + return Err(pyo3::exceptions::PyValueError::new_err( + "rows_in_batch and bytes_in_batch are mutually exclusive", + )); + } + let builder = lance_datagen::rand(&schema.0); + let batch_count = BatchCount::from(batch_count.unwrap_or(DEFAULT_BATCH_COUNT)); + let reader: Box = match rows_in_batch { + Some(rows) => Box::new(builder.into_reader_rows(RowCount::from(rows), batch_count)), + None => Box::new( + builder + .into_reader_bytes( + ByteCount::from(bytes_in_batch.unwrap_or(DEFAULT_BATCH_SIZE_BYTES)), + batch_count, + lance_datagen::RoundingBehavior::RoundUp, + ) + .map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to generate batches: {}", + e + )) + })?, + ), + }; + reader .map(|item| { item.map(PyArrowType::from).map_err(|e| { pyo3::exceptions::PyValueError::new_err(format!("Failed to generate batch: {}", e)) diff --git a/python/src/dataset.rs b/python/src/dataset.rs index b4b4d8af050..a119f184791 100644 --- a/python/src/dataset.rs +++ b/python/src/dataset.rs @@ -3,99 +3,117 @@ 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; use arrow::ffi_stream::ArrowArrayStreamReader; use arrow::pyarrow::*; use arrow_array::Array; -use arrow_array::{make_array, RecordBatch, RecordBatchReader}; +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; use blob::LanceBlobFile; use chrono::{Duration, TimeDelta, Utc}; use futures::{StreamExt, TryFutureExt}; +use lance_index::scalar::minhash_lsh::MinHashQuery; use lance_index::vector::bq::RQBuildParams; +use lance_index::vector::bq::storage::RabitQuantizationMetadata; use log::error; use object_store::path::Path; use pyo3::exceptions::{PyStopIteration, PyTypeError}; -use pyo3::types::{PyBytes, PyInt, PyList, PySet, PyString, PyTuple}; +use pyo3::types::{PyBytes, PyInt, PyList, PyString, PyTuple}; +use pyo3::{IntoPyObjectExt, prelude::*}; use pyo3::{ + PyResult, exceptions::{PyIOError, PyKeyError, PyValueError}, intern, pybacked::PyBackedStr, pyclass, types::{IntoPyDict, PyDict}, - PyResult, }; -use pyo3::{prelude::*, IntoPyObjectExt}; -use snafu::location; +use uuid::Uuid; -use lance::dataset::cleanup::CleanupPolicyBuilder; +use lance::dataset::AutoCleanupParams; +use lance::dataset::cleanup::{CleanupFileKind, CleanupPolicyBuilder}; use lance::dataset::refs::{Ref, TagContents}; use lance::dataset::scanner::{ - ColumnOrdering, DatasetRecordBatchStream, ExecutionStatsCallback, MaterializationStyle, + AggregateExpr, ColumnOrdering, DatasetRecordBatchStream, ExecutionStatsCallback, ExprFilter, + MaterializationStyle, QueryFilter, RowAddrMask, RowAddrTreeMap, }; use lance::dataset::statistics::{DataStatistics, DatasetStatisticsExt}; -use lance::dataset::AutoCleanupParams; use lance::dataset::{ + BatchInfo, BatchUDF, CommitBuilder, MergeStats, NewColumnTransform, UDFCheckpointStore, + WriteDestination, +}; +use lance::dataset::{ColumnAlteration, ProjectionRequest, validate_dataset_root_for_drop}; +use lance::dataset::{ + Dataset as LanceDataset, DeleteBuilder, ExternalBlobMode, + MergeInsertBuilder as LanceMergeInsertBuilder, MergeInsertWriteMode, ReadParams, + UncommittedMergeInsert, UpdateBuilder, Version, VersionRef, WhenMatched, WhenNotMatched, + WhenNotMatchedBySource, WriteMode, WriteParams, fragment::FileFragment as LanceFileFragment, progress::WriteFragmentProgress, scanner::Scanner as LanceScanner, transaction::{Operation, Transaction}, - Dataset as LanceDataset, DeleteBuilder, MergeInsertBuilder as LanceMergeInsertBuilder, - ReadParams, UncommittedMergeInsert, UpdateBuilder, Version, WhenMatched, WhenNotMatched, - WhenNotMatchedBySource, WriteMode, WriteParams, -}; -use lance::dataset::{ - BatchInfo, BatchUDF, CommitBuilder, MergeStats, NewColumnTransform, UDFCheckpointStore, - WriteDestination, }; -use lance::dataset::{ColumnAlteration, ProjectionRequest}; use lance::index::vector::utils::get_vector_type; -use lance::index::{vector::VectorIndexParams, DatasetIndexInternalExt}; +use lance::index::{ + DatasetIndexExt, DatasetIndexInternalExt, IndexSegment, IntoIndexSegment, + vector::VectorIndexParams, +}; use lance::{dataset::builder::DatasetBuilder, index::vector::IndexFileVersion}; use lance_arrow::as_fixed_size_list_array; -use lance_core::datatypes::BlobHandling; use lance_core::Error; +use lance_core::datatypes::BlobHandling; use lance_datafusion::utils::reader_to_stream; use lance_encoding::decoder::DecoderConfig; use lance_file::reader::FileReaderOptions; +use lance_file::writer::FileWriterOptions; +use lance_index::scalar::inverted::query::Occur; use lance_index::scalar::inverted::query::{ - BooleanQuery, BoostQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator, PhraseQuery, -}; -use lance_index::{ - infer_system_index_type, metrics::NoOpMetricsCollector, scalar::inverted::query::Occur, + BooleanQuery, BoostQuery, CombinedFieldsQuery, FtsQuery, MatchQuery, MultiMatchQuery, Operator, + PhraseQuery, }; use lance_index::{ + FtsPrewarmOptions, IndexParams, IndexType, PrewarmOptions, optimize::OptimizeOptions, + progress::{IndexBuildProgress, NoopIndexBuildProgress}, + scalar::inverted::{DocumentGranularity, InvertedListFormatVersion}, scalar::{FullTextSearchQuery, InvertedIndexParams, ScalarIndexParams}, vector::{ + ApproxMode, DEFAULT_QUERY_PARALLELISM, Query as VectorQuery, hnsw::builder::HnswBuildParams, ivf::IvfBuildParams, pq::PQBuildParams, sq::builder::SQBuildParams, }, - DatasetIndexExt, IndexParams, IndexType, }; -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; +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::fts::FtsTokenizerOptions; +use crate::indices::{PyIndexConfig, PyIndexDescription, PyIndexSegment}; +use crate::namespace::extract_namespace_arc; use crate::rt; use crate::scanner::ScanStatistics; -use crate::schema::{logical_schema_from_lance, LanceSchema}; +use crate::schema::{LanceSchema, logical_schema_from_lance}; use crate::session::Session; use crate::storage_options::PyStorageOptionsAccessor; use crate::utils::PyLance; use crate::{LanceReader, Scanner}; +use lance::io::commit::namespace_manifest::LanceNamespaceExternalManifestStore; -use self::cleanup::CleanupStats; +use self::cleanup::{ + CleanupCandidateFile, CleanupExplanation, CleanupReferencedBranch, CleanupStats, +}; use self::commit::PyCommitLock; use self::io_stats::IoStats; @@ -108,6 +126,172 @@ pub mod stats; const DEFAULT_NPROBES: usize = 1; const LANCE_COMMIT_MESSAGE_KEY: &str = "__lance_commit_message"; +const INDEX_PROGRESS_QUEUE_SIZE: usize = 1024; + +type PyBlobBytes = Option>; +type PyReadBlob = (u64, PyBlobBytes); +type PyReadBlobRange = (usize, u64, PyBlobBytes); + +fn read_blobs_to_python(py: Python<'_>, blobs: Vec) -> Vec { + blobs + .into_iter() + .map(|blob| { + ( + blob.row_address, + blob.data.map(|data| PyBytes::new(py, &data).unbind()), + ) + }) + .collect() +} + +fn read_blob_ranges_to_python( + py: Python<'_>, + ranges: Vec, +) -> Vec { + ranges + .into_iter() + .map(|range| { + ( + range.request_index, + range.row_address, + range.data.map(|data| PyBytes::new(py, &data).unbind()), + ) + }) + .collect() +} + +fn blob_range_requests_from_tuples( + requests: Vec<(u64, u64, u64)>, +) -> Vec { + requests + .into_iter() + .map(|(row, offset, length)| lance::dataset::BlobRangeRequest::new(row, offset, length)) + .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 configure_read_blob_ranges_builder( + mut builder: lance::dataset::ReadBlobRangesBuilder, + io_buffer_size: Option, + preserve_order: Option, +) -> lance::dataset::ReadBlobRangesBuilder { + 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 stats_log_interval_from_millis(ms: u64) -> Option { + if ms == 0 { + None + } else { + Some(std::time::Duration::from_millis(ms)) + } +} + +/// Build a `ShardWriterConfig` from optional MemWAL writer keyword arguments. +/// +/// Returns `None` when no keyword argument is supplied, so callers can tell +/// "use the defaults" apart from an explicit configuration. +#[allow(clippy::too_many_arguments)] +fn writer_config_from_kwargs( + durable_write: Option, + max_wal_buffer_size: Option, + max_wal_flush_interval_ms: Option, + max_memtable_size: Option, + max_memtable_rows: Option, + max_memtable_batches: Option, + max_unflushed_memtable_bytes: Option, + manifest_scan_batch_size: Option, + backpressure_log_interval_ms: Option, + stats_log_interval_ms: Option, + hnsw_params: Option>>, +) -> PyResult> { + use std::time::Duration; + + let mut config = lance::dataset::mem_wal::ShardWriterConfig::default(); + let mut any = false; + if let Some(v) = durable_write { + config = config.with_durable_write(v); + any = true; + } + if let Some(v) = max_wal_buffer_size { + config = config.with_max_wal_buffer_size(v); + any = true; + } + if let Some(v) = max_wal_flush_interval_ms { + config = config.with_max_wal_flush_interval(Duration::from_millis(v)); + any = true; + } + if let Some(v) = max_memtable_size { + config = config.with_max_memtable_size(v); + any = true; + } + if let Some(v) = max_memtable_rows { + config = config.with_max_memtable_rows(v); + any = true; + } + if let Some(v) = max_memtable_batches { + config = config.with_max_memtable_batches(v); + any = true; + } + if let Some(v) = max_unflushed_memtable_bytes { + config = config.with_max_unflushed_memtable_bytes(v); + any = true; + } + if let Some(v) = manifest_scan_batch_size { + config = config.with_manifest_scan_batch_size(v); + any = true; + } + if let Some(v) = backpressure_log_interval_ms { + config = config.with_backpressure_log_interval(Duration::from_millis(v)); + any = true; + } + if let Some(v) = stats_log_interval_ms { + config = config.with_stats_log_interval(stats_log_interval_from_millis(v)); + any = true; + } + if let Some(overrides) = hnsw_params { + // {index_name: {param: value}} -> HnswBuildParams, rejecting unknown + // keys so typos surface instead of being silently ignored. + for (index_name, params) in overrides { + let mut build = HnswBuildParams::default(); + for (key, value) in params { + match key.as_str() { + "num_edges" => build = build.num_edges(value as usize), + "ef_construction" => build = build.ef_construction(value as usize), + "max_level" => build = build.max_level(value as u16), + other => { + return Err(PyValueError::new_err(format!( + "unknown HNSW build param '{}' for index '{}'; \ + expected one of 'num_edges', 'ef_construction', 'max_level'", + other, index_name + ))); + } + } + } + config = config.with_hnsw_params(index_name, build); + any = true; + } + } + Ok(any.then_some(config)) +} fn convert_reader(reader: &Bound) -> PyResult> { let py = reader.py(); @@ -145,7 +329,7 @@ impl MergeInsertBuilder { // schema's unenforced primary key (if configured). let on = if let Some(on_any) = on { on_any - .downcast::() + .cast::() .map(|val| vec![val.to_string()]) .or_else(|_| { let iterator = on_any.try_iter().map_err(|_| { @@ -155,7 +339,7 @@ impl MergeInsertBuilder { })?; let mut keys = Vec::new(); for key in iterator { - keys.push(key?.downcast::()?.to_string()); + keys.push(key?.cast::()?.to_string()); } PyResult::Ok(keys) })? @@ -240,11 +424,53 @@ impl MergeInsertBuilder { Ok(slf) } + pub fn data_storage_version<'a>( + mut slf: PyRefMut<'a, Self>, + version: &str, + ) -> PyResult> { + slf.builder + .data_storage_version(version.parse().infer_error()?); + Ok(slf) + } + pub fn use_index(mut slf: PyRefMut<'_, Self>, use_index: bool) -> PyResult> { slf.builder.use_index(use_index); Ok(slf) } + pub fn write_mode<'a>(mut slf: PyRefMut<'a, Self>, mode: &str) -> PyResult> { + let mode = match mode { + "auto" => MergeInsertWriteMode::Auto, + "rewrite_rows" => MergeInsertWriteMode::RewriteRows, + "rewrite_columns" => MergeInsertWriteMode::RewriteColumns, + other => { + return Err(PyValueError::new_err(format!( + "Invalid write_mode: {other}. Expected one of \ + 'auto', 'rewrite_rows', 'rewrite_columns'" + ))); + } + }; + slf.builder.write_mode(mode); + Ok(slf) + } + + pub fn target_bases( + mut slf: PyRefMut<'_, Self>, + bases: Vec, + ) -> PyResult> { + slf.builder.target_base_names_or_paths(bases); + Ok(slf) + } + + #[pyo3(signature = (include_primary = true))] + pub fn target_all_bases( + mut slf: PyRefMut<'_, Self>, + include_primary: bool, + ) -> PyResult> { + slf.builder.target_all_bases(include_primary); + Ok(slf) + } + pub fn execute(&mut self, new_data: &Bound) -> PyResult> { let py = new_data.py(); let new_data = convert_reader(new_data)?; @@ -288,6 +514,60 @@ impl MergeInsertBuilder { Ok((PyLance(transaction), stats)) } + /// Execute the merge insert from fully-materialized data. + /// + /// The data is read into memory and wrapped in an in-memory table, so retries + /// never spill to disk and the source's statistics drive the join. Callers + /// should only route in-memory inputs (e.g. a `pa.Table`) here. + pub fn execute_batches(&mut self, new_data: &Bound) -> PyResult> { + let py = new_data.py(); + let reader = convert_reader(new_data)?; + let batches = reader + .collect::, _>>() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + let job = self + .builder + .try_build() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + let (new_dataset, stats) = rt() + .spawn(Some(py), job.execute_batches(batches))? + .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + + let dataset = self.dataset.bind(py); + dataset.borrow_mut().ds = new_dataset; + + Ok(Self::build_stats(&stats, py)?.into()) + } + + /// [`Self::execute_batches`] without committing; returns the transaction. + pub fn execute_uncommitted_batches<'a>( + &mut self, + new_data: &Bound<'a, PyAny>, + ) -> PyResult<(PyLance, Bound<'a, PyDict>)> { + let py = new_data.py(); + let reader = convert_reader(new_data)?; + let batches = reader + .collect::, _>>() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + let job = self + .builder + .try_build() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + let UncommittedMergeInsert { + transaction, stats, .. + } = rt() + .spawn(Some(py), job.execute_uncommitted_batches(batches))? + .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + + let stats = Self::build_stats(&stats, py)?; + + Ok((PyLance(transaction), stats)) + } + #[pyo3(signature=(schema = None, verbose = false))] pub fn explain_plan( &mut self, @@ -318,6 +598,84 @@ impl MergeInsertBuilder { rt().block_on(None, job.analyze_plan(new_data_stream))? .map_err(|err| PyIOError::new_err(err.to_string())) } + + /// [`Self::analyze_plan`] for fully-materialized data. + /// + /// Routed to the same in-memory table `execute_batches` uses, so the reported + /// plan is the one such a source actually runs. + pub fn analyze_plan_batches(&mut self, new_data: &Bound) -> PyResult { + let reader = convert_reader(new_data)?; + let batches = reader + .collect::, _>>() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + let job = self + .builder + .clone() + .try_build() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + rt().block_on(None, job.analyze_plan_batches(batches))? + .map_err(|err| PyIOError::new_err(err.to_string())) + } + + /// Mark MemWAL SSTables as compacted into the base table. + /// + /// Call this when executing a merge_insert that compacts MemWAL SSTables. + /// This updates MemWAL compaction progress to prevent duplicate compactions. + pub fn mark_sstables_as_compacted<'a>( + mut slf: PyRefMut<'a, Self>, + sstables: Vec>, + ) -> PyResult> { + use lance_index::mem_wal::CompactedSsTable; + + let compacted_sstables: Vec = sstables + .iter() + .map(|sstable| sstable.borrow().to_lance()) + .collect::>()?; + slf.builder.mark_sstables_as_compacted(compacted_sstables); + Ok(slf) + } +} + +fn index_metadata_to_segment(metadata: IndexMetadata) -> PyResult { + metadata.into_index_segment().infer_error() +} + +fn extract_index_segments(segments: &Bound<'_, PyAny>) -> PyResult> { + let mut extracted = Vec::new(); + for item in segments.try_iter()? { + let item = item?; + match item.extract::>() { + Ok(segment) => extracted.push(segment.inner.clone()), + Err(segment_err) => match item.extract::>() { + Ok(metadata) => extracted.push(index_metadata_to_segment(metadata.0)?), + Err(metadata_err) => { + return Err(PyTypeError::new_err(format!( + "commit_existing_index_segments expected IndexSegment or Index items; \ + failed to read item as IndexSegment ({segment_err}) or Index ({metadata_err})" + ))); + } + }, + } + } + Ok(extracted) +} + +fn parse_index_segment_ids(index_segments: Option>) -> PyResult>> { + index_segments + .map(|segments| { + segments + .into_iter() + .map(|segment| { + Uuid::parse_str(&segment).map_err(|err| { + PyValueError::new_err(format!( + "invalid index segment uuid '{segment}': {err}" + )) + }) + }) + .collect::>>() + }) + .transpose() } impl MergeInsertBuilder { @@ -334,7 +692,7 @@ pub fn transforms_from_python( py: Python<'_>, transforms: &Bound<'_, PyAny>, ) -> PyResult { - if let Ok(transforms) = transforms.downcast::() { + if let Ok(transforms) = transforms.cast::() { let expressions = transforms .iter() .map(|(k, v)| { @@ -359,14 +717,11 @@ pub fn transforms_from_python( let result = udf_obj .call_method1(py, "_call", (py_batch,)) .map_err(|err| { - lance::Error::invalid_input( - format_python_error(err, py).unwrap(), - location!(), - ) + lance::Error::invalid_input(format_python_error(err, py).unwrap()) })?; let result_batch: PyArrowType = result .extract(py) - .map_err(|err| lance::Error::invalid_input(err.to_string(), location!()))?; + .map_err(|err: PyErr| lance::Error::invalid_input(err.to_string()))?; Ok(result_batch.0) }) }; @@ -379,8 +734,9 @@ pub fn transforms_from_python( })) } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let ascending: bool = ob.getattr("ascending")?.extract()?; let nulls_first: bool = ob.getattr("nulls_first")?.extract()?; let column_name: String = ob.getattr("column_name")?.extract()?; @@ -412,7 +768,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&ColumnOrdering> { } /// Python binding for BasePath -#[pyclass(name = "DatasetBasePath", module = "_lib")] +#[pyclass(name = "DatasetBasePath", module = "_lib", from_py_object)] #[derive(Clone)] pub struct DatasetBasePath { #[pyo3(get)] @@ -469,7 +825,7 @@ impl From for BasePath { } /// Lance Dataset that will be wrapped by another class in Python -#[pyclass(name = "_Dataset", module = "_lib")] +#[pyclass(name = "_Dataset", module = "_lib", from_py_object)] #[derive(Clone)] pub struct Dataset { #[pyo3(get)] @@ -477,12 +833,112 @@ pub struct Dataset { pub(crate) ds: Arc, } +impl Dataset { + async fn cleanup_policy( + &self, + older_than_micros: Option, + retain_versions: Option, + delete_unverified: Option, + error_if_tagged_old_versions: Option, + delete_rate_limit: Option, + versions: Option>, + ) -> lance_core::Result { + let mut builder = CleanupPolicyBuilder::default(); + if let Some(v) = older_than_micros { + let older_than = Duration::microseconds(v); + builder = builder.before_timestamp(Utc::now() - older_than); + } + if let Some(v) = retain_versions { + builder = builder.retain_n_versions(self.ds.as_ref(), v).await?; + } + if let Some(v) = delete_unverified { + builder = builder.delete_unverified(v); + } + if let Some(v) = error_if_tagged_old_versions { + builder = builder.error_if_tagged_old_versions(v); + } + if let Some(v) = delete_rate_limit { + builder = builder.delete_rate_limit(v)?; + } + if let Some(v) = versions { + builder = builder.versions(v)?; + } + Ok(builder.build()) + } +} + +fn cleanup_stats(stats: lance::dataset::cleanup::RemovalStats) -> CleanupStats { + CleanupStats { + bytes_removed: stats.bytes_removed, + old_versions: stats.old_versions, + data_files_removed: stats.data_files_removed, + transaction_files_removed: stats.transaction_files_removed, + index_files_removed: stats.index_files_removed, + deletion_files_removed: stats.deletion_files_removed, + failed_deletes: stats.failed_deletes, + } +} + +fn cleanup_file_kind(kind: CleanupFileKind) -> &'static str { + match kind { + CleanupFileKind::Manifest => "manifest", + CleanupFileKind::Data => "data", + CleanupFileKind::Transaction => "transaction", + CleanupFileKind::Index => "index", + CleanupFileKind::Deletion => "deletion", + CleanupFileKind::TemporaryManifest => "temporary_manifest", + } +} + +fn cleanup_explanation( + explanation: lance::dataset::cleanup::CleanupExplanation, +) -> CleanupExplanation { + CleanupExplanation { + read_version: explanation.read_version, + stats: cleanup_stats(explanation.stats), + candidate_files: explanation + .candidate_files + .into_iter() + .map(|file| CleanupCandidateFile { + path: file.path, + kind: cleanup_file_kind(file.kind).to_string(), + unverified: file.unverified, + size_bytes: file.size_bytes, + }) + .collect(), + candidate_files_truncated: explanation.candidate_files_truncated, + candidate_file_limit: explanation.candidate_file_limit, + referenced_branches: explanation + .referenced_branches + .into_iter() + .map(|branch| CleanupReferencedBranch { + name: branch.name, + referenced_version: branch.referenced_version, + cleanup_candidate: branch.cleanup_candidate, + }) + .collect(), + warnings: explanation.warnings, + } +} + +fn version_to_py(py: Python<'_>, version: &Version) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("version", version.version)?; + dict.set_item( + "timestamp", + version.timestamp.timestamp_nanos_opt().unwrap_or_default(), + )?; + let tup: Vec<(&String, &String)> = version.metadata.iter().collect(); + dict.set_item("metadata", tup.into_py_dict(py)?)?; + dict.into_py_any(py) +} + #[pymethods] 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))] + #[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, base_store_params=None))] fn new( py: Python, uri: String, @@ -497,7 +953,10 @@ impl Dataset { index_cache_size_bytes: Option, read_params: Option<&Bound>, session: Option, - storage_options_provider: Option<&Bound<'_, PyAny>>, + namespace_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, + namespace_client_managed_versioning: bool, + base_store_params: Option>>, ) -> PyResult { let mut params = ReadParams::default(); if let Some(metadata_cache_size_bytes) = metadata_cache_size_bytes { @@ -527,22 +986,24 @@ impl Dataset { // Handle read_params dict if let Some(read_params_dict) = read_params { - let cache_repetition_index = read_params_dict + let mut decoder_config = DecoderConfig::default(); + + if let Some(cache_repetition_index) = read_params_dict .get_item("cache_repetition_index") .unwrap_or(None) .and_then(|v| v.extract::().ok()) - .unwrap_or(false); + { + decoder_config.cache_repetition_index = cache_repetition_index; + } - let validate_on_decode = read_params_dict + if let Some(validate_on_decode) = read_params_dict .get_item("validate_on_decode") .unwrap_or(None) .and_then(|v| v.extract::().ok()) - .unwrap_or(false); + { + decoder_config.validate_on_decode = validate_on_decode; + } - let decoder_config = DecoderConfig { - cache_repetition_index, - validate_on_decode, - }; let file_reader_options = FileReaderOptions { decoder_config, ..Default::default() @@ -553,10 +1014,10 @@ impl Dataset { let mut builder = DatasetBuilder::from_uri(&uri).with_read_params(params); if let Some(ver) = version { - if let Ok(i) = ver.downcast::() { + if let Ok(i) = ver.cast::() { let v: u64 = i.extract()?; builder = builder.with_version(v); - } else if let Ok(v) = ver.downcast::() { + } else if let Ok(v) = ver.cast::() { let t: &str = &v.to_string_lossy(); builder = builder.with_tag(t); } else { @@ -565,6 +1026,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"))); @@ -585,11 +1049,51 @@ 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)?; + + // 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 { + // The store derives the branch a request targets from the base + // path it is handed, resolved against the table root. + let external_store = LanceNamespaceExternalManifestStore::for_table_uri( + ns_client, + tid.clone(), + &uri, + ) + .infer_error()?; + let commit_handler: Arc = + Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + builder = builder.with_commit_handler(commit_handler); + } + } + + if let Some(base_store_params) = base_store_params { + for (base_path, opts) in base_store_params { + let accessor = Arc::new(StorageOptionsAccessor::with_static_options(opts)); + let store_params = ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() + }; + builder = builder.with_base_store_params(base_path, store_params); + } } let dataset = rt().block_on(Some(py), builder.load())?; @@ -661,7 +1165,18 @@ impl Dataset { #[getter(data_storage_version)] fn data_storage_version(&self) -> PyResult { - Ok(self.ds.manifest().data_storage_format.version.clone()) + Ok(self + .ds + .manifest() + .data_storage_format + .version + .to_manifest_string() + .to_string()) + } + + #[getter(has_stable_row_ids)] + fn has_stable_row_ids(&self) -> bool { + self.ds.manifest().uses_stable_row_ids() } /// Get index statistics @@ -678,6 +1193,32 @@ impl Dataset { }) } + /// Remap row addresses across compactions still recorded in the + /// fragment-reuse index. Rows a compaction dropped become null. The index + /// retains only recent rounds (older ones are pruned as index remap catches + /// up), so remap promptly: an address whose round was pruned is returned + /// unchanged, not remapped. Returns None when there is no fragment-reuse + /// index. + fn remap_row_addrs( + &self, + py: Python, + addrs: PyArrowType, + ) -> PyResult>> { + use lance_index::metrics::NoOpMetricsCollector; + + let array = make_array(addrs.0); + let frag_reuse_index = rt() + .block_on( + Some(py), + self.ds.open_frag_reuse_index(&NoOpMetricsCollector), + )? + .map_err(|err| { + PyIOError::new_err(format!("failed to open fragment reuse index: {err}")) + })?; + + Ok(frag_reuse_index.map(|fri| PyArrowType(fri.remap_row_ids_array(array).to_data()))) + } + fn serialized_manifest(&self, py: Python) -> Py { let manifest_bytes = self.ds.manifest().serialized(); PyBytes::new(py, &manifest_bytes).into() @@ -698,88 +1239,26 @@ impl Dataset { Ok(dict.into()) } - /// Load index metadata. - /// - /// This call will open the index and return its concrete index type. - fn load_indices(self_: PyRef<'_, Self>) -> PyResult>> { - let index_metadata = rt() - .block_on(Some(self_.py()), self_.ds.load_indices())? - .map_err(|err| PyValueError::new_err(err.to_string()))?; - let py = self_.py(); - index_metadata - .iter() - .map(|idx| { - let dict = PyDict::new(py); - let schema = self_.ds.schema(); - let field_paths = idx - .fields - .iter() - .map(|field_id| schema.field_path(*field_id).unwrap()) - .collect::>(); - - let ds = self_.ds.clone(); - let idx_type = match rt().block_on(Some(self_.py()), async { - if let Some(system_index_type) = infer_system_index_type(idx) { - Ok::<_, lance::Error>(system_index_type.to_string()) - } else { - let idx = ds - .open_generic_index( - &field_paths[0], - &idx.uuid.to_string(), - &NoOpMetricsCollector, - ) - .await?; - Ok::<_, lance::Error>(idx.index_type().to_string()) - } - })? { - Ok(r) => r, - Err(error) => { - log::warn!("Cannot derive index type for {:?}: {}", idx, error); - // mark the type as unknown for any new index type - "Unknown".to_owned() - } - }; - - let fragment_set = PySet::empty(py).unwrap(); - if let Some(bitmap) = &idx.fragment_bitmap { - for fragment_id in bitmap.iter() { - fragment_set.add(fragment_id).unwrap(); - } - } - - dict.set_item("name", idx.name.clone()).unwrap(); - // TODO: once we add more than vector indices, we need to: - // 1. Change protos and write path to persist index type - // 2. Use the new field from idx instead of hard coding it to Vector - dict.set_item("type", idx_type).unwrap(); - dict.set_item("uuid", idx.uuid.to_string()).unwrap(); - dict.set_item("fields", field_paths).unwrap(); - dict.set_item("version", idx.dataset_version).unwrap(); - dict.set_item("fragment_ids", fragment_set).unwrap(); - dict.set_item("base_id", idx.base_id.map(|id| id as i64)) - .unwrap(); - dict.into_py_any(py) - }) - .collect::>>() - } - #[allow(clippy::too_many_arguments)] - #[pyo3(signature=(columns=None, columns_with_transform=None, 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))] + #[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, index_segments=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, row_addr_allowlist=None, row_addr_blocklist=None, minhash_query=None))] fn scanner( self_: PyRef<'_, Self>, columns: Option>, columns_with_transform: Option>, filter: Option, + search_filter: Option, prefilter: Option, limit: Option, offset: Option, nearest: Option<&Bound>, batch_size: Option, + batch_size_bytes: Option, io_buffer_size: Option, batch_readahead: Option, fragment_readahead: Option, scan_in_order: Option, fragments: Option>, + index_segments: Option>, with_row_id: Option, with_row_address: Option, use_stats: Option, @@ -794,6 +1273,10 @@ impl Dataset { strict_batch_size: Option, order_by: Option>>, disable_scoring_autoprojection: Option, + substrait_aggregate: Option>, + row_addr_allowlist: Option>, + row_addr_blocklist: Option>, + minhash_query: Option<&Bound>, ) -> PyResult { let mut scanner: LanceScanner = self_.ds.scan(); @@ -813,7 +1296,7 @@ impl Dataset { (Some(_), Some(_)) => { return Err(PyValueError::new_err( "Cannot specify both columns and columns_with_transform", - )) + )); } (Some(c), None) => { scanner @@ -837,8 +1320,13 @@ impl Dataset { .filter(f.as_str()) .map_err(|err| PyValueError::new_err(err.to_string()))?; } + if let Some(qf) = search_filter { + scanner + .filter_query(qf.inner) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } if let Some(full_text_query) = full_text_query { - let fts_query = if let Ok(full_text_query) = full_text_query.downcast::() { + let fts_query = if let Ok(full_text_query) = full_text_query.cast::() { let mut query = full_text_query .get_item("query")? .ok_or_else(|| PyKeyError::new_err("query must be specified"))? @@ -849,7 +1337,7 @@ impl Dataset { } else { Some( columns - .downcast::()? + .cast::()? .iter() .map(|c| c.extract::()) .collect::>>()?, @@ -861,6 +1349,13 @@ impl Dataset { let is_phrase = query.len() >= 2 && query.starts_with('"') && query.ends_with('"'); let is_multi_match = columns.as_ref().map(|cols| cols.len() > 1).unwrap_or(false); + let document_granularity = match full_text_query.get_item("document_granularity")? { + Some(value) if !value.is_none() => Some( + DocumentGranularity::try_from(value.extract::()?.as_str()) + .map_err(|err| PyValueError::new_err(err.to_string()))?, + ), + _ => None, + }; if is_phrase { // Remove the surrounding quotes for phrase queries @@ -868,8 +1363,20 @@ impl Dataset { } let query: FtsQuery = match (is_phrase, is_multi_match) { - (false, _) => MatchQuery::new(query).into(), - (true, false) => PhraseQuery::new(query).into(), + (false, _) => { + let mut query = MatchQuery::new(query); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity(document_granularity); + } + query.into() + } + (true, false) => { + let mut query = PhraseQuery::new(query); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity(document_granularity); + } + query.into() + } (true, true) => { return Err(PyValueError::new_err( "Phrase queries cannot be used with multiple columns.", @@ -886,7 +1393,7 @@ impl Dataset { })?; } query - } else if let Ok(query) = full_text_query.downcast::() { + } else if let Ok(query) = full_text_query.cast::() { let query = query.borrow(); FullTextSearchQuery::new_query(query.inner.clone()) } else { @@ -905,6 +1412,18 @@ impl Dataset { if let Some(prefilter) = prefilter { scanner.prefilter(prefilter); } + // Serialized RowAddrTreeMap payloads rather than an object: a mask built by + // another extension module cannot hand over a Rust value, but both sides + // agree on this encoding. RowAddrMask::from_serialized_parts is the shared + // entry point, so no binding has to reimplement the allow/block combination. + if let Some(mask) = RowAddrMask::from_serialized_parts( + row_addr_allowlist.as_deref(), + row_addr_blocklist.as_deref(), + ) + .infer_error()? + { + scanner.with_row_addr_prefilter(mask); + } scanner .limit(limit, offset) @@ -913,6 +1432,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); } @@ -949,6 +1471,22 @@ impl Dataset { scanner.with_fragments(fragments); } + if let Some(index_segments) = index_segments { + let index_segments = index_segments + .into_iter() + .map(|segment| { + Uuid::parse_str(&segment).map_err(|err| { + PyValueError::new_err(format!( + "invalid index segment uuid '{segment}': {err}" + )) + }) + }) + .collect::>>()?; + scanner + .with_index_segments(index_segments) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } + if let Some(scan_stats_callback) = scan_stats_callback { let callback = Self::make_scan_stats_callback(scan_stats_callback.clone())?; scanner.scan_stats_callback(callback); @@ -982,7 +1520,7 @@ impl Dataset { other => { return Err(PyValueError::new_err(format!( "Invalid blob_handling: {other}. Expected one of: all_binary, blobs_descriptions, all_descriptions" - ))) + ))); } } } else { @@ -1001,121 +1539,39 @@ impl Dataset { } if let Some(nearest) = nearest { - let column = nearest - .get_item("column")? - .ok_or_else(|| PyKeyError::new_err("Need column for nearest"))? - .to_string(); - - let qval = nearest - .get_item("q")? - .ok_or_else(|| PyKeyError::new_err("Need q for nearest"))?; - let data = ArrayData::from_pyarrow_bound(&qval)?; - let q = make_array(data); - - let k: usize = if let Some(k) = nearest.get_item("k")? { - if k.is_none() { - // Use limit if k is not specified, default to 10. - limit.unwrap_or(10) as usize - } else { - k.extract()? - } - } else { - 10 - }; - - let mut minimum_nprobes = DEFAULT_NPROBES; - let mut maximum_nprobes = None; - - if let Some(nprobes) = nearest.get_item("nprobes")? { - if !nprobes.is_none() { - let extracted: usize = nprobes.extract()?; - minimum_nprobes = extracted; - maximum_nprobes = Some(extracted); - } - } - - if let Some(min_nprobes) = nearest.get_item("minimum_nprobes")? { - if !min_nprobes.is_none() { - minimum_nprobes = min_nprobes.extract()?; - } - } - - if let Some(max_nprobes) = nearest.get_item("maximum_nprobes")? { - if !max_nprobes.is_none() { - maximum_nprobes = Some(max_nprobes.extract()?); - } - } - - if let Some(maximum_nprobes) = maximum_nprobes { - if minimum_nprobes > maximum_nprobes { - return Err(PyValueError::new_err( - "minimum_nprobes must be <= maximum_nprobes", - )); - } - } - - if minimum_nprobes < 1 { - return Err(PyValueError::new_err("minimum_nprobes must be >= 1")); - } - - if let Some(maximum_nprobes) = maximum_nprobes { - if maximum_nprobes < 1 { - return Err(PyValueError::new_err("maximum_nprobes must be >= 1")); - } - } - - let metric_type: Option = - if let Some(metric) = nearest.get_item("metric")? { - if metric.is_none() { - None - } else { - Some( - MetricType::try_from(metric.to_string().to_lowercase().as_str()) - .map_err(|err| PyValueError::new_err(err.to_string()))?, - ) - } - } else { - None - }; - - // When refine factor is specified, a final Refine stage will be added to the I/O plan, - // and use Flat index over the raw vectors to refine the results. - // By default, `refine_factor` is None to not involve extra I/O exec node and random access. - let refine_factor: Option = if let Some(rf) = nearest.get_item("refine_factor")? { - if rf.is_none() { - None - } else { - rf.extract()? - } - } else { - None - }; - - let use_index: bool = if let Some(idx) = nearest.get_item("use_index")? { - idx.extract()? - } else { - true - }; - - let ef: Option = if let Some(ef) = nearest.get_item("ef")? { - if ef.is_none() { - None - } else { - ef.extract()? - } - } else { - None - }; + let default_k: usize = limit.unwrap_or(10) as usize; + let ( + column, + q, + k, + minimum_nprobes, + maximum_nprobes, + metric_type, + refine_factor, + use_index, + ef, + query_parallelism, + approx_mode, + ) = vector_query_params_from_dict(nearest, default_k)?; let (_, element_type) = get_vector_type(self_.ds.schema(), &column) .map_err(|e| PyValueError::new_err(e.to_string()))?; let scanner = match element_type { DataType::UInt8 => { - let q = arrow::compute::cast(&q, &DataType::UInt8).map_err(|e| { + let query_type = match q.data_type() { + DataType::List(field) => DataType::List(Arc::new( + field.as_ref().clone().with_data_type(DataType::UInt8), + )), + DataType::FixedSizeList(field, dimension) => DataType::FixedSizeList( + Arc::new(field.as_ref().clone().with_data_type(DataType::UInt8)), + *dimension, + ), + _ => DataType::UInt8, + }; + let q = arrow::compute::cast(&q, &query_type).map_err(|e| { PyValueError::new_err(format!("Failed to cast q to binary vector: {}", e)) })?; - let q = q.as_primitive::(); - scanner.nearest(&column, q, k) + scanner.nearest(&column, q.as_ref(), k) } _ => scanner.nearest(&column, &q, k), }; @@ -1125,7 +1581,7 @@ impl Dataset { None } else { let tuple = dr - .downcast::() + .cast::() .map_err(|err| PyValueError::new_err(err.to_string()))?; if tuple.len() != 2 { return Err(PyValueError::new_err( @@ -1165,6 +1621,8 @@ impl Dataset { if let Some(ef) = ef { s = s.ef(ef); } + s = s.query_parallelism(query_parallelism); + s = s.approx_mode(approx_mode); s.use_index(use_index); if let Some((lower, upper)) = distance_range { s.distance_range(lower, upper); @@ -1178,6 +1636,30 @@ impl Dataset { .order_by(Some(orderings.into_iter().map(|o| o.0).collect())) .map_err(|err| PyValueError::new_err(err.to_string()))?; } + if let Some(aggregate_bytes) = substrait_aggregate { + scanner + .aggregate(AggregateExpr::substrait(aggregate_bytes)) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } + if let Some(minhash_query) = minhash_query { + let column: String = minhash_query + .get_item("column")? + .ok_or_else(|| PyKeyError::new_err("MinHash query must specify a column"))? + .extract()?; + let text: String = minhash_query + .get_item("text")? + .ok_or_else(|| PyKeyError::new_err("MinHash query must specify text"))? + .extract()?; + if limit.is_none() { + // Same default as the vector `nearest` path when no limit is given + scanner + .limit(Some(10), offset) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } + scanner + .minhash_search(MinHashQuery::new(text, column)) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } let scan = Arc::new(scanner); Ok(Scanner::new(scan)) } @@ -1199,7 +1681,7 @@ impl Dataset { (Some(_), Some(_)) => { return Err(PyValueError::new_err( "Cannot specify both columns and columns_with_transform", - )) + )); } (Some(columns), None) => { Ok(ProjectionRequest::from_columns(columns, self_.ds.schema())) @@ -1226,7 +1708,7 @@ impl Dataset { (Some(_), Some(_)) => { return Err(PyValueError::new_err( "Cannot specify both columns and columns_with_transform", - )) + )); } (Some(columns), None) => { Ok(ProjectionRequest::from_columns(columns, self_.ds.schema())) @@ -1250,18 +1732,21 @@ impl Dataset { self_: PyRef<'_, Self>, row_ids: Vec, blob_column: &str, - ) -> PyResult> { + ) -> PyResult>> { let blobs = rt() .block_on(Some(self_.py()), self_.ds.take_blobs(&row_ids, blob_column))? .infer_error()?; - Ok(blobs.into_iter().map(LanceBlobFile::from).collect()) + Ok(blobs + .into_iter() + .map(|blob| blob.map(LanceBlobFile::from)) + .collect()) } fn take_blobs_by_addresses( self_: PyRef<'_, Self>, row_addresses: Vec, blob_column: &str, - ) -> PyResult> { + ) -> PyResult>> { let blobs = rt() .block_on( Some(self_.py()), @@ -1270,21 +1755,145 @@ impl Dataset { .take_blobs_by_addresses(&row_addresses, blob_column), )? .infer_error()?; - Ok(blobs.into_iter().map(LanceBlobFile::from).collect()) + Ok(blobs + .into_iter() + .map(|blob| blob.map(LanceBlobFile::from)) + .collect()) } fn take_blobs_by_indices( self_: PyRef<'_, Self>, row_indices: Vec, blob_column: &str, - ) -> PyResult> { + ) -> PyResult>> { let blobs = rt() .block_on( Some(self_.py()), self_.ds.take_blobs_by_indices(&row_indices, blob_column), )? .infer_error()?; - Ok(blobs.into_iter().map(LanceBlobFile::from).collect()) + Ok(blobs + .into_iter() + .map(|blob| blob.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=( + requests, + blob_column, + selector, + io_buffer_size=None, + preserve_order=None + ))] + fn read_blob_ranges( + self_: PyRef<'_, Self>, + requests: Vec<(u64, u64, u64)>, + blob_column: &str, + selector: &str, + io_buffer_size: Option, + preserve_order: Option, + ) -> PyResult> { + let requests = blob_range_requests_from_tuples(requests); + let builder = self_.ds.read_blob_ranges(blob_column).infer_error()?; + let builder = match selector { + "ids" => builder.with_row_ids(requests), + "addresses" => builder.with_row_addresses(requests), + "indices" => builder.with_row_indices(requests), + selector => { + return Err(PyValueError::new_err(format!( + "selector must be one of 'ids', 'addresses', or 'indices', got {selector:?}" + ))); + } + }; + let builder = configure_read_blob_ranges_builder(builder, io_buffer_size, preserve_order); + let ranges = rt() + .block_on(Some(self_.py()), builder.execute())? + .infer_error()?; + Ok(read_blob_ranges_to_python(self_.py(), ranges)) } #[pyo3(signature = (row_slices, columns = None, batch_readahead = 10))] @@ -1316,10 +1925,7 @@ impl Dataset { { Ok((start, end)) => Some(Ok(start..end)), Err(err) if err.is_instance_of::(py) => None, - Err(err) => Some(Err(lance::Error::InvalidInput { - source: Box::new(err), - location: location!(), - })), + Err(err) => Some(Err(lance::Error::invalid_input_source(Box::new(err)))), } }) }); @@ -1335,7 +1941,7 @@ impl Dataset { let alterations = alterations .iter() .map(|obj| { - let obj = obj.downcast::()?; + let obj = obj.cast::()?; let path: String = obj .get_item("path")? .ok_or_else(|| PyValueError::new_err("path is required"))? @@ -1411,11 +2017,21 @@ impl Dataset { #[pyo3(signature=(predicate, conflict_retries=None, retry_timeout=None))] fn delete( &mut self, - predicate: String, + py: Python<'_>, + predicate: &Bound<'_, PyAny>, conflict_retries: Option, retry_timeout: Option, - ) -> PyResult<()> { - let mut builder = DeleteBuilder::new(self.ds.clone(), predicate); + ) -> PyResult> { + let mut builder = if let Ok(sql) = predicate.cast::() { + DeleteBuilder::new(self.ds.clone(), sql.to_str()?) + } else if let Ok(bytes) = predicate.cast::() { + let expr = ExprFilter::Substrait(bytes.as_bytes().to_vec()) + .to_datafusion(self.ds.schema(), self.ds.schema()) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + DeleteBuilder::from_expr(self.ds.clone(), expr) + } else { + return Err(PyTypeError::new_err("predicate must be a string or bytes")); + }; if let Some(retries) = conflict_retries { builder = builder.conflict_retries(retries); @@ -1425,26 +2041,37 @@ impl Dataset { builder = builder.retry_timeout(timeout); } - let new_dataset = rt() + let result = rt() .block_on(None, builder.execute())? .map_err(|err| PyIOError::new_err(err.to_string()))?; - self.ds = new_dataset; - Ok(()) + self.ds = result.new_dataset; + let dict = PyDict::new(py); + dict.set_item("num_deleted_rows", result.num_deleted_rows)?; + Ok(dict.into()) } - #[pyo3(signature=(updates, predicate=None, conflict_retries=None, retry_timeout=None))] + #[pyo3(signature=(updates, predicate=None, conflict_retries=None, retry_timeout=None, data_storage_version=None))] fn update( &mut self, updates: &Bound<'_, PyDict>, - predicate: Option<&str>, + predicate: Option<&Bound<'_, PyAny>>, conflict_retries: Option, retry_timeout: Option, + data_storage_version: Option<&str>, ) -> PyResult> { let mut builder = UpdateBuilder::new(self.ds.clone()); if let Some(predicate) = predicate { - builder = builder - .update_where(predicate) - .map_err(|err| PyValueError::new_err(err.to_string()))?; + builder = if let Ok(sql) = predicate.cast::() { + builder.update_where(sql.to_str()?) + } else if let Ok(bytes) = predicate.cast::() { + let expr = ExprFilter::Substrait(bytes.as_bytes().to_vec()) + .to_datafusion(self.ds.schema(), self.ds.schema()) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + builder.update_where_expr(expr) + } else { + return Err(PyTypeError::new_err("predicate must be a string or bytes")); + } + .map_err(|err| PyValueError::new_err(err.to_string()))?; } if let Some(retries) = conflict_retries { @@ -1455,9 +2082,13 @@ impl Dataset { builder = builder.retry_timeout(timeout); } + if let Some(version) = data_storage_version { + builder = builder.data_storage_version(version.parse().infer_error()?); + } + for (key, value) in updates { - let column: PyBackedStr = key.downcast::()?.clone().try_into()?; - let expr: PyBackedStr = value.downcast::()?.clone().try_into()?; + let column: PyBackedStr = key.cast::()?.clone().try_into()?; + let expr: PyBackedStr = value.cast::()?.clone().try_into()?; builder = builder .set(column, &expr) @@ -1508,20 +2139,28 @@ impl Dataset { let versions = self_.list_versions()?; let pyvers: Vec> = versions .iter() - .map(|v| { + .map(|v| version_to_py(py, v)) + .collect::>>()?; + Ok(pyvers) + } + + /// Fetches the currently checked out version of the dataset, with its + /// timestamp and the summary of its manifest. + fn current_version(&self, py: Python<'_>) -> PyResult> { + version_to_py(py, &self.ds.version()) + } + + fn version_refs(self_: PyRef<'_, Self>) -> PyResult>> { + let py = self_.py(); + self_ + .list_version_refs()? + .iter() + .map(|version| { let dict = PyDict::new(py); - dict.set_item("version", v.version).unwrap(); - dict.set_item( - "timestamp", - v.timestamp.timestamp_nanos_opt().unwrap_or_default(), - ) - .unwrap(); - let tup: Vec<(&String, &String)> = v.metadata.iter().collect(); - dict.set_item("metadata", tup.into_py_dict(py)?).unwrap(); + dict.set_item("version", version.version)?; dict.into_py_any(py) }) - .collect::>>()?; - Ok(pyvers) + .collect() } /// Fetches the currently checked out version of the dataset. @@ -1607,6 +2246,38 @@ impl Dataset { }) } + /// Deep clone the selected version into a new dataset. + #[pyo3(signature = (target_path, reference, storage_options=None))] + fn deep_clone( + &mut self, + py: Python, + target_path: String, + reference: Option>, + storage_options: Option>, + ) -> PyResult { + let store_params = storage_options.as_ref().map(|opts| ObjectStoreParams { + storage_options_accessor: Some(Arc::new( + lance::io::StorageOptionsAccessor::with_static_options(opts.clone()), + )), + ..Default::default() + }); + + let mut new_self = self.ds.as_ref().clone(); + let reference = self.transform_ref(reference)?; + let ds = rt() + .block_on( + Some(py), + new_self.deep_clone(&target_path, reference, store_params), + )? + .map_err(|err: Error| PyIOError::new_err(err.to_string()))?; + + let uri = ds.uri().to_string(); + Ok(Self { + ds: Arc::new(ds), + uri, + }) + } + fn restore(&mut self) -> PyResult<()> { let mut new_self = self.ds.as_ref().clone(); rt().block_on(None, new_self.restore())? @@ -1625,38 +2296,74 @@ impl Dataset { } /// Cleanup old versions from the dataset - #[pyo3(signature = (older_than_micros = None, retain_versions = None, delete_unverified = None, error_if_tagged_old_versions = None))] + #[pyo3(signature = (older_than_micros = None, retain_versions = None, delete_unverified = None, error_if_tagged_old_versions = None, delete_rate_limit = None, versions = None))] fn cleanup_old_versions( &self, older_than_micros: Option, retain_versions: Option, delete_unverified: Option, error_if_tagged_old_versions: Option, + delete_rate_limit: Option, + versions: Option>, ) -> PyResult { - let cleanup_stats = rt() + let stats = rt() .block_on(None, async { - let mut builder = CleanupPolicyBuilder::default(); - if let Some(v) = older_than_micros { - let older_than = Duration::microseconds(v); - builder = builder.before_timestamp(Utc::now() - older_than); - } - if let Some(v) = retain_versions { - builder = builder.retain_n_versions(self.ds.as_ref(), v).await?; - } - if let Some(v) = delete_unverified { - builder = builder.delete_unverified(v); - } - if let Some(v) = error_if_tagged_old_versions { - builder = builder.error_if_tagged_old_versions(v); - } + let policy = self + .cleanup_policy( + older_than_micros, + retain_versions, + delete_unverified, + error_if_tagged_old_versions, + delete_rate_limit, + versions, + ) + .await?; + self.ds.cleanup_with_policy(policy).await + })? + .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + Ok(cleanup_stats(stats)) + } - self.ds.cleanup_with_policy(builder.build()).await + /// Explain cleanup old versions from the dataset without deleting files + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (older_than_micros = None, retain_versions = None, delete_unverified = None, error_if_tagged_old_versions = None, delete_rate_limit = None, versions = None, include_files = false, max_files = 1000))] + fn explain_cleanup_old_versions( + &self, + older_than_micros: Option, + retain_versions: Option, + delete_unverified: Option, + error_if_tagged_old_versions: Option, + delete_rate_limit: Option, + versions: Option>, + include_files: bool, + max_files: usize, + ) -> PyResult { + let explanation = rt() + .block_on(None, async { + let policy = self + .cleanup_policy( + older_than_micros, + retain_versions, + delete_unverified, + error_if_tagged_old_versions, + delete_rate_limit, + versions, + ) + .await?; + self.ds + .cleanup(policy) + .with_max_candidate_files(max_files) + .explain() + .await })? .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; - Ok(CleanupStats { - bytes_removed: cleanup_stats.bytes_removed, - old_versions: cleanup_stats.old_versions, - }) + let mut explanation = cleanup_explanation(explanation); + if !include_files { + explanation.candidate_files.clear(); + explanation.candidate_files_truncated = false; + explanation.warnings.clear(); + } + Ok(explanation) } fn tags_ordered(self_: PyRef<'_, Self>, order: Option) -> PyResult> { @@ -1667,8 +2374,12 @@ impl Dataset { for (tag_name, tag_content) in tags { let dict = PyDict::new(py); + dict.set_item("branch", tag_content.branch.clone())?; dict.set_item("version", tag_content.version)?; + dict.set_item("created_at", tag_content.created_at)?; + dict.set_item("updated_at", tag_content.updated_at)?; dict.set_item("manifest_size", tag_content.manifest_size)?; + dict.set_item("metadata", tag_content.metadata.clone())?; pylist.append((tag_name.as_str(), dict))?; } @@ -1685,7 +2396,10 @@ impl Dataset { let dict = PyDict::new(py); dict.set_item("branch", v.branch.clone())?; dict.set_item("version", v.version)?; + dict.set_item("created_at", v.created_at)?; + dict.set_item("updated_at", v.updated_at)?; dict.set_item("manifest_size", v.manifest_size)?; + dict.set_item("metadata", v.metadata.clone())?; pytags.set_item(k, dict.into_py_any(py)?)?; } pytags.into_py_any(py) @@ -1741,6 +2455,15 @@ impl Dataset { Ok(()) } + fn replace_tag_metadata(&self, tag: String, metadata: HashMap) -> PyResult<()> { + rt().block_on( + None, + self.ds.as_ref().tags().replace_metadata(&tag, metadata), + )? + .infer_error()?; + Ok(()) + } + /// Check out the latest version of the current branch fn checkout_latest(&mut self) -> PyResult<()> { let mut new_self = self.ds.as_ref().clone(); @@ -1805,14 +2528,32 @@ 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)?; + dict.set_item("metadata", meta.metadata.clone())?; pybranches.set_item(name, dict.into_py_any(py)?)?; } Ok(pybranches.into()) } + fn replace_branch_metadata( + &self, + branch: String, + metadata: HashMap, + ) -> PyResult<()> { + rt().block_on( + None, + self.ds + .as_ref() + .branches() + .replace_metadata(&branch, metadata), + )? + .infer_error()?; + Ok(()) + } + /// List branches ordered by parent_version fn branches_ordered( &self, @@ -1839,9 +2580,11 @@ 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)?; + dict.set_item("metadata", meta.metadata.clone())?; out.push((name, dict.into_py_any(py)?)); } Ok(out) @@ -1855,6 +2598,9 @@ impl Dataset { if let Some(num_indices_to_merge) = kwargs.get_item("num_indices_to_merge")? { options.num_indices_to_merge = num_indices_to_merge.extract()?; } + if let Some(retrain) = kwargs.get_item("retrain")? { + options.retrain = retrain.extract()?; + } if let Some(index_names) = kwargs.get_item("index_names")? { options.index_names = Some( index_names @@ -1898,12 +2644,13 @@ impl Dataset { "LABEL_LIST" => IndexType::LabelList, "RTREE" => IndexType::RTree, "INVERTED" | "FTS" => IndexType::Inverted, + "FM" => IndexType::Fm, "IVF_FLAT" | "IVF_PQ" | "IVF_SQ" | "IVF_RQ" | "IVF_HNSW_FLAT" | "IVF_HNSW_PQ" | "IVF_HNSW_SQ" => IndexType::Vector, _ => { return Err(PyValueError::new_err(format!( "Index type '{index_type}' is not supported." - ))) + ))); } }; @@ -1937,6 +2684,27 @@ impl Dataset { index_type: "rtree".to_string(), params: None, }), + "FM" => { + let mut params_json = serde_json::Map::new(); + if let Some(kwargs) = kwargs + && let Some(num_segments) = kwargs.get_item("num_segments")? + { + let n: u32 = num_segments.extract()?; + params_json.insert( + "num_segments".to_string(), + serde_json::Value::Number(n.into()), + ); + } + let params = if params_json.is_empty() { + None + } else { + Some(serde_json::Value::Object(params_json).to_string()) + }; + Box::new(ScalarIndexParams { + index_type: "fm".to_string(), + params, + }) + } "SCALAR" => { let Some(kwargs) = kwargs else { return Err(PyValueError::new_err( @@ -1957,51 +2725,81 @@ impl Dataset { "INVERTED" | "FTS" => { let mut params = InvertedIndexParams::default(); if let Some(kwargs) = kwargs { - if let Some(with_position) = kwargs.get_item("with_position")? { - params = params.with_position(with_position.extract()?); - } - if let Some(base_tokenizer) = kwargs.get_item("base_tokenizer")? { - params = params.base_tokenizer(base_tokenizer.extract()?); - } - if let Some(language) = kwargs.get_item("language")? { - let language: PyBackedStr = - language.downcast::()?.clone().try_into()?; - params = params.language(&language).map_err(|e| { - PyValueError::new_err(format!( - "can't set tokenizer language to {}: {:?}", - language, e - )) - })?; - } - if let Some(max_token_length) = kwargs.get_item("max_token_length")? { - params = params.max_token_length(max_token_length.extract()?); - } - if let Some(lower_case) = kwargs.get_item("lower_case")? { - params = params.lower_case(lower_case.extract()?); - } - if let Some(stem) = kwargs.get_item("stem")? { - params = params.stem(stem.extract()?); - } - if let Some(remove_stop_words) = kwargs.get_item("remove_stop_words")? { - params = params.remove_stop_words(remove_stop_words.extract()?); + let allowed_kwargs = [ + "analyzer", + "document_granularity", + "with_position", + "base_tokenizer", + "language", + "max_token_length", + "lower_case", + "stem", + "remove_stop_words", + "custom_stop_words", + "ascii_folding", + "min_ngram_length", + "max_ngram_length", + "prefix_only", + "block_size", + "split_identifiers", + "split_on_numerics", + "preserve_original", + "index_operators", + "memory_limit", + "num_workers", + "format_version", + "fragment_ids", + "index_uuid", + "progress_callback", + ]; + for (key, _) in kwargs.iter() { + let key: String = key.extract()?; + if !allowed_kwargs.contains(&key.as_str()) { + return Err(PyValueError::new_err(format!( + "unknown FTS index parameter '{}'", + key + ))); + } } - if let Some(stop_words_file) = kwargs.get_item("custom_stop_words")? { - params = params.custom_stop_words(stop_words_file.extract()?); + + params = FtsTokenizerOptions::from_kwargs(kwargs)?.apply(params)?; + if let Some(document_granularity) = kwargs.get_item("document_granularity")? { + let document_granularity: String = document_granularity.extract()?; + params = params.document_granularity( + DocumentGranularity::try_from(document_granularity.as_str()) + .map_err(|err| PyValueError::new_err(err.to_string()))?, + ); } - if let Some(ascii_folding) = kwargs.get_item("ascii_folding")? { - params = params.ascii_folding(ascii_folding.extract()?); + if let Some(with_position) = kwargs.get_item("with_position")? { + params = params.with_position(with_position.extract()?); } - if let Some(min_ngram_length) = kwargs.get_item("min_ngram_length")? { - params = params.ngram_min_length(min_ngram_length.extract()?); + if let Some(block_size) = kwargs.get_item("block_size")? { + params = params + .block_size(block_size.extract()?) + .map_err(|e| PyValueError::new_err(e.to_string()))?; } - if let Some(max_ngram_length) = kwargs.get_item("max_ngram_length")? { - params = params.ngram_max_length(max_ngram_length.extract()?); + if let Some(memory_limit) = kwargs.get_item("memory_limit")? { + params = params.memory_limit_mb(memory_limit.extract()?); } - if let Some(prefix_only) = kwargs.get_item("prefix_only")? { - params = params.ngram_prefix_only(prefix_only.extract()?); + if let Some(num_workers) = kwargs.get_item("num_workers")? { + params = params.num_workers(num_workers.extract()?); } - if let Some(skip_merge) = kwargs.get_item("skip_merge")? { - params = params.skip_merge(skip_merge.extract()?); + if let Some(format_version) = kwargs.get_item("format_version")? + && !format_version.is_none() + { + let value = if let Ok(value) = format_version.cast::() { + value.to_string_lossy().to_string() + } else if let Ok(value) = format_version.extract::() { + value.to_string() + } else { + return Err(PyValueError::new_err( + "format_version must be 1, 2, 3, 'v1', 'v2', or 'v3'", + )); + }; + let format_version = value + .parse::() + .map_err(|err| PyValueError::new_err(err.to_string()))?; + params = params.format_version(format_version); } } Box::new(params) @@ -2010,7 +2808,7 @@ impl Dataset { let column_type = match self.ds.schema().field(columns[0]) { Some(f) => f.data_type().clone(), None => { - return Err(PyValueError::new_err("Column not found in dataset schema.")) + return Err(PyValueError::new_err("Column not found in dataset schema.")); } }; prepare_vector_index_params(&index_type, &column_type, storage_options, kwargs)? @@ -2020,6 +2818,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()) @@ -2039,10 +2838,22 @@ impl Dataset { None }; - let index_uuid: Option = if let Some(kwargs) = kwargs { + let index_uuid: Option = if let Some(kwargs) = kwargs { kwargs .get_item("index_uuid")? - .and_then(|v| if v.is_none() { None } else { Some(v.extract()) }) + .and_then(|v| { + if v.is_none() { + None + } else { + Some(v.extract::()) + } + }) + .transpose()? + .map(|s| { + Uuid::parse_str(&s).map_err(|e| { + PyValueError::new_err(format!("Invalid UUID string for index_uuid: {e}")) + }) + }) .transpose()? } else { None @@ -2056,20 +2867,23 @@ 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; // Use execute_uncommitted if fragment_ids is provided, otherwise use execute let index_metadata = if has_fragment_ids { // For fragment-level indexing, use execute_uncommitted - let index_metadata = rt() - .block_on(None, builder.execute_uncommitted())? - .infer_error()?; // Note: We don't update self.ds here as the index is not committed - index_metadata + 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 }; @@ -2077,32 +2891,107 @@ impl Dataset { Ok(PyLance(index_metadata)) } - 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))? + 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: &Bound<'_, PyAny>, + ) -> PyResult<()> { + let mut new_self = self.ds.as_ref().clone(); + let segments = extract_index_segments(segments)?; + 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))? .infer_error()?; self.ds = Arc::new(new_self); 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, index_segments = None))] + fn prewarm_index( + &self, + name: &str, + with_position: bool, + index_segments: Option>, + ) -> PyResult<()> { + let index_segments = parse_index_segment_ids(index_segments)?; + + rt().block_on(None, async { + if with_position { + let options = PrewarmOptions::Fts(FtsPrewarmOptions::new().with_position(true)); + if let Some(index_segments) = index_segments.as_deref() { + self.ds + .prewarm_index_segments_with_options(name, index_segments, &options) + .await + } else { + self.ds.prewarm_index_with_options(name, &options).await + } + } else { + if let Some(index_segments) = index_segments.as_deref() { + self.ds.prewarm_index_segments(name, index_segments).await + } else { + self.ds.prewarm_index(name).await + } + } + })? + .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) - .await - })? + let parsed_uuid = Uuid::parse_str(index_uuid).map_err(|e| { + PyValueError::new_err(format!("Invalid UUID string for index_uuid: {e}")) + })?; + 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( + &parsed_uuid, + IndexType::try_from(index_type)?, + batch_readhead, + progress, + ) + .await + }, + progress_handler.as_mut(), + )? .map_err(|err| PyValueError::new_err(err.to_string())) } @@ -2152,15 +3041,21 @@ impl Dataset { } /// Get a snapshot of current IO statistics without resetting counters - fn io_stats_snapshot(&self) -> IoStats { - let stats = self.ds.object_store().io_stats_snapshot(); - IoStats::from_lance(stats) + fn io_stats_snapshot(&self) -> PyResult { + let object_store = rt() + .block_on(None, self.ds.object_store(None))? + .infer_error()?; + let stats = object_store.io_stats_snapshot(); + Ok(IoStats::from_lance(stats)) } /// Get incremental IO statistics for this dataset - fn io_stats_incremental(&self) -> IoStats { - let stats = self.ds.object_store().io_stats_incremental(); - IoStats::from_lance(stats) + fn io_stats_incremental(&self) -> PyResult { + let object_store = rt() + .block_on(None, self.ds.object_store(None))? + .infer_error()?; + let stats = object_store.io_stats_incremental(); + Ok(IoStats::from_lance(stats)) } #[staticmethod] @@ -2173,6 +3068,9 @@ impl Dataset { rt().spawn(None, async move { let (object_store, path) = object_store_from_uri_or_path(&dest, storage_options).await?; + validate_dataset_root_for_drop(&object_store, &path) + .await + .map_err(|e| PyValueError::new_err(e.to_string()))?; let result = object_store.remove_dir_all(path).await; match result { @@ -2196,18 +3094,22 @@ 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))] + #[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, commit_timeout = None))] 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_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, + namespace_client_managed_versioning: bool, + commit_timeout: Option, ) -> PyResult { let mut transaction = Transaction::new(read_version.unwrap_or_default(), operation.0, None); @@ -2223,31 +3125,37 @@ impl Dataset { PyLance(transaction), commit_lock, storage_options, - storage_options_provider, enable_v2_manifest_paths, detached, max_retries, + enable_stable_row_ids, + namespace_client, + table_id, + namespace_client_managed_versioning, + commit_timeout, ) } #[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))] + #[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, commit_timeout = None))] 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_client: Option<&Bound<'_, PyAny>>, + table_id: Option>, + namespace_client_managed_versioning: bool, + commit_timeout: Option, ) -> 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 { @@ -2258,19 +3166,46 @@ impl Dataset { None }; - let commit_handler = commit_lock - .as_ref() - .map(|commit_lock| { - commit_lock - .into_py_any(commit_lock.py()) - .map(|cl| Arc::new(PyCommitLock::new(cl)) as Arc) - }) - .transpose()?; + // 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 + Some( + commit_lock + .into_py_any(commit_lock.py()) + .map(|cl| Arc::new(PyCommitLock::new(cl)) as Arc)?, + ) + } 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. The store derives the + // branch a request targets from the base path it is handed, + // resolved against the table root. + let ns_client = extract_namespace_arc(ns_client.py(), ns_client)?; + let external_store = LanceNamespaceExternalManifestStore::for_table_uri( + ns_client, + tid, + &dest.table_root_uri()?, + ) + .infer_error()?; + Some(Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }) as Arc) + } else { + None + }; let mut builder = CommitBuilder::new(dest.as_dest()) .enable_v2_manifest_paths(enable_v2_manifest_paths.unwrap_or(true)) .with_detached(detached.unwrap_or(false)) - .with_max_retries(max_retries.unwrap_or(20)); + .with_max_retries(max_retries.unwrap_or(20)) + .with_timeout(commit_timeout); + + if let Some(enable) = enable_stable_row_ids { + builder = builder.use_stable_row_ids(enable); + } if let Some(store_params) = object_store_params { builder = builder.with_store_params(store_params); @@ -2285,7 +3220,7 @@ impl Dataset { commit_lock.map(|cl| cl.py()), builder.execute(transaction.0), )? - .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + .io_or_timeout_error()?; let uri = ds.uri().to_string(); Ok(Self { @@ -2297,21 +3232,19 @@ 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, commit_timeout = 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, + commit_timeout: 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 { @@ -2333,7 +3266,8 @@ impl Dataset { let mut builder = CommitBuilder::new(dest.as_dest()) .enable_v2_manifest_paths(enable_v2_manifest_paths.unwrap_or(true)) .with_detached(detached.unwrap_or(false)) - .with_max_retries(max_retries.unwrap_or(20)); + .with_max_retries(max_retries.unwrap_or(20)) + .with_timeout(commit_timeout); if let Some(store_params) = object_store_params { builder = builder.with_store_params(store_params); @@ -2350,7 +3284,7 @@ impl Dataset { let res = rt() .block_on(None, builder.execute_batch(transactions))? - .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + .io_or_timeout_error()?; let uri = res.dataset.uri().to_string(); let ds = Self { ds: Arc::new(res.dataset), @@ -2403,7 +3337,7 @@ impl Dataset { new_self.add_columns(transforms, None, batch_size).await?; Ok(new_self) })? - .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + .io_or_commit_conflict_error()?; self.ds = Arc::new(new_self); Ok(()) @@ -2427,7 +3361,7 @@ impl Dataset { .await?; Ok(new_self) })? - .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + .io_or_commit_conflict_error()?; self.ds = Arc::new(new_self); Ok(()) @@ -2445,7 +3379,7 @@ impl Dataset { new_self.add_columns(transform, None, None).await?; Ok(new_self) })? - .map_err(|err: lance::Error| PyIOError::new_err(err.to_string()))?; + .io_or_commit_conflict_error()?; self.ds = Arc::new(new_self); Ok(()) } @@ -2471,6 +3405,127 @@ impl Dataset { Ok(PyArrowType(reader)) } + #[pyo3(signature = (column, distance_threshold, *, memory_limit=None, max_concurrency=None))] + fn find_duplicate_pairs( + &self, + py: Python<'_>, + column: &str, + distance_threshold: f32, + memory_limit: Option, + max_concurrency: Option, + ) -> PyResult>> { + let mut options = lance::index::vector::dedup::DuplicatePairsOptions::default(); + if let Some(size) = memory_limit { + options = options.with_memory_limit(size); + } + if let Some(concurrency) = max_concurrency { + options = options.with_max_concurrency(concurrency); + } + let stream = rt() + .block_on( + Some(py), + lance::index::vector::dedup::find_duplicate_pairs_with_options( + self.ds.clone(), + column, + distance_threshold, + options, + ), + )? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + Ok(PyArrowType(Box::new(LanceReader::from_stream( + DatasetRecordBatchStream::new(stream), + )))) + } + + #[pyo3(signature = (column, segment_id, partition_id, distance_threshold, *, memory_limit=None, max_concurrency=None))] + // Keep the binding aligned with the Python utility's keyword-only controls. + #[allow(clippy::too_many_arguments)] + fn find_duplicate_pairs_in_partition( + &self, + py: Python<'_>, + column: &str, + segment_id: &str, + partition_id: usize, + distance_threshold: f32, + memory_limit: Option, + max_concurrency: Option, + ) -> PyResult>> { + let mut options = lance::index::vector::dedup::DuplicatePairsOptions::default(); + if let Some(size) = memory_limit { + options = options.with_memory_limit(size); + } + if let Some(concurrency) = max_concurrency { + options = options.with_max_concurrency(concurrency); + } + let segment_id = uuid::Uuid::parse_str(segment_id).map_err(|err| { + PyValueError::new_err(format!("invalid segment_id '{segment_id}': {err}")) + })?; + let stream = rt() + .block_on( + Some(py), + lance::index::vector::dedup::find_duplicate_pairs_in_partition_with_options( + self.ds.clone(), + column, + segment_id, + partition_id, + distance_threshold, + options, + ), + )? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + Ok(PyArrowType(Box::new(LanceReader::from_stream( + DatasetRecordBatchStream::new(stream), + )))) + } + + #[pyo3(signature = (*, min_version=None, progress=None))] + fn tracked_files( + &self, + min_version: Option, + progress: Option<&Bound<'_, PyAny>>, + ) -> PyResult>> { + use lance::dataset::files::{TrackedFilesOptions, TrackedFilesProgress}; + + let progress_cb: Option> = + if let Some(cb) = progress { + if !cb.is_callable() { + return Err(PyValueError::new_err("progress must be callable")); + } + let cb = cb.clone().unbind(); + Some(Box::new(move |p: TrackedFilesProgress| { + Python::attach(|py| { + let total: Option = p.manifests_total; + match cb.call1(py, (p.manifests_processed, total)) { + Ok(_) => (), + Err(e) => { + log::error!("Error in tracked_files progress callback: {}", e); + } + } + }); + })) + } else { + None + }; + + let options = TrackedFilesOptions { + min_version, + progress: progress_cb, + }; + let stream = rt().block_on(None, self.ds.tracked_files_with_options(options))?; + let reader = Box::new(LanceReader::from_stream(DatasetRecordBatchStream::new( + stream, + ))); + Ok(PyArrowType(reader)) + } + + fn all_files(&self) -> PyResult>> { + let stream = rt().block_on(None, self.ds.all_files())?; + let reader = Box::new(LanceReader::from_stream(DatasetRecordBatchStream::new( + stream, + ))); + Ok(PyArrowType(reader)) + } + #[pyo3(signature = (keys))] fn delete_config_keys(&mut self, keys: Vec) -> PyResult<()> { let mut new_self = self.ds.as_ref().clone(); @@ -2660,11 +3715,7 @@ impl Dataset { let vindex = self .ds - .open_vector_index( - column_name, - &idx_meta.uuid.to_string(), - &NoOpMetricsCollector, - ) + .open_vector_index(column_name, &idx_meta.uuid, &NoOpMetricsCollector) .await .infer_error()?; @@ -2726,9 +3777,465 @@ impl Dataset { let builder = ds.delta(); Ok(DatasetDeltaBuilder { builder }) } + + /// Initialize MemWAL on this dataset. + /// + /// Must be called once before any `mem_wal_writer()` calls. Append-only + /// tables may omit primary-key metadata; primary keys are only required + /// for primary-key lookup and last-write-wins deduplication workflows. + /// + /// At most one sharding mode may be selected: bucket sharding + /// (`bucket_column` + `num_buckets`), identity sharding (`identity_column`), + /// or `unsharded`. With none selected, shards are managed manually. + /// + /// Any writer-configuration keyword arguments are recorded as the default + /// `ShardWriter` configuration for the MemWAL index. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature=( + *, + maintained_indexes=None, + bucket_column=None, + num_buckets=None, + identity_column=None, + unsharded=false, + durable_write=None, + max_wal_buffer_size=None, + max_wal_flush_interval_ms=None, + max_memtable_size=None, + max_memtable_rows=None, + max_memtable_batches=None, + max_unflushed_memtable_bytes=None, + manifest_scan_batch_size=None, + backpressure_log_interval_ms=None, + stats_log_interval_ms=None, + hnsw_params=None, + ))] + fn initialize_mem_wal( + &mut self, + py: Python<'_>, + maintained_indexes: Option>, + bucket_column: Option, + num_buckets: Option, + identity_column: Option, + unsharded: bool, + durable_write: Option, + max_wal_buffer_size: Option, + max_wal_flush_interval_ms: Option, + max_memtable_size: Option, + max_memtable_rows: Option, + max_memtable_batches: Option, + max_unflushed_memtable_bytes: Option, + manifest_scan_batch_size: Option, + backpressure_log_interval_ms: Option, + stats_log_interval_ms: Option, + hnsw_params: Option>>, + ) -> PyResult<()> { + use lance::dataset::mem_wal::DatasetMemWalExt; + + let bucket = match (bucket_column.as_deref(), num_buckets) { + (Some(_), Some(_)) => true, + (None, None) => false, + _ => { + return Err(PyValueError::new_err( + "bucket sharding requires both `bucket_column` and `num_buckets`", + )); + } + }; + let modes = [bucket, identity_column.is_some(), unsharded] + .into_iter() + .filter(|&m| m) + .count(); + if modes > 1 { + return Err(PyValueError::new_err( + "at most one of bucket sharding, `identity_column`, or `unsharded` may be set", + )); + } + + let writer_config = writer_config_from_kwargs( + durable_write, + max_wal_buffer_size, + max_wal_flush_interval_ms, + max_memtable_size, + max_memtable_rows, + max_memtable_batches, + max_unflushed_memtable_bytes, + manifest_scan_batch_size, + backpressure_log_interval_ms, + stats_log_interval_ms, + hnsw_params, + )?; + + let mut ds = Arc::clone(&self.ds); + let new_ds = rt() + .block_on(Some(py), async move { + let dataset = Arc::make_mut(&mut ds); + let mut builder = dataset.initialize_mem_wal(); + if let (Some(column), Some(n)) = (bucket_column, num_buckets) { + builder = builder.bucket_sharding(column, n); + } else if let Some(column) = identity_column { + builder = builder.identity_sharding(column); + } else if unsharded { + builder = builder.unsharded(); + } + // Flattening `None` to an empty list here would ask for no + // index at all rather than every one. + if let Some(maintained_indexes) = maintained_indexes { + builder = builder.maintained_indexes(maintained_indexes); + } + if let Some(config) = writer_config { + builder = builder.writer_config_defaults(config); + } + builder.execute().await?; + Ok::, lance_core::Error>(ds) + })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + self.ds = new_ds; + Ok(()) + } + + /// Return the MemWAL index details for this dataset, or `None` if MemWAL + /// has not been initialized. + /// + /// The returned dict has `num_shards`, `maintained_indexes`, + /// `maintain_all_indexes`, `writer_config_defaults`, and `sharding_specs`. + /// + /// `maintain_all_indexes` is what distinguishes maintaining every index + /// the table has from maintaining none: both leave `maintained_indexes` + /// empty. + fn mem_wal_index_details<'py>(&self, py: Python<'py>) -> PyResult>> { + use lance::dataset::mem_wal::DatasetMemWalExt; + + let ds = self.ds.clone(); + let details = rt() + .block_on(Some(py), async move { ds.mem_wal_index_details().await })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + let Some(details) = details else { + return Ok(None); + }; + + let dict = PyDict::new(py); + dict.set_item("num_shards", details.num_shards)?; + dict.set_item("maintained_indexes", details.maintained_indexes)?; + dict.set_item("maintain_all_indexes", details.maintain_all_indexes)?; + dict.set_item("writer_config_defaults", details.writer_config_defaults)?; + + let specs = PyList::empty(py); + for spec in &details.sharding_specs { + let spec_dict = PyDict::new(py); + spec_dict.set_item("spec_id", spec.spec_id)?; + let fields = PyList::empty(py); + for field in &spec.fields { + let field_dict = PyDict::new(py); + field_dict.set_item("field_id", &field.field_id)?; + field_dict.set_item("source_ids", field.source_ids.clone())?; + field_dict.set_item("transform", field.transform.clone())?; + field_dict.set_item("expression", field.expression.clone())?; + field_dict.set_item("result_type", &field.result_type)?; + field_dict.set_item("parameters", field.parameters.clone())?; + fields.append(field_dict)?; + } + spec_dict.set_item("fields", fields)?; + specs.append(spec_dict)?; + } + dict.set_item("sharding_specs", specs)?; + Ok(Some(dict)) + } + + /// Get a ShardWriter for the specified shard. + /// + /// `initialize_mem_wal()` must be called before using this method. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature=( + shard_id, + *, + durable_write=None, + max_wal_buffer_size=None, + max_wal_flush_interval_ms=None, + max_memtable_size=None, + max_memtable_rows=None, + max_memtable_batches=None, + max_unflushed_memtable_bytes=None, + manifest_scan_batch_size=None, + backpressure_log_interval_ms=None, + stats_log_interval_ms=None, + hnsw_params=None, + ))] + fn mem_wal_writer( + &self, + py: Python<'_>, + shard_id: String, + durable_write: Option, + max_wal_buffer_size: Option, + max_wal_flush_interval_ms: Option, + max_memtable_size: Option, + max_memtable_rows: Option, + max_memtable_batches: Option, + max_unflushed_memtable_bytes: Option, + manifest_scan_batch_size: Option, + backpressure_log_interval_ms: Option, + stats_log_interval_ms: Option, + hnsw_params: Option>>, + ) -> PyResult { + use lance::dataset::mem_wal::DatasetMemWalExt; + + let uuid = uuid::Uuid::parse_str(&shard_id) + .map_err(|e| PyValueError::new_err(format!("Invalid shard_id UUID: {}", e)))?; + + let config = writer_config_from_kwargs( + durable_write, + max_wal_buffer_size, + max_wal_flush_interval_ms, + max_memtable_size, + max_memtable_rows, + max_memtable_batches, + max_unflushed_memtable_bytes, + manifest_scan_batch_size, + backpressure_log_interval_ms, + stats_log_interval_ms, + hnsw_params, + )? + .unwrap_or_default(); + + let ds = self.ds.clone(); + let writer = rt() + .block_on( + Some(py), + async move { ds.mem_wal_writer(uuid, config).await }, + )? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + + Ok(crate::mem_wal::PyShardWriter::new( + writer, + uuid, + self.ds.clone(), + )) + } + + /// Perform pairwise hamming distance clustering on a partition of an IVF_FLAT index. + /// + /// This function loads a specific partition from every segment of an IVF_FLAT + /// index on a hash column, computes pairwise hamming distances between all + /// hashes in the combined partition, filters by threshold, and clusters the + /// results using union-find. + /// + /// Parameters + /// ---------- + /// index_name : str + /// Name of the IVF_FLAT index on the hash column + /// partition_id : int + /// The partition ID within the IVF_FLAT index + /// hamming_threshold : int + /// Maximum hamming distance to consider as similar + /// index_segments : list of str, optional + /// If specified, only these physical index segment UUIDs of the named + /// logical index contribute rows. Defaults to all segments. + /// + /// Returns + /// ------- + /// pyarrow.RecordBatchReader + /// A reader yielding batches with columns: + /// - 'representative': uint64 - The representative row ID for each cluster + /// - 'duplicates': list - List of duplicate row IDs in each cluster + #[pyo3(signature = (index_name, partition_id, hamming_threshold, index_segments=None))] + fn hamming_clustering_for_ivf_partition( + &self, + py: Python<'_>, + index_name: &str, + partition_id: usize, + hamming_threshold: u32, + index_segments: Option>, + ) -> PyResult>> { + use lance::index::vector::hamming::{ + hamming_clustering_for_ivf_partition, hamming_clustering_for_ivf_partition_segments, + }; + + let segment_ids = parse_index_segment_ids(index_segments)?; + let ds = self.ds.as_ref(); + let reader = rt() + .block_on(Some(py), async { + match segment_ids.as_deref() { + Some(segment_ids) => { + hamming_clustering_for_ivf_partition_segments( + ds, + index_name, + segment_ids, + partition_id, + hamming_threshold, + ) + .await + } + None => { + hamming_clustering_for_ivf_partition( + ds, + index_name, + partition_id, + hamming_threshold, + ) + .await + } + } + })? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + Ok(PyArrowType(reader)) + } + + /// Get partition information for an IVF_FLAT index. + /// + /// Partition sizes are aggregated across all segments of the logical index + /// unless a subset is selected via ``index_segments``. + /// + /// Parameters + /// ---------- + /// index_name : str + /// Name of the IVF_FLAT index + /// index_segments : list of str, optional + /// If specified, only these physical index segment UUIDs of the named + /// logical index contribute to the sizes. Defaults to all segments. + /// + /// Returns + /// ------- + /// List[dict] + /// List of partition info dicts with 'partition_id' and 'size' + #[pyo3(signature = (index_name, index_segments=None))] + fn get_ivf_partition_info( + &self, + py: Python<'_>, + index_name: &str, + index_segments: Option>, + ) -> PyResult>> { + use lance::index::vector::hamming::{ + get_ivf_partition_info, get_ivf_partition_info_segments, + }; + + let segment_ids = parse_index_segment_ids(index_segments)?; + let ds = self.ds.as_ref(); + let result = rt() + .block_on(Some(py), async { + match segment_ids.as_deref() { + Some(segment_ids) => { + get_ivf_partition_info_segments(ds, index_name, segment_ids).await + } + None => get_ivf_partition_info(ds, index_name).await, + } + })? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + let partitions: PyResult> = result + .iter() + .map(|p| { + let dict = PyDict::new(py); + dict.set_item("partition_id", p.partition_id)?; + dict.set_item("size", p.size)?; + Ok(dict.into()) + }) + .collect(); + + partitions + } + + /// Perform pairwise hamming distance clustering on sampled rows from a dataset. + /// + /// This function samples N rows randomly from the dataset, extracts hashes, + /// computes pairwise hamming distances, and clusters the results. + /// It's useful for benchmarking and testing without requiring an IVF index. + /// + /// Parameters + /// ---------- + /// column : str + /// Name of the hash column (must be FixedSizeList where N is + /// a positive multiple of 8 bytes) + /// sample_size : int, optional + /// Number of rows to sample (if None or >= total rows, uses all rows) + /// hamming_threshold : int + /// Maximum hamming distance to consider as similar + /// + /// Returns + /// ------- + /// pyarrow.RecordBatchReader + /// A reader yielding batches with columns: + /// - 'representative': uint64 - The representative row ID for each cluster + /// - 'duplicates': list - List of duplicate row IDs in each cluster + #[pyo3(signature = (column, sample_size, hamming_threshold))] + fn hamming_clustering_for_sample( + &self, + py: Python<'_>, + column: &str, + sample_size: Option, + hamming_threshold: u32, + ) -> PyResult>> { + use lance::index::vector::hamming::hamming_clustering_for_sample; + + let ds = self.ds.as_ref(); + let reader = rt() + .block_on( + Some(py), + hamming_clustering_for_sample(ds, column, sample_size, hamming_threshold), + )? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + Ok(PyArrowType(reader)) + } + + /// Perform pairwise hamming distance clustering on a contiguous range of rows from a fragment. + /// + /// This function reads a contiguous range of rows from a specific fragment, + /// extracts hashes, computes pairwise hamming distances, and clusters the results. + /// Unlike sampling, this reads sequential rows which is useful for distributed + /// processing where each worker handles a specific range of a fragment. + /// + /// Parameters + /// ---------- + /// column : str + /// Name of the hash column (must be FixedSizeList where N is + /// a positive multiple of 8 bytes) + /// fragment_id : int + /// The fragment ID to read from + /// start_row : int + /// The starting row offset within the fragment + /// num_rows : int + /// Number of rows to read from the start position + /// hamming_threshold : int + /// Maximum hamming distance to consider as similar + /// + /// Returns + /// ------- + /// pyarrow.RecordBatchReader + /// A reader yielding batches with columns: + /// - 'representative': uint64 - The representative row ID for each cluster + /// - 'duplicates': list - List of duplicate row IDs in each cluster + #[pyo3(signature = (column, fragment_id, start_row, num_rows, hamming_threshold))] + fn hamming_clustering_for_range( + &self, + py: Python<'_>, + column: &str, + fragment_id: usize, + start_row: usize, + num_rows: usize, + hamming_threshold: u32, + ) -> PyResult>> { + use lance::index::vector::hamming::hamming_clustering_for_range; + + let ds = self.ds.as_ref(); + let reader = rt() + .block_on( + Some(py), + hamming_clustering_for_range( + ds, + column, + fragment_id, + start_row, + num_rows, + hamming_threshold, + ), + )? + .map_err(|err| PyValueError::new_err(err.to_string()))?; + + Ok(PyArrowType(reader)) + } } -#[pyclass(name = "SqlQuery", module = "_lib", subclass)] +#[pyclass(name = "SqlQuery", module = "_lib", subclass, skip_from_py_object)] #[derive(Clone)] pub struct SqlQuery { builder: lance::dataset::sql::SqlQueryBuilder, @@ -2788,7 +4295,12 @@ impl SqlQuery { } } -#[pyclass(name = "SqlQueryBuilder", module = "_lib", subclass)] +#[pyclass( + name = "SqlQueryBuilder", + module = "_lib", + subclass, + skip_from_py_object +)] #[derive(Clone)] pub struct SqlQueryBuilder { builder: lance::dataset::sql::SqlQueryBuilder, @@ -2817,15 +4329,46 @@ impl SqlQueryBuilder { } } - /// Build the SQL query. - fn build(&self) -> PyResult { - Ok(SqlQuery { - builder: self.builder.clone(), + #[pyo3(signature = (blob_handling))] + fn blob_handling(&self, blob_handling: &str) -> PyResult { + let blob_handling = match blob_handling { + "all_binary" => BlobHandling::AllBinary, + "blobs_descriptions" => BlobHandling::BlobsDescriptions, + "all_descriptions" => BlobHandling::AllDescriptions, + other => { + return Err(PyValueError::new_err(format!( + "Invalid blob_handling: {other}. Expected one of: all_binary, blobs_descriptions, all_descriptions" + ))); + } + }; + Ok(Self { + builder: self.builder.clone().blob_handling(blob_handling), }) } -} - -// -------------------- Delta API Bindings -------------------- + + #[pyo3(signature = (batch_size))] + fn batch_size(&self, batch_size: usize) -> Self { + Self { + builder: self.builder.clone().batch_size(batch_size), + } + } + + #[pyo3(signature = (batch_size_bytes))] + fn batch_size_bytes(&self, batch_size_bytes: u64) -> Self { + Self { + builder: self.builder.clone().batch_size_bytes(batch_size_bytes), + } + } + + /// Build the SQL query. + fn build(&self) -> PyResult { + Ok(SqlQuery { + builder: self.builder.clone(), + }) + } +} + +// -------------------- Delta API Bindings -------------------- #[pyclass(name = "DatasetDelta", module = "_lib", subclass)] pub struct DatasetDelta { @@ -2865,9 +4408,26 @@ impl DatasetDelta { let reader: Box = Box::new(LanceReader::from_stream(stream)); reader.into_pyarrow(py) } + /// Get the row ids deleted between begin_version (exclusive) and end_version (inclusive) as a stream reader. + /// + /// Requires stable row ids on the dataset. + fn get_deleted_row_ids<'py>(&self, py: Python<'py>) -> PyResult> { + use arrow::pyarrow::IntoPyArrow; + use arrow_array::RecordBatchReader; + let stream = rt() + .block_on(None, self.inner.get_deleted_row_ids())? + .infer_error()?; + let reader: Box = Box::new(LanceReader::from_stream(stream)); + reader.into_pyarrow(py) + } } -#[pyclass(name = "DatasetDeltaBuilder", module = "_lib", subclass)] +#[pyclass( + name = "DatasetDeltaBuilder", + module = "_lib", + subclass, + skip_from_py_object +)] #[derive(Clone)] pub struct DatasetDeltaBuilder { builder: lance::dataset::delta::DatasetDeltaBuilder, @@ -2915,18 +4475,224 @@ impl PyWriteDest { Self::Uri(uri) => WriteDestination::Uri(uri), } } + + /// The table root uri of this destination (a branch dataset resolves to + /// its main location). Used to root the namespace manifest store. + pub fn table_root_uri(&self) -> PyResult { + match self { + Self::Dataset(ds) => Ok(ds.ds.branch_location().find_main().infer_error()?.uri), + Self::Uri(uri) => Ok(uri.to_string()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +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() { + let is_complete = event.event == IndexProgressEventType::Complete; + if let Err(err) = self.dispatch(event) { + if is_complete { + // Complete events are purely informational — the stage's work + // is already done. Propagating a callback error here would + // abort the operation after the real work has succeeded. + log::warn!( + "Ignoring progress callback error on stage-complete event: {}", + err + ); + } else { + return Err(err); + } + } + } + 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 { - if let Ok(i) = reference.downcast::() { + if let Ok(i) = reference.cast::() { let version_number: u64 = i.extract()?; Ok(version_number.into()) - } else if let Ok(tag_name) = reference.downcast::() { + } else if let Ok(tag_name) = reference.cast::() { let tag: &str = &tag_name.to_string_lossy(); Ok(tag.into()) - } else if let Ok(tuple) = reference.downcast::() { + } else if let Ok(tuple) = reference.cast::() { if tuple.len() == 2 { let (branch_name, version_number) = tuple.extract::<(Option, Option)>()?; @@ -2967,6 +4733,10 @@ impl Dataset { rt().block_on(None, self.ds.versions())?.infer_error() } + fn list_version_refs(&self) -> PyResult> { + rt().block_on(None, self.ds.version_refs())?.infer_error() + } + fn list_tags(&self) -> PyResult> { rt().block_on(None, self.ds.tags().list())?.infer_error() } @@ -3009,6 +4779,77 @@ 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) + } + } +} + +/// Serialize row addresses into the payload the scanner's `row_addr_allowlist` / +/// `row_addr_blocklist` parameters accept. +/// +/// Without this those parameters are unusable from Python: they take the roaring +/// `RowAddrTreeMap` encoding, which nothing else exposed here can produce. The +/// result stays plain bytes, so a mask may equally be built by another extension +/// module and handed in. +#[pyfunction(name = "_serialize_row_addrs")] +pub fn serialize_row_addrs(py: Python<'_>, addrs: Vec) -> PyResult> { + let treemap = RowAddrTreeMap::from_iter(addrs); + let mut buf = Vec::with_capacity(treemap.serialized_size()); + treemap.serialize_into(&mut buf).infer_error()?; + Ok(PyBytes::new(py, &buf).unbind()) } #[pyfunction(name = "_write_dataset")] @@ -3017,7 +4858,7 @@ pub fn write_dataset( dest: PyWriteDest, options: &Bound<'_, PyDict>, ) -> PyResult { - let params = get_write_params(options)?; + let params = get_write_params(options, &dest.table_root_uri()?)?; let py = options.py(); let ds = if reader.is_instance_of::() { let scanner: Scanner = reader.extract()?; @@ -3070,8 +4911,8 @@ pub fn get_commit_handler(options: &Bound<'_, PyDict>) -> PyResult>( - dict: &'a Bound<'py, PyDict>, +fn get_dict_opt<'py, D: FromPyObjectOwned<'py>>( + dict: &Bound<'py, PyDict>, key: &str, ) -> PyResult> { let value = dict.get_item(key)?; @@ -3080,14 +4921,19 @@ fn get_dict_opt<'a, 'py, D: FromPyObject<'a>>( if v.is_none() { None } else { - Some(v.extract::()) + Some(v.extract::().map_err(Into::into)) } }) .transpose() } +/// `table_uri` is the destination table's root uri; it roots the namespace +/// manifest store when `namespace_client_managed_versioning` is requested. #[allow(deprecated)] -pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult> { +pub fn get_write_params( + options: &Bound<'_, PyDict>, + table_uri: &str, +) -> PyResult> { let params = if options.is_none() { None } else { @@ -3104,6 +4950,15 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult(options, "max_bytes_per_file")? { p.max_bytes_per_file = maybe_nbytes; } + let data_cache_bytes = get_dict_opt::(options, "data_cache_bytes")?; + let max_page_bytes = get_dict_opt::(options, "max_page_bytes")?; + if data_cache_bytes.is_some() || max_page_bytes.is_some() { + p.file_writer_options = Some(FileWriterOptions { + data_cache_bytes, + max_page_bytes, + ..Default::default() + }); + } if let Some(data_storage_version) = get_dict_opt::(options, "data_storage_version")? { p.data_storage_version = Some(data_storage_version.parse().infer_error()?); @@ -3111,22 +4966,40 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult>(options, "progress")? { p.progress = Arc::new(PyWriteProgress::new(progress.into_py_any(options.py())?)); } + if let Some(session) = get_dict_opt::(options, "session")? { + p.session = Some(session.inner.clone()); + } let storage_options = get_dict_opt::>(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")? @@ -3184,12 +5057,45 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult>(options, "target_bases")? { - if !target_bases_list.is_empty() { - p = p.with_target_base_names_or_paths(target_bases_list); + if let Some(target_bases_list) = get_dict_opt::>(options, "target_bases")? + && !target_bases_list.is_empty() + { + p = p.with_target_base_names_or_paths(target_bases_list); + } + + // Handle target_all_bases parameter (bool: include primary storage) + if let Some(target_all_bases) = get_dict_opt::(options, "target_all_bases")? { + p = p.with_target_all_bases(target_all_bases); + } + + // Handle base_store_params: per-base storage options keyed by base path URI + if let Some(base_store_params) = + get_dict_opt::>>(options, "base_store_params")? + { + for (base_path, opts) in base_store_params { + let accessor = Arc::new(StorageOptionsAccessor::with_static_options(opts)); + let store_params = ObjectStoreParams { + storage_options_accessor: Some(accessor), + ..Default::default() + }; + p = p.with_base_store_params(base_path, store_params); } } + if let Some(allow_external) = + get_dict_opt::(options, "allow_external_blob_outside_bases")? + { + p = p.with_allow_external_blob_outside_bases(allow_external); + } + if let Some(external_blob_mode) = get_dict_opt::(options, "external_blob_mode")? { + p = p.with_external_blob_mode( + ExternalBlobMode::try_from(external_blob_mode.as_str()).infer_error()?, + ); + } + if let Some(max_bytes) = get_dict_opt::(options, "blob_pack_file_size_threshold")? { + p = p.with_blob_pack_file_size_threshold(max_bytes); + } + // Handle properties if let Some(props) = get_dict_opt::>(options, "transaction_properties")? @@ -3205,6 +5111,30 @@ pub fn get_write_params(options: &Bound<'_, PyDict>) -> PyResult(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()) + { + // The store derives the branch a request targets from the base path + // it is handed, resolved against the table root. + let ns_client = extract_namespace_arc(options.py(), ns_client)?; + let external_store = LanceNamespaceExternalManifestStore::for_table_uri( + ns_client, + table_id.clone(), + table_uri, + ) + .infer_error()?; + let commit_handler: Arc = Arc::new(ExternalManifestCommitHandler { + external_manifest_store: Arc::new(external_store), + }); + p.commit_handler = Some(commit_handler); + } + Some(p) }; Ok(params) @@ -3223,6 +5153,7 @@ fn prepare_vector_index_params( let mut sq_params = SQBuildParams::default(); let mut rq_params = RQBuildParams::default(); let mut index_file_version = IndexFileVersion::V3; + let mut skip_transpose = false; if let Some(kwargs) = kwargs { // Parse metric type @@ -3239,6 +5170,22 @@ fn prepare_vector_index_params( sq_params.sample_rate = sample_rate; } + if let Some(max_iters) = kwargs.get_item("max_iters")? { + let max_iters: usize = max_iters.extract()?; + ivf_params.max_iters = max_iters; + pq_params.max_iters = max_iters; + } + + if let Some(streaming_sample_rate) = kwargs.get_item("streaming_sample_rate")? { + ivf_params.streaming_sample_rate = Some(streaming_sample_rate.extract()?); + } + if let Some(streaming_coreset_rate) = kwargs.get_item("streaming_coreset_rate")? { + ivf_params.streaming_coreset_rate = Some(streaming_coreset_rate.extract()?); + } + if let Some(streaming_refine_passes) = kwargs.get_item("streaming_refine_passes")? { + ivf_params.streaming_refine_passes = streaming_refine_passes.extract()?; + } + // Parse IVF params if let Some(n) = kwargs.get_item("num_partitions")? { ivf_params.num_partitions = Some(n.extract()?) @@ -3260,18 +5207,27 @@ fn prepare_vector_index_params( )); } + let centroid_type = match column_type { + DataType::List(field) + if matches!(field.data_type(), DataType::FixedSizeList(_, _)) => + { + field.data_type() + } + _ => column_type, + }; + // It's important that the centroids are the same data type // 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)) - })?; + if centroids.data_type() != centroid_type { + centroids = + cast_with_options(centroids.as_ref(), centroid_type, &Default::default()) + .map_err(|e| { + PyValueError::new_err(format!( + "Failed to cast centroids to vector type: {}", + e + )) + })?; } let centroids = as_fixed_size_list_array(centroids.as_ref()); @@ -3287,29 +5243,26 @@ fn prepare_vector_index_params( } match ( - kwargs.get_item("precomputed_shuffle_buffers")?, - kwargs.get_item("precomputed_shuffle_buffers_path")? - ) { - (Some(l), Some(p)) => { - let path = Path::parse(p.to_string()).map_err(|e| { - PyValueError::new_err(format!( - "Failed to parse precomputed_shuffle_buffers_path: {}", - e - )) - })?; - let list = l.downcast::()? - .iter() - .map(|f| f.to_string()) - .collect(); - ivf_params.precomputed_shuffle_buffers = Some((path, list)); - }, - (None, None) => {}, - _ => { - return Err(PyValueError::new_err( - "precomputed_shuffle_buffers and precomputed_shuffle_buffers_path must be specified together." + kwargs.get_item("precomputed_shuffle_buffers")?, + kwargs.get_item("precomputed_shuffle_buffers_path")?, + ) { + (Some(l), Some(p)) => { + let path = Path::parse(p.to_string()).map_err(|e| { + PyValueError::new_err(format!( + "Failed to parse precomputed_shuffle_buffers_path: {}", + e )) - } + })?; + let list = l.cast::()?.iter().map(|f| f.to_string()).collect(); + ivf_params.precomputed_shuffle_buffers = Some((path, list)); + } + (None, None) => {} + _ => { + return Err(PyValueError::new_err( + "precomputed_shuffle_buffers and precomputed_shuffle_buffers_path must be specified together.", + )); } + } // Parse HNSW params if let Some(max_level) = kwargs.get_item("max_level")? { @@ -3346,11 +5299,22 @@ fn prepare_vector_index_params( pq_params.codebook = Some(codebook.values().clone()) }; + if let Some(r) = kwargs.get_item("rabitq_model")? { + let json: String = r.extract()?; + let meta: RabitQuantizationMetadata = serde_json::from_str(&json) + .map_err(|e| PyValueError::new_err(format!("Invalid rabitq_model JSON: {e}")))?; + rq_params.rotation = Some(meta); + }; + if let Some(version) = kwargs.get_item("index_file_version")? { let version: String = version.extract()?; index_file_version = IndexFileVersion::try_from(&version) .map_err(|e| PyValueError::new_err(format!("Invalid index_file_version: {e}")))?; } + + if let Some(value) = kwargs.get_item("skip_transpose")? { + skip_transpose = value.extract()?; + } } let mut params = match index_type { @@ -3395,6 +5359,14 @@ fn prepare_vector_index_params( ))), }?; params.version(index_file_version); + params.skip_transpose(skip_transpose); + if let Some(kwargs) = kwargs + && let Some(acc) = kwargs.get_item("accelerator")? + { + params + .runtime_hints + .insert("lancedb.accelerator".to_string(), acc.to_string()); + } Ok(params) } @@ -3422,10 +5394,10 @@ impl WriteFragmentProgress for PyWriteProgress { Ok(()) }) .map_err(|e| { - lance::Error::invalid_input( - format!("Failed to call begin() on WriteFragmentProgress: {}", e), - location!(), - ) + lance::Error::invalid_input(format!( + "Failed to call begin() on WriteFragmentProgress: {}", + e + )) })?; Ok(()) } @@ -3439,10 +5411,10 @@ impl WriteFragmentProgress for PyWriteProgress { Ok(()) }) .map_err(|e| { - lance::Error::invalid_input( - format!("Failed to call complete() on WriteFragmentProgress: {}", e), - location!(), - ) + lance::Error::invalid_input(format!( + "Failed to call complete() on WriteFragmentProgress: {}", + e + )) })?; Ok(()) } @@ -3483,10 +5455,10 @@ impl UDFCheckpointStore for PyBatchUDFCheckpointWrapper { Ok(batch.map(|b| b.0)) }) .map_err(|err: PyErr| { - lance_core::Error::invalid_input( - format!("Failed to call get_batch() on UDFCheckpointer: {}", err), - location!(), - ) + lance_core::Error::invalid_input(format!( + "Failed to call get_batch() on UDFCheckpointer: {}", + err + )) }) } @@ -3499,18 +5471,18 @@ impl UDFCheckpointStore for PyBatchUDFCheckpointWrapper { Ok(fragment) }) .map_err(|err: PyErr| { - lance_core::Error::invalid_input( - format!("Failed to call get_fragment() on UDFCheckpointer: {}", err), - location!(), - ) + lance_core::Error::invalid_input(format!( + "Failed to call get_fragment() on UDFCheckpointer: {}", + err + )) })?; fragment_data .map(|data| { serde_json::from_str(&data).map_err(|err| { - lance_core::Error::invalid_input( - format!("Failed to deserialize fragment data: {}", err), - location!(), - ) + lance_core::Error::invalid_input(format!( + "Failed to deserialize fragment data: {}", + err + )) }) }) .transpose() @@ -3524,19 +5496,16 @@ impl UDFCheckpointStore for PyBatchUDFCheckpointWrapper { Ok(()) }) .map_err(|err: PyErr| { - lance_core::Error::invalid_input( - format!("Failed to call insert_batch() on UDFCheckpointer: {}", err), - location!(), - ) + lance_core::Error::invalid_input(format!( + "Failed to call insert_batch() on UDFCheckpointer: {}", + err + )) }) } fn insert_fragment(&self, fragment: Fragment) -> lance_core::Result<()> { let data = serde_json::to_string(&fragment).map_err(|err| { - lance_core::Error::io( - format!("Failed to serialize fragment data: {}", err), - location!(), - ) + lance_core::Error::io(format!("Failed to serialize fragment data: {}", err)) })?; Python::attach(|py| { self.inner @@ -3544,18 +5513,15 @@ impl UDFCheckpointStore for PyBatchUDFCheckpointWrapper { Ok(()) }) .map_err(|err: PyErr| { - lance_core::Error::invalid_input( - format!( - "Failed to call insert_fragment() on UDFCheckpointer: {}", - err - ), - location!(), - ) + lance_core::Error::invalid_input(format!( + "Failed to call insert_fragment() on UDFCheckpointer: {}", + err + )) }) } } -#[pyclass(name = "PyFullTextQuery")] +#[pyclass(name = "PyFullTextQuery", from_py_object)] #[derive(Debug, Clone)] pub struct PyFullTextQuery { pub(crate) inner: FtsQuery, @@ -3564,7 +5530,8 @@ pub struct PyFullTextQuery { #[pymethods] impl PyFullTextQuery { #[staticmethod] - #[pyo3(signature = (query, column, boost=1.0, fuzziness=Some(0), max_expansions=50, operator="OR", prefix_length=0))] + #[pyo3(signature = (query, column, boost=1.0, fuzziness=Some(0), max_expansions=50, operator="OR", prefix_length=0, document_granularity=None))] + #[allow(clippy::too_many_arguments)] fn match_query( query: String, column: String, @@ -3573,30 +5540,48 @@ impl PyFullTextQuery { max_expansions: usize, operator: &str, prefix_length: u32, + document_granularity: Option<&str>, ) -> PyResult { + let mut query = MatchQuery::new(query) + .with_column(Some(column)) + .with_boost(boost) + .with_fuzziness(fuzziness) + .with_max_expansions(max_expansions) + .with_operator( + Operator::try_from(operator) + .map_err(|e| PyValueError::new_err(format!("Invalid operator: {}", e)))?, + ) + .with_prefix_length(prefix_length); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity( + DocumentGranularity::try_from(document_granularity) + .map_err(|err| PyValueError::new_err(err.to_string()))?, + ); + } Ok(Self { - inner: MatchQuery::new(query) - .with_column(Some(column)) - .with_boost(boost) - .with_fuzziness(fuzziness) - .with_max_expansions(max_expansions) - .with_operator( - Operator::try_from(operator) - .map_err(|e| PyValueError::new_err(format!("Invalid operator: {}", e)))?, - ) - .with_prefix_length(prefix_length) - .into(), + inner: query.into(), }) } #[staticmethod] - #[pyo3(signature = (query, column, slop))] - fn phrase_query(query: String, column: String, slop: u32) -> PyResult { + #[pyo3(signature = (query, column, slop, document_granularity=None))] + fn phrase_query( + query: String, + column: String, + slop: u32, + document_granularity: Option<&str>, + ) -> PyResult { + let mut query = PhraseQuery::new(query) + .with_column(Some(column)) + .with_slop(slop); + if let Some(document_granularity) = document_granularity { + query = query.with_document_granularity( + DocumentGranularity::try_from(document_granularity) + .map_err(|err| PyValueError::new_err(err.to_string()))?, + ); + } Ok(Self { - inner: PhraseQuery::new(query) - .with_column(Some(column)) - .with_slop(slop) - .into(), + inner: query.into(), }) } @@ -3633,6 +5618,31 @@ impl PyFullTextQuery { }) } + #[staticmethod] + #[pyo3(signature = (query, columns, boosts=None, operator="OR"))] + fn combined_fields_query( + query: String, + columns: Vec, + boosts: Option>, + operator: &str, + ) -> PyResult { + let q = CombinedFieldsQuery::try_new(query, columns) + .map_err(|e| PyValueError::new_err(format!("Invalid query: {}", e)))?; + let q = if let Some(boosts) = boosts { + q.try_with_boosts(boosts) + .map_err(|e| PyValueError::new_err(format!("Invalid boosts: {}", e)))? + } else { + q + }; + + let op = Operator::try_from(operator) + .map_err(|e| PyValueError::new_err(format!("Invalid operator: {}", e)))?; + + Ok(Self { + inner: q.with_operator(op).into(), + }) + } + #[staticmethod] #[pyo3(signature = (queries))] fn boolean_query(queries: Vec<(String, Self)>) -> PyResult { @@ -3648,3 +5658,237 @@ impl PyFullTextQuery { }) } } + +type VectorQueryParams = ( + String, + arrow_array::ArrayRef, + usize, + usize, + Option, + Option, + Option, + bool, + Option, + i32, + ApproxMode, +); + +fn extract_query_parallelism(value: &Bound<'_, PyAny>) -> PyResult { + let query_parallelism = value.extract()?; + if query_parallelism < -1 { + Err(PyValueError::new_err("query_parallelism must be >= -1")) + } else { + Ok(query_parallelism) + } +} + +fn vector_query_query_parallelism_from_dict(dict: &Bound<'_, PyDict>) -> PyResult { + if let Some(query_parallelism) = dict.get_item("query_parallelism")? + && !query_parallelism.is_none() + { + extract_query_parallelism(&query_parallelism) + } else { + Ok(DEFAULT_QUERY_PARALLELISM) + } +} + +fn vector_query_approx_mode_from_dict(dict: &Bound<'_, PyDict>) -> PyResult { + if let Some(approx_mode) = dict.get_item("approx_mode")? + && !approx_mode.is_none() + { + match approx_mode.to_string().to_lowercase().as_str() { + "fast" => Ok(ApproxMode::Fast), + "normal" => Ok(ApproxMode::Normal), + "accurate" => Ok(ApproxMode::Accurate), + value => Err(PyValueError::new_err(format!( + "approx_mode must be one of 'fast', 'normal', or 'accurate', got '{value}'" + ))), + } + } else { + Ok(ApproxMode::Normal) + } +} + +fn vector_query_params_from_dict( + dict: &Bound<'_, PyDict>, + default_k: usize, +) -> PyResult { + let column = dict + .get_item("column")? + .ok_or_else(|| PyKeyError::new_err("Need column for nearest"))? + .to_string(); + + let qval = dict + .get_item("q")? + .ok_or_else(|| PyKeyError::new_err("Need q for nearest"))?; + let data = ArrayData::from_pyarrow_bound(&qval)?; + let key = make_array(data); + + let k: usize = if let Some(k) = dict.get_item("k")? { + if k.is_none() { + // Use limit if k is not specified, default to 10. + default_k + } else { + k.extract()? + } + } else { + default_k + }; + + let mut minimum_nprobes = DEFAULT_NPROBES; + let mut maximum_nprobes: Option = None; + + if let Some(nprobes) = dict.get_item("nprobes")? + && !nprobes.is_none() + { + let extracted: usize = nprobes.extract()?; + minimum_nprobes = extracted; + maximum_nprobes = Some(extracted); + } + + if let Some(min_nprobes) = dict.get_item("minimum_nprobes")? + && !min_nprobes.is_none() + { + minimum_nprobes = min_nprobes.extract()?; + } + + if let Some(max_nprobes) = dict.get_item("maximum_nprobes")? + && !max_nprobes.is_none() + { + maximum_nprobes = Some(max_nprobes.extract()?); + } + + if let Some(maximum_nprobes_val) = maximum_nprobes + && minimum_nprobes > maximum_nprobes_val + { + return Err(PyValueError::new_err( + "minimum_nprobes must be <= maximum_nprobes", + )); + } + + if minimum_nprobes < 1 { + return Err(PyValueError::new_err("minimum_nprobes must be >= 1")); + } + + if let Some(maximum_nprobes_val) = maximum_nprobes + && maximum_nprobes_val < 1 + { + return Err(PyValueError::new_err("maximum_nprobes must be >= 1")); + } + + let metric_type: Option = if let Some(metric) = dict.get_item("metric")? { + if metric.is_none() { + None + } else { + Some( + MetricType::try_from(metric.to_string().to_lowercase().as_str()) + .map_err(|err| PyValueError::new_err(err.to_string()))?, + ) + } + } else { + None + }; + + // When refine factor is specified, a final Refine stage will be added to the I/O plan, + // and use Flat index over the raw vectors to refine the results. + // By default, `refine_factor` is None to not involve extra I/O exec node and random access. + let refine_factor: Option = if let Some(rf) = dict.get_item("refine_factor")? { + if rf.is_none() { None } else { rf.extract()? } + } else { + None + }; + + let use_index: bool = if let Some(idx) = dict.get_item("use_index")? { + idx.extract()? + } else { + true + }; + + let ef: Option = if let Some(ef_obj) = dict.get_item("ef")? { + if ef_obj.is_none() { + None + } else { + ef_obj.extract()? + } + } else { + None + }; + + let query_parallelism = vector_query_query_parallelism_from_dict(dict)?; + let approx_mode = vector_query_approx_mode_from_dict(dict)?; + + Ok(( + column, + key, + k, + minimum_nprobes, + maximum_nprobes, + metric_type, + refine_factor, + use_index, + ef, + query_parallelism, + approx_mode, + )) +} + +#[pyclass(name = "PySearchFilter", from_py_object)] +#[derive(Debug, Clone)] +pub struct PySearchFilter { + pub(crate) inner: QueryFilter, +} + +#[pymethods] +impl PySearchFilter { + /// Create a search filter from a full text query. + #[staticmethod] + #[pyo3(signature = (query))] + fn from_full_text_query(query: PyFullTextQuery) -> PyResult { + Ok(Self { + inner: QueryFilter::Fts(FullTextSearchQuery::new_query(query.inner.clone())), + }) + } + + /// Create a query filter from a vector search query dict. + #[staticmethod] + #[pyo3(signature = (query))] + fn from_vector_search_query(query: &Bound<'_, PyDict>) -> PyResult { + let default_k = 10; + let ( + column, + key, + k, + minimum_nprobes, + maximum_nprobes, + metric_type_opt, + refine_factor, + use_index, + ef, + query_parallelism, + approx_mode, + ) = vector_query_params_from_dict(query, default_k)?; + + let metric_type = Some(metric_type_opt.unwrap_or(MetricType::L2)); + + let vector_query = VectorQuery { + column, + key, + k, + lower_bound: None, + upper_bound: None, + minimum_nprobes, + maximum_nprobes, + ef, + refine_factor, + metric_type, + use_index, + query_parallelism, + dist_q_c: 0.0, + approx_mode, + }; + + Ok(Self { + inner: QueryFilter::Vector(vector_query), + }) + } +} diff --git a/python/src/dataset/blob.rs b/python/src/dataset/blob.rs index 12d0f362fea..3b9f0dc25bd 100644 --- a/python/src/dataset/blob.rs +++ b/python/src/dataset/blob.rs @@ -15,10 +15,10 @@ use std::sync::Arc; use pyo3::{ + Bound, PyResult, Python, exceptions::PyValueError, pyclass, pymethods, types::{PyByteArray, PyByteArrayMethods, PyBytes}, - Bound, PyResult, Python, }; use lance::dataset::BlobFile as InnerBlobFile; @@ -62,6 +62,49 @@ 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)) + } + + /// Read multiple blob-local `(offset, length)` ranges without changing the current cursor. + pub fn read_ranges<'py>( + &self, + py: Python<'py>, + ranges: Vec<(u64, u64)>, + ) -> PyResult>> { + let ranges = ranges + .into_iter() + .enumerate() + .map(|(i, (offset, length))| { + let end = offset.checked_add(length).ok_or_else(|| { + PyValueError::new_err(format!( + "Blob range request {i} offset + length overflowed u64: \ + offset={offset}, length={length}" + )) + })?; + Ok(offset..end) + }) + .collect::>>()?; + let inner = self.inner.clone(); + let data = rt() + .block_on(Some(py), inner.read_ranges(&ranges))? + .infer_error()?; + Ok(data.iter().map(|bytes| PyBytes::new(py, bytes)).collect()) + } + pub fn read_into(&self, dst: Bound<'_, PyByteArray>) -> PyResult { let inner = self.inner.clone(); @@ -83,6 +126,18 @@ impl LanceBlobFile { Ok(data.len()) } } + + /// Sequential read-ahead size in bytes. `0` disables read-ahead. + pub fn set_buffer_size(&self, py: Python<'_>, buffer_size: usize) -> PyResult<()> { + let inner = self.inner.clone(); + rt().block_on(Some(py), inner.set_buffer_size(buffer_size))? + .infer_error() + } + + #[pyo3(name = "_range_submission_count")] + pub fn range_submission_count(&self) -> usize { + self.inner.range_submission_count() + } } impl From for LanceBlobFile { diff --git a/python/src/dataset/cleanup.rs b/python/src/dataset/cleanup.rs index e45bf308faa..bc2d1431057 100644 --- a/python/src/dataset/cleanup.rs +++ b/python/src/dataset/cleanup.rs @@ -14,11 +14,17 @@ use pyo3::{pyclass, pymethods}; -#[pyclass(get_all)] -#[derive(Debug)] +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug)] pub struct CleanupStats { pub bytes_removed: u64, pub old_versions: u64, + pub data_files_removed: u64, + pub transaction_files_removed: u64, + pub index_files_removed: u64, + pub deletion_files_removed: u64, + /// Files that could not be deleted; not counted as removed. + pub failed_deletes: u64, } #[pymethods] @@ -27,3 +33,53 @@ impl CleanupStats { format!("{self:?}") } } + +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug)] +pub struct CleanupCandidateFile { + pub path: String, + pub kind: String, + pub unverified: bool, + pub size_bytes: u64, +} + +#[pymethods] +impl CleanupCandidateFile { + fn __repr__(&self) -> String { + format!("{self:?}") + } +} + +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug)] +pub struct CleanupReferencedBranch { + pub name: String, + pub referenced_version: u64, + pub cleanup_candidate: bool, +} + +#[pymethods] +impl CleanupReferencedBranch { + fn __repr__(&self) -> String { + format!("{self:?}") + } +} + +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug)] +pub struct CleanupExplanation { + pub read_version: u64, + pub stats: CleanupStats, + pub candidate_files: Vec, + pub candidate_files_truncated: bool, + pub candidate_file_limit: usize, + pub referenced_branches: Vec, + pub warnings: Vec, +} + +#[pymethods] +impl CleanupExplanation { + fn __repr__(&self) -> String { + format!("{self:?}") + } +} diff --git a/python/src/dataset/commit.rs b/python/src/dataset/commit.rs index c76a91f718c..bb904f89bf7 100644 --- a/python/src/dataset/commit.rs +++ b/python/src/dataset/commit.rs @@ -16,7 +16,6 @@ use std::fmt::Debug; use std::sync::LazyLock; use lance_table::io::commit::{CommitError, CommitLease, CommitLock}; -use snafu::location; use lance_core::Error; @@ -24,8 +23,7 @@ use pyo3::{exceptions::PyIOError, prelude::*}; static PY_CONFLICT_ERROR: LazyLock>> = LazyLock::new(|| { Python::attach(|py| { - py.import("lance") - .and_then(|lance| lance.getattr("commit")) + py.import("lance.commit") .and_then(|commit| commit.getattr("CommitConflictError")) .map(|err| err.unbind()) }) @@ -35,20 +33,20 @@ fn handle_error(py_err: PyErr, py: Python) -> CommitError { let conflict_err_type = match &*PY_CONFLICT_ERROR { Ok(err) => err.bind(py).get_type(), Err(import_error) => { - return CommitError::OtherError(Error::Internal { - message: format!("Error importing from pylance {}", import_error), - location: location!(), - }) + return CommitError::OtherError(Error::internal(format!( + "Error importing from pylance {}", + import_error + ))); } }; if py_err.is_instance(py, &conflict_err_type) { CommitError::CommitConflict } else { - CommitError::OtherError(Error::Internal { - message: format!("Error from commit handler: {}", py_err), - location: location!(), - }) + CommitError::OtherError(Error::internal(format!( + "Error from commit handler: {}", + py_err + ))) } } diff --git a/python/src/dataset/io_stats.rs b/python/src/dataset/io_stats.rs index fd6f10513c3..c153dc20fed 100644 --- a/python/src/dataset/io_stats.rs +++ b/python/src/dataset/io_stats.rs @@ -12,7 +12,7 @@ use pyo3::{pyclass, pymethods}; /// /// Note: Calling `io_stats()` returns the statistics accumulated since the last call /// and resets the internal counters (incremental stats pattern). -#[pyclass(name = "IOStats", module = "_lib", get_all)] +#[pyclass(name = "IOStats", module = "_lib", get_all, skip_from_py_object)] #[derive(Clone, Debug)] pub struct IoStats { /// Number of read IO operations performed diff --git a/python/src/dataset/optimize.rs b/python/src/dataset/optimize.rs index d398f5b7ce6..e17023b7450 100644 --- a/python/src/dataset/optimize.rs +++ b/python/src/dataset/optimize.rs @@ -15,16 +15,20 @@ use lance::dataset::{ index::DatasetIndexRemapperOptions, optimize::{ - commit_compaction, compact_files, plan_compaction, CompactionMetrics, CompactionOptions, - CompactionPlan, CompactionTask, RewriteResult, + CompactionMetrics, CompactionMode, CompactionOptions, CompactionPlan, CompactionTask, + RewriteResult, commit_compaction, compact_files, plan_compaction, }, }; 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()?; @@ -45,12 +49,49 @@ fn parse_compaction_options(options: &Bound<'_, PyDict>) -> PyResult { opts.materialize_deletions_threshold = value.extract()?; } + "defer_index_remap" => { + opts.defer_index_remap = value.extract()?; + } "num_threads" => { opts.num_threads = value.extract()?; } "batch_size" => { opts.batch_size = value.extract()?; } + "io_buffer_size" => { + opts.io_buffer_size = value.extract()?; + } + "compaction_mode" => { + let mode_str: Option = value.extract()?; + if let Some(mode_str) = mode_str { + opts.compaction_mode = Some( + CompactionMode::try_from(mode_str.as_str()) + .map_err(|e| PyValueError::new_err(e.to_string()))?, + ); + } + } + "binary_copy_read_batch_bytes" => { + opts.binary_copy_read_batch_bytes = value.extract()?; + } + "max_source_fragments" => { + opts.max_source_fragments = value.extract()?; + } + "max_source_rows" => { + opts.max_source_rows = value.extract()?; + } + "max_source_bytes" => { + opts.max_source_bytes = value.extract()?; + } + "excluded_fragment_ids" => { + opts.excluded_fragment_ids = + value.extract::>>()?.unwrap_or_default(); + } + "data_storage_version" => { + let version: Option = value.extract()?; + if let Some(version) = version { + opts.data_storage_version = Some(version.parse().infer_error()?); + } + } _ => { return Err(PyValueError::new_err(format!( "Invalid compaction option: {}", @@ -64,7 +105,8 @@ fn parse_compaction_options(options: &Bound<'_, PyDict>) -> PyResult) -> PyResult> { - dataset.getattr("_ds")?.extract() + let ds = dataset.getattr("_ds")?; + Ok(ds.cast::()?.clone()) } fn wrap_fragment<'py>(py: Python<'py>, fragment: &Fragment) -> PyResult> { @@ -207,7 +249,7 @@ impl PyCompactionPlan { } } -#[pyclass(name = "CompactionTask", module = "lance.optimize")] +#[pyclass(name = "CompactionTask", module = "lance.optimize", from_py_object)] #[derive(Clone)] pub struct PyCompactionTask(CompactionTask); @@ -325,7 +367,7 @@ impl PyCompactionTask { /// /// This result is pickle-able, so it can be serialized and sent back to the /// main process to be passed to :py:meth:`lance.optimize.Compaction.commit`. -#[pyclass(name = "RewriteResult", module = "lance.optimize")] +#[pyclass(name = "RewriteResult", module = "lance.optimize", from_py_object)] #[derive(Clone)] pub struct PyRewriteResult(RewriteResult); @@ -469,8 +511,9 @@ impl PyCompaction { let dataset = dataset_ref.borrow().clone(); // 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 options = options.cast::()?; + 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 { @@ -502,8 +545,9 @@ impl PyCompaction { let dataset = dataset.borrow().clone(); // 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 options = options.cast::()?; + 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 @@ -526,26 +570,34 @@ impl PyCompaction { /// new version once committed. /// rewrites : List[RewriteResult] /// The results of the compaction tasks to include in the commit. + /// options : dict, optional + /// Compaction options to apply at commit time. + /// When absent or ``None``, defaults to ``CompactionOptions::default()``. /// /// Returns /// ------- /// CompactionMetrics #[staticmethod] + #[pyo3(signature = (dataset, rewrites, options = None))] pub fn commit( dataset: Bound, rewrites: Vec, + options: Option>, ) -> PyResult { let dataset_ref = unwrap_dataset(dataset)?; let dataset = dataset_ref.borrow().clone(); + let config = dataset.ds.manifest.config.clone(); + let opts = match options { + Some(ref dict) => parse_compaction_options(dict, &config)?, + None => CompactionOptions::default(), + }; let rewrites: Vec = rewrites.into_iter().map(|r| r.0).collect(); let mut new_ds = dataset.ds.as_ref().clone(); - // TODO: pass compaction option from plan and execute time - let options: CompactionOptions = CompactionOptions::default(); let fut = commit_compaction( &mut new_ds, rewrites, Arc::new(DatasetIndexRemapperOptions::default()), - &options, + &opts, ); let metrics = rt() .block_on(None, fut)? diff --git a/python/src/dataset/stats.rs b/python/src/dataset/stats.rs index fc294727d60..4f4b49b5ac3 100644 --- a/python/src/dataset/stats.rs +++ b/python/src/dataset/stats.rs @@ -13,9 +13,9 @@ // limitations under the License. use lance::dataset::statistics::{DataStatistics, FieldStatistics}; -use pyo3::{intern, types::PyAnyMethods, Bound, IntoPyObject, PyAny, PyErr, Python}; +use pyo3::{Bound, IntoPyObject, PyAny, PyErr, Python, intern, types::PyAnyMethods}; -use crate::utils::{export_vec, PyLance}; +use crate::utils::{PyLance, export_vec}; impl<'py> IntoPyObject<'py> for PyLance<&FieldStatistics> { type Target = PyAny; diff --git a/python/src/debug.rs b/python/src/debug.rs index 2aa6cb608e4..bc0e09aba2b 100644 --- a/python/src/debug.rs +++ b/python/src/debug.rs @@ -3,11 +3,11 @@ use std::sync::Arc; -use lance::{datatypes::Schema, Error}; +use lance::{Error, datatypes::Schema}; use lance_table::format::{DeletionFile, Fragment}; use pyo3::{exceptions::PyIOError, prelude::*}; -use crate::{rt, utils::PyLance, Dataset}; +use crate::{Dataset, rt, utils::PyLance}; /// Format the Lance schema of a dataset as a string. /// @@ -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, @@ -137,7 +137,7 @@ pub fn list_transactions( return Err(PyIOError::new_err(format!( "Failed to checkout version: {:?}", err - ))) + ))); } } } diff --git a/python/src/error.rs b/python/src/error.rs index 11e4146910b..c1d2235f2f7 100644 --- a/python/src/error.rs +++ b/python/src/error.rs @@ -14,13 +14,44 @@ use lance_namespace::error::NamespaceError; use pyo3::{ - exceptions::{PyIOError, PyNotImplementedError, PyRuntimeError, PyValueError}, - types::{PyAnyMethods, PyModule}, BoundObject, PyErr, PyResult, Python, + exceptions::{PyIOError, PyNotImplementedError, PyRuntimeError, PyTimeoutError, PyValueError}, + types::{PyAnyMethods, PyModule}, }; use lance::Error as LanceError; +/// Return the `retryable` flag for a commit-conflict error. +/// +/// Mirrors the Rust conflict contract: `RetryableCommitConflict` and +/// `CommitConflict` (commit-step retries exhausted, safe to retry) are +/// retryable; `IncompatibleTransaction` (a conflict retrying cannot fix) is not. +fn commit_conflict_retryable(err: &LanceError) -> bool { + match err { + LanceError::RetryableCommitConflict { .. } | LanceError::CommitConflict { .. } => true, + LanceError::IncompatibleTransaction { .. } => false, + _ => false, + } +} + +/// Convert a commit-conflict `LanceError` to the Python `CommitConflictError`. +/// +/// The `retryable` flag is carried as a typed attribute on the exception so +/// clients can drive retry loops without string-matching the message. +fn commit_conflict_error(py: Python<'_>, err: &LanceError, retryable: bool) -> PyErr { + let message = err.to_string(); + match PyModule::import(py, "lance.commit") { + Ok(module) => match module.getattr("CommitConflictError") { + Ok(conflict_type) => match conflict_type.call1((message.clone(), retryable)) { + Ok(instance) => PyErr::from_value(instance), + Err(_) => PyIOError::new_err(message), + }, + Err(_) => PyIOError::new_err(message), + }, + Err(_) => PyIOError::new_err(message), + } +} + /// Try to convert a NamespaceError to the corresponding Python exception. /// Returns the appropriate Python exception from lance_namespace.errors module. fn namespace_error_to_pyerr(py: Python<'_>, ns_err: &NamespaceError) -> PyErr { @@ -66,6 +97,18 @@ pub trait PythonErrorExt { fn not_implemented(self) -> PyResult; /// Convert to PyIoError fn io_error(self) -> PyResult; + /// Convert to PyTimeoutError + fn timeout_error(self) -> PyResult; + /// Convert to PyTimeoutError for `Error::Timeout`, otherwise PyIoError. + /// + /// Used by call sites that historically mapped every `lance::Error` to + /// PyIoError but should surface timeouts distinctly. + fn io_or_timeout_error(self) -> PyResult; + /// Convert commit conflicts to `CommitConflictError`, otherwise PyIoError. + /// + /// Used by call sites that historically mapped every `lance::Error` to + /// PyIoError but should surface commit conflicts distinctly. + fn io_or_commit_conflict_error(self) -> PyResult; } impl PythonErrorExt for std::result::Result { @@ -76,9 +119,16 @@ impl PythonErrorExt for std::result::Result { LanceError::InvalidInput { .. } => self.value_error(), LanceError::NotSupported { .. } => self.not_implemented(), LanceError::IO { .. } => self.io_error(), + LanceError::Timeout { .. } => self.timeout_error(), LanceError::NotFound { .. } => self.value_error(), LanceError::RefNotFound { .. } => self.value_error(), LanceError::VersionNotFound { .. } => self.value_error(), + LanceError::RetryableCommitConflict { .. } + | LanceError::CommitConflict { .. } + | LanceError::IncompatibleTransaction { .. } => { + let retryable = commit_conflict_retryable(err); + Python::attach(|py| Err(commit_conflict_error(py, err, retryable))) + } LanceError::Namespace { source, .. } => { // Try to downcast to NamespaceError and convert to proper Python exception if let Some(ns_err) = source.downcast_ref::() { @@ -112,4 +162,37 @@ impl PythonErrorExt for std::result::Result { fn io_error(self) -> PyResult { self.map_err(|err| PyIOError::new_err(err.to_string())) } + + fn timeout_error(self) -> PyResult { + self.map_err(|err| PyTimeoutError::new_err(err.to_string())) + } + + fn io_or_timeout_error(self) -> PyResult { + match &self { + Err(LanceError::Timeout { .. }) => self.timeout_error(), + Err( + err @ (LanceError::RetryableCommitConflict { .. } + | LanceError::CommitConflict { .. } + | LanceError::IncompatibleTransaction { .. }), + ) => { + let retryable = commit_conflict_retryable(err); + Python::attach(|py| Err(commit_conflict_error(py, err, retryable))) + } + _ => self.io_error(), + } + } + + fn io_or_commit_conflict_error(self) -> PyResult { + match &self { + Err( + err @ (LanceError::RetryableCommitConflict { .. } + | LanceError::CommitConflict { .. } + | LanceError::IncompatibleTransaction { .. }), + ) => { + let retryable = commit_conflict_retryable(err); + Python::attach(|py| Err(commit_conflict_error(py, err, retryable))) + } + _ => self.io_error(), + } + } } diff --git a/python/src/executor.rs b/python/src/executor.rs index 23b08d07f80..e2c1ca4b383 100644 --- a/python/src/executor.rs +++ b/python/src/executor.rs @@ -15,10 +15,14 @@ use std::sync::mpsc::RecvTimeoutError; use futures::Future; -use pyo3::{exceptions::PyRuntimeError, PyResult, Python}; +use pyo3::{PyResult, Python, exceptions::PyRuntimeError}; pub const SIGNAL_CHECK_INTERVAL: std::time::Duration = std::time::Duration::from_millis(100); +fn is_python314_or_later(py: Option>) -> bool { + py.is_some_and(|py| py.version_info() >= (3, 14)) +} + /// A wrapper around tokio runtime. /// /// This is used to spawn tasks in the background and wait synchronously for them @@ -58,8 +62,15 @@ impl BackgroundExecutor { if let Some(py) = py { py.detach(|| self.spawn_impl(task)) } else { - // Python::with_gil is a no-op if the GIL is already held by the thread. - Python::attach(|py| py.detach(|| self.spawn_impl(task))) + let mut task = Some(task); + if let Some(result) = Python::try_attach(|py| { + let task = task.take().expect("task should not be taken"); + py.detach(|| self.spawn_impl(task)) + }) { + result + } else { + self.spawn_impl(task.expect("task should still be available")) + } } } @@ -83,7 +94,13 @@ impl BackgroundExecutor { loop { // Check for keyboard interrupts - match Python::attach(|py| py.check_signals()) { + let signal_check = match Python::try_attach(|py| py.check_signals()) { + Some(result) => result, + // Python may be finalizing or unavailable. In this state we can't + // observe KeyboardInterrupt reliably, but we should not panic. + None => Ok(()), + }; + match signal_check { Ok(_) => {} Err(err) => { handle.abort(); @@ -113,12 +130,18 @@ impl BackgroundExecutor { self.runtime.spawn(task); }) } else { - // Python::with_gil is a no-op if the GIL is already held by the thread. - Python::attach(|py| { + let mut task = Some(task); + if Python::try_attach(|py| { + let task = task.take().expect("task should not be taken"); py.detach(|| { self.runtime.spawn(task); }) }) + .is_none() + { + self.runtime + .spawn(task.expect("task should still be available")); + } } } @@ -141,8 +164,103 @@ impl BackgroundExecutor { if let Some(py) = py { py.detach(move || self.runtime.block_on(future)) } else { - // Python::with_gil is a no-op if the GIL is already held by the thread. - Python::attach(|py| py.detach(|| self.runtime.block_on(future))) + let mut future = Some(future); + if let Some(result) = Python::try_attach(|py| { + let future = future.take().expect("future should not be taken"); + py.detach(|| self.runtime.block_on(future)) + }) { + result + } else { + self.runtime + .block_on(future.expect("future should still be available")) + } + } + } + + /// 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 should_propagate_on_completion = is_python314_or_later(py); + + 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 { + // When the index build finishes so fast that no pump cycles + // occurred during execution, pending events sit in the channel + // buffer and get drained after completion. Python ≥ 3.14 changed + // GIL/async scheduling timing such that callback invocations may + // only be visible in this post-completion drain, so we propagate + // errors for 3.14+. For ≤ 3.13 we keep the old tolerant behavior + // to avoid spurious failures when scheduling shifts slightly. + if should_propagate_on_completion { + pump()?; + } else { + if let Err(err) = pump() { + log::warn!( + "Ignoring progress callback error after operation completed successfully: {}", + err + ); + } + } + return Ok(output); + } } } @@ -154,7 +272,13 @@ impl BackgroundExecutor { let interrupt_future = async { loop { // Check for keyboard interrupts - match Python::attach(|py| py.check_signals()) { + let signal_check = match Python::try_attach(|py| py.check_signals()) { + Some(result) => result, + // Python may be finalizing or unavailable. In this state we can't + // observe KeyboardInterrupt reliably, but we should not panic. + None => Ok(()), + }; + match signal_check { Ok(_) => { // Wait for 100ms before checking signals again tokio::time::sleep(SIGNAL_CHECK_INTERVAL).await; diff --git a/python/src/file.rs b/python/src/file.rs index 85a5818037e..19519207f53 100644 --- a/python/src/file.rs +++ b/python/src/file.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +use crate::blob::{PyDedicatedBlobWriter, PyPackedBlobWriter}; +use crate::namespace::extract_namespace_arc; use crate::{error::PythonErrorExt, rt}; use arrow::pyarrow::PyArrowType; use arrow_array::{RecordBatch, RecordBatchReader, UInt32Array}; @@ -19,34 +21,35 @@ use arrow_schema::Schema as ArrowSchema; use bytes::Bytes; use futures::stream::StreamExt; use lance::io::{ObjectStore, RecordBatchStream}; +use lance_arrow::json::convert_json_columns; use lance_core::cache::LanceCache; use lance_core::utils::path::LancePathExt; use lance_encoding::decoder::{DecoderPlugins, FilterExpression}; use lance_file::reader::{ BufferDescriptor, CachedFileMetadata, FileReader, FileReaderOptions, FileStatistics, - ReaderProjection, }; use lance_file::writer::{FileWriter, FileWriterOptions}; -use lance_file::{version::LanceFileVersion, LanceEncodingsIo}; -use lance_io::object_store::ObjectStoreParams; +use lance_file::{LanceEncodingsIo, version::LanceFileVersion, versions as file_versions}; +use lance_io::object_store::{LanceNamespaceStorageOptionsProvider, ObjectStoreParams}; use lance_io::{ + ReadBatchParams, scheduler::{ScanScheduler, SchedulerConfig}, + traits::Writer, utils::CachedFileSize, - ReadBatchParams, }; -use object_store::path::Path; +use object_store::{ObjectStoreExt, path::Path}; use pyo3::{ + Bound, IntoPyObjectExt, Py, PyErr, PyResult, Python, exceptions::{PyIOError, PyRuntimeError}, pyclass, pyfunction, pymethods, types::PyAny, - Bound, IntoPyObjectExt, Py, PyErr, PyResult, Python, }; use serde::Serialize; use std::collections::HashMap; use std::{pin::Pin, sync::Arc}; use tokio::io::AsyncWriteExt; use tokio::sync::Mutex; -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LanceBufferDescriptor { /// The byte offset of the buffer in the file @@ -68,7 +71,7 @@ impl LanceBufferDescriptor { } } -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LancePageMetadata { /// The buffers in the page @@ -92,7 +95,7 @@ impl LancePageMetadata { } } -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LanceColumnMetadata { /// The column-wide buffers @@ -117,7 +120,7 @@ impl LanceColumnMetadata { } /// Statistics summarize some of the file metadata for quick summary info -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LanceFileStatistics { /// Statistics about each of the columns in the file @@ -133,7 +136,7 @@ impl LanceFileStatistics { } /// Summary information describing a column -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LanceColumnStatistics { /// The number of pages in the column @@ -168,7 +171,7 @@ impl LanceFileStatistics { } } -#[pyclass(get_all)] +#[pyclass(get_all, skip_from_py_object)] #[derive(Clone, Debug, Serialize)] pub struct LanceFileMetadata { /// The schema of the file @@ -227,6 +230,26 @@ impl LanceFileMetadata { } } +/// Summary of a completed Lance file write +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug, Serialize)] +pub struct LanceFileWriteSummary { + /// The number of rows written to the file + pub num_rows: u64, + /// The final size of the file, in bytes + pub size_bytes: u64, +} + +#[pymethods] +impl LanceFileWriteSummary { + fn __repr__(&self) -> String { + format!( + "FileWriteSummary(num_rows={}, size_bytes={})", + self.num_rows, self.size_bytes + ) + } +} + #[pyclass] pub struct LanceFileWriter { inner: Arc>>, @@ -272,21 +295,23 @@ impl LanceFileWriter { max_page_bytes: Option, ) -> PyResult { let object_writer = object_store.create(&path).await.infer_error()?; + let version = version + .map(|value| value.parse::()) + .transpose() + .infer_error()? + .unwrap_or_default() + .resolve(); let options = FileWriterOptions { data_cache_bytes, keep_original_array, max_page_bytes, - format_version: version - .map(|v| v.parse::()) - .transpose() - .infer_error()?, - ..Default::default() }; let inner = if let Some(schema) = schema { let lance_schema = lance_core::datatypes::Schema::try_from(&schema.0).infer_error()?; - FileWriter::try_new(object_writer, lance_schema, options).infer_error() + file_versions::create_writer(version, object_writer, lance_schema, options) + .infer_error() } else { - Ok(FileWriter::new_lazy(object_writer, options)) + file_versions::create_lazy_writer(version, object_writer, options).infer_error() }?; Ok(Self { inner: Arc::new(Mutex::new(Box::new(inner))), @@ -297,7 +322,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, @@ -305,14 +330,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, @@ -330,15 +363,27 @@ impl LanceFileWriter { } pub fn write_batch(&self, batch: PyArrowType) -> PyResult<()> { + let batch = convert_json_columns(&batch.0) + .map_err(lance_core::Error::from) + .infer_error()?; rt().block_on(None, async { - self.inner.lock().await.write_batch(&batch.0).await + self.inner.lock().await.write_batch(&batch).await })? .infer_error() } - pub fn finish(&self) -> PyResult { - rt().block_on(None, async { self.inner.lock().await.finish().await })? - .infer_error() + /// Finish the file and return the row count and the final file size + /// + /// The size is reported by the object writer once the file has been closed + /// and so it is accurate for object stores as well as local filesystems. + pub fn finish(&self) -> PyResult { + let summary = rt() + .block_on(None, async { self.inner.lock().await.finish().await })? + .infer_error()?; + Ok(LanceFileWriteSummary { + num_rows: summary.num_rows, + size_bytes: summary.size_bytes, + }) } pub fn add_global_buffer(&self, bytes: Vec) -> PyResult { @@ -446,15 +491,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))? } @@ -503,6 +557,30 @@ impl LanceFileSession { )? } + pub fn open_packed_blob_writer( + &self, + path: String, + blob_id: u32, + ) -> PyResult { + let path = self.base_path.child_path(&Path::from(path)); + rt().block_on( + None, + PyPackedBlobWriter::try_new(self.object_store.clone(), path, blob_id), + )? + } + + pub fn open_dedicated_blob_writer( + &self, + path: String, + blob_id: u32, + ) -> PyResult { + let path = self.base_path.child_path(&Path::from(path)); + rt().block_on( + None, + PyDedicatedBlobWriter::try_new(self.object_store.clone(), path, blob_id), + )? + } + pub fn contains(&self, path: String) -> PyResult { let full_path = self.base_path.child_path(&Path::from(path)); rt().block_on(None, async { @@ -553,6 +631,57 @@ impl LanceFileSession { })? } + /// Non-recursive, delimited list of a single directory level. + /// + /// Returns a tuple `(common_prefixes, objects)` of paths relative to the + /// session's `base_path`, where `common_prefixes` are the immediate child + /// "directories" and `objects` are the immediate child files. Unlike + /// `list`, this does not recurse into the subtree. + #[pyo3(signature=(path=None))] + pub fn list_with_delimiter( + &self, + path: Option, + ) -> PyResult<(Vec, Vec)> { + rt().block_on(None, async { + let list_path = if let Some(prefix) = path { + self.base_path.child_path(&Path::from(prefix)) + } else { + self.base_path.clone() + }; + + let result = self + .object_store + .list_with_delimiter(Some(&list_path)) + .await + .map_err(|e| PyErr::new::(format!("{}", e)))?; + + // Strip the base_path prefix to make each path relative to the session. + let relativize = |location: &Path| -> PyResult { + let relative_parts = location.prefix_match(&self.base_path).ok_or_else(|| { + PyErr::new::(format!( + "Path '{}' does not start with base path '{}'", + location.as_ref(), + self.base_path.as_ref() + )) + })?; + Ok(Path::from_iter(relative_parts).as_ref().to_string()) + }; + + let common_prefixes = result + .common_prefixes + .iter() + .map(relativize) + .collect::>>()?; + let objects = result + .objects + .iter() + .map(|meta| relativize(&meta.location)) + .collect::>>()?; + + Ok((common_prefixes, objects)) + })? + } + /// Upload a file from local filesystem to the object store /// /// Parameters @@ -577,8 +706,7 @@ impl LanceFileSession { tokio::io::copy(&mut reader, &mut writer) .await .map_err(|e| PyIOError::new_err(format!("Failed to upload file: {}", e)))?; - writer - .shutdown() + Writer::shutdown(writer.as_mut()) .await .map_err(|e| PyIOError::new_err(format!("Failed to finalize upload: {}", e)))?; @@ -586,6 +714,61 @@ impl LanceFileSession { })? } + /// Delete a file from the object store. + /// + /// The path is interpreted relative to the session's `base_path`, matching + /// `contains`/`upload_file`/`download_file`. Deleting a missing object + /// raises `OSError`, consistent with `download_file`. + /// + /// Parameters + /// ---------- + /// path : str + /// Path relative to `base_path` to delete. + pub fn delete_file(&self, path: String) -> PyResult<()> { + rt().block_on(None, async { + let full_path = self.base_path.child_path(&Path::from(path)); + self.object_store + .inner + .delete(&full_path) + .await + .map_err(|e| PyIOError::new_err(format!("Failed to delete remote file: {}", e)))?; + Ok(()) + })? + } + + /// Read a byte range from a file in the object store. + /// + /// The path is interpreted relative to the session's `base_path`, matching + /// the other session methods. This issues a single ranged GET. Reading a + /// missing object raises `OSError`, consistent with `download_file`. + /// + /// Parameters + /// ---------- + /// path : str + /// Path relative to `base_path` to read from. + /// offset : int + /// Byte offset at which to start reading. + /// length : int + /// Number of bytes to read. + /// + /// Returns + /// ------- + /// bytes + /// The requested byte range. + pub fn read_range(&self, path: String, offset: usize, length: usize) -> PyResult> { + rt().block_on(None, async { + let full_path = self.base_path.child_path(&Path::from(path)); + let bytes = self + .object_store + .read_one_range(&full_path, offset..offset + length) + .await + .map_err(|e| { + PyIOError::new_err(format!("Failed to read range from remote file: {}", e)) + })?; + Ok(bytes.to_vec()) + })? + } + /// Download a file from object store to local filesystem /// /// Parameters @@ -666,7 +849,7 @@ impl LanceFileReader { let mut base_projection = None; if let Some(columns) = columns { base_projection = Some( - ReaderProjection::from_column_names( + file_versions::reader_projection_from_column_names( file_metadata.version(), &file_metadata.file_schema, &columns.iter().map(|s| s.as_str()).collect::>(), @@ -716,8 +899,6 @@ impl LanceFileReader { batch_size: u32, batch_readahead: u32, ) -> PyResult>> { - // read_stream is a synchronous method but it launches tasks and needs to be - // run in the context of a tokio runtime let inner = self.inner.clone(); let stream = rt().block_on(None, async move { inner @@ -727,6 +908,7 @@ impl LanceFileReader { batch_readahead, FilterExpression::no_filter(), ) + .await .infer_error() })??; Ok(PyArrowType(Box::new(LanceReaderAdapter(stream)))) @@ -736,16 +918,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/fragment.rs b/python/src/fragment.rs index 9bfdb14beb8..60f18b6d63e 100644 --- a/python/src/fragment.rs +++ b/python/src/fragment.rs @@ -19,13 +19,14 @@ use arrow::ffi_stream::ArrowArrayStreamReader; use arrow::pyarrow::{FromPyArrow, PyArrowType, ToPyArrow}; use arrow_array::RecordBatchReader; use futures::TryFutureExt; +use lance::Error; use lance::dataset::fragment::FileFragment as LanceFragment; -use lance::dataset::scanner::ColumnOrdering; +use lance::dataset::scanner::{ColumnOrdering, MaterializationStyle}; use lance::dataset::transaction::{Operation, Transaction}; -use lance::dataset::{InsertBuilder, NewColumnTransform}; -use lance::Error; +use lance::dataset::{InsertBuilder, NewColumnTransform, WriteParams}; use lance_core::datatypes::BlobHandling; use lance_io::utils::CachedFileSize; +use lance_table::format::overlay::DataOverlayFile; use lance_table::format::{ DataFile, DeletionFile, DeletionFileType, Fragment, RowDatasetVersionMeta, RowIdMeta, }; @@ -35,15 +36,16 @@ use pyo3::basic::CompareOp; use pyo3::types::PyTuple; use pyo3::{exceptions::*, types::PyDict}; use pyo3::{intern, prelude::*}; -use snafu::location; -use crate::dataset::{get_write_params, transforms_from_python, PyWriteDest}; +use crate::dataset::{PyWriteDest, get_write_params, transforms_from_python}; use crate::error::PythonErrorExt; -use crate::schema::{logical_schema_from_lance, LanceSchema}; -use crate::utils::{export_vec, extract_vec, PyLance}; -use crate::{rt, Dataset, Scanner}; +use crate::schema::{LanceSchema, logical_schema_from_lance}; +use crate::utils::{PyLance, export_vec, extract_vec}; +use crate::{Dataset, Scanner, rt}; -#[pyclass(name = "_Fragment", module = "_lib")] +type UpdateColumnsResult = (PyLance, Vec, Option>); + +#[pyclass(name = "_Fragment", module = "_lib", from_py_object)] #[derive(Clone)] pub struct FileFragment { fragment: LanceFragment, @@ -97,6 +99,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( @@ -106,7 +122,7 @@ impl FileFragment { kwargs: Option<&Bound<'_, PyDict>>, ) -> PyResult> { let params = if let Some(kw_params) = kwargs { - get_write_params(kw_params)? + get_write_params(kw_params, dataset_uri)? } else { None }; @@ -197,7 +213,7 @@ impl FileFragment { } #[allow(clippy::too_many_arguments)] - #[pyo3(signature=(columns=None, columns_with_transform=None, batch_size=None, filter=None, limit=None, offset=None, with_row_id=None, with_row_address=None, batch_readahead=None, blob_handling=None, order_by=None))] + #[pyo3(signature=(columns=None, columns_with_transform=None, batch_size=None, filter=None, limit=None, offset=None, with_row_id=None, with_row_address=None, batch_readahead=None, blob_handling=None, order_by=None, use_scalar_index=None, io_buffer_size=None, late_materialization=None, include_deleted_rows=None, batch_size_bytes=None, strict_batch_size=None, substrait_filter=None))] fn scanner( self_: PyRef<'_, Self>, columns: Option>, @@ -211,6 +227,13 @@ impl FileFragment { batch_readahead: Option, blob_handling: Option>, order_by: Option>>, + use_scalar_index: Option, + io_buffer_size: Option, + late_materialization: Option>, + include_deleted_rows: Option, + batch_size_bytes: Option, + strict_batch_size: Option, + substrait_filter: Option>, ) -> PyResult { let mut scanner = self_.fragment.scan(); @@ -237,10 +260,18 @@ impl FileFragment { scanner.batch_size(batch_size); } if let Some(f) = filter { + if substrait_filter.is_some() { + return Err(PyValueError::new_err( + "cannot specify both a string filter and a substrait filter", + )); + } scanner .filter(&f) .map_err(|err| PyValueError::new_err(err.to_string()))?; } + if let Some(f) = substrait_filter { + scanner.filter_substrait(&f).infer_error()?; + } scanner .limit(limit, offset) @@ -264,7 +295,7 @@ impl FileFragment { other => { return Err(PyValueError::new_err(format!( "Invalid blob_handling: {other}. Expected one of: all_binary, blobs_descriptions, all_descriptions" - ))) + ))); } } } else { @@ -279,6 +310,39 @@ impl FileFragment { .order_by(col_orderings) .map_err(|err| PyValueError::new_err(err.to_string()))?; } + if let Some(io_buffer_size) = io_buffer_size { + scanner.io_buffer_size(io_buffer_size); + } + if let Some(use_scalar_index) = use_scalar_index { + scanner.use_scalar_index(use_scalar_index); + } + if let Some(late_materialization) = late_materialization { + if let Ok(style_as_bool) = late_materialization.extract::() { + if style_as_bool { + scanner.materialization_style(MaterializationStyle::AllLate); + } else { + scanner.materialization_style(MaterializationStyle::AllEarly); + } + } else if let Ok(columns) = late_materialization.extract::>() { + scanner.materialization_style( + MaterializationStyle::all_early_except(&columns, self_.fragment.schema()) + .infer_error()?, + ); + } else { + return Err(PyValueError::new_err( + "late_materialization must be a bool or a list of strings", + )); + } + } + if let Some(batch_size_bytes) = batch_size_bytes { + scanner.batch_size_bytes(batch_size_bytes); + } + if let Some(true) = include_deleted_rows { + scanner.include_deleted_rows(); + } + if let Some(strict_batch_size) = strict_batch_size { + scanner.strict_batch_size(strict_batch_size); + } let scn = Arc::new(scanner); Ok(Scanner::new(scn)) } @@ -330,14 +394,11 @@ impl FileFragment { reader: PyArrowType, left_on: String, right_on: String, - max_field_id: i32, ) -> PyResult<(PyLance, LanceSchema)> { let mut fragment = self.fragment.clone(); let (fragment, schema) = rt() .spawn(None, async move { - fragment - .merge_columns(reader.0, &left_on, &right_on, max_field_id) - .await + fragment.merge_columns(reader.0, &left_on, &right_on).await })? .infer_error()?; @@ -349,15 +410,34 @@ impl FileFragment { reader: PyArrowType, left_on: String, right_on: String, - ) -> PyResult<(PyLance, Vec)> { + with_offsets: bool, + ) -> PyResult { let mut fragment = self.fragment.clone(); - let (updated_fragment, fields_modified) = rt() + let result = rt() .spawn(None, async move { - fragment.update_columns(reader.0, &left_on, &right_on).await + fragment + .update_columns_with_offsets(reader.0, &left_on, &right_on) + .await })? .infer_error()?; - Ok((PyLance(updated_fragment), fields_modified)) + let matched_offsets = if with_offsets { + let mut buf = Vec::with_capacity(result.matched_offsets.serialized_size()); + result + .matched_offsets + .serialize_into(&mut buf) + .map_err(|err| { + PyIOError::new_err(format!("Failed to serialize matched row offsets: {err}")) + })?; + Some(buf) + } else { + None + }; + Ok(( + PyLance(result.fragment), + result.fields_modified, + matched_offsets, + )) } fn delete(&self, predicate: &str) -> PyResult> { @@ -372,6 +452,38 @@ impl FileFragment { } } + /// Delete rows by their local (within-fragment) physical row offsets. + /// + /// Adds the given 0-based offsets to this fragment's deletion vector and + /// writes a new deletion file. Returns the updated fragment, or None if every + /// row is now deleted. Unlike `delete(predicate)`, this deletes exactly the + /// supplied rows without re-evaluating a filter -- useful when the caller + /// already knows which rows to delete (e.g. offsets collected from a prior + /// scan), avoiding a redundant predicate evaluation. + fn delete_rows(&self, offsets: Vec) -> PyResult> { + let old_fragment = self.fragment.clone(); + // The core deletion path only errors once the deletion count reaches + // physical_rows, so out-of-range offsets must be rejected here. + let updated_fragment = rt() + .block_on(None, async { + let physical_rows = old_fragment.physical_rows().await?; + if let Some(&offset) = offsets.iter().find(|&&o| o as usize >= physical_rows) { + return Err(Error::invalid_input(format!( + "delete_rows offset {offset} is out of range for fragment {} \ + with {physical_rows} rows", + old_fragment.id() + ))); + } + old_fragment.extend_deletions(offsets).await + })? + .infer_error()?; + + match updated_fragment { + Some(frag) => Ok(Some(Self::new(frag))), + None => Ok(None), + } + } + fn schema<'py>(self_: PyRef<'py, Self>) -> PyResult> { let schema = self_.fragment.dataset().schema(); let logical_schema = logical_schema_from_lance(schema); @@ -407,6 +519,11 @@ impl FileFragment { rt().block_on(None, self.fragment.physical_rows())? .map_err(|err| PyIOError::new_err(err.to_string())) } + + fn validate(&self) -> PyResult<()> { + rt().block_on(None, self.fragment.validate())? + .map_err(|err| PyIOError::new_err(err.to_string())) + } } impl From for LanceFragment { @@ -422,10 +539,10 @@ fn do_write_fragments( ) -> PyResult { let batches = convert_reader(reader)?; - let params = kwargs - .and_then(|params| get_write_params(params).transpose()) - .transpose()? - .unwrap_or_default(); + let params = match kwargs { + Some(params) => get_write_params(params, &dest.table_root_uri()?)?.unwrap_or_default(), + None => WriteParams::default(), + }; rt().block_on( Some(reader.py()), @@ -448,10 +565,7 @@ pub fn write_fragments( let get_fragments = |operation| match operation { Operation::Overwrite { fragments, .. } => Ok(fragments), Operation::Append { fragments, .. } => Ok(fragments), - _ => Err(Error::Internal { - message: "Unexpected operation".into(), - location: location!(), - }), + _ => Err(Error::internal("Unexpected operation")), }; let fragments = get_fragments(written.operation).map_err(|err| PyRuntimeError::new_err(err.to_string()))?; @@ -508,7 +622,7 @@ impl PyDeletionFile { return Err(PyValueError::new_err(format!( "file_type must be either 'array' or 'bitmap', got '{}'", file_type - ))) + ))); } }; Ok(Self(DeletionFile { @@ -631,10 +745,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 { @@ -654,6 +768,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()?; @@ -724,7 +845,12 @@ impl PyRowDatasetVersionMeta { } } -#[pyclass(name = "FragmentSession", module = "_lib", subclass)] +#[pyclass( + name = "FragmentSession", + module = "_lib", + subclass, + skip_from_py_object +)] #[derive(Clone)] pub struct FragmentSession { session: Arc, @@ -745,8 +871,9 @@ impl FragmentSession { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &pyo3::Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let files = extract_vec(&ob.getattr("files")?)?; let deletion_file: Option> = @@ -770,6 +897,10 @@ impl FromPyObject<'_> for PyLance { row_id_meta, last_updated_at_version_meta, created_at_version_meta, + // Round-tripped so overlays survive operations that pass existing + // fragments back (a manual Delete/Update/Merge commit). Sorting + // newest-last is deferred to the manifest reload after commit. + overlays: extract_vec::(&ob.getattr("overlays")?)?, })) } } @@ -802,6 +933,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Fragment> { .created_at_version_meta .as_ref() .map(|r| PyRowDatasetVersionMeta(r.clone())); + let overlays = export_vec(py, &self.0.overlays)?; cls.call1(( self.0.id, @@ -811,6 +943,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Fragment> { row_id_meta, created_at_version_meta, last_updated_at_version_meta, + overlays, )) } } @@ -825,14 +958,17 @@ impl<'py> IntoPyObject<'py> for PyLance { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &pyo3::Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, 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, @@ -855,8 +991,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/python/src/fts.rs b/python/src/fts.rs new file mode 100644 index 00000000000..c24cefd6300 --- /dev/null +++ b/python/src/fts.rs @@ -0,0 +1,258 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use lance_index::scalar::InvertedIndexParams; +use lance_index::scalar::inverted::query::collect_query_tokens; +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::PyDict; + +fn extract_kwarg<'py, T>(kwargs: &Bound<'py, PyDict>, key: &str) -> PyResult> +where + T: FromPyObjectOwned<'py>, +{ + kwargs + .get_item(key)? + .map(|value| value.extract::().map_err(Into::into)) + .transpose() +} + +pub(crate) struct FtsTokenizerOptions { + analyzer: Option, + base_tokenizer: Option, + language: Option, + // The outer option tracks omission; the inner None explicitly disables the limit. + max_token_length: Option>, + lower_case: Option, + stem: Option, + remove_stop_words: Option, + // Preserve omitted versus explicit None to match create-index keyword semantics. + custom_stop_words: Option>>, + ascii_folding: Option, + min_ngram_length: Option, + max_ngram_length: Option, + prefix_only: Option, + split_identifiers: Option, + split_on_numerics: Option, + preserve_original: Option, + index_operators: Option, +} + +impl FtsTokenizerOptions { + pub(crate) fn from_kwargs(kwargs: &Bound<'_, PyDict>) -> PyResult { + Ok(Self { + analyzer: extract_kwarg(kwargs, "analyzer")?, + base_tokenizer: extract_kwarg(kwargs, "base_tokenizer")?, + language: extract_kwarg(kwargs, "language")?, + max_token_length: extract_kwarg(kwargs, "max_token_length")?, + lower_case: extract_kwarg(kwargs, "lower_case")?, + stem: extract_kwarg(kwargs, "stem")?, + remove_stop_words: extract_kwarg(kwargs, "remove_stop_words")?, + custom_stop_words: extract_kwarg(kwargs, "custom_stop_words")?, + ascii_folding: extract_kwarg(kwargs, "ascii_folding")?, + min_ngram_length: extract_kwarg(kwargs, "min_ngram_length")?, + max_ngram_length: extract_kwarg(kwargs, "max_ngram_length")?, + prefix_only: extract_kwarg(kwargs, "prefix_only")?, + split_identifiers: extract_kwarg(kwargs, "split_identifiers")?, + split_on_numerics: extract_kwarg(kwargs, "split_on_numerics")?, + preserve_original: extract_kwarg(kwargs, "preserve_original")?, + index_operators: extract_kwarg(kwargs, "index_operators")?, + }) + } + + pub(crate) fn apply(self, mut params: InvertedIndexParams) -> PyResult { + match (self.analyzer.as_deref(), self.base_tokenizer.as_deref()) { + (Some("text"), Some("code")) => { + return Err(PyValueError::new_err( + "base_tokenizer='code' requires analyzer='code'", + )); + } + (Some("code"), Some(base_tokenizer)) if base_tokenizer != "code" => { + return Err(PyValueError::new_err(format!( + "analyzer='code' requires base_tokenizer='code', got '{base_tokenizer}'" + ))); + } + _ => {} + } + + let uses_code_analyzer = match self.analyzer.as_deref() { + Some("code") => true, + Some("text") | None => self.base_tokenizer.as_deref() == Some("code"), + Some(_) => true, + }; + if !uses_code_analyzer + && [ + self.split_identifiers, + self.split_on_numerics, + self.preserve_original, + self.index_operators, + ] + .into_iter() + .flatten() + .any(|value| value) + { + return Err(PyValueError::new_err( + "code analyzer flags require analyzer='code'", + )); + } + + if let Some(analyzer) = self.analyzer { + params = params + .analyzer(&analyzer) + .map_err(|err| PyValueError::new_err(err.to_string()))?; + } + if let Some(base_tokenizer) = self.base_tokenizer { + params = params.base_tokenizer(base_tokenizer); + } + if let Some(language) = self.language { + params = params.language(&language).map_err(|err| { + PyValueError::new_err(format!("can't set tokenizer language to {language}: {err}")) + })?; + } + if let Some(max_token_length) = self.max_token_length { + params = params.max_token_length(max_token_length); + } + if let Some(lower_case) = self.lower_case { + params = params.lower_case(lower_case); + } + if let Some(stem) = self.stem { + params = params.stem(stem); + } + if let Some(remove_stop_words) = self.remove_stop_words { + params = params.remove_stop_words(remove_stop_words); + } + if let Some(custom_stop_words) = self.custom_stop_words { + params = params.custom_stop_words(custom_stop_words); + } + if let Some(ascii_folding) = self.ascii_folding { + params = params.ascii_folding(ascii_folding); + } + if let Some(min_ngram_length) = self.min_ngram_length { + params = params.ngram_min_length(min_ngram_length); + } + if let Some(max_ngram_length) = self.max_ngram_length { + params = params.ngram_max_length(max_ngram_length); + } + if let Some(prefix_only) = self.prefix_only { + params = params.ngram_prefix_only(prefix_only); + } + if let Some(split_identifiers) = self.split_identifiers { + params = params.split_identifiers(split_identifiers); + } + if let Some(split_on_numerics) = self.split_on_numerics { + params = params.split_on_numerics(split_on_numerics); + } + if let Some(preserve_original) = self.preserve_original { + params = params.preserve_original(preserve_original); + } + if let Some(index_operators) = self.index_operators { + params = params.index_operators(index_operators); + } + Ok(params) + } +} + +/// A token produced by the full-text search query tokenizer. +#[pyclass(get_all, skip_from_py_object)] +#[derive(Clone, Debug)] +pub struct FtsToken { + /// The token text after all configured filters have been applied. + pub text: String, + /// The position used by full-text query matching. + pub position: u32, +} + +#[pymethods] +impl FtsToken { + fn __repr__(&self) -> String { + format!("FtsToken(text={:?}, position={})", self.text, self.position) + } +} + +/// Tokenize a full-text search query without creating a dataset or index. +/// +/// The tokenizer options are the same as the tokenizer-related options accepted by +/// ``LanceDataset.create_scalar_index(..., index_type="INVERTED")``. ``None`` uses +/// the selected analyzer profile's default, except ``max_token_length=None``, which +/// disables the length limit; omitting it keeps the default limit of 40. Returned +/// positions are normalized to the first retained token while preserving gaps left +/// by token filters. +/// +/// Examples +/// -------- +/// >>> import lance +/// >>> tokens = lance.tokenize("the Cats and Dogs") +/// >>> [(token.text, token.position) for token in tokens] +/// [('cat', 0), ('dog', 2)] +#[pyfunction] +#[pyo3(signature = ( + query, + *, + analyzer = None, + base_tokenizer = None, + language = None, + max_token_length = Some(40), + lower_case = None, + stem = None, + remove_stop_words = None, + custom_stop_words = None, + ascii_folding = None, + min_ngram_length = None, + max_ngram_length = None, + prefix_only = None, + split_identifiers = None, + split_on_numerics = None, + preserve_original = None, + index_operators = None, +))] +#[allow(clippy::too_many_arguments)] +pub fn tokenize( + query: &str, + analyzer: Option, + base_tokenizer: Option, + language: Option, + max_token_length: Option, + lower_case: Option, + stem: Option, + remove_stop_words: Option, + custom_stop_words: Option>, + ascii_folding: Option, + min_ngram_length: Option, + max_ngram_length: Option, + prefix_only: Option, + split_identifiers: Option, + split_on_numerics: Option, + preserve_original: Option, + index_operators: Option, +) -> PyResult> { + let params = FtsTokenizerOptions { + analyzer, + base_tokenizer, + language, + max_token_length: Some(max_token_length), + lower_case, + stem, + remove_stop_words, + custom_stop_words: custom_stop_words.map(Some), + ascii_folding, + min_ngram_length, + max_ngram_length, + prefix_only, + split_identifiers, + split_on_numerics, + preserve_original, + index_operators, + } + .apply(InvertedIndexParams::default())?; + + let mut tokenizer = params + .build() + .map_err(|err| PyValueError::new_err(format!("Failed to build tokenizer: {err}")))?; + let tokens = collect_query_tokens(query, &mut tokenizer); + Ok((0..tokens.len()) + .map(|index| FtsToken { + text: tokens.get_token(index).to_string(), + position: tokens.position(index), + }) + .collect()) +} diff --git a/python/src/indices.rs b/python/src/indices.rs index 412f329d83a..7802a07ae7e 100644 --- a/python/src/indices.rs +++ b/python/src/indices.rs @@ -1,29 +1,35 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::collections::HashSet; +use std::fmt::Write; +use std::sync::Arc; use arrow::pyarrow::{PyArrowType, ToPyArrow}; 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::IndexSegment; use lance::index::vector::ivf::builder::write_vector_storage; +use lance::index::vector::pq::build_pq_model_in_fragments; use lance::io::ObjectStore; -use lance_index::vector::ivf::shuffler::{shuffle_vectors, IvfShuffler}; +use lance_index::progress::NoopIndexBuildProgress; +use lance_index::vector::ivf::shuffler::{IvfShuffler, shuffle_vectors}; use lance_index::vector::{ - ivf::{storage::IvfModel, IvfBuildParams}, + ivf::{IvfBuildParams, storage::IvfModel}, 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::*; use pyo3::types::PyModuleMethods; -use pyo3::Bound; use pyo3::{ - pyfunction, + PyResult, Python, pyfunction, types::{PyList, PyModule}, - wrap_pyfunction, PyResult, Python, + wrap_pyfunction, }; use lance::index::DatasetIndexInternalExt; @@ -34,10 +40,15 @@ 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::{IndexDescription, IndexType}; use uuid::Uuid; -#[pyclass(name = "IndexConfig", module = "lance.indices", get_all)] +#[pyclass( + name = "IndexConfig", + module = "lance.indices", + get_all, + from_py_object +)] #[derive(Debug, Clone)] pub struct PyIndexConfig { pub index_type: String, @@ -55,7 +66,40 @@ impl PyIndexConfig { } } -#[pyclass(name = "IvfModel", module = "lance.indices")] +#[pyclass(name = "IndexSegment", module = "lance.indices", skip_from_py_object)] +#[derive(Debug, Clone)] +pub struct PyIndexSegment { + pub(crate) inner: IndexSegment, +} + +#[pymethods] +impl PyIndexSegment { + #[getter] + fn uuid(&self) -> String { + self.inner.uuid().to_string() + } + + #[getter] + fn fragment_ids(&self) -> crate::bitmap::PyBitmap { + crate::bitmap::PyBitmap::new(self.inner.fragment_bitmap().clone()) + } + + #[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().__repr__(), + self.index_version() + ) + } +} + +#[pyclass(name = "IvfModel", module = "lance.indices", skip_from_py_object)] #[derive(Debug, Clone)] pub struct PyIvfModel { pub(crate) inner: IvfModel, @@ -100,11 +144,7 @@ async fn do_get_ivf_model(dataset: &Dataset, index_name: &str) -> PyResult, 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, @@ -126,6 +167,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(); @@ -141,6 +183,8 @@ async fn do_train_ivf_model( dimension, distance_type, ¶ms, + fragment_ids.as_deref(), + Arc::new(NoopIndexBuildProgress), ) .await .infer_error()?; @@ -150,6 +194,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, @@ -159,6 +204,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), @@ -170,6 +216,7 @@ fn train_ivf_model<'py>( distance_type, sample_rate, max_iters, + fragment_ids, ), )??; centroids.to_pyarrow(py) @@ -184,24 +231,27 @@ async fn do_train_pq_model( distance_type: &str, sample_rate: u32, max_iters: u32, + num_bits: 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(); let params = PQBuildParams { num_sub_vectors: num_subvectors as usize, - num_bits: 8, + num_bits: num_bits as usize, max_iters: max_iters as usize, 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()?; @@ -210,6 +260,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, num_bits=8))] fn train_pq_model<'py>( py: Python<'py>, dataset: &Dataset, @@ -220,6 +271,8 @@ fn train_pq_model<'py>( sample_rate: u32, max_iters: u32, ivf_centroids: PyArrowType, + fragment_ids: Option>, + num_bits: u32, ) -> PyResult> { let ivf_centroids = ivf_centroids.0; let ivf_centroids = FixedSizeListArray::from(ivf_centroids); @@ -239,12 +292,69 @@ fn train_pq_model<'py>( distance_type, sample_rate, max_iters, + num_bits, ivf_model, + fragment_ids, ), )??; codebook.to_pyarrow(py) } +/// Mint one RaBitQ rotation and return it as a JSON string. +/// +/// Distributed IVF_RQ builds must pin a single rotation across all workers so that +/// independently built per-fragment segments rotate vectors identically and their +/// binary codes remain comparable when merged. A driver calls this once and broadcasts +/// the resulting string to every `create_index_uncommitted(..., rabitq_model=...)` call. +/// +/// The rotation is always the "fast" rotation since its sign vector is JSON-serializable, +/// whereas the "matrix" rotation stores a dense matrix in a binary buffer that is dropped by +/// the JSON wire format. `dtype` is accepted for API symmetry but does not affect the fast +/// rotation. +/// +/// # Example (Python) +/// +/// ```python +/// from lance.lance import indices +/// +/// # Mint one model and broadcast `model` to every worker. +/// model = indices.build_rq_model(dimension=128, num_bits=5) +/// seg = ds.create_index_uncommitted( +/// column="vector", +/// index_type="IVF_RQ", +/// num_partitions=256, +/// ivf_centroids=centroids, +/// rabitq_model=model, +/// fragment_ids=my_fragments, +/// ) +/// ``` +#[pyfunction] +#[pyo3(signature = (dimension, num_bits=5, dtype="float32"))] +pub fn build_rq_model(dimension: usize, num_bits: u8, dtype: &str) -> PyResult { + use arrow::datatypes::{Float16Type, Float32Type, Float64Type}; + use lance_index::vector::bq::RQRotationType; + use lance_index::vector::bq::builder::RabitQuantizer; + use lance_index::vector::quantizer::Quantization; + + if !dimension.is_multiple_of(u8::BITS as usize) { + return Err(PyValueError::new_err( + "dimension must be divisible by 8 for IVF_RQ", + )); + } + let dim = dimension as i32; + let rotation = RQRotationType::Fast; + let quantizer = match dtype.to_lowercase().as_str() { + "float16" => RabitQuantizer::new_with_rotation::(num_bits, dim, rotation), + "float32" => RabitQuantizer::new_with_rotation::(num_bits, dim, rotation), + "float64" => RabitQuantizer::new_with_rotation::(num_bits, dim, rotation), + other => { + return Err(PyValueError::new_err(format!("unsupported dtype: {other}"))); + } + }; + serde_json::to_string(&quantizer.metadata(None)) + .map_err(|e| PyValueError::new_err(format!("failed to serialize RQ model: {e}"))) +} + #[allow(clippy::too_many_arguments)] async fn do_transform_vectors( dataset: &Dataset, @@ -290,7 +400,7 @@ async fn do_transform_vectors( #[pyfunction] #[allow(clippy::too_many_arguments)] -#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, ivf_centroids, pq_codebook, dst_uri, fragments, partitions_ds_uri=None))] +#[pyo3(signature=(dataset, column, dimension, num_subvectors, distance_type, ivf_centroids, pq_codebook, dst_uri, fragments, partitions_ds_uri=None, num_bits=8))] pub fn transform_vectors( py: Python<'_>, dataset: &Dataset, @@ -303,15 +413,23 @@ pub fn transform_vectors( dst_uri: &str, fragments: Vec, partitions_ds_uri: Option<&str>, + num_bits: u32, ) -> PyResult<()> { let ivf_centroids = ivf_centroids.0; let ivf_centroids = FixedSizeListArray::from(ivf_centroids); let codebook = pq_codebook.0; let codebook = FixedSizeListArray::from(codebook); + lance_index::vector::pq::validate_supplied_codebook( + codebook.values().len(), + dimension, + num_subvectors as usize, + num_bits as usize, + ) + .infer_error()?; let distance_type = DistanceType::try_from(distance_type).unwrap(); let pq = ProductQuantizer::new( num_subvectors as usize, - /*num_bits=*/ 8, + num_bits, dimension, codebook, distance_type, @@ -412,7 +530,27 @@ 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) + let index_dir = ds.indices_dir().clone().join(index_id.to_string()); + let object_store = ds.object_store(None).await.infer_error()?; + let files = list_index_files_with_sizes(object_store.as_ref(), &index_dir) + .await + .infer_error()?; + let metadata = IndexMetadata { + uuid: index_id, + name: index_name.to_string(), + fields: vec![ds.schema().field(column).unwrap().id], + covering_fields: vec![], + 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_index::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()?; @@ -420,7 +558,7 @@ async fn do_load_shuffled_vectors( } #[pyfunction] -#[pyo3(signature=(filenames, dir_path, dataset, column, ivf_centroids, pq_codebook, pq_dimension, num_subvectors, distance_type, index_name=None))] +#[pyo3(signature=(filenames, dir_path, dataset, column, ivf_centroids, pq_codebook, pq_dimension, num_subvectors, distance_type, index_name=None, num_bits=8))] #[allow(clippy::too_many_arguments)] pub fn load_shuffled_vectors( filenames: Vec, @@ -433,6 +571,7 @@ pub fn load_shuffled_vectors( num_subvectors: u32, distance_type: &str, index_name: Option<&str>, + num_bits: u32, ) -> PyResult<()> { let mut default_idx_name = column.to_string(); default_idx_name.push_str("_idx"); @@ -450,11 +589,18 @@ pub fn load_shuffled_vectors( let codebook = pq_codebook.0; let codebook = FixedSizeListArray::from(codebook); + lance_index::vector::pq::validate_supplied_codebook( + codebook.values().len(), + pq_dimension, + num_subvectors as usize, + num_bits as usize, + ) + .infer_error()?; let distance_type = DistanceType::try_from(distance_type).unwrap(); let pq_model = ProductQuantizer::new( num_subvectors as usize, - /*num_bits=*/ 8, + num_bits, pq_dimension, codebook, distance_type, @@ -468,7 +614,12 @@ pub fn load_shuffled_vectors( )? } -#[pyclass(name = "IndexSegmentDescription", module = "lance.indices", get_all)] +#[pyclass( + name = "IndexSegmentDescription", + module = "lance.indices", + get_all, + skip_from_py_object +)] #[derive(Clone)] pub struct PyIndexSegmentDescription { /// The UUID of the index segment @@ -476,16 +627,52 @@ pub struct PyIndexSegmentDescription { /// The dataset version at which the index segment was last updated pub dataset_version_at_last_update: u64, /// The fragment ids that are covered by the index segment - pub fragment_ids: HashSet, + pub fragment_ids: crate::bitmap::PyBitmap, /// The version of the index 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, + /// The id of the dataset base path that stores this segment + /// (None when the segment is stored in the dataset's default base path) + pub base_id: Option, + /// The ids of the fields whose values this segment carries but is not keyed on. + /// Always the trailing entries of the segment's fields. + pub covering_fields: Vec, } impl PyIndexSegmentDescription { + pub fn from_metadata(segment: &lance_table::format::IndexMetadata) -> Self { + let fragment_ids = + crate::bitmap::PyBitmap::new(segment.fragment_bitmap.clone().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, + base_id: segment.base_id.map(|id| id as i64), + covering_fields: segment.covering_fields.clone(), + } + } + pub fn __repr__(&self) -> String { - format!("IndexSegmentDescription(uuid={}, dataset_version_at_last_update={}, fragment_ids={:?}, index_version={}, created_at={:?})", self.uuid, self.dataset_version_at_last_update, self.fragment_ids, self.index_version, self.created_at) + format!( + "IndexSegmentDescription(uuid={}, dataset_version_at_last_update={}, fragment_ids={}, index_version={}, created_at={:?}, size_bytes={:?}, base_id={:?}, covering_fields={:?})", + self.uuid, + self.dataset_version_at_last_update, + self.fragment_ids.__repr__(), + self.index_version, + self.created_at, + self.size_bytes, + self.base_id, + self.covering_fields + ) } } @@ -499,7 +686,8 @@ pub struct PyIndexDescription { pub index_type: String, /// The ids of the fields that the index is built on pub fields: Vec, - /// The names of the fields that the index is built on + /// The full paths of the fields that the index is built on + /// (dotted, with backtick-quoted segments for non-identifier names) pub field_names: Vec, /// The number of rows indexed by the index pub num_rows_indexed: u64, @@ -507,6 +695,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 { @@ -517,29 +708,15 @@ impl PyIndexDescription { .map(|field| { dataset .schema() - .field_by_id(*field as i32) - .map(|f| f.name.clone()) - .unwrap_or("".to_string()) + .field_path_minimal(*field as i32) + .unwrap_or_else(|_| "".to_string()) }) .collect(); let segments = index .metadata() .iter() - .map(|segment| { - let fragment_ids = segment - .fragment_bitmap - .as_ref() - .map(|bitmap| bitmap.iter().collect::>()) - .unwrap_or_default(); - 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, - } - }) + .map(PyIndexSegmentDescription::from_metadata) .collect(); let details = index.details().unwrap_or_else(|_| "{}".to_string()); @@ -553,6 +730,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(), } } } @@ -560,7 +738,20 @@ impl PyIndexDescription { #[pymethods] impl PyIndexDescription { pub fn __repr__(&self) -> String { - 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()) + 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 } } @@ -568,11 +759,13 @@ pub fn register_indices(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { let indices = PyModule::new(py, "indices")?; indices.add_wrapped(wrap_pyfunction!(train_ivf_model))?; indices.add_wrapped(wrap_pyfunction!(train_pq_model))?; + indices.add_wrapped(wrap_pyfunction!(build_rq_model))?; indices.add_wrapped(wrap_pyfunction!(transform_vectors))?; indices.add_wrapped(wrap_pyfunction!(shuffle_transformed_vectors))?; 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_wrapped(wrap_pyfunction!(get_ivf_model))?; diff --git a/python/src/lib.rs b/python/src/lib.rs index 5243e0fddd4..ecb40b56792 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -25,39 +25,45 @@ use std::env; use std::fs::OpenOptions; use std::path::Path; -use std::sync::atomic::{self, Ordering}; use std::sync::Arc; - -use std::ffi::CString; +use std::sync::atomic::{self, Ordering}; 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")] use datagen::register_datagen; use dataset::blob::LanceBlobFile; -use dataset::cleanup::CleanupStats; +use dataset::cleanup::{ + CleanupCandidateFile, CleanupExplanation, CleanupReferencedBranch, CleanupStats, +}; use dataset::io_stats::IoStats; use dataset::optimize::{ PyCompaction, PyCompactionMetrics, PyCompactionPlan, PyCompactionTask, PyRewriteResult, }; -use dataset::{DatasetBasePath, MergeInsertBuilder, PyFullTextQuery}; +use dataset::{DatasetBasePath, MergeInsertBuilder, PyFullTextQuery, PySearchFilter}; use env_logger::{Builder, Env}; use file::{ - stable_version, LanceBufferDescriptor, LanceColumnMetadata, LanceFileMetadata, LanceFileReader, - LanceFileStatistics, LanceFileWriter, LancePageMetadata, + LanceBufferDescriptor, LanceColumnMetadata, LanceFileMetadata, LanceFileReader, + LanceFileStatistics, LanceFileWriteSummary, LanceFileWriter, LancePageMetadata, stable_version, }; -use lance_index::DatasetIndexExt; use log::Level; use pyo3::exceptions::PyIOError; +use pyo3::ffi::c_str; use pyo3::prelude::*; use pyo3::types::{PyAny, PyAnyMethods, PyCapsule}; use scanner::ScanStatistics; use session::Session; +use std::ffi::CString; +use std::ptr::NonNull; pub(crate) mod arrow; +pub(crate) mod bitmap; +pub(crate) mod blob; #[cfg(feature = "datagen")] pub(crate) mod datagen; pub(crate) mod dataset; @@ -66,9 +72,14 @@ pub(crate) mod error; pub(crate) mod executor; pub(crate) mod file; pub(crate) mod fragment; +pub(crate) mod fts; pub(crate) mod indices; +pub(crate) mod mem_wal; pub(crate) mod namespace; +pub(crate) mod object_store; +pub(crate) mod otel; pub(crate) mod reader; +pub(crate) mod rowids; pub(crate) mod scanner; pub(crate) mod schema; pub(crate) mod session; @@ -77,20 +88,25 @@ pub(crate) mod tracing; pub(crate) mod transaction; pub(crate) mod utils; -pub use crate::arrow::{bfloat16_array, BFloat16}; +pub use crate::arrow::{BFloat16, bfloat16_array}; use crate::file::LanceFileSession; use crate::fragment::{write_fragments, write_fragments_transaction}; -use crate::tracing::{capture_trace_events, shutdown_tracing, PyTraceEvent}; -pub use crate::tracing::{trace_to_chrome, TraceGuard}; +use crate::tracing::{PyTraceEvent, capture_trace_events, shutdown_tracing}; +pub use crate::tracing::{TraceGuard, trace_to_chrome}; use crate::utils::Hnsw; use crate::utils::KMeans; -pub use dataset::write_dataset; pub use dataset::Dataset; +pub use dataset::serialize_row_addrs; +pub use dataset::write_dataset; use fragment::{FileFragment, PyDeletionFile, PyRowDatasetVersionMeta, PyRowIdMeta}; pub use indices::register_indices; pub use reader::LanceReader; +use rowids::{PyRowIdSequence, PyRowIdSequenceIterator}; pub use scanner::Scanner; +use crate::blob::{ + PyBlobDescriptor, PyBlobDescriptorArrayBuilder, PyDedicatedBlobWriter, PyPackedBlobWriter, +}; use crate::executor::BackgroundExecutor; const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION"); @@ -122,11 +138,13 @@ static EXECUTOR_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false); static ATFORK_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false); -pub fn rt() -> &'static mut BackgroundExecutor { +pub fn rt() -> &'static BackgroundExecutor { loop { let ptr = BACKGROUND_EXECUTOR.load(Ordering::SeqCst); if !ptr.is_null() { - return unsafe { &mut *ptr }; + // SAFETY: installed executors are leaked and remain valid for the + // process lifetime. BackgroundExecutor uses shared access only. + return unsafe { &*ptr }; } if !EXECUTOR_INSTALLED.fetch_or(true, Ordering::SeqCst) { break; @@ -138,7 +156,8 @@ pub fn rt() -> &'static mut BackgroundExecutor { } let new_ptr = Box::into_raw(Box::new(create_background_executor())); BACKGROUND_EXECUTOR.store(new_ptr, Ordering::SeqCst); - unsafe { &mut *new_ptr } + // SAFETY: the executor is leaked and all of its operations take `&self`. + unsafe { &*new_ptr } } /// After a fork() operation, force re-creation of the BackgroundExecutor. Note: this function @@ -207,14 +226,14 @@ fn set_log_file_target(builder: &mut env_logger::Builder) { let path = Path::new(&log_file_path); // Create parent directories if they don't exist - if let Some(parent) = path.parent() { - if let Err(e) = std::fs::create_dir_all(parent) { - println!( - "Failed to create parent directories for log file '{}': {}, using stderr", - log_file_path, e - ); - return; - } + if let Some(parent) = path.parent() + && let Err(e) = std::fs::create_dir_all(parent) + { + println!( + "Failed to create parent directories for log file '{}': {}, using stderr", + log_file_path, e + ); + return; } // Try to open/create the log file @@ -249,11 +268,18 @@ 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::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; @@ -262,6 +288,9 @@ 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::()?; m.add_class::()?; m.add_class::()?; @@ -272,27 +301,54 @@ 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::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; + // MemWAL classes + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_wrapped(wrap_pyfunction!(mem_wal::py_evaluate_sharding_spec))?; + m.add_wrapped(wrap_pyfunction!(mem_wal::py_write_pk_sidecar))?; m.add_wrapped(wrap_pyfunction!(bfloat16_array))?; m.add_wrapped(wrap_pyfunction!(write_dataset))?; + m.add_wrapped(wrap_pyfunction!(serialize_row_addrs))?; m.add_wrapped(wrap_pyfunction!(write_fragments))?; m.add_wrapped(wrap_pyfunction!(write_fragments_transaction))?; m.add_wrapped(wrap_pyfunction!(schema_to_json))?; m.add_wrapped(wrap_pyfunction!(json_to_schema))?; + m.add_wrapped(wrap_pyfunction!(schema::parse_field_path))?; + m.add_wrapped(wrap_pyfunction!(schema::format_field_path))?; m.add_wrapped(wrap_pyfunction!(trace_to_chrome))?; m.add_wrapped(wrap_pyfunction!(capture_trace_events))?; m.add_wrapped(wrap_pyfunction!(shutdown_tracing))?; + m.add_wrapped(wrap_pyfunction!(fts::tokenize))?; + // OpenTelemetry metrics bridge + m.add_class::()?; + m.add_class::()?; + m.add_wrapped(wrap_pyfunction!(otel::register_lance_metrics_recorder))?; + m.add_wrapped(wrap_pyfunction!(otel::lance_metrics_catalog))?; + m.add_wrapped(wrap_pyfunction!(otel::snapshot_lance_metrics))?; m.add_wrapped(wrap_pyfunction!(manifest_needs_migration))?; m.add_wrapped(wrap_pyfunction!(language_model_home))?; m.add_wrapped(wrap_pyfunction!(bytes_read_counter))?; m.add_wrapped(wrap_pyfunction!(iops_counter))?; + m.add_wrapped(wrap_pyfunction!(simd_info))?; m.add_wrapped(wrap_pyfunction!(stable_version))?; // Debug functions m.add_wrapped(wrap_pyfunction!(debug::format_schema))?; @@ -311,6 +367,37 @@ fn iops_counter() -> PyResult { Ok(::lance::io::iops_counter()) } +/// Returns a dict describing which SIMD tier the lance runtime dispatches to +/// on this host, plus the raw CPU feature flags it detected. +/// +/// Mirrors `pyarrow.runtime_info()`: a cheap, transparent way to verify that +/// the host is hitting the expected SIMD tier (e.g., `"avx512_fp16"`, +/// `"avx2"`) when debugging vector-search performance. +/// +/// Returns: +/// { +/// "tier": str, # e.g. "avx2", "avx_fma", "neon", "none" +/// "target_arch": str, # e.g. "x86_64", "aarch64", "loongarch64" +/// "host_features": list[str], # raw CPU feature flags (x86_64 only) +/// } +/// +/// Examples: +/// >>> import lance +/// >>> info = lance.simd_info() +/// >>> sorted(info) +/// ['host_features', 'target_arch', 'tier'] +/// >>> isinstance(info["tier"], str) +/// True +#[pyfunction] +pub fn simd_info(py: Python<'_>) -> PyResult> { + let info = lance_core::utils::cpu::simd_info(); + let dict = pyo3::types::PyDict::new(py); + dict.set_item("tier", info.tier.to_string())?; + dict.set_item("target_arch", info.target_arch)?; + dict.set_item("host_features", info.host_features)?; + Ok(dict.into()) +} + #[pyfunction(name = "bytes_read_counter")] fn bytes_read_counter() -> PyResult { Ok(::lance::io::bytes_read_counter()) @@ -366,7 +453,12 @@ fn manifest_needs_migration(dataset: &Bound<'_, PyAny>) -> PyResult { )) } -#[pyclass(name = "FFILanceTableProvider", module = "lance", subclass)] +#[pyclass( + name = "FFILanceTableProvider", + module = "lance", + subclass, + skip_from_py_object +)] #[derive(Clone)] struct FFILanceTableProvider { dataset: Arc<::lance::Dataset>, @@ -394,6 +486,7 @@ impl FFILanceTableProvider { fn __datafusion_table_provider__<'py>( &self, py: Python<'py>, + session: Bound, ) -> PyResult> { let name = CString::new("datafusion_table_provider").unwrap(); let a_lance_table_provider = Arc::new(LanceTableProvider::new( @@ -402,9 +495,42 @@ impl FFILanceTableProvider { self.with_row_addr, )); - let ffi_provider = - FFI_TableProvider::new(a_lance_table_provider, true, rt().get_runtime_handle()); - let capsule = PyCapsule::new(py, ffi_provider, Some(name.clone())); - capsule + let codec = ffi_logical_codec_from_pycapsule(session)?; + let ffi_provider = FFI_TableProvider::new_with_ffi_codec( + a_lance_table_provider, + true, + rt().get_runtime_handle(), + codec, + ); + PyCapsule::new(py, ffi_provider, Some(name.clone())) + } +} + +fn ffi_logical_codec_from_pycapsule(obj: Bound) -> PyResult { + let attr_name = "__datafusion_logical_extension_codec__"; + let capsule = if obj.hasattr(attr_name)? { + obj.getattr(attr_name)?.call0()? + } else { + obj + }; + + let capsule = capsule.cast::()?; + let data: NonNull = capsule + .pointer_checked(Some(c_str!("datafusion_logical_extension_codec")))? + .cast(); + let codec = unsafe { data.as_ref() }; + + Ok(codec.clone()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn background_executor_is_a_shared_singleton() { + let first: &'static BackgroundExecutor = rt(); + let second: &'static BackgroundExecutor = rt(); + assert!(std::ptr::eq(first, second)); } } diff --git a/python/src/mem_wal.rs b/python/src/mem_wal.rs new file mode 100644 index 00000000000..48ea62d6e10 --- /dev/null +++ b/python/src/mem_wal.rs @@ -0,0 +1,1104 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::collections::HashMap; +use std::sync::Arc; + +use arrow::ffi_stream::ArrowArrayStreamReader; +use arrow::pyarrow::*; +use arrow_array::{ + Array, FixedSizeListArray, Float32Array, RecordBatch, RecordBatchIterator, RecordBatchReader, + StructArray, make_array, +}; +use arrow_data::ArrayData; +use arrow_schema::{DataType, Field, Schema as ArrowSchema}; +use datafusion::common::ScalarValue; +use datafusion::physical_plan::{ExecutionPlan, collect, displayable}; +use datafusion::prelude::SessionContext; +use futures::TryStreamExt; +use lance::dataset::Dataset as LanceDataset; +use lance::dataset::mem_wal::scanner::{ + LsmDataSourceCollector, LsmPointLookupPlanner, LsmVectorSearchPlanner, SsTable, + parse_filter_expr as parse_lsm_filter_expr, +}; +use lance::dataset::mem_wal::write::{MemTableStats, ShardMemory, WriteStatsSnapshot}; +use lance::dataset::mem_wal::{ + LsmScanner, ShardSnapshot, ShardWriter, arrow_schema_with_field_ids, evaluate_sharding_spec, +}; +use lance_index::mem_wal::{ + CompactedSsTable as LanceCompactedSsTable, ShardingField, ShardingSpec, +}; +use lance_linalg::distance::DistanceType; +use pyo3::exceptions::{PyIOError, PyRuntimeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyList}; +use tokio::sync::Mutex as TokioMutex; +use uuid::Uuid; + +use crate::dataset::Dataset as PyDataset; +use crate::rt; +use crate::schema::LanceSchema as PyLanceSchema; + +/// Evaluate a MemWAL sharding spec against one PyArrow RecordBatch. +#[pyfunction(name = "_evaluate_sharding_spec", signature = (batch, spec, schema))] +pub fn py_evaluate_sharding_spec<'py>( + py: Python<'py>, + batch: PyArrowType, + spec: &Bound<'_, PyAny>, + schema: &PyLanceSchema, +) -> PyResult> { + let PyArrowType(batch) = batch; + let spec = sharding_spec_from_py(spec)?; + let result = evaluate_sharding_spec(&batch, &spec, &schema.0) + .map_err(|e| PyValueError::new_err(e.to_string()))?; + result.to_pyarrow(py) +} + +/// Write a primary-key dedup sidecar (`_pk_index/`) for an SSTable +/// dataset already written at `gen_path`, mirroring what production flush emits. +/// +/// Test-support only: lets Python tests stage a *faithful* SSTable +/// (dataset + sidecar). Production always writes the sidecar during flush, so a +/// dataset-without-sidecar is not a state the system otherwise produces. +#[pyfunction(name = "_write_pk_sidecar", signature = (gen_path, data, pk_columns))] +pub fn py_write_pk_sidecar( + py: Python<'_>, + gen_path: String, + data: &Bound<'_, PyAny>, + pk_columns: Vec, +) -> PyResult<()> { + let reader = ArrowArrayStreamReader::from_pyarrow_bound(data) + .map_err(|e| PyValueError::new_err(format!("Cannot read data as Arrow: {}", e)))?; + let batches: Vec = reader + .collect::>() + .map_err(|e| PyIOError::new_err(format!("Failed to read batches: {}", e)))?; + rt().block_on(Some(py), async move { + let pk_refs: Vec<&str> = pk_columns.iter().map(String::as_str).collect(); + lance::dataset::mem_wal::scanner::write_pk_sidecar(&gen_path, &batches, &pk_refs).await + })? + .map_err(|e: lance::Error| PyIOError::new_err(e.to_string())) +} + +fn sharding_spec_from_py(spec: &Bound<'_, PyAny>) -> PyResult { + let spec_id = get_py_value(spec, "spec_id")?.extract::()?; + let fields_obj = get_py_value(spec, "fields")?; + let mut fields = Vec::new(); + for field_obj in fields_obj.try_iter()? { + fields.push(sharding_field_from_py(&field_obj?)?); + } + Ok(ShardingSpec { spec_id, fields }) +} + +fn sharding_field_from_py(field: &Bound<'_, PyAny>) -> PyResult { + Ok(ShardingField { + field_id: get_py_value(field, "field_id")?.extract::()?, + source_ids: get_py_value(field, "source_ids")?.extract::>()?, + transform: optional_string(get_py_value(field, "transform")?)?, + expression: optional_string(get_py_value(field, "expression")?)?, + result_type: get_py_value(field, "result_type")?.extract::()?, + parameters: get_py_value(field, "parameters")?.extract::>()?, + }) +} + +fn get_py_value<'py>(obj: &Bound<'py, PyAny>, name: &str) -> PyResult> { + if let Ok(dict) = obj.cast::() { + dict.get_item(name)? + .ok_or_else(|| PyValueError::new_err(format!("Missing sharding spec field '{name}'"))) + } else { + obj.getattr(name) + } +} + +fn optional_string(value: Bound<'_, PyAny>) -> PyResult> { + if value.is_none() { + Ok(None) + } else { + value.extract::().map(Some) + } +} + +/// Points to an SSTable compacted into the base table. +/// +/// Used with `MergeInsertBuilder.mark_sstables_as_compacted()`. +#[pyclass(name = "_CompactedSsTable", module = "_lib")] +pub struct PyCompactedSsTable { + pub shard_id: String, + pub generation: u64, +} + +#[pymethods] +impl PyCompactedSsTable { + #[new] + pub fn new(shard_id: String, generation: u64) -> Self { + Self { + shard_id, + generation, + } + } + + #[getter] + pub fn shard_id(&self) -> &str { + &self.shard_id + } + + #[getter] + pub fn generation(&self) -> u64 { + self.generation + } + + pub fn __repr__(&self) -> String { + format!( + "_CompactedSsTable(shard_id='{}', generation={})", + self.shard_id, self.generation + ) + } +} + +impl PyCompactedSsTable { + pub fn to_lance(&self) -> PyResult { + let uuid = Uuid::parse_str(&self.shard_id) + .map_err(|e| PyValueError::new_err(format!("Invalid shard_id UUID: {}", e)))?; + Ok(LanceCompactedSsTable::new(uuid, self.generation)) + } +} + +/// Snapshot of a MemWAL shard's state at a point in time. +/// +/// Used to specify which SSTables to include when creating an +/// `_LsmScanner`. Supports a builder pattern for adding generations. +#[pyclass(name = "_ShardSnapshot", module = "_lib", skip_from_py_object)] +#[derive(Clone)] +pub struct PyShardSnapshot { + pub inner: ShardSnapshot, +} + +#[pymethods] +impl PyShardSnapshot { + #[new] + pub fn new(shard_id: String) -> PyResult { + let uuid = Uuid::parse_str(&shard_id) + .map_err(|e| PyValueError::new_err(format!("Invalid shard_id UUID: {}", e)))?; + Ok(Self { + inner: ShardSnapshot::new(uuid), + }) + } + + /// Set the sharding spec ID for this snapshot. + pub fn with_spec_id(mut slf: PyRefMut<'_, Self>, spec_id: u32) -> PyRefMut<'_, Self> { + slf.inner = slf.inner.clone().with_spec_id(spec_id); + slf + } + + /// Set the current (active) generation number. + pub fn with_current_generation( + mut slf: PyRefMut<'_, Self>, + generation: u64, + ) -> PyRefMut<'_, Self> { + slf.inner = slf.inner.clone().with_current_generation(generation); + slf + } + + /// Add an SSTable by its generation number and storage path. + pub fn with_sstable( + mut slf: PyRefMut<'_, Self>, + generation: u64, + path: String, + ) -> PyRefMut<'_, Self> { + slf.inner = slf.inner.clone().with_sstable(generation, path); + slf + } + + #[getter] + pub fn shard_id(&self) -> String { + self.inner.shard_id.to_string() + } + + pub fn __repr__(&self) -> String { + format!( + "_ShardSnapshot(shard_id='{}', current_gen={}, sstables={})", + self.inner.shard_id, + self.inner.current_generation, + self.inner.sstables.len() + ) + } +} + +/// Long-lived stateful writer for a MemWAL shard. +/// +/// Supports writing batches, querying statistics, creating LSM scanners, +/// and graceful shutdown. Supports the Python context manager protocol. +#[pyclass(name = "_ShardWriter", module = "_lib")] +pub struct PyShardWriter { + inner: Arc>>, + closed_state: Arc>>, + shard_id: Uuid, + dataset: Arc, +} + +#[derive(Clone)] +struct ClosedShardWriterState { + stats: WriteStatsSnapshot, + memtable_stats: MemTableStats, +} + +fn collect_record_batches(data: &Bound<'_, PyAny>) -> PyResult> { + let reader = ArrowArrayStreamReader::from_pyarrow_bound(data) + .map_err(|e| PyValueError::new_err(format!("Cannot read data as Arrow: {}", e)))?; + reader + .collect::>() + .map_err(|e| PyIOError::new_err(format!("Failed to read batches: {}", e))) +} + +#[pymethods] +impl PyShardWriter { + /// Write data batches to the MemWAL. + /// + /// Accepts any PyArrow-compatible data source (RecordBatch, Table, + /// or an Arrow stream reader). + pub fn put(&self, py: Python<'_>, data: &Bound<'_, PyAny>) -> PyResult<()> { + let batches = collect_record_batches(data)?; + + if batches.is_empty() { + return Ok(()); + } + + let inner = self.inner.clone(); + rt().block_on(Some(py), async move { + let guard = inner.lock().await; + match guard.as_ref() { + Some(writer) => writer.put(batches).await.map(|_| ()), + None => Err(lance_core::Error::invalid_input( + "ShardWriter is already closed", + )), + } + })? + .map_err(|e: lance::Error| PyIOError::new_err(e.to_string())) + } + + /// Delete rows from the MemWAL by primary key. + /// + /// Accepts any PyArrow-compatible data source carrying the shard's primary + /// key column(s). Rust core validates that primary keys exist and builds the + /// tombstone rows. + pub fn delete(&self, py: Python<'_>, keys: &Bound<'_, PyAny>) -> PyResult<()> { + let batches = collect_record_batches(keys)?; + + if batches.is_empty() { + return Ok(()); + } + + let inner = self.inner.clone(); + rt().block_on(Some(py), async move { + let guard = inner.lock().await; + match guard.as_ref() { + Some(writer) => writer.delete(batches).await.map(|_| ()), + None => Err(lance_core::Error::invalid_input( + "ShardWriter is already closed", + )), + } + })? + .map_err(|e: lance::Error| PyIOError::new_err(e.to_string())) + } + + /// Flush pending data and close the writer. + /// + /// After close(), calling put() will raise an error. + /// This is called automatically when using the context manager. + pub fn close(&self, py: Python<'_>) -> PyResult<()> { + let inner = self.inner.clone(); + let closed_state = self.closed_state.clone(); + rt().block_on(Some(py), async move { + let mut guard = inner.lock().await; + if let Some(writer) = guard.take() { + let stats_handle = writer.stats_handle(); + // Snapshot stats before close so the captured state reflects + // what was written, not any internal bookkeeping done by close(). + let stats_snapshot = stats_handle.snapshot(); + let memtable_stats_before_close = writer.memtable_stats().await?; + writer.close().await?; + let closed_memtable_stats = closed_memtable_stats(memtable_stats_before_close); + let mut closed_guard = closed_state.lock().await; + *closed_guard = Some(ClosedShardWriterState { + stats: stats_snapshot, + memtable_stats: closed_memtable_stats, + }); + Ok(()) + } else { + Ok(()) + } + })? + .map_err(|e: lance::Error| PyIOError::new_err(e.to_string())) + } + + /// Return a snapshot of current write statistics. + /// + /// Returns a dict with keys: put_count, put_time_ms, wal_flush_count, + /// wal_flush_bytes, memtable_flush_count, memtable_flush_rows. + pub fn stats(&self, py: Python<'_>) -> PyResult> { + let inner = self.inner.clone(); + let closed_state = self.closed_state.clone(); + let stats = rt() + .block_on(Some(py), async move { + let guard = inner.lock().await; + if let Some(writer) = guard.as_ref() { + Ok(writer.stats()) + } else { + let closed_guard = closed_state.lock().await; + closed_guard + .as_ref() + .map(|state| state.stats.clone()) + .ok_or_else(|| { + lance_core::Error::invalid_input("ShardWriter is already closed") + }) + } + })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + + write_stats_to_pydict(py, &stats) + } + + /// Return current MemTable statistics. + /// + /// Returns a dict with keys: row_count, batch_count, estimated_size_bytes, + /// index_bytes, frozen_bytes, generation. + pub fn memtable_stats(&self, py: Python<'_>) -> PyResult> { + let inner = self.inner.clone(); + let closed_state = self.closed_state.clone(); + let (stats, bytes) = rt() + .block_on(Some(py), async move { + let guard = inner.lock().await; + match guard.as_ref() { + // Byte totals come from `memory()`, the lock-free view; + // `memtable_stats` carries none. + Some(w) => w + .memtable_stats() + .await + .map(|stats| (stats, Some(w.memory()))), + None => { + let closed_guard = closed_state.lock().await; + closed_guard + .as_ref() + // A closed writer holds nothing, so the byte totals + // are zero by construction rather than stale. + .map(|state| (state.memtable_stats.clone(), None)) + .ok_or_else(|| { + lance_core::Error::invalid_input("ShardWriter is already closed") + }) + } + } + })? + .map_err(|e: lance::Error| PyIOError::new_err(e.to_string()))?; + + memtable_stats_to_pydict(py, &stats, bytes.as_ref()) + } + + /// Create an LSM scanner that includes the active MemTable for strong consistency. + /// + /// The scanner covers: base table + given SSTables + current active MemTable. + #[pyo3(signature = (shard_snapshots=vec![]))] + pub fn lsm_scanner( + &self, + py: Python<'_>, + shard_snapshots: Vec>, + ) -> PyResult { + let mut snapshots: Vec = shard_snapshots + .iter() + .map(|s| s.borrow().inner.clone()) + .collect(); + + let pk_columns = get_pk_columns(&self.dataset)?; + let inner = self.inner.clone(); + let dataset = self.dataset.clone(); + let shard_id = self.shard_id; + + let (active_ref, writer_snapshot) = rt() + .block_on(Some(py), async move { + let guard = inner.lock().await; + match guard.as_ref() { + Some(w) => { + let active_ref = w.active_memtable_ref().await?; + let writer_snapshot = w + .manifest() + .await? + .map(shard_snapshot_from_manifest) + .unwrap_or_else(|| ShardSnapshot::new(shard_id)); + Ok((active_ref, writer_snapshot)) + } + None => Err(lance_core::Error::invalid_input( + "ShardWriter is already closed", + )), + } + })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + snapshots.retain(|snapshot| snapshot.shard_id != shard_id); + snapshots.push(writer_snapshot); + + let scanner = LsmScanner::new(dataset, snapshots, pk_columns) + .with_active_memtable(shard_id, active_ref); + + Ok(PyLsmScanner { + inner: Some(scanner), + }) + } + + /// Return the shard ID as a UUID string. + #[getter] + pub fn shard_id(&self) -> String { + self.shard_id.to_string() + } + + pub fn __enter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + pub fn __exit__( + &self, + py: Python<'_>, + _exc_type: &Bound<'_, PyAny>, + _exc_val: &Bound<'_, PyAny>, + _exc_tb: &Bound<'_, PyAny>, + ) -> PyResult { + self.close(py)?; + Ok(false) + } +} + +impl PyShardWriter { + /// Create from a Rust ShardWriter and dataset reference. + pub fn new(writer: ShardWriter, shard_id: Uuid, dataset: Arc) -> Self { + Self { + inner: Arc::new(TokioMutex::new(Some(writer))), + closed_state: Arc::new(TokioMutex::new(None)), + shard_id, + dataset, + } + } +} + +/// Python wrapper around a DataFusion physical execution plan. +#[pyclass(name = "_ExecutionPlan", module = "_lib", skip_from_py_object)] +#[derive(Clone)] +pub struct PyExecutionPlan { + plan: Arc, + dataset_schema: Arc, +} + +impl PyExecutionPlan { + pub fn new(plan: Arc, dataset_schema: Arc) -> Self { + Self { + plan, + dataset_schema, + } + } +} + +#[pymethods] +impl PyExecutionPlan { + #[getter] + fn schema<'py>(&self, py: Python<'py>) -> PyResult> { + self.plan.schema().to_pyarrow(py) + } + + #[getter] + fn dataset_schema<'py>(&self, py: Python<'py>) -> PyResult> { + self.dataset_schema.to_pyarrow(py) + } + + fn explain(&self) -> String { + format!("{}", displayable(self.plan.as_ref()).indent(true)) + } + + fn to_batches<'py>(&self, py: Python<'py>) -> PyResult> { + let plan = self.plan.clone(); + let batches = rt() + .block_on(Some(py), async move { + let ctx = SessionContext::new(); + collect(plan, ctx.task_ctx()).await + })? + .map_err(|e| PyIOError::new_err(format!("Plan execution failed: {}", e)))?; + + let py_batches: Vec> = batches + .into_iter() + .map(|batch| { + PyArrowType(batch) + .into_pyobject(py) + .map(|batch| batch.into_any()) + .map_err(|e| PyIOError::new_err(e.to_string())) + }) + .collect::>()?; + PyList::new(py, py_batches) + } + + fn to_reader<'py>(&self, py: Python<'py>) -> PyResult> { + let plan = self.plan.clone(); + let batches = rt() + .block_on(Some(py), async move { + let ctx = SessionContext::new(); + collect(plan, ctx.task_ctx()).await + })? + .map_err(|e| PyIOError::new_err(format!("Plan execution failed: {}", e)))?; + + let schema = self.plan.schema().clone(); + let reader: Box = Box::new(RecordBatchIterator::new( + batches.into_iter().map(Ok), + schema, + )); + reader.into_pyarrow(py) + } +} + +/// LSM-aware scanner covering base table, SSTables, and active MemTable. +/// +/// Provides deduplication by primary key, always returning the newest version +/// of each row across all LSM levels. +#[pyclass(name = "_LsmScanner", module = "_lib")] +pub struct PyLsmScanner { + inner: Option, +} + +#[pymethods] +impl PyLsmScanner { + /// Create a scanner from dataset and shard snapshots (without active MemTable). + #[staticmethod] + pub fn from_snapshots( + dataset: &Bound<'_, PyDataset>, + shard_snapshots: Vec>, + ) -> PyResult { + let ds = dataset.borrow().ds.clone(); + let snapshots: Vec = shard_snapshots + .iter() + .map(|s| s.borrow().inner.clone()) + .collect(); + let pk_columns = get_pk_columns(&ds)?; + Ok(Self { + inner: Some(LsmScanner::new(ds, snapshots, pk_columns)), + }) + } + + /// Select specific columns to return. + pub fn project( + mut slf: PyRefMut<'_, Self>, + columns: Vec, + ) -> PyResult> { + let scanner = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + let cols: Vec<&str> = columns.iter().map(|s| s.as_str()).collect(); + slf.inner = Some( + scanner + .project(&cols) + .map_err(|e| PyValueError::new_err(e.to_string()))?, + ); + Ok(slf) + } + + /// Set a SQL filter expression. + pub fn filter(mut slf: PyRefMut<'_, Self>, expr: String) -> PyResult> { + let scanner = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + slf.inner = Some( + scanner + .filter(&expr) + .map_err(|e| PyValueError::new_err(e.to_string()))?, + ); + Ok(slf) + } + + /// Limit the number of rows returned, optionally with an offset. + #[pyo3(signature = (n=None, offset=None))] + pub fn limit( + mut slf: PyRefMut<'_, Self>, + n: Option, + offset: Option, + ) -> PyResult> { + let scanner = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + slf.inner = Some( + scanner + .limit(n.map(|n| n as i64), offset.map(|o| o as i64)) + .map_err(|e| PyValueError::new_err(e.to_string()))?, + ); + Ok(slf) + } + + /// Include the `_rowaddr` internal column in results. + pub fn with_row_address(mut slf: PyRefMut<'_, Self>) -> PyResult> { + let scanner = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + slf.inner = Some(scanner.with_row_address()); + Ok(slf) + } + + /// Include the `_memtable_gen` internal column in results. + pub fn with_memtable_gen(mut slf: PyRefMut<'_, Self>) -> PyResult> { + let scanner = slf + .inner + .take() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + slf.inner = Some(scanner.with_memtable_gen()); + Ok(slf) + } + + /// Execute the scan and return a single PyArrow RecordBatch. + pub fn to_batch<'py>(&self, py: Python<'py>) -> PyResult> { + let scanner = self + .inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + let batch = rt() + .block_on(Some(py), scanner.try_into_batch()) + .map_err(|e| PyIOError::new_err(e.to_string()))? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + PyArrowType(batch).into_pyobject(py).map(|b| b.into_any()) + } + + /// Execute the scan and return all batches as a Python list. + pub fn to_batches<'py>(&self, py: Python<'py>) -> PyResult> { + let scanner = self + .inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + let stream = rt() + .block_on(Some(py), scanner.try_into_stream()) + .map_err(|e| PyIOError::new_err(e.to_string()))? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + let batches: Vec = rt() + .block_on(Some(py), stream.try_collect()) + .map_err(|e| PyIOError::new_err(e.to_string()))? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + + let py_batches: Vec> = batches + .into_iter() + .map(|b| { + PyArrowType(b) + .into_pyobject(py) + .map(|b| b.into_any()) + .map_err(|e| PyIOError::new_err(e.to_string())) + }) + .collect::>()?; + PyList::new(py, py_batches) + } + + /// Return the row count without loading all data. + pub fn count_rows(&self, py: Python<'_>) -> PyResult { + let scanner = self + .inner + .as_ref() + .ok_or_else(|| PyRuntimeError::new_err("Scanner has already been consumed"))?; + rt().block_on(Some(py), scanner.count_rows()) + .map_err(|e| PyIOError::new_err(e.to_string()))? + .map_err(|e| PyIOError::new_err(e.to_string())) + } +} + +/// Plans and executes primary key point lookups across all LSM levels. +/// +/// More efficient than `_LsmScanner` for known-PK lookups due to bloom filter +/// optimizations and short-circuit evaluation. +#[pyclass(name = "_LsmPointLookupPlanner", module = "_lib")] +pub struct PyLsmPointLookupPlanner { + planner: LsmPointLookupPlanner, + dataset_schema: Arc, + pk_columns: Vec, +} + +#[pymethods] +impl PyLsmPointLookupPlanner { + #[new] + #[pyo3(signature = (dataset, shard_snapshots, pk_columns=None))] + pub fn new( + dataset: &Bound<'_, PyDataset>, + shard_snapshots: Vec>, + pk_columns: Option>, + ) -> PyResult { + let ds = dataset.borrow().ds.clone(); + let snapshots: Vec = shard_snapshots + .iter() + .map(|s| s.borrow().inner.clone()) + .collect(); + let pk_cols = match pk_columns { + Some(cols) => cols, + None => get_pk_columns(&ds)?, + }; + let base_schema = Arc::new(ArrowSchema::from(ds.schema())); + // A sealed generation stores the names it was written with, so the + // planner resolves its columns by id; `base_schema` carries none. + let identity_schema = Arc::new(arrow_schema_with_field_ids(ds.schema())); + let collector = LsmDataSourceCollector::new(ds.clone(), snapshots); + let planner = LsmPointLookupPlanner::new(collector, pk_cols.clone(), base_schema.clone()) + .map_err(|e| PyIOError::new_err(e.to_string()))? + .with_identity_schema(identity_schema); + Ok(Self { + planner, + dataset_schema: base_schema, + pk_columns: pk_cols, + }) + } + + /// Plan a single-row point lookup by primary key. + /// + /// For single-column primary keys, `pk_value` should be a PyArrow array + /// with exactly one element. For composite primary keys, `pk_value` must + /// be a StructArray with exactly one row and one field per PK column. + #[pyo3(signature = (pk_value, columns=None))] + pub fn plan_lookup( + &self, + py: Python<'_>, + pk_value: PyArrowType, + columns: Option>, + ) -> PyResult { + let array = make_array(pk_value.0); + let pk_values = scalar_values_from_pk_value(array.as_ref(), &self.pk_columns)?; + let proj: Option> = columns; + let planner_ref = &self.planner; + let plan = rt() + .block_on(Some(py), async { + planner_ref.plan_lookup(&pk_values, proj.as_deref()).await + })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + + Ok(PyExecutionPlan::new(plan, self.dataset_schema.clone())) + } +} + +/// Plans and executes vector KNN search across all LSM levels. +/// +/// Only supports IVF-PQ vector indexes maintained in MemWAL. +/// Results include staleness filtering to return only the latest version of each row. +#[pyclass(name = "_LsmVectorSearchPlanner", module = "_lib")] +pub struct PyLsmVectorSearchPlanner { + planner: LsmVectorSearchPlanner, + vector_dim: usize, + dataset_schema: Arc, +} + +#[pymethods] +impl PyLsmVectorSearchPlanner { + #[new] + #[pyo3(signature = (dataset, shard_snapshots, vector_column, pk_columns=None, distance_type=None, filter=None))] + pub fn new( + dataset: &Bound<'_, PyDataset>, + shard_snapshots: Vec>, + vector_column: String, + pk_columns: Option>, + distance_type: Option, + filter: Option, + ) -> PyResult { + let ds = dataset.borrow().ds.clone(); + let snapshots: Vec = shard_snapshots + .iter() + .map(|s| s.borrow().inner.clone()) + .collect(); + let pk_cols = match pk_columns { + Some(cols) => cols, + None => get_pk_columns(&ds)?, + }; + let base_schema = Arc::new(ArrowSchema::from(ds.schema())); + // See the point-lookup planner: a rename moves a name and keeps the id. + let identity_schema = Arc::new(arrow_schema_with_field_ids(ds.schema())); + + let dist_type = parse_distance_type(distance_type.as_deref().unwrap_or("l2"))?; + + let vector_dim = get_vector_dim(&ds, &vector_column)?; + let filter = filter + .as_deref() + .map(|filter| { + parse_lsm_filter_expr(base_schema.as_ref(), filter) + .map_err(|e| PyValueError::new_err(e.to_string())) + }) + .transpose()?; + + let collector = LsmDataSourceCollector::new(ds.clone(), snapshots); + let mut planner = LsmVectorSearchPlanner::new( + collector, + pk_cols, + base_schema.clone(), + vector_column, + dist_type, + ) + .with_identity_schema(identity_schema) + .with_dataset(ds); + if let Some(filter) = filter { + planner = planner.with_filter(Some(filter)); + } + + Ok(Self { + planner, + vector_dim, + dataset_schema: base_schema, + }) + } + + /// Plan a KNN vector search. + /// + /// `query` should be a flat PyArrow Float32Array with `vector_dim` elements. + #[allow(clippy::too_many_arguments)] + #[pyo3(signature = (query, k=10, nprobes=20, columns=None, refine_base_table=false, overfetch_factor=1.0))] + pub fn plan_search( + &self, + py: Python<'_>, + query: PyArrowType, + k: usize, + nprobes: usize, + columns: Option>, + refine_base_table: bool, + overfetch_factor: f64, + ) -> PyResult { + let query_array = make_array(query.0); + let float32_array = query_array + .as_any() + .downcast_ref::() + .ok_or_else(|| { + PyValueError::new_err( + "query must be a Float32Array. Use pa.array(values, type=pa.float32())", + ) + })?; + + if float32_array.len() != self.vector_dim { + return Err(PyValueError::new_err(format!( + "Query vector has {} dimensions, expected {}", + float32_array.len(), + self.vector_dim + ))); + } + + // Wrap the flat array into a FixedSizeListArray with one row + let field = Arc::new(Field::new("item", DataType::Float32, true)); + let fsl = FixedSizeListArray::try_new( + field, + self.vector_dim as i32, + Arc::new(float32_array.clone()), + None, + ) + .map_err(|e| PyValueError::new_err(format!("Cannot create query vector: {}", e)))?; + + let planner_ref = &self.planner; + let plan = rt() + .block_on(Some(py), async { + planner_ref + .plan_search( + &fsl, + k, + nprobes, + columns.as_deref(), + refine_base_table, + overfetch_factor, + ) + .await + })? + .map_err(|e| PyIOError::new_err(e.to_string()))?; + + Ok(PyExecutionPlan::new(plan, self.dataset_schema.clone())) + } +} + +/// Extract primary key column names from dataset schema. +pub(crate) fn get_pk_columns(ds: &LanceDataset) -> PyResult> { + let pk_fields = ds.schema().unenforced_primary_key(); + if pk_fields.is_empty() { + return Err(PyValueError::new_err( + "Dataset has no primary key. Set 'lance-schema:unenforced-primary-key' metadata \ + on the primary key field(s).", + )); + } + Ok(pk_fields.iter().map(|f| f.name.clone()).collect()) +} + +/// Parse distance type string to DistanceType enum. +fn parse_distance_type(s: &str) -> PyResult { + match s.to_lowercase().as_str() { + "l2" | "euclidean" => Ok(DistanceType::L2), + "cosine" => Ok(DistanceType::Cosine), + "dot" | "inner_product" => Ok(DistanceType::Dot), + "hamming" => Ok(DistanceType::Hamming), + _ => Err(PyValueError::new_err(format!( + "Unknown distance_type '{}'. Valid values: 'l2', 'cosine', 'dot', 'hamming'", + s + ))), + } +} + +/// Get the vector dimension from the dataset schema for a given column. +fn get_vector_dim(ds: &LanceDataset, column: &str) -> PyResult { + let schema = ArrowSchema::from(ds.schema()); + let field = schema.field_with_name(column).map_err(|_| { + PyValueError::new_err(format!("Column '{}' not found in dataset schema", column)) + })?; + match field.data_type() { + DataType::FixedSizeList(_, size) => Ok(*size as usize), + other => Err(PyValueError::new_err(format!( + "Column '{}' is not a FixedSizeList (got {:?}). \ + Vector columns must be FixedSizeList.", + column, other + ))), + } +} + +fn write_stats_to_pydict(py: Python<'_>, stats: &WriteStatsSnapshot) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("put_count", stats.put_count)?; + dict.set_item("put_time_ms", stats.put_time.as_millis() as u64)?; + dict.set_item("wal_flush_count", stats.wal_flush_count)?; + dict.set_item("wal_flush_bytes", stats.wal_flush_bytes)?; + dict.set_item("wal_flush_time_ms", stats.wal_flush_time.as_millis() as u64)?; + dict.set_item("memtable_flush_count", stats.memtable_flush_count)?; + dict.set_item("memtable_flush_rows", stats.memtable_flush_rows)?; + dict.set_item( + "memtable_flush_time_ms", + stats.memtable_flush_time.as_millis() as u64, + )?; + Ok(dict.into_any().unbind()) +} + +fn memtable_stats_to_pydict( + py: Python<'_>, + stats: &MemTableStats, + bytes: Option<&ShardMemory>, +) -> PyResult> { + let dict = PyDict::new(py); + dict.set_item("row_count", stats.row_count)?; + dict.set_item("batch_count", stats.batch_count)?; + // Row data only, as this key has always meant; `index_bytes` below is the + // rest of the active memtable's footprint. + dict.set_item( + "estimated_size_bytes", + bytes.map_or(0, ShardMemory::row_bytes), + )?; + dict.set_item("index_bytes", bytes.map_or(0, ShardMemory::index_bytes))?; + dict.set_item("generation", stats.generation)?; + dict.set_item( + "max_buffered_batch_position", + stats.max_buffered_batch_position, + )?; + dict.set_item("durable_batch_count", stats.durable_batch_count)?; + dict.set_item("global_offset", stats.global_offset)?; + dict.set_item( + "pending_wal_start_batch_position", + stats.pending_wal_start_batch_position, + )?; + dict.set_item( + "pending_wal_end_batch_position", + stats.pending_wal_end_batch_position, + )?; + dict.set_item("pending_wal_batch_count", stats.pending_wal_batch_count)?; + dict.set_item("pending_wal_row_count", stats.pending_wal_row_count)?; + dict.set_item( + "pending_wal_estimated_bytes", + stats.pending_wal_estimated_bytes, + )?; + dict.set_item("frozen_count", stats.frozen_count)?; + dict.set_item("frozen_bytes", bytes.map_or(0, ShardMemory::frozen_bytes))?; + // Flushed generations still lingering out `frozen_memtable_grace`. Resident, + // but no flush reclaims them, so they are not in `frozen_bytes`. + dict.set_item("grace_bytes", bytes.map_or(0, ShardMemory::grace_bytes))?; + // The whole footprint: what a process-wide budget meters, as opposed to + // `frozen_bytes` + active, which is only what a flush can give back. + dict.set_item( + "retained_bytes", + bytes.map_or(0, ShardMemory::retained_bytes), + )?; + Ok(dict.into_any().unbind()) +} + +fn scalar_values_from_pk_value( + pk_value: &dyn Array, + pk_columns: &[String], +) -> PyResult> { + if pk_value.len() != 1 { + return Err(PyValueError::new_err(format!( + "pk_value must contain exactly one row, got {}", + pk_value.len() + ))); + } + + if pk_columns.len() == 1 { + let scalar = ScalarValue::try_from_array(pk_value, 0) + .map_err(|e| PyValueError::new_err(format!("Cannot convert pk_value: {}", e)))?; + return Ok(vec![scalar]); + } + + let struct_array = pk_value.as_any().downcast_ref::().ok_or_else(|| { + PyValueError::new_err(format!( + "Composite primary key lookup requires a StructArray with exactly one row and {} fields", + pk_columns.len() + )) + })?; + + if struct_array.num_columns() != pk_columns.len() { + return Err(PyValueError::new_err(format!( + "Composite primary key lookup expected {} fields, got {}", + pk_columns.len(), + struct_array.num_columns() + ))); + } + + let mut pk_values = Vec::with_capacity(pk_columns.len()); + for column_name in pk_columns { + let column = struct_array.column_by_name(column_name).ok_or_else(|| { + PyValueError::new_err(format!( + "Composite primary key lookup requires field '{}' in pk_value", + column_name + )) + })?; + let scalar = ScalarValue::try_from_array(column.as_ref(), 0).map_err(|e| { + PyValueError::new_err(format!("Cannot convert composite pk_value: {}", e)) + })?; + pk_values.push(scalar); + } + Ok(pk_values) +} + +fn shard_snapshot_from_manifest(manifest: lance_index::mem_wal::ShardManifest) -> ShardSnapshot { + ShardSnapshot { + shard_id: manifest.shard_id, + spec_id: manifest.shard_spec_id, + current_generation: manifest.current_generation, + sstables: manifest + .sstables + .into_iter() + .map(|sstable| SsTable { + generation: sstable.generation, + path: sstable.path, + }) + .collect(), + } +} + +fn closed_memtable_stats(stats_before_close: MemTableStats) -> MemTableStats { + // Close awaits every frozen memtable's flush, so nothing is owed afterwards + // regardless of whether the active memtable had buffered batches. + let stats_before_close = MemTableStats { + frozen_count: 0, + ..stats_before_close + }; + + if stats_before_close.batch_count == 0 { + return stats_before_close; + } + + // After a successful close every buffered batch is flushed and WAL-durable, + // so the synthesized empty memtable starts at the writer's global end and the + // durable cursor has caught up to it. + let global_end = stats_before_close.global_offset + stats_before_close.batch_count; + MemTableStats { + row_count: 0, + batch_count: 0, + generation: stats_before_close.generation.saturating_add(1), + max_buffered_batch_position: None, + durable_batch_count: global_end, + global_offset: global_end, + pending_wal_start_batch_position: None, + pending_wal_end_batch_position: None, + pending_wal_batch_count: 0, + pending_wal_row_count: 0, + pending_wal_estimated_bytes: 0, + frozen_count: 0, + } +} diff --git a/python/src/namespace.rs b/python/src/namespace.rs index 90bc3f8fa03..996d3f92ca7 100644 --- a/python/src/namespace.rs +++ b/python/src/namespace.rs @@ -6,10 +6,30 @@ use std::collections::HashMap; use std::sync::Arc; +use async_trait::async_trait; use bytes::Bytes; +use lance_namespace::LanceNamespace as LanceNamespaceTrait; +use lance_namespace::compat::merge_insert_request_from_json; +use lance_namespace::models::{ + AlterTableAddColumnsRequest, AlterTableAlterColumnsRequest, AlterTableBackfillColumnsRequest, + AlterTableDropColumnsRequest, AlterTransactionRequest, AnalyzeTableQueryPlanRequest, + CountTableRowsRequest, CreateMaterializedViewRequest, CreateTableIndexRequest, + CreateTableTagRequest, CreateTableVersionRequest, CreateTableVersionResponse, + DeleteFromTableRequest, DeleteTableTagRequest, DescribeTableIndexStatsRequest, + DescribeTableRequest, DescribeTableResponse, DescribeTableVersionRequest, + DescribeTableVersionResponse, DescribeTransactionRequest, DropTableIndexRequest, + ExplainTableQueryPlanRequest, GetTableStatsRequest, GetTableTagVersionRequest, + InsertIntoTableRequest, ListTableIndicesRequest, ListTableTagsRequest, + ListTableVersionsRequest, ListTableVersionsResponse, ListTablesRequest, QueryTableRequest, + RefreshMaterializedViewRequest, RestoreTableRequest, UpdateTableRequest, + UpdateTableSchemaMetadataRequest, UpdateTableTagRequest, +}; 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}; @@ -68,7 +88,7 @@ impl DynamicContextProvider for PyDynamicContextProvider { Ok(headers_py) => { // Convert Python dict to Rust HashMap let bound_headers = headers_py.bind(py); - if let Ok(dict) = bound_headers.downcast::() { + if let Ok(dict) = bound_headers.cast::() { dict_to_hashmap(dict).unwrap_or_default() } else { log::warn!("Context provider did not return a dict"); @@ -98,7 +118,7 @@ fn dict_to_hashmap(dict: &Bound<'_, PyDict>) -> PyResult /// Python wrapper for DirectoryNamespace #[pyclass(name = "PyDirectoryNamespace", module = "lance.lance")] pub struct PyDirectoryNamespace { - inner: Arc, + pub(crate) inner: Arc, } #[pymethods] @@ -298,256 +318,1441 @@ impl PyDirectoryNamespace { pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } - #[allow(deprecated)] - fn create_empty_table<'py>( + fn declare_table<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { let request = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.create_empty_table(request))? + .block_on(Some(py), self.inner.declare_table(request))? .infer_error()?; - pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + Ok(pythonize(py, &response)?.into()) } - fn declare_table<'py>( + 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.declare_table(request))? + .block_on(Some(py), self.inner.rename_table(request))? .infer_error()?; Ok(pythonize(py, &response)?.into()) } -} -/// Python wrapper for RestNamespace -#[pyclass(name = "PyRestNamespace", module = "lance.lance")] -pub struct PyRestNamespace { - inner: Arc, -} + // Table version operations -#[pymethods] -impl PyRestNamespace { - /// Create a new RestNamespace from properties - /// - /// # Arguments - /// - /// * `context_provider` - Optional object with `provide_context(info: dict) -> dict` method - /// for providing dynamic per-request context. Context keys that start with `headers.` - /// are converted to HTTP headers by stripping the prefix. For example, - /// `{"headers.Authorization": "Bearer token"}` becomes the `Authorization` header. - /// * `**properties` - Namespace configuration properties (uri, delimiter, header.*, etc.) - #[new] - #[pyo3(signature = (context_provider = None, **properties))] - fn new( - context_provider: Option<&Bound<'_, PyAny>>, - properties: Option<&Bound<'_, PyDict>>, - ) -> PyResult { - let mut props = HashMap::new(); + fn list_table_versions<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_versions(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } - if let Some(dict) = properties { - props = dict_to_hashmap(dict)?; - } + fn create_table_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } - let mut builder = RestNamespaceBuilder::from_properties(props).map_err(|e| { - pyo3::exceptions::PyValueError::new_err(format!( - "Failed to create RestNamespace: {}", - e - )) - })?; + fn describe_table_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_table_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } - // Add context provider if provided - if let Some(provider) = context_provider { - let py_provider = PyDynamicContextProvider::new(provider.clone().unbind()); - builder = builder.context_provider(Arc::new(py_provider)); - } + fn batch_delete_table_versions<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.batch_delete_table_versions(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } - let namespace = builder.build(); + // Table branch operations - Ok(Self { - inner: Arc::new(namespace), - }) + fn create_table_branch<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_branch(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } - /// Get the namespace ID - fn namespace_id(&self) -> String { - format!("{:?}", self.inner) + fn list_table_branches<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_branches(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } - fn __repr__(&self) -> String { - format!("PyRestNamespace({})", self.namespace_id()) + fn delete_table_branch<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_table_branch(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } - // Namespace operations + // Data manipulation operations - fn list_namespaces<'py>( + 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 = depythonize(request)?; + 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.list_namespaces(request))? + .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 describe_namespace<'py>( + fn merge_insert_into_table<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request = depythonize(request)?; + let request = merge_insert_request_from_json(depythonize(request)?).infer_error()?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); let response = crate::rt() - .block_on(Some(py), self.inner.describe_namespace(request))? + .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 create_namespace<'py>( + fn update_table<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: UpdateTableRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.create_namespace(request))? + .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 drop_namespace<'py>( + fn delete_from_table<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: DeleteFromTableRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.drop_namespace(request))? + .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 namespace_exists(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult<()> { - let request = depythonize(request)?; - crate::rt() - .block_on(Some(py), self.inner.namespace_exists(request))? + 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(()) + Ok(PyBytes::new(py, &response)) } - // Table operations + // Index operations - fn list_tables<'py>( + fn create_table_index<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: CreateTableIndexRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.list_tables(request))? + .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 describe_table<'py>( + fn list_table_indices<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: ListTableIndicesRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.describe_table(request))? + .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 register_table<'py>( + fn describe_table_index_stats<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: DescribeTableIndexStatsRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.register_table(request))? + .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())) } - fn table_exists(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult<()> { - let request = depythonize(request)?; - crate::rt() - .block_on(Some(py), self.inner.table_exists(request))? + // 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()?; - Ok(()) + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } - fn drop_table<'py>( + fn alter_transaction<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: AlterTransactionRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.drop_table(request))? + .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())) } - fn deregister_table<'py>( + // Additional index operations + + fn create_table_scalar_index<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: CreateTableIndexRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.deregister_table(request))? + .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 create_table<'py>( + fn drop_table_index<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, - request_data: &Bound<'_, PyBytes>, ) -> PyResult> { - let request = depythonize(request)?; - let data = Bytes::copy_from_slice(request_data.as_bytes()); + let request: DropTableIndexRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.create_table(request, data))? + .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())) } - #[allow(deprecated)] - fn create_empty_table<'py>( + fn restore_table<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: RestoreTableRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.create_empty_table(request))? + .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 declare_table<'py>( + fn update_table_schema_metadata<'py>( &self, py: Python<'py>, request: &Bound<'_, PyAny>, ) -> PyResult> { - let request = depythonize(request)?; + let request: UpdateTableSchemaMetadataRequest = depythonize(request)?; let response = crate::rt() - .block_on(Some(py), self.inner.declare_table(request))? + .block_on(Some(py), self.inner.update_table_schema_metadata(request))? .infer_error()?; - Ok(pythonize(py, &response)?.into()) + 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())) + } + + fn alter_table_backfill_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableBackfillColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_backfill_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn refresh_materialized_view<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: RefreshMaterializedViewRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.refresh_materialized_view(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) } + + fn create_materialized_view<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateMaterializedViewRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_materialized_view(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. + /// + /// 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, +} + +#[pymethods] +impl PyRestNamespace { + /// Create a new RestNamespace from properties + /// + /// # Arguments + /// + /// * `context_provider` - Optional object with `provide_context(info: dict) -> dict` method + /// for providing dynamic per-request context. Context keys that start with `headers.` + /// are converted to HTTP headers by stripping the prefix. For example, + /// `{"headers.Authorization": "Bearer token"}` becomes the `Authorization` header. + /// * `**properties` - Namespace configuration properties (uri, delimiter, header.*, etc.) + #[new] + #[pyo3(signature = (context_provider = None, **properties))] + fn new( + context_provider: Option<&Bound<'_, PyAny>>, + properties: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + let mut props = HashMap::new(); + + if let Some(dict) = properties { + props = dict_to_hashmap(dict)?; + } + + let mut builder = RestNamespaceBuilder::from_properties(props).map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!( + "Failed to create RestNamespace: {}", + e + )) + })?; + + // Add context provider if provided + if let Some(provider) = context_provider { + let py_provider = PyDynamicContextProvider::new(provider.clone().unbind()); + builder = builder.context_provider(Arc::new(py_provider)); + } + + let namespace = builder.build(); + + Ok(Self { + inner: Arc::new(namespace), + }) + } + + /// Get the namespace ID + fn namespace_id(&self) -> String { + format!("{:?}", self.inner) + } + + fn __repr__(&self) -> String { + format!("PyRestNamespace({})", self.namespace_id()) + } + + // Namespace operations + + fn list_namespaces<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_namespaces(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn describe_namespace<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_namespace(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_namespace<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_namespace(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn drop_namespace<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.drop_namespace(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn namespace_exists(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult<()> { + let request = depythonize(request)?; + crate::rt() + .block_on(Some(py), self.inner.namespace_exists(request))? + .infer_error()?; + Ok(()) + } + + // Table operations + + fn list_tables<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_tables(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn describe_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn register_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.register_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn table_exists(&self, py: Python, request: &Bound<'_, PyAny>) -> PyResult<()> { + let request = depythonize(request)?; + crate::rt() + .block_on(Some(py), self.inner.table_exists(request))? + .infer_error()?; + Ok(()) + } + + fn drop_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.drop_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn deregister_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.deregister_table(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + request_data: &Bound<'_, PyBytes>, + ) -> PyResult> { + let request = depythonize(request)?; + let data = Bytes::copy_from_slice(request_data.as_bytes()); + let response = crate::rt() + .block_on(Some(py), self.inner.create_table(request, data))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn declare_table<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.declare_table(request))? + .infer_error()?; + 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>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_versions(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_table_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn describe_table_version<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.describe_table_version(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn batch_delete_table_versions<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.batch_delete_table_versions(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + // Table branch operations + + fn create_table_branch<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_table_branch(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn list_table_branches<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.list_table_branches(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn delete_table_branch<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.delete_table_branch(request))? + .infer_error()?; + 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 = merge_insert_request_from_json(depythonize(request)?).infer_error()?; + 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())) + } + + fn alter_table_backfill_columns<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: AlterTableBackfillColumnsRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.alter_table_backfill_columns(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn refresh_materialized_view<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: RefreshMaterializedViewRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.refresh_materialized_view(request))? + .infer_error()?; + pythonize(py, &response).map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) + } + + fn create_materialized_view<'py>( + &self, + py: Python<'py>, + request: &Bound<'_, PyAny>, + ) -> PyResult> { + let request: CreateMaterializedViewRequest = depythonize(request)?; + let response = crate::rt() + .block_on(Some(py), self.inner.create_materialized_view(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. + /// + /// 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) +} + +/// Convert a Python namespace exception into a lance error, preserving the +/// namespace error identity when the exception is a `lance_namespace` +/// `LanceNamespaceError` carrying an error `code`, so callers can react to +/// e.g. TableNotFound the same way they do for native clients. Foreign +/// exceptions that happen to carry an integer `code` (e.g. SystemExit) must +/// not be reinterpreted, so the extraction is gated on the exception type. +fn namespace_error_from_py(method_name: &'static str, e: PyErr) -> lance_core::Error { + Python::attach(|py| { + let value = e.value(py); + let is_namespace_error = py + .import("lance_namespace.errors") + .and_then(|module| module.getattr("LanceNamespaceError")) + .and_then(|class| value.is_instance(&class)) + .unwrap_or(false); + if is_namespace_error + && let Ok(code) = value.getattr("code").and_then(|code| code.extract::()) + { + return lance_namespace::error::NamespaceError::from_code(code, value.to_string()) + .into(); + } + lance_core::Error::io(format!("Python error in {}: {}", method_name, e)) + }) +} + +/// 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| namespace_error_from_py(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 +/// to be used as a Rust LanceNamespace. +/// +/// This is similar to JavaLanceNamespace in the Java bindings - it wraps a Python +/// object and calls back into Python when namespace methods are invoked. +/// +/// We use `Arc>` instead of `Py` directly because cloning `Py` +/// requires the GIL, but cloning `Arc` does not. This allows us to pass the +/// namespace reference to `spawn_blocking` without holding the GIL. +pub struct PyLanceNamespace { + py_namespace: Arc>, + namespace_id: String, +} + +impl PyLanceNamespace { + /// Create a new PyLanceNamespace wrapper around a Python namespace object. + pub fn new(_py: Python<'_>, py_namespace: &Bound<'_, PyAny>) -> PyResult { + // Get the namespace_id by calling the Python method + let namespace_id = py_namespace + .call_method0("namespace_id")? + .extract::()?; + + Ok(Self { + py_namespace: Arc::new(py_namespace.clone().unbind()), + namespace_id, + }) + } + + /// Create an Arc from a Python namespace object. + pub fn create_arc( + py: Python<'_>, + py_namespace: &Bound<'_, PyAny>, + ) -> PyResult> { + let wrapper = Self::new(py, py_namespace)?; + Ok(Arc::new(wrapper)) + } +} + +impl std::fmt::Debug for PyLanceNamespace { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "PyLanceNamespace {{ id: {} }}", self.namespace_id) + } +} + +#[async_trait] +impl LanceNamespaceTrait for PyLanceNamespace { + fn namespace_id(&self) -> String { + 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 { + call_py_method(self.py_namespace.clone(), "describe_table_version", request).await + } + + async fn create_table_version( + &self, + request: CreateTableVersionRequest, + ) -> lance_core::Result { + call_py_method(self.py_namespace.clone(), "create_table_version", request).await + } + + async fn list_table_versions( + &self, + request: ListTableVersionsRequest, + ) -> lance_core::Result { + call_py_method(self.py_namespace.clone(), "list_table_versions", request).await + } +} + +/// Extract an `Arc` from a Python namespace object. +/// +/// This function handles the different ways a Python namespace can be provided: +/// 1. Direct PyO3 class (PyDirectoryNamespace or PyRestNamespace) +/// 2. Python wrapper class with `_inner` attribute that holds the PyO3 class +/// 3. Custom Python implementation (wrapped with PyLanceNamespace) +/// +/// For Python wrapper classes (DirectoryNamespace, RestNamespace in namespace.py), +/// we check if it's the exact wrapper class by comparing type names. Subclasses +/// are wrapped with PyLanceNamespace to call through Python. +pub fn extract_namespace_arc( + py: Python<'_>, + namespace_client: &Bound<'_, PyAny>, +) -> PyResult> { + // Direct PyO3 class + if let Ok(dir_namespace_client) = namespace_client.cast::() { + return Ok(dir_namespace_client.borrow().inner.clone() as Arc); + } + if let Ok(rest_namespace_client) = namespace_client.cast::() { + 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) = 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_namespace_client) = inner.cast::() { + return Ok( + dir_namespace_client.borrow().inner.clone() as Arc + ); + } + } else if type_name == "RestNamespace" + && let Ok(rest_namespace_client) = inner.cast::() + { + return Ok(rest_namespace_client.borrow().inner.clone() as Arc); + } + } + + // Custom Python implementation or subclass - wrap with PyLanceNamespace + PyLanceNamespace::create_arc(py, namespace_client) } /// Python wrapper for REST adapter server @@ -564,21 +1769,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/object_store.rs b/python/src/object_store.rs new file mode 100644 index 00000000000..a7704002481 --- /dev/null +++ b/python/src/object_store.rs @@ -0,0 +1,238 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Runtime registration hook for external `ObjectStoreProvider` implementations. +//! +//! This module exposes two pyclasses: +//! +//! - `PyObjectStoreRegistry` wraps [`lance_io::object_store::ObjectStoreRegistry`] +//! and lets Python code register additional `ObjectStoreProvider`s under new +//! URL schemes. A registry constructed here can be passed to `Session` so +//! `lance.dataset("myscheme://...")` dispatches through the new provider. +//! - `PyObjectStoreProvider` is a bridge that adapts a built-in Rust provider +//! (currently just `MemoryStoreProvider`), a Python object that implements +//! the `new_store` protocol, or an `Arc` produced +//! by a *separate* wheel and handed across a `PyCapsule`, to +//! `Arc`. +//! +//! The Python-callable path is intentionally stubbed for this first cut: the +//! full Python-to-Rust `ObjectStore` bridge (i.e. wrapping a Python-returned +//! object as an `object_store::ObjectStore`) is a follow-up. The smoke test +//! against the built-in memory provider proves the registration + dispatch +//! plumbing works end to end. +//! +//! # Out-of-tree providers via `PyCapsule` +//! +//! [`PyObjectStoreProvider::from_capsule`] lets an external wheel register a +//! Rust `ObjectStoreProvider` it compiled itself. The external wheel builds an +//! `Arc`, wraps it in a `PyCapsule` named +//! [`PROVIDER_CAPSULE_NAME`], and passes that capsule here. Because Rust has +//! no stable ABI, this is sound **only when both wheels are built in lockstep**: +//! identical `rustc`, identical `lance-io` / `object_store` source, and +//! identical resolved dependency versions, so the trait object's vtable and the +//! types in `new_store`'s signature have the same layout on both sides. In +//! Phase I both wheels are built locally from the same branch and toolchain, so +//! the constraint holds; distributing pre-built wheels that must interoperate +//! is deferred (a packaging-phase concern). + +use std::ffi::CStr; +use std::sync::Arc; + +use pyo3::exceptions::PyValueError; +use pyo3::prelude::*; +use pyo3::types::{PyCapsule, PyCapsuleMethods}; + +use lance_io::object_store::providers::memory::MemoryStoreProvider; +use lance_io::object_store::{ + ObjectStore, ObjectStoreParams, ObjectStoreProvider, ObjectStoreRegistry, +}; + +/// Name that every capsule passed to [`PyObjectStoreProvider::from_capsule`] +/// must carry. External wheels create their capsule with this exact name so a +/// capsule holding some unrelated pointer cannot be mistaken for a provider. +pub const PROVIDER_CAPSULE_NAME: &CStr = c"lance_object_store_provider"; + +/// Bridge between a Python object and the Rust `ObjectStoreProvider` trait. +/// +/// For the memory variant we short-circuit to a real Rust provider so the +/// smoke test can prove the scheme-dispatch plumbing works. For the Python +/// callable variant we hold a `Py` and (in a follow-up) will call +/// `new_store(base_path, storage_options)` on it via the GIL. +#[derive(Debug)] +enum PyProviderBridge { + /// Built-in `MemoryStoreProvider`, wrapped directly. + Memory(MemoryStoreProvider), + /// A Python object implementing the `new_store(base_path, storage_options)` + /// protocol. Not yet dispatchable end-to-end (see module docstring). + /// + /// The wrapped `Py` is intentionally held even though we do not + /// invoke it yet: keeping the Python object alive here means once the + /// bridge lands, we can dispatch without changing the enum shape. + #[allow(dead_code)] + PyCallable(Py), +} + +#[async_trait::async_trait] +impl ObjectStoreProvider for PyProviderBridge { + async fn new_store( + &self, + base_path: url::Url, + params: &ObjectStoreParams, + ) -> lance_core::Result { + match self { + Self::Memory(inner) => inner.new_store(base_path, params).await, + Self::PyCallable(_) => Err(lance_core::Error::not_supported( + "PyObjectStoreProvider: the Python-callable bridge is not yet \ + implemented. Use PyObjectStoreProvider.memory() for the current cut.", + )), + } + } +} + +/// Python-facing wrapper around `Arc`. +/// +/// There are three ways to construct one from Python, ordered here by how +/// complete they are today: +/// +/// 1. `_ObjectStoreProvider.from_capsule(capsule)` — **fully dispatches.** +/// Adopt an `Arc` built by a separate, +/// ABI-compatible wheel and handed over in a `PyCapsule`. `new_store` then +/// calls that provider's own Rust implementation directly — the +/// `PyProviderBridge` below is not involved. This is how an out-of-tree Rust +/// provider (e.g. an on-node NVMe cache) plugs in without living in the +/// Lance source tree; see the module docs for the `PyCapsule` handoff and +/// its ABI-lockstep build requirement. +/// 2. `_ObjectStoreProvider.memory()` — **fully dispatches.** Wrap the built-in +/// `MemoryStoreProvider`; registrable under any scheme and functional for +/// read/write. Primarily a test/reference vehicle. +/// 3. `_ObjectStoreProvider(py_obj)` — **stub; does not dispatch yet.** Hold a +/// Python object implementing `new_store(base_path, storage_options)`. +/// Registration succeeds, but dispatch raises `not_supported`: the full +/// Python-to-Rust `ObjectStore` bridge (calling back into Python from +/// `new_store` under the GIL) is a follow-up. +#[pyclass(name = "_ObjectStoreProvider", module = "_lib", from_py_object)] +#[derive(Clone)] +pub struct PyObjectStoreProvider { + pub(crate) inner: Arc, +} + +#[pymethods] +impl PyObjectStoreProvider { + /// Wrap a Python object implementing the `new_store(base_path, storage_options)` + /// protocol. Registration will succeed, but scheme-dispatch will raise until + /// the full Python-to-Rust `ObjectStore` bridge is implemented. + #[new] + fn new(py_object: Py) -> Self { + Self { + inner: Arc::new(PyProviderBridge::PyCallable(py_object)), + } + } + + /// Return a provider backed by the built-in `MemoryStoreProvider`. Every + /// call to `new_store` allocates a fresh in-memory `object_store::InMemory`; + /// the enclosing `ObjectStoreRegistry` caches the resulting `ObjectStore` + /// so writers and readers using the same scheme share storage as long as + /// something holds a strong reference. + #[staticmethod] + fn memory() -> Self { + Self { + inner: Arc::new(PyProviderBridge::Memory(MemoryStoreProvider)), + } + } + + /// Adopt an `Arc` carried in a `PyCapsule` created + /// by a separate wheel. The capsule must be named [`PROVIDER_CAPSULE_NAME`] + /// and hold exactly an `Arc`. + /// + /// See the module docstring for the ABI-lockstep requirement: the calling + /// wheel must be built against the identical `lance-io` / `object_store` + /// source and toolchain as this one. + #[staticmethod] + fn from_capsule(capsule: &Bound<'_, PyCapsule>) -> PyResult { + // `pointer_checked(Some(name))` asks CPython for the pointer *and* + // requires the capsule to carry exactly this name and a non-null + // pointer, so a foreign or misnamed capsule is rejected here rather + // than dereferenced. (Passing `None` asks for a *nameless* capsule and + // would reject every correctly-named one.) + let ptr = capsule + .pointer_checked(Some(PROVIDER_CAPSULE_NAME)) + .map_err(|e| { + PyValueError::new_err(format!( + "expected a PyCapsule named {:?}: {e}", + PROVIDER_CAPSULE_NAME.to_string_lossy(), + )) + })?; + + // SAFETY: by the capsule-name contract above, the capsule carries an + // `Arc` built against the identical lance-io / + // object_store types (same source, rustc, and resolved dependency + // versions). We dereference only long enough to clone the `Arc` + // (bumping the strong count); the capsule keeps its own reference and + // its destructor drops that on GC. + let provider = unsafe { ptr.cast::>().as_ref() }; + Ok(Self { + inner: provider.clone(), + }) + } + + /// Test/reference producer: wrap the built-in memory provider in a + /// `PyCapsule` named [`PROVIDER_CAPSULE_NAME`], mirroring what an external, + /// ABI-compatible wheel emits. Lets `from_capsule` be exercised end to end + /// from Python without a second wheel in the tree. + #[staticmethod] + fn _memory_capsule(py: Python<'_>) -> PyResult> { + let provider: Arc = + Arc::new(PyProviderBridge::Memory(MemoryStoreProvider)); + PyCapsule::new(py, provider, Some(PROVIDER_CAPSULE_NAME.to_owned())) + } + + fn __repr__(&self) -> String { + format!("_ObjectStoreProvider({:?})", self.inner) + } +} + +/// Python-facing wrapper around `Arc`. +/// +/// A new instance starts from `ObjectStoreRegistry::default()`, so all +/// built-in schemes (memory, file, and any of s3/az/gs/oss/... enabled at +/// build time) are already registered. Additional providers can be inserted +/// under new (or overridden) schemes via `register_provider`. +/// +/// Pass an instance as the `store_registry` argument of `Session(...)` to +/// make its schemes visible to `lance.dataset(uri, session=...)` and +/// `lance.write_dataset(..., uri, session=...)`. +#[pyclass(name = "_ObjectStoreRegistry", module = "_lib", from_py_object)] +#[derive(Clone)] +pub struct PyObjectStoreRegistry { + pub(crate) inner: Arc, +} + +#[pymethods] +impl PyObjectStoreRegistry { + /// Create a new registry pre-populated with the built-in schemes. + #[new] + fn new() -> Self { + Self { + inner: Arc::new(ObjectStoreRegistry::default()), + } + } + + /// Register a provider under a scheme. Idempotent: registering the same + /// scheme again replaces the previous provider. Registering under a + /// built-in scheme (e.g. `"memory"`) overrides that built-in. + fn register_provider(&self, scheme: &str, provider: &PyObjectStoreProvider) -> PyResult<()> { + if scheme.is_empty() { + return Err(PyValueError::new_err("scheme must be a non-empty string")); + } + self.inner.insert(scheme, provider.inner.clone()); + Ok(()) + } + + fn __repr__(&self) -> String { + let stats = self.inner.stats(); + format!( + "_ObjectStoreRegistry(active_stores={}, hits={}, misses={})", + stats.active_stores, stats.hits, stats.misses, + ) + } +} diff --git a/python/src/otel.rs b/python/src/otel.rs new file mode 100644 index 00000000000..71d191d7db9 --- /dev/null +++ b/python/src/otel.rs @@ -0,0 +1,640 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Bridge from the [`metrics`] crate facade to Python OpenTelemetry. +//! +//! Lance core publishes metrics through the global [`metrics`] facade without +//! choosing a backend. This module installs a process-global [`Recorder`] that +//! aggregates those metrics into lock-free cumulative storage, and exposes that +//! state to Python so the bindings can feed it into the user's OpenTelemetry +//! `MeterProvider`. +//! +//! While it targets OpenTelemetry on the Python side, the recorder is agnostic +//! to the metric *source*: it records any metric emitted through the facade, +//! keyed by name and labels. Object store metrics are the first producer, but +//! nothing here is specific to them. New metrics flow through automatically; +//! they only need to be described (see [`describe_all`]) so the Python layer can +//! discover their name, kind, and unit up front. +//! +//! ## Why pull, not push +//! +//! OpenTelemetry collects on its own schedule and invokes observable-instrument +//! callbacks at collection time. Cumulative counters map directly onto OTel's +//! `ObservableCounter` semantics. So the bridge aggregates in Rust and lets the +//! Python collection thread pull a [`snapshot`](snapshot_lance_metrics). The +//! snapshot is lock-free, but it still walks every registered series and +//! allocates owned copies of their names and labels, so it runs with the GIL +//! released to avoid stalling other Python threads during collection. +//! +//! ## Histograms +//! +//! OpenTelemetry has no asynchronous histogram instrument, so histograms cannot +//! be pulled as-is. Instead each histogram is aggregated into fixed buckets +//! (Prometheus style) and exposed as cumulative `le` bucket counts plus a count +//! and sum, which the Python layer surfaces as observable counters. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, LazyLock, Mutex, OnceLock, RwLock}; + +use metrics::{Counter, Gauge, Histogram, Key, KeyName, Metadata, Recorder, SharedString, Unit}; +use metrics_util::registry::{Registry, Storage}; +use pyo3::prelude::*; + +/// Bucket boundaries used when a histogram has no registered bounds. Covers a +/// broad latency range so unknown histograms still produce useful buckets. +const DEFAULT_BOUNDS: &[f64] = &[ + 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, 30.0, 60.0, 120.0, 300.0, +]; + +/// The kind of a metric, mirroring the three `metrics` instrument types. +#[derive(Clone, Copy)] +enum MetricKind { + Counter, + Gauge, + Histogram, +} + +impl MetricKind { + fn as_str(self) -> &'static str { + match self { + Self::Counter => "counter", + Self::Gauge => "gauge", + Self::Histogram => "histogram", + } + } +} + +/// Description of a metric, populated by the recorder's `describe_*` methods. +struct MetricDescription { + kind: MetricKind, + unit: Option, + description: String, +} + +/// Catalog of described metrics, keyed by metric name. The Python layer reads +/// this to create one OpenTelemetry instrument per metric up front. +static CATALOG: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Per-metric histogram bucket boundaries, keyed by metric name. Producers +/// register their recommended bounds before any metric is recorded. +static HISTOGRAM_BOUNDS: LazyLock>>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// The installed recorder's registry, available once installation succeeds. +static REGISTRY: OnceLock>> = OnceLock::new(); + +fn bounds_for(name: &str) -> Arc<[f64]> { + HISTOGRAM_BOUNDS + .read() + .unwrap() + .get(name) + .cloned() + .unwrap_or_else(|| Arc::from(DEFAULT_BOUNDS)) +} + +/// A histogram that buckets samples at record time into fixed boundaries, +/// keeping a cumulative count and sum. Bucketing eagerly keeps memory bounded +/// (unlike retaining raw samples) and produces Prometheus-style `le` buckets. +struct BucketedHistogram { + /// Sorted, finite upper bounds. A sample `v` falls in the first bucket whose + /// bound is `>= v`; samples above all bounds fall in the implicit `+Inf` + /// bucket stored as the final entry of `counts`. + bounds: Arc<[f64]>, + /// Per-bucket (non-cumulative) counts; length is `bounds.len() + 1`. + counts: Box<[AtomicU64]>, + count: AtomicU64, + /// Running sum of recorded values, stored as `f64` bits (there is no atomic + /// f64, so the bit pattern is held in a `u64`; see [`Self::add_to_sum`]). + sum_bits: AtomicU64, +} + +// All atomics here use `Ordering::Relaxed`: each metric counter is independent, +// so no happens-before relationship is needed between them, and a snapshot +// reader tolerates slightly stale values. This matches `metrics_util`'s +// `AtomicStorage`. + +impl BucketedHistogram { + fn new(bounds: Arc<[f64]>) -> Self { + let counts = (0..bounds.len() + 1) + .map(|_| AtomicU64::new(0)) + .collect::>() + .into_boxed_slice(); + Self { + bounds, + counts, + count: AtomicU64::new(0), + sum_bits: AtomicU64::new(0), + } + } + + fn add_to_sum(&self, value: f64) { + // No atomic offers an f64 add, so read the current bit pattern, add in + // float space, and CAS it back, retrying if another thread won the race. + let mut current = self.sum_bits.load(Ordering::Relaxed); + loop { + let updated = (f64::from_bits(current) + value).to_bits(); + match self.sum_bits.compare_exchange_weak( + current, + updated, + Ordering::Relaxed, + Ordering::Relaxed, + ) { + Ok(_) => break, + Err(actual) => current = actual, + } + } + } + + /// Cumulative `le` buckets, total count, and sum at this instant. + fn snapshot(&self) -> MetricValue { + let mut cumulative = 0u64; + let mut buckets = Vec::with_capacity(self.bounds.len() + 1); + for (i, bound) in self.bounds.iter().enumerate() { + cumulative += self.counts[i].load(Ordering::Relaxed); + buckets.push((format!("{}", bound), cumulative)); + } + cumulative += self.counts[self.bounds.len()].load(Ordering::Relaxed); + buckets.push(("+Inf".to_string(), cumulative)); + MetricValue::Histogram { + buckets, + count: self.count.load(Ordering::Relaxed), + sum: f64::from_bits(self.sum_bits.load(Ordering::Relaxed)), + } + } +} + +impl metrics::HistogramFn for BucketedHistogram { + fn record(&self, value: f64) { + let idx = self.bounds.partition_point(|&bound| bound < value); + self.counts[idx].fetch_add(1, Ordering::Relaxed); + self.count.fetch_add(1, Ordering::Relaxed); + self.add_to_sum(value); + } +} + +/// Storage backing the registry. Counters and gauges are plain atomics (as in +/// `metrics_util`'s `AtomicStorage`); histograms use [`BucketedHistogram`]. +struct LanceStorage; + +impl Storage for LanceStorage { + type Counter = Arc; + type Gauge = Arc; + type Histogram = Arc; + + fn counter(&self, _key: &Key) -> Self::Counter { + Arc::new(AtomicU64::new(0)) + } + + fn gauge(&self, _key: &Key) -> Self::Gauge { + // The `metrics` facade writes the f64 bit pattern into this `u64` (the + // snapshot decodes it with `f64::from_bits`), matching `AtomicStorage`. + // `0` decodes to `0.0`, the correct initial value. + Arc::new(AtomicU64::new(0)) + } + + fn histogram(&self, key: &Key) -> Self::Histogram { + Arc::new(BucketedHistogram::new(bounds_for(key.name()))) + } +} + +struct LanceRecorder { + registry: Arc>, +} + +impl LanceRecorder { + fn describe( + &self, + key: KeyName, + kind: MetricKind, + unit: Option, + description: SharedString, + ) { + CATALOG.lock().unwrap().insert( + key.as_str().to_string(), + MetricDescription { + kind, + unit: unit.map(|u| u.as_canonical_label().to_string()), + description: description.into_owned(), + }, + ); + } +} + +impl Recorder for LanceRecorder { + fn describe_counter(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Counter, unit, description); + } + + fn describe_gauge(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Gauge, unit, description); + } + + fn describe_histogram(&self, key: KeyName, unit: Option, description: SharedString) { + self.describe(key, MetricKind::Histogram, unit, description); + } + + fn register_counter(&self, key: &Key, _metadata: &Metadata<'_>) -> Counter { + self.registry + .get_or_create_counter(key, |c| Counter::from_arc(c.clone())) + } + + fn register_gauge(&self, key: &Key, _metadata: &Metadata<'_>) -> Gauge { + self.registry + .get_or_create_gauge(key, |g| Gauge::from_arc(g.clone())) + } + + fn register_histogram(&self, key: &Key, _metadata: &Metadata<'_>) -> Histogram { + self.registry + .get_or_create_histogram(key, |h| Histogram::from_arc(h.clone())) + } +} + +/// Register the recommended histogram bounds for every metric-emitting +/// subsystem. New subsystems add their `histogram_bounds()` here. +fn register_bounds() { + let mut bounds = HISTOGRAM_BOUNDS.write().unwrap(); + for (name, values) in lance_io::object_store::metrics::histogram_bounds() { + bounds.insert((*name).to_string(), Arc::from(*values)); + } +} + +/// Describe every metric-emitting subsystem so the catalog is populated. Must +/// run after the recorder is installed. New subsystems add their +/// `describe_metrics()` here. +fn describe_all() { + lance_io::object_store::metrics::describe_metrics(); +} + +enum MetricValue { + Scalar(f64), + Histogram { + buckets: Vec<(String, u64)>, + count: u64, + sum: f64, + }, +} + +struct MetricPoint { + name: String, + kind: &'static str, + attributes: HashMap, + value: MetricValue, +} + +fn labels(key: &Key) -> HashMap { + key.labels() + .map(|label| (label.key().to_string(), label.value().to_string())) + .collect() +} + +fn collect_points(registry: &Registry) -> Vec { + let mut points = Vec::new(); + for (key, handle) in registry.get_counter_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "counter", + attributes: labels(&key), + // OpenTelemetry observations are float; counts stay well within the + // f64-exact integer range (2^53), so this cast is lossless in practice. + value: MetricValue::Scalar(handle.load(Ordering::Relaxed) as f64), + }); + } + for (key, handle) in registry.get_gauge_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "gauge", + attributes: labels(&key), + value: MetricValue::Scalar(f64::from_bits(handle.load(Ordering::Relaxed))), + }); + } + for (key, handle) in registry.get_histogram_handles() { + points.push(MetricPoint { + name: key.name().to_string(), + kind: "histogram", + attributes: labels(&key), + value: handle.snapshot(), + }); + } + points +} + +/// One metric data point exposed to Python. For counters and gauges only +/// `value` is set; for histograms `buckets` (cumulative `le` counts), `count`, +/// and `sum` are set. +#[pyclass(name = "MetricPoint", get_all)] +pub struct PyMetricPoint { + name: String, + kind: String, + attributes: HashMap, + value: Option, + buckets: Option>, + count: Option, + sum: Option, +} + +impl From for PyMetricPoint { + fn from(point: MetricPoint) -> Self { + let (value, buckets, count, sum) = match point.value { + MetricValue::Scalar(v) => (Some(v), None, None, None), + MetricValue::Histogram { + buckets, + count, + sum, + } => (None, Some(buckets), Some(count), Some(sum)), + }; + Self { + name: point.name, + kind: point.kind.to_string(), + attributes: point.attributes, + value, + buckets, + count, + sum, + } + } +} + +/// A described metric, used by the Python layer to create instruments up front. +#[pyclass(name = "MetricDescription", get_all)] +pub struct PyMetricDescription { + name: String, + kind: String, + unit: Option, + description: String, +} + +/// Install the Lance metrics recorder as the process-global `metrics` recorder. +/// +/// Returns `True` if the recorder is installed (now or previously). Returns +/// `False` if a *different* recorder is already installed — `metrics` allows +/// only one global recorder per process, so Lance cannot coexist with another. +#[pyfunction] +pub fn register_lance_metrics_recorder() -> bool { + if REGISTRY.get().is_some() { + return true; + } + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + // Register histogram bounds *before* installing the recorder. Once the + // recorder is global, another thread can emit a metric and create the + // histogram handle concurrently; if the bounds aren't registered yet that + // handle would be built with the fallback bounds and keep them for the + // life of the process. `register_bounds()` doesn't need the recorder. + register_bounds(); + match metrics::set_global_recorder(recorder) { + Ok(()) => { + let _ = REGISTRY.set(registry); + describe_all(); + true + } + Err(_) => false, + } +} + +/// The catalog of described Lance metrics. Empty until the recorder is installed. +#[pyfunction] +pub fn lance_metrics_catalog() -> Vec { + CATALOG + .lock() + .unwrap() + .iter() + .map(|(name, desc)| PyMetricDescription { + name: name.clone(), + kind: desc.kind.as_str().to_string(), + unit: desc.unit.clone(), + description: desc.description.clone(), + }) + .collect() +} + +/// A point-in-time snapshot of every recorded metric. Empty until the recorder +/// is installed. The read is lock-free but walks every series and allocates, so +/// it runs with the GIL released. +#[pyfunction] +pub fn snapshot_lance_metrics(py: Python<'_>) -> Vec { + let Some(registry) = REGISTRY.get() else { + return Vec::new(); + }; + let points = py.detach(|| collect_points(registry)); + points.into_iter().map(PyMetricPoint::from).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use metrics::HistogramFn; + + fn bucket_count(buckets: &[(String, u64)], le: &str) -> u64 { + buckets + .iter() + .find(|(b, _)| b == le) + .map(|(_, c)| *c) + .unwrap_or_else(|| panic!("no bucket with le={le}")) + } + + #[test] + fn bucketed_histogram_records_cumulative_buckets() { + let hist = BucketedHistogram::new(Arc::from([0.1f64, 1.0, 10.0].as_slice())); + hist.record(0.05); // le=0.1 + hist.record(0.5); // le=1 + hist.record(0.5); // le=1 + hist.record(50.0); // +Inf + + let MetricValue::Histogram { + buckets, + count, + sum, + } = hist.snapshot() + else { + panic!("expected histogram"); + }; + + // Buckets are cumulative (Prometheus `le` semantics). + assert_eq!(bucket_count(&buckets, "0.1"), 1); + assert_eq!(bucket_count(&buckets, "1"), 3); + assert_eq!(bucket_count(&buckets, "10"), 3); + assert_eq!(bucket_count(&buckets, "+Inf"), 4); + assert_eq!(count, 4); + assert!((sum - 51.05).abs() < 1e-9); + } + + #[test] + fn bucketed_histogram_boundary_is_inclusive() { + let hist = BucketedHistogram::new(Arc::from([1.0f64].as_slice())); + hist.record(1.0); // exactly the bound -> le=1, not +Inf + let MetricValue::Histogram { buckets, .. } = hist.snapshot() else { + panic!("expected histogram"); + }; + assert_eq!(bucket_count(&buckets, "1"), 1); + assert_eq!(bucket_count(&buckets, "+Inf"), 1); + } + + #[test] + fn bucketed_histogram_boundary_is_inclusive_mid_range() { + // A value equal to a middle bound lands in that bucket, not the next. + let hist = BucketedHistogram::new(Arc::from([0.1f64, 1.0, 10.0].as_slice())); + hist.record(1.0); + let MetricValue::Histogram { buckets, .. } = hist.snapshot() else { + panic!("expected histogram"); + }; + assert_eq!(bucket_count(&buckets, "0.1"), 0); + assert_eq!(bucket_count(&buckets, "1"), 1); + assert_eq!(bucket_count(&buckets, "10"), 1); // cumulative, so still 1 + assert_eq!(bucket_count(&buckets, "+Inf"), 1); + } + + #[test] + fn recorder_aggregates_counters_with_labels() { + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + metrics::with_local_recorder(&recorder, || { + metrics::counter!("test_requests_total", "operation" => "get", "scheme" => "s3") + .increment(2); + metrics::counter!("test_requests_total", "operation" => "get", "scheme" => "s3") + .increment(3); + // A distinct label set must produce a separate point, not merge. + metrics::counter!("test_requests_total", "operation" => "put", "scheme" => "gs") + .increment(7); + }); + + let scalar = |attrs: &[(&str, &str)]| { + let points = collect_points(®istry); + let point = points + .into_iter() + .find(|p| { + p.name == "test_requests_total" + && attrs + .iter() + .all(|(k, v)| p.attributes.get(*k).map(String::as_str) == Some(*v)) + }) + .expect("counter recorded for label set"); + assert_eq!(point.kind, "counter"); + match point.value { + MetricValue::Scalar(v) => v, + _ => panic!("expected scalar"), + } + }; + + // Same labels aggregate; distinct labels stay separate. + assert!((scalar(&[("operation", "get"), ("scheme", "s3")]) - 5.0).abs() < 1e-9); + assert!((scalar(&[("operation", "put"), ("scheme", "gs")]) - 7.0).abs() < 1e-9); + } + + #[test] + fn recorder_records_gauges() { + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + // Gauges store the f64 bit pattern in a u64; the snapshot must decode it. + metrics::with_local_recorder(&recorder, || { + metrics::gauge!("test_gauge", "scheme" => "s3").set(3.5); + }); + + let points = collect_points(®istry); + let point = points + .iter() + .find(|p| p.name == "test_gauge") + .expect("gauge recorded"); + assert_eq!(point.kind, "gauge"); + assert!(matches!(point.value, MetricValue::Scalar(v) if (v - 3.5).abs() < 1e-9)); + } + + #[test] + fn recorder_falls_back_to_default_bounds() { + // A histogram with no registered bounds uses DEFAULT_BOUNDS. + let name = "test_unregistered_histogram"; + assert!(!HISTOGRAM_BOUNDS.read().unwrap().contains_key(name)); + + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + metrics::with_local_recorder(&recorder, || { + metrics::histogram!(name).record(0.02); + }); + + let points = collect_points(®istry); + let point = points.iter().find(|p| p.name == name).expect("recorded"); + let MetricValue::Histogram { buckets, count, .. } = &point.value else { + panic!("expected histogram"); + }; + assert_eq!(*count, 1); + // DEFAULT_BOUNDS yields one bucket per bound plus the implicit `+Inf`. + assert_eq!(buckets.len(), DEFAULT_BOUNDS.len() + 1); + // 0.02 falls in the le=0.025 bucket (the third DEFAULT_BOUNDS entry). + assert_eq!(bucket_count(buckets, "0.025"), 1); + assert_eq!(bucket_count(buckets, "0.01"), 0); + assert_eq!(bucket_count(buckets, "+Inf"), 1); + } + + #[test] + fn recorder_uses_registered_histogram_bounds() { + let name = "test_recorder_bounds_seconds"; + HISTOGRAM_BOUNDS + .write() + .unwrap() + .insert(name.to_string(), Arc::from([0.1f64, 1.0].as_slice())); + + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { + registry: registry.clone(), + }; + metrics::with_local_recorder(&recorder, || { + metrics::histogram!(name).record(0.05); + metrics::histogram!(name).record(5.0); + }); + + let points = collect_points(®istry); + let point = points.iter().find(|p| p.name == name).expect("recorded"); + let MetricValue::Histogram { buckets, count, .. } = &point.value else { + panic!("expected histogram"); + }; + assert_eq!(*count, 2); + assert_eq!(bucket_count(buckets, "0.1"), 1); + assert_eq!(bucket_count(buckets, "+Inf"), 2); + } + + #[test] + fn describe_populates_catalog() { + let name = "test_describe_catalog_total"; + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { registry }; + metrics::with_local_recorder(&recorder, || { + metrics::describe_counter!(name, Unit::Count, "a test counter"); + }); + + let catalog = CATALOG.lock().unwrap(); + let desc = catalog.get(name).expect("described"); + assert!(matches!(desc.kind, MetricKind::Counter)); + assert_eq!(desc.description, "a test counter"); + } + + #[test] + fn describe_all_covers_object_store_metrics() { + use lance_io::object_store::metrics as os; + + let registry = Arc::new(Registry::new(LanceStorage)); + let recorder = LanceRecorder { registry }; + metrics::with_local_recorder(&recorder, describe_all); + + let catalog = CATALOG.lock().unwrap(); + let kind = |name: &str| catalog.get(name).expect("described").kind; + // Every emitted object store metric must be described so the OTel + // bridge can create an instrument for it, including the gauge and the + // retryable counter that a plain request path might never emit. + assert!(matches!(kind(os::METRIC_REQUESTS), MetricKind::Counter)); + assert!(matches!(kind(os::METRIC_BYTES), MetricKind::Counter)); + assert!(matches!(kind(os::METRIC_ERRORS), MetricKind::Counter)); + assert!(matches!(kind(os::METRIC_THROTTLE), MetricKind::Counter)); + assert!(matches!(kind(os::METRIC_RETRYABLE), MetricKind::Counter)); + assert!(matches!(kind(os::METRIC_IN_FLIGHT), MetricKind::Gauge)); + assert!(matches!(kind(os::METRIC_DURATION), MetricKind::Histogram)); + } +} diff --git a/python/src/reader.rs b/python/src/reader.rs index f8917d4ff53..3c57c49da51 100644 --- a/python/src/reader.rs +++ b/python/src/reader.rs @@ -14,44 +14,125 @@ // KIND, either express or implied. See the License for the // specific language governing permissions and limitations // under the License. -use std::sync::Arc; - use arrow_array::{RecordBatch, RecordBatchReader}; use arrow_schema::{ArrowError, SchemaRef}; -use futures::lock::Mutex; -use futures::stream::StreamExt; +use futures::{lock::Mutex, stream::StreamExt}; +use tokio::sync::{mpsc, oneshot}; use lance::dataset::scanner::{DatasetRecordBatchStream, Scanner as LanceScanner}; use lance_io::stream::RecordBatchStream; use crate::rt; +const READER_CHANNEL_CAPACITY: usize = 4; + +enum ReaderMessage { + Batch(Result), + Finished, +} + /// Lance's RecordBatchReader -/// This implements Arrow's RecordBatchReader trait -/// which is then used for FFI to turn this into -/// an ArrowArrayStream in the Arrow C Data Interface +/// +/// The async scan is driven by one background producer for the lifetime of the +/// reader. The synchronous Arrow C stream consumer receives batches through a +/// channel with capacity four, avoiding a runtime task spawn and cross-thread +/// rendezvous for every batch while preserving backpressure. The channel can +/// queue four batches while the producer holds at most one more pending send. pub struct LanceReader { schema: SchemaRef, - /// We wrap stream in a mutex so we can call `next` in the background - /// executor while we still have a reference to the stream on the main thread. - stream: Arc>, + receiver: std::sync::Arc>>, + cancel_sender: Option>, + finished: bool, } impl LanceReader { - pub async fn try_new(mut scanner: Arc) -> ::lance::Result { - let stream = Arc::make_mut(&mut scanner).try_into_stream().await?; + pub async fn try_new(mut scanner: std::sync::Arc) -> ::lance::Result { + let stream = std::sync::Arc::make_mut(&mut scanner) + .try_into_stream() + .await?; + Ok(Self::from_stream(stream)) + } + + pub fn from_stream(mut stream: DatasetRecordBatchStream) -> Self { let schema = stream.schema(); - Ok(Self { + let (sender, receiver) = mpsc::channel(READER_CHANNEL_CAPACITY); + let (cancel_sender, mut cancel_receiver) = oneshot::channel(); + rt().spawn_background(None, async move { + loop { + let next = tokio::select! { + biased; + _ = &mut cancel_receiver => break, + _ = sender.closed() => break, + next = stream.next() => next, + }; + let (message, terminal) = match next { + Some(Ok(batch)) => (ReaderMessage::Batch(Ok(batch)), false), + Some(Err(error)) => (ReaderMessage::Batch(Err(ArrowError::from(error))), true), + None => (ReaderMessage::Finished, true), + }; + + let sent = tokio::select! { + biased; + _ = &mut cancel_receiver => false, + _ = sender.closed() => false, + result = sender.send(message) => result.is_ok(), + }; + if !sent || terminal { + break; + } + } + }); + Self { schema, - stream: Arc::new(Mutex::new(stream)), // needs tokio Runtime - }) + receiver: std::sync::Arc::new(Mutex::new(receiver)), + cancel_sender: Some(cancel_sender), + finished: false, + } } - pub fn from_stream(stream: DatasetRecordBatchStream) -> Self { - Self { - schema: stream.schema(), - stream: Arc::new(Mutex::new(stream)), + fn finish(&mut self) { + self.cancel_sender.take(); + self.finished = true; + } + + fn cancel_producer(&mut self) { + if let Some(cancel_sender) = self.cancel_sender.take() { + let _ = cancel_sender.send(()); } + self.finished = true; + } + + fn handle_receive_result( + &mut self, + result: pyo3::PyResult>, + ) -> Option> { + match result { + Ok(Some(ReaderMessage::Batch(Ok(batch)))) => Some(Ok(batch)), + Ok(Some(ReaderMessage::Batch(Err(error)))) => { + self.finish(); + Some(Err(error)) + } + Ok(Some(ReaderMessage::Finished)) => { + self.finish(); + None + } + Ok(None) => { + self.finish(); + Some(Err(ArrowError::ExternalError(Box::new( + std::io::Error::other("Lance reader producer terminated before end of stream"), + )))) + } + Err(error) => { + self.cancel_producer(); + Some(Err(ArrowError::ExternalError(Box::new(error)))) + } + } + } +} + +impl Drop for LanceReader { + fn drop(&mut self) { + self.cancel_producer(); } } @@ -59,17 +140,26 @@ impl Iterator for LanceReader { type Item = Result; fn next(&mut self) -> Option { - let stream = self.stream.clone(); - rt().spawn(None, async move { - let mut stream = stream.lock().await; - stream.next().await - }) - .transpose() - .map(|rs| match rs { - Ok(Ok(batch)) => Ok(batch), - Ok(Err(err)) => Err(ArrowError::from(err)), - Err(err) => Err(ArrowError::ExternalError(Box::new(err))), - }) + if self.finished { + return None; + } + let receiver = self.receiver.clone(); + let recv = async move { receiver.lock().await.recv().await }; + let result = match tokio::runtime::Handle::try_current() { + Ok(handle) if handle.runtime_flavor() == tokio::runtime::RuntimeFlavor::MultiThread => { + // Tell Tokio that this worker will block before using the + // signal-aware cross-thread rendezvous. Without this, a task + // spawned onto the same runtime can remain in this worker's + // local queue and deadlock. + tokio::task::block_in_place(|| rt().spawn(None, recv)) + } + // A current-thread runtime cannot be the multi-threaded Lance + // runtime. Hand the receive to Lance's runtime instead of nesting + // block_on on the caller's runtime. + Ok(_) => rt().spawn(None, recv), + Err(_) => rt().block_on(None, recv), + }; + self.handle_receive_result(result) } } @@ -78,3 +168,253 @@ impl RecordBatchReader for LanceReader { self.schema.clone() } } + +#[cfg(test)] +mod tests { + use std::{ + sync::{Arc, mpsc::Sender}, + time::Duration, + }; + + use arrow_array::{Int32Array, RecordBatchReader, StringArray}; + use arrow_schema::{DataType, Field, Schema}; + use datafusion::{ + error::DataFusionError, + physical_plan::{SendableRecordBatchStream, stream::RecordBatchStreamAdapter}, + }; + use futures::stream; + + use super::*; + + fn make_reader( + schema: SchemaRef, + batches: impl futures::Stream> + Send + 'static, + ) -> LanceReader { + let stream: SendableRecordBatchStream = + Box::pin(RecordBatchStreamAdapter::new(schema, batches)); + LanceReader::from_stream(DatasetRecordBatchStream::new(stream)) + } + + #[test] + fn test_reader_preserves_schema_batches_and_end_of_stream() { + let schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Int32, + false, + )])); + let expected = (0..3) + .map(|batch_index| { + RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![ + batch_index * 2, + batch_index * 2 + 1, + ]))], + ) + .unwrap() + }) + .collect::>(); + let batches = stream::iter(expected.clone().into_iter().map(Ok)); + let mut reader = make_reader(schema.clone(), batches); + + assert_eq!(reader.schema(), schema); + let actual = reader.by_ref().collect::, _>>().unwrap(); + assert_eq!(actual, expected); + assert!(reader.next().is_none()); + } + + #[test] + fn test_reader_propagates_stream_errors() { + let schema = Arc::new(Schema::empty()); + let batches = stream::once(async { + Err(DataFusionError::Execution( + "expected reader error".to_string(), + )) + }) + .chain(stream::poll_fn(|_| { + panic!("the stream must not be polled after its first error"); + })); + let mut reader = make_reader(schema, batches); + + let error = reader.next().unwrap().unwrap_err(); + assert!(error.to_string().contains("expected reader error")); + assert!(reader.next().is_none()); + } + + #[test] + fn test_reader_receive_error_cancels_producer_without_drop() { + pyo3::Python::initialize(); + let schema = Arc::new(Schema::empty()); + let (drop_sender, drop_receiver) = std::sync::mpsc::channel(); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let batches = stream::once(async move { + let _drop_notify = DropNotify(drop_sender); + started_sender.send(()).ok(); + std::future::pending::>().await + }); + let mut reader = make_reader(schema, batches); + + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("the producer should poll the stream"); + let error = reader + .handle_receive_result(Err(pyo3::exceptions::PyKeyboardInterrupt::new_err( + "expected interrupt", + ))) + .unwrap() + .unwrap_err(); + assert!(error.to_string().contains("expected interrupt")); + drop_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("an interrupted receive should cancel the producer"); + assert!(reader.next().is_none()); + } + + #[test] + fn test_reader_reports_producer_panic_after_a_batch() { + let schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Int32, + false, + )])); + let expected = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + let batches = stream::iter([Ok(expected.clone())]).chain(stream::poll_fn(|_| { + panic!("expected producer panic"); + })); + let mut reader = make_reader(schema, batches); + + assert_eq!(reader.next().unwrap().unwrap(), expected); + let error = reader.next().unwrap().unwrap_err(); + assert!( + error + .to_string() + .contains("producer terminated before end of stream") + ); + assert!(reader.next().is_none()); + } + + struct DropNotify(Sender<()>); + + impl Drop for DropNotify { + fn drop(&mut self) { + self.0.send(()).ok(); + } + } + + #[test] + fn test_reader_drop_cancels_pending_stream() { + let schema = Arc::new(Schema::empty()); + let (drop_sender, drop_receiver) = std::sync::mpsc::channel(); + let (started_sender, started_receiver) = std::sync::mpsc::channel(); + let batches = stream::once(async move { + let _drop_notify = DropNotify(drop_sender); + started_sender.send(()).ok(); + std::future::pending::>().await + }); + let reader = make_reader(schema, batches); + + started_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("the producer should poll the stream"); + drop(reader); + drop_receiver + .recv_timeout(Duration::from_secs(1)) + .expect("dropping the reader should cancel and drop the producer stream"); + } + + #[test] + fn test_reader_bounds_wide_batch_read_ahead() { + let schema = Arc::new(Schema::new(vec![Field::new( + "payload", + DataType::Utf8, + false, + )])); + let (poll_sender, poll_receiver) = std::sync::mpsc::channel(); + let batch_schema = schema.clone(); + let batches = stream::unfold(0, move |batch_index| { + let poll_sender = poll_sender.clone(); + let batch_schema = batch_schema.clone(); + async move { + if batch_index == 10 { + return None; + } + poll_sender.send(batch_index).ok(); + let batch = RecordBatch::try_new( + batch_schema, + vec![Arc::new(StringArray::from_iter_values(std::iter::once( + "x".repeat(1024 * 1024), + )))], + ) + .unwrap(); + Some((Ok(batch), batch_index + 1)) + } + }); + let mut reader = make_reader(schema, batches); + + assert_eq!( + (0..5) + .map(|_| poll_receiver.recv_timeout(Duration::from_secs(1)).unwrap()) + .collect::>(), + [0, 1, 2, 3, 4] + ); + assert!( + poll_receiver + .recv_timeout(Duration::from_millis(100)) + .is_err() + ); + + assert_eq!(reader.next().unwrap().unwrap().num_rows(), 1); + assert_eq!( + poll_receiver.recv_timeout(Duration::from_secs(1)).unwrap(), + 5 + ); + } + + #[test] + fn test_reader_can_be_consumed_from_background_runtime() { + let schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Int32, + false, + )])); + let expected = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + let batches = stream::iter([Ok(expected.clone())]); + let mut reader = make_reader(schema, batches); + + let actual = rt() + .spawn(None, async move { reader.next().unwrap().unwrap() }) + .unwrap(); + assert_eq!(actual, expected); + } + + #[test] + fn test_reader_can_be_consumed_from_current_thread_runtime() { + let schema = Arc::new(Schema::new(vec![Field::new( + "value", + DataType::Int32, + false, + )])); + let expected = RecordBatch::try_new( + schema.clone(), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + let batches = stream::iter([Ok(expected.clone())]); + let mut reader = make_reader(schema, batches); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + let actual = runtime.block_on(async move { reader.next().unwrap().unwrap() }); + assert_eq!(actual, expected); + } +} diff --git a/python/src/rowids.rs b/python/src/rowids.rs new file mode 100644 index 00000000000..4b6fd1de3d4 --- /dev/null +++ b/python/src/rowids.rs @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::ops::Range; + +use arrow::array::{Array, UInt64Array, make_array}; +use arrow::compute::CastOptions; +use arrow::compute::kernels::cast::cast_with_options; +use arrow::datatypes::DataType; +use arrow::pyarrow::{FromPyArrow, ToPyArrow}; +use arrow_array::cast::AsArray; +use arrow_array::types::UInt64Type; +use arrow_data::ArrayData; +use lance_table::format::RowIdMeta; +use lance_table::rowids::{RowIdSequence, read_row_ids, write_row_ids}; +use pyo3::basic::CompareOp; +use pyo3::exceptions::{PyNotImplementedError, PyTypeError, PyValueError}; +use pyo3::prelude::*; +use pyo3::types::{PyRange, PyRangeMethods, PyTuple}; +use pyo3::{IntoPyObjectExt, intern}; + +use crate::error::PythonErrorExt; +use crate::fragment::PyRowIdMeta; + +/// The number of row ids shown in `RowIdSequence.__repr__` before eliding. +const REPR_PREVIEW_LEN: usize = 10; + +/// A sequence of stable row ids belonging to a single fragment. +#[pyclass(name = "RowIdSequence", module = "lance.fragment")] +pub struct PyRowIdSequence(pub RowIdSequence); + +#[pymethods] +impl PyRowIdSequence { + #[new] + fn new(row_ids: &Bound<'_, PyAny>) -> PyResult { + let sequence = match contiguous_range(row_ids)? { + // A `Range` segment is the most compact encoding, and taking it + // directly avoids materializing the ids of a large range. + Some(range) if range.is_empty() => RowIdSequence::new(), + Some(range) => RowIdSequence::from(range), + None => RowIdSequence::try_from_iter(extract_row_ids(row_ids)?).infer_error()?, + }; + Ok(Self(sequence)) + } + + /// Read back the sequence stored inline in fragment row id metadata. + #[staticmethod] + fn from_inline_metadata(metadata: PyRef<'_, PyRowIdMeta>) -> PyResult { + match &metadata.0 { + RowIdMeta::Inline(data) => read_row_ids(data).infer_error().map(Self), + RowIdMeta::Column => Err(PyNotImplementedError::new_err( + "Row ids stored outside the manifest cannot be read into a RowIdSequence", + )), + } + } + + /// Encode the sequence as row id metadata stored inline in the manifest. + fn to_inline_metadata(&self) -> PyRowIdMeta { + PyRowIdMeta(RowIdMeta::Inline(write_row_ids(&self.0).into())) + } + + fn to_pyarrow<'py>(&self, py: Python<'py>) -> PyResult> { + let array = UInt64Array::from(self.0.iter().collect::>()); + array.into_data().to_pyarrow(py) + } + + fn __len__(&self) -> usize { + self.0.len() as usize + } + + fn __iter__(&self, py: Python<'_>) -> PyResult> { + let row_ids: Vec = self.0.iter().collect(); + Py::new(py, PyRowIdSequenceIterator(row_ids.into_iter())) + } + + fn __repr__(&self) -> String { + let len = self.0.len(); + let preview = self + .0 + .iter() + .take(REPR_PREVIEW_LEN) + .map(|row_id| row_id.to_string()) + .collect::>() + .join(", "); + if len > REPR_PREVIEW_LEN as u64 { + format!("RowIdSequence([{}, ...], len={})", preview, len) + } else { + format!("RowIdSequence([{}])", preview) + } + } + + fn __richcmp__( + &self, + other: &Bound<'_, PyAny>, + op: CompareOp, + py: Python<'_>, + ) -> PyResult> { + let Ok(other) = other.cast::() else { + return Ok(py.NotImplemented()); + }; + let equal = self.0 == other.borrow().0; + match op { + CompareOp::Eq => equal.into_py_any(py), + CompareOp::Ne => (!equal).into_py_any(py), + _ => Ok(py.NotImplemented()), + } + } + + fn __reduce__(&self, py: Python<'_>) -> PyResult<(Py, Py)> { + let from_inline_metadata = PyModule::import(py, "lance.fragment")? + .getattr("RowIdSequence")? + .getattr("from_inline_metadata")? + .extract()?; + let metadata = Py::new(py, self.to_inline_metadata())?; + let state = PyTuple::new(py, [metadata])?.extract()?; + Ok((from_inline_metadata, state)) + } +} + +/// The row ids are materialized up front because `RowIdSequence::iter` borrows +/// the sequence and a `#[pyclass]` cannot hold a borrowing iterator. +/// +/// Refilling a bounded buffer from `RowIdSequence::slice` looks like the way to +/// avoid that, but it is quadratic: slicing takes an absolute offset, and for +/// the gapped `RangeWithHoles` and `RangeWithBitmap` encodings the slice skips +/// its prefix one element at a time. Making iteration both lazy and linear +/// needs a forward cursor over the private segments, which belongs in +/// `lance-table` rather than here. Bulk callers should use `to_pyarrow`. +#[pyclass(name = "RowIdSequenceIterator", module = "lance.fragment")] +pub struct PyRowIdSequenceIterator(std::vec::IntoIter); + +#[pymethods] +impl PyRowIdSequenceIterator { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(mut slf: PyRefMut<'_, Self>) -> Option { + slf.0.next() + } +} + +/// Recognize a `range` with a step of one, whose row ids need no materialization. +/// +/// Returns `None` for anything else, including strided and descending ranges, +/// which are handled by the general iterable path. +fn contiguous_range(ob: &Bound<'_, PyAny>) -> PyResult>> { + let Ok(range) = ob.cast::() else { + return Ok(None); + }; + // Bounds are read as `isize`. A range reaching past that is well beyond any + // dataset's row count, so it falls through to the element-wise path, which + // reads each value as a u64, rather than failing. + let (Ok(start), Ok(stop), Ok(step)) = (range.start(), range.stop(), range.step()) else { + return Ok(None); + }; + if step != 1 { + return Ok(None); + } + if start < 0 { + return Err(PyValueError::new_err(format!( + "Row ids must be non-negative, but the range starts at {}", + start + ))); + } + if stop <= start { + return Ok(Some(0..0)); + } + Ok(Some(start as u64..stop as u64)) +} + +fn extract_row_ids(ob: &Bound<'_, PyAny>) -> PyResult> { + let py = ob.py(); + if ob.hasattr(intern!(py, "__arrow_c_array__"))? { + return row_ids_from_arrow(ob); + } + // A `pyarrow.ChunkedArray`, such as a `_rowid` column taken from a table. + let chunks = intern!(py, "chunks"); + if ob.hasattr(chunks)? { + let mut row_ids = Vec::new(); + for chunk in ob.getattr(chunks)?.try_iter()? { + row_ids.extend(row_ids_from_arrow(&chunk?)?); + } + return Ok(row_ids); + } + + let iter = ob.try_iter().map_err(|_| { + PyTypeError::new_err(format!( + "Row ids must be an iterable of integers or an Arrow array, but got {}", + ob.get_type().name().map_or_else( + |_| "an object of unknown type".to_string(), + |name| name.to_string() + ) + )) + })?; + iter.map(|row_id| row_id?.extract::()).collect() +} + +fn row_ids_from_arrow(array: &Bound<'_, PyAny>) -> PyResult> { + let array = make_array(ArrayData::from_pyarrow_bound(array)?); + if !array.data_type().is_integer() { + return Err(PyTypeError::new_err(format!( + "Row ids must be an array of integers, but got an array of type {}", + array.data_type() + ))); + } + if array.null_count() > 0 { + return Err(PyValueError::new_err(format!( + "Row ids must not be null, but the array has {} null values", + array.null_count() + ))); + } + + // `safe: false` so that negative values raise instead of wrapping around + // into the top of the row id space. + let cast_options = CastOptions { + safe: false, + ..Default::default() + }; + let array = cast_with_options(&array, &DataType::UInt64, &cast_options) + .map_err(|err| PyValueError::new_err(format!("Row ids must fit in a uint64: {}", err)))?; + Ok(array.as_primitive::().values().to_vec()) +} diff --git a/python/src/scanner.rs b/python/src/scanner.rs index 1e85af6711f..9aa115f5191 100644 --- a/python/src/scanner.rs +++ b/python/src/scanner.rs @@ -33,7 +33,7 @@ use crate::schema::logical_arrow_schema; /// This will be wrapped by a python class to provide /// additional functionality -#[pyclass(name = "_Scanner", module = "_lib")] +#[pyclass(name = "_Scanner", module = "_lib", from_py_object)] #[derive(Clone)] pub struct Scanner { scanner: Arc, @@ -49,7 +49,7 @@ impl Scanner { } } -#[pyclass(name = "ScanStatistics", module = "_lib", get_all)] +#[pyclass(name = "ScanStatistics", module = "_lib", get_all, skip_from_py_object)] #[derive(Clone)] /// Statistics about the scan. pub struct ScanStatistics { @@ -66,9 +66,16 @@ pub struct ScanStatistics { pub parts_loaded: usize, /// Number of index comparisons performed pub index_comparisons: usize, + /// Number of index cache page lookups that were served from memory + pub index_cache_hits: usize, + /// Number of index cache page lookups that had to load from storage + pub index_cache_misses: usize, /// 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 debugging timings in nanoseconds. Keys may change; nested and + /// concurrent stages overlap and must not be summed as query wall time. + pub all_times: HashMap, } impl ScanStatistics { @@ -80,7 +87,11 @@ impl ScanStatistics { indices_loaded: stats.indices_loaded, parts_loaded: stats.parts_loaded, index_comparisons: stats.index_comparisons, + index_cache_hits: stats.index_cache_hits(), + index_cache_misses: stats.index_cache_misses(), all_counts: stats.all_counts.clone(), + // Keep Python callbacks consistent with native execution summaries. + all_times: stats.all_times.clone(), } } } @@ -89,8 +100,17 @@ impl ScanStatistics { impl ScanStatistics { fn __repr__(&self) -> String { format!( - "ScanStatistics(iops={}, requests={}, bytes_read={}, indices_loaded={}, parts_loaded={}, index_comparisons={}, all_counts={:?})", - self.iops, self.requests, self.bytes_read, self.indices_loaded, self.parts_loaded, self.index_comparisons, self.all_counts + "ScanStatistics(iops={}, requests={}, bytes_read={}, indices_loaded={}, parts_loaded={}, index_comparisons={}, index_cache_hits={}, index_cache_misses={}, all_counts={:?}, all_times={:?})", + self.iops, + self.requests, + self.bytes_read, + self.indices_loaded, + self.parts_loaded, + self.index_comparisons, + self.index_cache_hits, + self.index_cache_misses, + self.all_counts, + self.all_times ) } } @@ -119,14 +139,17 @@ impl Scanner { Ok(res) } - #[pyo3(signature = (*))] - fn analyze_plan(self_: PyRef<'_, Self>) -> PyResult { + #[pyo3(signature = (*, count_rows = false))] + fn analyze_plan(self_: PyRef<'_, Self>, count_rows: bool) -> PyResult { let scanner = self_.scanner.clone(); let res = rt() - .spawn( - Some(self_.py()), - async move { scanner.analyze_plan().await }, - )? + .spawn(Some(self_.py()), async move { + if count_rows { + scanner.analyze_count_plan().await + } else { + scanner.analyze_plan().await + } + })? .map_err(|err| PyValueError::new_err(err.to_string()))?; Ok(res) diff --git a/python/src/schema.rs b/python/src/schema.rs index d257c009a72..8cdc2115cd1 100644 --- a/python/src/schema.rs +++ b/python/src/schema.rs @@ -10,14 +10,14 @@ use lance_file::datatypes::{Fields, FieldsWithMeta}; use lance_file::format::pb; use prost::Message; use pyo3::{ + IntoPyObjectExt, basic::CompareOp, exceptions::{PyNotImplementedError, PyValueError}, prelude::*, types::PyTuple, - IntoPyObjectExt, }; -#[pyclass(name = "LanceField", module = "lance.schema")] +#[pyclass(name = "LanceField", module = "lance.schema", from_py_object)] #[derive(Clone)] pub struct LanceField(pub Field); @@ -72,6 +72,21 @@ impl LanceField { .filter(|&pos| pos > 0) } + /// Check if this field is part of an unenforced clustering key. + pub fn is_unenforced_clustering_key(&self) -> bool { + self.0.is_unenforced_clustering_key() + } + + /// Get the position of this field within a composite clustering key. + /// + /// Returns the 1-based position if explicitly set, or None if not part of + /// a clustering key or using schema field id ordering. + pub fn unenforced_clustering_key_position(&self) -> Option { + self.0 + .unenforced_clustering_key_position + .filter(|&pos| pos > 0) + } + pub fn to_arrow(&self) -> PyArrowType { PyArrowType((&self.0).into()) } @@ -86,7 +101,7 @@ impl LanceField { /// /// The assignment of field ids is particular to each dataset, so these schemas /// cannot be used interchangeably between datasets. -#[pyclass(name = "LanceSchema", module = "lance.schema")] +#[pyclass(name = "LanceSchema", module = "lance.schema", from_py_object)] #[derive(Clone)] pub struct LanceSchema(pub Schema); @@ -164,7 +179,8 @@ impl LanceSchema { fields: Fields(fields), metadata, }; - let schema = Schema::from(fields_with_meta); + let schema = Schema::try_from(fields_with_meta) + .map_err(|err| PyValueError::new_err(format!("Failed to reconstruct schema: {err}")))?; Ok(Self(schema)) } @@ -172,6 +188,26 @@ impl LanceSchema { Ok(self.0.fields.iter().cloned().map(LanceField).collect()) } + /// Get the unenforced primary key fields, ordered by position. + pub fn unenforced_primary_key(&self) -> Vec { + self.0 + .unenforced_primary_key() + .into_iter() + .cloned() + .map(LanceField) + .collect() + } + + /// Get the unenforced clustering key fields, ordered by position. + pub fn unenforced_clustering_key(&self) -> Vec { + self.0 + .unenforced_clustering_key() + .into_iter() + .cloned() + .map(LanceField) + .collect() + } + /// Get a field by name or path. /// /// For nested fields, use dot notation (e.g., "parent.child"). @@ -217,3 +253,14 @@ pub(crate) fn logical_arrow_schema(schema: &ArrowSchema) -> ArrowSchema { pub(crate) fn logical_schema_from_lance(schema: &Schema) -> ArrowSchema { logical_arrow_schema(&ArrowSchema::from(schema)) } + +#[pyfunction(name = "_parse_field_path")] +pub fn parse_field_path(path: &str) -> PyResult> { + lance_core::datatypes::parse_field_path(path).map_err(|e| PyValueError::new_err(e.to_string())) +} + +#[pyfunction(name = "_format_field_path")] +pub fn format_field_path(segments: Vec) -> String { + let refs: Vec<&str> = segments.iter().map(String::as_str).collect(); + lance_core::datatypes::format_field_path(&refs) +} diff --git a/python/src/session.rs b/python/src/session.rs index ceaa00b3ae9..99d3d904dcf 100644 --- a/python/src/session.rs +++ b/python/src/session.rs @@ -1,19 +1,42 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::collections::HashMap; use std::sync::Arc; -use pyo3::{pyclass, pymethods}; +use pyo3::exceptions::PyValueError; +use pyo3::types::{PyAnyMethods, PyDict, PyDictMethods, PyString}; +use pyo3::{Bound, PyAny, PyResult, pyclass, pymethods}; -use lance::dataset::{DEFAULT_INDEX_CACHE_SIZE, DEFAULT_METADATA_CACHE_SIZE}; -use lance::session::Session as LanceSession; +use lance::session::{CacheSpec, Session as LanceSession}; +use lance_core::cache::{BackendConfig, build_from_config, build_from_uri}; +use crate::object_store::PyObjectStoreRegistry; use crate::rt; /// The Session holds stateful information for a dataset. /// /// The session contains caches for opened indices and file metadata. -#[pyclass(name = "_Session", module = "_lib")] +/// +/// Parameters +/// ---------- +/// index_cache_size_bytes : int, optional +/// Capacity of the default index cache in bytes. +/// metadata_cache_size_bytes : int, optional +/// Capacity of the default metadata cache in bytes. +/// index_cache_backend : str or dict, optional +/// Custom index cache backend. Strings are backend URIs such as +/// ``"moka://?capacity=1048576"``. Dicts must contain ``"kind"`` and may +/// contain ``"options"``, for example +/// ``{"kind": "moka", "options": {"capacity": "1048576"}}``. +/// metadata_cache_backend : str or dict, optional +/// Custom metadata cache backend with the same format as +/// ``index_cache_backend``. +/// +/// ``index_cache_backend`` is mutually exclusive with +/// ``index_cache_size_bytes``. ``metadata_cache_backend`` is mutually +/// exclusive with ``metadata_cache_size_bytes``. +#[pyclass(name = "_Session", module = "_lib", from_py_object)] #[derive(Clone)] pub struct Session { pub inner: Arc, @@ -25,22 +48,154 @@ impl Session { } } +/// Turn a Python-supplied backend descriptor into an `Arc`, +/// or return `Ok(None)` when the caller did not pass one. +/// +/// Accepts: +/// * `str` — treated as a URI (`moka://?capacity=...`) and passed to +/// [`build_from_uri`]. +/// * `dict` — must have string keys `kind` (required) and `options` +/// (optional `dict[str, str]`) matching [`BackendConfig`]; passed to +/// [`build_from_config`]. +/// +/// Any other Python type is rejected with a clear `TypeError`-style +/// `PyValueError`. +/// +/// If `size_field_set` is `true` and `backend` is `Some`, both a size and a +/// backend were provided for the same cache. Rather than silently letting +/// one override the other (Proposal §7), this is rejected up-front so the +/// operator gets an actionable error. +fn resolve_cache_spec( + backend_field: &str, + backend: Option<&Bound<'_, PyAny>>, + size_field: &str, + size: Option, +) -> PyResult { + if backend.is_some() && size.is_some() { + return Err(PyValueError::new_err(format!( + "{} and {} are mutually exclusive; set one or the other", + size_field, backend_field, + ))); + } + + let Some(value) = backend else { + return Ok(size.map(CacheSpec::Size).unwrap_or(CacheSpec::Default)); + }; + + if value.cast::().is_ok() { + let uri: String = value.extract()?; + return build_from_uri(&uri) + .map(CacheSpec::Backend) + .map_err(|e| PyValueError::new_err(format!("{}: {}", backend_field, e))); + } + + if let Ok(dict) = value.cast::() { + let cfg = backend_config_from_dict(backend_field, dict)?; + return build_from_config(&cfg) + .map(CacheSpec::Backend) + .map_err(|e| PyValueError::new_err(format!("{}: {}", backend_field, e))); + } + + let type_name: String = value.get_type().getattr("__name__")?.extract()?; + Err(PyValueError::new_err(format!( + "{}: expected str (URI) or dict with 'kind'/'options' keys, got {}", + backend_field, type_name, + ))) +} + +fn backend_config_from_dict(field: &str, dict: &Bound<'_, PyDict>) -> PyResult { + for (key, _) in dict.iter() { + if key.cast::().is_err() { + return Err(PyValueError::new_err(format!( + "{}: dict keys must be strings", + field + ))); + } + let key: String = key.extract()?; + if key != "kind" && key != "options" { + return Err(PyValueError::new_err(format!( + "{}: unknown dict key {:?}; expected 'kind' or 'options'", + field, key + ))); + } + } + + let kind_obj = dict.get_item("kind")?.ok_or_else(|| { + PyValueError::new_err(format!("{}: dict must contain a 'kind' key", field)) + })?; + if kind_obj.cast::().is_err() { + return Err(PyValueError::new_err(format!( + "{}: 'kind' must be a string", + field + ))); + } + let kind: String = kind_obj.extract()?; + + let mut options: HashMap = HashMap::new(); + if let Some(options_obj) = dict.get_item("options")? { + let options_dict = options_obj.cast::().map_err(|_| { + PyValueError::new_err(format!("{}: 'options' must be a dict[str, str]", field)) + })?; + for (k, v) in options_dict.iter() { + if k.cast::().is_err() { + return Err(PyValueError::new_err(format!( + "{}: 'options' keys must be strings", + field + ))); + } + if v.cast::().is_err() { + return Err(PyValueError::new_err(format!( + "{}: 'options' values must be strings", + field + ))); + } + let key: String = k.extract()?; + let value: String = v.extract()?; + options.insert(key, value); + } + } + + let mut config = BackendConfig::new(&kind) + .map_err(|e| PyValueError::new_err(format!("{}: {}", field, e)))?; + config.options = options; + Ok(config) +} + #[pymethods] impl Session { #[new] - #[pyo3(signature=(index_cache_size_bytes=None, metadata_cache_size_bytes=None))] + #[pyo3(signature=( + index_cache_size_bytes=None, + metadata_cache_size_bytes=None, + index_cache_backend=None, + metadata_cache_backend=None, + store_registry=None, + ))] fn create( index_cache_size_bytes: Option, metadata_cache_size_bytes: Option, - ) -> Self { - let session = LanceSession::new( - index_cache_size_bytes.unwrap_or(DEFAULT_INDEX_CACHE_SIZE), - metadata_cache_size_bytes.unwrap_or(DEFAULT_METADATA_CACHE_SIZE), - Default::default(), - ); - Self { + index_cache_backend: Option>, + metadata_cache_backend: Option>, + store_registry: Option, + ) -> PyResult { + let index_cache = resolve_cache_spec( + "index_cache_backend", + index_cache_backend.as_ref(), + "index_cache_size_bytes", + index_cache_size_bytes, + )?; + let metadata_cache = resolve_cache_spec( + "metadata_cache_backend", + metadata_cache_backend.as_ref(), + "metadata_cache_size_bytes", + metadata_cache_size_bytes, + )?; + let store_registry = store_registry.map(|r| r.inner).unwrap_or_default(); + let session = + LanceSession::with_cache_backends(index_cache, metadata_cache, store_registry); + Ok(Self { inner: Arc::new(session), - } + }) } fn __repr__(&self) -> String { @@ -63,6 +218,13 @@ impl Session { self.inner.size_bytes() } + /// Return the current size of the index cache in bytes. + pub fn index_cache_size_bytes(&self) -> PyResult { + rt().block_on(None, async move { + self.inner.index_cache_stats().await.size_bytes as u64 + }) + } + /// Return whether the other session is the same as this one. pub fn is_same_as(&self, other: &Self) -> bool { Arc::ptr_eq(&self.inner, &other.inner) diff --git a/python/src/storage_options.rs b/python/src/storage_options.rs index 7e2c6011ece..30fe5e166a9 100644 --- a/python/src/storage_options.rs +++ b/python/src/storage_options.rs @@ -4,174 +4,15 @@ 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 - ))), - location: snafu::location!(), - })?; - - // 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::InvalidInput { - source: - "fetch_storage_options() must return None or a dict of string key-value pairs" - .into(), - location: snafu::location!(), - } - })?; - - // 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::InvalidInput { - source: format!("storage option keys must be strings: {}", e).into(), - location: snafu::location!(), - })?; - let value_str: String = - value - .extract() - .map_err(|e| lance_core::Error::InvalidInput { - source: format!("storage option values must be strings: {}", e) - .into(), - location: snafu::location!(), - })?; - 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 - ))), - location: snafu::location!(), - })? - } - - 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. -#[pyclass(name = "StorageOptionsAccessor")] +#[pyclass(name = "StorageOptionsAccessor", skip_from_py_object)] #[derive(Clone)] pub struct PyStorageOptionsAccessor { inner: Arc, @@ -197,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(); @@ -263,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/src/tracing.rs b/python/src/tracing.rs index 68bb4e24b7e..3aadae60060 100644 --- a/python/src/tracing.rs +++ b/python/src/tracing.rs @@ -18,37 +18,37 @@ use crate::CLIENT_VERSION; use chrono::{SecondsFormat, Utc}; use datafusion_common::HashMap; -use pyo3::pyfunction; -use pyo3::pymethods; -use pyo3::types::PyDict; -use pyo3::types::PyDictMethods; -use pyo3::types::PyTuple; use pyo3::Bound; use pyo3::IntoPyObject; use pyo3::PyErr; use pyo3::PyResult; use pyo3::Python; -use pyo3::{pyclass, Py, PyAny}; -use std::sync::atomic::AtomicBool; -use std::sync::mpsc; -use std::sync::mpsc::TryRecvError; -use std::sync::mpsc::TrySendError; +use pyo3::pyfunction; +use pyo3::pymethods; +use pyo3::types::PyDict; +use pyo3::types::PyDictMethods; +use pyo3::types::PyTuple; +use pyo3::{Py, PyAny, pyclass}; use std::sync::Arc; use std::sync::LazyLock; use std::sync::Mutex; use std::sync::RwLock; +use std::sync::atomic::AtomicBool; +use std::sync::mpsc; +use std::sync::mpsc::TryRecvError; +use std::sync::mpsc::TrySendError; use std::thread::JoinHandle; +use tracing::Event; use tracing::field::Field; use tracing::field::Visit; use tracing::span; use tracing::subscriber; -use tracing::Event; use tracing_chrome::ChromeLayer; use tracing_chrome::{ChromeLayerBuilder, TraceStyle}; +use tracing_subscriber::Registry; use tracing_subscriber::filter; use tracing_subscriber::layer::Context; use tracing_subscriber::prelude::*; -use tracing_subscriber::Registry; static SUBSCRIBER: LazyLock>>> = LazyLock::new(|| Arc::new(RwLock::new(None))); @@ -219,16 +219,21 @@ impl Visit for EventToMap { } } +fn trace_event_log_target(target: &str) -> String { + let target = target.strip_prefix("lance::").unwrap_or(target); + format!("lance::events::{target}") +} + #[derive(Clone)] pub struct LoggingPassthroughRef(Arc>>); impl LoggingPassthroughRef { fn inner_do)>(&self, f: F) { let state_guard = self.0.read().unwrap(); - if let Some(state) = state_guard.as_ref() { - if let Some(inner) = state.inner.as_ref() { - f(inner) - } + if let Some(state) = state_guard.as_ref() + && let Some(inner) = state.inner.as_ref() + { + f(inner) } } } @@ -248,7 +253,14 @@ impl tracing_subscriber::Layer for LoggingPassthroughRef { let mut fields = EventToStr::default(); event.record(&mut fields); - log::log!(target: "lance::events", state.level, "target=\"{}\" {}", event.metadata().target(), fields.str); + let log_target = trace_event_log_target(event.metadata().target()); + log::log!( + target: &log_target, + state.level, + "target=\"{}\" {}", + event.metadata().target(), + fields.str + ); if let Some(callback_sender) = state.callback_sender.as_ref() { let mut args = EventToMap::default(); diff --git a/python/src/transaction.rs b/python/src/transaction.rs index 4f57bf3dd49..b3f2311b3f4 100644 --- a/python/src/transaction.rs +++ b/python/src/transaction.rs @@ -1,29 +1,68 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use crate::bitmap::{PyBitmap, bitmap_from_iterable, is_int, value_from_py}; use crate::dataset::DatasetBasePath; use crate::schema::LanceSchema; -use crate::utils::{class_name, export_vec, extract_vec, PyLance}; +use crate::utils::{PyLance, class_name, export_vec, extract_vec}; use arrow::pyarrow::PyArrowType; use arrow_schema::Schema as ArrowSchema; use lance::dataset::transaction::{ - DataReplacementGroup, Operation, RewriteGroup, RewrittenIndex, Transaction, UpdateMap, - UpdateMapEntry, UpdateMode, + DataOverlayGroup, DataReplacementGroup, Operation, RewriteGroup, RewrittenIndex, Transaction, + UpdateMap, UpdateMapEntry, UpdateMode, UpdatedFragmentOffsets, }; use lance::datatypes::Schema; -use lance_table::format::{BasePath, DataFile, Fragment, IndexMetadata}; +use lance_table::format::overlay::{DataOverlayFile, OverlayCoverage}; +use lance_table::format::{BasePath, DataFile, Fragment, IndexFile, IndexMetadata}; use pyo3::exceptions::PyValueError; -use pyo3::types::PySet; -use pyo3::{intern, prelude::*}; use pyo3::{Bound, FromPyObject, PyAny, PyResult, Python}; +use pyo3::{intern, prelude::*}; use roaring::RoaringBitmap; use std::collections::HashMap; use std::sync::Arc; use uuid::Uuid; -// Add Index bindings -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +// IndexFile bindings +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, 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 { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let uuid = ob.getattr("uuid")?.to_string(); let name = ob.getattr("name")?.extract()?; let fields = ob.getattr("fields")?.extract()?; @@ -32,28 +71,41 @@ impl FromPyObject<'_> for PyLance { let fragment_ids = ob.getattr("fragment_ids")?; let created_at = ob.getattr("created_at")?.extract()?; - let fragment_ids_ref: &Bound<'_, PySet> = fragment_ids.downcast()?; - let fragment_bitmap = Some( - fragment_ids_ref - .into_iter() - .map(|id| id.extract::()) - .collect::>()?, - ); + let fragment_bitmap = Some(extract_fragment_bitmap(&fragment_ids)?); let base_id: Option = ob .getattr("base_id")? .extract::>()? .map(|id| id as u32); + let files: Option> = ob + .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, + }; + // Tolerate an object predating this attribute, as with `index_details` + // above: absent means the index carries no covered columns. + let covering_fields: Vec = match ob.getattr("covering_fields") { + Ok(value) => value.extract()?, + Err(_) => Vec::new(), + }; + Ok(Self(IndexMetadata { uuid: Uuid::parse_str(&uuid).map_err(|e| PyValueError::new_err(e.to_string()))?, name, fields, + covering_fields, dataset_version, fragment_bitmap, - index_details: None, + index_details, index_version, created_at, base_id, + files, })) } } @@ -71,20 +123,23 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { let uuid = self.0.uuid.to_string(); let name = &self.0.name; let fields = &self.0.fields; + let covering_fields = &self.0.covering_fields; let dataset_version = self.0.dataset_version; let index_version = self.0.index_version; - let fragment_ids = self.0.fragment_bitmap.as_ref().map_or_else( - || PySet::empty(py).unwrap(), - |bitmap| { - let set = PySet::empty(py).unwrap(); - for id in bitmap.iter() { - set.add(id).unwrap(); - } - set - }, - ); + let fragment_ids = PyBitmap::new(self.0.fragment_bitmap.clone().unwrap_or_default()); 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 index_details = self + .0 + .index_details + .as_ref() + .map(|details| (details.type_url.clone(), details.value.clone())); let cls = namespace .getattr("Index") @@ -98,6 +153,9 @@ impl<'py> IntoPyObject<'py> for PyLance<&IndexMetadata> { index_version, created_at, base_id, + files, + index_details, + covering_fields.clone(), )) } } @@ -112,8 +170,9 @@ impl<'py> IntoPyObject<'py> for PyLance { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let fragment_id = ob.getattr("fragment_id")?.extract::()?; let new_file = &ob.getattr("new_file")?.extract::>()?; @@ -132,8 +191,8 @@ impl<'py> IntoPyObject<'py> for PyLance<&DataReplacementGroup> { .and_then(|module| module.getattr(intern!(py, "LanceOperation"))) .expect("Failed to import LanceOperation namespace"); - let fragment_id = self.0 .0; - let new_file = PyLance(&self.0 .1).into_pyobject(py)?; + let fragment_id = self.0.0; + let new_file = PyLance(&self.0.1).into_pyobject(py)?; let cls = namespace .getattr("DataReplacementGroup") @@ -142,11 +201,204 @@ impl<'py> IntoPyObject<'py> for PyLance<&DataReplacementGroup> { } } +const OFFSETS_SHAPE_ERR: &str = "DataOverlayFile.offsets must be an iterable of ints (dense coverage shared by every \ + field) or an iterable of per-field int iterables (sparse coverage)"; + +// Fragment ids are just a set: accept a `Bitmap` or any other iterable of ints +// (a `set`, `list`, ...) in whatever order it yields, since `RoaringBitmap` +// defines the canonical ascending, deduplicated order. Unlike +// `DataOverlayFile.offsets` below, a repeated fragment id means nothing here, +// so it collapses rather than being rejected. +fn extract_fragment_bitmap(ob: &Bound<'_, PyAny>) -> PyResult { + if let Ok(bitmap) = ob.extract::() { + return Ok((*bitmap.0).clone()); + } + bitmap_from_iterable(ob) +} + +// Add one covered offset, rejecting a repeat. +// +// Offsets map positionally to rows of the value file — the smallest covered +// offset to row 0, the next to row 1, and so on — so a repeated offset would +// shift every later offset onto the wrong row, producing wrong values with no +// error at read time. A `RoaringBitmap` is a set and would swallow the repeat, +// so it has to be caught on the way in. +fn insert_offset(offsets: &mut RoaringBitmap, item: &Bound<'_, PyAny>) -> PyResult<()> { + let offset = value_from_py(item)?; + if !offsets.insert(offset) { + return Err(PyValueError::new_err(format!( + "DataOverlayFile.offsets must not repeat an offset, but {offset} appears more than \ + once; each offset maps positionally to a row of the overlay's value file" + ))); + } + Ok(()) +} + +// Collect one field's covered offsets: a `Bitmap` (shared, not copied) or any +// iterable of ints. +fn extract_offsets(ob: &Bound<'_, PyAny>) -> PyResult> { + if let Ok(bitmap) = ob.extract::() { + // Already a set, so it cannot carry a repeat. + return Ok(bitmap.0); + } + let mut offsets = RoaringBitmap::new(); + for item in ob + .try_iter() + .map_err(|_| PyValueError::new_err(OFFSETS_SHAPE_ERR))? + { + insert_offset(&mut offsets, &item?)?; + } + Ok(Arc::new(offsets)) +} + +// Resolve `offsets` into a dense (one coverage shared by every field) or +// sparse (one per field) `OverlayCoverage`, deciding by shape: a `Bitmap` or +// an iterable of ints is dense, an iterable of `Bitmap`s/int iterables is +// sparse. +// +// The items are drained into a `Vec` before the shape is decided, because +// `offsets` may be a one-shot iterable (a generator). Probing the dense shape +// by iterating and then re-iterating for the sparse shape would silently drop +// whatever the first pass consumed — committing truncated or empty coverage +// that only fails much later, at read time. +fn extract_coverage(offsets: &Bound<'_, PyAny>) -> PyResult { + if let Ok(bitmap) = offsets.extract::() { + return Ok(OverlayCoverage::Shared(bitmap.0)); + } + let items = offsets + .try_iter() + .map_err(|_| PyValueError::new_err(OFFSETS_SHAPE_ERR))? + .collect::>>()?; + let Some(first) = items.first() else { + // An empty iterable can't distinguish the two shapes; an empty dense + // coverage and an empty sparse one describe the same (no) rows. + return Ok(OverlayCoverage::Shared(Arc::new(RoaringBitmap::new()))); + }; + // The first item picks the shape, and every remaining item must match it — + // a mixed iterable like `[0, [1]]` is a caller mistake, not a shape. Only + // that mismatch is reported as a shape error; a bad offset within the + // chosen shape keeps the error that names it. + if is_int(first)? { + let mut dense = RoaringBitmap::new(); + for item in &items { + if !is_int(item)? { + return Err(PyValueError::new_err(OFFSETS_SHAPE_ERR)); + } + insert_offset(&mut dense, item)?; + } + Ok(OverlayCoverage::Shared(Arc::new(dense))) + } else { + let per_field = items + .iter() + .map(|item| { + if is_int(item)? { + return Err(PyValueError::new_err(OFFSETS_SHAPE_ERR)); + } + extract_offsets(item) + }) + .collect::>>()?; + Ok(OverlayCoverage::PerField(per_field)) + } +} + +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { + let data_file = ob.getattr("data_file")?.extract::>()?.0; + let offsets = ob.getattr("offsets")?; + + let coverage = extract_coverage(&offsets)?; + + // Present (and preserved) when round-tripping an existing fragment's + // overlays; None/0 when creating an overlay to commit, since the + // DataOverlay commit stamps the effective version. + let committed_version = ob + .getattr("committed_version")? + .extract::>()? + .unwrap_or(0); + + Ok(Self(DataOverlayFile { + data_file, + coverage, + committed_version, + })) + } +} + +impl<'py> IntoPyObject<'py> for PyLance<&DataOverlayFile> { + 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")) + .and_then(|module| module.getattr(intern!(py, "LanceOperation"))) + .expect("Failed to import LanceOperation namespace"); + + let data_file = PyLance(&self.0.data_file).into_pyobject(py)?; + let cls = namespace + .getattr("DataOverlayFile") + .expect("Failed to get DataOverlayFile class"); + + let committed_version = self.0.committed_version; + + // Mirror the read side: a dense overlay becomes a single Bitmap, a + // sparse overlay a list of per-field Bitmaps. + match &self.0.coverage { + OverlayCoverage::Shared(bitmap) => { + // `bitmap` is already an `Arc` — cloning it is a + // cheap refcount bump, not a deep copy. + let offsets = PyBitmap(bitmap.clone()); + cls.call1((data_file, offsets, committed_version)) + } + OverlayCoverage::PerField(bitmaps) => { + let offsets: Vec = bitmaps.iter().cloned().map(PyBitmap).collect(); + cls.call1((data_file, offsets, committed_version)) + } + } + } +} + +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { + let fragment_id = ob.getattr("fragment_id")?.extract::()?; + let overlays = extract_vec(&ob.getattr("overlays")?)?; + Ok(Self(DataOverlayGroup { + fragment_id, + overlays, + })) + } +} + +impl<'py> IntoPyObject<'py> for PyLance<&DataOverlayGroup> { + 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")) + .and_then(|module| module.getattr(intern!(py, "LanceOperation"))) + .expect("Failed to import LanceOperation namespace"); + + let fragment_id = self.0.fragment_id; + let overlays = export_vec(py, self.0.overlays.as_slice())?; + + let cls = namespace + .getattr("DataOverlayGroup") + .expect("Failed to get DataOverlayGroup class"); + cls.call1((fragment_id, overlays)) + } +} + #[derive(Debug, Clone)] pub struct PyUpdateMode(pub UpdateMode); -impl FromPyObject<'_> for PyUpdateMode { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyUpdateMode { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let mode_str: String = ob.extract()?; match mode_str.as_str() { "rewrite_rows" => Ok(Self(UpdateMode::RewriteRows)), @@ -159,9 +411,10 @@ impl FromPyObject<'_> for PyUpdateMode { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { - match class_name(ob)?.as_str() { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { + match class_name(&ob)?.as_str() { "Overwrite" => { let schema = extract_schema(&ob.getattr("new_schema")?)?; @@ -224,15 +477,41 @@ impl FromPyObject<'_> for PyLance { .ok() .map(|py_mode| py_mode.0); + // Absent on objects predating the field. + let updated_fragment_offsets = ob + .getattr("updated_fragment_offsets") + .ok() + .map(|v| v.extract::>>>()) + .transpose()? + .flatten() + .map(|offsets| { + offsets + .into_iter() + .map(|(frag_id, bytes)| { + RoaringBitmap::deserialize_from(&bytes[..]) + .map(|bitmap| (frag_id, bitmap)) + .map_err(|e| { + PyValueError::new_err(format!( + "updated_fragment_offsets[{frag_id}]: invalid \ + portable RoaringBitmap bytes: {e}" + )) + }) + }) + .collect::>>() + }) + .transpose()? + .map(UpdatedFragmentOffsets); + let op = Operation::Update { removed_fragment_ids, updated_fragments, new_fragments, fields_modified, - merged_generations: vec![], + compacted_sstables: vec![], fields_for_preserving_frag_bitmap, update_mode, inserted_rows_filter: None, + updated_fragment_offsets, }; Ok(Self(op)) } @@ -244,7 +523,18 @@ impl FromPyObject<'_> for PyLance { .extract::>>()?; let fragments = fragments.into_iter().map(|f| f.0).collect(); - let op = Operation::Merge { schema, fragments }; + // Absent on objects predating the field: no assertion, which + // conservatively conflicts. + let preserves_nullability = ob + .getattr("preserves_nullability") + .and_then(|v| v.extract()) + .unwrap_or(false); + + let op = Operation::Merge { + schema, + fragments, + preserves_nullability, + }; Ok(Self(op)) } "Restore" => { @@ -252,6 +542,21 @@ impl FromPyObject<'_> for PyLance { let op = Operation::Restore { version }; Ok(Self(op)) } + "Clone" => { + let is_shallow = ob.getattr("is_shallow")?.extract()?; + let ref_name = ob.getattr("ref_name")?.extract()?; + let ref_version = ob.getattr("ref_version")?.extract()?; + let ref_path = ob.getattr("ref_path")?.extract()?; + let branch_name = ob.getattr("branch_name")?.extract()?; + let op = Operation::Clone { + is_shallow, + ref_name, + ref_version, + ref_path, + branch_name, + }; + Ok(Self(op)) + } "Rewrite" => { let groups = extract_vec(&ob.getattr("groups")?)?; let rewritten_indices = extract_vec(&ob.getattr("rewritten_indices")?)?; @@ -283,10 +588,26 @@ impl FromPyObject<'_> for PyLance { Ok(Self(op)) } + "DataOverlay" => { + let groups = extract_vec(&ob.getattr("groups")?)?; + + let op = Operation::DataOverlay { groups }; + + Ok(Self(op)) + } "Project" => { let schema = extract_schema(&ob.getattr("schema")?)?; - - let op = Operation::Project { schema }; + // Absent on objects predating the field: no assertion, which + // conservatively conflicts. + let preserves_nullability = ob + .getattr("preserves_nullability") + .and_then(|v| v.extract()) + .unwrap_or(false); + + let op = Operation::Project { + schema, + preserves_nullability, + }; Ok(Self(op)) } "UpdateConfig" => { @@ -300,17 +621,17 @@ impl FromPyObject<'_> for PyLance { let field_metadata_updates_py = ob.getattr("field_metadata_updates")?; let mut field_metadata_updates = HashMap::new(); - if !field_metadata_updates_py.is_none() { - if let Ok(items) = field_metadata_updates_py.call_method0("items") { - for item in items.try_iter()? { - let item = item?; - // Extract as a tuple and then get individual elements - let tuple = item.downcast::()?; - let field_id = tuple.get_item(0)?.extract::()?; - let update_map = tuple.get_item(1)?; - if let Some(map) = extract_update_map(&update_map)? { - field_metadata_updates.insert(field_id, map); - } + if !field_metadata_updates_py.is_none() + && let Ok(items) = field_metadata_updates_py.call_method0("items") + { + for item in items.try_iter()? { + let item = item?; + // Extract as a tuple and then get individual elements + let tuple = item.cast::()?; + let field_id = tuple.get_item(0)?.extract::()?; + let update_map = tuple.get_item(1)?; + if let Some(map) = extract_update_map(&update_map)? { + field_metadata_updates.insert(field_id, map); } } } @@ -342,7 +663,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { .expect("Failed to import LanceOperation namespace"); match self.0 { - Operation::Append { ref fragments } => { + Operation::Append { fragments } => { let fragments = export_vec(py, fragments.as_slice())?; let cls = namespace .getattr("Append") @@ -350,9 +671,9 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { cls.call1((fragments,)) } Operation::Overwrite { - ref fragments, - ref schema, - ref initial_bases, + fragments, + schema, + initial_bases, .. } => { let fragments_py = export_vec(py, fragments.as_slice())?; @@ -384,6 +705,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { fields_modified, fields_for_preserving_frag_bitmap, update_mode, + updated_fragment_offsets, .. } => { let removed_fragment_ids = removed_fragment_ids.into_pyobject(py)?; @@ -401,6 +723,21 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { }, None => "rewrite_rows", }; + let updated_fragment_offsets = + updated_fragment_offsets + .as_ref() + .map(|UpdatedFragmentOffsets(offsets)| { + offsets + .iter() + .map(|(frag_id, bitmap)| { + let mut buf = Vec::with_capacity(bitmap.serialized_size()); + bitmap + .serialize_into(&mut buf) + .expect("RoaringBitmap serialization cannot fail"); + (*frag_id, buf) + }) + .collect::>>() + }); let cls = namespace .getattr("Update") .expect("Failed to get Update class"); @@ -411,6 +748,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { fields_modified, fields_for_preserving_frag_bitmap, update_mode, + updated_fragment_offsets, )) } Operation::DataReplacement { replacements } => { @@ -420,6 +758,13 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { .expect("Failed to get DataReplacement class"); cls.call1((replacements,)) } + Operation::DataOverlay { groups } => { + let groups = export_vec(py, groups.as_slice())?; + let cls = namespace + .getattr("DataOverlay") + .expect("Failed to get DataOverlay class"); + cls.call1((groups,)) + } Operation::Delete { updated_fragments, deleted_fragment_ids, @@ -433,15 +778,16 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { cls.call1((updated_fragments, deleted_fragment_ids, predicate)) } Operation::Merge { - ref fragments, - ref schema, + fragments, + schema, + preserves_nullability, } => { let fragments_py = export_vec(py, fragments.as_slice())?; let schema_py = LanceSchema(schema.clone()); let cls = namespace .getattr("Merge") .expect("Failed to get Merge class"); - cls.call1((fragments_py, schema_py)) + cls.call1((fragments_py, schema_py, *preserves_nullability)) } Operation::Restore { version } => { let cls = namespace @@ -449,9 +795,21 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { .expect("Failed to get Restore class"); cls.call1((version,)) } + Operation::Clone { + is_shallow, + ref_name, + ref_version, + ref_path, + branch_name, + } => { + let cls = namespace + .getattr("Clone") + .expect("Failed to get Clone class"); + cls.call1((is_shallow, ref_name, ref_version, ref_path, branch_name)) + } Operation::Rewrite { - ref groups, - ref rewritten_indices, + groups, + rewritten_indices, .. } => { let groups_py = export_vec(py, groups.as_slice())?; @@ -462,8 +820,9 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { cls.call1((groups_py, rewritten_indices_py)) } Operation::CreateIndex { - ref new_indices, - ref removed_indices, + new_indices, + removed_indices, + .. } => { let new_indices_py = export_vec(py, new_indices.as_slice())?; let removed_indices_py = export_vec(py, removed_indices.as_slice())?; @@ -473,12 +832,15 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { .expect("Failed to get CreateIndex class"); cls.call1((new_indices_py, removed_indices_py)) } - Operation::Project { ref schema } => { + Operation::Project { + schema, + preserves_nullability, + } => { let schema_py = LanceSchema(schema.clone()); let cls = namespace .getattr("Project") .expect("Failed to get Project class"); - cls.call1((schema_py,)) + cls.call1((schema_py, *preserves_nullability)) } Operation::ReserveFragments { num_fragments } => { if let Ok(cls) = namespace.getattr("ReserveFragments") { @@ -489,10 +851,10 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { } } Operation::UpdateConfig { - ref config_updates, - ref table_metadata_updates, - ref schema_metadata_updates, - ref field_metadata_updates, + config_updates, + table_metadata_updates, + schema_metadata_updates, + field_metadata_updates, } => { if let Ok(cls) = namespace.getattr("UpdateConfig") { let config = export_update_map(py, config_updates)?; @@ -517,7 +879,7 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { base_op.call0() } } - Operation::UpdateBases { ref new_bases } => { + Operation::UpdateBases { new_bases } => { if let Ok(cls) = namespace.getattr("UpdateBases") { use crate::dataset::DatasetBasePath; let new_bases_py: Vec = new_bases @@ -531,13 +893,17 @@ impl<'py> IntoPyObject<'py> for PyLance<&Operation> { base_op.call0() } } - _ => todo!(), + unsupported => Err(PyValueError::new_err(format!( + "Unsupported operation: {}", + unsupported.name() + ))), } } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &pyo3::Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let read_version = ob.getattr("read_version")?.extract()?; let uuid = ob.getattr("uuid")?.extract()?; let operation = ob.getattr("operation")?.extract::>()?.0; @@ -595,8 +961,9 @@ impl<'py> IntoPyObject<'py> for PyLance { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { Ok(Self(RewriteGroup { old_fragments: extract_vec(&ob.getattr("old_fragments")?)?, new_fragments: extract_vec(&ob.getattr("new_fragments")?)?, @@ -623,8 +990,9 @@ impl<'py> IntoPyObject<'py> for PyLance<&RewriteGroup> { } } -impl FromPyObject<'_> for PyLance { - fn extract_bound(ob: &Bound<'_, PyAny>) -> PyResult { +impl FromPyObject<'_, '_> for PyLance { + type Error = PyErr; + fn extract(ob: Borrowed<'_, '_, PyAny>) -> PyResult { let old_id: String = ob.getattr("old_id")?.extract()?; let new_id: String = ob.getattr("new_id")?.extract()?; let old_id = Uuid::parse_str(&old_id) @@ -642,6 +1010,7 @@ impl FromPyObject<'_> for PyLance { value: new_details_value, }, new_index_version, + new_index_files: None, })) } } @@ -711,7 +1080,7 @@ fn export_update_map(py: Python<'_>, update_map: &Option) -> PyResult } fn extract_schema(schema: &Bound<'_, PyAny>) -> PyResult { - match schema.downcast::() { + match schema.cast::() { Ok(schema) => Ok(schema.borrow().0.clone()), Err(_) => { let arrow_schema = schema.extract::>()?.0; diff --git a/python/src/utils.rs b/python/src/utils.rs index b44e357bf36..5b3801e79aa 100644 --- a/python/src/utils.rs +++ b/python/src/utils.rs @@ -19,17 +19,16 @@ use crate::rt; use arrow::compute::concat; use arrow::datatypes::Float32Type; use arrow::pyarrow::{FromPyArrow, ToPyArrow}; -use arrow_array::{cast::AsArray, Array, FixedSizeListArray, Float32Array, UInt32Array}; +use arrow_array::{Array, FixedSizeListArray, Float32Array, UInt32Array, cast::AsArray}; use arrow_data::ArrayData; use arrow_schema::DataType; -use lance::datatypes::Schema; use lance::Result; +use lance::datatypes::Schema; use lance_arrow::FixedSizeListArrayExt; -use lance_file::previous::writer::FileWriter as PreviousFileWriter; -use lance_index::scalar::IndexWriter; -use lance_index::vector::hnsw::{builder::HnswBuildParams, HNSW}; +use lance_file::versions::v1::writer::FileWriter as V1FileWriter; +use lance_index::vector::hnsw::{HNSW, builder::HnswBuildParams}; use lance_index::vector::kmeans::{ - compute_partitions, KMeans as LanceKMeans, KMeansAlgoFloat, KMeansParams, + KMeans as LanceKMeans, KMeansAlgoFloat, KMeansParams, compute_partitions, }; use lance_index::vector::v3::subindex::IvfSubIndex; use lance_linalg::distance::DistanceType; @@ -37,10 +36,10 @@ use lance_table::io::manifest::ManifestDescribing; use pyo3::intern; use pyo3::types::PyNone; use pyo3::{ + IntoPyObjectExt, exceptions::{PyIOError, PyRuntimeError, PyValueError}, prelude::*, types::PyIterator, - IntoPyObjectExt, }; /// A wrapper around a JSON string that converts to a Python object @@ -243,7 +242,7 @@ impl Hnsw { let mut writer = rt() .block_on( Some(py), - PreviousFileWriter::::try_new( + V1FileWriter::::try_new( &object_store, &path, Schema::try_from(HNSW::schema().as_ref()) @@ -255,7 +254,7 @@ impl Hnsw { rt().block_on(Some(py), async { let batch = self.hnsw.to_batch()?; let metadata = batch.schema_ref().metadata().clone(); - writer.write_record_batch(batch).await?; + writer.write(&[batch]).await?; writer.finish_with_metadata(&metadata).await?; Result::Ok(()) })? @@ -274,9 +273,9 @@ impl Hnsw { pub struct PyLance(pub T); /// Extract a Vec of PyLance types from a Python object. -pub fn extract_vec<'a, T>(ob: &Bound<'a, PyAny>) -> PyResult> +pub fn extract_vec<'py, T>(ob: &Bound<'py, PyAny>) -> PyResult> where - PyLance: FromPyObject<'a>, + PyLance: FromPyObjectOwned<'py>, { ob.extract::>>() .map(|v| v.into_iter().map(|t| t.0).collect()) diff --git a/python/uv.lock b/python/uv.lock index 8f65fa0f636..5e4653040d5 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -1,35 +1,26 @@ version = 1 revision = 3 -requires-python = ">=3.9" +requires-python = ">=3.10" resolution-markers = [ - "python_full_version >= '3.13'", + "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.*'", - "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, 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, upload-time = "2025-07-03T09:31:42.253Z" }, + "python_full_version < '3.11'", ] [[package]] name = "aiohappyeyeballs" -version = "2.6.1" +version = "2.7.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, upload-time = "2025-03-12T01:42:48.764Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, ] [[package]] name = "aiohttp" -version = "3.12.15" +version = "3.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiohappyeyeballs" }, @@ -39,95 +30,129 @@ dependencies = [ { name = "frozenlist" }, { name = "multidict" }, { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, { name = "yarl" }, ] -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/82/78/8ea7308cac6934de8c74a14f3d5f65d1c89287426688be79538d0e5c013d/aiohttp-3.14.1.tar.gz", hash = "sha256:307f2cff90a764d329e77040603fa032db89c5c24fdad50c4c15334cba744035", size = 7955794, upload-time = "2026-06-07T21:09:35.529Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/67/58ded4b3f2e10f94972d8928050c85330e249a31dd45a0e5f3c0e9c3fa05/aiohttp-3.14.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8f6bb621e5863cfe8fe5ff5468002d200ec31f30f1280b259dc505b02595099e", size = 766140, upload-time = "2026-06-07T21:05:37.471Z" }, + { url = "https://files.pythonhosted.org/packages/18/68/4ae5b4e08943f316594bb68da89957d3baf5760588fa09509594bd777e4b/aiohttp-3.14.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:4f7215cb3933784f79ed20e5f050e15984f390424339b22375d5a53c933a0491", size = 519430, upload-time = "2026-06-07T21:05:40.751Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c1/316c8f3549dbe5245f92bfd523ec6f32dd4d98cafe21df3f6a19b1184c75/aiohttp-3.14.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d9d4e294455b23a68c9b8f042d0e8e377a265bcb15332753695f6e5b6819e0ce", size = 514406, upload-time = "2026-06-07T21:05:42.111Z" }, + { url = "https://files.pythonhosted.org/packages/5a/ee/fb0ac28684e8d753b83c8a4eebc19a5846912aa0a4daaabb6a9936363840/aiohttp-3.14.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b238af795833d5731d049d82bc84b768ae6f8f97f0495963b3ed9935c5901cc3", size = 1703649, upload-time = "2026-06-07T21:05:43.427Z" }, + { url = "https://files.pythonhosted.org/packages/3b/57/aa2beab673331f111885db8a7b69dfe3ab0e53e446a0ace18ca694b4dc58/aiohttp-3.14.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e4e5e0ae56914ecdbf446493addefc0159053dd53962cef37d7839f37f73d505", size = 1675126, upload-time = "2026-06-07T21:05:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/47/ea/dad128abe365e79be03b16ed464198ac73e0d257e8260c6f7d6f31cbef26/aiohttp-3.14.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:092e4ce3619a7c6dee52a6bdabda973d9b34b66781f840ce93c7e0cec30cf521", size = 1771558, upload-time = "2026-06-07T21:05:46.405Z" }, + { url = "https://files.pythonhosted.org/packages/63/f3/b5b4e10327cb85d34d24232c6b71b64602f190b3ccb238a043ac6b187dac/aiohttp-3.14.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb33777ea21e8b7ecde0e6fc84f598be0a1192eab1a63bc746d75aa75d38e7bd", size = 1856631, upload-time = "2026-06-07T21:05:47.844Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/93294c3045775c708ac8310eb3d3622a11d2951345ad590d532d62a1faa4/aiohttp-3.14.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23119f8fd4f5d16902ed459b63b100bcd269628075162bddac56cc7b5273b3fb", size = 1714139, upload-time = "2026-06-07T21:05:49.982Z" }, + { url = "https://files.pythonhosted.org/packages/29/c4/93067c85a0373492ce8e577435203c5947c454af074ac48ed4f3a1b9dd4a/aiohttp-3.14.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:57fc6745a4b7d0f5a9eb4f40a69718be6c0bc1b8368cc9fe89e90118719f4f42", size = 1588321, upload-time = "2026-06-07T21:05:51.431Z" }, + { url = "https://files.pythonhosted.org/packages/c4/39/9ff91aaf02af8b7b8222a987466da539f154c3e01732c22b5f5a20a8ee66/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:6fd35beba67c4183b09375c5fff9accb47524191a244a99f95fd4472f5402c2b", size = 1670375, upload-time = "2026-06-07T21:05:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e4/77452a3676b8d99ac1375f77691d6bf65ea6e9f4b201b82ef77c916dc767/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:672b9d65f42eb877f5c3f234a4547e4e1a226ca8c2eed879bb34670a0ce51192", size = 1690933, upload-time = "2026-06-07T21:05:54.902Z" }, + { url = "https://files.pythonhosted.org/packages/7d/84/b0059a7c7fc05ea23f3bc1596ba91c12f79588b9450564a24cac37536d0a/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:24ba13339fed9251d9b1a1bec8c7ab84c0d1675d79d33501e11f94f8b9a84e05", size = 1740798, upload-time = "2026-06-07T21:05:56.458Z" }, + { url = "https://files.pythonhosted.org/packages/8f/3a/e2a513ecbfc362591caa51a7f7e011b3bfc8938b388ae44cd95560d36999/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:94da27378da0610e341c4d30de29a191672683cc82b8f9556e8f7c7212a020fe", size = 1576412, upload-time = "2026-06-07T21:05:57.953Z" }, + { url = "https://files.pythonhosted.org/packages/a1/10/08f1654f538f93d36dcac66310a06eefce4641cdafca83f9f0a5317be254/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:52cdac9432d8b4a719f35094a818d95adcae0f0b4fe9b9b921909e0c87de9e7d", size = 1750199, upload-time = "2026-06-07T21:05:59.488Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/d91b70c57d8b8e9611e4a2e52238ca3698d3dc1c2efe25b7a9bf594ac584/aiohttp-3.14.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:672ac254412a24d0d0cf00a9e6c238877e4be5e5fa2d188832c1244f45f31966", size = 1699356, upload-time = "2026-06-07T21:06:01.131Z" }, + { url = "https://files.pythonhosted.org/packages/3d/f1/15340176f35ff61b95dbe34020bcf43f9e624a2d7bbac934715ff97d2033/aiohttp-3.14.1-cp310-cp310-win32.whl", hash = "sha256:2fe3607e71acc6ebb0ec8e492a247bf7a291226192dc0084236dfc12478916f6", size = 458939, upload-time = "2026-06-07T21:06:02.86Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c2/a2f1ec5b37f903109e43ae2862268cfe4a67a60c1b2cf43169fcdff5995f/aiohttp-3.14.1-cp310-cp310-win_amd64.whl", hash = "sha256:30099eda75a53c32efb0920e9c33c195314d2cc1c680fbfd30894932ac5f27df", size = 482583, upload-time = "2026-06-07T21:06:04.666Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/7b56f6732ef79530afaa72aa335d41b67c8d79b946995f0b11ad72985435/aiohttp-3.14.1-cp310-cp310-win_arm64.whl", hash = "sha256:5a837f49d901f9e368651b676912bff1104ed8c1a83b280bcd7b29adccef5c9c", size = 453470, upload-time = "2026-06-07T21:06:06.322Z" }, + { url = "https://files.pythonhosted.org/packages/26/dd/bf526e6f0a1120dd6f2df2e97bacfe4d358f13d17a0ff5847301a1375a51/aiohttp-3.14.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:aa00140699487bd435fde4342d85c94cb256b7cd3a5b9c3396c67f19922afda2", size = 765225, upload-time = "2026-06-07T21:06:07.957Z" }, + { url = "https://files.pythonhosted.org/packages/8f/e1/a2872aa55495a70f61310d411541c6ee23812d9a884e000c716e1bc3edbf/aiohttp-3.14.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1c1af67559445498b502030c35c59db59966f47041ca9de5b4e707f86bd10b5f", size = 518743, upload-time = "2026-06-07T21:06:09.749Z" }, + { url = "https://files.pythonhosted.org/packages/5b/e7/c60c7b209e509cc787de3cea0550a518538cfc08003e1c1e14c1c63fff71/aiohttp-3.14.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d44ec478e713ee7f29b439f7eb8dc2b9d4079e11ae114d2c2ac3d5daf30516c8", size = 514139, upload-time = "2026-06-07T21:06:11.26Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/614ace2f579702c9840ab1e1447fd8509e35b0b904f7196418fa2f57b25d/aiohttp-3.14.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d3b1a184a9a8f548a6b73f1e26b96b052193e4b3175ed7342aaf1151a1f00a04", size = 1784088, upload-time = "2026-06-07T21:06:12.887Z" }, + { url = "https://files.pythonhosted.org/packages/49/e0/726e90f99542bf292f81a96a12cc4847deb86f3ccf62c6f4014a201f4d33/aiohttp-3.14.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5f2504bc0322437c9a1ff6d3333ca56c7477b727c995f036b976ae17b98372c8", size = 1737835, upload-time = "2026-06-07T21:06:14.564Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4b/d176d5c4db9d33dacf0543102ea59503bc1d528af4cfd0b719949ca49389/aiohttp-3.14.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73f05ea02013e02512c3bf42714f1208c57168c779cc6fe23516e4543089d0a6", size = 1842801, upload-time = "2026-06-07T21:06:16.228Z" }, + { url = "https://files.pythonhosted.org/packages/dc/d6/5a99b563690ea0cbed912ae94a2ce33993a5709a651a3a4fe761e7dd973a/aiohttp-3.14.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:797457503c2d426bee06eef808d07b31ede30b65e054444e7de64cad0061b7af", size = 1929992, upload-time = "2026-06-07T21:06:17.947Z" }, + { url = "https://files.pythonhosted.org/packages/76/7f/a987b14a3859094b3cea3f4825219c3e5536242564af6e3f9c2f6c994eb2/aiohttp-3.14.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b821a1f7dedf7e37450654e620038ac3b2e81e8fa6ea269337e97101978ec730", size = 1786989, upload-time = "2026-06-07T21:06:19.677Z" }, + { url = "https://files.pythonhosted.org/packages/f1/1a/420e5c85a3e73349372ed22ce0b6af86bfa6ce16a4b20a64a2e94608c781/aiohttp-3.14.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4cd96b5ba05d67ed0cf00b5b405c8cd99586d8e3481e8ee0a831057591af7621", size = 1640129, upload-time = "2026-06-07T21:06:22.558Z" }, + { url = "https://files.pythonhosted.org/packages/a7/80/18a592ed3be0a402cc03670bd72ee1f8563ddbe1d8d5542dbf868f274136/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d459b98a932296c6f0e94f87511a0b1b90a8a02c30a50e60a297619cd5a58ee", size = 1756576, upload-time = "2026-06-07T21:06:24.8Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0b/8b3d5713373858ff71a617daf6e3b0e81ad63e79d09a3cf2f6b6b983939c/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:764457a7be60825fb770a644852ff717bcbb5042f189f2bd16df61a81b3f6573", size = 1754668, upload-time = "2026-06-07T21:06:26.528Z" }, + { url = "https://files.pythonhosted.org/packages/9f/49/fd564575cf225821d7ba5a117cb8bc27213d8a7e1811162afb43ae077039/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f7a16ef45b081454ef844502d87a848876c490c4cb5c650c230f6ec79ed2c1e7", size = 1817019, upload-time = "2026-06-07T21:06:28.297Z" }, + { url = "https://files.pythonhosted.org/packages/ed/1b/e850c9ae6fc91356552ae668bb6c51e93fa29c8aef13398a10b56678557f/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2fbc3ed048b3475b9f0cbcb9978e9d2d3511acd91ead203af26ed9f0056004cf", size = 1631638, upload-time = "2026-06-07T21:06:30.242Z" }, + { url = "https://files.pythonhosted.org/packages/eb/94/3c337ba72451a89806ace6f75bddc92bafc5b8d53d90115a512858024b63/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:bedb0cd073cc2dc035e30aeb99444389d3cd2113afe4ef9fcd23d439f5bade85", size = 1835660, upload-time = "2026-06-07T21:06:31.943Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9c/9c18cf367a0498212d9ba7daf990b504a5e8ae064cda4b504e2647c89c03/aiohttp-3.14.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b6feea921016eb3d4e04d65fc4e9ca402d1a3801f562aef94989f54694917af3", size = 1775698, upload-time = "2026-06-07T21:06:33.72Z" }, + { url = "https://files.pythonhosted.org/packages/b5/63/a251a9d2a6cb45065b2ddc0bde2b3dd10108740a9a42f632c66405a761a2/aiohttp-3.14.1-cp311-cp311-win32.whl", hash = "sha256:313701e488100074ce99850404ee36e741abf6330179fec908a1944ecf570126", size = 458386, upload-time = "2026-06-07T21:06:35.279Z" }, + { url = "https://files.pythonhosted.org/packages/17/ca/69274c51dcd6e8947d77b2806cf47a4a15f2c846e2cbeb1882547d3da283/aiohttp-3.14.1-cp311-cp311-win_amd64.whl", hash = "sha256:03ab4530fdcb3a543a122ba4b65ac9919da9fe9f78a03d328a6e38ff962f7aa5", size = 483406, upload-time = "2026-06-07T21:06:36.824Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8a/c25904f77690c3688ec140f87591ef11a0cfe36bf3d5c0f1f38056fb62b3/aiohttp-3.14.1-cp311-cp311-win_arm64.whl", hash = "sha256:486f7d16ed54c39c2cbd7ca71fd8ba2b8bb7860df65bd7b6ed640bab96a38a8b", size = 452987, upload-time = "2026-06-07T21:06:38.371Z" }, + { url = "https://files.pythonhosted.org/packages/1d/21/151624b51cd92553d95424daf4bf19f19ce9be9002d19253e7e7ce67197b/aiohttp-3.14.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d35143e27778b4bb0fb189562d7f275bff79c62ab8e98459717c0ea617ff2480", size = 757402, upload-time = "2026-06-07T21:06:40.311Z" }, + { url = "https://files.pythonhosted.org/packages/c2/82/280619e0bd7bf2454987e19282616e84762255dd9c8468f62382e8c191f1/aiohttp-3.14.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bcfb80a2cc36fba2534e5e5b5264dc7ae6fcd9bf15256da3e53d2f499e6fa29d", size = 512310, upload-time = "2026-06-07T21:06:42.207Z" }, + { url = "https://files.pythonhosted.org/packages/55/b2/2aac325583aaa1353045f96dffa586d8a34e8322e14a7ba49cffeb103ab4/aiohttp-3.14.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:27fd7c91e51729b4f7e1577865fa6d34c9adccbc39aabe9000285b48af9f0ec2", size = 512448, upload-time = "2026-06-07T21:06:43.813Z" }, + { url = "https://files.pythonhosted.org/packages/8a/72/a60607cb849faa8af8a356c9329ea2eb6f395d49e82cc82ccba1fd8deb8f/aiohttp-3.14.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64c567bf9eaf664280116a8688f63016e6b32db2505908e2bdaca1b6438142f2", size = 1766854, upload-time = "2026-06-07T21:06:45.391Z" }, + { url = "https://files.pythonhosted.org/packages/b5/d3/d9fe1c9ec7557ab4d0d82bebaa728c6418f0b93295ec2f4ab015f7710cc7/aiohttp-3.14.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f5e6ff2bdbb8f4cd3fbe41f99e25bbcd58e3bf9f13d3dd31a11e7917251cc77a", size = 1740884, upload-time = "2026-06-07T21:06:47.413Z" }, + { url = "https://files.pythonhosted.org/packages/c1/dc/f2cecfaf9337ba3e63f181500814ff502aa3d00d9c7ec93a9d23d10a27b2/aiohttp-3.14.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2f73e01dc37122325caf079982621262f96d74823c179038a82fddfc50359264", size = 1810034, upload-time = "2026-06-07T21:06:50.165Z" }, + { url = "https://files.pythonhosted.org/packages/66/d7/2ff65c5e65c0d7476daf7e15c032e0805e36811185b9623e3238ad6c763e/aiohttp-3.14.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bb2c0c80d431c0d03f2c7dbf125150fedd4f0de17366a7ca33f7ccb822391842", size = 1904054, upload-time = "2026-06-07T21:06:52.035Z" }, + { url = "https://files.pythonhosted.org/packages/20/9c/d445818389df371f56d141d881153ba23183c4735a03f7356ffb43f7757d/aiohttp-3.14.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3e6fc1a85fa7194a1a7d19f44e8609180f4a8eb5fa4c7ed8b4355f080fad235c", size = 1790278, upload-time = "2026-06-07T21:06:54.049Z" }, + { url = "https://files.pythonhosted.org/packages/4d/aa/bf04cb4d865fc6101c2229a294ad744973b72e513fdc5a6b791e6983d72a/aiohttp-3.14.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:686b6c0d3911ec387b444ddf5dc62fb7f7c0a7d5186a7861626496a5ab4aff95", size = 1591795, upload-time = "2026-06-07T21:06:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b4/4dac0038960427ba832f6609dfb4ea5437d7fd80c72001b9e48f834f428b/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c6fa4dc7ad6f8109c70bb1499e589f76b0b792baf39f9b017eb92c8a81d0a199", size = 1728397, upload-time = "2026-06-07T21:06:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/7cd4e8ad7aa3b75f17d56bb5498dd604a93d4e6eece822ba0568c413fff0/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:87a5eea1b2a5e21e1ebdbb33ad4165359189327e63fc4e4894693e7f821ac817", size = 1766504, upload-time = "2026-06-07T21:07:00.009Z" }, + { url = "https://files.pythonhosted.org/packages/f9/df/fc01d9fcad0f73fed3f3d361f1f94f975947b50dff82919f6dc2bf4316cc/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1c1421eb01d4fd608d88cc8290211d177a58532b55ad94076fb349c5bf467f0a", size = 1777806, upload-time = "2026-06-07T21:07:02.064Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/47e2d090bddcc8fb4ccb4c314aadc32d7c5d9bb55f50f6ad1c92fc15d501/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:34b257ec41345c1e8f2df68fa908a7952f5de932723871eb633ecbbff396c9a4", size = 1580707, upload-time = "2026-06-07T21:07:03.942Z" }, + { url = "https://files.pythonhosted.org/packages/3d/36/f1a4ce904ae0b6930cfe9afc96d0896f7ec1a620c400405d63783bb95a9c/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:de538791a80e5d862addbc183f70f0158ac9b9bb872bb147f1fd2a683691e087", size = 1798121, upload-time = "2026-06-07T21:07:05.987Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/e0075ce9ca0279ee1d4f0c0b85f54fea02ebc83c3007651a72bece658fec/aiohttp-3.14.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6f71173be42d3241d428f760122febb748de0623f44308a6f120d0dd9ec572e3", size = 1767580, upload-time = "2026-06-07T21:07:07.873Z" }, + { url = "https://files.pythonhosted.org/packages/3e/61/a0c0a8f327a9c52095cdd8e312391b00d3ed64ab6c72bb5c33d8ec251cf7/aiohttp-3.14.1-cp312-cp312-win32.whl", hash = "sha256:ec8dc383ee57ea3e883477dcca3f11b65d58199f1080acaf4cd6ad9a99698be4", size = 452771, upload-time = "2026-06-07T21:07:09.669Z" }, + { url = "https://files.pythonhosted.org/packages/df/d9/ea367c75f16ac9c6cdc8febb25e8318fa21a2b1bc8d6514d4b2d890bface/aiohttp-3.14.1-cp312-cp312-win_amd64.whl", hash = "sha256:2aa92c87868cd13674989f9ee83e5f9f7ea4237589b728048e1f0c8f6caa3271", size = 479873, upload-time = "2026-06-07T21:07:11.538Z" }, + { url = "https://files.pythonhosted.org/packages/03/64/8d96784a7851156db8a4c6c3f6f91042fdf39fb15a4cc38c8b3c14833c45/aiohttp-3.14.1-cp312-cp312-win_arm64.whl", hash = "sha256:2c840c90759922cb5e6dda94596e079a30fb5a5ba548e7e0dc00574703940847", size = 448073, upload-time = "2026-06-07T21:07:13.637Z" }, + { url = "https://files.pythonhosted.org/packages/bc/97/bd137012dd97e1649162b099135a80e1fd59aaa807b2430fc448d1029aff/aiohttp-3.14.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:b3a03285a7f9c7b016324574a6d92a1c895da6b978cb8f1deee3ac72bc6da178", size = 506882, upload-time = "2026-06-07T21:07:15.501Z" }, + { url = "https://files.pythonhosted.org/packages/ef/79/e5cc690e9d922a66887ceeaca53a8ffd5a7b0be3816142b7abc433742d89/aiohttp-3.14.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:2a73f487ab8ef5abbb24b7aa9b73e98eaba9e9e031804ff2416f02eca315ccaf", size = 515270, upload-time = "2026-06-07T21:07:17.53Z" }, + { url = "https://files.pythonhosted.org/packages/fe/22/a73ccbf9dbd6e26dda0b24d5fd5db7da92ee3383a79f47677ffb834c5c5b/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:915fbb7b41b115192259f8c9ae58f3ddc444d2b5579917270211858e606a4afd", size = 485841, upload-time = "2026-06-07T21:07:19.555Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b9/57ed8eaf596321c2ad747bd480fb1700dbd7177c60dfc9e4c187f629662e/aiohttp-3.14.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:7fb4bdf95b0561a79f259f9d28fbc109728c5ee7f27aff6391f0ca703a329abe", size = 492088, upload-time = "2026-06-07T21:07:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/78/c0/5ebe5270a7c140d7c6f79dcb018640225f14d406c149e4eec04a7d82fe71/aiohttp-3.14.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:1b9748363260121d2927704f5d4fc498150669ca3ae93625986ee89c8f80dcd4", size = 501564, upload-time = "2026-06-07T21:07:23.388Z" }, + { url = "https://files.pythonhosted.org/packages/75/7f/8cdaa24fc7983865e0915153b96a9ac5bcdd3548d64c5a27d17cecccad2d/aiohttp-3.14.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:86a6dab78b0e43e2897a3bbe15745aa60dc5423ca437b7b0b164c069bf91b876", size = 751998, upload-time = "2026-06-07T21:07:25.046Z" }, + { url = "https://files.pythonhosted.org/packages/b2/f4/c4227aacfacc5cb0cc2d119b65301d177912a6842cd64e120c47af76064f/aiohttp-3.14.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4dfd6e47d3c44c2279907607f73a4240b88c69eb8b90da7e2441a8045dfd21da", size = 510918, upload-time = "2026-06-07T21:07:27.28Z" }, + { url = "https://files.pythonhosted.org/packages/ab/01/a2d5f96cd4e74424864d30bc0a7e44d0a12dacdcfa91b5b2d1bd3dca6bf3/aiohttp-3.14.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:317acd9f8602858dc7d59679812c376c7f0b97bcbbf16e0d6237f54141d8a8a6", size = 508657, upload-time = "2026-06-07T21:07:29.252Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ed/3c0fb5c500fdd8e7ebc10d1889c04384fffa1a9163eac1356088ca9da1b1/aiohttp-3.14.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd869c427324e5cb15195793de951295710db28be7d818247f3097b4ab5d4b96", size = 1757907, upload-time = "2026-06-07T21:07:31.03Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ab/d4c924d9bd5be3050c226612413ce68cb54c70d2c31b661bfc8d9a5b6a70/aiohttp-3.14.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:93b032b5ec3255473c143627d21a69ac74ae12f7f33974cb587c564d11b1066f", size = 1737565, upload-time = "2026-06-07T21:07:33.031Z" }, + { url = "https://files.pythonhosted.org/packages/19/2a/37326821ff779084020cdc33224d20b19f42f4183a500ff92022a739eda7/aiohttp-3.14.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f234b4deb12f3ad59127e037bc57c40c21e45b45282df7d3a55a0f409f595296", size = 1799018, upload-time = "2026-06-07T21:07:35.003Z" }, + { url = "https://files.pythonhosted.org/packages/b3/4f/6e947ba73e4ce09070761c05ed3a8ceb7c21f5e46798671d8b2aac0e4626/aiohttp-3.14.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9af6779bfb46abf124068327abcdf9ce95c9ef8287a3e8da76ccf2d0f16c28fa", size = 1894416, upload-time = "2026-06-07T21:07:36.956Z" }, + { url = "https://files.pythonhosted.org/packages/9d/6e/dbf1d0625dc711fb2851f4f3c3055c39ed58bae92082d8c627dbe6013736/aiohttp-3.14.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:faccab372e66bc76d5731525e7f1143c922271725b9d38c9f97edcc66266b451", size = 1783881, upload-time = "2026-06-07T21:07:39.063Z" }, + { url = "https://files.pythonhosted.org/packages/44/c2/5e25098a67268ed369483ae7d1a58bd0a13d03aab860d2a0e4a6eb25b046/aiohttp-3.14.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f380468b09d2a81633ee863b0ec5648d364bd17bb8ecfb8c2f387f7ac1faf42c", size = 1587572, upload-time = "2026-06-07T21:07:41.058Z" }, + { url = "https://files.pythonhosted.org/packages/2a/bd/cf9cee17e140f942a3de73e658a543aa8fbf35a5fc67a9d2538d52d77f0b/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:97e704dcd26271f5bda3fa07c3ce0fb76d6d3f8659f4baa1a24442cc9ba177ca", size = 1722137, upload-time = "2026-06-07T21:07:43.014Z" }, + { url = "https://files.pythonhosted.org/packages/89/6d/5684f8c59045c96f81a18cefbc1fbbd79d25b88f1c622f2a5c5c08fcb632/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:269b76ac5394092b95bc4a098f4fc6c191c083c3bd12775d1e30e663132f6a09", size = 1755953, upload-time = "2026-06-07T21:07:45.933Z" }, + { url = "https://files.pythonhosted.org/packages/a8/40/35caf3170f8359760740a7d9aa0fff2e344bef98e1d1186f5a0f6dec17e6/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c0b3e614340c889d575451696374c9d17affd54cd607ca0babed8f8c37b9397", size = 1766479, upload-time = "2026-06-07T21:07:48.047Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a1/b0c61e7a137f0d81de49a82023a6df73c3c16d6fefb0f8e4a93d21639002/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:5663ee9257cfa1add7253a7da3035a02f31b6600ec48261585e1800a81533080", size = 1580077, upload-time = "2026-06-07T21:07:50.069Z" }, + { url = "https://files.pythonhosted.org/packages/0b/41/194ea4623693009fcefebef7aef63c141754f153e9cd0d39d3b9e36c175c/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:603a2c834142172ffddc054067f5ec0ca65d57a0aa98a71bc81952573208e345", size = 1791688, upload-time = "2026-06-07T21:07:52.106Z" }, + { url = "https://files.pythonhosted.org/packages/ba/45/4de841f005cfe1fd63e2a2fe011262c515e2a62aa6994b15947e7d717ac9/aiohttp-3.14.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cb21957bb8aca671c1765e32f58164cf0c50e6bf41c0bbbd16da20732ecaf588", size = 1761094, upload-time = "2026-06-07T21:07:54.113Z" }, + { url = "https://files.pythonhosted.org/packages/e4/ae/dbce10533d3896d544d5053939ed75b7dc31a1b0973d959b1b5ae21028d6/aiohttp-3.14.1-cp313-cp313-win32.whl", hash = "sha256:e509a55f681e6158c20f70f102f9cf61fb20fbc382272bc6d94b7343f2582780", size = 452662, upload-time = "2026-06-07T21:07:56.06Z" }, + { url = "https://files.pythonhosted.org/packages/7b/d9/0bf1a19362c32f06229da5e7ddfcec91f93474d6307f7a2d3135e9c674dc/aiohttp-3.14.1-cp313-cp313-win_amd64.whl", hash = "sha256:1ac8531b638959718e18c2207fbfe297819875da46a740b29dfa29beba64355a", size = 479748, upload-time = "2026-06-07T21:07:58.319Z" }, + { url = "https://files.pythonhosted.org/packages/22/0a/62e7232dc9484fbec112ceb32efb6a624cc7994ec6e2b019286f17c4e8f2/aiohttp-3.14.1-cp313-cp313-win_arm64.whl", hash = "sha256:250d14af67f6b6a1a4a811049b1afa69d61d617fca6bf33149b3ab1a6dbcf7b8", size = 447723, upload-time = "2026-06-07T21:08:00.154Z" }, + { url = "https://files.pythonhosted.org/packages/c4/a1/5fafa04e1ca91ddb47608699d60649c1c6db3cf41c99e78fc4056f9513db/aiohttp-3.14.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:7c106c26852ca1c2047c6b80384f17100b4e439af276f21ef3d4e2f450ae7e15", size = 508531, upload-time = "2026-06-07T21:08:02.093Z" }, + { url = "https://files.pythonhosted.org/packages/fa/2e/bfa02f699d87ffc86d5959270b28f1cb410add3ccaced8ed2e0b8a5238fc/aiohttp-3.14.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:20205f7f5ade7aaec9f4b500549bbc071b046453aed72f9c06dcab87896a83e8", size = 514718, upload-time = "2026-06-07T21:08:04.476Z" }, + { url = "https://files.pythonhosted.org/packages/85/a5/9594ad6289eebbc97d167c44213d557807f90e59115caad24de21ad2c3b1/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:62a759436b29e677181a9e76bab8b8f689a29cb9c535f45f7c48c9c830d3f8c3", size = 487918, upload-time = "2026-06-07T21:08:06.377Z" }, + { url = "https://files.pythonhosted.org/packages/b4/61/16a32c36c3c49edec122a3dc811f2057df2f94d3b14aa107c8017d981618/aiohttp-3.14.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:2964cbf553df4d7a57348da44d961d871895fc1ee4e8c322b2a95612c7b17fba", size = 494014, upload-time = "2026-06-07T21:08:08.263Z" }, + { url = "https://files.pythonhosted.org/packages/9b/89/3ebcf96ed99c05bec9c434aaac6963fd3cbab4a786ae739908a144d9ce44/aiohttp-3.14.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:237651caadc3a59badd39319c54642b5299e9cc98a3a194310e55d5bb9f5e397", size = 502398, upload-time = "2026-06-07T21:08:10.244Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3d/b74870a0c2d40c355928cd5b96c7a11fa821b8a40fc41365e64479b151fb/aiohttp-3.14.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:896e12dfdbbab9d8f7e16d2b28c6769a60126fa92095d1ebf9473d02593a2448", size = 758018, upload-time = "2026-06-07T21:08:12.447Z" }, + { url = "https://files.pythonhosted.org/packages/d3/66/f42f5c984d99e49c6cff5f26f590750f2e2f7ef1fcfb99966ab5be1b632e/aiohttp-3.14.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d03f281ed22579314ba00821ce20115a7c0ac430660b4cc05704a3f818b3e004", size = 512462, upload-time = "2026-06-07T21:08:14.624Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a7/248e1aebe0c7810b0271e021a0f2a5eb6e78a051885b3c9df49f42a5802d/aiohttp-3.14.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:07eabb979d236335fed927e137a928c9adfb7df3b9ec7aa31726f133a62be983", size = 512824, upload-time = "2026-06-07T21:08:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/26/97/2aa0e5ba0727dc3bd5aaebb7ccbc510f7dfb7fb961ec87497cd496635ab1/aiohttp-3.14.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4fe1f1087cbadb280b5e1bb054a4f00d1423c74d6626c5e48400d871d34ecefe", size = 1749898, upload-time = "2026-06-07T21:08:18.635Z" }, + { url = "https://files.pythonhosted.org/packages/00/8d/e97f6c96c891d457c8479d92a514ba194d0412f981d72c70341ee18488ed/aiohttp-3.14.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:367a9314fdc79dab0fac96e216cb41dd73c85bdca85306ce8999118ba7e0f333", size = 1710114, upload-time = "2026-06-07T21:08:20.892Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e6/aa8d7e863048c8fceb5cd6ce74017311cec3ead07847387e12265fb4444e/aiohttp-3.14.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a24f677ebe83749039e7bdf862ff0bbb16818ae4193d4ef96505e269375bcce0", size = 1802541, upload-time = "2026-06-07T21:08:23.044Z" }, + { url = "https://files.pythonhosted.org/packages/83/a8/72193137de57fda4ebfae4563182d082c8856e3b6e9871d0b46f028fb369/aiohttp-3.14.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c83afe0ba876be7e943d2e0ba645809ad441575d2840c895c21ee5de93b9377a", size = 1875776, upload-time = "2026-06-07T21:08:25.288Z" }, + { url = "https://files.pythonhosted.org/packages/a0/18/938441025db6769a3464596b2410af3afde0b21eb2f204c6f766f68af4bd/aiohttp-3.14.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:634e385930fb6d2d479cf3aa66515955863b77a5e3c2b5894ca259a25b308602", size = 1760329, upload-time = "2026-06-07T21:08:27.363Z" }, + { url = "https://files.pythonhosted.org/packages/60/29/bf2496b4065e76e09fe48015aaffe5ce161d8f089b06ac6982070f653076/aiohttp-3.14.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eeea07c4397bbc57719c4eed8f9c284874d4f175f9b6d57f7a1546b976d455ca", size = 1587293, upload-time = "2026-06-07T21:08:29.805Z" }, + { url = "https://files.pythonhosted.org/packages/49/a2/2136674d52123b1354bd05dd5753c318db47dc0c927cc70b27bab3755456/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:335c0cc3e3545ce98dcb9cfcb836f40c3411f43fa03dab757597d80c89af8a35", size = 1714756, upload-time = "2026-06-07T21:08:32.094Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b9/e5fd2e6f915503081c0f9b1e8540947037929c70c191da2e4d54b31a21a1/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:ae6be797afdef264e8a84864a85b196ca06045586481b3df8a967322fd2fa844", size = 1721052, upload-time = "2026-06-07T21:08:34.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/5a/2833e324a2263e104e31e2e91bc5bbee81bc499afd32203faee048a883f0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:8560b4d712474335d08907db7973f71912d3a9a8f1dee992ec06b5d2fe359496", size = 1766888, upload-time = "2026-06-07T21:08:36.95Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/dea6511870913162f3b2e8c42a7614eb203a4540b8c2da43e0bfb0548f3c/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7edd08e0a5deb1e8564a2fcd8f4561014a3f05252334671bbf55ddd47db0e5", size = 1581679, upload-time = "2026-06-07T21:08:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/14/bd/3cf0d55e71784b33534e9710a67d382d900598b4787fbce6cc7317f8c42a/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:b6ff7fcee63287ae57b5df3e4f5957ce032122802509246dec1a5bcc55904c95", size = 1782021, upload-time = "2026-06-07T21:08:41.407Z" }, + { url = "https://files.pythonhosted.org/packages/c1/af/14bb5843eccbe234f4dfb78ab73e549d99727247e62ae5d62cbd22eaf5b0/aiohttp-3.14.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6ffbb2f4ec1ceaff7e07d43922954da26b223d188bf30658e561b98e23089444", size = 1742574, upload-time = "2026-06-07T21:08:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/f2/1e/fbeb7af9210a67ac0f9c9bec0f8f4568497924e33137a3d5b48e1cf85f3f/aiohttp-3.14.1-cp314-cp314-win32.whl", hash = "sha256:a9875b46d910cff3ea2f5962f9d266b465459fe634e22556ab9bd6fc1192eea0", size = 457773, upload-time = "2026-06-07T21:08:46.168Z" }, + { url = "https://files.pythonhosted.org/packages/f0/2b/13e8d741a9ec5db7d900c060554cf8352ab85e44e2a4469ebb9d377bda17/aiohttp-3.14.1-cp314-cp314-win_amd64.whl", hash = "sha256:af8b4b81a960eeaf1234971ac3cd0ba5901f3cd42eae42a46b4d089a8b492719", size = 485001, upload-time = "2026-06-07T21:08:48.401Z" }, + { url = "https://files.pythonhosted.org/packages/df/30/491acfa2c4d6c3ff59c49a14fc1b50be3241e25bbb0c84c09e2da4d11395/aiohttp-3.14.1-cp314-cp314-win_arm64.whl", hash = "sha256:cf4491381b1b57425c315a56a439251b1bdac07b2275f19a8c44bc57744532ec", size = 453809, upload-time = "2026-06-07T21:08:50.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/e3/19dbe1a1f4cc6230eb9e314de7fe68053b0992f9302b27d12141a0b5db53/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:819c054312f1af92947e6a55883d1b66feefab11531a7fc45e0fb9b63880b5c2", size = 793320, upload-time = "2026-06-07T21:08:52.775Z" }, + { url = "https://files.pythonhosted.org/packages/7f/20/1b7182219ba1b108430d6e4dc53d25ae02dcfcf5a045b33af4e8c5167527/aiohttp-3.14.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10ee9c1753a8f706345b22496c79fbddb5be0599e0823f3738b1534058e25340", size = 529077, upload-time = "2026-06-07T21:08:55Z" }, + { url = "https://files.pythonhosted.org/packages/b9/c8/14ce60ec31a2e5f5274bb17d383a6f7a3aabca31ac04eee05585bbadab16/aiohttp-3.14.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1601cc37baf5750ccacae618ec2daf020769581695550e3b654a911f859c563d", size = 532476, upload-time = "2026-06-07T21:08:57.176Z" }, + { url = "https://files.pythonhosted.org/packages/7e/02/9ac85e081e53da2e061b02fa7758fe0a12d17b8ce2d1f5e6c7cb76730328/aiohttp-3.14.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d6e0ac9da31c9c04c84e1c0182ad8d6df35965a85cae29cd71d089621b3ae94", size = 1922347, upload-time = "2026-06-07T21:08:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/c0/3e/d3ba07a0ab38b5389e10bec4362d21e10a4f667cba2d79ba30837b3a5059/aiohttp-3.14.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:9e8f2d660c350b3d0e259c7a7e3d9b7fc8b41210cbcc3d4a7076ff0a5e5c2fdc", size = 1786465, upload-time = "2026-06-07T21:09:01.909Z" }, + { url = "https://files.pythonhosted.org/packages/0b/cb/e2ee978a00cfb2df829704a69528b18154eba5939f45bc1efa8f33aee4c5/aiohttp-3.14.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4691802dda97be727f79d86818acaad7eb8e9252626a1d6b519fedbb92d5e251", size = 1909423, upload-time = "2026-06-07T21:09:04.357Z" }, + { url = "https://files.pythonhosted.org/packages/73/5d/1430334858b1022b58ae50399a918f0bd6fe8fa7fa183598d657ff61e040/aiohttp-3.14.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c389c482a7e9b9dc3ee2701ac46c4125297a3818875b9c305ddb603c04828fd1", size = 2001906, upload-time = "2026-06-07T21:09:06.722Z" }, + { url = "https://files.pythonhosted.org/packages/66/4e/560c7472d3d198a23aa5c8b19a5115bf6a9b77b7d3e4bb363da320430ad2/aiohttp-3.14.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fc0cacab7ba4e56f0f81c82a98c09bed2f39c940107b03a34b168bdf7597edd3", size = 1877095, upload-time = "2026-06-07T21:09:09.011Z" }, + { url = "https://files.pythonhosted.org/packages/0d/f1/4745806578d447db4a784a8591e2dae3afdfc2bcb96f8f81271b13df6543/aiohttp-3.14.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:979ed4717f59b8bb12e3963378fa285d93d367e15bcd66c721311826d3c44a6c", size = 1676222, upload-time = "2026-06-07T21:09:11.461Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c9/48255813cca749a229ef0ab476004ec623728ad79a9c0840616f6c076325/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38e1e7daaea81df51c952e18483f323d878499a1e2bfe564790e0f9701d6f203", size = 1842922, upload-time = "2026-06-07T21:09:14.118Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c0/bbd054e2bee909f529523a5af3891052606af5143c09f5f183ec3b234676/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4132e72c608fe9fecb8f409113567605915b83e9bdd3ea56538d2f9cd35002f1", size = 1825035, upload-time = "2026-06-07T21:09:16.447Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ae/90395d4376deceb74e09ec26b6adf7d2015a6f8802d6d84446af860fef04/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:eefd9cc9b6d4a2db5f00a26bc3e4f9acf71926a6ec557cd56c9c6f27c290b665", size = 1849512, upload-time = "2026-06-07T21:09:18.742Z" }, + { url = "https://files.pythonhosted.org/packages/93/bd/fb25f3049957553d4ce0ba6ae480aa2f592a6985497fca590837d16c1be0/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b165790117eea512d7f3fb22f1f6dad3d55a7189571993eb015591c1401276d1", size = 1668571, upload-time = "2026-06-07T21:09:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/3f/22/7f73303d64dd567ff3addca90b556690ed1233a47b8f55d242fb90af3681/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:ed09c7eb1c391271c2ed0314a51903e72a3acb653d5ccfc264cdf3ef11f8269d", size = 1881159, upload-time = "2026-06-07T21:09:23.813Z" }, + { url = "https://files.pythonhosted.org/packages/44/be/0474c5a8b5640e1e4aa1923430a91f4151be82e511373fe764189b89aef5/aiohttp-3.14.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:99abd37084b82f5830c635fddd0b4993b9742a66eb746dacf433c8590e8f9e3c", size = 1841409, upload-time = "2026-06-07T21:09:26.207Z" }, + { url = "https://files.pythonhosted.org/packages/7b/3c/bb4a7cba26956cb3da4553cc2056cf67be5b5ff6e6d8fa4fbdff73bfb7ae/aiohttp-3.14.1-cp314-cp314t-win32.whl", hash = "sha256:47ddf841cdecc810749921d25606dee45857d12d2ad5ddb7b5bd7eab12e4b365", size = 494166, upload-time = "2026-06-07T21:09:28.505Z" }, + { url = "https://files.pythonhosted.org/packages/8a/84/ec80c2c1f66a952555a9f86df6b33af65108a6febfa0471b69013a12f807/aiohttp-3.14.1-cp314-cp314t-win_amd64.whl", hash = "sha256:5e78b522b7a6e27e0b25d19b247b75039ac4c94f99823e3c9e53ae1603a9f7e9", size = 530255, upload-time = "2026-06-07T21:09:30.843Z" }, + { url = "https://files.pythonhosted.org/packages/2a/71/6e22be134a4061ada85a92951b842f2657f17d926b727f3f94c56ae963d6/aiohttp-3.14.1-cp314-cp314t-win_arm64.whl", hash = "sha256:90d53f1609c29ccc2193945ef732428382a28f78d0456ae4d3daf0d48b74f0f6", size = 469640, upload-time = "2026-06-07T21:09:33.028Z" }, ] [[package]] @@ -153,91 +178,94 @@ wheels = [ ] [[package]] -name = "arro3-core" -version = "0.6.5" +name = "anyio" +version = "4.14.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -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]] -name = "astunparse" -version = "1.6.3" +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "arro3-core" +version = "0.8.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "six" }, - { name = "wheel" }, + { name = "typing-extensions", marker = "python_full_version < '3.12'" }, ] -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, upload-time = "2019-12-22T18:12:11.297Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/c0/c8/fc5bacb6fc264dc61e46d4832f690015b7f6c693ff5dea8a1e53b63cb772/arro3_core-0.8.1.tar.gz", hash = "sha256:1df54a8e2c14a877f291d90de65f00bafe9cc6d5958417ff749f178f059dcd39", size = 93684, upload-time = "2026-06-11T18:01:37.508Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/18/81ff17882bb43bcef22bfc67d5b9522ee52f39b0bc6516eda718f99fb1aa/arro3_core-0.8.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:88b15a937ebe7e64f63cbbd1134d18daa3cb3f769e936a1f0284136375707ebb", size = 3077128, upload-time = "2026-06-11T17:59:25.637Z" }, + { url = "https://files.pythonhosted.org/packages/a2/c1/f9b9675f12f0cc2da922f4e1d029600f76b834879e11eed22b1a2b5d9521/arro3_core-0.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0501662cb3c7c6a05dcd6b998db55f9a97743aa571da42203733366c42166a7a", size = 2798583, upload-time = "2026-06-11T17:59:27.193Z" }, + { url = "https://files.pythonhosted.org/packages/39/80/62e19c6935d5633452a76354881be62f7a25f1bc7d8ad2e74faf3c68790a/arro3_core-0.8.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5674a052c68721963f462b10ce92596a7e85cfacd3fbdc3839633182844f6f3c", size = 3270157, upload-time = "2026-06-11T17:59:28.785Z" }, + { url = "https://files.pythonhosted.org/packages/9a/7d/0eadbcc314d61cad40679878bab76ae224a476059097af271051e4f36024/arro3_core-0.8.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ebc5e8917ccc638f276292532e7a59dad3d89ba8a26e874e596a6bc4ffb0ca4b", size = 3404256, upload-time = "2026-06-11T17:59:30.276Z" }, + { url = "https://files.pythonhosted.org/packages/76/6f/6b2ed8c8c0f4bff723e9485ac64daf3d34e424f3d266517e002644b9171d/arro3_core-0.8.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3f30c53a51934884416b752e95ef50b1958d6b55de6a386e919c202ceec7d4ff", size = 3467310, upload-time = "2026-06-11T17:59:31.889Z" }, + { url = "https://files.pythonhosted.org/packages/34/48/0fe2f381f29e16e639e691392e1bbc628d275f950f3a8dec3bab7d04742f/arro3_core-0.8.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:31b966b0f9b2a6fb3f285339ed5ec4eac69591bbb92a9fdb2cf80ad995b759af", size = 3190458, upload-time = "2026-06-11T17:59:33.385Z" }, + { url = "https://files.pythonhosted.org/packages/73/63/fd6af88fe75a1316f7742ac11937fb9ab94ffbc582e9e722b807cf2957fa/arro3_core-0.8.1-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:f27615f05e55e7c277b4386fd18356d7b052e0c92be66cc13cf823286d94b141", size = 2950698, upload-time = "2026-06-11T17:59:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/40/a6/5c3f9c4a8f0f8c510e6b0fea41329f704d6be569cc52f7479c179a84d196/arro3_core-0.8.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f6c9e50b3bc594260cbcc94e4b9fff590c5fd7f35a6c5729ecc6a6ff0a117981", size = 3398996, upload-time = "2026-06-11T17:59:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/aa/0c/b52e17b284e9b0b3cb0c402071e1057bf422309407e7f9c3bc80818ee581/arro3_core-0.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97584eb695f6d9a2cb5e66aa4866fdd17316c313d1724257660bcb0f37a59fb1", size = 3128930, upload-time = "2026-06-11T17:59:37.914Z" }, + { url = "https://files.pythonhosted.org/packages/58/99/6e6b15a8e9b6ea8995f2b0ccd01a91fd3e72af74ef45b856b7675bb58b04/arro3_core-0.8.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:35dfbc1c19afb63371ae23bd653202b57fd99b57f17ed1561849c4b5994b63d9", size = 3547956, upload-time = "2026-06-11T17:59:39.327Z" }, + { url = "https://files.pythonhosted.org/packages/dc/3e/82d1df9edcd26632c5b6271ebbc6c825307abb76dc9c8de04094510b3dc6/arro3_core-0.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2dd781426d776fad17dba1c991df422d071d22e59cfd0db80b104648b6c96040", size = 3505514, upload-time = "2026-06-11T17:59:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/c1/c8/ffc2d069ba82444fbeb0a41b24a4c51ddfe99935b11c8572ce3c1911c19f/arro3_core-0.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:540f85f0b6a31820347d069c217b002488fe60f8e509d134bd1e0bc2eeffbfff", size = 3405220, upload-time = "2026-06-11T17:59:43.275Z" }, + { url = "https://files.pythonhosted.org/packages/df/12/d7de53951ed8b89bc3f390b416e25e92dd765bc2faa13eedcd467248bc24/arro3_core-0.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:507c4a58c993af697eb2e7b324c1f2c4ced22a72c85e9c2adbc23d28c6a6c20b", size = 3357259, upload-time = "2026-06-11T17:59:44.961Z" }, + { url = "https://files.pythonhosted.org/packages/ca/75/29517738623cccba1d60d0aa65b896bdd046ab4a82c5cd5a9fca115f6d7a/arro3_core-0.8.1-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:64d3ea60da4279e9c6a9b2e60abf7f4fefb6643544ecb2dfde899f72f1f91bad", size = 3085640, upload-time = "2026-06-11T17:59:46.597Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b5/bff1842ed8dbb2134b004f78c52d977b52a6df1d9389b1284aad02bf4d93/arro3_core-0.8.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:463db0b7f698abc19f4274d6df697560bc9c19463cfdbd01094bb0fa6ddd1206", size = 2800705, upload-time = "2026-06-11T17:59:48.242Z" }, + { url = "https://files.pythonhosted.org/packages/32/20/ef60404d5008f84bca50510f65d7acfe26812e61638ee19a416bf3f11530/arro3_core-0.8.1-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:881afcb9c83334ac26498b429265223c676e028cd5d88fb0c909578f8e0330de", size = 3272103, upload-time = "2026-06-11T17:59:49.641Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8e/6687efc8e0414cfde6281b1f4ea3b5b44aeb318584e39113d645d1f913a2/arro3_core-0.8.1-cp311-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d1dc03e1e5965528bfc7e26a6ee857e3b6fc5f201ff530a03dc731b9f3fb14fd", size = 3405750, upload-time = "2026-06-11T17:59:51.037Z" }, + { url = "https://files.pythonhosted.org/packages/2e/38/2567f26cd041387a2c1f381f1757b5246184de22dd8bd9dbbcb91a1b3586/arro3_core-0.8.1-cp311-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:410cdca92392be39a5580059ebf9a6f89a0410af6dc10a8baa76e9d511fa6624", size = 3467290, upload-time = "2026-06-11T17:59:52.672Z" }, + { url = "https://files.pythonhosted.org/packages/85/38/2c5af3a3e8c806188f1b3a651cbaa6c22d1f094c41e82900d40219c2b337/arro3_core-0.8.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f4082c6bbff7f164619d99dffcbc2313ed69024653a9e58d9f94cc65d9226f6c", size = 3195582, upload-time = "2026-06-11T17:59:54.536Z" }, + { url = "https://files.pythonhosted.org/packages/e4/8d/d7d8686901a273743c53aae98f898f00caaedea807e28854200f2a7365e4/arro3_core-0.8.1-cp311-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:40da2ee0701f217cd482e61228023ee9d8993984daccaf6ded089035b5fdc132", size = 2950909, upload-time = "2026-06-11T17:59:56.05Z" }, + { url = "https://files.pythonhosted.org/packages/5b/8d/f44198a859b13f519b331906d08079c7c281f8267d718082a3ca858c93ee/arro3_core-0.8.1-cp311-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d79b12a83403b1a100731587d33f09a818a4ff93472f84fcdf3a38d3934dfc5e", size = 3403497, upload-time = "2026-06-11T17:59:57.651Z" }, + { url = "https://files.pythonhosted.org/packages/88/6d/431d2ef9942a30599db4a86cf13b7f1de92d340717438074a25659d382f4/arro3_core-0.8.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3ff2da3c888ec504291deafd6965f2364f14f1fb63977f202f2f7680f10909f6", size = 3130901, upload-time = "2026-06-11T17:59:59.262Z" }, + { url = "https://files.pythonhosted.org/packages/ce/9e/458e8596c675fb88075137ab21d4c4a2f9e585101a97d77e910839862168/arro3_core-0.8.1-cp311-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:0bbcfb60ddf9b571387b4c0aaf78171d2ee7e0d335c08514bc247685f01bc086", size = 3550071, upload-time = "2026-06-11T18:00:00.808Z" }, + { url = "https://files.pythonhosted.org/packages/93/cd/8fa1f4a49e707ea364022620ca5033ba69b6396215e0bae1fb9fa9efcfe0/arro3_core-0.8.1-cp311-abi3-musllinux_1_2_i686.whl", hash = "sha256:44f6256cae47d369fe9076e9fa61f5c2899447b76ca5bda641668ad07cf26bf6", size = 3508957, upload-time = "2026-06-11T18:00:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/7f/02/0126ff3b2ff48187315a9782280c0f6412c066559e22bf206ceec2791299/arro3_core-0.8.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b2517ea3fb7cb15a41e2d0e3496d91afe4703eca86a88072f917bef044c2b8ee", size = 3408923, upload-time = "2026-06-11T18:00:03.97Z" }, + { url = "https://files.pythonhosted.org/packages/d8/fd/a19cb50480a769b1cc3f347efa1a808e13b853bb3b1d94b289677115fe92/arro3_core-0.8.1-cp311-abi3-win_amd64.whl", hash = "sha256:6a96df94a4538ab9acf01873eecf35161ad0c54ab79134247e69a42cd66515dc", size = 3368045, upload-time = "2026-06-11T18:00:05.578Z" }, + { url = "https://files.pythonhosted.org/packages/5c/86/1ac6eed229482b1ef6ac6f3211c6760012aaaa026b0a385a18e42ec0ff74/arro3_core-0.8.1-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:71165a75a7d22c16226085ed2255afe84741369cd35eb01cc153c4687e85b49f", size = 1724964, upload-time = "2026-06-11T18:00:07.653Z" }, + { url = "https://files.pythonhosted.org/packages/4a/32/c2e4a65b3b4f7e00552d280e9c2eaaca59c19466b0f95e4e08d6a020ab2d/arro3_core-0.8.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dc81e079d182dfc2ba6bace815608dbab0158809b9268c4eb59816685f7871fb", size = 3071070, upload-time = "2026-06-11T18:00:09.12Z" }, + { url = "https://files.pythonhosted.org/packages/f0/32/56016de27757bcc0d66999ae2fec624eaa27bd097ee69ac7a5a0d52d407b/arro3_core-0.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7695e9fca1e2c0064571bea65d9b7909a957ed9be4c81718d19356e578818649", size = 2794497, upload-time = "2026-06-11T18:00:10.872Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6e/a7aac0e87d6d63672d4ebb7e8466b276fde2a6413579008fadd1e8e919c5/arro3_core-0.8.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ad0f507dfafb1e8a8c15e8a902bccdd9a9c2cf2102fc39ad35c22e23ca76a65e", size = 3272090, upload-time = "2026-06-11T18:00:12.561Z" }, + { url = "https://files.pythonhosted.org/packages/1b/fd/8b96e57eea8ebf5fa4e2556b06c84a29ea25ded924f56d300807ff377a94/arro3_core-0.8.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3fc91a59cb27c7660134b9ed0c067d53c0f5bb1722f16f2cf7d4c446ce1086c4", size = 3399753, upload-time = "2026-06-11T18:00:13.992Z" }, + { url = "https://files.pythonhosted.org/packages/80/8b/76f1385ecd0448fcc60902a4474f3f00bd2197fe6d7fa4434bb6a0b3dee9/arro3_core-0.8.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71bc86880256cca5f22802ca444a92b3e9edbb5c9e5b74df1cbfcfe2ca788097", size = 3468912, upload-time = "2026-06-11T18:00:15.502Z" }, + { url = "https://files.pythonhosted.org/packages/0f/15/e1c3aefee23d926be9f75f125d6d94f189f28826aa01713498f1de60b080/arro3_core-0.8.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94528515f59146b5b4421e468bc269f682754ff082434cccf0ecbfb0d74d0b38", size = 3193338, upload-time = "2026-06-11T18:00:17.064Z" }, + { url = "https://files.pythonhosted.org/packages/de/3d/bfa3963bd09941f69d810a387bbea61deb8a091101c1765216266ec61394/arro3_core-0.8.1-cp313-cp313t-manylinux_2_24_aarch64.whl", hash = "sha256:e348fa5349e6655df4b1ea281ab71e0794c749ba842da54248eb942201f93fb8", size = 2950166, upload-time = "2026-06-11T18:00:18.607Z" }, + { url = "https://files.pythonhosted.org/packages/21/07/67bdc66582294d5914b8f7462907b1e44377ccb77a0f7e7b7760c9f81fd1/arro3_core-0.8.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d97170d56690655699977f4535120f170c5047813341c2b0264abc3af00625dc", size = 3401097, upload-time = "2026-06-11T18:00:20.135Z" }, + { url = "https://files.pythonhosted.org/packages/6d/a3/ab0d46e8fe2337f0ce1789949c5078e63d532fd628af4f254702e1359989/arro3_core-0.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6f71fdd46e1a86bc1db0733361130e51447d2d3c7d5fca13937381e2ba085dca", size = 3128454, upload-time = "2026-06-11T18:00:21.598Z" }, + { url = "https://files.pythonhosted.org/packages/c2/6e/e90cea2555aaa429c16173cbc121b41fb6a656e70ae9f21edbe3f9e73eb0/arro3_core-0.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:3e2c2e4c6b36a0f493f07222eadf7c628155f4a306c650a14c4d6d343870043c", size = 3550294, upload-time = "2026-06-11T18:00:24.344Z" }, + { url = "https://files.pythonhosted.org/packages/15/38/f3db72e411fbaea83844d19b1965abbdf3dd387083f99a715d3b3752b461/arro3_core-0.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:90d31db38bc7cbbe6061d23ae11b3e830b55ce60ba6f0550c70915bab4cafe41", size = 3507806, upload-time = "2026-06-11T18:00:26.143Z" }, + { url = "https://files.pythonhosted.org/packages/47/73/5d011dad78ac58aaa61e9cc65d349b879afec6781c5aa78f80c839fe359f/arro3_core-0.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:9d28d87625d6c743fee1d2e20252531db6cff6c5218b89055a9f7492af99c14c", size = 3408910, upload-time = "2026-06-11T18:00:27.843Z" }, + { url = "https://files.pythonhosted.org/packages/6a/43/be86ea01c2d53d32376602cb2efa9a9b7a25d98e14ad4b5bf0d7da8b0564/arro3_core-0.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:b394218ad71ce266088e0e65bfad0542556adf0eb93197b66afaa2009358662f", size = 3356174, upload-time = "2026-06-11T18:00:29.395Z" }, + { url = "https://files.pythonhosted.org/packages/7a/0f/2f7b5b458fd38ecf9277fd59919e4c90047795d1abdfd47f0081c48c28fa/arro3_core-0.8.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a7c227982c0fd762271d550a6242e54ff7d2aa7aff73c918e127042cf9601ec0", size = 1710676, upload-time = "2026-06-11T18:00:30.947Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9e/b64c9325173b7d87030955007af55348b866a1c02545382261d8966d8bb1/arro3_core-0.8.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:5861781e6363db0707e1a4ba5166025a1113d921ccff870a0a097f27ab2dece1", size = 3072327, upload-time = "2026-06-11T18:00:32.313Z" }, + { url = "https://files.pythonhosted.org/packages/3c/bf/2c58a549b2409439fcac4ce2abe31ef060d05114a1e5412ce3088e2a2cde/arro3_core-0.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8c1d9ff8b2848bf48bbcad962271b2adeda922457cf23591464c12af9b0ddbf6", size = 2794872, upload-time = "2026-06-11T18:00:33.92Z" }, + { url = "https://files.pythonhosted.org/packages/1f/21/dee8d1c9309820783fe30ad29149508638743cdc61d2851f0132b370ac49/arro3_core-0.8.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:246057dc9d283a283cb05b228f710e1ada3b4a8c483e519b0ec8b0d8499204db", size = 3272432, upload-time = "2026-06-11T18:00:35.563Z" }, + { url = "https://files.pythonhosted.org/packages/b6/b2/dbc6a3d5ece2cb11c3041821c8322df500b8c8030534d9f791b66b29c090/arro3_core-0.8.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5d55178ad56c637f278286370532e58256eff48f71d3c36a4e5f8652eb7300b3", size = 3400822, upload-time = "2026-06-11T18:00:37.129Z" }, + { url = "https://files.pythonhosted.org/packages/a4/44/0ca0e96eebc7f96af4ad8b78c545a9477e8db271aa090043bb8f797e7f58/arro3_core-0.8.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7a917195d7553a188bbb9fa815d430ff70ee53357fcc6fdb395d0e4436a66243", size = 3469220, upload-time = "2026-06-11T18:00:39.375Z" }, + { url = "https://files.pythonhosted.org/packages/8e/70/3e9c63e9e4499d373304ceb1315afc8dd326395dd0692ccdebcfec703dab/arro3_core-0.8.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2435dc4057d604650a34d195e6977ef40b26af0b9dea8f1b842a924a270de2fc", size = 3193813, upload-time = "2026-06-11T18:00:41.281Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a8/48842a836d7fc2bdac16ecaf4e60c9fd98f46d1e9c80f45b85b071ccaffb/arro3_core-0.8.1-cp314-cp314t-manylinux_2_24_aarch64.whl", hash = "sha256:c8b90ad6fbdd3507c20bdbc2b23c88929a3f7c49a8a43697f316e084b5664289", size = 2950818, upload-time = "2026-06-11T18:00:42.987Z" }, + { url = "https://files.pythonhosted.org/packages/bb/46/635ca59c20b972f0575b9fd1b7debebd94bcba1d6fd68271bfff65d3ae91/arro3_core-0.8.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8918dd0fcf5c3650332678ddbfb7c13ce1f0534f9669ab6839c38069885318cf", size = 3401202, upload-time = "2026-06-11T18:00:44.654Z" }, + { url = "https://files.pythonhosted.org/packages/4a/03/020d032cf4937cca0652f434613d327f8f11e10b4a3fbd4ccf48846c3158/arro3_core-0.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6cc8c7c9b81cad2b4eaaeb29da25854bddb5bb373a278f1f1046b277fa7f2ee6", size = 3129352, upload-time = "2026-06-11T18:00:46.322Z" }, + { url = "https://files.pythonhosted.org/packages/b2/55/ce1c840af64e8c5d4f8b684a4c00c5f6d260659c3d2f732c6074eb5597fe/arro3_core-0.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2aa0bda5251e93c67318aa8315e563b02fe39d5577b72654c335d69fce802d03", size = 3550475, upload-time = "2026-06-11T18:00:48.021Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/fa8f1a53cf10fe33029619d14f06525e08ea35c1bcdce42157b230098e1b/arro3_core-0.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:cd462e7ef7735c341c27f754756b244d90a9485818c5ea090fa46009bd8ae252", size = 3507877, upload-time = "2026-06-11T18:00:49.798Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e5/bf8349b2e5613e6c0caf2f4f0758d61dc273649c2bdcfc96978ab9bc1686/arro3_core-0.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:480c33d62d66adf92fd731e77728f81b7bd3fcde4e2eb313f5616b5097b1875f", size = 3409360, upload-time = "2026-06-11T18:00:51.393Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0d/e13efbbc448bc817f8343bf12606d22f83011682c821ccd32dda304816be/arro3_core-0.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:51bea8dc0bc0230b6af8d1b258cf2a7e4f69770ed99062592ce1b43b17732727", size = 3355557, upload-time = "2026-06-11T18:00:52.922Z" }, + { url = "https://files.pythonhosted.org/packages/de/bc/ab3081cc65cd38a5ceda6dd112ed9d70a481e2810c8f92d37441edab6a8a/arro3_core-0.8.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d70c68665525744154dc1f65b5038fe97600a5204e8a875b3fe7675455363024", size = 3076025, upload-time = "2026-06-11T18:01:16.781Z" }, + { url = "https://files.pythonhosted.org/packages/0b/5b/b278236c7425f36fe1d25087964ca143d2a11f99b69b19f3c050cf3a0f4b/arro3_core-0.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b89c867f65852de7fa0586a4883195a2cca1ee3a7bca8c95c309a02ce6be15b5", size = 2797246, upload-time = "2026-06-11T18:01:18.648Z" }, + { url = "https://files.pythonhosted.org/packages/1c/a5/a3a96bef25a7d0d7fd49ea3762d901a17d908fda774c868abe9aa065e0f2/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:66d685f7c53137798e05322a17dabc09445a818c9c8959d21e9fb3e084371f51", size = 3269959, upload-time = "2026-06-11T18:01:20.317Z" }, + { url = "https://files.pythonhosted.org/packages/f7/2f/4da148dea5890ae24eade70d3f9dfc93d191f501bf425d906c21dee89510/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5a7563856e609dd24be03efde72edd5b1c16dd1fb3d67fd84970df6d9ca36b96", size = 3400877, upload-time = "2026-06-11T18:01:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/7b/9a/2ed015d4a03c131b9e3da322ee8681c5921320a90218865a711d7fe39768/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7379e14f8f49e99d53e364c26533293d2136920a3cf818138718a9a9bc114d0", size = 3466119, upload-time = "2026-06-11T18:01:23.909Z" }, + { url = "https://files.pythonhosted.org/packages/25/a8/154478c9b3093602eaec8a5d71b657205177f0ac9fd9da2eef9ddfb42fe0/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:af88a17346e9e7897b6d1bc165f686b87ea928b4c7e89666156b2281f0236cee", size = 3190211, upload-time = "2026-06-11T18:01:25.786Z" }, + { url = "https://files.pythonhosted.org/packages/5b/65/60c6323bbaffd2399f03336a90d7c02450a5b9120348808eb0260651de56/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_24_aarch64.whl", hash = "sha256:1e9e04cbbba877f31073146798b328537b63675f65d0b7e2154754d5203260ed", size = 2950345, upload-time = "2026-06-11T18:01:27.423Z" }, + { url = "https://files.pythonhosted.org/packages/21/81/3a0c786ec86a8c3aee946e4588b188f08a3ccbba71c4e0db144a0151b56c/arro3_core-0.8.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:35c46c49442853f8a1b09d37ceabe4ce31e2a276d4a73c407ba3a25fcf1b9221", size = 3398153, upload-time = "2026-06-11T18:01:29.036Z" }, + { url = "https://files.pythonhosted.org/packages/f6/52/e230d0af9881056e527f91e58c518b85000e9ae49f8706c3d0e027cbadd4/arro3_core-0.8.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:4e4af152b4ccf7694d634573db906e1a9b28f5beda84bac5adeb60f7e98262a8", size = 3128123, upload-time = "2026-06-11T18:01:30.733Z" }, + { url = "https://files.pythonhosted.org/packages/62/79/9bc36279f1ef2754fde18e1c3c282b2cabe6093b7ca8bd8c0c6c735685ed/arro3_core-0.8.1-pp311-pypy311_pp73-musllinux_1_2_armv7l.whl", hash = "sha256:7bf8cb1053cb51529437544eedbc6643819912eec15c0491d7304d6bad0fb151", size = 3547345, upload-time = "2026-06-11T18:01:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/f9/33/cee8670131009d3a23e140dd0a6cd454111dac54a9a2e83567a6f972ad46/arro3_core-0.8.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:212dfde17bd26193754aaee6024f7e99624163e0d44966745e8752af79c4c454", size = 3504672, upload-time = "2026-06-11T18:01:34.207Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5f/daa7a8af9ad53eebbdb818078d5034525663f298ec9bfdb1933cfd0cc694/arro3_core-0.8.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:81426cb122d6bfccacd2e0ae5df8b847dc3c3e07b5f336547cb18f873d186f54", size = 3404906, upload-time = "2026-06-11T18:01:36.097Z" }, ] [[package]] @@ -251,11 +279,11 @@ wheels = [ [[package]] name = "attrs" -version = "25.3.0" +version = "26.1.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, upload-time = "2025-03-13T11:10:22.779Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, ] [[package]] @@ -274,101 +302,133 @@ wheels = [ [[package]] name = "botocore" -version = "1.40.43" +version = "1.40.76" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jmespath" }, { name = "python-dateutil" }, - { 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'" }, + { name = "urllib3" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/07/eb/50e2d280589a3c20c3b649bb66262d2b53a25c03262e4cc492048ac7540a/botocore-1.40.76.tar.gz", hash = "sha256:2b16024d68b29b973005adfb5039adfe9099ebe772d40a90ca89f2e165c495dc", size = 14494001, upload-time = "2025-11-18T20:22:59.131Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7f/6c/522e05388aa6fc66cf8ea46c6b29809a1a6f527ea864998b01ffb368ca36/botocore-1.40.76-py3-none-any.whl", hash = "sha256:fe425d386e48ac64c81cbb4a7181688d813df2e2b4c78b95ebe833c9e868c6f4", size = 14161738, upload-time = "2025-11-18T20:22:55.332Z" }, ] [[package]] name = "certifi" -version = "2025.8.3" +version = "2026.6.17" 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, upload-time = "2025-08-03T03:07:47.08Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, ] [[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, 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" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "cloudpickle" +version = "3.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/27/fb/576f067976d320f5f0114a8d9fa1215425441bb35627b1993e5afd8111e5/cloudpickle-3.1.2.tar.gz", hash = "sha256:7fda9eb655c9c230dab534f1983763de5835249750e85fbcef43aaa30a9a2414", size = 22330, upload-time = "2025-11-03T09:25:26.604Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/39/799be3f2f0f38cc727ee3b4f1445fe6d5e4133064ec2e4115069418a5bb6/cloudpickle-3.1.2-py3-none-any.whl", hash = "sha256:9acb47f6afd73f60dc1df93bb801b472f05ff42fa6c84167d25cb206be1fbf4a", size = 22228, upload-time = "2025-11-03T09:25:25.534Z" }, ] [[package]] @@ -380,36 +440,122 @@ wheels = [ { 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]] +name = "cuda-bindings" +version = "13.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cuda-pathfinder" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1f/5ef51f5fbaa5d4d3201bb3d7555af028ec1aa4416275ccbf73c9e34e3d2d/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9851b0caa8bfd3bc6fa054eaf57bea7c8e9c3a62db2d2621224677f49f3c53d0", size = 6675244, upload-time = "2026-05-29T23:11:38.664Z" }, + { url = "https://files.pythonhosted.org/packages/51/6b/457ca12dad3ee9bfcc9a545cfd6b64b359ba49de40f776f6e028e678f262/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c5879712accf6e14bb01aa5e67440eb84998b8d104b509cc7a6dc0b8f656a474", size = 6053539, upload-time = "2026-05-29T23:11:43.19Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/c5e3c34a409b148f5c0f5a4ea374158f95d488862c1dffedf9aa5c639df9/cuda_bindings-13.3.1-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04436a9364059c84b8f9636f359eccda1cf814341f5b670c71d80d2f79dbc708", size = 6674166, upload-time = "2026-05-29T23:11:45.478Z" }, + { url = "https://files.pythonhosted.org/packages/ce/67/5e7dba1ba576dd73da5dee894ca076ca5e959450dfff66d6d510a255d1f7/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7855c4868aabc0cfae28abbe83d56734bdfbd08f08fc234ac1912a12858bf49", size = 6025351, upload-time = "2026-05-29T23:11:49.685Z" }, + { url = "https://files.pythonhosted.org/packages/39/2a/6d2e9047d1fb243dbaa364b01e0297534b9ed7fd27dba1c9f361519cf69b/cuda_bindings-13.3.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e32d08f71ebcdf00f0f41eab2eb37e8da94c8ed411cc9f7f7a019ce6b34abe3a", size = 6657965, upload-time = "2026-05-29T23:11:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6e/2394f8163360f8391f8f1b7e72d300a82724edb81a7b7084c799fbd4c91f/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9efb21c1ee64981e184b9e0ba5eb3179e5ba3d4b51665a6cb52b8ef3d01a7cbf", size = 5920504, upload-time = "2026-05-29T23:11:56.883Z" }, + { url = "https://files.pythonhosted.org/packages/34/c2/ef9b6a63f7dc432712a462c816662e662e00d38caa9b861c8c2588195d03/cuda_bindings-13.3.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2732904099e0a4d4db774a5fc6d91ee95fae065b4d2ecabb4968c5fe2406c9d7", size = 6476660, upload-time = "2026-05-29T23:11:59.188Z" }, + { url = "https://files.pythonhosted.org/packages/b1/81/bff68ce829999c1e4209c761bbf903b1c06ec570416ddb25020864ad5907/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ab2f74ed65bfef4163ba07a8db16f1085e0729291db12a2423aff84ee8278b8", size = 6013639, upload-time = "2026-05-29T23:12:03.509Z" }, + { url = "https://files.pythonhosted.org/packages/d4/e0/c8a1f0c8f9ffdea4f5fe6dbab89b326cef4d85caf489dad39e209da89416/cuda_bindings-13.3.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd4c814d311ec08c981f6dded1dbe7d4b371067ee4f6c14cccec4bde9590f80", size = 6534419, upload-time = "2026-05-29T23:12:05.633Z" }, + { url = "https://files.pythonhosted.org/packages/52/b8/83b1f563925b290f2d11a01a77a84013ba56052fe3653a5bef3ccfbb43d6/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3c772dfff49681541d59630c90f858e173ac926b9c593a2b7123f2a1043cc76", size = 5809771, upload-time = "2026-05-29T23:12:10.422Z" }, + { url = "https://files.pythonhosted.org/packages/12/20/e79b4bfe98f075195afb6343d41c498f9dbd2d161d7021d4d28bceb83581/cuda_bindings-13.3.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36febb7c1079d68a981dbbd8d5a67235b399802b82075c9388624719607e52b9", size = 6358584, upload-time = "2026-05-29T23:12:12.767Z" }, +] + +[[package]] +name = "cuda-pathfinder" +version = "1.5.6" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/53/8fc9b0cdc5b7f62746e6a01b85b6461e5ae27f871010a5fcf8fa6950766d/cuda_pathfinder-1.5.6-py3-none-any.whl", hash = "sha256:7e4c07c117b78ba1fb35dac4c444d21f3677b1b1ff56175c53a8e3025c5b43c0", size = 52972, upload-time = "2026-06-30T00:58:04.34Z" }, +] + +[[package]] +name = "cuda-toolkit" +version = "13.0.3.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/c7/a79086a62c98befcdb8349656c6f114e2db3b8b2422f6e25c97a7f2a9a3c/cuda_toolkit-13.0.3.0-py2.py3-none-any.whl", hash = "sha256:d693caaa261214ddd7dbb60d68e71cbed884e68c2be7509778f3051da0b91c3f", size = 2512, upload-time = "2026-04-14T00:50:08.173Z" }, +] + +[package.optional-dependencies] +cublas = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cudart = [ + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufft = [ + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cufile = [ + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cupti = [ + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +curand = [ + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusolver = [ + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +cusparse = [ + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvjitlink = [ + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvrtc = [ + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] +nvtx = [ + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, +] + [[package]] name = "datafusion" -version = "50.1.0" +version = "54.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cloudpickle" }, { name = "pyarrow" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/fa/cc/e8e8f7c472e93e7a560203ac40ac319b926029007c0dad873dbba97f9f2d/datafusion-50.1.0.tar.gz", hash = "sha256:d8b8f027c7ce2498cda1589d3ce6d8720798963e031660fbe4d2e26e172442ec", size = 188103, upload-time = "2025-10-20T12:39:23.802Z" } +sdist = { url = "https://files.pythonhosted.org/packages/60/90/886f7e9cf827f07ebd60bd293e54e0a028a50dd49bbaef0ee42aae1981ea/datafusion-54.0.0.tar.gz", hash = "sha256:cfe7e8dfc026efc05824f49b53ad6a72caf5c2d6820759b6212a09e245a427ed", size = 276448, upload-time = "2026-06-29T11:19:34.816Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1f/6e/f9e2d5d935024a79fd549b5ce1d05549d26a027aab800727d492ac036504/datafusion-50.1.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:aeaa3c7bcf630bbea962b8fe75d300d98eaf7e2a5edf98e6a0130a1bec3543ea", size = 29280689, upload-time = "2025-10-20T12:39:06.913Z" }, - { url = "https://files.pythonhosted.org/packages/db/58/2dc473240f552d3620186b527c04397f82b36f02243afaf49f0813c84a17/datafusion-50.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:85727df82c818103092c3ee18d198365833d3e44c2921d2b378d4d682798e511", size = 26140751, upload-time = "2025-10-20T12:39:09.95Z" }, - { url = "https://files.pythonhosted.org/packages/00/ba/8d8aa1df96e0666752e5c9d406d440495df2014d315b2a95bbef9856b23e/datafusion-50.1.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49f5bd0edb2bf2d00625beeb46a115e1421db2e1b14b535f7c17cc0927f36b8a", size = 32165290, upload-time = "2025-10-20T12:39:13.713Z" }, - { url = "https://files.pythonhosted.org/packages/11/9a/afce9586145b3ed153d75364b21102a6a95260940352e06b7c6709e9d2db/datafusion-50.1.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5c9c2f70922ddedf54d8abd4ba9585a5026c3409438f5aafc1ad0428a67a4d1f", size = 29982398, upload-time = "2025-10-20T12:39:16.823Z" }, - { url = "https://files.pythonhosted.org/packages/51/a3/41ef1c565770ef0c4060ee3fd50367dd06816f70a5be1ef41fbd7c3975e8/datafusion-50.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:145c8f2e969c9cc51dc6af8a185ec39739ebeb5d680f9fe0020e005564ed40a8", size = 31258359, upload-time = "2025-10-20T12:39:21.731Z" }, + { url = "https://files.pythonhosted.org/packages/46/58/4c5b981e3d9ade32a906c15a4941eef50c9b862781cdc14bf4dff48d026a/datafusion-54.0.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:946f55e48b8d523d7b4ac106bdf588b4493c2c66f81877d6952aafeaf7c3ec73", size = 39810553, upload-time = "2026-06-29T11:19:02.1Z" }, + { url = "https://files.pythonhosted.org/packages/66/e5/5e4dbd42ce9a2affb3be90d9ab17cebde1a6f28b0d9fb4b83d612d5c8e42/datafusion-54.0.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:2a3bf43185c7e43e25242e5fb17b6a11b86bf976434c0bc493fdedbd9a080969", size = 37145255, upload-time = "2026-06-29T11:19:05.491Z" }, + { url = "https://files.pythonhosted.org/packages/c6/5e/dbb9e6e3e5006d34f295d7ac73f1302c8f2df140666402a06e6c55028edb/datafusion-54.0.0-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9432bf162381e9282cbc74915b8b773895de18be836f7e3f6d0de4d981f24630", size = 38853856, upload-time = "2026-06-29T11:19:08.732Z" }, + { url = "https://files.pythonhosted.org/packages/a8/81/e69008e3479f4d0134875bc4ae39503bedcd55ca2597e71392c963c651b4/datafusion-54.0.0-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3bcd4d213fa74710e75e6e182cc468c2bdbc5ffc74a08c8155d414fbbfa1b3f6", size = 41050149, upload-time = "2026-06-29T11:19:12.108Z" }, + { url = "https://files.pythonhosted.org/packages/61/d4/8ba6e3fe3291c9ccc94b5ca3ec3c1fbcbfbe5ece5ffb965e4550844e2c56/datafusion-54.0.0-cp310-abi3-win_amd64.whl", hash = "sha256:b934e097e1bdca7d5768a81ac1bc4a1812cb459269f8b1a5d892a5d930f18376", size = 43444869, upload-time = "2026-06-29T11:19:15.963Z" }, + { url = "https://files.pythonhosted.org/packages/9d/41/5608323226f21a0fa180823c531dbc0ed270e9b694f299b7647505cb6a06/datafusion-54.0.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4e79048da82ad89b768bd0be7df39254cd2a0afe2b719d1f129e8a7229af683", size = 39796248, upload-time = "2026-06-29T11:19:19.208Z" }, + { url = "https://files.pythonhosted.org/packages/18/81/392ee323104ab14ca689384723b69e137064a828233c165574f97a74c0e9/datafusion-54.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:fe57038003b18e28b90752c1e32b44af74ec4f552a1904aee725e1129a00c447", size = 37153577, upload-time = "2026-06-29T11:19:22.397Z" }, + { url = "https://files.pythonhosted.org/packages/40/c4/ebd5ef5349ecbea7f5f9da76c213581c13e7bbe1b5735c9925b279eeb4eb/datafusion-54.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:574f642832a106456cfc4f32aa82484c504fc32f4be2b510202bcb579de8e6d1", size = 38849839, upload-time = "2026-06-29T11:19:25.783Z" }, + { url = "https://files.pythonhosted.org/packages/5a/b9/2383d30d317bb913cab97dbf2e6e1d5f37f594860d5c5bc176e025cf7d4a/datafusion-54.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:796fd5683927443c5bc61999d00b9007ef9b5ce107725ea8d241df718860985d", size = 41074623, upload-time = "2026-06-29T11:19:29.119Z" }, + { url = "https://files.pythonhosted.org/packages/35/5c/553fd1107dede0a56727fda7216a7198d41394f2d19697f4fb104cc695ea/datafusion-54.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:64973c63874ec31670dd97b32b18af7b07fad679cb20d58ed154038e3a5c204e", size = 43438801, upload-time = "2026-06-29T11:19:32.799Z" }, ] [[package]] name = "datasets" -version = "4.1.1" +version = "4.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "dill" }, { name = "filelock" }, { name = "fsspec", extra = ["http"] }, + { name = "httpx" }, { name = "huggingface-hub" }, { name = "multiprocess" }, - { 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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "packaging" }, { name = "pandas" }, { name = "pyarrow" }, @@ -418,9 +564,21 @@ dependencies = [ { 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, upload-time = "2025-09-18T13:14:27.108Z" } +sdist = { url = "https://files.pythonhosted.org/packages/57/13/f05a80bbbac5f62e492e5e463ec59a4479647ef9c376b1fdfaa4d3ed01cc/datasets-4.4.0.tar.gz", hash = "sha256:0430d39b9f13b53c37afb80c23c7e5d8c6ceccc014c14a14d15fa2b4e8688d2a", size = 585143, upload-time = "2025-11-04T10:36:22.73Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/31/d552336985f747b19f0a852d98ca7a2ef4727ba956b38041cfbda08dde0a/datasets-4.4.0-py3-none-any.whl", hash = "sha256:b7e6d1d48c2e1d3a95d6b378e8fc3d7ab29f24f14ddf505a8d417dd09c692f19", size = 511463, upload-time = "2025-11-04T10:36:20.062Z" }, +] + +[[package]] +name = "deprecated" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "wrapt" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" }, ] [[package]] @@ -434,190 +592,204 @@ wheels = [ [[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, 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" }, +version = "1.5.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/19/e57151753576373c6696a12022648546cca6038e8833fda2908ee2342d9b/duckdb-1.5.5.tar.gz", hash = "sha256:72f33ee57ca7595b23957671a2cc7f7fe2be0ecc2d68f63abedcfcaa3a5c1238", size = 18066741, upload-time = "2026-07-22T10:55:17.819Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d4/298acf9331a80b3ce6ac64dd940e7e13f4058fb69d18914445f02e3c7bfe/duckdb-1.5.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:3b805507f88171b428b21c966c30e9a3d54e30b24528918a44ed0032542bc26f", size = 32702934, upload-time = "2026-07-22T10:53:19.069Z" }, + { url = "https://files.pythonhosted.org/packages/d5/90/c489fb63d64b2e7ee109ce8460bdede003a0f256e5b41a03a2a1c4764058/duckdb-1.5.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b08e19cc856220d8a26fa62abc2264b349aff67255e9373c6a3f607addd56dc6", size = 17343604, upload-time = "2026-07-22T10:53:22.767Z" }, + { url = "https://files.pythonhosted.org/packages/0b/27/effa80a15b1f0c61c235622f797868485359e8c9ad6a8e358e7a0c479151/duckdb-1.5.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a17e6a922e42a5c06ed2353fe78c5dff2610f6632d603836f9606ad0bf754079", size = 15488179, upload-time = "2026-07-22T10:53:25.945Z" }, + { url = "https://files.pythonhosted.org/packages/5d/07/21212345c8d24ba62dceaa20be3b21f5c46f1510b1b42ce93bb058afe0c4/duckdb-1.5.5-cp310-cp310-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1bdc38922c365c37720149f90d90b1e9823eb82dad6830855b5f87537fa6fc0c", size = 19367323, upload-time = "2026-07-22T10:53:30.23Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d0/10371ae875fb4b5ef61bb892743b4b2e90c512b371fdf29317deb744857d/duckdb-1.5.5-cp310-cp310-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e238060db5ca59879882a6e9b015e2c65d5c64ddf281ba1d7a9a2033764152cf", size = 21476568, upload-time = "2026-07-22T10:53:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/09568ce617dd7bc0757b3d7b6a981660b9e4f0b7594de8ed776755eae740/duckdb-1.5.5-cp310-cp310-win_amd64.whl", hash = "sha256:4acc72798ba1885a9c17d1242903d2cd502f13b1271c7677f7cab25d8578eceb", size = 13156129, upload-time = "2026-07-22T10:53:37.55Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c2/b62ec24d57bb8df4e24b0b58f7f8facb32f5fdb9f1895aed9e9fcdded168/duckdb-1.5.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b543841b0ae18a9c982345cfa3987e9c065d3a4b0f067daa473d92d1e65f528", size = 32708371, upload-time = "2026-07-22T10:53:41.642Z" }, + { url = "https://files.pythonhosted.org/packages/8a/ce/769171ba45f0b73632dc3bc3108d891e81dd6c6bbfba630a34a75b4dcc0f/duckdb-1.5.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a925d06c2a4c3b64553d6cc1aced5028d376d4479bed689a7d47e9b1dccd80a", size = 17343979, upload-time = "2026-07-22T10:53:44.951Z" }, + { url = "https://files.pythonhosted.org/packages/46/59/a8e3384ee916e00d5dcf985194c1511d61978540778a1e96fa47f9fb3e0d/duckdb-1.5.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0c42757cb34722144bd4dfb94b6f336339e7b2468f6813fa7fa9a319ba07bab4", size = 15493704, upload-time = "2026-07-22T10:53:47.912Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1d/9840179c2607b90523a2884a129c4d4e6dbdc1178ba62a976c1043beba88/duckdb-1.5.5-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e72f9e1a4f90a5c8483ad4d540e495bf0834ba61c360b52499a573d7ed62a3f", size = 19366574, upload-time = "2026-07-22T10:53:51.876Z" }, + { url = "https://files.pythonhosted.org/packages/b5/55/f9641a4eebcc2f4df631287d6c3b9ed2eea3b92644f93acbad825e3972b6/duckdb-1.5.5-cp311-cp311-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b9b6f86ed85d4ef5e0211eaebf75d057bd8bb520bba438a95dd0f4e42234bbfe", size = 21477952, upload-time = "2026-07-22T10:53:55.575Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3a/07c3556e37a5c97b95917b029c8fdde4a25fbd76a660bacdac195cf20dcb/duckdb-1.5.5-cp311-cp311-win_amd64.whl", hash = "sha256:9f4287f97ccf0c1f3d471e7115be2b067cbf99627e2d34bffd462dd64703cddc", size = 13156986, upload-time = "2026-07-22T10:53:58.823Z" }, + { url = "https://files.pythonhosted.org/packages/4f/ff/07b48eef2078ca033847e9caa46cc7633b714c5f91ad1ce091c8ca89d792/duckdb-1.5.5-cp311-cp311-win_arm64.whl", hash = "sha256:179633a3fc6296c75d57c69c1e239fa9e5cdcb670fd1dbff88a02663f932905c", size = 14001317, upload-time = "2026-07-22T10:54:01.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/40/2e05d324400fdaa5656c9f48d6298da421cb034d85e509fa0e6e325cf04b/duckdb-1.5.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d4dd65f8941a604b947e0b9b4b4f7165988e29a23ec0b69b4038520956d9933e", size = 32753858, upload-time = "2026-07-22T10:54:05.514Z" }, + { url = "https://files.pythonhosted.org/packages/79/15/5ceb58ffb5bb8a62b3fd7abb39c41467cdf94850ece02e6d88664dfc75ce/duckdb-1.5.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:33db46679b071f108d57139493dee2d37e1f5efcf5c5c039c2969eed11a6c8a7", size = 17368293, upload-time = "2026-07-22T10:54:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/5c/bf02da0b354fe83cca4f95a4fbf762181af466f7d551ab2a093f7698882a/duckdb-1.5.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f0b88535a5d86fdd63dba6ea02ab68c003dfb9e4892b11256ef24c4da208baae", size = 15509131, upload-time = "2026-07-22T10:54:12.228Z" }, + { url = "https://files.pythonhosted.org/packages/ea/a9/5f1f09da421d8e930e0b063d11c1b3f90363f40ede74438cd188afdd13a2/duckdb-1.5.5-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f316eae2323d9a851883fdf2dee91c1f9efe251ab33e14a2272f82a913422ed6", size = 19391959, upload-time = "2026-07-22T10:54:15.551Z" }, + { url = "https://files.pythonhosted.org/packages/4f/98/6549769f158126fa64fd6c1ac2eb59a18282146c939867a3eb31b7c1db07/duckdb-1.5.5-cp312-cp312-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7a6d2d11859d82a936ebdcb30ce3d8a1cbb3e990bff05c12abb9b54c44fa7bd1", size = 21510909, upload-time = "2026-07-22T10:54:19.681Z" }, + { url = "https://files.pythonhosted.org/packages/af/b7/5753b41d3124838f868f9f523362812d9fc45409e9e4dd70dcbb0a25826e/duckdb-1.5.5-cp312-cp312-win_amd64.whl", hash = "sha256:ddfbdb096c11d51ee22492397d342c90a82e62c5d09961477895934d0a25372f", size = 13168544, upload-time = "2026-07-22T10:54:22.789Z" }, + { url = "https://files.pythonhosted.org/packages/5c/28/44b679c7d46245f8398feae7edac959d1b83d4eb143e25b3fce0630b78bd/duckdb-1.5.5-cp312-cp312-win_arm64.whl", hash = "sha256:2725d2b9ace3a4e75d72fc5a239f6a44b502c580edadb8fb2676db772c5f9282", size = 13988684, upload-time = "2026-07-22T10:54:26.003Z" }, + { url = "https://files.pythonhosted.org/packages/47/37/4a38116e7700720fd152c666292214fd3abdf916496991296d8d1f66efbf/duckdb-1.5.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd98829b67788609017e65c761bd42a5dd0f9129441bed8bda4d6881ccf819f0", size = 32754294, upload-time = "2026-07-22T10:54:29.822Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/7d392f1ba1eee0eaf4ab4c8c7a604bfe3536cd63f979cf5c98798664f807/duckdb-1.5.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:feead93c56679b79592d437c62975d39cb67adedffa7592c763baf8160ac7366", size = 17368211, upload-time = "2026-07-22T10:54:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/9f/a5/0a6f4fa60562faa615e55e15bd1953a2f2b17a8edd8105e5cda215e43457/duckdb-1.5.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:49c963d9469373d7aba8d750d9ea565ab823e94166efed953f184dd9b169b98c", size = 15509136, upload-time = "2026-07-22T10:54:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/e4/cb/023c89f51978545b9fab318581bba0c457a58e7530d2d933e54ae7d8647c/duckdb-1.5.5-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a736217825461732b5442d05a220f3da2e23a0dae114efbf08c9bf171b53098a", size = 19392147, upload-time = "2026-07-22T10:54:39.551Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c5/41bef391fb8b23dbc133c9f2ba016e7a7a8124513d2cc1b430f1897d87e4/duckdb-1.5.5-cp313-cp313-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:078e6a60dd8eedde5832f45422ca5c4a6b8c837aeabd8a56ca0b7d933f588053", size = 21511060, upload-time = "2026-07-22T10:54:42.788Z" }, + { url = "https://files.pythonhosted.org/packages/07/9f/c44dfc1f924ac29b3252dc1b91393c01d009dbfe9f8ed33f10b986151bd1/duckdb-1.5.5-cp313-cp313-win_amd64.whl", hash = "sha256:6826504277dba513c0c5d71d828456c94d729c9d2482f94b2e289f90a9167e28", size = 13168028, upload-time = "2026-07-22T10:54:46.127Z" }, + { url = "https://files.pythonhosted.org/packages/ca/88/591384b2cd59abddd6f5dc175e60374f9abae6064429f0c4402854c10f44/duckdb-1.5.5-cp313-cp313-win_arm64.whl", hash = "sha256:baa9c5702002fabb559ded2a39008f9f421fcbc7237d388b8213eff1e08858de", size = 13989955, upload-time = "2026-07-22T10:54:49.262Z" }, + { url = "https://files.pythonhosted.org/packages/3e/56/12c65bfa2d2605b81981b264788891bcf11ec72227889554cead5d8d13b9/duckdb-1.5.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8e6413dd40facb7b8ab21bd844450cd8f549b29e138635be9cf090ef4d2049e2", size = 32761946, upload-time = "2026-07-22T10:54:53.412Z" }, + { url = "https://files.pythonhosted.org/packages/b9/46/682ce155f17e0d2822d4f13ee3db9ca4b5b7c2da61b841b2629035e1f4bc/duckdb-1.5.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:64078acfd16541132ac6e191eb81b2845554444a0305cc1aa581ba107e514aa8", size = 17375069, upload-time = "2026-07-22T10:54:57.269Z" }, + { url = "https://files.pythonhosted.org/packages/39/ce/a24bcbd3289c8f305a430759c5fc12242740b4af3e17f7593f3a34e333d2/duckdb-1.5.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8c11775cc99a447618d5f1840126db17f2652f3eae05529df4f81f40e2df7151", size = 15519791, upload-time = "2026-07-22T10:55:00.681Z" }, + { url = "https://files.pythonhosted.org/packages/d9/76/3a01afbc615c1d418c0de58a6b68ac5ce2a8563232c0464bfbc2ce552398/duckdb-1.5.5-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:77bbc1e6ba12e1e06f9020117bdf848627ecfdf36f907550e62e008e6109dece", size = 19398251, upload-time = "2026-07-22T10:55:04.168Z" }, + { url = "https://files.pythonhosted.org/packages/a1/43/3a5e81d1728f4d234c79bfe385808ee7c04834f7c37a4b5c257459c25614/duckdb-1.5.5-cp314-cp314-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fbf0f2d48b43c6c304d00463b463c27ead6c4b01c3c1816b750f728decf71afe", size = 21513851, upload-time = "2026-07-22T10:55:07.864Z" }, + { url = "https://files.pythonhosted.org/packages/91/41/fc7c829172c60ca22485251eab285f4f1a0d87b486a024c726f21471d86e/duckdb-1.5.5-cp314-cp314-win_amd64.whl", hash = "sha256:9dc826c4b50e64f6c4e4d07a3a9cb075ef70ba3899dc43ec5493dc3d7b04b353", size = 13691858, upload-time = "2026-07-22T10:55:11.181Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2c/95d9216b79e9273689d7ebce125a54503ed0c9bd7da931f0265888e99779/duckdb-1.5.5-cp314-cp314-win_arm64.whl", hash = "sha256:63e48d4b74b15aeacd688976432a7225163df8c226eddeb8536bba2d4d4ff433", size = 14470180, upload-time = "2026-07-22T10:55:14.445Z" }, ] [[package]] name = "exceptiongroup" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] [[package]] -name = "filelock" -version = "3.19.1" +name = "execnet" +version = "2.1.2" 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, upload-time = "2025-08-14T16:56:03.016Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, ] [[package]] -name = "flatbuffers" -version = "25.9.23" +name = "filelock" +version = "3.29.7" 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, upload-time = "2025-09-24T05:25:30.106Z" } +sdist = { url = "https://files.pythonhosted.org/packages/35/94/00f2059e4835eace3ae8fde680b932c496f8ec7bdc99168dfa53fb2e6b79/filelock-3.29.7.tar.gz", hash = "sha256:5b481979797ae69e72f0b389d89a80bdd585c260c5b3f1fb9c0a5ba9bb3f195d", size = 71521, upload-time = "2026-07-08T05:46:58.716Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/60/02/be4a57b60c7149b55b9e3b3c13f609cd8eb5307c751f22bd8fb8d262e75b/filelock-3.29.7-py3-none-any.whl", hash = "sha256:987db6f789a3a2a59f55081801b2b3697cb97e2a736b5f1a9e99b559285fbc51", size = 46036, upload-time = "2026-07-08T05:46:57.53Z" }, ] [[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, 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" }, +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/4a/557715d5047da48d54e659203b9335be7bfaafda2c3f627b7c47e0b3aaf3/frozenlist-1.8.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b37f6d31b3dcea7deb5e9696e529a6aa4a898adc33db82da12e4c60a7c4d2011", size = 86230, upload-time = "2025-10-06T05:35:23.699Z" }, + { url = "https://files.pythonhosted.org/packages/a2/fb/c85f9fed3ea8fe8740e5b46a59cc141c23b842eca617da8876cfce5f760e/frozenlist-1.8.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ef2b7b394f208233e471abc541cc6991f907ffd47dc72584acee3147899d6565", size = 49621, upload-time = "2025-10-06T05:35:25.341Z" }, + { url = "https://files.pythonhosted.org/packages/63/70/26ca3f06aace16f2352796b08704338d74b6d1a24ca38f2771afbb7ed915/frozenlist-1.8.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a88f062f072d1589b7b46e951698950e7da00442fc1cacbe17e19e025dc327ad", size = 49889, upload-time = "2025-10-06T05:35:26.797Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ed/c7895fd2fde7f3ee70d248175f9b6cdf792fb741ab92dc59cd9ef3bd241b/frozenlist-1.8.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f57fb59d9f385710aa7060e89410aeb5058b99e62f4d16b08b91986b9a2140c2", size = 219464, upload-time = "2025-10-06T05:35:28.254Z" }, + { url = "https://files.pythonhosted.org/packages/6b/83/4d587dccbfca74cb8b810472392ad62bfa100bf8108c7223eb4c4fa2f7b3/frozenlist-1.8.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:799345ab092bee59f01a915620b5d014698547afd011e691a208637312db9186", size = 221649, upload-time = "2025-10-06T05:35:29.454Z" }, + { url = "https://files.pythonhosted.org/packages/6a/c6/fd3b9cd046ec5fff9dab66831083bc2077006a874a2d3d9247dea93ddf7e/frozenlist-1.8.0-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c23c3ff005322a6e16f71bf8692fcf4d5a304aaafe1e262c98c6d4adc7be863e", size = 219188, upload-time = "2025-10-06T05:35:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ce/80/6693f55eb2e085fc8afb28cf611448fb5b90e98e068fa1d1b8d8e66e5c7d/frozenlist-1.8.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8a76ea0f0b9dfa06f254ee06053d93a600865b3274358ca48a352ce4f0798450", size = 231748, upload-time = "2025-10-06T05:35:32.101Z" }, + { url = "https://files.pythonhosted.org/packages/97/d6/e9459f7c5183854abd989ba384fe0cc1a0fb795a83c033f0571ec5933ca4/frozenlist-1.8.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c7366fe1418a6133d5aa824ee53d406550110984de7637d65a178010f759c6ef", size = 236351, upload-time = "2025-10-06T05:35:33.834Z" }, + { url = "https://files.pythonhosted.org/packages/97/92/24e97474b65c0262e9ecd076e826bfd1d3074adcc165a256e42e7b8a7249/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:13d23a45c4cebade99340c4165bd90eeb4a56c6d8a9d8aa49568cac19a6d0dc4", size = 218767, upload-time = "2025-10-06T05:35:35.205Z" }, + { url = "https://files.pythonhosted.org/packages/ee/bf/dc394a097508f15abff383c5108cb8ad880d1f64a725ed3b90d5c2fbf0bb/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:e4a3408834f65da56c83528fb52ce7911484f0d1eaf7b761fc66001db1646eff", size = 235887, upload-time = "2025-10-06T05:35:36.354Z" }, + { url = "https://files.pythonhosted.org/packages/40/90/25b201b9c015dbc999a5baf475a257010471a1fa8c200c843fd4abbee725/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:42145cd2748ca39f32801dad54aeea10039da6f86e303659db90db1c4b614c8c", size = 228785, upload-time = "2025-10-06T05:35:37.949Z" }, + { url = "https://files.pythonhosted.org/packages/84/f4/b5bc148df03082f05d2dd30c089e269acdbe251ac9a9cf4e727b2dbb8a3d/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e2de870d16a7a53901e41b64ffdf26f2fbb8917b3e6ebf398098d72c5b20bd7f", size = 230312, upload-time = "2025-10-06T05:35:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/db/4b/87e95b5d15097c302430e647136b7d7ab2398a702390cf4c8601975709e7/frozenlist-1.8.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:20e63c9493d33ee48536600d1a5c95eefc870cd71e7ab037763d1fbb89cc51e7", size = 217650, upload-time = "2025-10-06T05:35:40.377Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/78a0315d1fea97120591a83e0acd644da638c872f142fd72a6cebee825f3/frozenlist-1.8.0-cp310-cp310-win32.whl", hash = "sha256:adbeebaebae3526afc3c96fad434367cafbfd1b25d72369a9e5858453b1bb71a", size = 39659, upload-time = "2025-10-06T05:35:41.863Z" }, + { url = "https://files.pythonhosted.org/packages/66/aa/3f04523fb189a00e147e60c5b2205126118f216b0aa908035c45336e27e4/frozenlist-1.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:667c3777ca571e5dbeb76f331562ff98b957431df140b54c85fd4d52eea8d8f6", size = 43837, upload-time = "2025-10-06T05:35:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/75/1135feecdd7c336938bd55b4dc3b0dfc46d85b9be12ef2628574b28de776/frozenlist-1.8.0-cp310-cp310-win_arm64.whl", hash = "sha256:80f85f0a7cc86e7a54c46d99c9e1318ff01f4687c172ede30fd52d19d1da1c8e", size = 39989, upload-time = "2025-10-06T05:35:44.596Z" }, + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] [[package]] name = "fsspec" -version = "2025.9.0" +version = "2025.10.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, upload-time = "2025-09-02T19:10:49.215Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/7f/2747c0d332b9acfa75dc84447a066fdf812b5a6b8d30472b74d309bfe8cb/fsspec-2025.10.0.tar.gz", hash = "sha256:b6789427626f068f9a83ca4e8a3cc050850b6c0f71f99ddb4f542b8266a26a59", size = 309285, upload-time = "2025-10-30T14:58:44.036Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/eb/02/a6b21098b1d5d6249b7c5ab69dde30108a71e4e819d4a9778f1de1d5b70d/fsspec-2025.10.0-py3-none-any.whl", hash = "sha256:7c7712353ae7d875407f97715f0e1ffcc21e33d5b24556cb1e090ae9409ec61d", size = 200966, upload-time = "2025-10-30T14:58:42.53Z" }, ] [package.optional-dependencies] @@ -625,80 +797,63 @@ http = [ { name = "aiohttp" }, ] -[[package]] -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, 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" }, -] - [[package]] name = "geoarrow-rust-core" -version = "0.6.1" +version = "0.6.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arro3-core" }, - { 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.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { 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, 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" }, + { url = "https://files.pythonhosted.org/packages/70/a7/9de5cdcb86089ef4d9a24940838a72ef0655d5be11b46dc4ee807b0d7772/geoarrow_rust_core-0.6.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:e1dbbca927858c05ef4eaa5e13a3977a62183cfa3f17fe7b19dd2d88ecf24e91", size = 3855749, upload-time = "2026-06-11T19:24:32.965Z" }, + { url = "https://files.pythonhosted.org/packages/54/48/da86c2bd1db71849f003f5a8eb78ce54f7a33341d5b33ddcdb480b5aafb4/geoarrow_rust_core-0.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ce7e126d340f335bcc108327cbf7264539e856cb6a299f59757a6ee8329f6643", size = 3710538, upload-time = "2026-06-11T19:24:34.925Z" }, + { url = "https://files.pythonhosted.org/packages/f6/65/7f8ecc05447a85f14643170de8a29715e7c3e732fbb7132617772d39eac7/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88eb7982c1345fc4c4b18d9895602f0148c9495fe7ac00df03a92c20c8058149", size = 4198382, upload-time = "2026-06-11T19:24:37.02Z" }, + { url = "https://files.pythonhosted.org/packages/41/57/b11fbb277fab166d8a8940bc1151bbd1aeef537e70c55f495ff85178f827/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c8368b91d4cab5cb5ad1b0f7369da4cec196d82bf73aa3823618a99c1bd4cf04", size = 4270350, upload-time = "2026-06-11T19:24:38.726Z" }, + { url = "https://files.pythonhosted.org/packages/6d/16/0c35e5aff4aca77d818b28d79f9ce20fe1c282ef26d6a2fcc764f3a55f26/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2955d82d0204197c8e96adbfb70f252fa5987821dd8f202e712a84bfb5b876d3", size = 5602389, upload-time = "2026-06-11T19:24:40.198Z" }, + { url = "https://files.pythonhosted.org/packages/e0/06/58e4d0c94f7d8897ca5e2469fe5db0dd937bfc3cd676dea43c6ce488effe/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cacbc2231b03c674975d5a25ff549c367dd8c07147c41edb5461c8ebda693739", size = 4414385, upload-time = "2026-06-11T19:24:41.779Z" }, + { url = "https://files.pythonhosted.org/packages/09/65/902e986d01d4978e752c1d0d5b15873de712321ce3f61c285f491e4149b9/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1f5726fd638563d11dfefd7d17dd769e679ac1efb868178791573de19d16b41f", size = 4251263, upload-time = "2026-06-11T19:24:43.556Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f1/b1e0f93ea5288706f08ac7c01f332eb0feaa128251f3c2c9896e5f42cba5/geoarrow_rust_core-0.6.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:df7a0319cdec5d0e4ffc3f17a171e16787e7719f85f82c8cf0035d873ec31e62", size = 4747229, upload-time = "2026-06-11T19:24:45.281Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f3/77ebd20cb5cf5eb18c5bb0e32e07f76ec915a728ea123e075365f0b6c53c/geoarrow_rust_core-0.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:19ce5fb18025480461253d0a03f20cbb635163214b5f193b0700bc1a407dfe4d", size = 3601298, upload-time = "2026-06-11T19:24:46.721Z" }, + { url = "https://files.pythonhosted.org/packages/02/a8/d50e482a56d9543119be40000bc405b725242b6056809bbee3a75eff2411/geoarrow_rust_core-0.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d91b5249d5e1da53a79268759601c107beb69a8944dd3b5b225e9515ab63d519", size = 3856056, upload-time = "2026-06-11T19:24:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/04/e3/f4de7795959d95d88b32b85740d5d2d6b0a2e17233258f0331aee6cb7b13/geoarrow_rust_core-0.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:14412f02c1e60c92d2f88bc9f92835cf6d80f1da37fe8ba462eafdb7bd570f3c", size = 3710092, upload-time = "2026-06-11T19:24:49.802Z" }, + { url = "https://files.pythonhosted.org/packages/b4/48/04888477c2a12fbe6a6f8898bd026facdc3a929b4e747d7b569e6d20dd58/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc5d6db2341568b1e44678ccc0ade1ca1e7660a2c186ebf8bf847acdb160f2cf", size = 4197891, upload-time = "2026-06-11T19:24:51.245Z" }, + { url = "https://files.pythonhosted.org/packages/fb/2d/c16b6eb6f9f2ab213dcd0cd2ac0dec2eae1e2ce5922b3fbeb7bb1ac2a865/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:45f4193b9d6f6caae969d8448f3687a19f0998d757519a091df609c06ffa68a0", size = 4269771, upload-time = "2026-06-11T19:24:52.781Z" }, + { url = "https://files.pythonhosted.org/packages/47/fd/2ee73341c37d554ce8d0b67a95525700ec32194fa785261c17262afadfc8/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bf9ca054562fb4610c8e5ea140fa1bf746ccc16de505d3a5684abd2fa11f9538", size = 5601846, upload-time = "2026-06-11T19:24:54.63Z" }, + { url = "https://files.pythonhosted.org/packages/67/05/229234ae7bf1d39306e41896f3055a2ae847707ce58f21bd0872b9a5764e/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ec9530fefb653f9a2e605cc26fc1c0d1ffa5c4923ec1037323ba9a16744f8ccc", size = 4413741, upload-time = "2026-06-11T19:24:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/eb/5a/7875548a48231b02f909d3d8c7d74ba47867b2af3396e7aed59cd3b2b40d/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2813aceabb29567d96f29fd2d3099d6f8decd0f5f968ff81ed1a664751dc84a3", size = 4251434, upload-time = "2026-06-11T19:24:57.527Z" }, + { url = "https://files.pythonhosted.org/packages/bf/46/ed0370def1a950f185edda603a02276bb412a9c95ad5a052c9e919b2df78/geoarrow_rust_core-0.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:49686767d1379ff3b165f9d35a73e96fc25daba786ce27cf3359c5feac880fd0", size = 4746598, upload-time = "2026-06-11T19:24:58.979Z" }, + { url = "https://files.pythonhosted.org/packages/44/bc/3a1720be855d7d0011416b7f0a7b7e33546b0fc7320faf59b05e401adff7/geoarrow_rust_core-0.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:fd9cc8c47af736dd087575306088e73b28a720f52e5c3342968851ddd2fb5778", size = 3601329, upload-time = "2026-06-11T19:25:00.459Z" }, + { url = "https://files.pythonhosted.org/packages/24/b2/65db3af5fcc7d64ac7ac86d7debc6a90803bb076c8f7d4599c167be79fd6/geoarrow_rust_core-0.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:86aaa60e5b6d99be08f9adc9e58bd088135e1dcfebd290085228ed8a0e93e90f", size = 3848323, upload-time = "2026-06-11T19:25:02.079Z" }, + { url = "https://files.pythonhosted.org/packages/27/9a/37bdd36d7feb9d591b9ccdc1952c6171b04dc777b999e2082b810eb1dd45/geoarrow_rust_core-0.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fec148cd63e616d9a7aa00c4ab08693eeec55aca7c9d700aa6451cd8001d0e08", size = 3707679, upload-time = "2026-06-11T19:25:03.594Z" }, + { url = "https://files.pythonhosted.org/packages/45/b7/8d2998284de21d0feb2a0935c41636f8ebf2b65723d8139026e7f9f3d5e8/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b1944f3d548b6296e9fbd668602accae0ad68e49ee0f5b8df9e7ea4f474e4ae", size = 4190279, upload-time = "2026-06-11T19:25:05.21Z" }, + { url = "https://files.pythonhosted.org/packages/25/f3/140209f53a70f261ef1459b08eea25c4edef3ad9f6ec0924033b5285ee7e/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f5c04195cbedf5d1684a50203e862d979cda0d6218aac32f607d6e3f7cd65c8", size = 4264876, upload-time = "2026-06-11T19:25:06.654Z" }, + { url = "https://files.pythonhosted.org/packages/14/32/0097bfb92816ef91b38f7e757f65fe8456e56152ca51cd7a05b1be8a2e40/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:671c6be9cbc68295a68598fc8c6ddd875de063a795d64b2cfd10d36abd1ee324", size = 5586563, upload-time = "2026-06-11T19:25:08.376Z" }, + { url = "https://files.pythonhosted.org/packages/fd/86/508fe299aa44afe95399d9fa73cdbc7a451841803b8f1431e8c3d0b26ec1/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5f4726fbe09d545a507993f2f76c2be7812fef3c20c994ff33c32aaa96aaa212", size = 4402886, upload-time = "2026-06-11T19:25:10.302Z" }, + { url = "https://files.pythonhosted.org/packages/46/81/fc34afcce2b0f17424610405481f69f3c6e4d670c5c94170d71ed6719794/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0fa37a90312e7ca06921be56cee183c12c442b345fadd982480cd1f8ed2eede", size = 4247331, upload-time = "2026-06-11T19:25:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0d/af42431f80282a2f7e1f3e496c39483dd2362e11f8008c65033be9d2ba4c/geoarrow_rust_core-0.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3f41a8c0a9f3558d73537dcad83c88b29c2a169bcc7766dc677e8245a98a5e95", size = 4741954, upload-time = "2026-06-11T19:25:13.964Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e5/be80aa4384f16be6a20828fd4cc67da18bd2266366f80c9bfefa481559f8/geoarrow_rust_core-0.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:382f0914c75d84b87420aef7b6f11e8b5d4d58b5f5db7c8d199815e4dd282a42", size = 3599115, upload-time = "2026-06-11T19:25:15.357Z" }, + { url = "https://files.pythonhosted.org/packages/19/52/93bbf15979ce656d09821f02f82420957fdc99ee4cd37e5e2d8c99a324da/geoarrow_rust_core-0.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:c11190008ed6a571b8ca4ef769198e95434dbe7c3caefa9acd5f0ceba1ed868f", size = 3848682, upload-time = "2026-06-11T19:25:16.914Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1e/1665171a3756b1977b7240a8f518bbbdfa778dcc156e0f90d659723468fb/geoarrow_rust_core-0.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:1308ad09912fb67a43ff7dd7dbc685ca8a8fbd8028d3876eb187b6b082a98a7b", size = 3707868, upload-time = "2026-06-11T19:25:22.483Z" }, + { url = "https://files.pythonhosted.org/packages/ec/38/e344ccb72473b8756c8f2dae3a8a9339e1821884a2a50befbad45150d178/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1247b961c61656596631ca3380d405f8d0a2f60f045f8b8a3a335b1a849dc55", size = 4189835, upload-time = "2026-06-11T19:25:24.116Z" }, + { url = "https://files.pythonhosted.org/packages/22/10/bc92b9fcdc628fa1ff7e234219701cd575b0a78da5fdf3a6c8884e5ca445/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5c2cb90116255c3f74d5aee563405f3a440bd4eb75471adac13cd0c80a2564dc", size = 4265584, upload-time = "2026-06-11T19:25:25.628Z" }, + { url = "https://files.pythonhosted.org/packages/a6/ed/67edd70967851bef3ef9e35d8ccef242923ed69104ecb885ad3adf4de9a2/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2a993d3a0964b8cf55a51bd404225dc3037b51f34b01c6bb1312611ce61f9b2d", size = 5586300, upload-time = "2026-06-11T19:25:27.32Z" }, + { url = "https://files.pythonhosted.org/packages/76/a6/a20fba654caa314b4688ad9dceb5e99fa7956bbf92b3059baa36e06c59b3/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cbd153a3348d166ecb57b2770b69b17c2df14cf303d41cd9168adba77532a31b", size = 4402375, upload-time = "2026-06-11T19:25:28.799Z" }, + { url = "https://files.pythonhosted.org/packages/ed/5d/c8949bb5916ff80186c854792b9ddadc9f3069db09d31311f24d82ba7096/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1fb5aaf3a6f104145b4c5a3188b1be589849b2599626c0e40181a18fc2e79f68", size = 4246712, upload-time = "2026-06-11T19:25:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/b5/36/c9b7afa2929b697a164ae18f35aba517bcab85efcf19cb48ffa5ac66642b/geoarrow_rust_core-0.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3b33be8308a479f3a3a6d3a664861d6b5f8b1ad8822798f5a7e5d9af0b924eb", size = 4742863, upload-time = "2026-06-11T19:25:32.468Z" }, + { url = "https://files.pythonhosted.org/packages/57/5c/55a8d753bff924959837c39c9aa37c7813c5929570a2629ae4ece811505f/geoarrow_rust_core-0.6.3-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090191ae224e8490a95e68038db7a14df8f0326706f10c2e958621bf6c06ef5", size = 1979216, upload-time = "2026-06-11T19:25:33.905Z" }, + { url = "https://files.pythonhosted.org/packages/71/c7/a9f93af9306fd3743a96cc61bfdd7fc9194c38026f7904c067d4b4a99f0c/geoarrow_rust_core-0.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:2606d6f5afacdb49145b39d3e024efadf33f847b596c19c9b6d3030d6beb2721", size = 3599237, upload-time = "2026-06-11T19:25:35.452Z" }, + { url = "https://files.pythonhosted.org/packages/8a/7a/6993bd89e12d0b227b611a53c657b38e63f906dfca773accae3a1f3815a4/geoarrow_rust_core-0.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:370cd1ef46bf18fa598f3038fe6f417b016da211ffe060f2b60e47dd2f684a34", size = 3854961, upload-time = "2026-06-11T19:25:37.045Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c4/92cbcabd2a6add1b69a76a22a349fa219bdfed8026dfab4b8ec230bf9943/geoarrow_rust_core-0.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4dbf733db0bc57859d1a34c4bc8c50805f19e60081496967588e43f1f606e885", size = 3708325, upload-time = "2026-06-11T19:25:38.638Z" }, + { url = "https://files.pythonhosted.org/packages/07/b3/8fc34c5efa95cd597328876b6295fbe280d4b71df615655aaa2cd1618881/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:45ac6715e790b1ca9be38ceb8ee39cdfe29395d29c83541f7a1190812290d81d", size = 4196828, upload-time = "2026-06-11T19:25:40.329Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/bd2026862995ff96eb6b94d2fc56f7bf737d13f6bac9662481eaae23d079/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d14917d471dce8ee5a0976ec50b5da800bab0117bfd72bc56e23518a1dbbdb3a", size = 4265577, upload-time = "2026-06-11T19:25:41.91Z" }, + { url = "https://files.pythonhosted.org/packages/3e/01/73d69c5205a34e043026a73048d210f448a986ebb577deee7ceb1923fb5a/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:43a371299305388663131321f0d623fc70ca4a3840f973598946b5183e5ba4e4", size = 5592303, upload-time = "2026-06-11T19:25:43.503Z" }, + { url = "https://files.pythonhosted.org/packages/98/20/fe35466e526a5d363ebd9c9dd16985dbad7fd677b90e1f123a8180bceb44/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:23eddb8dd65dfefb397762cc3c3f6bfaffb4271641bd9dc8043a9ab3aa4cd72a", size = 4409972, upload-time = "2026-06-11T19:25:45.114Z" }, + { url = "https://files.pythonhosted.org/packages/e5/c8/dc588827ad6e8dad75413bc1d35b5189c8a011a2be4827499a4ab9402253/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43ce7b3aaeb0e8c8ad7c37c84ceed49e10d0929a5a92042c3f6ec5ef33271de4", size = 4250885, upload-time = "2026-06-11T19:25:46.649Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e2/a9923e4c5848ace6e3e6f09a40d3860955f7d836675affe35bc79bc27033/geoarrow_rust_core-0.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c67201bd662e4732a822f91651111bc024329b3e71eba9f4eed19e58c9cf789b", size = 4742518, upload-time = "2026-06-11T19:25:48.098Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/3112def9e93e88341210dd22b4d04c598fb4d0726adef2114b68157354d5/geoarrow_rust_core-0.6.3-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:8461e6d07a7b39ab099c9885a68d5e7983d4e83a82a42dd5b331c543683c9d6e", size = 1959191, upload-time = "2026-06-11T19:25:49.668Z" }, + { url = "https://files.pythonhosted.org/packages/ed/0f/de74ce2171c408e4b4a7660f69f6dfaa294797a18a209fa85b1ea79be141/geoarrow_rust_core-0.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:5d2fd45d09bf700e0ca4d30b51ebcd59fb8d1a9eb4a4d7b4fc5f53a6cca59475", size = 3603948, upload-time = "2026-06-11T19:25:51.078Z" }, ] [[package]] @@ -707,8 +862,7 @@ version = "0.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "arro3-core" }, - { 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.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, { name = "pyproj", version = "3.7.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] wheels = [ @@ -752,14 +906,6 @@ wheels = [ { 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" }, @@ -768,155 +914,122 @@ wheels = [ ] [[package]] -name = "google-pasta" -version = "0.2.0" +name = "h11" +version = "0.16.0" 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, upload-time = "2020-03-13T18:57:50.34Z" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, ] [[package]] -name = "grpcio" -version = "1.75.1" -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, 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]] -name = "h5py" -version = "3.14.0" +name = "hf-xet" +version = "1.5.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4b/2d/57fd21d84d93efb4bd0b962383790e19dd1bc053501b4264c97903b4e83e/hf_xet-1.5.1.tar.gz", hash = "sha256:51ef4500dab3764b41135ee1381a4b62ce56fc54d4c92b719b59e597d6df5bf6", size = 876636, upload-time = "2026-06-08T23:02:53.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/ee/dd9ba7beae1005e54131b7d45263cc74c8a066d47d354e6d58ae9445a388/hf_xet-1.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:dbf48c0d02cf0b2e568944330c60d9120c272dabe013bd892d48e25bc6797577", size = 4069485, upload-time = "2026-06-08T23:02:13.193Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/9cae6cfeb4e03070874e73e5c97c66eb90369d3206b6a2b1ef5f96520888/hf_xet-1.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e78e4e5192ad2b674c2e1160b651cb9134db974f8ae1835bdfbfb0166b894a43", size = 3838493, upload-time = "2026-06-08T23:02:15.282Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b4/d5c01e0eb6d9f2ca2dacd84d0d1b71e6cfbb2ef3208c968528e010e9b3d7/hf_xet-1.5.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6f7a04a8ad962422e225bc49fbbac99dc1806764b1f3e54dbd154bffa7593947", size = 4505658, upload-time = "2026-06-08T23:02:17.196Z" }, + { url = "https://files.pythonhosted.org/packages/76/c5/29a7598c0c6383c523dc22186d577f4e04267a626cd95ae60f67c00bfe66/hf_xet-1.5.1-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:d48199c2bf4f8df0adc55d31d1368b6ec0e4d4f45bc86b08038089c23db0bed8", size = 4292822, upload-time = "2026-06-08T23:02:18.608Z" }, + { url = "https://files.pythonhosted.org/packages/04/9a/dceaf6ca69390126b86ea825fb354b93d01163199070b7bd849225de9468/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:97f212a88d14bbf573619a74b7fecb238de77d08fc702e54dec6f78276ca3283", size = 4491255, upload-time = "2026-06-08T23:02:20.124Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/e5a7afaacf6c1791fdbeeac42951fb81c3d2bc482992b115dedcc86d963e/hf_xet-1.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f61e3665892a6c8c5e765395838b8ddf36185da835253d4bc4509a81e49fb342", size = 4711062, upload-time = "2026-06-08T23:02:21.863Z" }, + { url = "https://files.pythonhosted.org/packages/53/49/2802f8433c9742ce281bddc1e65c02c32268ca3098d66828b05e12e45ee2/hf_xet-1.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f4ad3ebd4c32dd2b27099d69dc7b2df821e30767e46fb6ee6a0713778243b8ff", size = 4017205, upload-time = "2026-06-08T23:02:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/9e/5a/50c71195b9fb883659f596e7252faf4c18c58e753a9013bdbf9bac5d2250/hf_xet-1.5.1-cp313-cp313t-win_arm64.whl", hash = "sha256:8298485c1e36e7e67cbd01eeb1376619b7af43d4f1ec245caae306f890a8a32d", size = 3845426, upload-time = "2026-06-08T23:02:25.124Z" }, + { url = "https://files.pythonhosted.org/packages/05/24/5e0c28f80371c17d49fed004597d9d132cb75c1f6f53db2cb95f459d2312/hf_xet-1.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3474760d10e3bb6f92ff3f024fcb00c0b3e4001e9b035c7483e49a5dd17aa70f", size = 4069676, upload-time = "2026-06-08T23:02:26.759Z" }, + { url = "https://files.pythonhosted.org/packages/d2/17/261ba565b6a4d960fb478f61fdf919c0be5824645aaf1c319eca660c1611/hf_xet-1.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6762d89b9e3267dfd502b29b2a327b4525f33b17e7b509a78d94e2151a30ce30", size = 3838509, upload-time = "2026-06-08T23:02:28.573Z" }, + { url = "https://files.pythonhosted.org/packages/4e/44/7ffdc2e184b0d41fc0f683ba3936ef669ab63cf242cf36ef50e57d683668/hf_xet-1.5.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bf67e6ed10260cef62e852789dc91ebb03f382d5bdc4b1dbeb64763ea275e7d6", size = 4505881, upload-time = "2026-06-08T23:02:30.257Z" }, + { url = "https://files.pythonhosted.org/packages/63/b6/788060d5aa4d5e671f1a31bf69624c314eb2d8babab3aa562f9e5d53444e/hf_xet-1.5.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:c6b6cd08ca095058780b50b8ce4d6cbf6787bcf27841705d58a9d32246e3e47a", size = 4292995, upload-time = "2026-06-08T23:02:31.993Z" }, + { url = "https://files.pythonhosted.org/packages/22/93/c5540cbd6b55529b7dc42f6734e88cebee21aefbea34128b66229df56c57/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e1af0de8ca6f190d4294a28b88023db64a1e2d1d719cab044baf75bec569e7a9", size = 4491570, upload-time = "2026-06-08T23:02:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/03/f3/9d8ceab30f44f36c1679b1b8683054c71a0dadc787dbf07421891742d3ca/hf_xet-1.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4f561cbbb92f80960772059864b7fb07eae879adde1b2e781ec6f86f6ac26c59", size = 4711565, upload-time = "2026-06-08T23:02:35.454Z" }, + { url = "https://files.pythonhosted.org/packages/cd/54/27ed9a5e2cc583b4df82f75a03a4df8dbf55f5a9fa1f47f1fadfb20dbeac/hf_xet-1.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:e7dbb40617410f432182d918e37c12303fe6700fd6aa6c5964e30a535a4461d6", size = 4017343, upload-time = "2026-06-08T23:02:37.14Z" }, + { url = "https://files.pythonhosted.org/packages/ae/12/ecb2fc8d45e767580e3a37faa97cb895608b614965567efb4f18cff67e27/hf_xet-1.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:6071d5ccb4d8d2cbd5fea5cc798da4f0ba3f44e25369591c4e89a4987050e61d", size = 3845716, upload-time = "2026-06-08T23:02:39.073Z" }, + { url = "https://files.pythonhosted.org/packages/7a/d8/5e54cf37434759d1f4f2ba9b66077ff9d4c4e1f37b6bd7975da5c40d94ab/hf_xet-1.5.1-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:6abd35c3221eff63836618ddfb954dcf84798603f71d8e33e3ed7b04acfdbe6e", size = 4077794, upload-time = "2026-06-08T23:02:40.656Z" }, + { url = "https://files.pythonhosted.org/packages/35/94/4b2ecfbad8f8b04701a23aefb62f540b9137d058b7e1dbef16a32676f0e9/hf_xet-1.5.1-cp37-abi3-macosx_11_0_arm64.whl", hash = "sha256:94e761bbd266bf4c03cee73753916062665ce8365aa40ed321f45afcb934b41e", size = 3845354, upload-time = "2026-06-08T23:02:42.702Z" }, + { url = "https://files.pythonhosted.org/packages/de/cc/f99f4bc7295023d7bd9ebbfd51f75cc530ca262c1227666268b8208f4b77/hf_xet-1.5.1-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:892e3a3a3aecc12aded8b93cf4f9cd059282c7de0732f7d55026f3abdf474350", size = 4514864, upload-time = "2026-06-08T23:02:44.497Z" }, + { url = "https://files.pythonhosted.org/packages/cd/6e/21f7e5a2381278bd3b7b7a5a4d90038518bb6308a0c1daf5d9f8268bb178/hf_xet-1.5.1-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a93df2039190502835b1db8cd7e178b0b7b889fe9ab51299d5ced26e0dd879a4", size = 4303784, upload-time = "2026-06-08T23:02:46.203Z" }, + { url = "https://files.pythonhosted.org/packages/35/0e/f992bb6927ac1cb30ef74e62268f551f338bc32b2191f7c96a44c6f7283e/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:0c97106032ef70467b4f6bc2d0ccc266d7613ee076afc56516c502f87ce1c4a6", size = 4500703, upload-time = "2026-06-08T23:02:47.628Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d1/90a498d05447980b977b1669246eeeeae4cfb0ea3e7a286eaba627f91bf9/hf_xet-1.5.1-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6208adb15d192b90e4c2ad2a27ed864359b2cb0f2494eb6d7c7f3699ac02e2bf", size = 4719498, upload-time = "2026-06-08T23:02:49.268Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b6/20f99cfe97cc663a711f7b33cc21d4793e51968e9a26125b4afcd77315ba/hf_xet-1.5.1-cp37-abi3-win_amd64.whl", hash = "sha256:f7b3002f95d1c13e24bcb4537baa8f0eb3838957067c91bb4959bc004a6435f5", size = 4026419, upload-time = "2026-06-08T23:02:50.829Z" }, + { url = "https://files.pythonhosted.org/packages/f9/fa/77453694888f03e5a8c8852d1514a0894d8e81c622d39edbaf308ea0dcf4/hf_xet-1.5.1-cp37-abi3-win_arm64.whl", hash = "sha256:93d090b57b211133f6c0dab0205ef5cb6d89162979ba75a74845045cc3063b8e", size = 3855178, upload-time = "2026-06-08T23:02:52.452Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" 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 = "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 = "certifi" }, + { name = "h11" }, ] -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" } +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 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, 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" }, + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, ] [[package]] -name = "hf-xet" -version = "1.1.10" +name = "httpx" +version = "0.28.1" 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, upload-time = "2025-09-12T20:10:27.12Z" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 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, 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" }, + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, ] [[package]] name = "huggingface-hub" -version = "0.35.3" +version = "1.23.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "click" }, { 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 = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" }, + { name = "httpx" }, { name = "packaging" }, { name = "pyyaml" }, - { name = "requests" }, { 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, upload-time = "2025-09-29T14:29:58.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1a/8f/999e4dda11c6187c78f090eac00895a47e11a0049308f07579bcb7aa3aa2/huggingface_hub-1.23.0.tar.gz", hash = "sha256:c04997fb8bbdace1e57b7703d30ed7678af51f70d00d241819ff411b92ae9a88", size = 919163, upload-time = "2026-07-09T14:49:32.315Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/f1/ce/13b2ba57838b8db1e6bd033c1b21ce0b9f6153b87d4e4939f77074e41eb0/huggingface_hub-1.23.0-py3-none-any.whl", hash = "sha256:b1d604788f5adc7f0eb246e03e0ec19011ca06e38400218c347dccc3dffa64a2", size = 770336, upload-time = "2026-07-09T14:49:30.597Z" }, ] [[package]] name = "idna" -version = "3.10" +version = "3.18" 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, upload-time = "2024-09-15T18:07:39.745Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, ] [[package]] name = "importlib-metadata" -version = "8.7.0" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp", marker = "python_full_version < '3.10'" }, + { name = "zipp" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/76/66/650a33bd90f786193e4de4b3ad86ea60b53c89b669a5c7be931fac31cdb0/importlib_metadata-8.7.0.tar.gz", hash = "sha256:d13b81ad223b890aa16c5471f2ac3056cf76c5f10f82d6f9292f0b415f389000", size = 56641, upload-time = "2025-04-27T15:29:01.736Z" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/12/33e59336dca5be0c398a7482335911a33aa0e20776128f038019f1a95f1b/importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7", size = 55304, upload-time = "2024-09-11T14:56:08.937Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/b0/36bd937216ec521246249be3bf9855081de4c5e06a0c9b4219dbeda50373/importlib_metadata-8.7.0-py3-none-any.whl", hash = "sha256:e5dd1551894c77868a30651cef00984d50e1002d06942a7101d34870c5f02afd", size = 27656, upload-time = "2025-04-27T15:29:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d9/a1e041c5e7caa9a05c925f4bdbdfb7f006d1f74996af53467bc394c97be7/importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b", size = 26514, upload-time = "2024-09-11T14:56:07.019Z" }, ] [[package]] name = "iniconfig" -version = "2.1.0" +version = "2.3.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, upload-time = "2025-03-19T20:09:59.721Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, ] [[package]] @@ -933,144 +1046,38 @@ wheels = [ [[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, 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, 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" +version = "1.1.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.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.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", 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, upload-time = "2025-08-21T22:08:57.643Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" }, ] [[package]] name = "lance-namespace" -version = "0.4.5" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b4/b5/0c3c55cf336b1e90392c2e24ac833551659e8bb3c61644b2d94825eb31bd/lance_namespace-0.4.5.tar.gz", hash = "sha256:0aee0abed3a1fa762c2955c7d12bb3004cea5c82ba28f6fcb9fe79d0cc19e317", size = 9827, upload-time = "2026-01-07T19:20:23.005Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/93/da5f7fcac690db9b282a3439ed9e34960c147619a0d6e1f4eb8cd240e7a5/lance_namespace-0.11.1.tar.gz", hash = "sha256:f67cfbbe0647b7cb42f23b673e7edf8a75b7d8a047265a916492f8d247ee1bc2", size = 11631, upload-time = "2026-08-18T17:40:06.294Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/34/88/173687dad72baf819223e3b506898e386bc88c26ff8da5e8013291e02daf/lance_namespace-0.4.5-py3-none-any.whl", hash = "sha256:cd1a4f789de03ba23a0c16f100b1464cca572a5d04e428917a54d09db912d548", size = 11703, upload-time = "2026-01-07T19:20:25.394Z" }, + { url = "https://files.pythonhosted.org/packages/fa/bc/601f2b3cc4cfa0070d858a33223bc823fffdd7981a25c45984a5216ca952/lance_namespace-0.11.1-py3-none-any.whl", hash = "sha256:07643fce9a42ad4d58cc8bf91e3f592bc7f4cbd8d0ad5233223506debf67551c", size = 13507, upload-time = "2026-08-18T17:40:03.561Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.4.5" +version = "0.11.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, { name = "python-dateutil" }, { name = "typing-extensions" }, - { 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/97/a9/4e527c2f05704565618b239b0965f829d1a194837f01234af3f8e2f33d92/lance_namespace_urllib3_client-0.4.5.tar.gz", hash = "sha256:184deda8cf8700926d994618187053c644eb1f2866a4479e7b80843cacc92b1c", size = 159726, upload-time = "2026-01-07T19:20:24.025Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ca/86/0adee7190408a28dcc5a0562c674537457e3de59ee51d1c724ecdc4a9930/lance_namespace_urllib3_client-0.4.5-py3-none-any.whl", hash = "sha256:2ee154d616ba4721f0bfdf043d33c4fef2e79d380653e2f263058ab00fb4adf4", size = 277969, upload-time = "2026-01-07T19:20:26.597Z" }, -] - -[[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, 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, 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" } -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, 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.13'", - "python_full_version == '3.12.*'", - "python_full_version == '3.11.*'", - "python_full_version == '3.10.*'", + { name = "urllib3" }, ] -dependencies = [ - { 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, upload-time = "2025-08-11T12:57:52.854Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/c5/2bdd0ff98b469894c8a73be809d26ffdad5402517b0e5f9e758026cba29e/lance_namespace_urllib3_client-0.11.1.tar.gz", hash = "sha256:145a9e9424d7597487249b5b95ee274423bf2910e1a9160b6a07b676b61ea46a", size = 237345, upload-time = "2026-08-18T17:40:07.308Z" } 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, upload-time = "2025-08-11T12:57:51.923Z" }, + { url = "https://files.pythonhosted.org/packages/2a/2a/eaaefd55d1190291207049fedc6b3eb22b506e57d6de91bae46bbaaa9c60/lance_namespace_urllib3_client-0.11.1-py3-none-any.whl", hash = "sha256:36537f529294da6d884ba0fe783704483f0a75463497c7705fd083a4d0257990", size = 406311, upload-time = "2026-08-18T17:40:04.842Z" }, ] [[package]] @@ -1156,26 +1163,30 @@ wheels = [ { 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" +name = "maturin" +version = "1.13.3" 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, upload-time = "2022-08-14T12:40:10.846Z" } +dependencies = [ + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, + { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, + { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, ] [[package]] @@ -1183,9 +1194,9 @@ name = "ml-dtypes" version = "0.5.3" 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 = "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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, ] 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 = [ @@ -1219,10 +1230,6 @@ wheels = [ { 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]] @@ -1236,163 +1243,163 @@ wheels = [ [[package]] name = "multidict" -version = "6.6.4" +version = "6.7.1" 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, 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" }, +sdist = { url = "https://files.pythonhosted.org/packages/1a/c2/c2d94cbe6ac1753f3fc980da97b3d930efe1da3af3c9f5125354436c073d/multidict-6.7.1.tar.gz", hash = "sha256:ec6652a1bee61c53a3e5776b6049172c53b6aaba34f18c9ad04f82712bac623d", size = 102010, upload-time = "2026-01-26T02:46:45.979Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/84/0b/19348d4c98980c4851d2f943f8ebafdece2ae7ef737adcfa5994ce8e5f10/multidict-6.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:c93c3db7ea657dd4637d57e74ab73de31bccefe144d3d4ce370052035bc85fb5", size = 77176, upload-time = "2026-01-26T02:42:59.784Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/9de3f8077852e3d438215c81e9b691244532d2e05b4270e89ce67b7d103c/multidict-6.7.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:974e72a2474600827abaeda71af0c53d9ebbc3c2eb7da37b37d7829ae31232d8", size = 44996, upload-time = "2026-01-26T02:43:01.674Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/08c7f7fe311f32e83f7621cd3f99d805f45519cd06fafb247628b861da7d/multidict-6.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:cdea2e7b2456cfb6694fb113066fd0ec7ea4d67e3a35e1f4cbeea0b448bf5872", size = 44631, upload-time = "2026-01-26T02:43:03.169Z" }, + { url = "https://files.pythonhosted.org/packages/b7/7f/0e3b1390ae772f27501199996b94b52ceeb64fe6f9120a32c6c3f6b781be/multidict-6.7.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17207077e29342fdc2c9a82e4b306f1127bf1ea91f8b71e02d4798a70bb99991", size = 242561, upload-time = "2026-01-26T02:43:04.733Z" }, + { url = "https://files.pythonhosted.org/packages/dd/f4/8719f4f167586af317b69dd3e90f913416c91ca610cac79a45c53f590312/multidict-6.7.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4f49cb5661344764e4c7c7973e92a47a59b8fc19b6523649ec9dc4960e58a03", size = 242223, upload-time = "2026-01-26T02:43:06.695Z" }, + { url = "https://files.pythonhosted.org/packages/47/ab/7c36164cce64a6ad19c6d9a85377b7178ecf3b89f8fd589c73381a5eedfd/multidict-6.7.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a9fc4caa29e2e6ae408d1c450ac8bf19892c5fca83ee634ecd88a53332c59981", size = 222322, upload-time = "2026-01-26T02:43:08.472Z" }, + { url = "https://files.pythonhosted.org/packages/f5/79/a25add6fb38035b5337bc5734f296d9afc99163403bbcf56d4170f97eb62/multidict-6.7.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c5f0c21549ab432b57dcc82130f388d84ad8179824cc3f223d5e7cfbfd4143f6", size = 254005, upload-time = "2026-01-26T02:43:10.127Z" }, + { url = "https://files.pythonhosted.org/packages/4a/7b/64a87cf98e12f756fc8bd444b001232ffff2be37288f018ad0d3f0aae931/multidict-6.7.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7dfb78d966b2c906ae1d28ccf6e6712a3cd04407ee5088cd276fe8cb42186190", size = 251173, upload-time = "2026-01-26T02:43:11.731Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/b605473de2bb404e742f2cc3583d12aedb2352a70e49ae8fce455b50c5aa/multidict-6.7.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9b0d9b91d1aa44db9c1f1ecd0d9d2ae610b2f4f856448664e01a3b35899f3f92", size = 243273, upload-time = "2026-01-26T02:43:13.063Z" }, + { url = "https://files.pythonhosted.org/packages/03/65/11492d6a0e259783720f3bc1d9ea55579a76f1407e31ed44045c99542004/multidict-6.7.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dd96c01a9dcd4889dcfcf9eb5544ca0c77603f239e3ffab0524ec17aea9a93ee", size = 238956, upload-time = "2026-01-26T02:43:14.843Z" }, + { url = "https://files.pythonhosted.org/packages/5f/a7/7ee591302af64e7c196fb63fe856c788993c1372df765102bd0448e7e165/multidict-6.7.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:067343c68cd6612d375710f895337b3a98a033c94f14b9a99eff902f205424e2", size = 233477, upload-time = "2026-01-26T02:43:16.025Z" }, + { url = "https://files.pythonhosted.org/packages/9c/99/c109962d58756c35fd9992fed7f2355303846ea2ff054bb5f5e9d6b888de/multidict-6.7.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:5884a04f4ff56c6120f6ccf703bdeb8b5079d808ba604d4d53aec0d55dc33568", size = 243615, upload-time = "2026-01-26T02:43:17.84Z" }, + { url = "https://files.pythonhosted.org/packages/d5/5f/1973e7c771c86e93dcfe1c9cc55a5481b610f6614acfc28c0d326fe6bfad/multidict-6.7.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:8affcf1c98b82bc901702eb73b6947a1bfa170823c153fe8a47b5f5f02e48e40", size = 249930, upload-time = "2026-01-26T02:43:19.06Z" }, + { url = "https://files.pythonhosted.org/packages/5d/a5/f170fc2268c3243853580203378cd522446b2df632061e0a5409817854c7/multidict-6.7.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:0d17522c37d03e85c8098ec8431636309b2682cf12e58f4dbc76121fb50e4962", size = 243807, upload-time = "2026-01-26T02:43:20.286Z" }, + { url = "https://files.pythonhosted.org/packages/de/01/73856fab6d125e5bc652c3986b90e8699a95e84b48d72f39ade6c0e74a8c/multidict-6.7.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:24c0cf81544ca5e17cfcb6e482e7a82cd475925242b308b890c9452a074d4505", size = 239103, upload-time = "2026-01-26T02:43:21.508Z" }, + { url = "https://files.pythonhosted.org/packages/e7/46/f1220bd9944d8aa40d8ccff100eeeee19b505b857b6f603d6078cb5315b0/multidict-6.7.1-cp310-cp310-win32.whl", hash = "sha256:d82dd730a95e6643802f4454b8fdecdf08667881a9c5670db85bc5a56693f122", size = 41416, upload-time = "2026-01-26T02:43:22.703Z" }, + { url = "https://files.pythonhosted.org/packages/68/00/9b38e272a770303692fc406c36e1a4c740f401522d5787691eb38a8925a8/multidict-6.7.1-cp310-cp310-win_amd64.whl", hash = "sha256:cf37cbe5ced48d417ba045aca1b21bafca67489452debcde94778a576666a1df", size = 46022, upload-time = "2026-01-26T02:43:23.77Z" }, + { url = "https://files.pythonhosted.org/packages/64/65/d8d42490c02ee07b6bbe00f7190d70bb4738b3cce7629aaf9f213ef730dd/multidict-6.7.1-cp310-cp310-win_arm64.whl", hash = "sha256:59bc83d3f66b41dac1e7460aac1d196edc70c9ba3094965c467715a70ecb46db", size = 43238, upload-time = "2026-01-26T02:43:24.882Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f1/a90635c4f88fb913fbf4ce660b83b7445b7a02615bda034b2f8eb38fd597/multidict-6.7.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7ff981b266af91d7b4b3793ca3382e53229088d193a85dfad6f5f4c27fc73e5d", size = 76626, upload-time = "2026-01-26T02:43:26.485Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/267e64eaf6fc637a15b35f5de31a566634a2740f97d8d094a69d34f524a4/multidict-6.7.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:844c5bca0b5444adb44a623fb0a1310c2f4cd41f402126bb269cd44c9b3f3e1e", size = 44706, upload-time = "2026-01-26T02:43:27.607Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a4/d45caf2b97b035c57267791ecfaafbd59c68212004b3842830954bb4b02e/multidict-6.7.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f2a0a924d4c2e9afcd7ec64f9de35fcd96915149b2216e1cb2c10a56df483855", size = 44356, upload-time = "2026-01-26T02:43:28.661Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d2/0a36c8473f0cbaeadd5db6c8b72d15bbceeec275807772bfcd059bef487d/multidict-6.7.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8be1802715a8e892c784c0197c2ace276ea52702a0ede98b6310c8f255a5afb3", size = 244355, upload-time = "2026-01-26T02:43:31.165Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/8c65be997fd7dd311b7d39c7b6e71a0cb449bad093761481eccbbe4b42a2/multidict-6.7.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2e2d2ed645ea29f31c4c7ea1552fcfd7cb7ba656e1eafd4134a6620c9f5fdd9e", size = 246433, upload-time = "2026-01-26T02:43:32.581Z" }, + { url = "https://files.pythonhosted.org/packages/01/fb/4dbd7e848d2799c6a026ec88ad39cf2b8416aa167fcc903baa55ecaa045c/multidict-6.7.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:95922cee9a778659e91db6497596435777bd25ed116701a4c034f8e46544955a", size = 225376, upload-time = "2026-01-26T02:43:34.417Z" }, + { url = "https://files.pythonhosted.org/packages/b6/8a/4a3a6341eac3830f6053062f8fbc9a9e54407c80755b3f05bc427295c2d0/multidict-6.7.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b83cabdc375ffaaa15edd97eb7c0c672ad788e2687004990074d7d6c9b140c8", size = 257365, upload-time = "2026-01-26T02:43:35.741Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a2/dd575a69c1aa206e12d27d0770cdf9b92434b48a9ef0cd0d1afdecaa93c4/multidict-6.7.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:38fb49540705369bab8484db0689d86c0a33a0a9f2c1b197f506b71b4b6c19b0", size = 254747, upload-time = "2026-01-26T02:43:36.976Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/21b27c560c13822ed93133f08aa6372c53a8e067f11fbed37b4adcdac922/multidict-6.7.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:439cbebd499f92e9aa6793016a8acaa161dfa749ae86d20960189f5398a19144", size = 246293, upload-time = "2026-01-26T02:43:38.258Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/23466059dc3854763423d0ad6c0f3683a379d97673b1b89ec33826e46728/multidict-6.7.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6d3bc717b6fe763b8be3f2bee2701d3c8eb1b2a8ae9f60910f1b2860c82b6c49", size = 242962, upload-time = "2026-01-26T02:43:40.034Z" }, + { url = "https://files.pythonhosted.org/packages/1f/67/51dd754a3524d685958001e8fa20a0f5f90a6a856e0a9dcabff69be3dbb7/multidict-6.7.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:619e5a1ac57986dbfec9f0b301d865dddf763696435e2962f6d9cf2fdff2bb71", size = 237360, upload-time = "2026-01-26T02:43:41.752Z" }, + { url = "https://files.pythonhosted.org/packages/64/3f/036dfc8c174934d4b55d86ff4f978e558b0e585cef70cfc1ad01adc6bf18/multidict-6.7.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0b38ebffd9be37c1170d33bc0f36f4f262e0a09bc1aac1c34c7aa51a7293f0b3", size = 245940, upload-time = "2026-01-26T02:43:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/3d/20/6214d3c105928ebc353a1c644a6ef1408bc5794fcb4f170bb524a3c16311/multidict-6.7.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:10ae39c9cfe6adedcdb764f5e8411d4a92b055e35573a2eaa88d3323289ef93c", size = 253502, upload-time = "2026-01-26T02:43:44.371Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e2/c653bc4ae1be70a0f836b82172d643fcf1dade042ba2676ab08ec08bff0f/multidict-6.7.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:25167cc263257660290fba06b9318d2026e3c910be240a146e1f66dd114af2b0", size = 247065, upload-time = "2026-01-26T02:43:45.745Z" }, + { url = "https://files.pythonhosted.org/packages/c8/11/a854b4154cd3bd8b1fd375e8a8ca9d73be37610c361543d56f764109509b/multidict-6.7.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:128441d052254f42989ef98b7b6a6ecb1e6f708aa962c7984235316db59f50fa", size = 241870, upload-time = "2026-01-26T02:43:47.054Z" }, + { url = "https://files.pythonhosted.org/packages/13/bf/9676c0392309b5fdae322333d22a829715b570edb9baa8016a517b55b558/multidict-6.7.1-cp311-cp311-win32.whl", hash = "sha256:d62b7f64ffde3b99d06b707a280db04fb3855b55f5a06df387236051d0668f4a", size = 41302, upload-time = "2026-01-26T02:43:48.753Z" }, + { url = "https://files.pythonhosted.org/packages/c9/68/f16a3a8ba6f7b6dc92a1f19669c0810bd2c43fc5a02da13b1cbf8e253845/multidict-6.7.1-cp311-cp311-win_amd64.whl", hash = "sha256:bdbf9f3b332abd0cdb306e7c2113818ab1e922dc84b8f8fd06ec89ed2a19ab8b", size = 45981, upload-time = "2026-01-26T02:43:49.921Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/9dd5305253fa00cd3c7555dbef69d5bf4133debc53b87ab8d6a44d411665/multidict-6.7.1-cp311-cp311-win_arm64.whl", hash = "sha256:b8c990b037d2fff2f4e33d3f21b9b531c5745b33a49a7d6dbe7a177266af44f6", size = 43159, upload-time = "2026-01-26T02:43:51.635Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9c/f20e0e2cf80e4b2e4b1c365bf5fe104ee633c751a724246262db8f1a0b13/multidict-6.7.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:a90f75c956e32891a4eda3639ce6dd86e87105271f43d43442a3aedf3cddf172", size = 76893, upload-time = "2026-01-26T02:43:52.754Z" }, + { url = "https://files.pythonhosted.org/packages/fe/cf/18ef143a81610136d3da8193da9d80bfe1cb548a1e2d1c775f26b23d024a/multidict-6.7.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3fccb473e87eaa1382689053e4a4618e7ba7b9b9b8d6adf2027ee474597128cd", size = 45456, upload-time = "2026-01-26T02:43:53.893Z" }, + { url = "https://files.pythonhosted.org/packages/a9/65/1caac9d4cd32e8433908683446eebc953e82d22b03d10d41a5f0fefe991b/multidict-6.7.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0fa96985700739c4c7853a43c0b3e169360d6855780021bfc6d0f1ce7c123e7", size = 43872, upload-time = "2026-01-26T02:43:55.041Z" }, + { url = "https://files.pythonhosted.org/packages/cf/3b/d6bd75dc4f3ff7c73766e04e705b00ed6dbbaccf670d9e05a12b006f5a21/multidict-6.7.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cb2a55f408c3043e42b40cc8eecd575afa27b7e0b956dfb190de0f8499a57a53", size = 251018, upload-time = "2026-01-26T02:43:56.198Z" }, + { url = "https://files.pythonhosted.org/packages/fd/80/c959c5933adedb9ac15152e4067c702a808ea183a8b64cf8f31af8ad3155/multidict-6.7.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eb0ce7b2a32d09892b3dd6cc44877a0d02a33241fafca5f25c8b6b62374f8b75", size = 258883, upload-time = "2026-01-26T02:43:57.499Z" }, + { url = "https://files.pythonhosted.org/packages/86/85/7ed40adafea3d4f1c8b916e3b5cc3a8e07dfcdcb9cd72800f4ed3ca1b387/multidict-6.7.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c3a32d23520ee37bf327d1e1a656fec76a2edd5c038bf43eddfa0572ec49c60b", size = 242413, upload-time = "2026-01-26T02:43:58.755Z" }, + { url = "https://files.pythonhosted.org/packages/d2/57/b8565ff533e48595503c785f8361ff9a4fde4d67de25c207cd0ba3befd03/multidict-6.7.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9c90fed18bffc0189ba814749fdcc102b536e83a9f738a9003e569acd540a733", size = 268404, upload-time = "2026-01-26T02:44:00.216Z" }, + { url = "https://files.pythonhosted.org/packages/e0/50/9810c5c29350f7258180dfdcb2e52783a0632862eb334c4896ac717cebcb/multidict-6.7.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:da62917e6076f512daccfbbde27f46fed1c98fee202f0559adec8ee0de67f71a", size = 269456, upload-time = "2026-01-26T02:44:02.202Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8d/5e5be3ced1d12966fefb5c4ea3b2a5b480afcea36406559442c6e31d4a48/multidict-6.7.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfde23ef6ed9db7eaee6c37dcec08524cb43903c60b285b172b6c094711b3961", size = 256322, upload-time = "2026-01-26T02:44:03.56Z" }, + { url = "https://files.pythonhosted.org/packages/31/6e/d8a26d81ac166a5592782d208dd90dfdc0a7a218adaa52b45a672b46c122/multidict-6.7.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3758692429e4e32f1ba0df23219cd0b4fc0a52f476726fff9337d1a57676a582", size = 253955, upload-time = "2026-01-26T02:44:04.845Z" }, + { url = "https://files.pythonhosted.org/packages/59/4c/7c672c8aad41534ba619bcd4ade7a0dc87ed6b8b5c06149b85d3dd03f0cd/multidict-6.7.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:398c1478926eca669f2fd6a5856b6de9c0acf23a2cb59a14c0ba5844fa38077e", size = 251254, upload-time = "2026-01-26T02:44:06.133Z" }, + { url = "https://files.pythonhosted.org/packages/7b/bd/84c24de512cbafbdbc39439f74e967f19570ce7924e3007174a29c348916/multidict-6.7.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:c102791b1c4f3ab36ce4101154549105a53dc828f016356b3e3bcae2e3a039d3", size = 252059, upload-time = "2026-01-26T02:44:07.518Z" }, + { url = "https://files.pythonhosted.org/packages/fa/ba/f5449385510825b73d01c2d4087bf6d2fccc20a2d42ac34df93191d3dd03/multidict-6.7.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:a088b62bd733e2ad12c50dad01b7d0166c30287c166e137433d3b410add807a6", size = 263588, upload-time = "2026-01-26T02:44:09.382Z" }, + { url = "https://files.pythonhosted.org/packages/d7/11/afc7c677f68f75c84a69fe37184f0f82fce13ce4b92f49f3db280b7e92b3/multidict-6.7.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3d51ff4785d58d3f6c91bdbffcb5e1f7ddfda557727043aa20d20ec4f65e324a", size = 259642, upload-time = "2026-01-26T02:44:10.73Z" }, + { url = "https://files.pythonhosted.org/packages/2b/17/ebb9644da78c4ab36403739e0e6e0e30ebb135b9caf3440825001a0bddcb/multidict-6.7.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc5907494fccf3e7d3f94f95c91d6336b092b5fc83811720fae5e2765890dfba", size = 251377, upload-time = "2026-01-26T02:44:12.042Z" }, + { url = "https://files.pythonhosted.org/packages/ca/a4/840f5b97339e27846c46307f2530a2805d9d537d8b8bd416af031cad7fa0/multidict-6.7.1-cp312-cp312-win32.whl", hash = "sha256:28ca5ce2fd9716631133d0e9a9b9a745ad7f60bac2bccafb56aa380fc0b6c511", size = 41887, upload-time = "2026-01-26T02:44:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/80/31/0b2517913687895f5904325c2069d6a3b78f66cc641a86a2baf75a05dcbb/multidict-6.7.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcee94dfbd638784645b066074b338bc9cc155d4b4bffa4adce1615c5a426c19", size = 46053, upload-time = "2026-01-26T02:44:15.371Z" }, + { url = "https://files.pythonhosted.org/packages/0c/5b/aba28e4ee4006ae4c7df8d327d31025d760ffa992ea23812a601d226e682/multidict-6.7.1-cp312-cp312-win_arm64.whl", hash = "sha256:ba0a9fb644d0c1a2194cf7ffb043bd852cea63a57f66fbd33959f7dae18517bf", size = 43307, upload-time = "2026-01-26T02:44:16.852Z" }, + { url = "https://files.pythonhosted.org/packages/f2/22/929c141d6c0dba87d3e1d38fbdf1ba8baba86b7776469f2bc2d3227a1e67/multidict-6.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:2b41f5fed0ed563624f1c17630cb9941cf2309d4df00e494b551b5f3e3d67a23", size = 76174, upload-time = "2026-01-26T02:44:18.509Z" }, + { url = "https://files.pythonhosted.org/packages/c7/75/bc704ae15fee974f8fccd871305e254754167dce5f9e42d88a2def741a1d/multidict-6.7.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84e61e3af5463c19b67ced91f6c634effb89ef8bfc5ca0267f954451ed4bb6a2", size = 45116, upload-time = "2026-01-26T02:44:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/79/76/55cd7186f498ed080a18440c9013011eb548f77ae1b297206d030eb1180a/multidict-6.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:935434b9853c7c112eee7ac891bc4cb86455aa631269ae35442cb316790c1445", size = 43524, upload-time = "2026-01-26T02:44:21.571Z" }, + { url = "https://files.pythonhosted.org/packages/e9/3c/414842ef8d5a1628d68edee29ba0e5bcf235dbfb3ccd3ea303a7fe8c72ff/multidict-6.7.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:432feb25a1cb67fe82a9680b4d65fb542e4635cb3166cd9c01560651ad60f177", size = 249368, upload-time = "2026-01-26T02:44:22.803Z" }, + { url = "https://files.pythonhosted.org/packages/f6/32/befed7f74c458b4a525e60519fe8d87eef72bb1e99924fa2b0f9d97a221e/multidict-6.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e82d14e3c948952a1a85503817e038cba5905a3352de76b9a465075d072fba23", size = 256952, upload-time = "2026-01-26T02:44:24.306Z" }, + { url = "https://files.pythonhosted.org/packages/03/d6/c878a44ba877f366630c860fdf74bfb203c33778f12b6ac274936853c451/multidict-6.7.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4cfb48c6ea66c83bcaaf7e4dfa7ec1b6bbcf751b7db85a328902796dfde4c060", size = 240317, upload-time = "2026-01-26T02:44:25.772Z" }, + { url = "https://files.pythonhosted.org/packages/68/49/57421b4d7ad2e9e60e25922b08ceb37e077b90444bde6ead629095327a6f/multidict-6.7.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d540e51b7e8e170174555edecddbd5538105443754539193e3e1061864d444d", size = 267132, upload-time = "2026-01-26T02:44:27.648Z" }, + { url = "https://files.pythonhosted.org/packages/b7/fe/ec0edd52ddbcea2a2e89e174f0206444a61440b40f39704e64dc807a70bd/multidict-6.7.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:273d23f4b40f3dce4d6c8a821c741a86dec62cded82e1175ba3d99be128147ed", size = 268140, upload-time = "2026-01-26T02:44:29.588Z" }, + { url = "https://files.pythonhosted.org/packages/b0/73/6e1b01cbeb458807aa0831742232dbdd1fa92bfa33f52a3f176b4ff3dc11/multidict-6.7.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d624335fd4fa1c08a53f8b4be7676ebde19cd092b3895c421045ca87895b429", size = 254277, upload-time = "2026-01-26T02:44:30.902Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b2/5fb8c124d7561a4974c342bc8c778b471ebbeb3cc17df696f034a7e9afe7/multidict-6.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:12fad252f8b267cc75b66e8fc51b3079604e8d43a75428ffe193cd9e2195dfd6", size = 252291, upload-time = "2026-01-26T02:44:32.31Z" }, + { url = "https://files.pythonhosted.org/packages/5a/96/51d4e4e06bcce92577fcd488e22600bd38e4fd59c20cb49434d054903bd2/multidict-6.7.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:03ede2a6ffbe8ef936b92cb4529f27f42be7f56afcdab5ab739cd5f27fb1cbf9", size = 250156, upload-time = "2026-01-26T02:44:33.734Z" }, + { url = "https://files.pythonhosted.org/packages/db/6b/420e173eec5fba721a50e2a9f89eda89d9c98fded1124f8d5c675f7a0c0f/multidict-6.7.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:90efbcf47dbe33dcf643a1e400d67d59abeac5db07dc3f27d6bdeae497a2198c", size = 249742, upload-time = "2026-01-26T02:44:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/44/a3/ec5b5bd98f306bc2aa297b8c6f11a46714a56b1e6ef5ebda50a4f5d7c5fb/multidict-6.7.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5c4b9bfc148f5a91be9244d6264c53035c8a0dcd2f51f1c3c6e30e30ebaa1c84", size = 262221, upload-time = "2026-01-26T02:44:36.604Z" }, + { url = "https://files.pythonhosted.org/packages/cd/f7/e8c0d0da0cd1e28d10e624604e1a36bcc3353aaebdfdc3a43c72bc683a12/multidict-6.7.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:401c5a650f3add2472d1d288c26deebc540f99e2fb83e9525007a74cd2116f1d", size = 258664, upload-time = "2026-01-26T02:44:38.008Z" }, + { url = "https://files.pythonhosted.org/packages/52/da/151a44e8016dd33feed44f730bd856a66257c1ee7aed4f44b649fb7edeb3/multidict-6.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:97891f3b1b3ffbded884e2916cacf3c6fc87b66bb0dde46f7357404750559f33", size = 249490, upload-time = "2026-01-26T02:44:39.386Z" }, + { url = "https://files.pythonhosted.org/packages/87/af/a3b86bf9630b732897f6fc3f4c4714b90aa4361983ccbdcd6c0339b21b0c/multidict-6.7.1-cp313-cp313-win32.whl", hash = "sha256:e1c5988359516095535c4301af38d8a8838534158f649c05dd1050222321bcb3", size = 41695, upload-time = "2026-01-26T02:44:41.318Z" }, + { url = "https://files.pythonhosted.org/packages/b2/35/e994121b0e90e46134673422dd564623f93304614f5d11886b1b3e06f503/multidict-6.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:960c83bf01a95b12b08fd54324a4eb1d5b52c88932b5cba5d6e712bb3ed12eb5", size = 45884, upload-time = "2026-01-26T02:44:42.488Z" }, + { url = "https://files.pythonhosted.org/packages/ca/61/42d3e5dbf661242a69c97ea363f2d7b46c567da8eadef8890022be6e2ab0/multidict-6.7.1-cp313-cp313-win_arm64.whl", hash = "sha256:563fe25c678aaba333d5399408f5ec3c383ca5b663e7f774dd179a520b8144df", size = 43122, upload-time = "2026-01-26T02:44:43.664Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b3/e6b21c6c4f314bb956016b0b3ef2162590a529b84cb831c257519e7fde44/multidict-6.7.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:c76c4bec1538375dad9d452d246ca5368ad6e1c9039dadcf007ae59c70619ea1", size = 83175, upload-time = "2026-01-26T02:44:44.894Z" }, + { url = "https://files.pythonhosted.org/packages/fb/76/23ecd2abfe0957b234f6c960f4ade497f55f2c16aeb684d4ecdbf1c95791/multidict-6.7.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:57b46b24b5d5ebcc978da4ec23a819a9402b4228b8a90d9c656422b4bdd8a963", size = 48460, upload-time = "2026-01-26T02:44:46.106Z" }, + { url = "https://files.pythonhosted.org/packages/c4/57/a0ed92b23f3a042c36bc4227b72b97eca803f5f1801c1ab77c8a212d455e/multidict-6.7.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e954b24433c768ce78ab7929e84ccf3422e46deb45a4dc9f93438f8217fa2d34", size = 46930, upload-time = "2026-01-26T02:44:47.278Z" }, + { url = "https://files.pythonhosted.org/packages/b5/66/02ec7ace29162e447f6382c495dc95826bf931d3818799bbef11e8f7df1a/multidict-6.7.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3bd231490fa7217cc832528e1cd8752a96f0125ddd2b5749390f7c3ec8721b65", size = 242582, upload-time = "2026-01-26T02:44:48.604Z" }, + { url = "https://files.pythonhosted.org/packages/58/18/64f5a795e7677670e872673aca234162514696274597b3708b2c0d276cce/multidict-6.7.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:253282d70d67885a15c8a7716f3a73edf2d635793ceda8173b9ecc21f2fb8292", size = 250031, upload-time = "2026-01-26T02:44:50.544Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ed/e192291dbbe51a8290c5686f482084d31bcd9d09af24f63358c3d42fd284/multidict-6.7.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0b4c48648d7649c9335cf1927a8b87fa692de3dcb15faa676c6a6f1f1aabda43", size = 228596, upload-time = "2026-01-26T02:44:51.951Z" }, + { url = "https://files.pythonhosted.org/packages/1e/7e/3562a15a60cf747397e7f2180b0a11dc0c38d9175a650e75fa1b4d325e15/multidict-6.7.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:98bc624954ec4d2c7cb074b8eefc2b5d0ce7d482e410df446414355d158fe4ca", size = 257492, upload-time = "2026-01-26T02:44:53.902Z" }, + { url = "https://files.pythonhosted.org/packages/24/02/7d0f9eae92b5249bb50ac1595b295f10e263dd0078ebb55115c31e0eaccd/multidict-6.7.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1b99af4d9eec0b49927b4402bcbb58dea89d3e0db8806a4086117019939ad3dd", size = 255899, upload-time = "2026-01-26T02:44:55.316Z" }, + { url = "https://files.pythonhosted.org/packages/00/e3/9b60ed9e23e64c73a5cde95269ef1330678e9c6e34dd4eb6b431b85b5a10/multidict-6.7.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6aac4f16b472d5b7dc6f66a0d49dd57b0e0902090be16594dc9ebfd3d17c47e7", size = 247970, upload-time = "2026-01-26T02:44:56.783Z" }, + { url = "https://files.pythonhosted.org/packages/3e/06/538e58a63ed5cfb0bd4517e346b91da32fde409d839720f664e9a4ae4f9d/multidict-6.7.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:21f830fe223215dffd51f538e78c172ed7c7f60c9b96a2bf05c4848ad49921c3", size = 245060, upload-time = "2026-01-26T02:44:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/b2/2f/d743a3045a97c895d401e9bd29aaa09b94f5cbdf1bd561609e5a6c431c70/multidict-6.7.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:f5dd81c45b05518b9aa4da4aa74e1c93d715efa234fd3e8a179df611cc85e5f4", size = 235888, upload-time = "2026-01-26T02:44:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/38/83/5a325cac191ab28b63c52f14f1131f3b0a55ba3b9aa65a6d0bf2a9b921a0/multidict-6.7.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:eb304767bca2bb92fb9c5bd33cedc95baee5bb5f6c88e63706533a1c06ad08c8", size = 243554, upload-time = "2026-01-26T02:45:01.054Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/9d2327086bd15da2725ef6aae624208e2ef828ed99892b17f60c344e57ed/multidict-6.7.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:c9035dde0f916702850ef66460bc4239d89d08df4d02023a5926e7446724212c", size = 252341, upload-time = "2026-01-26T02:45:02.484Z" }, + { url = "https://files.pythonhosted.org/packages/e8/2c/2a1aa0280cf579d0f6eed8ee5211c4f1730bd7e06c636ba2ee6aafda302e/multidict-6.7.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:af959b9beeb66c822380f222f0e0a1889331597e81f1ded7f374f3ecb0fd6c52", size = 246391, upload-time = "2026-01-26T02:45:03.862Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/7ca022ffc36c5a3f6e03b179a5ceb829be9da5783e6fe395f347c0794680/multidict-6.7.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:41f2952231456154ee479651491e94118229844dd7226541788be783be2b5108", size = 243422, upload-time = "2026-01-26T02:45:05.296Z" }, + { url = "https://files.pythonhosted.org/packages/dc/1d/b31650eab6c5778aceed46ba735bd97f7c7d2f54b319fa916c0f96e7805b/multidict-6.7.1-cp313-cp313t-win32.whl", hash = "sha256:df9f19c28adcb40b6aae30bbaa1478c389efd50c28d541d76760199fc1037c32", size = 47770, upload-time = "2026-01-26T02:45:06.754Z" }, + { url = "https://files.pythonhosted.org/packages/ac/5b/2d2d1d522e51285bd61b1e20df8f47ae1a9d80839db0b24ea783b3832832/multidict-6.7.1-cp313-cp313t-win_amd64.whl", hash = "sha256:d54ecf9f301853f2c5e802da559604b3e95bb7a3b01a9c295c6ee591b9882de8", size = 53109, upload-time = "2026-01-26T02:45:08.044Z" }, + { url = "https://files.pythonhosted.org/packages/3d/a3/cc409ba012c83ca024a308516703cf339bdc4b696195644a7215a5164a24/multidict-6.7.1-cp313-cp313t-win_arm64.whl", hash = "sha256:5a37ca18e360377cfda1d62f5f382ff41f2b8c4ccb329ed974cc2e1643440118", size = 45573, upload-time = "2026-01-26T02:45:09.349Z" }, + { url = "https://files.pythonhosted.org/packages/91/cc/db74228a8be41884a567e88a62fd589a913708fcf180d029898c17a9a371/multidict-6.7.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8f333ec9c5eb1b7105e3b84b53141e66ca05a19a605368c55450b6ba208cb9ee", size = 75190, upload-time = "2026-01-26T02:45:10.651Z" }, + { url = "https://files.pythonhosted.org/packages/d5/22/492f2246bb5b534abd44804292e81eeaf835388901f0c574bac4eeec73c5/multidict-6.7.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a407f13c188f804c759fc6a9f88286a565c242a76b27626594c133b82883b5c2", size = 44486, upload-time = "2026-01-26T02:45:11.938Z" }, + { url = "https://files.pythonhosted.org/packages/f1/4f/733c48f270565d78b4544f2baddc2fb2a245e5a8640254b12c36ac7ac68e/multidict-6.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0e161ddf326db5577c3a4cc2d8648f81456e8a20d40415541587a71620d7a7d1", size = 43219, upload-time = "2026-01-26T02:45:14.346Z" }, + { url = "https://files.pythonhosted.org/packages/24/bb/2c0c2287963f4259c85e8bcbba9182ced8d7fca65c780c38e99e61629d11/multidict-6.7.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1e3a8bb24342a8201d178c3b4984c26ba81a577c80d4d525727427460a50c22d", size = 245132, upload-time = "2026-01-26T02:45:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/a7/f9/44d4b3064c65079d2467888794dea218d1601898ac50222ab8a9a8094460/multidict-6.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97231140a50f5d447d3164f994b86a0bed7cd016e2682f8650d6a9158e14fd31", size = 252420, upload-time = "2026-01-26T02:45:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/8b/13/78f7275e73fa17b24c9a51b0bd9d73ba64bb32d0ed51b02a746eb876abe7/multidict-6.7.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:6b10359683bd8806a200fd2909e7c8ca3a7b24ec1d8132e483d58e791d881048", size = 233510, upload-time = "2026-01-26T02:45:19.356Z" }, + { url = "https://files.pythonhosted.org/packages/4b/25/8167187f62ae3cbd52da7893f58cb036b47ea3fb67138787c76800158982/multidict-6.7.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:283ddac99f7ac25a4acadbf004cb5ae34480bbeb063520f70ce397b281859362", size = 264094, upload-time = "2026-01-26T02:45:20.834Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e7/69a3a83b7b030cf283fb06ce074a05a02322359783424d7edf0f15fe5022/multidict-6.7.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:538cec1e18c067d0e6103aa9a74f9e832904c957adc260e61cd9d8cf0c3b3d37", size = 260786, upload-time = "2026-01-26T02:45:22.818Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3b/8ec5074bcfc450fe84273713b4b0a0dd47c0249358f5d82eb8104ffe2520/multidict-6.7.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7eee46ccb30ff48a1e35bb818cc90846c6be2b68240e42a78599166722cea709", size = 248483, upload-time = "2026-01-26T02:45:24.368Z" }, + { url = "https://files.pythonhosted.org/packages/48/5a/d5a99e3acbca0e29c5d9cba8f92ceb15dce78bab963b308ae692981e3a5d/multidict-6.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa263a02f4f2dd2d11a7b1bb4362aa7cb1049f84a9235d31adf63f30143469a0", size = 248403, upload-time = "2026-01-26T02:45:25.982Z" }, + { url = "https://files.pythonhosted.org/packages/35/48/e58cd31f6c7d5102f2a4bf89f96b9cf7e00b6c6f3d04ecc44417c00a5a3c/multidict-6.7.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2e1425e2f99ec5bd36c15a01b690a1a2456209c5deed58f95469ffb46039ccbb", size = 240315, upload-time = "2026-01-26T02:45:27.487Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/1cd210229559cb90b6786c30676bb0c58249ff42f942765f88793b41fdce/multidict-6.7.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:497394b3239fc6f0e13a78a3e1b61296e72bf1c5f94b4c4eb80b265c37a131cd", size = 245528, upload-time = "2026-01-26T02:45:28.991Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/6e1107d226278c876c783056b7db43d800bb64c6131cec9c8dfb6903698e/multidict-6.7.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:233b398c29d3f1b9676b4b6f75c518a06fcb2ea0b925119fb2c1bc35c05e1601", size = 258784, upload-time = "2026-01-26T02:45:30.503Z" }, + { url = "https://files.pythonhosted.org/packages/4d/c1/11f664f14d525e4a1b5327a82d4de61a1db604ab34c6603bb3c2cc63ad34/multidict-6.7.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:93b1818e4a6e0930454f0f2af7dfce69307ca03cdcfb3739bf4d91241967b6c1", size = 251980, upload-time = "2026-01-26T02:45:32.603Z" }, + { url = "https://files.pythonhosted.org/packages/e1/9f/75a9ac888121d0c5bbd4ecf4eead45668b1766f6baabfb3b7f66a410e231/multidict-6.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f33dc2a3abe9249ea5d8360f969ec7f4142e7ac45ee7014d8f8d5acddf178b7b", size = 243602, upload-time = "2026-01-26T02:45:34.043Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e7/50bf7b004cc8525d80dbbbedfdc7aed3e4c323810890be4413e589074032/multidict-6.7.1-cp314-cp314-win32.whl", hash = "sha256:3ab8b9d8b75aef9df299595d5388b14530839f6422333357af1339443cff777d", size = 40930, upload-time = "2026-01-26T02:45:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/e0/bf/52f25716bbe93745595800f36fb17b73711f14da59ed0bb2eba141bc9f0f/multidict-6.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:5e01429a929600e7dab7b166062d9bb54a5eed752384c7384c968c2afab8f50f", size = 45074, upload-time = "2026-01-26T02:45:37.546Z" }, + { url = "https://files.pythonhosted.org/packages/97/ab/22803b03285fa3a525f48217963da3a65ae40f6a1b6f6cf2768879e208f9/multidict-6.7.1-cp314-cp314-win_arm64.whl", hash = "sha256:4885cb0e817aef5d00a2e8451d4665c1808378dc27c2705f1bf4ef8505c0d2e5", size = 42471, upload-time = "2026-01-26T02:45:38.889Z" }, + { url = "https://files.pythonhosted.org/packages/e0/6d/f9293baa6146ba9507e360ea0292b6422b016907c393e2f63fc40ab7b7b5/multidict-6.7.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:0458c978acd8e6ea53c81eefaddbbee9c6c5e591f41b3f5e8e194780fe026581", size = 82401, upload-time = "2026-01-26T02:45:40.254Z" }, + { url = "https://files.pythonhosted.org/packages/7a/68/53b5494738d83558d87c3c71a486504d8373421c3e0dbb6d0db48ad42ee0/multidict-6.7.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c0abd12629b0af3cf590982c0b413b1e7395cd4ec026f30986818ab95bfaa94a", size = 48143, upload-time = "2026-01-26T02:45:41.635Z" }, + { url = "https://files.pythonhosted.org/packages/37/e8/5284c53310dcdc99ce5d66563f6e5773531a9b9fe9ec7a615e9bc306b05f/multidict-6.7.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:14525a5f61d7d0c94b368a42cff4c9a4e7ba2d52e2672a7b23d84dc86fb02b0c", size = 46507, upload-time = "2026-01-26T02:45:42.99Z" }, + { url = "https://files.pythonhosted.org/packages/e4/fc/6800d0e5b3875568b4083ecf5f310dcf91d86d52573160834fb4bfcf5e4f/multidict-6.7.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:17307b22c217b4cf05033dabefe68255a534d637c6c9b0cc8382718f87be4262", size = 239358, upload-time = "2026-01-26T02:45:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/41/75/4ad0973179361cdf3a113905e6e088173198349131be2b390f9fa4da5fc6/multidict-6.7.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a7e590ff876a3eaf1c02a4dfe0724b6e69a9e9de6d8f556816f29c496046e59", size = 246884, upload-time = "2026-01-26T02:45:47.167Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9c/095bb28b5da139bd41fb9a5d5caff412584f377914bd8787c2aa98717130/multidict-6.7.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5fa6a95dfee63893d80a34758cd0e0c118a30b8dcb46372bf75106c591b77889", size = 225878, upload-time = "2026-01-26T02:45:48.698Z" }, + { url = "https://files.pythonhosted.org/packages/07/d0/c0a72000243756e8f5a277b6b514fa005f2c73d481b7d9e47cd4568aa2e4/multidict-6.7.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a0543217a6a017692aa6ae5cc39adb75e587af0f3a82288b1492eb73dd6cc2a4", size = 253542, upload-time = "2026-01-26T02:45:50.164Z" }, + { url = "https://files.pythonhosted.org/packages/c0/6b/f69da15289e384ecf2a68837ec8b5ad8c33e973aa18b266f50fe55f24b8c/multidict-6.7.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f99fe611c312b3c1c0ace793f92464d8cd263cc3b26b5721950d977b006b6c4d", size = 252403, upload-time = "2026-01-26T02:45:51.779Z" }, + { url = "https://files.pythonhosted.org/packages/a2/76/b9669547afa5a1a25cd93eaca91c0da1c095b06b6d2d8ec25b713588d3a1/multidict-6.7.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9004d8386d133b7e6135679424c91b0b854d2d164af6ea3f289f8f2761064609", size = 244889, upload-time = "2026-01-26T02:45:53.27Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a9/a50d2669e506dad33cfc45b5d574a205587b7b8a5f426f2fbb2e90882588/multidict-6.7.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e628ef0e6859ffd8273c69412a2465c4be4a9517d07261b33334b5ec6f3c7489", size = 241982, upload-time = "2026-01-26T02:45:54.919Z" }, + { url = "https://files.pythonhosted.org/packages/c5/bb/1609558ad8b456b4827d3c5a5b775c93b87878fd3117ed3db3423dfbce1b/multidict-6.7.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:841189848ba629c3552035a6a7f5bf3b02eb304e9fea7492ca220a8eda6b0e5c", size = 232415, upload-time = "2026-01-26T02:45:56.981Z" }, + { url = "https://files.pythonhosted.org/packages/d8/59/6f61039d2aa9261871e03ab9dc058a550d240f25859b05b67fd70f80d4b3/multidict-6.7.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce1bbd7d780bb5a0da032e095c951f7014d6b0a205f8318308140f1a6aba159e", size = 240337, upload-time = "2026-01-26T02:45:58.698Z" }, + { url = "https://files.pythonhosted.org/packages/a1/29/fdc6a43c203890dc2ae9249971ecd0c41deaedfe00d25cb6564b2edd99eb/multidict-6.7.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b26684587228afed0d50cf804cc71062cc9c1cdf55051c4c6345d372947b268c", size = 248788, upload-time = "2026-01-26T02:46:00.862Z" }, + { url = "https://files.pythonhosted.org/packages/a9/14/a153a06101323e4cf086ecee3faadba52ff71633d471f9685c42e3736163/multidict-6.7.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9f9af11306994335398293f9958071019e3ab95e9a707dc1383a35613f6abcb9", size = 242842, upload-time = "2026-01-26T02:46:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/604ae839e64a4a6efc80db94465348d3b328ee955e37acb24badbcd24d83/multidict-6.7.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b4938326284c4f1224178a560987b6cf8b4d38458b113d9b8c1db1a836e640a2", size = 240237, upload-time = "2026-01-26T02:46:05.898Z" }, + { url = "https://files.pythonhosted.org/packages/5f/60/c3a5187bf66f6fb546ff4ab8fb5a077cbdd832d7b1908d4365c7f74a1917/multidict-6.7.1-cp314-cp314t-win32.whl", hash = "sha256:98655c737850c064a65e006a3df7c997cd3b220be4ec8fe26215760b9697d4d7", size = 48008, upload-time = "2026-01-26T02:46:07.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f7/addf1087b860ac60e6f382240f64fb99f8bfb532bb06f7c542b83c29ca61/multidict-6.7.1-cp314-cp314t-win_amd64.whl", hash = "sha256:497bde6223c212ba11d462853cfa4f0ae6ef97465033e7dc9940cdb3ab5b48e5", size = 53542, upload-time = "2026-01-26T02:46:08.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/81/4629d0aa32302ef7b2ec65c75a728cc5ff4fa410c50096174c1632e70b3e/multidict-6.7.1-cp314-cp314t-win_arm64.whl", hash = "sha256:2bbd113e0d4af5db41d5ebfe9ccaff89de2120578164f86a5d17d5a576d1e5b2", size = 44719, upload-time = "2026-01-26T02:46:11.146Z" }, + { url = "https://files.pythonhosted.org/packages/81/08/7036c080d7117f28a4af526d794aab6a84463126db031b007717c1a6676e/multidict-6.7.1-py3-none-any.whl", hash = "sha256:55d97cc6dae627efa6a6e548885712d4864b81110ac76fa4e534c03819fa4a56", size = 12319, upload-time = "2026-01-26T02:46:44.004Z" }, ] [[package]] name = "multiprocess" -version = "0.70.16" +version = "0.70.18" 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, upload-time = "2024-01-28T18:52:34.85Z" } +sdist = { url = "https://files.pythonhosted.org/packages/72/fd/2ae3826f5be24c6ed87266bc4e59c46ea5b059a103f3d7e7eb76a52aeecb/multiprocess-0.70.18.tar.gz", hash = "sha256:f9597128e6b3e67b23956da07cf3d2e5cba79e2f4e0fba8d7903636663ec6d0d", size = 1798503, upload-time = "2025-04-17T03:11:27.742Z" } 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, 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, 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, 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" }, + { url = "https://files.pythonhosted.org/packages/c8/f8/7f9a8f08bf98cea1dfaa181e05cc8bbcb59cecf044b5a9ac3cce39f9c449/multiprocess-0.70.18-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:25d4012dcaaf66b9e8e955f58482b42910c2ee526d532844d8bcf661bbc604df", size = 135083, upload-time = "2025-04-17T03:11:04.223Z" }, + { url = "https://files.pythonhosted.org/packages/e5/03/b7b10dbfc17b2b3ce07d4d30b3ba8367d0ed32d6d46cd166e298f161dd46/multiprocess-0.70.18-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:06b19433de0d02afe5869aec8931dd5c01d99074664f806c73896b0d9e527213", size = 135128, upload-time = "2025-04-17T03:11:06.045Z" }, + { url = "https://files.pythonhosted.org/packages/c1/a3/5f8d3b9690ea5580bee5868ab7d7e2cfca74b7e826b28192b40aa3881cdc/multiprocess-0.70.18-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:6fa1366f994373aaf2d4738b0f56e707caeaa05486e97a7f71ee0853823180c2", size = 135132, upload-time = "2025-04-17T03:11:07.533Z" }, + { url = "https://files.pythonhosted.org/packages/55/4d/9af0d1279c84618bcd35bf5fd7e371657358c7b0a523e54a9cffb87461f8/multiprocess-0.70.18-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:8b8940ae30139e04b076da6c5b83e9398585ebdf0f2ad3250673fef5b2ff06d6", size = 144695, upload-time = "2025-04-17T03:11:09.161Z" }, + { url = "https://files.pythonhosted.org/packages/17/bf/87323e79dd0562474fad3373c21c66bc6c3c9963b68eb2a209deb4c8575e/multiprocess-0.70.18-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0929ba95831adb938edbd5fb801ac45e705ecad9d100b3e653946b7716cb6bd3", size = 144742, upload-time = "2025-04-17T03:11:10.072Z" }, + { url = "https://files.pythonhosted.org/packages/dd/74/cb8c831e58dc6d5cf450b17c7db87f14294a1df52eb391da948b5e0a0b94/multiprocess-0.70.18-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:4d77f8e4bfe6c6e2e661925bbf9aed4d5ade9a1c6502d5dfc10129b9d1141797", size = 144745, upload-time = "2025-04-17T03:11:11.453Z" }, + { url = "https://files.pythonhosted.org/packages/ba/d8/0cba6cf51a1a31f20471fbc823a716170c73012ddc4fb85d706630ed6e8f/multiprocess-0.70.18-py310-none-any.whl", hash = "sha256:60c194974c31784019c1f459d984e8f33ee48f10fcf42c309ba97b30d9bd53ea", size = 134948, upload-time = "2025-04-17T03:11:20.223Z" }, + { url = "https://files.pythonhosted.org/packages/4b/88/9039f2fed1012ef584751d4ceff9ab4a51e5ae264898f0b7cbf44340a859/multiprocess-0.70.18-py311-none-any.whl", hash = "sha256:5aa6eef98e691281b3ad923be2832bf1c55dd2c859acd73e5ec53a66aae06a1d", size = 144462, upload-time = "2025-04-17T03:11:21.657Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b6/5f922792be93b82ec6b5f270bbb1ef031fd0622847070bbcf9da816502cc/multiprocess-0.70.18-py312-none-any.whl", hash = "sha256:9b78f8e5024b573730bfb654783a13800c2c0f2dfc0c25e70b40d184d64adaa2", size = 150287, upload-time = "2025-04-17T03:11:22.69Z" }, + { url = "https://files.pythonhosted.org/packages/ee/25/7d7e78e750bc1aecfaf0efbf826c69a791d2eeaf29cf20cba93ff4cced78/multiprocess-0.70.18-py313-none-any.whl", hash = "sha256:871743755f43ef57d7910a38433cfe41319e72be1bbd90b79c7a5ac523eb9334", size = 151917, upload-time = "2025-04-17T03:11:24.044Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c3/ca84c19bd14cdfc21c388fdcebf08b86a7a470ebc9f5c3c084fc2dbc50f7/multiprocess-0.70.18-py38-none-any.whl", hash = "sha256:dbf705e52a154fe5e90fb17b38f02556169557c2dd8bb084f2e06c2784d8279b", size = 132636, upload-time = "2025-04-17T03:11:24.936Z" }, + { url = "https://files.pythonhosted.org/packages/6c/28/dd72947e59a6a8c856448a5e74da6201cb5502ddff644fbc790e4bd40b9a/multiprocess-0.70.18-py39-none-any.whl", hash = "sha256:e78ca805a72b1b810c690b6b4cc32579eba34f403094bbbae962b7b5bf9dfcb8", size = 133478, upload-time = "2025-04-17T03:11:26.253Z" }, ] [[package]] @@ -1400,7 +1407,7 @@ name = "networkx" version = "3.4.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] 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 = [ @@ -1409,80 +1416,26 @@ wheels = [ [[package]] name = "networkx" -version = "3.5" +version = "3.6.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "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/6c/4f/ccdb8ad3a38e583f214547fd2f7ff1fc160c43a75af88e6aec213404b96a/networkx-3.5.tar.gz", hash = "sha256:d4c6f9cf81f52d69230866796b82afbccdec3db7ae4fbd1b65ea750feed50037", size = 2471065, upload-time = "2025-05-29T11:35:07.804Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" }, ] [[package]] name = "nodeenv" -version = "1.9.1" +version = "1.10.0" 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, upload-time = "2024-06-04T18:44:11.171Z" } +sdist = { url = "https://files.pythonhosted.org/packages/24/bf/d1bda4f6168e0b2e9e5958945e01910052158313224ada5ce1fb2e1113b8/nodeenv-1.10.0.tar.gz", hash = "sha256:996c191ad80897d076bdfba80a41994c2b47c68e224c542b48feba42ba00f8bb", size = 55611, upload-time = "2025-12-20T14:08:54.006Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/88/b2/d0896bdcdc8d28a7fc5717c305f1a861c26e18c05047949fb371034d98bd/nodeenv-1.10.0-py2.py3-none-any.whl", hash = "sha256:5bb13e3eed2923615535339b3c620e76779af4cb4c6a90deccc9e36b274d3827", size = 23438, upload-time = "2025-12-20T14:08:52.782Z" }, ] [[package]] @@ -1490,7 +1443,7 @@ name = "numpy" version = "2.2.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "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, upload-time = "2025-05-17T22:38:04.611Z" } wheels = [ @@ -1552,289 +1505,341 @@ wheels = [ [[package]] name = "numpy" -version = "2.3.3" +version = "2.4.6" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", - "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, 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]] -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, upload-time = "2025-03-07T01:44:31.254Z" }, -] - -[[package]] -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, upload-time = "2025-03-07T01:40:21.213Z" }, -] - -[[package]] -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, upload-time = "2025-03-07T01:42:13.562Z" }, -] - -[[package]] -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, upload-time = "2025-03-07T01:40:01.615Z" }, -] - -[[package]] -name = "nvidia-cudnn-cu12" -version = "9.10.2.21" +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", + "python_full_version == '3.12.*'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "nvidia-cublas" +version = "13.1.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nvidia-cuda-nvrtc" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, + { url = "https://files.pythonhosted.org/packages/3b/cd/154ca20c38269e05eff77c1464e6c1da89f50a6390b565e9d82e06bc11e1/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:37936a16db8fe4ac1f065c2139360608a543a09275cb1a1af612e08cfa065436", size = 423138758, upload-time = "2026-04-08T18:46:58.655Z" }, +] + +[[package]] +name = "nvidia-cuda-cupti" +version = "13.0.85" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/2a/80353b103fc20ce05ef51e928daed4b6015db4aaa9162ed0997090fe2250/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_aarch64.whl", hash = "sha256:796bd679890ee55fb14a94629b698b6db54bcfd833d391d5e94017dd9d7d3151", size = 10310827, upload-time = "2025-09-04T08:26:42.012Z" }, + { url = "https://files.pythonhosted.org/packages/33/6d/737d164b4837a9bbd202f5ae3078975f0525a55730fe871d8ed4e3b952b0/nvidia_cuda_cupti-13.0.85-py3-none-manylinux_2_25_x86_64.whl", hash = "sha256:4eb01c08e859bf924d222250d2e8f8b8ff6d3db4721288cf35d14252a4d933c8", size = 10715597, upload-time = "2025-09-04T08:26:51.312Z" }, +] + +[[package]] +name = "nvidia-cuda-nvrtc" +version = "13.0.88" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c3/68/483a78f5e8f31b08fb1bb671559968c0ca3a065ac7acabfc7cee55214fd6/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:ad9b6d2ead2435f11cbb6868809d2adeeee302e9bb94bcf0539c7a40d80e8575", size = 90215200, upload-time = "2025-09-04T08:28:44.204Z" }, + { url = "https://files.pythonhosted.org/packages/b7/dc/6bb80850e0b7edd6588d560758f17e0550893a1feaf436807d64d2da040f/nvidia_cuda_nvrtc-13.0.88-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d27f20a0ca67a4bb34268a5e951033496c5b74870b868bacd046b1b8e0c3267b", size = 43015449, upload-time = "2025-09-04T08:28:20.239Z" }, +] + +[[package]] +name = "nvidia-cuda-runtime" +version = "13.0.96" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/87/4f/17d7b9b8e285199c58ce28e31b5c5bbaa4d8271af06a89b6405258245de2/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ef9bcbe90493a2b9d810e43d249adb3d02e98dd30200d86607d8d02687c43f55", size = 2261060, upload-time = "2025-10-09T08:55:15.78Z" }, + { url = "https://files.pythonhosted.org/packages/2e/24/d1558f3b68b1d26e706813b1d10aa1d785e4698c425af8db8edc3dced472/nvidia_cuda_runtime-13.0.96-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f82250d7782aa23b6cfe765ecc7db554bd3c2870c43f3d1821f1d18aebf0548", size = 2243632, upload-time = "2025-10-09T08:55:36.117Z" }, +] + +[[package]] +name = "nvidia-cudnn-cu13" +version = "9.24.0.43" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, + { name = "nvidia-cublas" }, ] 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, upload-time = "2025-06-06T21:54:08.597Z" }, + { url = "https://files.pythonhosted.org/packages/ca/30/7c257e3d5cb4fecb147b93895c66e29c93f8e76d74b45bb418ff0587c4ec/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:a6812a554a1ff0413e9c52b84c26c050380649ab9615f9c16bded368ce9f421f", size = 650976863, upload-time = "2026-07-02T16:23:39.248Z" }, + { url = "https://files.pythonhosted.org/packages/5c/ba/791cffd048fe5b044e620df55267e3e95c0e6e07d50b41e377c03dfc910f/nvidia_cudnn_cu13-9.24.0.43-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:71f181cd810e90f9b6023b01186fe82d13d65f0ec098581ee201d39fad769e4b", size = 553099438, upload-time = "2026-07-02T16:27:42.58Z" }, ] [[package]] -name = "nvidia-cufft-cu12" -version = "11.3.3.83" +name = "nvidia-cufft" +version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink" }, ] 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, upload-time = "2025-03-07T01:45:27.821Z" }, + { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2f/7b57e29836ea8714f81e9898409196f47d772d5ddedddf1592eadb8ab743/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6c44f692dce8fd5ffd3e3df134b6cdb9c2f72d99cf40b62c32dde45eea9ddad3", size = 214085489, upload-time = "2025-09-04T08:31:56.044Z" }, ] [[package]] -name = "nvidia-cufile-cu12" -version = "1.13.1.3" +name = "nvidia-cufile" +version = "1.15.1.6" 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, upload-time = "2025-03-07T01:45:50.723Z" }, + { url = "https://files.pythonhosted.org/packages/3f/70/4f193de89a48b71714e74602ee14d04e4019ad36a5a9f20c425776e72cd6/nvidia_cufile-1.15.1.6-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:08a3ecefae5a01c7f5117351c64f17c7c62efa5fffdbe24fc7d298da19cd0b44", size = 1223672, upload-time = "2025-09-04T08:32:22.779Z" }, + { url = "https://files.pythonhosted.org/packages/ab/73/cc4a14c9813a8a0d509417cf5f4bdaba76e924d58beb9864f5a7baceefbf/nvidia_cufile-1.15.1.6-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:bdc0deedc61f548bddf7733bdc216456c2fdb101d020e1ab4b88d232d5e2f6d1", size = 1136992, upload-time = "2025-09-04T08:32:14.119Z" }, ] [[package]] -name = "nvidia-curand-cu12" -version = "10.3.9.90" +name = "nvidia-curand" +version = "10.4.0.35" 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, upload-time = "2025-03-07T01:46:23.323Z" }, + { url = "https://files.pythonhosted.org/packages/1e/72/7c2ae24fb6b63a32e6ae5d241cc65263ea18d08802aaae087d9f013335a2/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:133df5a7509c3e292aaa2b477afd0194f06ce4ea24d714d616ff36439cee349a", size = 61962106, upload-time = "2025-08-04T10:21:41.128Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9f/be0a41ca4a4917abf5cb9ae0daff1a6060cc5de950aec0396de9f3b52bc5/nvidia_curand-10.4.0.35-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:1aee33a5da6e1db083fe2b90082def8915f30f3248d5896bcec36a579d941bfc", size = 59544258, upload-time = "2025-08-04T10:22:03.992Z" }, ] [[package]] -name = "nvidia-cusolver-cu12" -version = "11.7.3.90" +name = "nvidia-cusolver" +version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas-cu12" }, - { name = "nvidia-cusparse-cu12" }, - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] 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, upload-time = "2025-03-07T01:47:16.273Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, + { url = "https://files.pythonhosted.org/packages/5f/67/cba3777620cdacb99102da4042883709c41c709f4b6323c10781a9c3aa34/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:0a759da5dea5c0ea10fd307de75cdeb59e7ea4fcb8add0924859b944babf1112", size = 200941980, upload-time = "2025-09-04T08:33:22.767Z" }, ] [[package]] -name = "nvidia-cusparse-cu12" -version = "12.5.8.93" +name = "nvidia-cusparse" +version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink-cu12" }, + { name = "nvidia-nvjitlink" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, + { url = "https://files.pythonhosted.org/packages/fa/18/623c77619c31d62efd55302939756966f3ecc8d724a14dab2b75f1508850/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b3c89c88d01ee0e477cb7f82ef60a11a4bcd57b6b87c33f789350b59759360b", size = 145942937, upload-time = "2025-09-04T08:33:58.029Z" }, ] + +[[package]] +name = "nvidia-cusparselt-cu13" +version = "0.8.1" +source = { registry = "https://pypi.org/simple" } 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, upload-time = "2025-03-07T01:48:13.779Z" }, + { url = "https://files.pythonhosted.org/packages/46/e1/cdc1797eadf82d3a9a575a19b33fdc871a97edbec42c00b5b5e914f4aff4/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4dca476c50bf4780d46cd0bfbd82e2bc10a08e4fef7950917ce8d7578d22a23f", size = 221051344, upload-time = "2025-09-05T18:49:51.289Z" }, + { url = "https://files.pythonhosted.org/packages/34/7d/2661f2fb3ac4302f3a246f5fc030213ac60c1fe0bce84f9783dbd831dbb7/nvidia_cusparselt_cu13-0.8.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:786ce87568c303fadb5afcc7102d454cd3040d75f6f8626f5db460d1871f4dd0", size = 170148586, upload-time = "2025-09-05T18:50:50.248Z" }, ] [[package]] -name = "nvidia-cusparselt-cu12" -version = "0.7.1" +name = "nvidia-nccl-cu13" +version = "2.30.7" 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, upload-time = "2025-02-26T00:15:44.104Z" }, + { url = "https://files.pythonhosted.org/packages/d1/21/a73174c6157101bdf1ffc22b517f76ff0082613989dd9bc8f43e8034caac/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_aarch64.whl", hash = "sha256:ca786ffa5a647c75d4d1f5cc72a6c4f537947e2ba8823d7c8aaf768e7a7b9f77", size = 215983881, upload-time = "2026-06-09T03:23:15.633Z" }, + { url = "https://files.pythonhosted.org/packages/3f/34/c500f90c7ae641b8e0f98965b36b8a7ac79cc8b296e8d251fe3eb592ee54/nvidia_nccl_cu13-2.30.7-py3-none-manylinux_2_18_x86_64.whl", hash = "sha256:cefa7fdb9710efd0f39c5f1be1d61ff6fc9a996c451265bd7fbdcf9455ed4b50", size = 215965170, upload-time = "2026-06-09T03:23:39.73Z" }, ] [[package]] -name = "nvidia-nccl-cu12" -version = "2.27.3" +name = "nvidia-nvjitlink" +version = "13.3.33" 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, upload-time = "2025-06-03T21:58:04.013Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ee/580ca6f29dcab0221db8706badca1bbbb084f1975c4d4e83329c3a7e31f0/nvidia_nvjitlink-13.3.33-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:26a6de7fb4c8fdaa7703d3dad720d6d427ddfea5c48a528fd97c11733ad830e5", size = 40742423, upload-time = "2026-05-26T16:54:51.613Z" }, + { url = "https://files.pythonhosted.org/packages/69/30/45414e35ff2eee7db3da037e5707037ccf9d2b5218ffbdb055ea4d5aa98a/nvidia_nvjitlink-13.3.33-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ce48b37dfeb3cb1eae4cf85adacb47d7a6539ea2272870c9a3628ce275c2037e", size = 39168635, upload-time = "2026-05-26T16:54:13.906Z" }, ] [[package]] -name = "nvidia-nvjitlink-cu12" -version = "12.8.93" +name = "nvidia-nvshmem-cu13" +version = "3.4.5" 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, upload-time = "2025-03-07T01:49:55.661Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/05cc9c720236dcd2db9c1ab97fff629e96821be2e63103569da0c9b72f19/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dc2a197f38e5d0376ad52cd1a2a3617d3cdc150fd5966f4aee9bcebb1d68fe9", size = 60215947, upload-time = "2025-09-06T00:32:20.022Z" }, + { url = "https://files.pythonhosted.org/packages/3c/35/a9bf80a609e74e3b000fef598933235c908fcefcef9026042b8e6dfde2a9/nvidia_nvshmem_cu13-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:290f0a2ee94c9f3687a02502f3b9299a9f9fe826e6d0287ee18482e78d495b80", size = 60412546, upload-time = "2025-09-06T00:32:41.564Z" }, ] [[package]] -name = "nvidia-nvtx-cu12" -version = "12.8.90" +name = "nvidia-nvtx" +version = "13.0.85" 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, upload-time = "2025-03-07T01:42:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f3/d86c845465a2723ad7e1e5c36dcd75ddb82898b3f53be47ebd429fb2fa5d/nvidia_nvtx-13.0.85-py3-none-manylinux1_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4936d1d6780fbe68db454f5e72a42ff64d1fd6397df9f363ae786930fd5c1cd4", size = 148047, upload-time = "2025-09-04T08:29:01.761Z" }, + { url = "https://files.pythonhosted.org/packages/a8/64/3708a90d1ebe202ffdeb7185f878a3c84d15c2b2c31858da2ce0583e2def/nvidia_nvtx-13.0.85-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cb7780edb6b14107373c835bf8b72e7a178bac7367e23da7acb108f973f157a6", size = 148878, upload-time = "2025-09-04T08:28:53.627Z" }, ] [[package]] -name = "opt-einsum" -version = "3.4.0" +name = "opentelemetry-api" +version = "1.30.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, upload-time = "2024-09-26T14:33:24.483Z" } +dependencies = [ + { name = "deprecated" }, + { name = "importlib-metadata" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2b/6d/bbbf879826b7f3c89a45252010b5796fb1f1a0d45d9dc4709db0ef9a06c8/opentelemetry_api-1.30.0.tar.gz", hash = "sha256:375893400c1435bf623f7dfb3bcd44825fe6b56c34d0667c542ea8257b1a1240", size = 63703, upload-time = "2025-02-04T18:17:13.789Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/36/0a/eea862fae6413d8181b23acf8e13489c90a45f17986ee9cf4eab8a0b9ad9/opentelemetry_api-1.30.0-py3-none-any.whl", hash = "sha256:d5f5284890d73fdf47f843dda3210edf37a38d66f44f2b5aedc1e89ed455dc09", size = 64955, upload-time = "2025-02-04T18:16:46.167Z" }, ] [[package]] -name = "optree" -version = "0.17.0" +name = "opentelemetry-sdk" +version = "1.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, { name = "typing-extensions" }, ] -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" }, +sdist = { url = "https://files.pythonhosted.org/packages/93/ee/d710062e8a862433d1be0b85920d0c653abe318878fef2d14dfe2c62ff7b/opentelemetry_sdk-1.30.0.tar.gz", hash = "sha256:c9287a9e4a7614b9946e933a67168450b9ab35f08797eb9bc77d998fa480fa18", size = 158633, upload-time = "2025-02-04T18:17:28.908Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/28/64d781d6adc6bda2260067ce2902bd030cf45aec657e02e28c5b4480b976/opentelemetry_sdk-1.30.0-py3-none-any.whl", hash = "sha256:14fe7afc090caad881addb6926cec967129bd9260c4d33ae6a217359f6b61091", size = 118717, upload-time = "2025-02-04T18:17:09.353Z" }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.51b0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "deprecated" }, + { name = "opentelemetry-api" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/c0/0f9ef4605fea7f2b83d55dd0b0d7aebe8feead247cd6facd232b30907b4f/opentelemetry_semantic_conventions-0.51b0.tar.gz", hash = "sha256:3fabf47f35d1fd9aebcdca7e6802d86bd5ebc3bc3408b7e3248dde6e87a18c47", size = 107191, upload-time = "2025-02-04T18:17:29.903Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/75/d7bdbb6fd8630b4cafb883482b75c4fc276b6426619539d266e32ac53266/opentelemetry_semantic_conventions-0.51b0-py3-none-any.whl", hash = "sha256:fdc777359418e8d06c86012c3dc92c88a6453ba662e941593adb062e48c2eeae", size = 177416, upload-time = "2025-02-04T18:17:11.305Z" }, ] [[package]] name = "packaging" -version = "25.0" +version = "26.2" 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, upload-time = "2025-04-19T11:48:59.673Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -1842,9 +1847,9 @@ name = "pandas" version = "2.3.3" 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 = "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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "python-dateutil" }, { name = "pytz" }, { name = "tzdata" }, @@ -1898,13 +1903,6 @@ wheels = [ { 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]] @@ -1993,17 +1991,6 @@ wheels = [ { 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" }, @@ -2066,118 +2053,130 @@ wheels = [ [[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, 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, 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" }, - { 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" }, +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/56/030b7b4719d53085722893e0009dffb9236aa10bca1b12121bdc5626ef16/propcache-0.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d5a81be28596d6559f6131ef33e10200de6e17643b3c74ce03f9eb103be6ae8b", size = 93417, upload-time = "2026-05-08T20:59:15.597Z" }, + { url = "https://files.pythonhosted.org/packages/1a/55/1140a8e067b8ec093a18a4ae7bb0045d9db65da38a08618ddc5e2f1994aa/propcache-0.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:29cbaac5ea0212663e6845e04b5e188d5a6ae6dd919810ac835bf1d3b42c3f4c", size = 53847, upload-time = "2026-05-08T20:59:17.096Z" }, + { url = "https://files.pythonhosted.org/packages/20/42/0e7443c90310498561addf346e7d57fe3c6ba1914e1ba938b5464c7bbfd2/propcache-0.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6bf3be92233808fcd338eba0fb4d0b59ec5772af4f4ecfcec450d1bfc0f8b5eb", size = 53512, upload-time = "2026-05-08T20:59:18.64Z" }, + { url = "https://files.pythonhosted.org/packages/b7/db/cf51a71bab2009517d1a7f0ee07657e3bd446c4d69f67e6966cf17bcf956/propcache-0.5.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2f8ea531c794b9d6274acd4e8d2c2ebcac590a4361d27482edd3010b79f1325e", size = 58068, upload-time = "2026-05-08T20:59:20.683Z" }, + { url = "https://files.pythonhosted.org/packages/b7/43/39b6bdee9699fa1e1641c519feeb64a67e2a9f93bb465c70776b37a7333f/propcache-0.5.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:decfca4c79dd53ebab484b00cc4b6717d8c369f86e74aa4ca395a64ac651495e", size = 61020, upload-time = "2026-05-08T20:59:22.112Z" }, + { url = "https://files.pythonhosted.org/packages/26/0b/843726fbb0a29a8c5684fdb25971823638399f31e52e9d1f06a02dc9aa6b/propcache-0.5.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4621064bbf28fa77ff64dd5d94367c04684c67d3a5bf1dff25f0cd0d98a38f3b", size = 62732, upload-time = "2026-05-08T20:59:23.805Z" }, + { url = "https://files.pythonhosted.org/packages/39/6e/899fed76dc1942b8a64193a4f059d7f1a2c7ef65085e8a9366ed8ec0d199/propcache-0.5.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b96db7141a592cbc968daf1feea83a118e6ab378af4abbc72b248c895414c22d", size = 60140, upload-time = "2026-05-08T20:59:25.389Z" }, + { url = "https://files.pythonhosted.org/packages/ab/09/3da4be9b5b879219ad234aa535b3dd4a080ed1ad48d3a73ca07a9e798f22/propcache-0.5.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1ca071adabaab6e9219924bbe00af821f1ee7de113a9eca1cdc292de3d120f4d", size = 60400, upload-time = "2026-05-08T20:59:27.238Z" }, + { url = "https://files.pythonhosted.org/packages/60/2f/09b72b874a9aa0044faf52a69807a6ed618e267ceaa9ec4a63195fa5b504/propcache-0.5.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e4294d04a94dcab1b3bccd8b66d962dcad411a1d19414b2a41d1445f1de32ad0", size = 58155, upload-time = "2026-05-08T20:59:28.48Z" }, + { url = "https://files.pythonhosted.org/packages/8a/37/97489848c54c95578045473954f10956d619ce6a09e7ac137b71cdcb698b/propcache-0.5.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:a0e399a2eccb91ed18721f86aa85757727400b6865c89e88934781deb9c8498b", size = 57037, upload-time = "2026-05-08T20:59:30.146Z" }, + { url = "https://files.pythonhosted.org/packages/22/db/6c695285ccfc49012743ee9c98212b8c5dd0aed7b63cfd816d4a0f7a1601/propcache-0.5.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:823581fd5cb08b12a48bfa11fe962a7916766b6170c17b028fbdf762b85eb9bf", size = 61103, upload-time = "2026-05-08T20:59:31.626Z" }, + { url = "https://files.pythonhosted.org/packages/98/a9/1e500401ca593b0bdb6bf75a70bc2d723835fd53360edff6af70692c7546/propcache-0.5.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:949c91d1a990cf3b2e8188dfcfb25005e0b834a06c63fa4ef9f360878ce21ecf", size = 60394, upload-time = "2026-05-08T20:59:32.829Z" }, + { url = "https://files.pythonhosted.org/packages/1f/87/f638b6e375eae0f30a1a2325d8b34fd85fdc785bb9960cf805f3bf1ec69a/propcache-0.5.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:cc1177027eda740fdb152706bd215a3f124e3eea15afc39f2cb9fe351b50619e", size = 63084, upload-time = "2026-05-08T20:59:35.964Z" }, + { url = "https://files.pythonhosted.org/packages/f6/18/884573f5d97b6d9eba68de759a82c901b7e39d7904d30f7b8d58d42d2a12/propcache-0.5.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b05d643f944a8c3c4bd86d65ffd87bf3264b617f87791940302bc474d2ff5274", size = 60999, upload-time = "2026-05-08T20:59:38.481Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1a/c3915eb059ceec9e758a56e4cfd955292bc0f201be2176a46b76d94b303a/propcache-0.5.2-cp310-cp310-win32.whl", hash = "sha256:8114f28879e0904748e831c3a7774261bd9e75f49be089f389a76f959dcd13fe", size = 39036, upload-time = "2026-05-08T20:59:40.323Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/1dfd5607501a602d19c1c449d2d193b7d1c611f9246b4059026a1189a80e/propcache-0.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:5fcb98e7598b1ee0addab320d90f65b530297a867dbfe9de52ea838077e16e3d", size = 42190, upload-time = "2026-05-08T20:59:42.232Z" }, + { url = "https://files.pythonhosted.org/packages/57/93/f71588ad08b3e6f4b555b5ef215808a3c02b042d0151ad82fa6f15be677a/propcache-0.5.2-cp310-cp310-win_arm64.whl", hash = "sha256:04dc2390d9edbbaef7461f33322555976ffddf0b650a038649d026358714e6c5", size = 38545, upload-time = "2026-05-08T20:59:44.087Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, ] [[package]] @@ -2207,57 +2206,57 @@ wheels = [ [[package]] name = "pyarrow" -version = "21.0.0" -source = { registry = "https://pypi.org/simple" } -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" }, +version = "25.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3d/e3/27f57f80141379d60defe6703eb50a707325706f07fedfd1312c7a751995/pyarrow-25.0.1.tar.gz", hash = "sha256:9150a83248bfed9813ea3c3af74c3856c1984d444aa28e58bf7733b9750ddf6a", size = 1201653, upload-time = "2026-08-10T12:40:53.904Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0a/3e/5cd70becb51e1d044c54ba5e627424a6e87df5b98008cbd22cc6abd409ca/pyarrow-25.0.1-cp310-cp310-macosx_12_0_arm64.whl", hash = "sha256:0b1edbb2f385a6a65e9711b62ba86ac54a7816a3f8d17bb3e8a5929d65fb2485", size = 35954271, upload-time = "2026-08-10T12:36:33.857Z" }, + { url = "https://files.pythonhosted.org/packages/64/be/17599e086df264ea7dc221d1101e3131e181e00da428a2f9bd0358f0d06b/pyarrow-25.0.1-cp310-cp310-macosx_12_0_x86_64.whl", hash = "sha256:a4dd8bf99a8fac133efc0ed6a92f5fddbe2adba0d0f6dd720e39ba9855cea85c", size = 37647543, upload-time = "2026-08-10T12:36:39.486Z" }, + { url = "https://files.pythonhosted.org/packages/42/34/e138b451fd3970a6eda4599f68ae3b2b32b661bc958de3239d54a0bf6575/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:bddd0c4f7630c2a3ddf6347c1bdaa79d97bcf6bd445f9e60c816b7d77c85a5ae", size = 46837120, upload-time = "2026-08-10T12:36:46.58Z" }, + { url = "https://files.pythonhosted.org/packages/57/5c/f8fc0eb2de03464a557d5a4d0c15e972d73362414696618833b771f7eddd/pyarrow-25.0.1-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:a4d6d5e9a3d1879a97c08ded0c797579b7965eafd0f0c26c30b45ccc06db939b", size = 50066460, upload-time = "2026-08-10T12:36:53.702Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d1/0dd64fd06de0333b808a02f60981635f067b71aad3a30698a9a104fae778/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:514ddb60285631af068875550c90eddc181db3e8e63a032b1559be189e82f056", size = 49937892, upload-time = "2026-08-10T12:37:00.349Z" }, + { url = "https://files.pythonhosted.org/packages/cb/3c/f89d1bd76d5f3284c2a44d7d7ebbd8204535e5ae2b41f4077069b4ff2ec6/pyarrow-25.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cab40b1edfef0262e0e5251aa2c58d75630f24d06dd7794480243acc001a1d7d", size = 53107240, upload-time = "2026-08-10T12:37:07.205Z" }, + { url = "https://files.pythonhosted.org/packages/67/67/b554a8e09f3f3decccf405eb8fbe86696321cbcb5b62d18b4a5057a4c113/pyarrow-25.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:60e89d8f13861a1f7f8d950fa54aebb8023b30734d0ac51ffa80beabe2df4bba", size = 27848683, upload-time = "2026-08-10T12:37:12.058Z" }, + { url = "https://files.pythonhosted.org/packages/ee/8b/0d23b47702fcfe8b3618d5292035099675c5a1c48258932350c08020f7b5/pyarrow-25.0.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:51093dd9e10325fbdb3c10a2ae7c4806e5c822d94e74ae4938b26524a3323fee", size = 35946180, upload-time = "2026-08-10T12:37:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/d8/17/707d17a5476c55a9541fde0db8213ac30979a792864d72415f176ba50c45/pyarrow-25.0.1-cp311-cp311-macosx_12_0_x86_64.whl", hash = "sha256:eb6203482ff3746a5632303a7279ae0b5a304c46985b49ed1378cb350ea6728d", size = 37644787, upload-time = "2026-08-10T12:37:25.795Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b2/cdc98ecf1a6408280bc3a6a07054cdd99a3f4670acc0545d383ce113e87d/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:880523be3d29efcf83d3998835d206118ccf35e3871dbd2fb60408cf6b007a80", size = 46834633, upload-time = "2026-08-10T12:37:33.604Z" }, + { url = "https://files.pythonhosted.org/packages/c8/6e/d3fafc41f378b2c65be43b827798c0fae42049a641c8526633ed3eb573e2/pyarrow-25.0.1-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:25f8720bf6387d5dc2ebd2622112de630760419e4b66134405dd24110d15f37e", size = 50065507, upload-time = "2026-08-10T12:37:40.565Z" }, + { url = "https://files.pythonhosted.org/packages/d5/12/8d0698954b8c3001844a898e0a6900bebe83d7ee40c11195174c5122f324/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4facd65742a024a4a366328a1d2292062d72d6e023c1b7dda8d4c37544933a25", size = 49955690, upload-time = "2026-08-10T12:37:46.644Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/1ecb936ac6409e90a34d58eea1c7cec09a9ae6d2141b9e49ad01a2b1ea47/pyarrow-25.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:aa0559502e1cd6254d6814614085dd9c5a3dd0419362978a936a3f68a9e5c3df", size = 53128198, upload-time = "2026-08-10T12:37:52.531Z" }, + { url = "https://files.pythonhosted.org/packages/8e/1c/5236033550633c9b7377b2a53660b2bbb06cb06dc09c4356332d67643ca1/pyarrow-25.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:62cd0d785b8aa6675ee355f9fc02252a340f4441257c42674937826fd7594325", size = 27857263, upload-time = "2026-08-10T12:37:56.943Z" }, + { url = "https://files.pythonhosted.org/packages/a6/e2/9ab15b88cbfac28e16419ce5439ec29234c5172cb8259301b4ba639bdec0/pyarrow-25.0.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:df961f2e7ae9cf496459259d798652c70625f6c080650d6952f8c04053c58ee9", size = 35861559, upload-time = "2026-08-10T12:38:02.567Z" }, + { url = "https://files.pythonhosted.org/packages/58/79/a0036dbe1eabe1f73127427342f1d99982584c4a2cde2651d6c93499c6f6/pyarrow-25.0.1-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:cc4aa407fde9fc660be3939e49ea31f50f3e9fec17c0ec63159f7711edd3efc9", size = 37628383, upload-time = "2026-08-10T12:38:09.083Z" }, + { url = "https://files.pythonhosted.org/packages/13/49/d93a57d375f4bf0cf82913dd6bb54acafde83dd993be2282c81ac5616cad/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:4340f0ba6c1d2e13f21658de1d7c662ca2545018568d0030a1e9afca159d87e3", size = 46820190, upload-time = "2026-08-10T12:38:15.458Z" }, + { url = "https://files.pythonhosted.org/packages/60/c9/711ca85d79f1ec98f29a5eae2b051e25b4ecec5de3e3c0e2d5c5dcb15664/pyarrow-25.0.1-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:5389cdf79447ed1515c9e31620e6e1e2302249564d603f2ad727d4f6d313e4c3", size = 50102437, upload-time = "2026-08-10T12:38:22.487Z" }, + { url = "https://files.pythonhosted.org/packages/80/53/8fb8359ff17cfb6263a1cf3ebf7caec9fe197de118719e84fcb1d0618026/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d51592cb7561e87877c506113e7adbf1342ab579e6c21f0ef44b8ba41cb74c80", size = 49942424, upload-time = "2026-08-10T12:38:28.755Z" }, + { url = "https://files.pythonhosted.org/packages/e8/83/4e5ae02a9341571b18a6fca380ac7a58ce6ddae7ab3c060208c0a1e79f02/pyarrow-25.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6109c94d8b9f3b17a041daca16cacb2f651ad8f1ef70a4232c2c0f37a23da2a8", size = 53144206, upload-time = "2026-08-10T12:38:34.862Z" }, + { url = "https://files.pythonhosted.org/packages/65/ee/197cbf47e49f83e6ebeb946a5259a48a638dea27ac774db42fe78022179d/pyarrow-25.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:8858d7bfc22e3f51529aeaa4077225029724623e4595dc9eff8c793935c34140", size = 27953934, upload-time = "2026-08-10T12:38:39.808Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8d/8f271a7a034c834910ec925d56fa4b29733b1380f5289419f5aaa3b02777/pyarrow-25.0.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:c7c534ec03c358a76ea3e505e74c1b6aef290af90c444dfd092dbfe23e755b85", size = 35855328, upload-time = "2026-08-10T12:38:45.489Z" }, + { url = "https://files.pythonhosted.org/packages/d2/cd/5bac242f4e841b9971d5eb94fdfe2577e2b70be983e27401e72055786037/pyarrow-25.0.1-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:dda9470024204d7bbf2042b47c6e8a0e47a3eeb8e34405882dfaea6577e0c153", size = 37622415, upload-time = "2026-08-10T12:38:51.107Z" }, + { url = "https://files.pythonhosted.org/packages/63/1f/96d03b4e1506524f7087adb0fd6b2f69f0c9c7aaff1ec36d8030082e15a5/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:44a9120ce5bd81936b8ab9a88076e3fd47c2c6838e0e43630fed83626aca81d9", size = 46813813, upload-time = "2026-08-10T12:38:57.773Z" }, + { url = "https://files.pythonhosted.org/packages/98/d6/33a411115b61dbfc16ad6ad73e71730f6fea654ee3667673bc53ab0e2fe7/pyarrow-25.0.1-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:0befcf816e45a1af33ac775a9970b749e4868a230c7372f0ae5e932bee27039f", size = 50104452, upload-time = "2026-08-10T12:39:04.579Z" }, + { url = "https://files.pythonhosted.org/packages/33/ae/b1b97c9ca87f9f9ddbb5230c798df94eccce61bd79b9b45458c69a478588/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f89685964f46e4216103c75483aac0c0692a5f72212d7ca835adba5ede56ce3", size = 49951343, upload-time = "2026-08-10T12:39:11.8Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a112df5cfd5a68cb1d9fc31cfe38c28d5aec9f10865ce37ecef2e4450873/pyarrow-25.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6943e2fe7954d29d84de45d29d34c8dc36ce96570e67d89aa9976e650a4a9138", size = 53144784, upload-time = "2026-08-10T12:39:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/31/24/97e8bd98f1e3b07e2ba08bcdff690674fbe16d69a7d2712cc3884665e615/pyarrow-25.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:31e49a7888fcdf3a835da33ae777f6bb9a866334e5a789282fc26dcf426f7f15", size = 27870159, upload-time = "2026-08-10T12:39:26.161Z" }, + { url = "https://files.pythonhosted.org/packages/36/4c/b525824ad3094076919273cd97db61fb3d78252dee76fa3b8dc8f76774aa/pyarrow-25.0.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:bf0b672390cdcb640d7288f96b826d71ff4e9abb254a86c89890baf51a29cee6", size = 35885255, upload-time = "2026-08-10T12:39:32.366Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/448bb0e940de41aec31d1a956e63ad9c54afdf122a103cc3ab20c2a3ce33/pyarrow-25.0.1-cp314-cp314-macosx_12_0_x86_64.whl", hash = "sha256:38a9a4b4b9613380e200641891495a56c3d5a98a092db4a870af9975e220471d", size = 37644461, upload-time = "2026-08-10T12:39:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/6e/9a/13587e38bd4806fd218f50fd13b8903fab60588a699ff0c406372e5b4043/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:0b726ad7e7b669be982b0c71c07fe4b037d654354130da79a7902a669e93a66b", size = 46877146, upload-time = "2026-08-10T12:39:43.722Z" }, + { url = "https://files.pythonhosted.org/packages/8d/61/1c5d1229fa21da4cff5365e41e57177aaac57c563c727f35419b8513d1c1/pyarrow-25.0.1-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:9171748cdf796972d85a4b60157c279913e242992e350c90c7450182a9838b2a", size = 50131616, upload-time = "2026-08-10T12:39:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/43/20/291e1d65cc0b09aa19f03cf25cf51a2f5fa94b5db315178f2d254ed5cad4/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b7a296aac7a71fa0886c08e155ddb6c636a50013f801f6178daafa0f9e726188", size = 50008879, upload-time = "2026-08-10T12:39:56.891Z" }, + { url = "https://files.pythonhosted.org/packages/8b/7c/1b7c9ec28e76576337e4f97b31141c9a181b89b6d1d6221e9d8205621a58/pyarrow-25.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0fe7c8b6c03969b49c8c66182e4a18e3819ab92d07cfab5d8370c531b9369ef0", size = 53170864, upload-time = "2026-08-10T12:40:04.918Z" }, + { url = "https://files.pythonhosted.org/packages/b7/75/f3d789dc06011a765d14d86bda799cf72ac1d715b6a6edecaa0d73d95062/pyarrow-25.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:f729cfdbd36fd99d543b67a914d2de044c84ebe45be8b34902b299b608c15c8f", size = 28620729, upload-time = "2026-08-10T12:40:51.41Z" }, + { url = "https://files.pythonhosted.org/packages/fc/05/647a8ee6f7c2662feb6921315617bc04dcd6034763fb61b1199720bf6162/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:59a2de54c0cbd954da861eee4d1d330f8e909c45b53455baef696380f2c55033", size = 36130288, upload-time = "2026-08-10T12:40:11.014Z" }, + { url = "https://files.pythonhosted.org/packages/93/f8/c9ee997554d7bea94520667dd1933f109ac1da3ee3556d2b49381e023484/pyarrow-25.0.1-cp314-cp314t-macosx_12_0_x86_64.whl", hash = "sha256:35935cd5de130aa5cf4dea052a63e6bf2e17006c35c3a468194242b9b2bf5956", size = 37762187, upload-time = "2026-08-10T12:40:16.592Z" }, + { url = "https://files.pythonhosted.org/packages/a2/08/a28c01c7fe9e96e8233ce2d13df1d402f4f999f848f51d2daacd6bb4c036/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:f3831aaa25c67a99f99dc8b05873cb9d64560390372e2aa197ce9dd4a3f06a44", size = 46888003, upload-time = "2026-08-10T12:40:23.242Z" }, + { url = "https://files.pythonhosted.org/packages/1b/b9/58612e977d28dc58c878448866838369ee8da2f1e7cc8ed2c84b952aafee/pyarrow-25.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:6a1fdfc6659b6b19022f2e50627fb5cf7156a66c46bf4299379955cbe742382a", size = 50079036, upload-time = "2026-08-10T12:40:29.169Z" }, + { url = "https://files.pythonhosted.org/packages/72/13/66e1402dcc860e1dc2760b1e0292c9a569b62b3bccab69def1b3e907d006/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:169d3429d5be7c752125890620f75a60776d38b0035eddae939651640822332e", size = 50040226, upload-time = "2026-08-10T12:40:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/78/10/3f1a5497a7ef732ab0f03ecca3e66d89d9c0f57fdc61b4794c456b781f01/pyarrow-25.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:119297a6dc197e45d9c6d4415f7814a67ffa36c180d26f68c154c58067ae782d", size = 53149035, upload-time = "2026-08-10T12:40:41.454Z" }, + { url = "https://files.pythonhosted.org/packages/93/c0/37d4a7e8e2f7a6076283673d5298018ca26478b934c6ee369e10505ab32c/pyarrow-25.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:4288f27577352d608ca08553b0865e4a9b3aa14820c5d95b53337218d609835b", size = 28753071, upload-time = "2026-08-10T12:40:46.623Z" }, ] [[package]] name = "pydantic" -version = "2.12.4" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -2265,149 +2264,134 @@ 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, upload-time = "2025-11-05T10:50:08.59Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.41.5" +version = "2.46.4" 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, 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" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[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, upload-time = "2025-06-21T13:39:12.283Z" } +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, upload-time = "2025-06-21T13:39:07.939Z" }, + { 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]] @@ -2415,9 +2399,9 @@ name = "pylance" source = { editable = "." } dependencies = [ { name = "lance-namespace" }, - { 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 = "numpy", version = "2.2.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, { name = "pyarrow" }, ] @@ -2433,6 +2417,10 @@ geo = [ { name = "geoarrow-rust-core" }, { name = "geoarrow-rust-io" }, ] +otel = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-sdk" }, +] tests = [ { name = "boto3" }, { name = "datafusion" }, @@ -2444,24 +2432,51 @@ tests = [ { name = "polars", extra = ["pandas", "pyarrow"] }, { name = "psutil" }, { name = "pytest" }, - { name = "tensorflow", marker = "sys_platform == 'linux'" }, + { name = "pytest-xdist" }, { name = "tqdm" }, ] torch = [ { name = "torch" }, ] +[package.dev-dependencies] +benchmarks = [ + { name = "pytest-benchmark" }, +] +dev = [ + { name = "maturin" }, + { name = "pyright" }, + { name = "ruff" }, +] +tests = [ + { name = "boto3" }, + { name = "datafusion" }, + { name = "datasets" }, + { name = "duckdb" }, + { name = "ml-dtypes" }, + { name = "opentelemetry-sdk" }, + { name = "pandas" }, + { name = "pillow" }, + { name = "polars", extra = ["pandas", "pyarrow"] }, + { name = "psutil" }, + { name = "pytest" }, + { name = "pytest-xdist" }, + { name = "tqdm" }, +] + [package.metadata] requires-dist = [ { name = "boto3", marker = "extra == 'tests'" }, - { name = "datafusion", marker = "extra == 'tests'", specifier = ">=50.1" }, - { name = "datasets", marker = "extra == 'tests'" }, - { name = "duckdb", marker = "extra == 'tests'" }, + { name = "datafusion", marker = "extra == 'tests'", specifier = ">=54,<55" }, + { name = "datasets", marker = "extra == 'tests'", specifier = "==4.4.0" }, + { name = "duckdb", marker = "extra == 'tests'", specifier = ">=1.5.0,<1.6.0" }, { name = "geoarrow-rust-core", marker = "extra == 'geo'" }, { name = "geoarrow-rust-io", marker = "extra == 'geo'" }, - { name = "lance-namespace", specifier = ">=0.4.5" }, + { name = "lance-namespace", specifier = ">=0.11.1,<0.12" }, { name = "ml-dtypes", marker = "extra == 'tests'" }, { name = "numpy", specifier = ">=1.22" }, + { name = "opentelemetry-api", marker = "extra == 'otel'" }, + { name = "opentelemetry-sdk", marker = "extra == 'otel'" }, { name = "pandas", marker = "extra == 'tests'" }, { name = "pillow", marker = "extra == 'tests'" }, { name = "polars", extras = ["pyarrow", "pandas"], marker = "extra == 'tests'" }, @@ -2470,51 +2485,34 @@ 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 = "tensorflow", marker = "sys_platform == 'linux' and extra == 'tests'" }, + { name = "pytest-xdist", marker = "extra == 'tests'" }, + { name = "ruff", marker = "extra == 'dev'", specifier = "==0.11.2" }, { name = "torch", marker = "extra == 'torch'", specifier = ">=2.0" }, { name = "tqdm", marker = "extra == 'tests'" }, ] -provides-extras = ["benchmarks", "dev", "geo", "tests", "torch"] +provides-extras = ["benchmarks", "dev", "geo", "otel", "tests", "torch"] -[[package]] -name = "pyproj" -version = "3.6.1" -source = { registry = "https://pypi.org/simple" } -resolution-markers = [ - "python_full_version < '3.10'", +[package.metadata.requires-dev] +benchmarks = [{ name = "pytest-benchmark", specifier = "==5.1.0" }] +dev = [ + { name = "maturin", specifier = "==1.13.3" }, + { name = "pyright", specifier = "==1.1.406" }, + { name = "ruff", specifier = "==0.11.2" }, ] -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" }, +tests = [ + { name = "boto3", specifier = "==1.40.43" }, + { name = "datafusion", specifier = "==54.0.0" }, + { name = "datasets", specifier = "==4.4.0" }, + { name = "duckdb", specifier = ">=1.5.0,<1.6.0" }, + { name = "ml-dtypes", specifier = "==0.5.3" }, + { name = "opentelemetry-sdk", specifier = "==1.30.0" }, + { name = "pandas", specifier = "==2.3.3" }, + { name = "pillow", specifier = "==11.3.0" }, + { name = "polars", extras = ["pyarrow", "pandas"], specifier = "==1.34.0" }, + { name = "psutil", specifier = "==7.1.0" }, + { name = "pytest", specifier = "==8.4.2" }, + { name = "pytest-xdist", specifier = "==3.8.0" }, + { name = "tqdm", specifier = "==4.67.1" }, ] [[package]] @@ -2522,10 +2520,10 @@ name = "pyproj" version = "3.7.1" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version == '3.10.*'", + "python_full_version < '3.11'", ] dependencies = [ - { name = "certifi", marker = "python_full_version == '3.10.*'" }, + { name = "certifi" }, ] 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 = [ @@ -2568,12 +2566,13 @@ name = "pyproj" version = "3.7.2" source = { registry = "https://pypi.org/simple" } resolution-markers = [ - "python_full_version >= '3.13'", + "python_full_version >= '3.14'", + "python_full_version == '3.13.*'", "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] dependencies = [ - { name = "certifi", marker = "python_full_version >= '3.11'" }, + { name = "certifi" }, ] 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 = [ @@ -2677,6 +2676,19 @@ wheels = [ { 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]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0" @@ -2691,11 +2703,11 @@ wheels = [ [[package]] name = "pytz" -version = "2025.2" +version = "2026.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, upload-time = "2025-03-25T02:25:00.538Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ff/46/dd499ec9038423421951e4fad73051febaa13d2df82b4064f87af8b8c0c3/pytz-2026.2.tar.gz", hash = "sha256:0e60b47b29f21574376f218fe21abc009894a2321ea16c6754f3cad6eb7cdd6a", size = 320861, upload-time = "2026-05-04T01:35:29.667Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/ec/dd/96da98f892250475bdf2328112d7468abdd4acc7b902b6af23f4ed958ea0/pytz-2026.2-py2.py3-none-any.whl", hash = "sha256:04156e608bee23d3792fd45c94ae47fae1036688e75032eea2e3bf0323d1f126", size = 510141, upload-time = "2026-05-04T01:35:27.408Z" }, ] [[package]] @@ -2760,69 +2772,46 @@ wheels = [ { 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]] name = "requests" -version = "2.32.5" +version = "2.34.2" 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 = "urllib3", version = "2.5.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "urllib3" }, ] -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/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } 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" }, -] - -[[package]] -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" }, -] -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, upload-time = "2025-07-25T07:32:56.73Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[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, upload-time = "2024-04-19T12:27:59.238Z" } -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, upload-time = "2024-04-19T12:27:04.138Z" }, - { 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, upload-time = "2024-04-19T12:27:09.354Z" }, - { 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, upload-time = "2024-04-19T12:27:12.845Z" }, - { 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, upload-time = "2024-04-19T12:27:15.809Z" }, - { 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, upload-time = "2024-04-19T12:27:18.364Z" }, - { 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, upload-time = "2024-04-19T12:27:22.275Z" }, - { 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, upload-time = "2024-04-19T12:27:25.45Z" }, - { 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, upload-time = "2024-04-19T12:27:28.831Z" }, - { 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, upload-time = "2024-04-19T12:27:32.319Z" }, - { 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, upload-time = "2024-04-19T12:27:35.921Z" }, - { 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, upload-time = "2024-04-19T12:27:39.216Z" }, - { 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, upload-time = "2024-04-19T12:27:42.647Z" }, - { 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, upload-time = "2024-04-19T12:27:45.716Z" }, - { url = "https://files.pythonhosted.org/packages/1c/30/b5f9fa73d7be01336750c8ca5bb3dc018a0ad29eb7e6d642785323bc67e5/ruff-0.4.1-py3-none-win32.whl", hash = "sha256:b90506f3d6d1f41f43f9b7b5ff845aeefabed6d2494307bc7b178360a8805252", size = 7600160, upload-time = "2024-04-19T12:27:48.663Z" }, - { url = "https://files.pythonhosted.org/packages/90/8f/07e0b4e24337ca92521472b8f8030f450c9765fe7bcd177ff248f708c028/ruff-0.4.1-py3-none-win_amd64.whl", hash = "sha256:c7d391e5936af5c9e252743d767c564670dc3889aff460d35c518ee76e4b26d7", size = 8451086, upload-time = "2024-04-19T12:27:52.485Z" }, - { url = "https://files.pythonhosted.org/packages/16/61/1843c9b453cd58b9c9e928388b609ebe70a63f3291bb10e7cfa24de69d03/ruff-0.4.1-py3-none-win_arm64.whl", hash = "sha256:a1eaf03d87e6a7cd5e661d36d8c6e874693cb9bc3049d110bc9a97b350680c43", size = 7964199, upload-time = "2024-04-19T12:27:55.935Z" }, +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]] @@ -2839,11 +2828,11 @@ wheels = [ [[package]] name = "setuptools" -version = "80.9.0" +version = "83.0.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, upload-time = "2025-05-27T00:56:51.443Z" } +sdist = { url = "https://files.pythonhosted.org/packages/34/26/f5d29e25ffdb535afef2d35cdb55b325298f96debd670da4c325e08d70f4/setuptools-83.0.0.tar.gz", hash = "sha256:025bccbbf0fa05b6192bc64ae1e7b16e001fd6d6d4d5de03c97b1c1ade523bef", size = 1154254, upload-time = "2026-07-04T15:31:22.699Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/5d/40/e1e72872c6354b306daef1703549e8e83b4d43cfea356311bf722a043752/setuptools-83.0.0-py3-none-any.whl", hash = "sha256:29b23c360f22f414dc7336bb39178cc7bcbf6021ed2733cde173f09dba19abb3", size = 1008090, upload-time = "2026-07-04T15:31:20.885Z" }, ] [[package]] @@ -2867,183 +2856,106 @@ wheels = [ { 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]] -name = "tensorboard" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "absl-py" }, - { name = "grpcio" }, - { name = "markdown" }, - { 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" }, - { name = "protobuf" }, - { name = "setuptools" }, - { name = "tensorboard-data-server" }, - { 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, upload-time = "2025-07-17T19:20:49.638Z" }, -] - -[[package]] -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, 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]] -name = "tensorflow" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -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 = "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 = "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" }, -] -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" }, - { 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, 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, 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, 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" }, +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] [[package]] name = "torch" -version = "2.8.0" +version = "2.14.0" source = { registry = "https://pypi.org/simple" } dependencies = [ + { name = "cuda-bindings", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, + { name = "cuda-toolkit", extra = ["cublas", "cudart", "cufft", "cufile", "cupti", "curand", "cusolver", "cusparse", "nvjitlink", "nvrtc", "nvtx"], marker = "sys_platform == 'linux'" }, { name = "filelock" }, { name = "fsspec" }, { name = "jinja2" }, - { 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'" }, - { name = "nvidia-cuda-nvrtc-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cuda-runtime-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cudnn-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufft-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cufile-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-curand-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusolver-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparse-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-cusparselt-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nccl-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvjitlink-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "nvidia-nvtx-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, - { name = "setuptools", marker = "python_full_version >= '3.12'" }, + { name = "networkx", version = "3.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, + { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "nvidia-cudnn-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-cusparselt-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nccl-cu13", marker = "sys_platform == 'linux'" }, + { name = "nvidia-nvshmem-cu13", marker = "sys_platform == 'linux'" }, + { name = "setuptools" }, { name = "sympy" }, - { name = "triton", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, + { name = "triton", marker = "python_full_version < '3.15' and sys_platform == 'linux'" }, { 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, 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" }, + { url = "https://files.pythonhosted.org/packages/76/28/14c2462ade87a149a794cfb1fbff9c9810638afbaf67122d9bc116270069/torch-2.14.0-cp310-cp310-macosx_14_0_arm64.whl", hash = "sha256:2fb30099be1fec9d163a6dd05ace176305ce8cf914b4d7a5f4f6d7a326aa4b60", size = 127245743, upload-time = "2026-09-02T13:42:48.266Z" }, + { url = "https://files.pythonhosted.org/packages/25/08/31e9f27c4af038b8541e02cd0377a92a2629fc800f7213fa2264c0e051bd/torch-2.14.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:96383c62423c3f2767023c4a94774b6435e2c006691daf396e9ddb55d412fbc6", size = 453985993, upload-time = "2026-09-02T13:43:11.736Z" }, + { url = "https://files.pythonhosted.org/packages/8b/b7/4113c03a90423f94c82f8212b033423e78b71036a94b96c2cf2c1ef3b5eb/torch-2.14.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:d30207cb89e713dfd6afb5d592b30d9ef252acf80407c5543157ea7b07c4d9a0", size = 554552101, upload-time = "2026-09-02T13:43:43.953Z" }, + { url = "https://files.pythonhosted.org/packages/19/df/ebf2d5d82ee3d89c5e066239b097e779c42696f0609bfa8e30628c1ba53f/torch-2.14.0-cp310-cp310-win_amd64.whl", hash = "sha256:d70c2b41bde81efde59fcefb147b9247fd062497e778677661b7de1bee5c8c99", size = 124096098, upload-time = "2026-09-02T13:42:53.127Z" }, + { url = "https://files.pythonhosted.org/packages/e6/2c/0c9757a1ea1fff8ccc4faff1d1c87e98a69542b5d4b606e50f7518f72e58/torch-2.14.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:ae530ddd3f3b94248b77f1fd3313c4f3405bd0d17283d505b81574816767133a", size = 127277244, upload-time = "2026-09-02T13:42:57.855Z" }, + { url = "https://files.pythonhosted.org/packages/ed/30/eb2f6cf77cb8f2d5e7ce4343882a856783521f51b557673d78d1d7bb8661/torch-2.14.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:4dbea8a10d80b10dff2be1ed467d2617b704fe6bf76bbfad4194774c8f6d886b", size = 453993896, upload-time = "2026-09-02T13:43:29.231Z" }, + { url = "https://files.pythonhosted.org/packages/8d/be/c0c8a845bf00552960d2f1f6a00b6ac9750fd312f55429c3e8c6c0e3aac4/torch-2.14.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8d9e232b6376c62f3090237889fb1cb6887c0ece9f73e6c1052e80fc1481f999", size = 554583496, upload-time = "2026-09-02T13:44:10.199Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5b/ed9d177f6a133389d5fa89e298f3e2a879b62952aa928ac6ddd04467f225/torch-2.14.0-cp311-cp311-win_amd64.whl", hash = "sha256:6981872df75eb5409c439050d36b67cb57d88b4e11080516e2aa7410f6730705", size = 124096356, upload-time = "2026-09-02T13:43:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/17/76/bb4770f56cf6d8971671dbcbb7493e5a6a15ad2825f4e359b02c27c38297/torch-2.14.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:c1f844f1c750e87df4b68bc3afbc0e2b0c7ef19d7b8f666e48bdcf6a0c4f0056", size = 127303200, upload-time = "2026-09-02T13:43:20.311Z" }, + { url = "https://files.pythonhosted.org/packages/fe/7b/ec44bacf2c8886b85ba4ca2285e8b09f2dff5d9c99e6a031326954082cb5/torch-2.14.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:ada340e62591d06a2bcc2d68170f20f45f0b0665d372dc510a8ed7eb3b1d609a", size = 454010251, upload-time = "2026-09-02T13:44:24.927Z" }, + { url = "https://files.pythonhosted.org/packages/15/71/49399acd41f750a906c686bd23c08a2001ccb8dd25f2971003c2ed89c1dd/torch-2.14.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:fecffb58f51fd643d213acd68da21cc3fc19bea05a3bc64b4ee55128f47a4963", size = 554620488, upload-time = "2026-09-02T13:44:49.442Z" }, + { url = "https://files.pythonhosted.org/packages/be/16/9489b137112040f9911d7527e452854f21cc4e499ce0da79864e6a7451a7/torch-2.14.0-cp312-cp312-win_amd64.whl", hash = "sha256:cad84f41bbdf3dcf333ce394aeeaf25237c4d94fd6b659ba5eb813c829978823", size = 124114011, upload-time = "2026-09-02T13:43:55.034Z" }, + { url = "https://files.pythonhosted.org/packages/ca/40/0db773452c2a62b37761d3f418acf933d381f9e87077036fb57c2a386c37/torch-2.14.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:9d4b1022a5d9b71282ec67ad0d9e7235870096b8a246dc1c32d6ea1fc83dc998", size = 127311393, upload-time = "2026-09-02T13:43:59.607Z" }, + { url = "https://files.pythonhosted.org/packages/13/36/537fd9da2adad49e7b2bb20741398625bee548493158274e87369a8eed56/torch-2.14.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:731b9ebdea402b8b1996d4c2ae613b16660e559b19e47bdc45d970568bc91c53", size = 454010525, upload-time = "2026-09-02T13:45:03.719Z" }, + { url = "https://files.pythonhosted.org/packages/21/f1/39bd13b21f57d1982b7f3ddf663f01c7266e2957714880744eba9e8c8d11/torch-2.14.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:84bf384779a10c02fc3c6bdbab71a9cb66b0dd93c652d1ed5d6dfc0cb37e5962", size = 554619993, upload-time = "2026-09-02T13:45:28.209Z" }, + { url = "https://files.pythonhosted.org/packages/89/a8/683d9c44737554b67ca76dd2db4f42258a0f014246cb511293e51e0154bd/torch-2.14.0-cp313-cp313-win_amd64.whl", hash = "sha256:0e7cf18cb0d8bd666b6120932e29c7aef3502b61a08b44da4839580c539a7cdb", size = 124113865, upload-time = "2026-09-02T13:44:33.705Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/1241e7db5ccc2455f8735bd6b1becfad39916206ad18001c4c0014d139e2/torch-2.14.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:860423e970f2ce02c4476e8e2d1350131b1c5c5a5e4912180e78b50b53241efa", size = 127321431, upload-time = "2026-09-02T13:44:38.236Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/bbeba65e3fbb4b8f61ea19cf4ebea9f4268cc6fe64e6f7d38bfb6cc152eb/torch-2.14.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:b985c7defeb8d28691b7513ebaecc64946c5f68ec770e9f4ddba39688864f46a", size = 454027631, upload-time = "2026-09-02T13:45:43.73Z" }, + { url = "https://files.pythonhosted.org/packages/50/75/8d2b9a7e724759470c209489b79260cac537f091cc9aac8001c8d2bc845c/torch-2.14.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b2cd92bce63d40bf6fc2e5d840fc2f0063bd180a242daa761cb6088cc2f46e27", size = 554623549, upload-time = "2026-09-02T13:46:04.252Z" }, + { url = "https://files.pythonhosted.org/packages/9a/1e/a5475c00b0555e686333e6b4036f2213e7cbea021a772ce6f9ced4dcbd2f/torch-2.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:44b044b9f6f633d982839422a57433d6a1da520037fd88e0c8a47efde589b3b8", size = 124110863, upload-time = "2026-09-02T13:45:12.764Z" }, + { url = "https://files.pythonhosted.org/packages/a5/49/bbaee76337742a42d2c5b0296ea62252567050e1d821bc2283b2e72ba6fb/torch-2.14.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:553aec938d37d77b783bcf801e638cad068870e7643c47eadac21eb180f551ed", size = 127653463, upload-time = "2026-09-02T13:45:17.1Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/6cc7a511bab384fbe8f4b8ddeecdd22e724b2162d6f15076f07a0a7ef15b/torch-2.14.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:cd8cd8f714d511ccdca907282d1da3d9be8322d4e1520b9c3bce39a5c1318a4b", size = 454009927, upload-time = "2026-09-02T13:46:19.637Z" }, + { url = "https://files.pythonhosted.org/packages/b6/da/0f04fe15fd05bf3f613a359b14acb44c35ae06fe69da0aefa8b5cdb4f9f9/torch-2.14.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d2526f71e6638133b97b3cd2881ece3df521460a4727030e8ae2a72a7d3ae31d", size = 554580530, upload-time = "2026-09-02T13:46:34.818Z" }, + { url = "https://files.pythonhosted.org/packages/f8/c3/72ae1f02747b1f012e1975743e48cd608f83095d7f9ce58de78b79248b35/torch-2.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:731784e3914843c6bcc7aba3987ff7610ac57dbbc816a5d6b9b62e04c240a641", size = 124400194, upload-time = "2026-09-02T13:45:53.555Z" }, ] [[package]] @@ -3060,28 +2972,30 @@ wheels = [ [[package]] name = "triton" -version = "3.4.0" +version = "3.8.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, 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" }, + { url = "https://files.pythonhosted.org/packages/0c/ae/6b01bdd0dc082e190f5b04aa70fa955f968ac3fc70fd71dde71aeb835a8f/triton-3.8.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:398f4b009c7ab08ed9aeb1d1282dee822945b53f399a5e12a4fecb643cf2007d", size = 226352592, upload-time = "2026-08-28T16:07:49.055Z" }, + { url = "https://files.pythonhosted.org/packages/65/65/18d0871f353eb170b0b9c562cd65f6ab9619b6e7b8aaecea9709e9ad16a6/triton-3.8.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6d914c52f89dc942b1819db959e7c07a5999f678ec760f677625c706b7e0d743", size = 247838081, upload-time = "2026-08-28T15:55:34.877Z" }, + { url = "https://files.pythonhosted.org/packages/a3/cf/d21c9b1a4d1df9ba3aed218069f24f762a4b9565c5a362bef6f110c08e72/triton-3.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:372285307d4c44ee74cee32de0b4f04bd157e071427e38c6f4ee3e3beb2194f4", size = 226467015, upload-time = "2026-08-28T16:08:05.871Z" }, + { url = "https://files.pythonhosted.org/packages/6f/9a/2c3d3823726d5ad5359f7065b02b984925158b74611c1e3987f6a37461fb/triton-3.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:68988ac85d5e7086baeda0ddc175af9667db7529b3c5e11a5c0601b8bef2200a", size = 247945226, upload-time = "2026-08-28T15:55:43.795Z" }, + { url = "https://files.pythonhosted.org/packages/87/07/0f8cd8e8db0472334253efdaaab3d0819fea27aa99bf0e7f1aeea4ceb5ae/triton-3.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a9c404c69ed4a39e8ec632eaf6b9fe058a060bf98979c177f6ef666f06bb8d50", size = 226474486, upload-time = "2026-08-28T16:08:18.29Z" }, + { url = "https://files.pythonhosted.org/packages/c1/09/b7012e5bfae67640f268aa584caa80fe1674f6b0da949046b679972c33e3/triton-3.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e91ffa46d095b252248297292dd22bcbacd53a125a0c2eefbbbf74925a320bc3", size = 247972921, upload-time = "2026-08-28T15:55:53.157Z" }, + { url = "https://files.pythonhosted.org/packages/87/4d/4c564374bcdadb166fccbf3e45aee0d4a473f88d341761bd2fefe3b8e8c1/triton-3.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7004666652f500ed854a86988e4b3d69d247188b5d2092b5df1e44f4a954099", size = 226476793, upload-time = "2026-08-28T16:08:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/3394d5548404c1cabd1dadadd28d0b3f9478db1dff8180da53bb3f0a1e19/triton-3.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1f0497218e26b7d79773ad9c2a3fa3b539ee69f587a13fac2e552b1d322a8015", size = 247975122, upload-time = "2026-08-28T15:56:04.112Z" }, + { url = "https://files.pythonhosted.org/packages/b8/59/bf0e9493118bb353ab59a5d6a65db3618d9b314417cc1459f0121e0ec5c9/triton-3.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f6b48d0591929a3867973acac3dccd4e058585f91bfb41022de496c9ffab304", size = 226488654, upload-time = "2026-08-28T16:08:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/93/d9/08c75f3459f19ad00425b564058e40efa4bcd79b816064cf27499303ea42/triton-3.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:387dae4cb0089a7b6ba1a428ae0782b65c4c58f57d94617cb22ca8593d8ccbca", size = 247972313, upload-time = "2026-08-28T15:56:14.007Z" }, + { url = "https://files.pythonhosted.org/packages/7c/34/429c5592181cfb7361a0a8e0bff218e7224b726709d75da2472b3e819f70/triton-3.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b84e7d512490ba529111260fa6f7cad8b254a6bb5fbdf41d5ef9a5e57f52d0a", size = 226591133, upload-time = "2026-08-28T16:09:02.271Z" }, + { url = "https://files.pythonhosted.org/packages/fe/d1/aa8a3e935c37efee7945984fdb64d7e0851bf6d920afd97b2d21f9d23360/triton-3.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74217bb56ed8692759227758e4c4b3bd2d608a209c1a7a081bf361fb4c2c1bf9", size = 248077577, upload-time = "2026-08-28T15:56:24.94Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.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, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -3098,352 +3012,386 @@ wheels = [ [[package]] name = "tzdata" -version = "2025.2" +version = "2026.3" 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, upload-time = "2025-03-23T13:54:43.652Z" } +sdist = { url = "https://files.pythonhosted.org/packages/92/ff/5a28bdfd8c3ebec42564ac7d0e54ca3db65044a9314a97f9564fa7a1e926/tzdata-2026.3.tar.gz", hash = "sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415", size = 198674, upload-time = "2026-07-10T08:50:37.887Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/e5/6d/b53b99a9f2766d095985947a5782f1702cabb129a34f7a802d7197af832f/tzdata-2026.3-py2.py3-none-any.whl", hash = "sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931", size = 348168, upload-time = "2026-07-10T08:50:36.46Z" }, ] [[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/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" } -resolution-markers = [ - "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, upload-time = "2025-06-18T14:07:40.39Z" }, -] - -[[package]] -name = "werkzeug" -version = "3.1.3" -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, 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, upload-time = "2024-11-08T15:52:16.132Z" }, -] - -[[package]] -name = "wheel" -version = "0.45.1" +version = "2.7.0" 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, upload-time = "2024-11-23T00:18:23.513Z" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } wheels = [ - { 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" }, + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] [[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, 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" }, +version = "2.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fe/a4/282c8e64300a59fc834518a54bf0afabb4ff9218b5fa76958b450459a844/wrapt-2.2.2.tar.gz", hash = "sha256:0788e321027c999bf221b667bd4a54aaefd1a36283749a860ac3eb77daed0302", size = 129068, upload-time = "2026-06-20T23:49:44.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/8b/59781d0fe7b0adfbea37f600857de4be68921e454aeecf1a11bda35cdccc/wrapt-2.2.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:055e6fcfaa28e58c6a8c247d48b92be9d56f818b7068aa4f22b15b3343a09931", size = 80556, upload-time = "2026-06-20T23:47:28.473Z" }, + { url = "https://files.pythonhosted.org/packages/94/dc/66c61aca927230c9cf97a3cb005c803971a1076ff9f7d61085d035c20085/wrapt-2.2.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8374eb6b1a58809211e84ff835a182bb17ab2807a5bfef23204c8cff38178a00", size = 81648, upload-time = "2026-06-20T23:47:30.504Z" }, + { url = "https://files.pythonhosted.org/packages/23/1b/545eee1c18f3af4cf140bb5822b6ef81ebe569df0a63ac109973103a30a5/wrapt-2.2.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:656593bb3f5529f03d27af4136c4d7b11990e470bcbc6fefa5ef218695bece55", size = 152956, upload-time = "2026-06-20T23:47:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/44/a7/6f42a3d03e44dc612a5dcff324e7366075a7857f0be2d49a8cb8a68279b8/wrapt-2.2.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dfb00cb7bb22099e2f64b7340fb96113639aa7260c0972af3797ace2297b936c", size = 154771, upload-time = "2026-06-20T23:47:33.352Z" }, + { url = "https://files.pythonhosted.org/packages/bf/55/4d76175aaa97523c38f1d28f79d18ab41a1b116814158a818bc0eba00571/wrapt-2.2.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e7f10ee0bd53673bfd52b67cbce83336fe6cad90d2377b03baf66491d2bbfb91", size = 149460, upload-time = "2026-06-20T23:47:34.712Z" }, + { url = "https://files.pythonhosted.org/packages/84/9b/12e23264d8f4735e8483262f95c5a6b03c3665fd2a84bdf99a45b6a2f4ec/wrapt-2.2.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:4402f57c5f0d0579599858ffbdd9bf4e3f0972f51096f2bd6cc7dab6b76ee49e", size = 153648, upload-time = "2026-06-20T23:47:36.092Z" }, + { url = "https://files.pythonhosted.org/packages/d6/a3/bcd5ec37289dcd85ecd4d15395a6a6063d60bc45ff94a9d77814e1e54d64/wrapt-2.2.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3a4eb7964ff4643d333c84f880bcf554652b2a1050aebc54ae696327f61acfaf", size = 148502, upload-time = "2026-06-20T23:47:37.623Z" }, + { url = "https://files.pythonhosted.org/packages/f2/be/716d708f607fa70f8a6eb47dff8ee945d5278dfc89ffeeff33039d052e63/wrapt-2.2.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e542b7c5af91e2123a8aabf19894319d5ec4268d2a9ffd2f239386133fc47746", size = 152238, upload-time = "2026-06-20T23:47:39.118Z" }, + { url = "https://files.pythonhosted.org/packages/b5/c0/1a48e7e54501274f5d906f18372221b13183b0afbb5b8bb4c7ca0392c0b4/wrapt-2.2.2-cp310-cp310-win32.whl", hash = "sha256:6e7e45b43d3c774d244fe7264378f5a3f0f383bc55a54a9866434e524540110f", size = 77278, upload-time = "2026-06-20T23:47:40.476Z" }, + { url = "https://files.pythonhosted.org/packages/b0/82/9cd69a1af288fbdedf01a10e3c8a0b6890b08c7f3f96d36a213699dbcd94/wrapt-2.2.2-cp310-cp310-win_amd64.whl", hash = "sha256:955f1d6e72a352e478de8d8b503abe301c5e139a141b62eb0923bd694995025f", size = 80131, upload-time = "2026-06-20T23:47:41.785Z" }, + { url = "https://files.pythonhosted.org/packages/7f/73/8db7e27daef37ae70a53ea62bef7fe80cc51a8b5e9e9181a8be6eb9a999c/wrapt-2.2.2-cp310-cp310-win_arm64.whl", hash = "sha256:b89d8d73c82db2bb7e6090b3afd7973f980d24e905cc34394eab60b884b3bf67", size = 79615, upload-time = "2026-06-20T23:47:43.109Z" }, + { url = "https://files.pythonhosted.org/packages/27/15/0c2d55168707465abfc41f33c0b23d792a5fa9b65c26983606940900a120/wrapt-2.2.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f1a2ff355ece6a111ca7a20dc86df6659c9205d3fcee674ca34f2a2854fd4e73", size = 80782, upload-time = "2026-06-20T23:47:44.367Z" }, + { url = "https://files.pythonhosted.org/packages/7d/b5/5c0b093eb48f8a062ef6267d3cb36e9bb1b88440181f6545a383c60efdf8/wrapt-2.2.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:55b9a899e6fff5444f229d30aa6e9ac92d2216d9d60f33c771b5d76a760d5f8e", size = 81678, upload-time = "2026-06-20T23:47:45.857Z" }, + { url = "https://files.pythonhosted.org/packages/34/f3/de70937472dd3e8a4e6811192f9c6075efdffd4a2cd9b4596bf160f89668/wrapt-2.2.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a2d78c363f97d8bd718ee40432c66395685e9e98528ccaa423c3355d1715a26d", size = 159671, upload-time = "2026-06-20T23:47:47.345Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ec/40aed2330e7f02ecf74386ffcfef9ccb7108c6a430f15b6a252b663b1bed/wrapt-2.2.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d619e1eed9bd4f6ed9f24cd61971aa086fa86505289628d464bcf8a2c2e3f328", size = 160785, upload-time = "2026-06-20T23:47:48.759Z" }, + { url = "https://files.pythonhosted.org/packages/45/04/aa5309beed5344b00220ae6b3b24055852192656194c27947bee1736306a/wrapt-2.2.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:518b0c5e323511ec56a38894802ddd5e1222626484e68efe63f201854ad788e5", size = 153699, upload-time = "2026-06-20T23:47:50.177Z" }, + { url = "https://files.pythonhosted.org/packages/01/df/2def7e99d1fe87eea413f95f671924cdddcb08823b1ffd212748dfa6d062/wrapt-2.2.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4bccea5cdecffa9dd70e343741f0e41e0a16619313d04b72f78bb525162ebcd0", size = 159695, upload-time = "2026-06-20T23:47:51.602Z" }, + { url = "https://files.pythonhosted.org/packages/c7/f6/a906d01a2ce12157bad2404957b3e2140da354b8a70b2fa48bbf282871c0/wrapt-2.2.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:209112cafd963710a05d199aae431d79a28bc76eb8e6d1bbbb8ad24340722cae", size = 152813, upload-time = "2026-06-20T23:47:53.03Z" }, + { url = "https://files.pythonhosted.org/packages/02/49/bc0086292d239575b4c08f4cf8a4079fa58abbad58ec23abf84833a283ed/wrapt-2.2.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e5a5290e4bf2f332fc29ce72ffb9a2fff678aaac047e2e9f5f7165cd7792e099", size = 158809, upload-time = "2026-06-20T23:47:54.391Z" }, + { url = "https://files.pythonhosted.org/packages/55/83/8fbd034de1f3e907edaa18786d5dd8f6932874edee0826c7cecb5cab03a1/wrapt-2.2.2-cp311-cp311-win32.whl", hash = "sha256:5499236ad1dc116012e2a5dd943f3f31af12fce452128e2bbcbd55a7d3d4d14c", size = 77414, upload-time = "2026-06-20T23:47:55.882Z" }, + { url = "https://files.pythonhosted.org/packages/7e/9c/23695baa331c6de4e874c3d78b8e0bed92e1d2a274e665b29858f6841672/wrapt-2.2.2-cp311-cp311-win_amd64.whl", hash = "sha256:8636809939152be6ae20a6cef0fed9fe60f411b47847d0426a826884b469e971", size = 80368, upload-time = "2026-06-20T23:47:57.237Z" }, + { url = "https://files.pythonhosted.org/packages/08/49/40cefc342bf89b234a4490d741290fce781774b831aefb39c25471da96c9/wrapt-2.2.2-cp311-cp311-win_arm64.whl", hash = "sha256:5d0a142f7af07caeb5e5da87493162a7b8efa19ba919e550a746f7446e13fb30", size = 79489, upload-time = "2026-06-20T23:47:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/2a/85/180b40628b23772692a0c76e8030114e1c0ae068470ed531919f0a5f2a4a/wrapt-2.2.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8417fd3c674d3c8023d080292d29301531a12daf8bd938dd419710dd2f464f2b", size = 81484, upload-time = "2026-06-20T23:47:59.924Z" }, + { url = "https://files.pythonhosted.org/packages/94/f2/21c90f2a16689702e2aaff45795b11018dff2c9b1242bac10d225483f676/wrapt-2.2.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e7070c7472582e31af3dfc2622b2381a0df7435110a9388ed8db5ffbce67efb", size = 82151, upload-time = "2026-06-20T23:48:01.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/b3/7e6e9fcf4fe7e1b69a49fe6cc5a44e8224bab6283c5233c97e132f14908e/wrapt-2.2.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2e096c9d39a59b35b63c9aacfbbbec2088ff51ff1fc31051acc60a07f42f273a", size = 169828, upload-time = "2026-06-20T23:48:02.719Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/894f132d857ed5a9904d937baf368badcbe5ea9e436e2f1930fe21c9f1f0/wrapt-2.2.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d1a6050405bf334be33bf66296f113563622972a34900ae6fa60fd283a1a900", size = 171544, upload-time = "2026-06-20T23:48:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/29/de/3c833e03725b477e9ea34028224dd21a48781830101e4e036f77e8b6b102/wrapt-2.2.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:10adb01371408c6de504a6658b9886480f1a4919a83752748a387a504a21df79", size = 160663, upload-time = "2026-06-20T23:48:05.708Z" }, + { url = "https://files.pythonhosted.org/packages/33/be/27edce350b24e3054d9d047f65f16d4c4d4c1f3f31c4278a1f8a95c723c8/wrapt-2.2.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3442eee2a5798f9b451f1b2cd7518ce8b7e28a2a364696c414460a0e295c012a", size = 169387, upload-time = "2026-06-20T23:48:07.243Z" }, + { url = "https://files.pythonhosted.org/packages/e2/c4/9fd9679af8bf38e146652c7f47b6b352c3e5795b4ad1c0b7f94e15ac2aa7/wrapt-2.2.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:6c99012a22f735a85eed7c4b86a3e99c30fdd57d9e115b2b45f796264b58d0bf", size = 158849, upload-time = "2026-06-20T23:48:08.91Z" }, + { url = "https://files.pythonhosted.org/packages/bc/c2/aa6c0c2206803068c6859dabe01f8c84c43744da93d4c67b8946d21655ee/wrapt-2.2.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3b686cfc008776a3952d6213cb296ed7f45d782a8453936406faa89eac0835ab", size = 168147, upload-time = "2026-06-20T23:48:10.374Z" }, + { url = "https://files.pythonhosted.org/packages/42/63/3eb25da41049d20ae18fcab2dd8b056e02387c4bfa626cbdfb7c3b872e4f/wrapt-2.2.2-cp312-cp312-win32.whl", hash = "sha256:ef2cce266b5b0b07e19fa82e59673b81142b7a3607c8ed1254113d048ed668da", size = 77734, upload-time = "2026-06-20T23:48:11.769Z" }, + { url = "https://files.pythonhosted.org/packages/da/09/0390e008a305360948fa9ce69507d041ac12cb2ee5d28e34467e2ee79391/wrapt-2.2.2-cp312-cp312-win_amd64.whl", hash = "sha256:abf8c20a2d72ee69e16328b3c91342c446e723bfe48bfcc4dded3b9722ac027f", size = 80585, upload-time = "2026-06-20T23:48:13.117Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/84c445c66969f2d3457276b183a48c91097d59bbef9af6c075366b0f8c36/wrapt-2.2.2-cp312-cp312-win_arm64.whl", hash = "sha256:c6c64c5d02578bc4c4bca4f0aef1504de933c1d5b4ac2710b9131111459506c8", size = 79553, upload-time = "2026-06-20T23:48:14.5Z" }, + { url = "https://files.pythonhosted.org/packages/43/fc/f32f4b22c6511173c11d9e541ab4e7d8467a0f1b3455acaf784115d31ff8/wrapt-2.2.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e8b648270c613720a202d9a45ebabc33261b22c3a839b115ac5bce8c0bb0d69", size = 81296, upload-time = "2026-06-20T23:48:15.881Z" }, + { url = "https://files.pythonhosted.org/packages/72/06/4d117d5d77a9344776c0248b24dae3d3dd2f58e5f765fa08cf887072e719/wrapt-2.2.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6fb7e94e8fe3e4c3067bb1653a91cce7c5e83acc119fdd41501b1bf74654617", size = 81841, upload-time = "2026-06-20T23:48:17.262Z" }, + { url = "https://files.pythonhosted.org/packages/15/ff/63ad96f98eb58a742b1a20d80f21da88924405910149950b912368150468/wrapt-2.2.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb18fc51e813df0d9c98049e3bf2298a5495a648602040e21fa3c7329371159e", size = 167882, upload-time = "2026-06-20T23:48:18.764Z" }, + { url = "https://files.pythonhosted.org/packages/20/1f/8bb62d8933df7acf3247194e6e9fc68edf9d2fa203252c89c94b319dd472/wrapt-2.2.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94b00b00f806eb3ef2abe9049ed45994a81ee9284884d96e6b8314927c6cea3d", size = 167411, upload-time = "2026-06-20T23:48:20.315Z" }, + { url = "https://files.pythonhosted.org/packages/17/09/8789dcb09ee1de715727db7521aabbb68ffa68dfade3a49468440cfced49/wrapt-2.2.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:62415fd095bc590b842b6d092f2b5d9ccbaeb7e0b28535c03dcea2718b48636b", size = 158607, upload-time = "2026-06-20T23:48:21.728Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/66e02562d53ee67d841f175e38e3c993c2d78a3e104c576cad61c028b43c/wrapt-2.2.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a41e758d80dc0ab8c210f641ac892009d356cf1f955d97db544c8dd317b4d14c", size = 166367, upload-time = "2026-06-20T23:48:23.177Z" }, + { url = "https://files.pythonhosted.org/packages/bd/a3/832ac4e41222fb263b3042d42c2f08d305db7d0f0c9b1d3a271a9eede8f6/wrapt-2.2.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b84cd4058001c9727b0e9980b7a9e66325b5ca748b1b578e822cade1bc6b304f", size = 157176, upload-time = "2026-06-20T23:48:24.711Z" }, + { url = "https://files.pythonhosted.org/packages/b7/01/1bd5e4d2df9c0178989ac8da9186543465388588ee2ef153e2591accebef/wrapt-2.2.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:26fc73a1b15e0946d2942b9a4426d162b51676338327dc067ccd8d2d76385f94", size = 167025, upload-time = "2026-06-20T23:48:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/1c/69/583ed25291ab53e1ec117135fb1c33425e2f46d2bc8f29c17f7a94cf4274/wrapt-2.2.2-cp313-cp313-win32.whl", hash = "sha256:3c4095803491f6ef72128914c28ec05bbad9758433bb35f6715a3e9c8e46fb2d", size = 77605, upload-time = "2026-06-20T23:48:27.643Z" }, + { url = "https://files.pythonhosted.org/packages/29/68/e69fc6d06e1523c68e0d00f95c9aed1158ce9908ee41603f7f2eae3d5db6/wrapt-2.2.2-cp313-cp313-win_amd64.whl", hash = "sha256:2cb07f414fab25dbe6b5c7398e1491423a5c81a6209533639969a6c928d474a4", size = 80508, upload-time = "2026-06-20T23:48:29.013Z" }, + { url = "https://files.pythonhosted.org/packages/55/21/fe7a393d9e5dc0923bed8f5d857e9dcff210f1fa0888c02cc8f3ffaa55aa/wrapt-2.2.2-cp313-cp313-win_arm64.whl", hash = "sha256:1fc7691f070220215cccb2a20836b9adbaecb8ff22ad47abe63de5f110994fac", size = 79565, upload-time = "2026-06-20T23:48:30.429Z" }, + { url = "https://files.pythonhosted.org/packages/b6/e5/c120d13bf5091164f68c3c1657e84f16f57e71d978421b626393ac5bd7eb/wrapt-2.2.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ec8f83949028366531383603139403cac7a826e4011955813cdd640017845ce5", size = 83264, upload-time = "2026-06-20T23:48:31.807Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b0/d4a1eb97e0e286625bdf21bc7f702637f9607787ffbbdb5ec14d50c79dbf/wrapt-2.2.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4b481fb0c40d9fd90a5809911208da700987d373a20a4709dc9e3944af7a6bec", size = 83791, upload-time = "2026-06-20T23:48:33.482Z" }, + { url = "https://files.pythonhosted.org/packages/18/1e/f060df47755e87b57684cee7bfc1362b204df55fac96ffebc0631b697b79/wrapt-2.2.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0065a3b657cec06813b4241d2462ccec287f6863103d7445b725fb3a889736f9", size = 203399, upload-time = "2026-06-20T23:48:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/c4/de/2316a757a1abb6453700b79d83e532146dcef2611348282d4d8889792161/wrapt-2.2.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:30f7424af5c5c345b7f26490e097f74a2ef45b3d08b664dc33571aee3bd3b56c", size = 210461, upload-time = "2026-06-20T23:48:36.569Z" }, + { url = "https://files.pythonhosted.org/packages/ed/29/d1160785ae18ca2495a6d82a21154103d74f656c9fd457fb35f6b11b965a/wrapt-2.2.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07fdcb012821859168641acf68afad61ef9783cf37100af85f152550e9677194", size = 195313, upload-time = "2026-06-20T23:48:38.175Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2d/7caa9598ae61a9cf0989cc501739cbeeb7d650ab3193cca1407b9af0c6ab/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:f90038ab58fafb584801ca62d72384d7d5225d93c76f7b773c22fae545bd8066", size = 206116, upload-time = "2026-06-20T23:48:39.804Z" }, + { url = "https://files.pythonhosted.org/packages/ac/02/281ea1088b8650d865f311b35cf86fd21df89128e2909714f1161e01c9d0/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:c5d7825491bfa2d08b97e9557768987952c7b9ae687d06c3320b40a37ccb7f20", size = 192668, upload-time = "2026-06-20T23:48:41.346Z" }, + { url = "https://files.pythonhosted.org/packages/be/7d/976e2d5b4b5c5babda40974edd54d0a5585cb60132ed86b46f4b80239b16/wrapt-2.2.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0ad520e6daa9bbf136f14de735474dbec7dcc0891f718e1d274ce8dc92e645af", size = 198891, upload-time = "2026-06-20T23:48:43.056Z" }, + { url = "https://files.pythonhosted.org/packages/59/b7/e47651797c097f75a37e2ce86dcf04048ff576f3a674f7c558df7b5e9622/wrapt-2.2.2-cp313-cp313t-win32.whl", hash = "sha256:25904acb9475f46c24fe0423dbc8fda8cc5fbc282ab3dc6e72e919748c53f4e9", size = 78537, upload-time = "2026-06-20T23:48:44.509Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6f/9fa5d59fb06d890defb5a8f727ce6a14d2932c8760153f96956628559fee/wrapt-2.2.2-cp313-cp313t-win_amd64.whl", hash = "sha256:305d4c247d61c4115794a169141823c62f719525ddb90b23aa332741c77d2c28", size = 82005, upload-time = "2026-06-20T23:48:46.391Z" }, + { url = "https://files.pythonhosted.org/packages/15/80/4c7bd9873d1f9f7d138d93556b500469dbe24f42710b877519c2b9eb380d/wrapt-2.2.2-cp313-cp313t-win_arm64.whl", hash = "sha256:c20279cd1a29800815d7b2d6338b60a6c6e78263f9d6e62e0eda251ba9cae2d0", size = 80762, upload-time = "2026-06-20T23:48:47.964Z" }, + { url = "https://files.pythonhosted.org/packages/24/05/7fd9c3f83b2c74cbfc572a0b88aa37431e04bd8aed70d2c0efd3464206de/wrapt-2.2.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0e64826f920c42d9d9f87e8cc09ffae66c51ede12d59061a5a426deb9aa71745", size = 81341, upload-time = "2026-06-20T23:48:49.39Z" }, + { url = "https://files.pythonhosted.org/packages/4b/68/1bfa43100dd90d4ef74a05897b86275cf57e1313ca14aae2545bc9f872c9/wrapt-2.2.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dcaa5e1451bd8751d7bd1568dfa3321c78092a52a7ecb5d1a0f18a5791e1fd00", size = 81921, upload-time = "2026-06-20T23:48:50.986Z" }, + { url = "https://files.pythonhosted.org/packages/74/eb/df7b7f0b631dbbc750f39be27d8b55f65777d8ac86da80e12be41a644c4b/wrapt-2.2.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0abfd648dac9ac9c5b3aa9b523d27f1789046640b58dcd5652a720ddb325e1fc", size = 167713, upload-time = "2026-06-20T23:48:52.598Z" }, + { url = "https://files.pythonhosted.org/packages/4d/9a/d1bd36f6d088c8e652a9383cabbd49af30b8c576302a7eccddbab6963e3f/wrapt-2.2.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f4bfd8d1eb438153eff8b8cfe87f032ba65731e1ce06138b5090f745a33f6f95", size = 166779, upload-time = "2026-06-20T23:48:54.33Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ae/24ffacd4187fac2740a1972093929e836dea092d42c87d728cd98fee11a6/wrapt-2.2.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c427c9d06d859848a69f0d928fe28b5c33a941b2265d10a0e1f15cd244f1ee33", size = 158407, upload-time = "2026-06-20T23:48:55.944Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ed/974427668249a356051e8d67d47fa54ef6c777f0fcf3bae9d292c047d4b6/wrapt-2.2.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4250b43d1a129d947e083c4dc6baf333c9bb34edd26f912d5b0457841fc858ab", size = 166594, upload-time = "2026-06-20T23:48:57.617Z" }, + { url = "https://files.pythonhosted.org/packages/fb/5f/e1d7c6e4523f78db2fbd7826babd0348da1d5e0834c4f918b9ab5757dfae/wrapt-2.2.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:173e5bb5ca350a6e0abab60b7ec7cdd7992a814cb14b4de670a28f067f105663", size = 157068, upload-time = "2026-06-20T23:48:59.171Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c1/7ebd1027f00700c0b0233b20aceef2b4784294ed64971424c4a78e069e34/wrapt-2.2.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:aa14b01804bce36c6d63d7b6a4f55df390f29f8648cc13a1f40b166f4d54680d", size = 166470, upload-time = "2026-06-20T23:49:00.737Z" }, + { url = "https://files.pythonhosted.org/packages/99/eb/974e471a6a978b8180186b8a9dc5ae3361ce269a967190b709b8ce17abfb/wrapt-2.2.2-cp314-cp314-win32.whl", hash = "sha256:58f9f8d637c9a6e245c6ef5b109b67ec187d2faed23d1405656b51d96e0a5b56", size = 78062, upload-time = "2026-06-20T23:49:02.327Z" }, + { url = "https://files.pythonhosted.org/packages/49/ec/e1281156cdc7a66693838ad7a0865ad641c74abd337a957d668b575aaffb/wrapt-2.2.2-cp314-cp314-win_amd64.whl", hash = "sha256:385cb1866f20479e83299af585375bfa0a4b0c6c9907a981483ea782ea8ae406", size = 80832, upload-time = "2026-06-20T23:49:03.837Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/1b6b5ddd94005a2dac97a4490c9838f3154977850d633abcb65b30089437/wrapt-2.2.2-cp314-cp314-win_arm64.whl", hash = "sha256:8ffbeaea6771a6eba6e6eeb09767864995726bc8240bb54baf88a9bb1db34d5c", size = 80029, upload-time = "2026-06-20T23:49:05.237Z" }, + { url = "https://files.pythonhosted.org/packages/b0/33/9ebcf8aafe91c601127cbd93708c16aa8f688f34a10bf004046803ecdc4f/wrapt-2.2.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:09f811d43f6f33ec7515f0be76b159569f4057ab54d3e079c3204dddb90afa2a", size = 83357, upload-time = "2026-06-20T23:49:06.632Z" }, + { url = "https://files.pythonhosted.org/packages/39/38/ec45b635153327b52e52732a0ea980e5f00b7efba65f9e018828f1e69daa/wrapt-2.2.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a795d3c06e5fbf9ea2f13196180b77aeab1b4685917256ee0d014cc163d90063", size = 83794, upload-time = "2026-06-20T23:49:08.098Z" }, + { url = "https://files.pythonhosted.org/packages/4e/ea/1a89e6d3b7a83c3affe5c09cde77792c947e63e4bc85ad84cd5bb9abb0d8/wrapt-2.2.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:45c2f2768e790c9f8db90f239ef23a2af8e7570f25a35619ef902df4a738447f", size = 203362, upload-time = "2026-06-20T23:49:09.811Z" }, + { url = "https://files.pythonhosted.org/packages/19/d8/3b58763d9863b5a73771c0d97110f9595d248db454009e07e1535ee905a4/wrapt-2.2.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bbf00ee0cb55ec24e2b0995a71942b85b21a066db8f3f46e1dbfdb9433ffba81", size = 210449, upload-time = "2026-06-20T23:49:11.521Z" }, + { url = "https://files.pythonhosted.org/packages/2d/6f/17fd9e053103d8be148d20d5d7505facc72d5fe1f9127973904ceaed79cf/wrapt-2.2.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2252f77663651b89255895f58cc6ac08fcb206d4371813e5af61bb62d4f7689c", size = 195349, upload-time = "2026-06-20T23:49:13.346Z" }, + { url = "https://files.pythonhosted.org/packages/ef/04/d0d1ccaaa12cb7dccf28a23f0279a608ba498f71e81d949d5ed54bcfd5c1/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2cd7181ab1c31192ff5219269830744b5a62020b3a6d433588c4f1c95b8f8bff", size = 206099, upload-time = "2026-06-20T23:49:15.051Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/e8aa07b619890a2aa6cde1931b1887abb08820721b564a5f80b7ca3f3aa0/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:6fe35fd51b74867d8b80174c277bd6bbf6a73e443f908129dc531c4b688a20d5", size = 192728, upload-time = "2026-06-20T23:49:16.854Z" }, + { url = "https://files.pythonhosted.org/packages/b7/f0/1819fb50f0d3c9bd758d8a83b56f1b470dee8b5b8eac8702b7c137cea9d4/wrapt-2.2.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:11d95fc2fbad3163596c39d440e6f21ca9fccece74b56e30a37ac2fca786a07c", size = 198842, upload-time = "2026-06-20T23:49:18.504Z" }, + { url = "https://files.pythonhosted.org/packages/67/7c/e88313f16a99930b899ef970d91c281544a470749a359decad994483bbda/wrapt-2.2.2-cp314-cp314t-win32.whl", hash = "sha256:d8a15813215f33fa83667bfc978b300e35669ea8bb424e970a1426bcb7bc6cca", size = 79059, upload-time = "2026-06-20T23:49:20.107Z" }, + { url = "https://files.pythonhosted.org/packages/a0/4f/ac12fda57a55068a094ec42851fb0a40e8489d8941863d517452de62e507/wrapt-2.2.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d09db0f7e8357060d3c38fc22a018aba683a796bf184360fd1a58f6fc180dc77", size = 82462, upload-time = "2026-06-20T23:49:21.631Z" }, + { url = "https://files.pythonhosted.org/packages/48/a7/df732dac86d9b2027c56bd163dbc883e037b16c3469614752e148d219c61/wrapt-2.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:f32fe639c39561ccc187bcae17e9271be0eb45f1c2952510d2f29b33ab577347", size = 81182, upload-time = "2026-06-20T23:49:23.199Z" }, + { url = "https://files.pythonhosted.org/packages/6e/d2/6317eb6d4554855bbf12d61857774af34747bf88a42c19bf306de67e2fa3/wrapt-2.2.2-py3-none-any.whl", hash = "sha256:5bad217350f19ce99ca5b5e71d406765ea86fe541628426772b657375ee1c048", size = 61460, upload-time = "2026-06-20T23:49:42.966Z" }, ] [[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, 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" }, +version = "3.8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/63/71aa56b151a1b28770037a61bd4e461c2619cfc8866a4fcaf1548605e325/xxhash-3.8.1.tar.gz", hash = "sha256:b0de4bf3aa66363552d52c6a89003c479911f12098cd48a53d44a0f7a25f7c46", size = 86223, upload-time = "2026-07-06T10:49:58.937Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/55/97/1a8cebf0a6650417f08a18231590e2515aacd5ce39c3ad8b9e013ebd437d/xxhash-3.8.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:27a9e475157f7315826118e3f3127909a0fe25f1b43d3d3be9c584f9d265f937", size = 34695, upload-time = "2026-07-06T10:43:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/2f/cf/745b9bc0dd9c341bc074b5fc700db7bbef0f3b69ab21446492296ab37e50/xxhash-3.8.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9b2ce44bf8f4a1d01f418b3110ff8dff32fd3f3e836c0e06333c3725f243fa6c", size = 32376, upload-time = "2026-07-06T10:43:41.97Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/8512a901b1d6ad4a9838d1b40385907a879d7e005a5afbec5d39526b69f6/xxhash-3.8.1-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:942bc86e9be6fdd6e1175048f5fe8f8fdaaf2309dd1323ef1e155a69cd346780", size = 217470, upload-time = "2026-07-06T10:43:43.572Z" }, + { url = "https://files.pythonhosted.org/packages/a0/ad/0ffd8094ea29579bb2dc42fa74d08570e9ea3d95db561e6b1105e69b9ca6/xxhash-3.8.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0204701e6d01f64254e0e5ff4255812b1febe027ddd7dda63372e27f98b5e91f", size = 237799, upload-time = "2026-07-06T10:43:45.248Z" }, + { url = "https://files.pythonhosted.org/packages/b3/90/783c6b3f9336bd07449fe672be32cef6833633936bbfda8d3b23ee18d202/xxhash-3.8.1-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7dc4bdf008f77c88d544849c48c1a40faf25a5eff6cc466de2e8edc37c191fce", size = 262587, upload-time = "2026-07-06T10:43:46.733Z" }, + { url = "https://files.pythonhosted.org/packages/c4/77/ba0316a7c3e661b86830a47ae4987798616ce1b15af8d2a6358e2d89ef60/xxhash-3.8.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5c566b123dce7e4867ca518434cdfb9f84e5023771235b2e3107a26c9a41cbd8", size = 238484, upload-time = "2026-07-06T10:43:48.453Z" }, + { url = "https://files.pythonhosted.org/packages/09/79/33001037c1cba90f4ced38b257161c13452024c0db44208f883e2e47f3fc/xxhash-3.8.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f23083e1bd9d901f844af7a126727c486e7eada9a1a6791c8f7e73f94fac656", size = 469909, upload-time = "2026-07-06T10:43:50.188Z" }, + { url = "https://files.pythonhosted.org/packages/45/90/237eded9dd6ae638083294e5a9f77b317aaebd480a330806b39c192a0de1/xxhash-3.8.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:64af54dd1c3a45a27c04942f9a1a4683322bdd127f4745cca4e02549c1d2d2bb", size = 217166, upload-time = "2026-07-06T10:43:51.816Z" }, + { url = "https://files.pythonhosted.org/packages/0b/6a/8cb439dc9920e1468e1c2d69ef77cbeb4be3b1ae9f4b5344c07a2b59af18/xxhash-3.8.1-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8ea8a141eeced4f6262ab6dd71c681ac546a558c30bb586abe087d814b5f85ea", size = 307593, upload-time = "2026-07-06T10:43:53.436Z" }, + { url = "https://files.pythonhosted.org/packages/ec/c6/c0607d373c8affea92101a3926c4fc8b026bcf8983e05fd58f3a0380ebf8/xxhash-3.8.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:a98b2f95cab589e0f5e92c48431afb4d56238b8bf6668edcc66166180e9b509b", size = 234702, upload-time = "2026-07-06T10:43:55.042Z" }, + { url = "https://files.pythonhosted.org/packages/5b/cb/f4cfd456624c1f017858168b7ba9443dad810da8aac779a612658450e827/xxhash-3.8.1-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:1b86ae798a976ccbc1d02af6ccb98f5b4d24756b1f65e995f11d10fe071f486f", size = 265749, upload-time = "2026-07-06T10:43:56.749Z" }, + { url = "https://files.pythonhosted.org/packages/33/f3/9006669c04b01206e21b2177425c649461ba188930a052c2f1728d6ec6a8/xxhash-3.8.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:81f4ed9ca9644bc95cd976bfe10f7a4cafab8ffdc3aed52877d4600e445be7ef", size = 221992, upload-time = "2026-07-06T10:43:58.12Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0b/7e6f3eaa05df5e0b6c94aa452b0672801f7031e602081f07fd441aaaaed5/xxhash-3.8.1-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:cb3fe820c27593f170770d6c8d791936cf6275d9269405fbb7b30a55363c10c8", size = 236899, upload-time = "2026-07-06T10:43:59.562Z" }, + { url = "https://files.pythonhosted.org/packages/da/cc/bbaee4987f3aab1d7b33bb430bb49e940646160af448b9167431c931126d/xxhash-3.8.1-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:7345007c12780985de4fd740148776d1eee18c0d41407c6fa1e48c5450304fe5", size = 297934, upload-time = "2026-07-06T10:44:01.132Z" }, + { url = "https://files.pythonhosted.org/packages/a7/97/6bee358660eb8b4f73c00b00b00bc616ebde00e1ab4b67c63486ce360648/xxhash-3.8.1-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:12eaeaa9ab8b9e6033a1fa5f6b338aaf55ff4df4bee11b59fd6ee03b19186ee4", size = 439315, upload-time = "2026-07-06T10:44:02.878Z" }, + { url = "https://files.pythonhosted.org/packages/c6/50/7e35275f39256bedace0c3cd5be3c72d4ac9d5aecf5e5fdc3530337cd263/xxhash-3.8.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e2a845687219ba3214126f14a8a5861f97c9e065a7d0b8252adb6df13eea86fb", size = 214038, upload-time = "2026-07-06T10:44:04.504Z" }, + { url = "https://files.pythonhosted.org/packages/59/2d/69d02d096ee50bdf3ef0d208d874f52c71b1aa6906066bce3c52fedb8bc6/xxhash-3.8.1-cp310-cp310-win32.whl", hash = "sha256:656256c9f9303e47f07d5cb8ae4468285370adfafd7ba48aea33a458e7697626", size = 31939, upload-time = "2026-07-06T10:44:06.213Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1d/e06fca9844919ca91c6587d530cfa1e745830ec73ad38f44f04b25d1bfb7/xxhash-3.8.1-cp310-cp310-win_amd64.whl", hash = "sha256:27cfc2f1ed76f956f36dfe0c56e5f5a3e94cd91eb78b893f63e2ef2ae404fcdf", size = 32729, upload-time = "2026-07-06T10:44:07.621Z" }, + { url = "https://files.pythonhosted.org/packages/8c/c2/800648d99039927b5a86d8ae02cd86a556a5ee1678d388216f6b44c8966c/xxhash-3.8.1-cp310-cp310-win_arm64.whl", hash = "sha256:c85949d02c85adf6d786eb94858e124989a632a4e65739835b2fc5761827fac3", size = 29215, upload-time = "2026-07-06T10:44:08.916Z" }, + { url = "https://files.pythonhosted.org/packages/8a/5a/05eaa129555f85476a3e16ff869e95f81a78bbe4647eef9d0229f515a317/xxhash-3.8.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:602efcad4a42c184e81d43a2b7e6e4f524d619878f2b6ee2ba469011f47c8147", size = 34699, upload-time = "2026-07-06T10:44:10.14Z" }, + { url = "https://files.pythonhosted.org/packages/80/59/0df1133958b2228929355e022aab1e958c7b2c43e27bf7f59bc9edfa8a54/xxhash-3.8.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:131324f719957b988861714de7d6ddf57b47abec3b0cc691302ffeaba0e05e10", size = 32373, upload-time = "2026-07-06T10:44:11.353Z" }, + { url = "https://files.pythonhosted.org/packages/3e/bf/1cfda5b5e6bf26617812b4a31662ef2220d2ad04e0a55b8ff9eb36e56a5c/xxhash-3.8.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:db77278a6eddadbf44ce5aae2fee5ebb4d061f026b1ce2130d058cd4d7a7b670", size = 220284, upload-time = "2026-07-06T10:44:12.683Z" }, + { url = "https://files.pythonhosted.org/packages/70/93/45dc0ad7913b69e5b08bd039236cf628380e4c9cc76a8a4c6625a328e058/xxhash-3.8.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c332dd48b8cb050da2bb2a3c96d72b1664168650a250ef9718e423df7989e05", size = 240980, upload-time = "2026-07-06T10:44:14.297Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/f28ba7d17f2c1410ee397982c817ab1bd5b2701070c2d2c373539aad000a/xxhash-3.8.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5cd96f6dcdf4fa657b2d95668d71d58455248f98712ecffaa9c528edf40ccae", size = 264526, upload-time = "2026-07-06T10:44:16.017Z" }, + { url = "https://files.pythonhosted.org/packages/5c/d0/f10651cec2c7981b20d693deae6bdfc438427d92be2db4ccabb6181f0021/xxhash-3.8.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c959f88160b13b4e730b0d75b459b7929fc0d2225c284c9683ac95d6feeeac6a", size = 241369, upload-time = "2026-07-06T10:44:17.698Z" }, + { url = "https://files.pythonhosted.org/packages/ff/40/136e0cbaf5db51e191423b1c98643593189f02b6cd90837bf64b19113d70/xxhash-3.8.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:027dee4355f3fcc41481650d846cf6cfc895c85a1ab7acd063063821a0df5b4c", size = 473186, upload-time = "2026-07-06T10:44:19.354Z" }, + { url = "https://files.pythonhosted.org/packages/4b/3f/6aa808a96bdc43dba9a740dec56c744526ee3c0019e32c75e810fa90ae4d/xxhash-3.8.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad52a0e4bcc0ba956a953a169d1feec2734a64981d689e4fc8f490f7bf91af60", size = 220092, upload-time = "2026-07-06T10:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/47/28/a8675e78a9ced96dab853416162268e10e05b452e95db7888cf69f58ac5f/xxhash-3.8.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d3dfb1f0ff146da7952867a9414f0c7a29762f8825a84879592612fd6139342", size = 309846, upload-time = "2026-07-06T10:44:22.543Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/7fe4d4ef4e69f0033e012396ee2a115886bca7b10b7e45ce398626436bfc/xxhash-3.8.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4482380b462ca9e59994d072a877ecadd1cf51102daeeab2db696f96ab763723", size = 237659, upload-time = "2026-07-06T10:44:24.135Z" }, + { url = "https://files.pythonhosted.org/packages/38/8f/83e9e31d4ed57fe963b99cb5b13a23e3e0f0dad1885aa0ebd2a7819dd423/xxhash-3.8.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:950ac754d16daea42038f38e7465eb84cda4d08d7343c1c915771b29470f065a", size = 268737, upload-time = "2026-07-06T10:44:25.875Z" }, + { url = "https://files.pythonhosted.org/packages/57/79/7e7de46dbe5d1f49afc96a0bc42e6b8df24eae3d6bad6007b99e42f48430/xxhash-3.8.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:0418ec8b2331b9d4d575fc9284427e8e69449d7172e99e1a86fcdd1f51a0a937", size = 224955, upload-time = "2026-07-06T10:44:27.777Z" }, + { url = "https://files.pythonhosted.org/packages/ec/34/b8540839e958d5ef5c6101af6f16032109e7099698ae8edbc8dcefe4d8f4/xxhash-3.8.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:32a94ad2763e0263d9102037d349002c3d3c401e42770542c3eeb4801f311661", size = 239653, upload-time = "2026-07-06T10:44:29.422Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/a735d05f7f859354acadabe470ff40e2c46672275f96dcf096a761904def/xxhash-3.8.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:89b11a5cdd441aa463f6d34ca0241602bc09b001a76994b6059828494108c673", size = 300213, upload-time = "2026-07-06T10:44:31.401Z" }, + { url = "https://files.pythonhosted.org/packages/98/31/3e1cb020237b68117fc212dc5f9753b87f865b4dfee7c1ce62d0836955b5/xxhash-3.8.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:09a204dd4bb0823daf938cdd0dc8057d5f1e14fe3cbde929424255f23f9de872", size = 442508, upload-time = "2026-07-06T10:44:33.023Z" }, + { url = "https://files.pythonhosted.org/packages/23/bf/f80090622141cc734b039ce1d15ce3ff6dced375e9680249bf5b9b8c6bf9/xxhash-3.8.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e710ad822c493fb80a4fbc1e3d0a807b1422cb90adbe64378f98291b7fa48fef", size = 216853, upload-time = "2026-07-06T10:44:34.983Z" }, + { url = "https://files.pythonhosted.org/packages/a6/a3/60157acecc307b238d3651c2483168e224b48b23a36ae6d6903588341d80/xxhash-3.8.1-cp311-cp311-win32.whl", hash = "sha256:5013be3bea7612852c62a7437f3302c1cfb91ca7e703b194459db0b2b2e0d792", size = 31936, upload-time = "2026-07-06T10:44:36.542Z" }, + { url = "https://files.pythonhosted.org/packages/59/5c/ef70c418d878d187b8da56d4cdc06aea6cf5e456b301e96e51e1d2cc8625/xxhash-3.8.1-cp311-cp311-win_amd64.whl", hash = "sha256:f377012b86c0a23a1df0cf5a1b05aa7187649e472f71c7892e5f2c2815bbe74f", size = 32724, upload-time = "2026-07-06T10:44:38.177Z" }, + { url = "https://files.pythonhosted.org/packages/2c/25/f008db952cec6b2a26445b456eeed2ebebd65e08e848ebe09ed6ac0634e6/xxhash-3.8.1-cp311-cp311-win_arm64.whl", hash = "sha256:836f11d4474d3228e9909d97216faa4f7505df41cfaf3927eb29809de785a78d", size = 29212, upload-time = "2026-07-06T10:44:39.577Z" }, + { url = "https://files.pythonhosted.org/packages/42/91/f65c34a7aa7b4e7cf4854f8e6ef3f7ee32ceac41d4f008da0780db0612f6/xxhash-3.8.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e6e49370822c1f4d8d90e678b06dbcb08b51a026a7c4b55479e7d467f2e813bc", size = 34680, upload-time = "2026-07-06T10:44:40.932Z" }, + { url = "https://files.pythonhosted.org/packages/57/04/b10a245a4c09a9cfa88f8e9ae755029413ad1ac17047f9a61906e5ae0799/xxhash-3.8.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:220d68130f83f7cc86d6edfdeab176adc73d7200bf3a8ec10c629e8cf605c215", size = 32397, upload-time = "2026-07-06T10:44:42.196Z" }, + { url = "https://files.pythonhosted.org/packages/3a/75/45ab795b5945b6388583bd75202106af505537935566c15a1577797a0e08/xxhash-3.8.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4d365ee1892c1fa803536f8c6ce21d24b29c9718ec75eb856095c07830f8c478", size = 220549, upload-time = "2026-07-06T10:44:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/13/44/5ba2bd0a14ddf4193fc7d8ec29625f659f22c06d60b28f04bf46305d8330/xxhash-3.8.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:852bfe059720632e2f16a6a4745e41d20937b2bf2a42a401e2412046bb6971cc", size = 241186, upload-time = "2026-07-06T10:44:45.534Z" }, + { url = "https://files.pythonhosted.org/packages/23/32/c4147def4d1e4538b906f82731e0ba23424377fc50a7cddd03cd284c8f63/xxhash-3.8.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f8c25a7061d952de589bd0ea0eaadee32378ff83dd6a677b267f9cd86f401f8", size = 264852, upload-time = "2026-07-06T10:44:47.199Z" }, + { url = "https://files.pythonhosted.org/packages/6c/bd/71ed14f4f0318bb7fd7b2ec51999413487fa8da8d41208e84d50d1ef0f98/xxhash-3.8.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:868a8dcaff1a84ba78038e1cef14fc88ccf84d9b4d12ea604696e0693296aa56", size = 242663, upload-time = "2026-07-06T10:44:48.846Z" }, + { url = "https://files.pythonhosted.org/packages/91/09/70af22c565a8473b3f2ae73f88e7721af281bc4a575236dbd1970c9f76f6/xxhash-3.8.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6536d8677d2fff7e64cd0b98b976df9de7aee0e69590044c2af5f51b76b7a170", size = 473510, upload-time = "2026-07-06T10:44:50.695Z" }, + { url = "https://files.pythonhosted.org/packages/18/96/34db781c8f0cf99c544ca1f2bc2e5bf55426e1eb4ca6de8ea5da56a9f352/xxhash-3.8.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82c0cedd280eab2e8291270e6c04894dbc096f8159a39dcf1807429f026ca3cc", size = 220469, upload-time = "2026-07-06T10:44:52.422Z" }, + { url = "https://files.pythonhosted.org/packages/93/5f/9a184f615fa5a4dce30c01534f62946ce5a11ce40f73785cbd356ccabaa9/xxhash-3.8.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daa86e4b68221d38e669bb236ba112d0335353829fb627c82e5909e4bbe8694c", size = 310290, upload-time = "2026-07-06T10:44:54.142Z" }, + { url = "https://files.pythonhosted.org/packages/a9/dc/9b9a9789011ee153723a5eb9e7dd7fcbae2ba9b3fe7a729249ca7c252056/xxhash-3.8.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2bc7113e6f2b6b3922dd61796ca9f36af09da3773898e7003038dc992fc83b8d", size = 238173, upload-time = "2026-07-06T10:44:55.693Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4d/71c6005ada9dcb608a4e1902e8475ecadb5f3fbfa04e1e244d276a2d0c43/xxhash-3.8.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:5eed32dad81d6ba8e62dc7b9ffa0500199385d7810a8dd9d4eafaceb8c6e20bb", size = 269026, upload-time = "2026-07-06T10:44:57.424Z" }, + { url = "https://files.pythonhosted.org/packages/2f/87/d6c036ba25dfbd9c8633be5aa86fc9474bbb9e2c68212a841d090abe7344/xxhash-3.8.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:83697b0ea1f10e7f5d8b26a4906fa851393c61546c63839643a2b7fe2d868061", size = 224970, upload-time = "2026-07-06T10:44:59.085Z" }, + { url = "https://files.pythonhosted.org/packages/48/62/4c1f035a41c5752aa05e195b6c904c07b94fe9061a16de61e72a6e6b135f/xxhash-3.8.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:36fc69160465ae75c6ec4ac9f781bb2aa16ae7ff869e73c26fee85fbb11b9887", size = 240820, upload-time = "2026-07-06T10:45:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/da/14/d39d565069b87e86d21a2af2a31d04db79249d25aa8d5b62959056a89857/xxhash-3.8.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:445e0f5a31f2f3546ae0895d4811e159518cdc9d824c11419898d40cfadb677e", size = 300619, upload-time = "2026-07-06T10:45:02.716Z" }, + { url = "https://files.pythonhosted.org/packages/13/22/75467acc887edc8cf71c97ab1708feb3df7a88bda589b9f399765c6387d2/xxhash-3.8.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:dfe0580fbfd5e4af87d0cc52d2044f155d55ebd8c8a93568758a2ea7d8e15975", size = 443267, upload-time = "2026-07-06T10:45:04.653Z" }, + { url = "https://files.pythonhosted.org/packages/a4/b6/1da3baa5fa6ef705e3425fddd382be7dfc4dfba2686df90a20f16e9c7b1b/xxhash-3.8.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:095e1323fa108be1292c54c86da3ef3c7a7dc015b105a52133973bc07a6ad11a", size = 217338, upload-time = "2026-07-06T10:45:06.304Z" }, + { url = "https://files.pythonhosted.org/packages/78/dd/b5295a9f97484e7a1c2b283a742ca45e3104991c55a1ef670dde161829ba/xxhash-3.8.1-cp312-cp312-win32.whl", hash = "sha256:bf28f55e427e0483acb1f666bd0d869b6d5e5a716680c216ad7befe3d4cfba2e", size = 31970, upload-time = "2026-07-06T10:45:07.823Z" }, + { url = "https://files.pythonhosted.org/packages/ec/31/3fa0b807d7e21515cd975e7fe5c039d52ac3e9401a96d6ad68dae6305215/xxhash-3.8.1-cp312-cp312-win_amd64.whl", hash = "sha256:2256e80e4960ee282f63428adb349cb7f8bd8efe4db770d88eb815f4b9860724", size = 32741, upload-time = "2026-07-06T10:45:09.42Z" }, + { url = "https://files.pythonhosted.org/packages/b8/05/86feada74e239600e6875aa507afb40482a89b92700aa74a92da83bdcb77/xxhash-3.8.1-cp312-cp312-win_arm64.whl", hash = "sha256:9df56e6df96a60590935e22373041cccc91fd55858763dcffb55bf63b3a2b396", size = 29234, upload-time = "2026-07-06T10:45:10.809Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/446bb782cd0d27007a917b5569a08dd73219c3e8d6e459014db104b27bdb/xxhash-3.8.1-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:3c682fcd96eb4bf64be32a4d95f96107e1588005831bd8a741b324fdda01b913", size = 38562, upload-time = "2026-07-06T10:45:12.425Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ec/c0c45627eaa6be7a5d6117423adf8f7a15b17ee74b4b17072cca5959a225/xxhash-3.8.1-cp313-cp313-android_21_x86_64.whl", hash = "sha256:036a024d8b9c01f70782e09ed98d532e76fd23f950ae7154bd950fe94e90ebec", size = 36656, upload-time = "2026-07-06T10:45:13.932Z" }, + { url = "https://files.pythonhosted.org/packages/f6/94/8324c04cc7597154caaeba6c094e01fbd2e7601d01e7a13eea9f5420e77b/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d6a5c0bce213b23b0166fe0d35bcbbe23ce4b968f257cc7eb6fd57cb8e1e6297", size = 31169, upload-time = "2026-07-06T10:45:15.687Z" }, + { url = "https://files.pythonhosted.org/packages/40/a4/beb6bb26e1184e126dbe7a5682330214ef54dcfbf882078aa9f4b5428d42/xxhash-3.8.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:5177aa44eddaa97c6ef0cc00c6d540edb64d51781d2f8fb941612ec61a92c9ed", size = 32177, upload-time = "2026-07-06T10:45:17.035Z" }, + { url = "https://files.pythonhosted.org/packages/56/0f/fc4c92a5a528f839b34b6419b2e53c8597f2a629d5a1f5d721f65bfa1fd6/xxhash-3.8.1-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7801b7223db017b9c0c9ccf37e44524edb35a1544a1c032add22c061c6af0276", size = 34642, upload-time = "2026-07-06T10:45:18.39Z" }, + { url = "https://files.pythonhosted.org/packages/d4/58/edbfb141d4000767ac6a9694f8ac0763e2c2e983e65c9e31620ba56e2667/xxhash-3.8.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:9e80238259655bf69d7bcd08226a970d7f42605f3157786bfa76dd13472d7fa0", size = 34684, upload-time = "2026-07-06T10:45:20.033Z" }, + { url = "https://files.pythonhosted.org/packages/07/3f/5072f1f0f5714186f0ac2a0b5a4929ce30d4b845e94886b6c01b6ebda0be/xxhash-3.8.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bcab50a389cc04d87f90092af78a6adba2ab3deca63175a3344ca83514045315", size = 32401, upload-time = "2026-07-06T10:45:21.414Z" }, + { url = "https://files.pythonhosted.org/packages/49/c7/802ea2f9c2ed59219934d6d65c470d502b1788043eae277a52af8658bda6/xxhash-3.8.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2489d3a776fa380cb8e71f54c7fda268a9baf3de9b1395093fd280f95735907", size = 220617, upload-time = "2026-07-06T10:45:23.234Z" }, + { url = "https://files.pythonhosted.org/packages/99/a8/e10488efd31fcb13fcd6acbc6e788f10c6f8e3a0cc4ae3eb89dc19c55a12/xxhash-3.8.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32ab1e5432690276e71192be7401b55f96db2d0eedea5d44eb1f164505669cc0", size = 241295, upload-time = "2026-07-06T10:45:25.364Z" }, + { url = "https://files.pythonhosted.org/packages/18/cc/14180b17d44892a631f8ae7323c30bfbb1328efc8209e528a480293528ac/xxhash-3.8.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b30e01a0b97a4bc3f519a4d7a82da3dc53251fb0de5eeea8660dcd4ff094c0c2", size = 264688, upload-time = "2026-07-06T10:45:27.09Z" }, + { url = "https://files.pythonhosted.org/packages/a9/72/a14019d0c5f6c41ee407a503036ae32787c91325ca218a96a9b5627be651/xxhash-3.8.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1f44275ddb0978b67a58a951501903f04d49335a91f7681c9ce122ecb8ccb329", size = 242740, upload-time = "2026-07-06T10:45:28.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/08/92550e556c6fcfcb96c6a336945eb53a431ed43120ed749636debb16c5cf/xxhash-3.8.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3b87cbd974512c0c5fc7b469c36b2cdc9ee6d76e4ec78bccb2c7184611c49b0", size = 473599, upload-time = "2026-07-06T10:45:30.524Z" }, + { url = "https://files.pythonhosted.org/packages/29/83/e361d3c1acd1b21e1d489616de6fa4aaf843365d8179f612e3743eac20a9/xxhash-3.8.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98ee81b4b7f3023c9cb04a78cc67610baffcb5812d92f2096cb5a5efc6f19437", size = 220559, upload-time = "2026-07-06T10:45:32.979Z" }, + { url = "https://files.pythonhosted.org/packages/05/01/006a4243c2c2a6831827f9999f6d1c23feeef100eb023c1f886022a00bf3/xxhash-3.8.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2666f059a1588a99267e33605365ed89cea92f424b3522806a9f4bd8ad2e3d62", size = 310383, upload-time = "2026-07-06T10:45:35.875Z" }, + { url = "https://files.pythonhosted.org/packages/d8/20/af388e8bf9f9a0f89eeef7d2a1935d176ee1c20bc6adeda05035879379cf/xxhash-3.8.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:b0093cf7eeb91b84776e8742113afa4bdf47533d36cf719179aaaf1f56f6f8bf", size = 238228, upload-time = "2026-07-06T10:45:38.02Z" }, + { url = "https://files.pythonhosted.org/packages/63/6b/4666579a87eebd1744663c404297355fa0658617b015cedfa58810ee7036/xxhash-3.8.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:3a800912a2e5e975d4128969d645c4a2a80aa886ccd6c9b1c6f44529e327e8cf", size = 269137, upload-time = "2026-07-06T10:45:39.954Z" }, + { url = "https://files.pythonhosted.org/packages/de/d3/e963a8a46f900a137d91b02144d8ea07a8f812971b138204a3b2f8b8e55c/xxhash-3.8.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0fe37f72a207223d22a4eddc3149d4298993385aa9daef25c039246ca5a309f3", size = 225068, upload-time = "2026-07-06T10:45:41.718Z" }, + { url = "https://files.pythonhosted.org/packages/aa/80/9d181dbcde4b0fe48375f48833a5832d4b8cd2b349b15110c92ee472d874/xxhash-3.8.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5db43f249b4be9f99ef4b967863f37094fb40e67effafb78ba4f0356b6396104", size = 240874, upload-time = "2026-07-06T10:45:43.414Z" }, + { url = "https://files.pythonhosted.org/packages/39/15/ce3ab5a1cd27ead25a5196e55a7284220f6ad6e316da494ffd900b2b600f/xxhash-3.8.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c4ed42965c2cd9081f011be22f69d0e65d3b6165fe7734072fd0c232840bbd4e", size = 300702, upload-time = "2026-07-06T10:45:45.135Z" }, + { url = "https://files.pythonhosted.org/packages/96/c0/2281a8ab5f2a62dbf57a23c58a01ccc1d98abf40f71193c8a81f59e759b5/xxhash-3.8.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3557bec8fcb11738a8920eeb68974bc76b75262f6947998d3147954ce0a4b893", size = 443351, upload-time = "2026-07-06T10:45:47.188Z" }, + { url = "https://files.pythonhosted.org/packages/81/2e/071a58c1a53a52d4f7a3aa0987be0c396dffd40da8204805fe1b130a81f4/xxhash-3.8.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:00de40f3b42240db23a82a5c682b55d7263d84a26a953240c1aee463409660e3", size = 217396, upload-time = "2026-07-06T10:45:48.925Z" }, + { url = "https://files.pythonhosted.org/packages/68/44/36ab58134badd9d3433fc7b53c4ca8d113d8e807782885628640f8297a4d/xxhash-3.8.1-cp313-cp313-win32.whl", hash = "sha256:b5196cc2574cfec572a5f3fb7cfa5ade27305ae3d06516a082132441aff4c83a", size = 31974, upload-time = "2026-07-06T10:45:50.591Z" }, + { url = "https://files.pythonhosted.org/packages/96/2a/2a0b84798448e766f7b89ceed073cb0cb5a43fc9ebbacbdea74a38de18e3/xxhash-3.8.1-cp313-cp313-win_amd64.whl", hash = "sha256:538f5f865df6cd8c32dd63158a0e5b4f5dd08d732a7da8b7228a5a0776c8ce55", size = 32739, upload-time = "2026-07-06T10:45:52.221Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/bb51dbf7c363ff88a7cbd50b7959718219577ef44d7cf255929ffc4a2194/xxhash-3.8.1-cp313-cp313-win_arm64.whl", hash = "sha256:a6617f30641ba0d8baa1635fbefb1dffc5165ec36d26921bd5cee13497cd937a", size = 29239, upload-time = "2026-07-06T10:45:53.714Z" }, + { url = "https://files.pythonhosted.org/packages/56/d3/827ca123c2ee5443a6aaed3c5dd199237dc2f010e2bebd7ec09ef36f3a5f/xxhash-3.8.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:bfcd82852c62a60e314670a9602de354c4460f8adad916e2e42a20860c7870bc", size = 34964, upload-time = "2026-07-06T10:45:55.535Z" }, + { url = "https://files.pythonhosted.org/packages/05/67/67ae2a3ccdeb8b8ef025d35aee9edd1d26c3abe5051d47da9286232afbf8/xxhash-3.8.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:08ea2081f5e88615fec8622a9f87fbe21b8ea58d88cfc02163ca11026ee62a92", size = 32697, upload-time = "2026-07-06T10:45:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/38/5a/3d3994346e1f45493679cb5c1ffc2bf454e410e9d1e8a662d253becee91e/xxhash-3.8.1-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2e32855b6f9e5b18f449e59d45e3d5778bdeb660632ef2693cca267a11246c75", size = 225954, upload-time = "2026-07-06T10:45:58.897Z" }, + { url = "https://files.pythonhosted.org/packages/3f/2c/53169270309b7cd8e05504e07fe123bac053b89d00ac63617faacf0a2ec0/xxhash-3.8.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6e088bd7870775624256a0d84c2a6714afd223b2eeb56b0ca58398e52a32fda", size = 249776, upload-time = "2026-07-06T10:46:00.977Z" }, + { url = "https://files.pythonhosted.org/packages/70/e0/5c551d8d592f944506f7c5185e210255c15e672a3c6008c156a1bd9b775e/xxhash-3.8.1-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:72eb5ae575cc7ae2b23f6f8064a8b10f638c7149819ae9cc6d20ebd4d37a1629", size = 274776, upload-time = "2026-07-06T10:46:02.869Z" }, + { url = "https://files.pythonhosted.org/packages/a0/2a/d3a762270cee2d7bcd0e25e28c623e5f3f5c0dc637b66e3e47dd5b0bb3f0/xxhash-3.8.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d0b48cdf690a64cedf7258c3dc9506cc41fc86edd7739c40e3098952265dc068", size = 252056, upload-time = "2026-07-06T10:46:04.688Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/b78e4373b2cb6d1c42af60ea2d7e9146ad0710b239ac7f706d5d31d5bb98/xxhash-3.8.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb9e256a357dfcede7818c6d34e70db2d6b664394803d1de4b6984d2de76c0f1", size = 482108, upload-time = "2026-07-06T10:46:06.498Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/642d923336ea61a15f8ce64fc7e078729e6e06c3a026e517fa79b2c23b7a/xxhash-3.8.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51f71a6e2ad071e70c937e41fcb6c19f82c3f9f49831eba850ed4a106ffbb647", size = 226739, upload-time = "2026-07-06T10:46:08.598Z" }, + { url = "https://files.pythonhosted.org/packages/a6/0a/a37d6da6427d45a8d23e3ee3a0ca9c9d4a90364849c6637fe2963a755f9b/xxhash-3.8.1-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e4a6443968c4e8dc69967e12776776a5952c119cc1bd94168ad1c5ad667c2be1", size = 319658, upload-time = "2026-07-06T10:46:10.504Z" }, + { url = "https://files.pythonhosted.org/packages/4a/51/ebbd40da8a3f1bc53b4b7a9a87f8e28bd95c5f21bc14b8a57860cf367d1b/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:714503083a1f2065c9ad15340dd49ac8a8e948a505a705ffa1750cb951519113", size = 246059, upload-time = "2026-07-06T10:46:12.634Z" }, + { url = "https://files.pythonhosted.org/packages/24/4c/d9014030147e1f0bb26e7da47aa240dd9ec61c763c573e558111d869f8e1/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:77f74e45a1e5574bbbf80181c8027b3a4c65c2248fffbd557bd596fff13102f9", size = 275535, upload-time = "2026-07-06T10:46:14.614Z" }, + { url = "https://files.pythonhosted.org/packages/84/86/caee2db41fadcd5a25aa4323213f9afec5a8586d4e419241e3d659362bd7/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:4e0e1b0fb0259c1b75d1251ac0bb4d7ab675d36f7a6bf4ba6aa630dae94f9ffa", size = 231292, upload-time = "2026-07-06T10:46:16.452Z" }, + { url = "https://files.pythonhosted.org/packages/0b/60/f52f08bcdc904c4514ea5c25caa19e9f3214144434a6ff96dc82dc1cbddd/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:10e4393ec33633c2f05ad01869e546ad080b1a18f2650503731f153774608b31", size = 250490, upload-time = "2026-07-06T10:46:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/24/a0/94dc7ae310838f250669c6ad7168e6d6fca17d49dac1053f06dc232c4a56/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b3ba794c3d885803db6c3116686923f1ec13bc86e621e169a375282b63ea1cc6", size = 309861, upload-time = "2026-07-06T10:46:20.503Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f9/adeead7d0eb28cdfc2832544ea639ffbc6749ccde47a8e228d667459182e/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:57189a69c0891e4818853feaa521c972d22c880a001453addea015f48e3c3398", size = 448739, upload-time = "2026-07-06T10:46:22.79Z" }, + { url = "https://files.pythonhosted.org/packages/04/a4/22ec0e07db57d901c9298ae98aa3cf2be45bafded6f07c13131e85b89032/xxhash-3.8.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:d59e71153fe9ff85648d00e18649b07e9b22c797291abb7e27274fa06df8b838", size = 223657, upload-time = "2026-07-06T10:46:24.831Z" }, + { url = "https://files.pythonhosted.org/packages/94/32/8a9531f37b59e5a013003db7cb7414baf4ce7e0e1268e0d5947cd3d6a2df/xxhash-3.8.1-cp313-cp313t-win32.whl", hash = "sha256:5b96f0024e9840f449bd91b2d005c921a4b666055a0d1b6492463799f32aae22", size = 32377, upload-time = "2026-07-06T10:46:26.86Z" }, + { url = "https://files.pythonhosted.org/packages/e7/ab/2ca45fd7f671de5f81fc297ef1c95080b40c86ec6be0cc6034b8f7707ac8/xxhash-3.8.1-cp313-cp313t-win_amd64.whl", hash = "sha256:37d5a56c36dcc0b9a87b814cd992598d33863ff683749de6c86081f278d5e629", size = 33274, upload-time = "2026-07-06T10:46:28.39Z" }, + { url = "https://files.pythonhosted.org/packages/5a/54/20d7163463ddb6438b73a427d1655a77a502cf9b9b0c3ada3599629d9c0a/xxhash-3.8.1-cp313-cp313t-win_arm64.whl", hash = "sha256:6696c8752aded28ff3b16f33ef28ce28fb5d209b80c206746f943199fcf5fd65", size = 29375, upload-time = "2026-07-06T10:46:29.962Z" }, + { url = "https://files.pythonhosted.org/packages/c2/8b/df2ba04f22a6cd6b39f96a6577329a8471a55c90ef8d8e2f7c102363613f/xxhash-3.8.1-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:9db455cb649dcfe4504d6d68a6d83a7315a99a3ca59871dc3ff840671f99adba", size = 38430, upload-time = "2026-07-06T10:46:31.496Z" }, + { url = "https://files.pythonhosted.org/packages/b2/4f/6a059e8ad3ca8deedc91dfe335b211204900895152212c03ebbe721de68b/xxhash-3.8.1-cp314-cp314-android_24_x86_64.whl", hash = "sha256:affb37f152e55b5e4494bb9d0107f7bb08515c6704fbed82d9f61214d74adc17", size = 36558, upload-time = "2026-07-06T10:46:33.078Z" }, + { url = "https://files.pythonhosted.org/packages/cb/95/40be178205acce092ae418feb20ac737b32a02c7b864926ed0717354c9f8/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:460261045936975193bfd20549a0de1cd52a33b405cbb972f0d80940c42266cd", size = 31181, upload-time = "2026-07-06T10:46:34.793Z" }, + { url = "https://files.pythonhosted.org/packages/3f/89/2da4dbf051bafa156c0e3f12012db2b0ac3b84ff37ca1f021f6bfffcdfbb/xxhash-3.8.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:38c887aedb696ef8bca19983206d270848558cfae4a91afa6a2fb05dde58ffc5", size = 32192, upload-time = "2026-07-06T10:46:36.393Z" }, + { url = "https://files.pythonhosted.org/packages/7c/4e/e000bbae3566bc8e0be771a8a0f294aa99075e3f0bc4ef43922ebffdebc8/xxhash-3.8.1-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:594131ce1aad18db3689781f806db1b065cdaa04f4df36b4c038d2013aefd0bf", size = 34691, upload-time = "2026-07-06T10:46:38.1Z" }, + { url = "https://files.pythonhosted.org/packages/b4/4a/ea954aacc7d1c8711880ac2b55da94429a9b4296b151c4fc0966549ca1ee/xxhash-3.8.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:78c794b643d214f1522e7a288bcf5a2de120d26cd170516749a4009dc92722c9", size = 34807, upload-time = "2026-07-06T10:46:39.647Z" }, + { url = "https://files.pythonhosted.org/packages/ca/29/df598e738ff37558ac627264deb2e560902d9bf7f46d3bd5175c9eee593e/xxhash-3.8.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:af0c9fedc4a2c24e8664953882fe8185f3790b8338c9c700f76f5ad660817711", size = 32410, upload-time = "2026-07-06T10:46:41.359Z" }, + { url = "https://files.pythonhosted.org/packages/59/9c/81ab40e7d33ada0b3df5d1bc884894d15dbf4f805cd645b685e4606bb8e0/xxhash-3.8.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:115772daeb71b2f3b9381177017f53e6cf3f3439c840737fdabd21aba6e54920", size = 220564, upload-time = "2026-07-06T10:46:43.463Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6f/62ae6f5c8606320a0e2a41c2dc8c6d91cc5d63d0f84dd9582e9543779dd8/xxhash-3.8.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:000435984a0469b0f822fe76f35bddea0f96a4d6521b3339a60a6428cdee1edc", size = 241462, upload-time = "2026-07-06T10:46:45.509Z" }, + { url = "https://files.pythonhosted.org/packages/15/a1/9c3a0ec6cb524396f551eddd102a76690a795494eb9784fc67542b0daa37/xxhash-3.8.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2f1c68394818e0595569c2ff3cbc1e6d5a36a434e796f5c526b987b80c8a8c62", size = 264491, upload-time = "2026-07-06T10:46:47.655Z" }, + { url = "https://files.pythonhosted.org/packages/64/f2/700a4674e4308eb59d2fdb973977e82eae231bea5044753fee5c9eec0e0c/xxhash-3.8.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46b39976d008e2a845758650f0ff7136bca004f40da0c8798bd37ac37860154f", size = 242905, upload-time = "2026-07-06T10:46:49.857Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8a/72d9874375c8d4cbc64a8cd1d659d5695a8765c3db82efa82dc5bd9f14d0/xxhash-3.8.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d5006c65ec507a333479e76e00e2c368781f16c24ededa764763956b32a0e93e", size = 473873, upload-time = "2026-07-06T10:46:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/03/f0/6db07590ed7e0a77f186ef0bcea8d52553bf1ba57833e09467a2411f0f2d/xxhash-3.8.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31a2649bcf1fe97cf11c79848d761df33ac46b3896942d31b640557b486ff6b", size = 220765, upload-time = "2026-07-06T10:46:55.41Z" }, + { url = "https://files.pythonhosted.org/packages/8f/10/00d12d8b8beabbf49a8bbc626fb9f40445145a8887eb41a6acfb69149ac4/xxhash-3.8.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f759eed402448c2bdbb492e4fba1f20668ffe29688605ea61f0f67f9e4e386d", size = 310478, upload-time = "2026-07-06T10:46:57.729Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f9/12a82394eefb0f185d15a7f7b9f627c61c475a72dd83718436a5b84b42ac/xxhash-3.8.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5f97ecfede10d5b2870383620e2d25c8561e217c7bf9081073802b54248d2b", size = 238393, upload-time = "2026-07-06T10:46:59.87Z" }, + { url = "https://files.pythonhosted.org/packages/20/f3/53f963e320b9ce678337aa7273f39ce692ded8b99e3d22a866ec722159ab/xxhash-3.8.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1da930bbcac3e8fbe2191850e2abb57977a99348c12c4b385e1058ac1b0a9ecc", size = 268704, upload-time = "2026-07-06T10:47:01.806Z" }, + { url = "https://files.pythonhosted.org/packages/0a/50/5b5badbd87c82d9f9b5f58ac74a3f29ef08f6fc387b324b8fd482450b862/xxhash-3.8.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:747476436f6891b9773374ce8d48edcc8b12cb5b61b67c6fb6289633747d088f", size = 225015, upload-time = "2026-07-06T10:47:03.784Z" }, + { url = "https://files.pythonhosted.org/packages/30/93/3ca68265afe7b4e69435e08a7b6a1d9d0f2a071e889da1f8041ed00fe878/xxhash-3.8.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4ef09bbc2519a93cd0f95f2ceb5f7b85919dffea643278e02362bf40e3c4bed1", size = 240951, upload-time = "2026-07-06T10:47:05.816Z" }, + { url = "https://files.pythonhosted.org/packages/dd/a6/27e19670c40f46b5e76e11f2f4713d21054804568425d870670e757172ad/xxhash-3.8.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:a5eed9d41995a83f3332b4e3396abb7f433cac584222bd7e305b606d8353861e", size = 300751, upload-time = "2026-07-06T10:47:07.95Z" }, + { url = "https://files.pythonhosted.org/packages/bc/fb/b33e27689959fe7ed2ae0b830af41560d65213943983afa9db3a8d481bce/xxhash-3.8.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:53f3ed9118397074ff63a79b66b7fec1c84c782eecde35c5bc94e420a971c231", size = 443480, upload-time = "2026-07-06T10:47:10Z" }, + { url = "https://files.pythonhosted.org/packages/26/60/0e0d973be5fe280753ef02fbc89349492ad6e903bf1dcb870b668f94b662/xxhash-3.8.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d247b34bf433c92b41689318fd25d246313cab2275a6a47e2efac178b80d6efe", size = 217657, upload-time = "2026-07-06T10:47:12.196Z" }, + { url = "https://files.pythonhosted.org/packages/ad/68/c9e3ecef4a9a417d464cb5bd200aa12f73192dee677901b9e08e0ad0d1bb/xxhash-3.8.1-cp314-cp314-win32.whl", hash = "sha256:d58ce8b6cfa9c4d2f230557f69caf7c06369e318015d0b19485095bc2c5963ab", size = 32690, upload-time = "2026-07-06T10:47:14.204Z" }, + { url = "https://files.pythonhosted.org/packages/d7/99/e9e44588c0b62837bbec5ba7927816de0afa03406b1a0b6c7a7e1d1a30a0/xxhash-3.8.1-cp314-cp314-win_amd64.whl", hash = "sha256:6cee733fe4ccb1737e0997135283c82341e5cfa9cf214b165f9087fb663aaf4f", size = 33460, upload-time = "2026-07-06T10:47:16.021Z" }, + { url = "https://files.pythonhosted.org/packages/45/2b/64f36d86380b3657ad9031967ab814f3ef31307174650853f69c18932ebc/xxhash-3.8.1-cp314-cp314-win_arm64.whl", hash = "sha256:58346024d47e84f7d8b3e7f5d6faa1d58acbbe49a8771497872059f58c1d8ea5", size = 30092, upload-time = "2026-07-06T10:47:17.81Z" }, + { url = "https://files.pythonhosted.org/packages/92/cb/18b64bff88c58a0ca209dc533e63cf02d7ae5aa6b1b9a9fd14e81b5dbd60/xxhash-3.8.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:01cab782f8a0a05ecad2c63d7ef10f7ab475f660e0d6419d069418c14d88de7c", size = 35024, upload-time = "2026-07-06T10:47:19.821Z" }, + { url = "https://files.pythonhosted.org/packages/af/1d/72d8a70520e5dcddb472ea0486d299da3240745a10658290cd7b5690ede2/xxhash-3.8.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:717b12fdc51819833704e85e6926d76981ffa3f780ef92e33ebb8b26d46bb230", size = 32697, upload-time = "2026-07-06T10:47:21.649Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/e041f555903c56db3d0a731b3d72a6575d75e0ed868b1bd2e5176111ca44/xxhash-3.8.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ec55d80e9b8a519d742669e0b49e8ce9e6747be42bf3c138158b6543a9c8e489", size = 226044, upload-time = "2026-07-06T10:47:23.612Z" }, + { url = "https://files.pythonhosted.org/packages/3a/7e/5cdcf06bf6ec4b5d2ac073feb23432ec1d603fd438864cbd2c09c7cb45e1/xxhash-3.8.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98d8ac1129b4dd39098cffed94d1284aceb61c3aa396757ccc736ac392e4cee5", size = 249899, upload-time = "2026-07-06T10:47:25.812Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c0/eb7e059cb5e1dba11fd30d2fdf882f56e5a417a3eaa43669d43623767f45/xxhash-3.8.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3bc0fa90830df1e1277f33cc6e55de9990b83c0319fd8c7412866cfde38b025e", size = 274892, upload-time = "2026-07-06T10:47:27.931Z" }, + { url = "https://files.pythonhosted.org/packages/66/74/a600aaf7cd39957fd1510adeedb1749c1e7eb82bd632a1153d9c664c3135/xxhash-3.8.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c73b6f652f0745425aa6378319c331293b5341756262e9408ed3d45f183375e6", size = 252243, upload-time = "2026-07-06T10:47:30.288Z" }, + { url = "https://files.pythonhosted.org/packages/ad/04/78d88fa75a6763e5d09bf1b947a392a27988903381b219006f92f3c68fc8/xxhash-3.8.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f6114692261eff4266386cdec0f7d87eee24e317ab397c218b7ae6a76b4c6339", size = 482191, upload-time = "2026-07-06T10:47:32.45Z" }, + { url = "https://files.pythonhosted.org/packages/7f/06/07a8aea1108d682de8791ce608cdf367d75ff4e7e57cd3c154bdc6f47b23/xxhash-3.8.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df57c0b161ec1b3ed0526a67b0db0914b557e86ee8aae51887aec941b261542", size = 226877, upload-time = "2026-07-06T10:47:34.705Z" }, + { url = "https://files.pythonhosted.org/packages/ed/b5/86bade5618a524d2c06c4041aa2fe8e5749ce16e88afba60d67c1684a21f/xxhash-3.8.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9043877a917be88ccf230aa5667c1bd059bce80f4c2727e4defa1b29b7f48b08", size = 319794, upload-time = "2026-07-06T10:47:37.08Z" }, + { url = "https://files.pythonhosted.org/packages/23/69/9b1a2b89b1621bb740fbcb7beb512f60f99480c1bdc680c0c90e1f56ff75/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:559e3cabe522231909f9de98ef06929edbd53782046bd21aae0c72db6f2a0775", size = 246202, upload-time = "2026-07-06T10:47:39.676Z" }, + { url = "https://files.pythonhosted.org/packages/08/ea/662ed6cb49f1d34078b6a3a3e0f3d29ff93fd7b5a03c0bc9ecfd9b2159c3/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:264710bd335016f303763ce1275c6486df30bb57c2245c91b224c983d7ac39b8", size = 275628, upload-time = "2026-07-06T10:47:41.99Z" }, + { url = "https://files.pythonhosted.org/packages/13/f5/49fc9e4c6728a5a3bd8fe639199d2fa67609b3a84f938aff6e8568dd3e4f/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e14800b9b10bb39d7a60ad4a310e403164d7b8988a27ae933d4e40618a44088e", size = 231390, upload-time = "2026-07-06T10:47:44.233Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/3acaf8f599c0e0b30e910a3a11ba32929da53c86dc73c7c55fe6a010b4e9/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:ea6a3e734b0fd41b82784a400be946821900daebe610c050a5e0760838a34f99", size = 250600, upload-time = "2026-07-06T10:47:47.611Z" }, + { url = "https://files.pythonhosted.org/packages/23/64/8acab4c5ec60dbe664b5b9858fd44c2413b07e535b09556a0a5022e78aa6/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:cf399fac542a1c7a4734a435b93df2c55e858c7d31abf6c1bdf46f9ae67fbfd0", size = 310032, upload-time = "2026-07-06T10:47:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/56/47/a0288d7329b1fe63e2734a32d19d444a96ae2b4810f545bc61e561224917/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:44c89d915a75c11d2547eaee9098fcd80398987c4bff2974a0497a925bf92c07", size = 448882, upload-time = "2026-07-06T10:47:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/01/e7/3071dfd3beb5c38204ce1cf56bf7749fce08de900fa92714b81d1d8ca1f2/xxhash-3.8.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:358650d5bda9c635da699c53adf4e8134af492ecc79c960f917eebf088bb6799", size = 223728, upload-time = "2026-07-06T10:47:55.093Z" }, + { url = "https://files.pythonhosted.org/packages/12/11/b99949f0ba2b07e9f9ffe83b9c86faa685f9080725dc21a916a607313be5/xxhash-3.8.1-cp314-cp314t-win32.whl", hash = "sha256:c240939e963653054fc7e4a17c382829cda4aa88a7daf0af841715dbded1b497", size = 33150, upload-time = "2026-07-06T10:47:57.274Z" }, + { url = "https://files.pythonhosted.org/packages/54/1c/09703eb341f8416e74e58d6c6732d4b5c46de59c942363203cb237cc95b0/xxhash-3.8.1-cp314-cp314t-win_amd64.whl", hash = "sha256:7258ee276e8772599bc19e14b36f6260306e21b637190cd7cb489a2449d48684", size = 34005, upload-time = "2026-07-06T10:47:59.434Z" }, + { url = "https://files.pythonhosted.org/packages/d6/f9/6ed7251bb6a8af10ac73b1821c60583d2826e5b2064e45a979c935287c98/xxhash-3.8.1-cp314-cp314t-win_arm64.whl", hash = "sha256:8f454166c2ffed45636c8d501741e649851ba2f346c4eb73a64c07ac00428f20", size = 30239, upload-time = "2026-07-06T10:48:01.874Z" }, + { url = "https://files.pythonhosted.org/packages/99/e4/4d8040435aeac814fc69ba63621565fbeb19229a138e2568324a26b2a45c/xxhash-3.8.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:39c9d5b61508b0bb68f29e54546de0ed2a74943c6a18585535a7e37356f1dd12", size = 32687, upload-time = "2026-07-06T10:49:42.803Z" }, + { url = "https://files.pythonhosted.org/packages/da/6a/975f1f2318c760e5bcec109ed379713ae645d8d856c2a3b9ec5d26857087/xxhash-3.8.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:83b9130b80b216d56fdf9e87131946b353c9627930c061955a101ea82b09fed9", size = 29879, upload-time = "2026-07-06T10:49:45.172Z" }, + { url = "https://files.pythonhosted.org/packages/08/0b/40a2a55ff52cf635bfdc5eae67a772bec85b4f44c6c737f73f6f528d51d1/xxhash-3.8.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8304be0982130954b7fd3aad18e2c6f8ee40254bc3d2e635991c16d77c91e2bd", size = 43246, upload-time = "2026-07-06T10:49:47.905Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6d/56ed2b6b200f26fb474f3fd387d95d0601efcd5bb33430c90c68924bdd77/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b512261801b1e5fde7b6ebf2fef7977339c620cbbca88a0040ad9ad134f4d02", size = 38202, upload-time = "2026-07-06T10:49:50.59Z" }, + { url = "https://files.pythonhosted.org/packages/0d/a3/56864d895d1161a9f17502088e9c1fb7c06bde2c2efdde620d22bb7a9c43/xxhash-3.8.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49aa8692507835dcc1e8ad8021f20c74c2dc13d83b5112e87877faa2a0035b20", size = 34448, upload-time = "2026-07-06T10:49:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/6b/57/5c6e0908a47f61dca96d01c8ee6fce01ed1050611eb779083ba8758fed81/xxhash-3.8.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:345b07b78e2bf583d71682aa34ae5b5fab575f7a1cb31e10263ebbc6f89f8c42", size = 32869, upload-time = "2026-07-06T10:49:55.972Z" }, ] [[package]] name = "yarl" -version = "1.20.1" +version = "1.24.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "idna" }, { 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, 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" }, +sdist = { url = "https://files.pythonhosted.org/packages/79/12/1e8f37460ea0f7eb59c221fdaf0ed75e7ac43e97f8093b9c6f411df50a78/yarl-1.24.2.tar.gz", hash = "sha256:9ac374123c6fd7abf64d1fec93962b0bd4ee2c19751755a762a72dd96c0378f8", size = 210798, upload-time = "2026-05-19T21:31:05.599Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/df/f1c7a3de0831cd83194f1a85c5bb431b13f81e6b45079314c86d1c4ef3f2/yarl-1.24.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:5249a113065c2b7a958bc699759e359cd61cfc81e3069662208f48f191b7ed12", size = 129057, upload-time = "2026-05-19T21:27:47.564Z" }, + { url = "https://files.pythonhosted.org/packages/48/41/7daafb32dd7562bf45b1ce56562e7e1a9146f6479b6456873eb8a3413c40/yarl-1.24.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7f4425fa244fbf530b006d0c5f79ce920114cfff5b4f5f6056e669f8e160fdc0", size = 91545, upload-time = "2026-05-19T21:27:50.089Z" }, + { url = "https://files.pythonhosted.org/packages/a8/8f/7b3ec212f1ea0683f55f978e3246bc313c38818664edfc97a9f349a4901e/yarl-1.24.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15c0b5e49d3c44e2a0b93e6a49476c5edad0a7686b92c395765a7ea775572a75", size = 91380, upload-time = "2026-05-19T21:27:51.953Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1b/8bafab7db23b0567ae9db749099b329d91e3b82bc6028b2050ba583e116c/yarl-1.24.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:246d32a53a947c8f0189f5d699cbd4c7036de45d9359e13ba238d1239678c727", size = 105957, upload-time = "2026-05-19T21:27:53.98Z" }, + { url = "https://files.pythonhosted.org/packages/7f/77/21030c2f8d21d21559719beafc772ada2014be933418ed1eaed9cc800e42/yarl-1.24.2-cp310-cp310-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:64480fb3e4d4ed9ed71c48a91a477384fc342a50ca30071d2f8a88d51d9c9413", size = 97242, upload-time = "2026-05-19T21:27:55.981Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/f9ea63d1b6aa910a866e089d871fff6cbd49caab29b86b35221a62dfa0d5/yarl-1.24.2-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:349de4701dc3760b6e876628423a8f147ef4f5599d10aba1e10702075d424ed9", size = 114719, upload-time = "2026-05-19T21:27:58.037Z" }, + { url = "https://files.pythonhosted.org/packages/e9/a3/04e0ee98ac58a249ea7ed75223f5f901ba81a834f0b4921b58e5cec11757/yarl-1.24.2-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d162677af8d5d3d6ebab8394b021f4d041ac107a4b705873148a77a49dc9e1b2", size = 112140, upload-time = "2026-05-19T21:27:59.618Z" }, + { url = "https://files.pythonhosted.org/packages/02/ad/0b9cc9f38a7324a7eb1d80f834eaa5283d17e9271bbda3186e598dddaeac/yarl-1.24.2-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f5f5c6ec23a9043f2d139cc072f53dd23168d202a334b9b2fda8de4c3e890d90", size = 106721, upload-time = "2026-05-19T21:28:02.586Z" }, + { url = "https://files.pythonhosted.org/packages/65/e7/a52478ebfc66ec989e085c6ae038b9f1bfa4190baa193b133b669c709e2f/yarl-1.24.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:60de6742447fbbf697f16f070b8a443f1b5fe6ca3826fbef9fe70ecd5328e643", size = 106478, upload-time = "2026-05-19T21:28:04.523Z" }, + { url = "https://files.pythonhosted.org/packages/04/d8/5508530fea8472542de00013ae280765fc938ee196fc4030c43a498afb36/yarl-1.24.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:acf93187c3710e422368eb768aee98db551ec7c85adc250207a95c16548ab7ac", size = 105423, upload-time = "2026-05-19T21:28:06.515Z" }, + { url = "https://files.pythonhosted.org/packages/84/f1/ece28505e9628e8b756e11bb4f28864a17cc33b6b44db4d2aaf0622bf630/yarl-1.24.2-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:f4b0352fd41fd34b6651934606268816afd6914d09626f9bcbbf018edb0afb3f", size = 99878, upload-time = "2026-05-19T21:28:08.637Z" }, + { url = "https://files.pythonhosted.org/packages/3f/52/fb5d34529b46dd84013afcfb30b8d2bc2832ed03d412736f577d604fa393/yarl-1.24.2-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:6b208bb939099b4b297438da4e9b25357f0b1c791888669b963e45b203ea9f36", size = 114025, upload-time = "2026-05-19T21:28:10.64Z" }, + { url = "https://files.pythonhosted.org/packages/43/f0/ff9d31aaab024f7a251c0ed308a98ae29bf9f7dc344e78f28b1322431ca2/yarl-1.24.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4b85b8825e631295ff4bc8943f7471d54c533a9360bbe15ebb38e018b555bb8a", size = 105613, upload-time = "2026-05-19T21:28:12.784Z" }, + { url = "https://files.pythonhosted.org/packages/31/7d/3296fb3f3ecd52bf9ae6c16b0895c1cda7e9170a2083861552b683f70264/yarl-1.24.2-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:e26acf20c26cb4fefc631fdb75aca2a6b8fa8b7b5d7f204fb6a8f1e63c706f53", size = 111665, upload-time = "2026-05-19T21:28:14.393Z" }, + { url = "https://files.pythonhosted.org/packages/1a/74/77aa6ddaca4fbf42e45e675a465c43956dd40702281049975a2aa04eae59/yarl-1.24.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:819ca24f8eafcfb683c1bd5f44f2f488cea1274eb8944731ffd2e1f10f619342", size = 106914, upload-time = "2026-05-19T21:28:15.893Z" }, + { url = "https://files.pythonhosted.org/packages/d8/02/7611f22cd1d4ed7373eb7f9ee21fde1046edba2e7c0e514880d760352f48/yarl-1.24.2-cp310-cp310-win_amd64.whl", hash = "sha256:5cb0f995a901c36be096ccbf4c673591c2faabbe96279598ffaec8c030f85bf4", size = 92658, upload-time = "2026-05-19T21:28:17.471Z" }, + { url = "https://files.pythonhosted.org/packages/91/00/671d0add79938127292839ae44506ce2f7fe8909c72d5a931864f128fd0b/yarl-1.24.2-cp310-cp310-win_arm64.whl", hash = "sha256:f408eace7e22a68b467a0562e0d27d322f91fe3eaaa6f466b962c6cfaea9fa39", size = 87887, upload-time = "2026-05-19T21:28:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/1ce244152ff2839645e7cae92f90e7bafcb2c52bea7ff586ac714f14f5df/yarl-1.24.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:36348bebb147b83818b9d7e673ea4debc75970afc6ffdc7e3975ad05ce5a58c1", size = 128971, upload-time = "2026-05-19T21:28:20.543Z" }, + { url = "https://files.pythonhosted.org/packages/87/5a/00f36967203ed89cb3acd2c8ed526cc3fed9418eb70ce128160a911c8499/yarl-1.24.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1a97e42c8a2233f2f279ecadd9e4a037bcb5d813b78435e8eedd4db5a9e9708c", size = 91507, upload-time = "2026-05-19T21:28:22.556Z" }, + { url = "https://files.pythonhosted.org/packages/31/d0/1fb0c1cd27288f39f6974da4318c32768d72c9890984541fdf1e2e32a51d/yarl-1.24.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8d027d56f1035e339d1001ac33eceab5b2ec8e42e449787bb75e289fb9a5cd1d", size = 91343, upload-time = "2026-05-19T21:28:24.092Z" }, + { url = "https://files.pythonhosted.org/packages/03/ce/d4a646508bed2f8dec6435b40166fe9308dd191262033d3f307b2bbcaecd/yarl-1.24.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a6377060e7927187a42b7eb202090cbe2b34933a4eeaf90e3bd9e33432e5cae", size = 105704, upload-time = "2026-05-19T21:28:25.872Z" }, + { url = "https://files.pythonhosted.org/packages/4b/07/b3278e82d8bc41485bcf6d856cd0433262593de615b1d3dc43bd3f5bead4/yarl-1.24.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:17076578bce0049a5ce57d14ad1bded391b68a3b213e9b81b0097b090244999a", size = 97281, upload-time = "2026-05-19T21:28:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/17/5b/4cee6e7c92e487bebe7afc797da0aa54a248ab4e776a68fe369ec29665a5/yarl-1.24.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:50713f1d4d6be6375bb178bb43d140ee1acb8abe589cd723320b7925a275be1e", size = 114020, upload-time = "2026-05-19T21:28:29.458Z" }, + { url = "https://files.pythonhosted.org/packages/5c/82/111076571545a7d4f9cca3fbd5c6f40615af58642be09f12328f48022468/yarl-1.24.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:34263e2fa8fb5bb63a0d97706cda38edbad62fddb58c7f12d6acbc092812aa50", size = 111450, upload-time = "2026-05-19T21:28:31.262Z" }, + { url = "https://files.pythonhosted.org/packages/b6/ec/08f671f69a444d704aeecebf92af659b67b97a869942411d0a578b08c334/yarl-1.24.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:49016d82f032b1bd1e10b01078a7d29ae71bf468eeae0ea22df8bab691e60003", size = 106384, upload-time = "2026-05-19T21:28:32.856Z" }, + { url = "https://files.pythonhosted.org/packages/e5/86/ce41e7a7a199340b2330d52b60f25c4074b6636dd0e60b1a80d31a9db042/yarl-1.24.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3f6d2c216318f8f32038ca3f72501ba08536f0fd18a36e858836b121b2deed9f", size = 106153, upload-time = "2026-05-19T21:28:35.222Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5d/31be8a729531ab3e55ac3e7e5c800be8c89ea98947f418b2f6ea259fb6ee/yarl-1.24.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:08d3a33218e0c64393e7610284e770409a9c31c429b078bcb24096ed0a783b8f", size = 105322, upload-time = "2026-05-19T21:28:36.642Z" }, + { url = "https://files.pythonhosted.org/packages/47/9b/b57afb22b386ae87ac9940f09878b98d8c333f89113e6fc96fcf4ca9eb64/yarl-1.24.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5d699376c4ca3cba49bbfae3a05b5b70ded572937171ce1e0b8d87118e2ba294", size = 99057, upload-time = "2026-05-19T21:28:38.386Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4f/06348c27c8389256c313e8a57d796808fc0264c915dd5e7cfd3c0e314dc7/yarl-1.24.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a1cab588b4fa14bea2e55ebea27478adfb05372f47573738e1acc4a36c0b05d2", size = 113502, upload-time = "2026-05-19T21:28:40.091Z" }, + { url = "https://files.pythonhosted.org/packages/5f/1c/284f307b298e4a17b7943b07d9d7ecc4151537f8d137ba51f3bb6c31ca20/yarl-1.24.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ec87ccc31bd21db7ad009d8572c127c1000f268517618a4cc09adba3c2a7f21c", size = 105253, upload-time = "2026-05-19T21:28:41.987Z" }, + { url = "https://files.pythonhosted.org/packages/c8/bf/0de123bec8619e45c80cbded9085f61b5b4a9eddb8abe6d25d28ee1ec866/yarl-1.24.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d1dd47a22843b212baa8d74f37796815d43bd046b42a0f41e9da433386c3136b", size = 111345, upload-time = "2026-05-19T21:28:43.93Z" }, + { url = "https://files.pythonhosted.org/packages/90/af/0248eb065e51129d2a9b2436cd1b5c772c19a6b04e5b6a186955671e3319/yarl-1.24.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7b54b9c67c2b06bd7b9a77253d242124b9c95d2c02def5a1144001ee547dd9d5", size = 106558, upload-time = "2026-05-19T21:28:45.806Z" }, + { url = "https://files.pythonhosted.org/packages/21/3c/f960d7a65ef97d8ba9b424fb5128796a4bc710fc6df2ddbbd7dfdc3bbd20/yarl-1.24.2-cp311-cp311-win_amd64.whl", hash = "sha256:f8fdbcff8b2c7c9284e60c196f693588598ddcee31e11c18e14949ce44519d45", size = 92808, upload-time = "2026-05-19T21:28:48.465Z" }, + { url = "https://files.pythonhosted.org/packages/03/1a/49fb03750e4de4d2284cd5b885a383133c34eef45bd59631b2bb8b7e81e8/yarl-1.24.2-cp311-cp311-win_arm64.whl", hash = "sha256:b32c37a7a337e90822c45797bf3d79d60875cfcccd3ecc80e9f453d87026c122", size = 87610, upload-time = "2026-05-19T21:28:50.07Z" }, + { url = "https://files.pythonhosted.org/packages/f0/da/866bcb01076ba49d2b42b309867bed3826421f1c479655eb7a607b44f20b/yarl-1.24.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:b975866c184564c827e0877380f0dae57dcca7e52782128381b72feff6dfceb8", size = 129957, upload-time = "2026-05-19T21:28:51.695Z" }, + { url = "https://files.pythonhosted.org/packages/bf/1d/fcefb70922ea2268a8971d8e5874d9a8218644200fb8465f1dcad55e6851/yarl-1.24.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3b075301a2836a0e297b1b658cb6d6135df535d62efefdd60366bd589c2c82f2", size = 92164, upload-time = "2026-05-19T21:28:53.242Z" }, + { url = "https://files.pythonhosted.org/packages/29/b6/170e2b8d4e3bc30e6bfdcca53556537f5bf595e938632dfcb059311f3ff6/yarl-1.24.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ae44649b00947634ab0dab2a374a638f52923a6e67083f2c156cd5cbd1a881d", size = 91688, upload-time = "2026-05-19T21:28:54.865Z" }, + { url = "https://files.pythonhosted.org/packages/fe/a5/c9f655d5553ea0b99fdac9d6a99ad3f9b3e73b8e5758bb46f58c9831f74c/yarl-1.24.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:507cc19f0b45454e2d6dcd62ff7d062b9f77a2812404e62dbdaec05b50faa035", size = 102902, upload-time = "2026-05-19T21:28:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/5d/bc/6b9664d815d79af4ee553337f9d606c56bbf269186ada9172de45f1b5f60/yarl-1.24.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c4c17bad5a530912d2111825d3f05e89bab2dd376aaa8cbc77e449e6db63e576", size = 97931, upload-time = "2026-05-19T21:28:58.56Z" }, + { url = "https://files.pythonhosted.org/packages/98/ec/32ba48acae30fecd60928f5791188b80a9d6ee3840507ffda29fecd37b71/yarl-1.24.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5f0cbb112838a4a293985b6ed73948a547dadcc1ba6d2089938e7abdedceef8", size = 111030, upload-time = "2026-05-19T21:29:00.148Z" }, + { url = "https://files.pythonhosted.org/packages/82/5a/6f4cd081e5f4934d2ae3a8ef4abe3afacc010d26f0035ee91b35cd7d7c37/yarl-1.24.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5ec8356b8a6afcf81fc7aeeef13b1ff7a49dec00f313394bbb9e83830d32ccd7", size = 110392, upload-time = "2026-05-19T21:29:02.155Z" }, + { url = "https://files.pythonhosted.org/packages/7a/da/323a01c349bd5fb01bb6652e314d9bb218cee630a736bdb810ad50e4013f/yarl-1.24.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7e7ebcdef69dec6c6451e616f32b622a6d4a2e92b445c992f7c8e5274a6bbc4c", size = 105612, upload-time = "2026-05-19T21:29:04.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/80/264ab684f181e1a876389374519ff05d10248725535ae2ac4e8ac4e563d6/yarl-1.24.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:47a55d6cf6db2f401017a9e96e5288844e5051911fb4e0c8311a3980f5e59a7d", size = 104487, upload-time = "2026-05-19T21:29:06.491Z" }, + { url = "https://files.pythonhosted.org/packages/41/07/efabe5df87e96d7ad5959760b888344be48cd6884db127b407c6b5503adc/yarl-1.24.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3065657c80a2321225e804048597ad55658a7e76b32d6f5ee4074d04c50401db", size = 102333, upload-time = "2026-05-19T21:29:08.267Z" }, + { url = "https://files.pythonhosted.org/packages/44/0c/bcf7c42603e1009295f586d8890f2ba032c8b53310e815adf0a202c73d9f/yarl-1.24.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:cb84b80d88e19ede158619b80813968713d8d008b0e2497a576e6a0557d50712", size = 99025, upload-time = "2026-05-19T21:29:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/4f/82/84482ab1a57a0f21a08afe6a7004c61d741f8f2ecc3b05c321577c612164/yarl-1.24.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:990de4f680b1c217e77ff0d6aa0029f9eb79889c11fb3e9a3942c7eba29c1996", size = 110507, upload-time = "2026-05-19T21:29:12.954Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8d/a546ba1dfe1b0f290e05fef145cd07614c0f15df1a707195e512d1e39d1d/yarl-1.24.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:abb8ec0323b80161e3802da3150ef660b41d0e9be2048b76a363d93eee992c2b", size = 103719, upload-time = "2026-05-19T21:29:14.893Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b6/267f2a09213138473adfce6b8a6e17791d7fee70bd4d9003218e4dec58b0/yarl-1.24.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e7977781f83638a4c73e0f88425563d70173e0dfd90ac006a45c65036293ee3c", size = 110438, upload-time = "2026-05-19T21:29:16.485Z" }, + { url = "https://files.pythonhosted.org/packages/48/2d/1c8d89c7c5f9cad9fb2902445d94e2ab1d7aa35de029afbb8ae95c42d00f/yarl-1.24.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e30dd55825dc554ec5b66a94953b8eda8745926514c5089dfcacecb9c99b5bd1", size = 105719, upload-time = "2026-05-19T21:29:18.367Z" }, + { url = "https://files.pythonhosted.org/packages/a7/25/722e3b93bd687009afb2d59a35e13d30ddd8f80571445bb0c4e4ce26ec66/yarl-1.24.2-cp312-cp312-win_amd64.whl", hash = "sha256:7dafe10c12ddd4d120d528c4b5599c953bd7b12845347d507b95451195bb6cad", size = 92901, upload-time = "2026-05-19T21:29:20.014Z" }, + { url = "https://files.pythonhosted.org/packages/39/47/4486ccfb674c04854a1ef8aa77868b6a6f765feaf69633409d7ca4f02cb8/yarl-1.24.2-cp312-cp312-win_arm64.whl", hash = "sha256:044a09d8401fcf8681977faef6d286b8ade1e2d2e9dceda175d1cfa5ca496f30", size = 87229, upload-time = "2026-05-19T21:29:22.1Z" }, + { url = "https://files.pythonhosted.org/packages/82/62/fcf0ce677f17e5c471c06311dd25964be38a4c586993632910d2e75278bc/yarl-1.24.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:491ac9141decf49ee8030199e1ee251cdff0e131f25678817ff6aa5f837a3536", size = 128978, upload-time = "2026-05-19T21:29:23.83Z" }, + { url = "https://files.pythonhosted.org/packages/d3/58/8e63299bb71ed61a834121d9d3fe6c9fcf2a6a5d09754ff4f20f2d20baf5/yarl-1.24.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e89418f65eda18f99030386305bd44d7d504e328a7945db1ead514fbe03a0607", size = 91733, upload-time = "2026-05-19T21:29:25.375Z" }, + { url = "https://files.pythonhosted.org/packages/c1/24/16748d5dab6daec8b0ed81ccec639a1cded0f18dcc62a4f696b4fe366c37/yarl-1.24.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cdfcce633b4a4bb8281913c57fcafd4b5933fbc19111a5e3930bbd299d6102f1", size = 91113, upload-time = "2026-05-19T21:29:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/b63fff7b71211e866624b21432d5943cbb633eb0c2872d9ee3070648f22c/yarl-1.24.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:863297ddede92ee49024e9a9b11ecb59f310ca85b60d8537f56bed9bbb5b1986", size = 103899, upload-time = "2026-05-19T21:29:28.842Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/ba1974b8533909636f7733fe86cf677e3619527c3c2fa913e0ea89c48757/yarl-1.24.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:374423f70754a2c96942ede36a29d37dc6b0cb8f92f8d009ddf3ed78d3da5488", size = 97862, upload-time = "2026-05-19T21:29:31.086Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/123ac993b5c2ba6f554a140305620cb8f150fa543711bbc49be3ec0a65a4/yarl-1.24.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33a29b5d00ccbf3219bb3e351d7875739c19481e030779f48cc46a7a71681a9b", size = 111060, upload-time = "2026-05-19T21:29:32.657Z" }, + { url = "https://files.pythonhosted.org/packages/23/37/c472d3af3509688392134a88a825276770a187f1daa4de3f6dc0a327a751/yarl-1.24.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a9532c57211730c515341af11fef6e9b61d157487272a096d0c04da445642592", size = 110613, upload-time = "2026-05-19T21:29:34.379Z" }, + { url = "https://files.pythonhosted.org/packages/df/88/09c28dad91e662ccfaa1b78f1c57badde74fc9d0b23e74aef644750ecd73/yarl-1.24.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:91e72cf093fd833483a97ee648e0c053c7c629f51ff4a0e7edd84f806b0c5617", size = 107012, upload-time = "2026-05-19T21:29:36.216Z" }, + { url = "https://files.pythonhosted.org/packages/07/ab/9d4f69d571a94f4d112fa7e2e007200f5a54d319f58c82ac7b7baa61f5c6/yarl-1.24.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b3177bc0a768ef3bacceb4f272632990b7bea352f1b2f1eee9d6d6ff16516f92", size = 105887, upload-time = "2026-05-19T21:29:38.746Z" }, + { url = "https://files.pythonhosted.org/packages/8e/9a/000b2b66c0d772a499fc531d21dab92dfeb73b640a12eed6ba89f49bb2d0/yarl-1.24.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e196952aacaf3b232e265ff02980b64d483dc0972bd49bcb061171ff22ac203a", size = 103620, upload-time = "2026-05-19T21:29:40.368Z" }, + { url = "https://files.pythonhosted.org/packages/41/7c/7c1050f73450fbdaa3f0c72017059f00ce5e13366692f3dba25275a1083d/yarl-1.24.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:204e7a61ce99919c0de1bf904ab5d7aa188a129ea8f690a8f76cfb6e2844dc44", size = 100599, upload-time = "2026-05-19T21:29:42.66Z" }, + { url = "https://files.pythonhosted.org/packages/ec/b1/29e5756b3926705f5f6089bd5b9f50a56eaac550da6e260bf713ead44d04/yarl-1.24.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b156914620f0b9d78dc1adb3751141daee561cfec796088abb89ed49d220f1a", size = 110604, upload-time = "2026-05-19T21:29:44.632Z" }, + { url = "https://files.pythonhosted.org/packages/a3/4b/8415bc96e9b150cde942fbac9a8182985e58f40ce5c54c34ed015407d3ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8372a2b976cf70654b2be6619ab6068acabb35f724c0fda7b277fbf53d66a5cf", size = 105161, upload-time = "2026-05-19T21:29:46.755Z" }, + { url = "https://files.pythonhosted.org/packages/8b/d4/cde059abfa229553b7298a2eadde2752e723d50aeedaef86ce59da2718ee/yarl-1.24.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:f9a1e9b622ca284143aab5d885848686dcd85453bb1ca9abcdb7503e64dc0056", size = 110619, upload-time = "2026-05-19T21:29:48.972Z" }, + { url = "https://files.pythonhosted.org/packages/e7/2c/d6a6c9a61549f7b6c7e6dc6937d195bcf069582b47b7200dcd0e7b256acf/yarl-1.24.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:810e19b685c8c3c5862f6a38160a1f4e4c0916c9390024ec347b6157a45a0992", size = 107362, upload-time = "2026-05-19T21:29:51Z" }, + { url = "https://files.pythonhosted.org/packages/92/dd/3ae5fe417e9d1c353a548553326eb9935e76b6b727161563b424cc296df3/yarl-1.24.2-cp313-cp313-win_amd64.whl", hash = "sha256:7d37fb7c38f2b6edab0f845c4f85148d4c44204f52bc127021bd2bc9fdbf1656", size = 92667, upload-time = "2026-05-19T21:29:52.743Z" }, + { url = "https://files.pythonhosted.org/packages/10/cc/a7beb239f78f27fca1b053c8e8595e4179c02e62249b4687ec218c370c50/yarl-1.24.2-cp313-cp313-win_arm64.whl", hash = "sha256:1e831894be7c2954240e49791fa4b50c05a0dc881de2552cfe3ffd8631c7f461", size = 87069, upload-time = "2026-05-19T21:29:54.442Z" }, + { url = "https://files.pythonhosted.org/packages/40/0e/e08087695fc12789263821c5dc0f8dc52b5b17efd0887cacf419f8a43ba3/yarl-1.24.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:f9312b3c02d9b3d23840f67952913c9c8721d7f1b7db305289faefa878f364c2", size = 129670, upload-time = "2026-05-19T21:29:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/3a/98/ab4b5ed1b1b5cd973c8a3eb994c3a6aefb6ce6d399e21bb5f0316c33815c/yarl-1.24.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a4f4d6cd615823bfc7fb7e9b5987c3f41666371d870d51058f77e2680fbe9630", size = 91916, upload-time = "2026-05-19T21:29:58.645Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b1/5297bb6a7df4782f7605bffc43b31f5044070935fbbcaa6c705a07e6ac65/yarl-1.24.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0c3063e5c0a8e8e62fae6c2596fa01da1561e4cd1da6fec5789f5cf99a8aefd8", size = 91625, upload-time = "2026-05-19T21:30:00.412Z" }, + { url = "https://files.pythonhosted.org/packages/02/a7/45baabfff76829264e623b185cff0c340d7e11bf3e1cd9ea37e7d17934bd/yarl-1.24.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fecd17873a096036c1c87ab3486f1aef7f269ada7f23f7f856f93b1cc7744f14", size = 104574, upload-time = "2026-05-19T21:30:02.544Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/3a5ab144d3d650ca37d4f4b57e56169be8af3ca34c448793e064b30baaed/yarl-1.24.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a46d1ab4ba4d32e6dc80daf8a28ce0bd83d08df52fbc32f3e288663427734535", size = 97534, upload-time = "2026-05-19T21:30:04.319Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b5/5658fef3681fb5776b4513b052bec750009f47b3a592251c705d75375798/yarl-1.24.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73e68edf6dfd5f73f9ca127d84e2a6f9213c65bdffb736bda19524c0564fcd14", size = 111481, upload-time = "2026-05-19T21:30:05.988Z" }, + { url = "https://files.pythonhosted.org/packages/4c/06/fdcd7dde037f00866dce123ed4ba23dba94beb56fc4cf561668d27be37f2/yarl-1.24.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a296ca617f2d25fbceafb962b88750d627e5984e75732c712154d058ae8d79a3", size = 111529, upload-time = "2026-05-19T21:30:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c2/53/d81269aaafccea0d33396c03035de997b743f11e648e6e27a0df99c72980/yarl-1.24.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e51b2cf5ec89a8b8470177641ed62a3ba22d74e1e898e06ad53aa77972487208", size = 107338, upload-time = "2026-05-19T21:30:09.713Z" }, + { url = "https://files.pythonhosted.org/packages/ae/04/23049463f729bd899df203a7960505a75333edd499cda8aa1d5a82b64df5/yarl-1.24.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:310fc687f7b2044ec54e372c8cbe923bb88f5c37bded0d3079e5791c2fc3cf50", size = 106147, upload-time = "2026-05-19T21:30:11.365Z" }, + { url = "https://files.pythonhosted.org/packages/14/18/04a4b5830b43ed5e4c5015b40e9f6241ad91487d71611061b4e111d6ac80/yarl-1.24.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:297a2fe352ecf858b30a98f87948746ec16f001d279f84aebdbd3bd965e2f1bd", size = 104272, upload-time = "2026-05-19T21:30:12.978Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f7/8cffdf319aee7a7c1dbd07b61d91c3e3fda460c7a93b5f93e445f3806c4c/yarl-1.24.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2a263e76b97bc42bdcd7c5f4953dec1f7cd62a1112fa7f869e57255229390d67", size = 99962, upload-time = "2026-05-19T21:30:15.001Z" }, + { url = "https://files.pythonhosted.org/packages/d7/39/b3cce3b7dbef64ac700ad4cea156a207d01bede0f507587616c364b5468e/yarl-1.24.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:822519b64cf0b474f1a0aaef1dc621438ea46bb77c94df97a5b4d213a7d8a8b1", size = 111063, upload-time = "2026-05-19T21:30:16.683Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ea/100818505e7ebf165c7242ff17fdf7d9fee79e27234aeca871c1082920d7/yarl-1.24.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:b6067060d9dc594899ba83e6db6c48c68d1e494a6dab158156ed86977ca7bcb1", size = 105438, upload-time = "2026-05-19T21:30:18.769Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d2/e075a0b32aa6625087de9e653087df0759fed5de4a435fef594181102a77/yarl-1.24.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:0063adad533e57171b79db3943b229d40dfafeeee579767f96541f106bac5f1b", size = 111458, upload-time = "2026-05-19T21:30:21.024Z" }, + { url = "https://files.pythonhosted.org/packages/e6/5c/ceea7ba98b65c8eb8d947fdc52f9bedfcd43c6a57c9e3c90c17be8f324a3/yarl-1.24.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ee8e3fb34513e8dc082b586ef4910c98335d43a6fab688cd44d4851bacfce3e8", size = 107589, upload-time = "2026-05-19T21:30:23.412Z" }, + { url = "https://files.pythonhosted.org/packages/fa/d9/5582d57e2b2db9b85eb6663a22efdd78e08805f3f5389566e9fcad254d1b/yarl-1.24.2-cp314-cp314-win_amd64.whl", hash = "sha256:afb00d7fd8e0f285ca29a44cc50df2d622ff2f7a6d933fa641577b5f9d5f3db0", size = 94424, upload-time = "2026-05-19T21:30:25.425Z" }, + { url = "https://files.pythonhosted.org/packages/92/10/7dc07a0e22806a9280f42a57361395506e800c64e22737cd7b0886feab42/yarl-1.24.2-cp314-cp314-win_arm64.whl", hash = "sha256:68cf6eacd6028ef1142bc4b48376b81566385ca6f9e7dde3b0fa91be08ffcb57", size = 88690, upload-time = "2026-05-19T21:30:27.623Z" }, + { url = "https://files.pythonhosted.org/packages/9e/13/d5b8e2c8667db955bcb3de233f18798fefe7edf1d7429c2c9d4f9c401114/yarl-1.24.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:221ce1dd921ac4f603957f17d7c18c5cc0797fbb52f156941f92e04605d1d67b", size = 136248, upload-time = "2026-05-19T21:30:29.297Z" }, + { url = "https://files.pythonhosted.org/packages/de/46/a4a97c05c9c9b8fd266bb2a0df12992c7fbd02391eb9640583411b6dab32/yarl-1.24.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5f3224db28173a00d7afacdee07045cc4673dfab2b15492c7ae10deddbece761", size = 95084, upload-time = "2026-05-19T21:30:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/95/b2/845cf2074a015e6fe0d0808cf1a2d9e868386c4220d657ebd8302b199043/yarl-1.24.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c557165320d6244ebe3a02431b2a201a20080e02f41f0cfa0ccc47a183765da8", size = 95272, upload-time = "2026-05-19T21:30:33.062Z" }, + { url = "https://files.pythonhosted.org/packages/fe/16/e69d4aa244aef45235ddfebc0e04036a6829842bc5a6a795aedc6c998d23/yarl-1.24.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:904065e6e85b1fa54d0d87438bd58c14c0bad97aad654ad1077fd9d87e8478ed", size = 101497, upload-time = "2026-05-19T21:30:34.842Z" }, + { url = "https://files.pythonhosted.org/packages/15/94/c07107715d621076863ee88b3ddf183fa5e9d4aba5769623c9979828410a/yarl-1.24.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8cec2a38d70edc10e0e856ceda886af5327a017ccbde8e1de1bd44d300357543", size = 94002, upload-time = "2026-05-19T21:30:37.724Z" }, + { url = "https://files.pythonhosted.org/packages/a9/35/fc1bbdd895b5e4010b8fdd037f7ed3aa289d3863e08231b30231ca9a0815/yarl-1.24.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e7484b9361ed222ee1ca5b4337aa4cbdcc4618ce5aff57d9ef1582fd95893fc0", size = 106524, upload-time = "2026-05-19T21:30:40.196Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/32b66d0a4ba47c296cf86d03e2c67bff58399fe6d6d84d5205c04c66cc6d/yarl-1.24.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:84f9670b89f34db07f81e53aee83e0b938a3412329d51c8f922488be7fcc4024", size = 106165, upload-time = "2026-05-19T21:30:41.888Z" }, + { url = "https://files.pythonhosted.org/packages/95/47/37cb5ff50c5e825d4d38e81bb04d1b7e96bf960f7ab89f9850b162f3f114/yarl-1.24.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:abb2759733d63a28b4956500a5dd57140f26486c92b2caedfb964ab7d9b79dbf", size = 103010, upload-time = "2026-05-19T21:30:43.985Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d2/4597912315096f7bb359e46e13bf8b60994fcbb2db29b804c0902ef4eff5/yarl-1.24.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:081c2bf54efe03774d0311172bc04fedf9ca01e644d4cd8c805688e527209bdc", size = 101128, upload-time = "2026-05-19T21:30:46.291Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d5/c8e86e120521e646013d02a8e3b8884392e28494be8f392366e50d208efc/yarl-1.24.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:86746bef442aa479107fe28132e1277237f9c24c2f00b0b0cf22b3ee0904f2bb", size = 101382, upload-time = "2026-05-19T21:30:48.085Z" }, + { url = "https://files.pythonhosted.org/packages/fa/98/70b229236118f89dbeb739b76f10225bbf53b5497725502594c9a01d699a/yarl-1.24.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:2d07d21d0bc4b17558e8de0b02fbfdf1e347d3bb3699edd00bb92e7c57925420", size = 95964, upload-time = "2026-05-19T21:30:49.785Z" }, + { url = "https://files.pythonhosted.org/packages/87/f8/56c386981e3c8648d279fdef2397ffec577e8320fd5649745e34d54faeb7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4fb1ac3fc5fecd8ae7453ea237e4d22b49befa70266dfe1629924245c21a0c7f", size = 106204, upload-time = "2026-05-19T21:30:51.862Z" }, + { url = "https://files.pythonhosted.org/packages/1a/1e/765afe97811ca35933e2a7de70ac57b1997ea2e4ee895719ee7a231fb7e5/yarl-1.24.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4da31a5512ed1729ca8d8aacde3f7faeb8843cde3165d6bcf7f88f74f17bb8aa", size = 101510, upload-time = "2026-05-19T21:30:53.62Z" }, + { url = "https://files.pythonhosted.org/packages/ee/78/393913f4b9039e1edd09ae8a9bbb9d539be909a8abf6d8a2084585bed4b7/yarl-1.24.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:533ded4dceb5f1f3da7906244f4e82cf46cfd40d84c69a1faf5ac506aa65ecbe", size = 105584, upload-time = "2026-05-19T21:30:55.962Z" }, + { url = "https://files.pythonhosted.org/packages/78/87/deb17b7049bbe74ea11a713b86f8f27800cc1c8648b0b797243ebb4830ba/yarl-1.24.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7b3a85525f6e7eeabcfdd372862b21ee1915db1b498a04e8bf0e389b607ff0bd", size = 103410, upload-time = "2026-05-19T21:30:57.962Z" }, + { url = "https://files.pythonhosted.org/packages/8f/be/f9f7594e23b5b93affff0318e4593c1920331bcaefda326cabcad94296a1/yarl-1.24.2-cp314-cp314t-win_amd64.whl", hash = "sha256:a7624b1ca46ca5d7b864ef0d2f8efe3091454085ee1855b4e992314529972215", size = 102980, upload-time = "2026-05-19T21:30:59.735Z" }, + { url = "https://files.pythonhosted.org/packages/65/a4/ba80dccd3593ff1f01051a818694d07b58cb8232677ee9a22a5a1f93a9fc/yarl-1.24.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e434a45ce2e7a947f951fc5a8944c8cc080b7e59f9c50ae80fd39107cf88126d", size = 91219, upload-time = "2026-05-19T21:31:01.934Z" }, + { url = "https://files.pythonhosted.org/packages/fd/4d/4b880086bd0d3e034d25647be1d830afc3e3f610e98c4ab3490af6b1b6d5/yarl-1.24.2-py3-none-any.whl", hash = "sha256:2783d9226db8797636cd6896e4de81feed252d1db72265686c9558d97a4d94b9", size = 53576, upload-time = "2026-05-19T21:31:03.909Z" }, ] [[package]] name = "zipp" -version = "3.23.0" +version = "4.1.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" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] diff --git a/release_process.md b/release_process.md index 0a97e3afaf5..c0403407c41 100644 --- a/release_process.md +++ b/release_process.md @@ -1,11 +1,14 @@ # Release Process -Lance uses [semantic versioning](https://semver.org/) and maintains a linear commit history. -* All pull requests are merged into the `main` branch. +Lance maintains a linear commit history with a controlled release process. + +* All pull requests are merged into the `main` branch first. * Beta releases (or preview releases) are created on-demand from the `main` branch. * Stable releases (non-prereleases) are created only after a voting process and come from a release branch `release/vX.Y`. These are typically created once every two weeks. * Release Candidates (RC) are created from release branches prior to voting. +* Minor releases can be cut from either main branch or an existing release branch (when main is targeting a major release). * Patch releases are created by committing fixes directly to the release branch, voting on a new RC, and releasing. +* All changes (features, fixes) must be committed to main first, then cherry-picked to release branches as needed. ```mermaid gitGraph @@ -31,15 +34,22 @@ gitGraph commit tag: "1.4.1-rc.1" commit tag: "1.4.1" checkout main - commit tag: "1.5.0-beta.1" + commit tag: "2.0.0-beta.1" id: "breaking" + checkout "release/v1.4" + cherry-pick id: "breaking" tag: "1.5.0-rc.1" + branch "release/v1.5" + checkout "release/v1.5" + commit tag: "1.5.0" ``` ## Version Semantics +Lance uses version numbers inspired by semantic versioning, but with flexibility for practical release management. Specifically, minor releases can be cut from existing release branches when the main branch is targeting a major release. + ### Version Format -Lance uses semantic versioning with prerelease identifiers: +Lance uses version numbers with prerelease identifiers: - **Stable**: `X.Y.Z` (e.g., `1.3.0`) - **Beta**: `X.Y.Z-beta.N` (e.g., `1.3.0-beta.1`, `1.3.0-beta.2`) - **RC**: `X.Y.Z-rc.N` (e.g., `1.3.0-rc.1`, `1.3.0-rc.2`) @@ -63,32 +73,51 @@ Lance uses semantic versioning with prerelease identifiers: ### GitHub Releases and Release Notes -| Release Type | GitHub Release Type | Start Commit (exclusive) | End Commit (inclusive) | Explanation | -|---------------------------|---------------------|-----------------------------|------------------------|----------------------------------------------------------------------| -| **Stable (Major/Minor)** | Release | `release-root/X.Y.0-beta.N` | `vX.Y.0` | All changes from main + RC fixes | -| **Stable (Patch)** | Release | `vX.Y.(Z-1)` | `vX.Y.Z` | Only changes in this patch release | -| **RC (Major/Minor)** | Pre-Release | `release-root/X.Y.0-beta.N` | `vX.Y.0-rc.N` | All changes for the release | -| **RC (Patch)** | Pre-Release | `vX.Y.(Z-1)` | `vX.Y.Z-rc.N` | Only changes in this patch release | -| **RC (Iterations)** | Pre-Release | `vX.Y.(Z-1)` | `vX.Y.Z-rc.N` | Only changes in this patch release (not changes against previous RC) | -| **Beta (Main branch)** | Pre-Release | `release-root/X.Y.Z-beta.N` | `vX.Y.Z-beta.N` | Changes since last stable release RC cut in main branch | -| **Beta (Release branch)** | Pre-Release | `vX.Y.(Z-1)` | `vX.Y.Z-beta.N` | Changes since last stable release | +| Release Type | GitHub Release Type | Start Commit (exclusive) | End Commit (inclusive) | Explanation | +|------------------------------------|---------------------|-----------------------------|------------------------|----------------------------------------------------------------------| +| **Stable (Major/Minor from main)** | Release | `release-root/X.Y.0-beta.N` | `vX.Y.0` | All changes from main + RC fixes | +| **Stable (Minor from release)** | Release | `vX.(Y-1).Z` (last stable) | `vX.Y.0` | Changes since last stable on source release branch | +| **Stable (Patch)** | Release | `vX.Y.(Z-1)` | `vX.Y.Z` | Only changes in this patch release | +| **RC (Major/Minor from main)** | Pre-Release | `release-root/X.Y.0-beta.N` | `vX.Y.0-rc.N` | All changes for the release | +| **RC (Minor from release)** | Pre-Release | `vX.(Y-1).Z` (last stable) | `vX.Y.0-rc.N` | Changes since last stable on source release branch | +| **RC (Patch)** | Pre-Release | `vX.Y.(Z-1)` | `vX.Y.Z-rc.N` | Only changes in this patch release | +| **RC (Iterations)** | Pre-Release | Same as initial RC | `vX.Y.Z-rc.N` | Same comparison as initial RC (not against previous RC) | +| **Beta (Main branch)** | Pre-Release | `release-root/X.Y.Z-beta.N` | `vX.Y.Z-beta.N` | Changes since last stable release RC cut in main branch | +| **Beta (Release branch)** | Pre-Release | `vX.Y.(Z-1)` | `vX.Y.Z-beta.N` | Changes since last stable release | ## Branching Strategy ### Main Branch + - Always contains the latest development work - Version format: `X.Y.Z-beta.N` - After RC creation, bumped to next minor version with `-beta.0` (unreleased) - Beta previews published by bumping to `-beta.1+` ### Release Branches + - Format: `release/v{major}.{minor}` (e.g., `release/v1.3`) - Created when cutting initial RC for major/minor release +- Can be created from: + - **Main branch**: Standard flow for major/minor releases + - **Existing release branch**: For minor releases when main is targeting a major release - Maintained for patch releases - Version progression: `rc.1` → `rc.2` → stable → `beta.0` → `rc.1` (for patches) +### Commit Flow + +All changes must be committed to the main branch first: + +1. **Features and fixes**: Merge PR to main +2. **Release branch needs**: Cherry-pick from main to release branch +3. **Never commit directly to release branch** without the change existing in main first + +This ensures main always has the complete history and release branches only contain subsets of main's changes. + ## Version Flow +### Standard Flow (Major/Minor from Main) + ```mermaid %%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%% flowchart LR @@ -112,6 +141,7 @@ flowchart LR ``` **Flow explanation:** + - **Main branch**: Commit M0 at `1.3.0-beta.2` has `release-root/1.4.0-beta.N` (created when cutting v1.3.0-rc.1, pointing to this commit) and `release-root/2.0.0-beta.N` (created when breaking changes bumped major version, pointing to same commit) → M1 bumps to `1.4.0-beta.0` (unreleased) → M2 publishes `1.4.0-beta.1` (preview, tagged) → M3 publishes `2.0.0-beta.1` after detecting breaking changes (tagged) - **Release branch** `release/v1.3` created from M0, starts at `1.3.0-rc.1` (tagged) → `1.3.0` (stable, tagged) → `1.3.1-beta.0` → `1.3.1-rc.1` (tagged) → `1.3.1` (stable, tagged) → `1.3.2-beta.0` - **Tags**: �� = version tag (points to tagged commit), � = release-root tag (points to commit before RC was created, used for breaking change detection) @@ -119,12 +149,47 @@ flowchart LR **Note**: All commits are linear on their respective branches. `beta.0` = unreleased, `beta.1+` = published previews. +### Minor Release from Release Branch (When Main is Major) + +```mermaid +%%{init: {'theme':'base', 'themeVariables': { 'fontSize':'14px'}}}%% +flowchart LR + subgraph main["Main Branch (at 2.0.0)"] + direction LR + M1["2.0.0-beta.0"] --> M2["2.0.0-beta.1
    �� v2.0.0-beta.1"] + end + + subgraph release13["Release Branch: release/v1.3"] + direction LR + R1["1.3.0
    �� v1.3.0"] --> R2["1.3.1-beta.0"] + R2 --> R3["1.3.1
    �� v1.3.1"] + end + + subgraph release14["Release Branch: release/v1.4"] + direction LR + S1["1.4.0-rc.1
    �� v1.4.0-rc.1"] --> S2["1.4.0
    �� v1.4.0"] + S2 --> S3["1.4.1-beta.0"] + end + + R3 -.->|"create minor release
    (main is at 2.x)"| S1 +``` + +**Flow explanation:** + +- **Main branch** is at `2.0.0-beta.N` (major version) +- **release/v1.3** has released `1.3.1` and needs a new minor release with features +- **release/v1.4** is created from `release/v1.3` (not from main) because main is at a different major version +- Release notes for `v1.4.0` compare against `v1.3.1` (latest stable on source branch) +- Main branch is NOT modified (already at 2.x) + ## Workflows ### User-Facing Workflows 1. **publish-beta.yml** - Publish beta preview releases from any branch 2. **create-release-branch.yml** - Create release branch with initial RC for new major/minor version + - From main: Standard flow for major/minor releases + - From release branch: For minor releases when main is targeting a major release 3. **create-rc.yml** - Create RC on existing release branch (for new patch release RC or iterations of an existing RC) 4. **approve-rc.yml** - Approve any RC to stable (works for all release types) @@ -140,6 +205,8 @@ flowchart LR **Result**: Creates a beta tag (e.g., `v1.4.0-beta.1`) and publishes preview artifacts to fury.io, Maven Central, and Buf Schema Registry. +**Release Notes**: For the first beta (beta.1), release notes include all changes since the release-root tag. For subsequent betas (beta.2+), release notes only include incremental changes since the previous beta. +
    How beta versioning works @@ -187,11 +254,27 @@ Release root tags mark the base commits for breaking change detection. The tag n - Example: When bumping 1.4.0-beta.5 → 2.0.0-beta.1, create `release-root/2.0.0-beta.N` pointing to the SAME commit with the SAME base RC version **Key properties**: + - **Multiple tags, same commit**: `release-root/1.4.0-beta.N` and `release-root/2.0.0-beta.N` point to the same commit on main (the commit before the RC branch was created) - **Major version bumped once**: Both tags store same base RC version (1.3.0-rc.1), so we know 2.x is already a major bump from 1.3.0 - **No additional bumps**: When at 2.0.0-beta.1, we detect breaking changes but see major already bumped (2 > 1), so just increment beta - **Beta reset on major bump**: When bumping major version, beta number resets to 1 (e.g., 1.4.0-beta.5 → 2.0.0-beta.1) +### Minor Release Root Tag + +When a minor release is created from an existing release branch (not from main), a `minor-release-root` tag is created to track the comparison base for release notes. + +**Tag Format**: `minor-release-root/{major}.{minor}.0` + +- Created when using `create-release-branch` workflow with `source_release_branch` parameter +- Tag message contains the source stable tag (e.g., `v1.3.1`) +- Used by `determine_previous_tag` to find the correct comparison base + +**Example**: When creating `release/v1.4` from `release/v1.3` (where latest stable is v1.3.1): + +- Creates `minor-release-root/1.4.0` with message `v1.3.1` +- Release notes for v1.4.0-rc.N and v1.4.0 will compare against v1.3.1 + ### Detection Process Breaking change detection happens **on every beta publish from main branch**: @@ -219,11 +302,12 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet **Key insight**: Multiple beta version series can share the same release-root commit, with major version bumped only once when first detected.
    -## Create a Major / Minor Release +## Create a Major / Minor Release (from Main) **Purpose**: Create a new major or minor release from the main branch. **Steps**: + 1. Ensure CI on main is green 2. Trigger **"Create Release Branch"** workflow with **dry_run**: `true` 3. Review the RC version and changes @@ -234,6 +318,7 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet 8. **If approved**: Trigger **"Approve RC"** workflow with **rc_tag** (e.g., `v1.3.0-rc.2`) **Result**: + - Creates release branch (e.g., `release/v1.3`) with RC tag (e.g., `v1.3.0-rc.1`) - Bumps main to next minor (e.g., `1.4.0-beta.0`) - After approval: Creates stable tag (e.g., `v1.3.0`) and publishes to PyPI, crates.io, Maven Central @@ -242,6 +327,7 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet What happens under the hood **Create Release Branch workflow**: + - Reads current version from main (e.g., `1.3.0-beta.2`) - Checks for breaking changes since release-root tag - If breaking changes: Creates RC with bumped major (e.g., `2.0.0-rc.1`), bumps main to `2.1.0-beta.0` @@ -250,6 +336,7 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet - Creates GitHub Discussion for voting **Approve RC workflow**: + - Bumps version from `rc.N` to stable - Generates release notes comparing against `release-root/{version}-beta.N` tag - Creates GitHub Release and publishes stable artifacts @@ -257,22 +344,77 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet - Main branch is NOT affected (already bumped in step 1) +## Create a Minor Release (from Release Branch) + +**Purpose**: Create a new minor release from an existing release branch when main is targeting a major release. + +**When to use**: When main branch has breaking changes and is targeting a major version (e.g., `2.0.0`), but you need to release new features for users who aren't ready to upgrade to the new major version. + +**Prerequisites**: + +- Main branch must be at a major version (e.g., `2.0.0-beta.N` where patch = 0 and minor = 0) +- Source release branch must exist (e.g., `release/v1.3`) +- Features to release must already be committed to main, then cherry-picked to the source release branch + +**Steps**: + +1. Cherry-pick desired features from main to the source release branch +2. Trigger **"Create Release Branch"** workflow with: + - **source_release_branch**: e.g., `release/v1.3` + - **dry_run**: `true` +3. Review the minor RC version (e.g., `1.4.0-rc.1`) +4. Run with **dry_run**: `false` to create new release branch and RC +5. Test RC artifacts and vote in the GitHub Discussion +6. **If issues found**: Fix and run **"Create RC"** workflow +7. **If approved**: Trigger **"Approve RC"** workflow + +**Result**: + +- Creates new release branch (e.g., `release/v1.4`) from source branch +- Creates RC tag (e.g., `v1.4.0-rc.1`) +- Main branch is NOT modified (already at major version) +- Release notes compare against latest stable on source branch (e.g., `v1.3.1`) +- After approval: Creates stable tag and publishes artifacts + +
    +What happens under the hood + +**Create Release Branch workflow (with source_release_branch)**: + +- Validates main is at major version (`X.0.0-beta.N` where patch = 0) +- Checks out source release branch (e.g., `release/v1.3`) +- Reads current version from source branch +- Increments minor version (e.g., `1.3.1-beta.0` → `1.4.0-rc.1`) +- Creates new release branch (e.g., `release/v1.4`) from source branch HEAD +- Finds latest stable tag on source branch for release notes comparison +- Does NOT modify main branch +- Creates GitHub Discussion for voting + +**Key differences from main branch flow**: + +- No breaking change detection (assumes features are already validated) +- No release-root tag created (not needed for this flow) +- Main branch version unchanged +- Release notes compare against source branch's latest stable release +
    + ## Create a Patch / Bugfix Release **Purpose**: Release critical bug fixes for an existing release. **Steps**: -1. Checkout the release branch (e.g., `release/v1.3`) -2. Create and test your fix (ensure no breaking changes) -3. Create a PR to merge into the release branch -4. Trigger **"Create RC"** workflow with **release_branch** (e.g., `release/v1.3`) and **dry_run**: `true` -5. Review the patch RC version -6. Run with **dry_run**: `false` to create the patch RC -7. Test RC artifacts and vote in the GitHub Discussion -8. **If issues found**: Fix and run **"Create RC"** again to create `rc.2`, `rc.3`, etc. -9. **If approved**: Trigger **"Approve RC"** workflow with **rc_tag** (e.g., `v1.3.1-rc.1`) + +1. Commit the fix to main branch first (all changes must go to main first) +2. Cherry-pick the fix to the release branch (e.g., `release/v1.3`) +3. Trigger **"Create RC"** workflow with **release_branch** (e.g., `release/v1.3`) and **dry_run**: `true` +4. Review the patch RC version +5. Run with **dry_run**: `false` to create the patch RC +6. Test RC artifacts and vote in the GitHub Discussion +7. **If issues found**: Fix and run **"Create RC"** again to create `rc.2`, `rc.3`, etc. +8. **If approved**: Trigger **"Approve RC"** workflow with **rc_tag** (e.g., `v1.3.1-rc.1`) **Result**: + - Creates patch RC tag (e.g., `v1.3.1-rc.1`) on release branch - After approval: Creates stable tag (e.g., `v1.3.1`) and publishes to PyPI, crates.io, Maven Central - Auto-bumps release branch to next patch `beta.0` (e.g., `1.3.2-beta.0`) @@ -281,7 +423,8 @@ Starting from v1.3.0-rc.1 cut, main at 1.4.0-beta.0 with `release-root/1.4.0-bet
    Important notes -- **Breaking changes not allowed**: Release branches are for patch releases only +- **Commit to main first**: All fixes must be committed to main before cherry-picking to release branches +- **Breaking changes not allowed**: Patch releases should not introduce breaking changes - **Beta versions**: Release branches stay at `X.Y.Z-beta.N` between releases (auto-bumped after stable) - **Release notes**: Compares against previous stable tag (e.g., `v1.3.0`) - **Allowed changes**: Correctness bugs, security fixes, major performance regressions, unintentional breaking change reverts @@ -419,7 +562,7 @@ Workflow: Create Release Branch ```bash # 1. Start with release/v1.3 @ 1.3.1-beta.0 (auto-bumped after previous stable release) # 2. Critical bug found in 1.3.0 -# 3. Fix committed to release/v1.3 +# 3. Fix committed to main first, then cherry-picked to release/v1.3 # 4. Create patch RC Workflow: Create RC release_branch: release/v1.3 @@ -444,6 +587,48 @@ Workflow: Approve RC - Main unchanged ``` +### Minor Release from Release Branch + +```bash +# Scenario: Main is at 2.0.0-beta.N (major version), need to release v1.4.0 with new features + +# 1. Main is at 2.0.0-beta.1 (breaking changes introduced) +# 2. release/v1.3 is at 1.3.1-beta.0 (after releasing v1.3.1) +# 3. Cherry-pick desired features from main to release/v1.3 + +# 4. Create minor release branch from release/v1.3 +Workflow: Create Release Branch + source_release_branch: release/v1.3 + Result: + - Validates main is at major version (2.0.0-beta.1) + - Source branch at 1.3.1-beta.0 + - Created release/v1.4 at 1.4.0-rc.1 + - Tagged v1.4.0-rc.1 + - Found latest stable: v1.3.1 + - Created GitHub Pre-Release with release notes from v1.3.1 to v1.4.0-rc.1 + - Main NOT modified (stays at 2.0.0-beta.1) + - GitHub Discussion created + +# 5. Vote on RC (3-day voting for minor release) + - Navigate to Discussion thread + - Test RC artifacts + - Vote with +1, 0, -1 + +# 6. Approve RC +Workflow: Approve RC + rc_tag: v1.4.0-rc.1 + Result: + - release/v1.4 @ 1.4.0 + - Tagged v1.4.0 + - Generated release notes comparing v1.4.0 vs v1.3.1 + - Created GitHub Release (not pre-release) + - Stable artifacts published + - Release branch auto-bumped to 1.4.1-beta.0 + - Main unchanged (stays at 2.0.0-beta.1) + +# 7. Future: Can continue with patches on release/v1.4 or create release/v1.5 from it +``` + ### RC Iteration Due to Issues ```bash diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 62b4dcbb1fa..c45ebe572c0 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.90.0" +channel = "1.98.1" components = ["rustfmt", "clippy", "rust-analyzer"] diff --git a/rust/AGENTS.md b/rust/AGENTS.md new file mode 100644 index 00000000000..70a803c6c76 --- /dev/null +++ b/rust/AGENTS.md @@ -0,0 +1,90 @@ +# Rust Guidelines + +Also see [root AGENTS.md](../AGENTS.md) for cross-language standards. + +## Code Style + +- Use `Vec::with_capacity()` when size is known or estimable — prefer over-estimating capacity to multiple reallocations. +- Wrap large or expensive-to-clone struct fields (maps, protobuf metadata, schemas) in `Arc` to avoid deep copies. +- Use `Box::pin(...)` or `.boxed()` but never both — `.boxed()` already returns `Pin>`. +- Remove dead code instead of adding `#[allow(dead_code)]`. Delete unused constants instead of reducing visibility. +- Use `column_by_name()` for `RecordBatch` column access in production code; use `batch["column_name"]` in tests. +- Use `PrimitiveArray::::from(vec)` (zero-copy) instead of `from_iter_values(vec)` for Vec-to-PrimitiveArray conversion. +- Implement `Default` trait on config/options structs instead of standalone `default_*()` helpers. +- Place `#[cfg(test)] mod tests` as a single block at the bottom of each file — no production code after it. +- Place `use` imports at the top of the file, not inline within function bodies. +- Extract substantial new logic (bin packing, scheduling) into dedicated submodules instead of inlining into large files. +- Delete obsolete internal (`pub(crate)` / private) methods in the same PR that introduces their replacements. For public API methods, follow the deprecation path in root AGENTS.md instead. +- Choose log levels by audience: `debug!` for routine/high-frequency ops, `info!` for infrequent operator-visible state changes, `warn!` for unexpected conditions. + +## Concurrency + +- The closure passed to `spawn_cpu()` must only consume CPU and return — it must **never** wait on anything: **no channels** (blocking send/recv), **no I/O**, **no locks**, and no `block_on`/`.blocking_*`. The CPU pool can collapse to a single worker in resource-constrained environments (`<= 3` CPUs), so a parked closure can deadlock the whole pool with a silent 0% hang. Keep the waiting in surrounding async code and hand only the pure-CPU work to `spawn_cpu()`. Only dispatch substantial work (rule of thumb: ~100µs+ of CPU); below that the pool overhead outweighs the benefit and the work is better left inline. See the doc comment on `spawn_cpu` for the rationale. + +## API Design + +- Use `with_`-prefixed builder methods for optional config (e.g., `MyStruct::new(required).with_option(v)`) — don't create separate constructor variants. +- For public APIs, prefer `Into` or `AsRef` trait bounds for flexible inputs. +- Prefer `pub(crate)` over `pub` for crate-internal items. Use `pub use` re-exports for the actual public API surface. +- Use enums instead of magic numbers for format versions, variant types, and discriminators — leverage exhaustive `match`. +- Use strongly-typed structs instead of `HashMap` in APIs — convert to strings only at serialization boundaries. +- Keep `RowAddr` (physical fragment+offset) and `RowId` (stable logical identifier) as distinct types — never raw `u64` for both. +- Use `RowAddress` from `lance-core/src/utils/address.rs` instead of raw bitwise operations on row addresses. +- Use `RowAddrTreeMap`/`RoaringBitmap` instead of `Vec>` for physical row selections. +- Use logical row counts (`num_rows()`) instead of `physical_rows` for user-facing metrics — subtract deletions. +- Keep traits minimal — only core abstraction methods. Move helpers to standalone functions and config to struct fields. +- Get column/field types from schema metadata — never materialize data rows just to inspect types. +- Use stable, versioned serialization formats for persistent storage (e.g., index files) — avoid unstable cross-version formats. +- Use Arrow's type-safe access (`ArrayAccessor` trait bounds, `as_*_array` helpers) instead of `arrow::compute::cast` + `downcast_ref`. Prefer `_opt` variants (e.g., `as_string_opt`) unless the data type has already been verified. +- In `lance-io/`, use single-syscall writes for local filesystem I/O — don't reuse cloud multipart upload machinery. + +## Error Handling + +- Never use `.unwrap()`, `.expect()`, `panic!()`, or `assert!()` in library code for fallible operations — use `?` with `Result` and proper error types. Reserve `.unwrap()` for tests only. +- Avoid bare `.unwrap()`; use `if let`, `match`, `let ... else`, `?`, or combinators. Never `.is_none()` followed by `.unwrap()`. If unavoidable, use `.expect("reason")`. +- Return `LanceError::NotSupported` instead of `todo!()` or `unimplemented!()` for unsupported code paths. Test with `Result::Err` assertions, not `#[should_panic]`. +- Match `Error` variant to root cause: `Error::invalid_input` for caller data issues, `Error::corrupt_file` for format/integrity issues, `Error::not_found` for missing resources, `Error::io` for I/O failures. +- Include full context in error messages — variable names, values, sizes, types, indices. Not generic messages like `"Invalid chunk size"`. +- Use `checked_add`/`checked_mul` instead of `wrapping_add`/`wrapping_mul` for counters and IDs — return an error on overflow. +- Prefer `debug_assert!` over `assert!` for non-safety invariants; reserve `assert!` for conditions preventing data corruption. Always include descriptive messages. +- Don't silently guard against impossible conditions — use `debug_assert!`, return an explicit error, or remove the check. +- Log warnings on best-effort/cleanup failures instead of silently swallowing or propagating errors. +- Log warnings for silent no-ops (skipped operations); omit warnings before errors since the error message is sufficient. +- Avoid `unwrap_or(default)` on map lookups for required config params — use `.ok_or_else(|| Error::...)` and verify key names match between serialization and deserialization. +- Advance all parallel iterators before any `continue` branches — early exits that skip `.next()` calls cause misalignment. +- Bind `iter.next()` with `let Some(x) = iter.next() else { ... }` — never call `.next()` twice to check-then-use. + +## Naming + +- Reserve `_`-prefixed names for truly unused bindings — if a variable is read, drop the underscore. +- Prefix boolean variables with `is_` or `has_` instead of ambiguous `with_` or bare adjectives. +- Name booleans so `false` (zero/`Default::default()`) is the desired default — use `disable_*` instead of `enable_*` when the feature should be on by default. +- Name functions to match their actual scope — e.g., `handle_partition_system_columns` not `handle_system_columns` if only a subset is handled. + +## Testing + +- Use `record_batch!()` from `arrow_array` to construct `RecordBatch` in tests instead of manual Schema/Arc/try_new boilerplate. +- Use `gen_batch()` builder API (`.col()`, `.into_reader_rows()`) for test data setup instead of manual Arrow construction. +- Use `.try_into_batch()` instead of `.try_into_stream().try_collect()` for scanner results in tests. +- Use plain `"memory://"` URIs in tests — no atomic counters or unique suffixes needed. +- Assert on both error variant (`assert!(matches!(error, ErrorType::Variant { .. }))`) and message content — don't just check `is_err()`. + +## Documentation + +- Add doc comments to public API elements that convey semantic meaning, valid values, and effects — don't restate type signatures. +- Document enum variant doc comments with behavioral semantics, not just labels. For numeric parameters, state whether it's an id, count, index, etc. +- Add doc comments to magic constants, thresholds, and non-obvious transformation functions — explain what the value represents and why it was chosen. +- Comment fallback/guard code paths with when they trigger and why they exist. +- Ensure doc comments match actual semantics — distinguish mutates-in-place (`&mut self`) from returns-new-value. +- Use explicit forward-looking language (`TODO`, `FIXME`) in comments to distinguish current behavior from planned changes. +- Document the semantic meaning of both present and absent states for `Option` fields. +- Use precise domain terminology — avoid ambiguous abbreviations (e.g., "FIXED" vs "fixed-width") or incorrect terms (e.g., "fields" when meaning "fragments"). + +## lance-encoding + +Performance-critical encoding/decoding paths have additional requirements: + +- Hoist loop-invariant conditionals out of hot loops — branch once outside, then use separate loop bodies or monomorphized variants. +- Pre-allocate single contiguous buffers. Default to `buf.resize(len, 0)` for safe initialization; reserve `Vec::with_capacity` + `unsafe { set_len() }` for measured hot paths only, with a `// SAFETY:` comment explaining why the buffer will be fully initialized before read (e.g., immediately followed by `read_exact`). +- Use `spawn_cpu()` only at the async-to-CPU boundary (e.g., FSST, decompression, batch materialization) — never nest redundant `spawn_cpu()` calls. +- Use `expect_next()` and similar utility methods instead of inlining `None`-checks with error returns. diff --git a/rust/arrow-scalar/Cargo.toml b/rust/arrow-scalar/Cargo.toml new file mode 100644 index 00000000000..b5e968faf8a --- /dev/null +++ b/rust/arrow-scalar/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "lance-arrow-scalar" +version = "58.0.0" +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +description = "Arrow scalar type with Ord, Hash, and Eq support" +keywords.workspace = true +categories.workspace = true +rust-version.workspace = true +readme = "README.md" + +[dependencies] +# Note: this is a core crate and we should aim to keep this dependency list +# as minimal as possible. +arrow-array = { workspace = true } +arrow-buffer = { workspace = true } +arrow-cast = { workspace = true } +arrow-data = { workspace = true } +arrow-row = { workspace = true } +arrow-schema = { workspace = true } +half = { workspace = true } + +[dev-dependencies] +arrow-ord = { workspace = true } +proptest = { workspace = true } +rstest = { workspace = true } + +[lints] +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/convert.rs b/rust/arrow-scalar/src/convert.rs new file mode 100644 index 00000000000..de783a3a604 --- /dev/null +++ b/rust/arrow-scalar/src/convert.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use arrow_array::*; +use half::f16; + +use crate::ArrowScalar; + +macro_rules! impl_from_primitive { + ($native_ty:ty, $array_ty:ty) => { + impl From<$native_ty> for ArrowScalar { + fn from(value: $native_ty) -> Self { + let array: ArrayRef = Arc::new(<$array_ty>::from(vec![value])); + Self::try_from_array(array).expect("single-element primitive array is always valid") + } + } + }; +} + +impl_from_primitive!(i8, Int8Array); +impl_from_primitive!(i16, Int16Array); +impl_from_primitive!(i32, Int32Array); +impl_from_primitive!(i64, Int64Array); +impl_from_primitive!(u8, UInt8Array); +impl_from_primitive!(u16, UInt16Array); +impl_from_primitive!(u32, UInt32Array); +impl_from_primitive!(u64, UInt64Array); +impl_from_primitive!(f32, Float32Array); +impl_from_primitive!(f64, Float64Array); + +impl From for ArrowScalar { + fn from(value: bool) -> Self { + let array: ArrayRef = Arc::new(BooleanArray::from(vec![value])); + Self::try_from_array(array).expect("single-element boolean array is always valid") + } +} + +impl From for ArrowScalar { + fn from(value: f16) -> Self { + let array: ArrayRef = Arc::new(Float16Array::from(vec![value])); + Self::try_from_array(array).expect("single-element f16 array is always valid") + } +} + +impl From<&str> for ArrowScalar { + fn from(value: &str) -> Self { + let array: ArrayRef = Arc::new(StringArray::from(vec![value])); + Self::try_from_array(array).expect("single-element string array is always valid") + } +} + +impl From for ArrowScalar { + fn from(value: String) -> Self { + Self::from(value.as_str()) + } +} + +impl From<&[u8]> for ArrowScalar { + fn from(value: &[u8]) -> Self { + let array: ArrayRef = Arc::new(BinaryArray::from_vec(vec![value])); + Self::try_from_array(array).expect("single-element binary array is always valid") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_from_primitives() { + let s = ArrowScalar::from(42i32); + assert!(!s.is_null()); + assert_eq!(format!("{s}"), "42"); + + let s = ArrowScalar::from(1.5f64); + assert!(!s.is_null()); + + let s = ArrowScalar::from(true); + assert_eq!(format!("{s}"), "true"); + } + + #[test] + fn test_from_string_types() { + let s = ArrowScalar::from("hello"); + assert_eq!(format!("{s}"), "hello"); + + let s = ArrowScalar::from(String::from("world")); + assert_eq!(format!("{s}"), "world"); + } + + #[test] + fn test_from_binary() { + let bytes: &[u8] = &[0xDE, 0xAD]; + let s = ArrowScalar::from(bytes); + assert!(!s.is_null()); + } + + #[test] + fn test_from_f16() { + let s = ArrowScalar::from(f16::from_f32(1.5)); + assert!(!s.is_null()); + } +} diff --git a/rust/arrow-scalar/src/lib.rs b/rust/arrow-scalar/src/lib.rs new file mode 100644 index 00000000000..70c468ebb99 --- /dev/null +++ b/rust/arrow-scalar/src/lib.rs @@ -0,0 +1,621 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! A scalar type backed by a single-element Arrow array with [`Ord`], [`Hash`], +//! and [`Eq`] support. +//! +//! Comparisons and hashing are delegated to [`arrow_row::OwnedRow`], which +//! provides a correct total ordering for all Arrow types (including proper NaN +//! handling for floats and null ordering). + +mod convert; +pub mod serde; + +use std::cmp::Ordering; +use std::fmt; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; + +use arrow_array::cast::AsArray; +use arrow_array::types::{Float16Type, Float32Type, Float64Type}; +use arrow_array::{ArrayRef, make_array, new_null_array}; +use arrow_cast::display::ArrayFormatter; +use arrow_data::transform::MutableArrayData; +use arrow_row::{OwnedRow, RowConverter, SortField}; +use arrow_schema::{ArrowError, DataType}; + +type Result = std::result::Result; + +/// A scalar value backed by a length-1 Arrow array. +/// +/// `ArrowScalar` provides [`Eq`], [`Ord`], and [`Hash`] by caching an +/// [`OwnedRow`] at construction time. This means comparisons and hashing are +/// O(1) row-byte operations rather than per-type dispatch. +/// +/// # Cross-type comparison +/// +/// Comparing scalars of different data types produces an arbitrary but +/// consistent ordering based on the underlying row bytes. This is intentional +/// — it allows scalars to be used as keys in sorted collections regardless of +/// type, but the ordering across types is not semantically meaningful. +/// +/// # Examples +/// +/// ``` +/// use lance_arrow_scalar::ArrowScalar; +/// +/// let a = ArrowScalar::from(1i32); +/// let b = ArrowScalar::from(2i32); +/// assert!(a < b); +/// +/// let c = ArrowScalar::from("hello"); +/// assert_eq!(c, ArrowScalar::from("hello")); +/// ``` +pub struct ArrowScalar { + array: ArrayRef, + row: OwnedRow, +} + +impl ArrowScalar { + /// Create a scalar by extracting the element at `offset` from `array`. + pub fn try_new(array: &ArrayRef, offset: usize) -> Result { + if offset >= array.len() { + return Err(ArrowError::InvalidArgumentError( + "Scalar index out of bounds".to_string(), + )); + } + + let data = array.to_data(); + let mut mutable = MutableArrayData::new(vec![&data], true, 1); + mutable.extend(0, offset, offset + 1); + let single = make_array(mutable.freeze()); + Self::try_from_array(single) + } + + /// Create a scalar from a length-1 array. + pub fn try_from_array(array: ArrayRef) -> Result { + if array.len() != 1 { + return Err(ArrowError::InvalidArgumentError(format!( + "ArrowScalar requires a length-1 array, got length {}", + array.len() + ))); + } + + let row = Self::compute_row(&array)?; + Ok(Self { array, row }) + } + + /// Create a null scalar of the given data type. + pub fn new_null(data_type: &DataType) -> Result { + Self::try_from_array(new_null_array(data_type, 1)) + } + + fn compute_row(array: &ArrayRef) -> Result { + let sort_field = SortField::new(array.data_type().clone()); + let converter = RowConverter::new(vec![sort_field])?; + let rows = converter.convert_columns(&[Arc::clone(array)])?; + Ok(rows.row(0).owned()) + } + + /// Returns a reference to the underlying length-1 array. + pub fn as_array(&self) -> &ArrayRef { + &self.array + } + + /// Returns the data type of this scalar. + pub fn data_type(&self) -> &DataType { + self.array.data_type() + } + + /// Returns `true` if this scalar is null. + pub fn is_null(&self) -> bool { + self.array.null_count() == 1 + } + + /// Returns `true` if this scalar is a non-null floating-point NaN. + /// + /// ``` + /// use lance_arrow_scalar::ArrowScalar; + /// + /// assert!(ArrowScalar::from(f32::NAN).is_nan()); + /// assert!(!ArrowScalar::from(1.0f32).is_nan()); + /// assert!(!ArrowScalar::from(1i32).is_nan()); + /// ``` + pub fn is_nan(&self) -> bool { + if self.is_null() { + return false; + } + + match self.data_type() { + DataType::Float16 => self.array.as_primitive::().value(0).is_nan(), + DataType::Float32 => self.array.as_primitive::().value(0).is_nan(), + DataType::Float64 => self.array.as_primitive::().value(0).is_nan(), + _ => false, + } + } +} + +impl PartialEq for ArrowScalar { + fn eq(&self, other: &Self) -> bool { + self.row == other.row + } +} + +impl Eq for ArrowScalar {} + +impl PartialOrd for ArrowScalar { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for ArrowScalar { + fn cmp(&self, other: &Self) -> Ordering { + self.row.cmp(&other.row) + } +} + +impl Hash for ArrowScalar { + fn hash(&self, state: &mut H) { + self.row.hash(state); + } +} + +impl fmt::Display for ArrowScalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_null() { + return write!(f, "null"); + } + let formatter = + ArrayFormatter::try_new(&self.array, &Default::default()).map_err(|_| fmt::Error)?; + write!(f, "{}", formatter.value(0)) + } +} + +impl fmt::Debug for ArrowScalar { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "ArrowScalar({}: {})", self.data_type(), self) + } +} + +impl Clone for ArrowScalar { + fn clone(&self) -> Self { + Self { + array: Arc::clone(&self.array), + row: self.row.clone(), + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::{BTreeSet, HashSet}; + use std::sync::Arc; + + use arrow_array::*; + use rstest::rstest; + + use super::*; + + #[test] + fn test_try_new_extracts_element() { + let array: ArrayRef = Arc::new(Int32Array::from(vec![10, 20, 30])); + let s = ArrowScalar::try_new(&array, 1).unwrap(); + assert_eq!(format!("{s}"), "20"); + } + + #[test] + fn test_try_new_out_of_bounds() { + let array: ArrayRef = Arc::new(Int32Array::from(vec![1])); + assert!(ArrowScalar::try_new(&array, 5).is_err()); + } + + #[test] + fn test_try_from_array_wrong_length() { + let array: ArrayRef = Arc::new(Int32Array::from(vec![1, 2])); + assert!(ArrowScalar::try_from_array(array).is_err()); + } + + #[test] + fn test_equality() { + let a = ArrowScalar::from(42i32); + let b = ArrowScalar::from(42i32); + let c = ArrowScalar::from(99i32); + assert_eq!(a, b); + assert_ne!(a, c); + } + + #[test] + fn test_ordering() { + let a = ArrowScalar::from(1i32); + let b = ArrowScalar::from(2i32); + let c = ArrowScalar::from(3i32); + assert!(a < b); + assert!(b < c); + assert_eq!(a.cmp(&a), Ordering::Equal); + } + + #[test] + fn test_hash_consistent_with_eq() { + use std::hash::DefaultHasher; + + let a = ArrowScalar::from(42i32); + let b = ArrowScalar::from(42i32); + let hash_a = { + let mut h = DefaultHasher::new(); + a.hash(&mut h); + h.finish() + }; + let hash_b = { + let mut h = DefaultHasher::new(); + b.hash(&mut h); + h.finish() + }; + assert_eq!(hash_a, hash_b); + } + + #[test] + fn test_in_hashset() { + let mut set = HashSet::new(); + set.insert(ArrowScalar::from(1i32)); + set.insert(ArrowScalar::from(2i32)); + set.insert(ArrowScalar::from(1i32)); + assert_eq!(set.len(), 2); + } + + #[test] + fn test_in_btreeset() { + let mut set = BTreeSet::new(); + set.insert(ArrowScalar::from(3i32)); + set.insert(ArrowScalar::from(1i32)); + set.insert(ArrowScalar::from(2i32)); + let values: Vec<_> = set.iter().map(|s| format!("{s}")).collect(); + assert_eq!(values, vec!["1", "2", "3"]); + } + + #[test] + fn test_null_scalar() { + let array: ArrayRef = Arc::new(Int32Array::from(vec![None])); + let s = ArrowScalar::try_from_array(array).unwrap(); + assert!(s.is_null()); + assert_eq!(format!("{s}"), "null"); + } + + #[test] + fn test_null_sorts_first() { + let null_scalar = { + let array: ArrayRef = Arc::new(Int32Array::from(vec![None])); + ArrowScalar::try_from_array(array).unwrap() + }; + let value_scalar = ArrowScalar::from(0i32); + assert!(null_scalar < value_scalar); + } + + #[rstest] + #[case::float_nan( + ArrowScalar::from(f64::NAN), + ArrowScalar::from(f64::INFINITY), + Ordering::Greater + )] + #[case::float_normal(ArrowScalar::from(1.0f64), ArrowScalar::from(2.0f64), Ordering::Less)] + fn test_float_ordering( + #[case] a: ArrowScalar, + #[case] b: ArrowScalar, + #[case] expected: Ordering, + ) { + assert_eq!(a.cmp(&b), expected); + } + + #[rstest] + #[case::float16_nan(ArrowScalar::from(half::f16::NAN), true)] + #[case::float32_nan(ArrowScalar::from(f32::NAN), true)] + #[case::float64_nan(ArrowScalar::from(f64::NAN), true)] + #[case::float64_finite(ArrowScalar::from(1.0f64), false)] + #[case::int32(ArrowScalar::from(1i32), false)] + fn test_is_nan(#[case] scalar: ArrowScalar, #[case] expected: bool) { + assert_eq!(scalar.is_nan(), expected); + } + + #[test] + fn test_null_is_not_nan() { + let array: ArrayRef = Arc::new(Float64Array::from(vec![None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert!(!scalar.is_nan()); + } + + #[test] + fn test_display_string() { + let s = ArrowScalar::from("hello world"); + assert_eq!(format!("{s}"), "hello world"); + } + + #[test] + fn test_debug() { + let s = ArrowScalar::from(42i32); + let debug = format!("{s:?}"); + assert!(debug.contains("ArrowScalar")); + assert!(debug.contains("42")); + } + + #[test] + fn test_clone() { + let a = ArrowScalar::from(42i32); + let b = a.clone(); + assert_eq!(a, b); + } + + #[test] + fn test_data_type() { + let s = ArrowScalar::from(42i32); + assert_eq!(s.data_type(), &DataType::Int32); + } + + #[test] + fn test_boolean_roundtrip() { + let t = ArrowScalar::from(true); + let f = ArrowScalar::from(false); + assert_eq!(t.data_type(), &DataType::Boolean); + assert!(!t.is_null()); + assert_eq!(format!("{t}"), "true"); + assert_eq!(format!("{f}"), "false"); + + // Extract from multi-element array + let array: ArrayRef = Arc::new(BooleanArray::from(vec![true, false, true])); + let s = ArrowScalar::try_new(&array, 1).unwrap(); + assert_eq!(format!("{s}"), "false"); + assert_eq!(s.data_type(), &DataType::Boolean); + } + + #[test] + fn test_boolean_equality_and_ordering() { + let t1 = ArrowScalar::from(true); + let t2 = ArrowScalar::from(true); + let f1 = ArrowScalar::from(false); + assert_eq!(t1, t2); + assert_ne!(t1, f1); + // false < true in arrow row encoding + assert!(f1 < t1); + } + + #[test] + fn test_boolean_null() { + let array: ArrayRef = Arc::new(BooleanArray::from(vec![None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert!(scalar.is_null()); + assert_eq!(scalar.data_type(), &DataType::Boolean); + assert_eq!(format!("{scalar}"), "null"); + + // null sorts before false + let f = ArrowScalar::from(false); + assert!(scalar < f); + } + + #[test] + fn test_string_view_roundtrip() { + let array: ArrayRef = Arc::new(StringViewArray::from(vec![ + "hello world, this is a long string view", + ])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert_eq!(scalar.data_type(), &DataType::Utf8View); + assert!(!scalar.is_null()); + assert_eq!( + format!("{scalar}"), + "hello world, this is a long string view" + ); + + // Extract from multi-element array + let array: ArrayRef = Arc::new(StringViewArray::from(vec!["alpha", "beta", "gamma"])); + let s = ArrowScalar::try_new(&array, 1).unwrap(); + assert_eq!(format!("{s}"), "beta"); + assert_eq!(s.data_type(), &DataType::Utf8View); + } + + #[test] + fn test_binary_view_roundtrip() { + let values: Vec<&[u8]> = vec![b"\xDE\xAD\xBE\xEF"]; + let array: ArrayRef = Arc::new(BinaryViewArray::from(values)); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert_eq!(scalar.data_type(), &DataType::BinaryView); + assert!(!scalar.is_null()); + + // Extract from multi-element array + let values: Vec<&[u8]> = vec![b"aaa", b"bbb", b"ccc"]; + let array: ArrayRef = Arc::new(BinaryViewArray::from(values)); + let s = ArrowScalar::try_new(&array, 2).unwrap(); + assert_eq!(s.data_type(), &DataType::BinaryView); + } + + #[test] + fn test_string_view_equality_and_ordering() { + let mk = |s: &str| { + let array: ArrayRef = Arc::new(StringViewArray::from(vec![s])); + ArrowScalar::try_from_array(array).unwrap() + }; + let a = mk("apple"); + let b = mk("apple"); + let c = mk("banana"); + assert_eq!(a, b); + assert_ne!(a, c); + assert!(a < c); + } + + #[test] + fn test_binary_view_equality_and_ordering() { + let mk = |b: &[u8]| { + let values: Vec<&[u8]> = vec![b]; + let array: ArrayRef = Arc::new(BinaryViewArray::from(values)); + ArrowScalar::try_from_array(array).unwrap() + }; + let a = mk(b"\x01\x02"); + let b = mk(b"\x01\x02"); + let c = mk(b"\x01\x03"); + assert_eq!(a, b); + assert_ne!(a, c); + assert!(a < c); + } + + #[test] + fn test_string_view_in_collections() { + let mk = |s: &str| { + let array: ArrayRef = Arc::new(StringViewArray::from(vec![s])); + ArrowScalar::try_from_array(array).unwrap() + }; + + let mut hset = HashSet::new(); + hset.insert(mk("foo")); + hset.insert(mk("bar")); + hset.insert(mk("foo")); + assert_eq!(hset.len(), 2); + + let mut bset = BTreeSet::new(); + bset.insert(mk("cherry")); + bset.insert(mk("apple")); + bset.insert(mk("banana")); + let sorted: Vec<_> = bset.iter().map(|s| format!("{s}")).collect(); + assert_eq!(sorted, vec!["apple", "banana", "cherry"]); + } + + #[test] + fn test_string_view_null() { + let array: ArrayRef = Arc::new(StringViewArray::from(vec![Option::<&str>::None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert!(scalar.is_null()); + assert_eq!(scalar.data_type(), &DataType::Utf8View); + assert_eq!(format!("{scalar}"), "null"); + } + + #[test] + fn test_binary_view_null() { + let array: ArrayRef = Arc::new(BinaryViewArray::from(vec![Option::<&[u8]>::None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert!(scalar.is_null()); + assert_eq!(scalar.data_type(), &DataType::BinaryView); + } + + #[test] + fn test_cross_type_comparison_is_consistent() { + let int_scalar = ArrowScalar::from(42i32); + let str_scalar = ArrowScalar::from("hello"); + // The ordering is arbitrary but must be consistent + let ord1 = int_scalar.cmp(&str_scalar); + let ord2 = int_scalar.cmp(&str_scalar); + assert_eq!(ord1, ord2); + // And the reverse should be opposite + assert_eq!(str_scalar.cmp(&int_scalar), ord1.reverse()); + } +} + +#[cfg(test)] +mod prop_tests { + use std::sync::Arc; + + use arrow_array::*; + use arrow_ord::sort::sort; + use arrow_schema::SortOptions; + use proptest::prelude::*; + + use super::ArrowScalar; + + /// Generate an arbitrary Arrow array of a randomly chosen type, including + /// nulls. Covers primitives, booleans, string/binary types and their view + /// variants. + fn arbitrary_array() -> BoxedStrategy { + let len = 0..=100usize; + + prop_oneof![ + // --- integer types --- + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Int8Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Int16Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Int32Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Int64Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(UInt8Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(UInt16Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(UInt32Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(UInt64Array::from(v)) as ArrayRef), + // --- float types --- + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Float32Array::from(v)) as ArrayRef), + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(Float64Array::from(v)) as ArrayRef), + // --- boolean --- + proptest::collection::vec(proptest::option::of(any::()), len.clone()) + .prop_map(|v| Arc::new(BooleanArray::from(v)) as ArrayRef), + // --- string types --- + proptest::collection::vec(proptest::option::of(any::()), len.clone()).prop_map( + |v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(StringArray::from(refs)) as ArrayRef + } + ), + proptest::collection::vec(proptest::option::of(any::()), len.clone()).prop_map( + |v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(LargeStringArray::from(refs)) as ArrayRef + } + ), + proptest::collection::vec(proptest::option::of(any::()), len.clone()).prop_map( + |v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(StringViewArray::from(refs)) as ArrayRef + } + ), + // --- binary types --- + proptest::collection::vec( + proptest::option::of(proptest::collection::vec(any::(), 0..50)), + len.clone(), + ) + .prop_map(|v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(BinaryArray::from(refs)) as ArrayRef + }), + proptest::collection::vec( + proptest::option::of(proptest::collection::vec(any::(), 0..50)), + len.clone(), + ) + .prop_map(|v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(LargeBinaryArray::from(refs)) as ArrayRef + }), + proptest::collection::vec( + proptest::option::of(proptest::collection::vec(any::(), 0..50)), + len, + ) + .prop_map(|v| { + let refs: Vec> = v.iter().map(|o| o.as_deref()).collect(); + Arc::new(BinaryViewArray::from(refs)) as ArrayRef + }), + ] + .boxed() + } + + proptest::proptest! { + #[test] + fn sorted_array_produces_sorted_scalars(array in arbitrary_array()) { + let sorted = sort( + &array, + Some(SortOptions { descending: false, nulls_first: true }), + ) + .unwrap(); + + let scalars: Vec = (0..sorted.len()) + .map(|i| ArrowScalar::try_new(&sorted, i).unwrap()) + .collect(); + + for i in 1..scalars.len() { + prop_assert!( + scalars[i - 1] <= scalars[i], + "scalar[{}] ({:?}) should be <= scalar[{}] ({:?})", + i - 1, scalars[i - 1], i, scalars[i], + ); + } + } + } +} diff --git a/rust/arrow-scalar/src/serde.rs b/rust/arrow-scalar/src/serde.rs new file mode 100644 index 00000000000..7a458d13887 --- /dev/null +++ b/rust/arrow-scalar/src/serde.rs @@ -0,0 +1,558 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Binary serialization for [`ArrowScalar`]. +//! +//! Default format (with type prefix): +//! ```text +//! | varint: format_string_len | raw: format_string_bytes | +//! | varint: null_flag (0 = non-null, 1 = null) | +//! | varint: num_buffers | (only if non-null) +//! | varint: buffer_0_len | ... | varint: buffer_{n-1}_len | (only if non-null) +//! | raw: buffer_0 bytes | ... | raw: buffer_{n-1} bytes | (only if non-null) +//! ``` +//! +//! The format string uses the +//! [Arrow C Data Interface](https://arrow.apache.org/docs/format/CDataInterface.html#data-type-description-format-strings) +//! encoding. Use [`EncodeOptions`] / [`DecodeOptions`] to omit the type prefix +//! when the caller already knows the data type. + +use std::borrow::Cow; +use std::sync::Arc; + +use arrow_array::make_array; +use arrow_buffer::Buffer; +use arrow_data::ArrayDataBuilder; +use arrow_schema::{ArrowError, DataType, IntervalUnit, TimeUnit}; + +use crate::ArrowScalar; + +type Result = std::result::Result; + +/// Options for [`ArrowScalar::encode_with_options`]. +pub struct EncodeOptions { + /// When `true` (the default), the Arrow C Data Interface format string + /// for the scalar's data type is prepended as a varint-length-prefixed + /// UTF-8 string. Set to `false` to omit the type prefix (the caller + /// must then supply the `DataType` at decode time). + pub include_data_type: bool, +} + +impl Default for EncodeOptions { + fn default() -> Self { + Self { + include_data_type: true, + } + } +} + +/// Options for [`ArrowScalar::decode_with_options`]. +#[derive(Default)] +pub struct DecodeOptions<'a> { + /// When `Some`, the data type is taken from this value and the encoded + /// bytes are assumed to contain no type prefix. When `None` (the + /// default), the data type is read from the encoded format-string prefix. + pub data_type: Option<&'a DataType>, +} + +/// Encode a `u64` as a variable-length integer (LEB128). +/// +/// Values below 128 use a single byte; the maximum encoding is 10 bytes. +pub fn encode_varint(out: &mut Vec, mut value: u64) { + loop { + let byte = (value & 0x7F) as u8; + value >>= 7; + if value == 0 { + out.push(byte); + return; + } + out.push(byte | 0x80); + } +} + +/// Decode a variable-length integer (LEB128) from `buf` at the given `offset`. +/// +/// On success, `offset` is advanced past the consumed bytes. +pub fn decode_varint(buf: &[u8], offset: &mut usize) -> Result { + let mut result: u64 = 0; + let mut shift = 0u32; + loop { + if *offset >= buf.len() { + return Err(ArrowError::InvalidArgumentError( + "Invalid varint: unexpected EOF".to_string(), + )); + } + let byte = buf[*offset]; + *offset += 1; + + result |= u64::from(byte & 0x7F) << shift; + if byte & 0x80 == 0 { + return Ok(result); + } + shift += 7; + if shift >= 64 { + return Err(ArrowError::InvalidArgumentError( + "Invalid varint: too many bytes".to_string(), + )); + } + } +} + +/// Convert a [`DataType`] to its Arrow C Data Interface format string. +/// +/// Only non-nested types are supported (nested types are already rejected by +/// [`ArrowScalar::encode`]). +fn data_type_to_format_string(dtype: &DataType) -> Result> { + match dtype { + DataType::Null => Ok("n".into()), + DataType::Boolean => Ok("b".into()), + DataType::Int8 => Ok("c".into()), + DataType::UInt8 => Ok("C".into()), + DataType::Int16 => Ok("s".into()), + DataType::UInt16 => Ok("S".into()), + DataType::Int32 => Ok("i".into()), + DataType::UInt32 => Ok("I".into()), + DataType::Int64 => Ok("l".into()), + DataType::UInt64 => Ok("L".into()), + DataType::Float16 => Ok("e".into()), + DataType::Float32 => Ok("f".into()), + DataType::Float64 => Ok("g".into()), + DataType::Binary => Ok("z".into()), + DataType::LargeBinary => Ok("Z".into()), + DataType::Utf8 => Ok("u".into()), + DataType::LargeUtf8 => Ok("U".into()), + DataType::BinaryView => Ok("vz".into()), + DataType::Utf8View => Ok("vu".into()), + DataType::FixedSizeBinary(n) => Ok(Cow::Owned(format!("w:{n}"))), + DataType::Decimal32(p, s) => Ok(Cow::Owned(format!("d:{p},{s},32"))), + DataType::Decimal64(p, s) => Ok(Cow::Owned(format!("d:{p},{s},64"))), + DataType::Decimal128(p, s) => Ok(Cow::Owned(format!("d:{p},{s}"))), + DataType::Decimal256(p, s) => Ok(Cow::Owned(format!("d:{p},{s},256"))), + DataType::Date32 => Ok("tdD".into()), + DataType::Date64 => Ok("tdm".into()), + DataType::Time32(TimeUnit::Second) => Ok("tts".into()), + DataType::Time32(TimeUnit::Millisecond) => Ok("ttm".into()), + DataType::Time64(TimeUnit::Microsecond) => Ok("ttu".into()), + DataType::Time64(TimeUnit::Nanosecond) => Ok("ttn".into()), + DataType::Timestamp(TimeUnit::Second, None) => Ok("tss:".into()), + DataType::Timestamp(TimeUnit::Millisecond, None) => Ok("tsm:".into()), + DataType::Timestamp(TimeUnit::Microsecond, None) => Ok("tsu:".into()), + DataType::Timestamp(TimeUnit::Nanosecond, None) => Ok("tsn:".into()), + DataType::Timestamp(TimeUnit::Second, Some(tz)) => Ok(Cow::Owned(format!("tss:{tz}"))), + DataType::Timestamp(TimeUnit::Millisecond, Some(tz)) => Ok(Cow::Owned(format!("tsm:{tz}"))), + DataType::Timestamp(TimeUnit::Microsecond, Some(tz)) => Ok(Cow::Owned(format!("tsu:{tz}"))), + DataType::Timestamp(TimeUnit::Nanosecond, Some(tz)) => Ok(Cow::Owned(format!("tsn:{tz}"))), + DataType::Duration(TimeUnit::Second) => Ok("tDs".into()), + DataType::Duration(TimeUnit::Millisecond) => Ok("tDm".into()), + DataType::Duration(TimeUnit::Microsecond) => Ok("tDu".into()), + DataType::Duration(TimeUnit::Nanosecond) => Ok("tDn".into()), + DataType::Interval(IntervalUnit::YearMonth) => Ok("tiM".into()), + DataType::Interval(IntervalUnit::DayTime) => Ok("tiD".into()), + DataType::Interval(IntervalUnit::MonthDayNano) => Ok("tin".into()), + other => Err(ArrowError::InvalidArgumentError(format!( + "Cannot encode data type as format string: {other:?}" + ))), + } +} + +/// Parse an Arrow C Data Interface format string back to a [`DataType`]. +/// +/// Only non-nested types are supported. +fn format_string_to_data_type(fmt: &str) -> Result { + match fmt { + "n" => Ok(DataType::Null), + "b" => Ok(DataType::Boolean), + "c" => Ok(DataType::Int8), + "C" => Ok(DataType::UInt8), + "s" => Ok(DataType::Int16), + "S" => Ok(DataType::UInt16), + "i" => Ok(DataType::Int32), + "I" => Ok(DataType::UInt32), + "l" => Ok(DataType::Int64), + "L" => Ok(DataType::UInt64), + "e" => Ok(DataType::Float16), + "f" => Ok(DataType::Float32), + "g" => Ok(DataType::Float64), + "z" => Ok(DataType::Binary), + "Z" => Ok(DataType::LargeBinary), + "u" => Ok(DataType::Utf8), + "U" => Ok(DataType::LargeUtf8), + "vz" => Ok(DataType::BinaryView), + "vu" => Ok(DataType::Utf8View), + "tdD" => Ok(DataType::Date32), + "tdm" => Ok(DataType::Date64), + "tts" => Ok(DataType::Time32(TimeUnit::Second)), + "ttm" => Ok(DataType::Time32(TimeUnit::Millisecond)), + "ttu" => Ok(DataType::Time64(TimeUnit::Microsecond)), + "ttn" => Ok(DataType::Time64(TimeUnit::Nanosecond)), + "tDs" => Ok(DataType::Duration(TimeUnit::Second)), + "tDm" => Ok(DataType::Duration(TimeUnit::Millisecond)), + "tDu" => Ok(DataType::Duration(TimeUnit::Microsecond)), + "tDn" => Ok(DataType::Duration(TimeUnit::Nanosecond)), + "tiM" => Ok(DataType::Interval(IntervalUnit::YearMonth)), + "tiD" => Ok(DataType::Interval(IntervalUnit::DayTime)), + "tin" => Ok(DataType::Interval(IntervalUnit::MonthDayNano)), + other => { + let parts: Vec<&str> = other.splitn(2, ':').collect(); + match parts.as_slice() { + ["w", num_bytes] => { + let n = num_bytes.parse::().map_err(|_| { + ArrowError::InvalidArgumentError( + "FixedSizeBinary requires an integer byte count".to_string(), + ) + })?; + Ok(DataType::FixedSizeBinary(n)) + } + ["d", extra] => { + let dec_parts: Vec<&str> = extra.splitn(3, ',').collect(); + match dec_parts.as_slice() { + [precision, scale] => { + let p = precision.parse::().map_err(|_| { + ArrowError::InvalidArgumentError( + "Decimal requires an integer precision".to_string(), + ) + })?; + let s = scale.parse::().map_err(|_| { + ArrowError::InvalidArgumentError( + "Decimal requires an integer scale".to_string(), + ) + })?; + Ok(DataType::Decimal128(p, s)) + } + [precision, scale, bits] => { + let p = precision.parse::().map_err(|_| { + ArrowError::InvalidArgumentError( + "Decimal requires an integer precision".to_string(), + ) + })?; + let s = scale.parse::().map_err(|_| { + ArrowError::InvalidArgumentError( + "Decimal requires an integer scale".to_string(), + ) + })?; + match *bits { + "32" => Ok(DataType::Decimal32(p, s)), + "64" => Ok(DataType::Decimal64(p, s)), + "128" => Ok(DataType::Decimal128(p, s)), + "256" => Ok(DataType::Decimal256(p, s)), + _ => Err(ArrowError::InvalidArgumentError(format!( + "Unsupported decimal bit width: {bits}" + ))), + } + } + _ => Err(ArrowError::InvalidArgumentError(format!( + "Invalid decimal format string: d:{extra}" + ))), + } + } + ["tss", ""] => Ok(DataType::Timestamp(TimeUnit::Second, None)), + ["tsm", ""] => Ok(DataType::Timestamp(TimeUnit::Millisecond, None)), + ["tsu", ""] => Ok(DataType::Timestamp(TimeUnit::Microsecond, None)), + ["tsn", ""] => Ok(DataType::Timestamp(TimeUnit::Nanosecond, None)), + ["tss", tz] => Ok(DataType::Timestamp(TimeUnit::Second, Some(Arc::from(*tz)))), + ["tsm", tz] => Ok(DataType::Timestamp( + TimeUnit::Millisecond, + Some(Arc::from(*tz)), + )), + ["tsu", tz] => Ok(DataType::Timestamp( + TimeUnit::Microsecond, + Some(Arc::from(*tz)), + )), + ["tsn", tz] => Ok(DataType::Timestamp( + TimeUnit::Nanosecond, + Some(Arc::from(*tz)), + )), + _ => Err(ArrowError::InvalidArgumentError(format!( + "Unsupported format string: {other:?}" + ))), + } + } + } +} + +impl ArrowScalar { + /// Serialize this scalar to a self-describing binary representation. + /// + /// The data type is encoded as a format-string prefix so that + /// [`decode`](Self::decode) can reconstruct the scalar without external + /// type information. Use [`encode_with_options`](Self::encode_with_options) + /// to omit the prefix when the caller already knows the type. + /// + /// Only non-nested scalars are supported. Null scalars are encoded as a + /// null flag with no buffer data. + pub fn encode(&self) -> Result> { + self.encode_with_options(&EncodeOptions::default()) + } + + /// Serialize this scalar with the given [`EncodeOptions`]. + pub fn encode_with_options(&self, options: &EncodeOptions) -> Result> { + let array = self.as_array(); + let data = array.to_data(); + if !data.child_data().is_empty() { + return Err(ArrowError::InvalidArgumentError( + "Cannot encode nested scalar".to_string(), + )); + } + + let mut out = Vec::with_capacity(64); + + if options.include_data_type { + let fmt = data_type_to_format_string(array.data_type())?; + encode_varint(&mut out, fmt.len() as u64); + out.extend_from_slice(fmt.as_bytes()); + } + + if self.is_null() { + encode_varint(&mut out, 1); // null_flag = 1 + } else { + encode_varint(&mut out, 0); // null_flag = 0 + let buffers = data.buffers(); + encode_varint(&mut out, buffers.len() as u64); + for b in buffers { + encode_varint(&mut out, b.len() as u64); + } + for b in buffers { + out.extend_from_slice(b.as_slice()); + } + } + Ok(out) + } + + /// Deserialize a scalar from the self-describing binary representation + /// produced by [`encode`](Self::encode). + /// + /// The data type is read from the format-string prefix in the encoded + /// bytes. Use [`decode_with_options`](Self::decode_with_options) to supply + /// the type externally when the prefix was omitted at encode time. + pub fn decode(buf: &[u8]) -> Result { + Self::decode_with_options(buf, &DecodeOptions::default()) + } + + /// Deserialize a scalar with the given [`DecodeOptions`]. + pub fn decode_with_options(buf: &[u8], options: &DecodeOptions) -> Result { + let mut offset = 0; + + let data_type = match options.data_type { + Some(dt) => dt.clone(), + None => { + let fmt_len = decode_varint(buf, &mut offset)? as usize; + if offset + fmt_len > buf.len() { + return Err(ArrowError::InvalidArgumentError( + "Invalid scalar buffer: unexpected EOF reading format string".to_string(), + )); + } + let fmt_str = std::str::from_utf8(&buf[offset..offset + fmt_len]).map_err(|e| { + ArrowError::InvalidArgumentError(format!( + "Invalid format string: not valid UTF-8: {e}" + )) + })?; + offset += fmt_len; + format_string_to_data_type(fmt_str)? + } + }; + + let null_flag = decode_varint(buf, &mut offset)?; + if null_flag == 1 { + if offset != buf.len() { + return Err(ArrowError::InvalidArgumentError( + "Invalid scalar buffer: trailing bytes after null flag".to_string(), + )); + } + return Self::new_null(&data_type); + } + + let num_buffers = decode_varint(buf, &mut offset)? as usize; + + let mut buffer_lens = Vec::with_capacity(num_buffers); + for _ in 0..num_buffers { + buffer_lens.push(decode_varint(buf, &mut offset)? as usize); + } + + let mut buffers = Vec::with_capacity(num_buffers); + for len in &buffer_lens { + if offset + len > buf.len() { + return Err(ArrowError::InvalidArgumentError( + "Invalid scalar buffer: unexpected EOF".to_string(), + )); + } + buffers.push(Buffer::from_vec(buf[offset..offset + len].to_vec())); + offset += len; + } + + if offset != buf.len() { + return Err(ArrowError::InvalidArgumentError( + "Invalid scalar buffer: trailing bytes".to_string(), + )); + } + + let mut builder = ArrayDataBuilder::new(data_type).len(1).null_count(0); + for b in buffers { + builder = builder.add_buffer(b); + } + let array = make_array(builder.build()?); + Self::try_from_array(array) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arrow_array::{ + ArrayRef, BinaryViewArray, Int32Array, StringArray, StringViewArray, + TimestampMicrosecondArray, + }; + use arrow_schema::DataType; + use rstest::rstest; + + use super::*; + use crate::ArrowScalar; + + #[test] + fn test_varint_roundtrip() { + for value in [0u64, 1, 127, 128, 16383, 16384, u64::MAX] { + let mut buf = Vec::new(); + encode_varint(&mut buf, value); + let mut offset = 0; + let decoded = decode_varint(&buf, &mut offset).unwrap(); + assert_eq!(decoded, value); + assert_eq!(offset, buf.len()); + } + } + + #[test] + fn test_varint_small_is_one_byte() { + let mut buf = Vec::new(); + encode_varint(&mut buf, 42); + assert_eq!(buf.len(), 1); + assert_eq!(buf[0], 42); + } + + #[rstest] + #[case::int32(Arc::new(Int32Array::from(vec![42])) as ArrayRef)] + #[case::string(Arc::new(StringArray::from(vec!["hello"])) as ArrayRef)] + #[case::string_view(Arc::new(StringViewArray::from(vec!["hello world, long string view"])) as ArrayRef)] + #[case::binary_view(Arc::new(BinaryViewArray::from(vec![b"\xDE\xAD\xBE\xEF".as_ref()])) as ArrayRef)] + fn test_encode_decode_roundtrip(#[case] array: ArrayRef) { + let scalar = ArrowScalar::try_from_array(array).unwrap(); + let encoded = scalar.encode().unwrap(); + let decoded = ArrowScalar::decode(&encoded).unwrap(); + assert_eq!(scalar, decoded); + assert_eq!(scalar.data_type(), decoded.data_type()); + } + + #[rstest] + #[case::int32(Arc::new(Int32Array::from(vec![42])) as ArrayRef, DataType::Int32)] + #[case::string(Arc::new(StringArray::from(vec!["hello"])) as ArrayRef, DataType::Utf8)] + #[case::string_view(Arc::new(StringViewArray::from(vec!["hello view"])) as ArrayRef, DataType::Utf8View)] + #[case::binary_view(Arc::new(BinaryViewArray::from(vec![b"\xCA\xFE".as_ref()])) as ArrayRef, DataType::BinaryView)] + fn test_encode_decode_without_type_prefix(#[case] array: ArrayRef, #[case] dt: DataType) { + let scalar = ArrowScalar::try_from_array(array).unwrap(); + let opts = EncodeOptions { + include_data_type: false, + }; + let encoded = scalar.encode_with_options(&opts).unwrap(); + let decode_opts = DecodeOptions { + data_type: Some(&dt), + }; + let decoded = ArrowScalar::decode_with_options(&encoded, &decode_opts).unwrap(); + assert_eq!(scalar, decoded); + } + + #[test] + fn test_null_encode_decode_roundtrip() { + let array: ArrayRef = Arc::new(Int32Array::from(vec![None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + assert!(scalar.is_null()); + let encoded = scalar.encode().unwrap(); + let decoded = ArrowScalar::decode(&encoded).unwrap(); + assert!(decoded.is_null()); + assert_eq!(decoded.data_type(), &DataType::Int32); + assert_eq!(scalar, decoded); + } + + #[test] + fn test_null_encode_decode_without_type_prefix() { + let array: ArrayRef = Arc::new(StringArray::from(vec![Option::<&str>::None])); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + let opts = EncodeOptions { + include_data_type: false, + }; + let encoded = scalar.encode_with_options(&opts).unwrap(); + let decode_opts = DecodeOptions { + data_type: Some(&DataType::Utf8), + }; + let decoded = ArrowScalar::decode_with_options(&encoded, &decode_opts).unwrap(); + assert!(decoded.is_null()); + assert_eq!(decoded.data_type(), &DataType::Utf8); + } + + #[test] + fn test_decode_trailing_bytes() { + let scalar = ArrowScalar::from(42i32); + let mut encoded = scalar.encode().unwrap(); + encoded.push(0xFF); + assert!(ArrowScalar::decode(&encoded).is_err()); + } + + #[test] + fn test_encoded_bytes_contain_format_prefix() { + let scalar = ArrowScalar::from(42i32); + let encoded = scalar.encode().unwrap(); + // First byte is varint length of format string "i" (length 1) + assert_eq!(encoded[0], 1); + // Second byte is the format string itself + assert_eq!(encoded[1], b'i'); + } + + #[rstest] + #[case::null(DataType::Null, "n")] + #[case::boolean(DataType::Boolean, "b")] + #[case::int8(DataType::Int8, "c")] + #[case::uint8(DataType::UInt8, "C")] + #[case::int16(DataType::Int16, "s")] + #[case::uint16(DataType::UInt16, "S")] + #[case::int32(DataType::Int32, "i")] + #[case::uint32(DataType::UInt32, "I")] + #[case::int64(DataType::Int64, "l")] + #[case::uint64(DataType::UInt64, "L")] + #[case::float16(DataType::Float16, "e")] + #[case::float32(DataType::Float32, "f")] + #[case::float64(DataType::Float64, "g")] + #[case::binary(DataType::Binary, "z")] + #[case::large_binary(DataType::LargeBinary, "Z")] + #[case::utf8(DataType::Utf8, "u")] + #[case::large_utf8(DataType::LargeUtf8, "U")] + #[case::binary_view(DataType::BinaryView, "vz")] + #[case::utf8_view(DataType::Utf8View, "vu")] + #[case::date32(DataType::Date32, "tdD")] + #[case::date64(DataType::Date64, "tdm")] + #[case::fixed_size_binary(DataType::FixedSizeBinary(16), "w:16")] + #[case::decimal128(DataType::Decimal128(10, 2), "d:10,2")] + #[case::decimal256(DataType::Decimal256(38, 10), "d:38,10,256")] + #[case::timestamp_us_utc( + DataType::Timestamp(TimeUnit::Microsecond, Some(Arc::from("UTC"))), + "tsu:UTC" + )] + #[case::timestamp_ns_none(DataType::Timestamp(TimeUnit::Nanosecond, None), "tsn:")] + #[case::duration_s(DataType::Duration(TimeUnit::Second), "tDs")] + #[case::interval_ym(DataType::Interval(IntervalUnit::YearMonth), "tiM")] + fn test_format_string_roundtrip(#[case] dt: DataType, #[case] expected_fmt: &str) { + let fmt = data_type_to_format_string(&dt).unwrap(); + assert_eq!(fmt.as_ref(), expected_fmt); + let roundtripped = format_string_to_data_type(&fmt).unwrap(); + assert_eq!(roundtripped, dt); + } + + #[test] + fn test_timestamp_with_tz_roundtrip() { + let array: ArrayRef = Arc::new( + TimestampMicrosecondArray::from(vec![1_000_000]).with_timezone("America/New_York"), + ); + let scalar = ArrowScalar::try_from_array(array).unwrap(); + let encoded = scalar.encode().unwrap(); + let decoded = ArrowScalar::decode(&encoded).unwrap(); + assert_eq!(scalar, decoded); + assert_eq!(scalar.data_type(), decoded.data_type()); + } +} diff --git a/rust/arrow-stats/Cargo.toml b/rust/arrow-stats/Cargo.toml new file mode 100644 index 00000000000..dc5a0616e40 --- /dev/null +++ b/rust/arrow-stats/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "lance-arrow-stats" +version = "58.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 } + +[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..3c4cec35bbe --- /dev/null +++ b/rust/arrow-stats/src/lib.rs @@ -0,0 +1,1318 @@ +// 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), + + // Decimal types + Decimal128(_, _) => find_extrema_primitive!(array, Decimal128Type), + Decimal256(_, _) => find_extrema_primitive!(array, Decimal256Type), + + // 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" + )] + #[case::decimal128( + DataType::Decimal128(10, 2), + Arc::new( + Decimal128Array::from(vec![300_i128, 100, 200]) + .with_precision_and_scale(10, 2) + .unwrap(), + ) as ArrayRef, + "1.00", "3.00" + )] + #[case::decimal256( + DataType::Decimal256(76, 2), + Arc::new( + Decimal256Array::from_iter_values( + [300_i64, 100, 200].into_iter().map(Into::into), + ) + .with_precision_and_scale(76, 2) + .unwrap(), + ) as ArrayRef, + "1.00", "3.00" + )] + 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, + } +} diff --git a/rust/compression/bitpacking/Cargo.toml b/rust/compression/bitpacking/Cargo.toml index f086ede8baa..56db321ac6d 100644 --- a/rust/compression/bitpacking/Cargo.toml +++ b/rust/compression/bitpacking/Cargo.toml @@ -13,8 +13,12 @@ rust-version.workspace = true [dependencies] arrayref = "0.3" +crunchy = "0.2" paste = "1" seq-macro = "0.3" +[dev-dependencies] +bitpacking = { workspace = true, features = ["bitpacker4x"] } + [lints] workspace = true diff --git a/rust/compression/bitpacking/src/bitpacker_internal/bitpacker4x.rs b/rust/compression/bitpacking/src/bitpacker_internal/bitpacker4x.rs new file mode 100644 index 00000000000..ca3c68908ef --- /dev/null +++ b/rust/compression/bitpacking/src/bitpacker_internal/bitpacker4x.rs @@ -0,0 +1,820 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use super::BitPacker; + +use crate::bitpacker_internal::{Available, UnsafeBitPacker}; + +const BLOCK_LEN: usize = 32 * 4; + +#[cfg(target_arch = "x86_64")] +mod sse3 { + + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + + use std::arch::x86_64::__m128i as DataType; + use std::arch::x86_64::_mm_and_si128 as op_and; + use std::arch::x86_64::_mm_lddqu_si128 as load_unaligned; + use std::arch::x86_64::_mm_or_si128 as op_or; + use std::arch::x86_64::_mm_set1_epi32 as set1; + use std::arch::x86_64::_mm_slli_epi32 as left_shift_32; + use std::arch::x86_64::_mm_srli_epi32 as right_shift_32; + use std::arch::x86_64::_mm_storeu_si128 as store_unaligned; + use std::arch::x86_64::{ + _mm_add_epi32, _mm_cvtsi128_si32, _mm_shuffle_epi32, _mm_slli_si128, _mm_srli_si128, + _mm_sub_epi32, + }; + + #[allow(non_snake_case)] + #[inline] + unsafe fn or_collapse_to_u32(accumulator: DataType) -> u32 { + let a__b__c__d_ = accumulator; + let ______a__b_ = _mm_srli_si128(a__b__c__d_, 8); + let a__b__ca_db = op_or(a__b__c__d_, ______a__b_); + let ___a__b__ca = _mm_srli_si128(a__b__ca_db, 4); + let _______cadb = op_or(a__b__ca_db, ___a__b__ca); + _mm_cvtsi128_si32(_______cadb) as u32 + } + + #[target_feature(enable = "sse3")] + unsafe fn compute_delta(curr: DataType, prev: DataType) -> DataType { + _mm_sub_epi32( + curr, + op_or(_mm_slli_si128(curr, 4), _mm_srli_si128(prev, 12)), + ) + } + + #[target_feature(enable = "sse3")] + #[allow(non_snake_case)] + #[inline] + unsafe fn integrate_delta(prev: DataType, delta: DataType) -> DataType { + let offset = _mm_shuffle_epi32(prev, 0xff); + let a__b__c__d_ = delta; + let ______a__b_ = _mm_slli_si128(delta, 8); + let a__b__ca_db = _mm_add_epi32(______a__b_, a__b__c__d_); + let ___a__b__ca = _mm_slli_si128(a__b__ca_db, 4); + let a_ab_abc_abcd: DataType = _mm_add_epi32(___a__b__ca, a__b__ca_db); + _mm_add_epi32(offset, a_ab_abc_abcd) + } + + #[target_feature(enable = "sse3")] + #[inline] + unsafe fn add(left: DataType, right: DataType) -> DataType { + _mm_add_epi32(left, right) + } + + unsafe fn sub(left: DataType, right: DataType) -> DataType { + _mm_sub_epi32(left, right) + } + + declare_bitpacker!(target_feature(enable = "sse3")); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + is_x86_feature_detected!("sse3") + } + } +} + +#[cfg(all(target_arch = "aarch64", target_endian = "little"))] +mod neon { + + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + use std::arch::aarch64::{ + uint32x4_t, vaddq_u32, vandq_u32, vdupq_n_u32, vextq_u32, vgetq_lane_u32, vld1q_u32, + vorrq_u32, vshlq_n_u32, vshrq_n_u32, vst1q_u32, vsubq_u32, + }; + + pub(crate) type DataType = uint32x4_t; + + #[inline] + /// Creates a vector with all elements set to `el`. + unsafe fn set1(el: i32) -> DataType { + vdupq_n_u32(el as u32) + } + + #[inline] + unsafe fn right_shift_32(el: DataType) -> DataType { + const { + assert!(N >= 0); + assert!(N <= 32); + } + + // We unroll here because vshrq_n_u32 only accepts constants from 1 to 32. + match N { + 0 => el, + 1 => vshrq_n_u32::<1>(el), + 2 => vshrq_n_u32::<2>(el), + 3 => vshrq_n_u32::<3>(el), + 4 => vshrq_n_u32::<4>(el), + 5 => vshrq_n_u32::<5>(el), + 6 => vshrq_n_u32::<6>(el), + 7 => vshrq_n_u32::<7>(el), + 8 => vshrq_n_u32::<8>(el), + 9 => vshrq_n_u32::<9>(el), + 10 => vshrq_n_u32::<10>(el), + 11 => vshrq_n_u32::<11>(el), + 12 => vshrq_n_u32::<12>(el), + 13 => vshrq_n_u32::<13>(el), + 14 => vshrq_n_u32::<14>(el), + 15 => vshrq_n_u32::<15>(el), + 16 => vshrq_n_u32::<16>(el), + 17 => vshrq_n_u32::<17>(el), + 18 => vshrq_n_u32::<18>(el), + 19 => vshrq_n_u32::<19>(el), + 20 => vshrq_n_u32::<20>(el), + 21 => vshrq_n_u32::<21>(el), + 22 => vshrq_n_u32::<22>(el), + 23 => vshrq_n_u32::<23>(el), + 24 => vshrq_n_u32::<24>(el), + 25 => vshrq_n_u32::<25>(el), + 26 => vshrq_n_u32::<26>(el), + 27 => vshrq_n_u32::<27>(el), + 28 => vshrq_n_u32::<28>(el), + 29 => vshrq_n_u32::<29>(el), + 30 => vshrq_n_u32::<30>(el), + 31 => vshrq_n_u32::<31>(el), + 32 => vdupq_n_u32(0), + _ => core::hint::unreachable_unchecked(), + } + } + + #[inline] + unsafe fn left_shift_32(el: DataType) -> DataType { + const { + assert!(N >= 0); + assert!(N <= 32); + } + + // We unroll here because vshlq_n_u32 only accepts constants from 0 to 31. + match N { + 0 => el, + 1 => vshlq_n_u32::<1>(el), + 2 => vshlq_n_u32::<2>(el), + 3 => vshlq_n_u32::<3>(el), + 4 => vshlq_n_u32::<4>(el), + 5 => vshlq_n_u32::<5>(el), + 6 => vshlq_n_u32::<6>(el), + 7 => vshlq_n_u32::<7>(el), + 8 => vshlq_n_u32::<8>(el), + 9 => vshlq_n_u32::<9>(el), + 10 => vshlq_n_u32::<10>(el), + 11 => vshlq_n_u32::<11>(el), + 12 => vshlq_n_u32::<12>(el), + 13 => vshlq_n_u32::<13>(el), + 14 => vshlq_n_u32::<14>(el), + 15 => vshlq_n_u32::<15>(el), + 16 => vshlq_n_u32::<16>(el), + 17 => vshlq_n_u32::<17>(el), + 18 => vshlq_n_u32::<18>(el), + 19 => vshlq_n_u32::<19>(el), + 20 => vshlq_n_u32::<20>(el), + 21 => vshlq_n_u32::<21>(el), + 22 => vshlq_n_u32::<22>(el), + 23 => vshlq_n_u32::<23>(el), + 24 => vshlq_n_u32::<24>(el), + 25 => vshlq_n_u32::<25>(el), + 26 => vshlq_n_u32::<26>(el), + 27 => vshlq_n_u32::<27>(el), + 28 => vshlq_n_u32::<28>(el), + 29 => vshlq_n_u32::<29>(el), + 30 => vshlq_n_u32::<30>(el), + 31 => vshlq_n_u32::<31>(el), + 32 => vdupq_n_u32(0), + _ => core::hint::unreachable_unchecked(), + } + } + + use vorrq_u32 as op_or; + + #[inline] + unsafe fn op_and(left: DataType, right: DataType) -> DataType { + vandq_u32(left, right) + } + + #[inline] + unsafe fn load_unaligned(addr: *const DataType) -> DataType { + vld1q_u32(addr.cast::()) + } + + #[inline] + unsafe fn store_unaligned(addr: *mut DataType, data: DataType) { + vst1q_u32(addr.cast::(), data); + } + + #[inline] + /// Collapses the vector by performing a bitwise OR across all lanes + unsafe fn or_collapse_to_u32(acc: DataType) -> u32 { + vgetq_lane_u32(acc, 0) + | vgetq_lane_u32(acc, 1) + | vgetq_lane_u32(acc, 2) + | vgetq_lane_u32(acc, 3) + } + + unsafe fn compute_delta(curr: DataType, prev: DataType) -> DataType { + // Build a vector with [prev[3], curr[0], curr[1], curr[2]] + let prev_shifted = vextq_u32(prev, curr, 3); + vsubq_u32(curr, prev_shifted) + } + + #[allow(non_snake_case)] + #[inline] + unsafe fn integrate_delta(prev: DataType, delta: DataType) -> DataType { + let base = vdupq_n_u32(vgetq_lane_u32(prev, 3)); + let zero = vdupq_n_u32(0); + let a__b__c__d_ = delta; + let ______a__b_ = vextq_u32(zero, a__b__c__d_, 2); + let a__b__ca_db = vaddq_u32(______a__b_, a__b__c__d_); + let ___a__b__ca = vextq_u32(zero, a__b__ca_db, 3); + let a_ab_abc_abcd = vaddq_u32(___a__b__ca, a__b__ca_db); + vaddq_u32(base, a_ab_abc_abcd) + } + + #[inline] + unsafe fn add(left: DataType, right: DataType) -> DataType { + vaddq_u32(left, right) + } + + #[inline] + unsafe fn sub(left: DataType, right: DataType) -> DataType { + vsubq_u32(left, right) + } + + declare_bitpacker!(target_feature(enable = "neon")); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + std::arch::is_aarch64_feature_detected!("neon") + } + } +} + +mod scalar { + + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + use std::ptr; + + pub(crate) type DataType = [u32; 4]; + + pub(crate) fn set1(el: i32) -> DataType { + [el as u32; 4] + } + + pub(crate) fn right_shift_32(el: DataType) -> DataType { + [el[0] >> N, el[1] >> N, el[2] >> N, el[3] >> N] + } + + pub(crate) fn left_shift_32(el: DataType) -> DataType { + [el[0] << N, el[1] << N, el[2] << N, el[3] << N] + } + + pub(crate) fn op_or(left: DataType, right: DataType) -> DataType { + [ + left[0] | right[0], + left[1] | right[1], + left[2] | right[2], + left[3] | right[3], + ] + } + + pub(crate) fn op_and(left: DataType, right: DataType) -> DataType { + [ + left[0] & right[0], + left[1] & right[1], + left[2] & right[2], + left[3] & right[3], + ] + } + + pub(crate) unsafe fn load_unaligned(addr: *const DataType) -> DataType { + ptr::read_unaligned(addr) + } + + pub(crate) unsafe fn store_unaligned(addr: *mut DataType, data: DataType) { + ptr::write_unaligned(addr, data); + } + + pub(crate) fn or_collapse_to_u32(accumulator: DataType) -> u32 { + (accumulator[0] | accumulator[1]) | (accumulator[2] | accumulator[3]) + } + + fn compute_delta(curr: DataType, prev: DataType) -> DataType { + [ + curr[0].wrapping_sub(prev[3]), + curr[1].wrapping_sub(curr[0]), + curr[2].wrapping_sub(curr[1]), + curr[3].wrapping_sub(curr[2]), + ] + } + + fn integrate_delta(offset: DataType, delta: DataType) -> DataType { + let el0 = offset[3].wrapping_add(delta[0]); + let el1 = el0.wrapping_add(delta[1]); + let el2 = el1.wrapping_add(delta[2]); + let el3 = el2.wrapping_add(delta[3]); + [el0, el1, el2, el3] + } + + pub(crate) fn add(left: DataType, right: DataType) -> DataType { + [ + left[0].wrapping_add(right[0]), + left[1].wrapping_add(right[1]), + left[2].wrapping_add(right[2]), + left[3].wrapping_add(right[3]), + ] + } + + pub(crate) fn sub(left: DataType, right: DataType) -> DataType { + [ + left[0].wrapping_sub(right[0]), + left[1].wrapping_sub(right[1]), + left[2].wrapping_sub(right[2]), + left[3].wrapping_sub(right[3]), + ] + } + + // The `allow(unused)` is here to put an attribute that has no effect. + // + // For other bitpacker, we enable specific CPU instruction set, but for the + // scalar bitpacker none is required. + declare_bitpacker!(allow(unused)); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + true + } + } +} + +#[derive(Clone, Copy)] +enum InstructionSet { + #[cfg(target_arch = "x86_64")] + SSE3, + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + NEON, + Scalar, +} + +/// `BitPacker4x` packs integers in groups of 4. This gives an opportunity +/// to leverage `SSE3` instructions to encode and decode the stream. +/// +/// One block must contain `128 integers`. +#[derive(Clone, Copy)] +pub struct BitPacker4x(InstructionSet); + +impl BitPacker4x { + #[cfg(target_arch = "x86_64")] + pub(crate) fn new_sse() -> Option { + sse3::UnsafeBitPackerImpl::available().then_some(BitPacker4x(InstructionSet::SSE3)) + } + + #[cfg(not(target_arch = "x86_64"))] + pub(crate) fn new_sse() -> Option { + None + } + + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + pub(crate) fn new_neon() -> Option { + neon::UnsafeBitPackerImpl::available().then_some(BitPacker4x(InstructionSet::NEON)) + } + + #[cfg(not(all(target_arch = "aarch64", target_endian = "little")))] + pub(crate) fn new_neon() -> Option { + None + } + + pub(crate) fn new_scalar() -> Self { + BitPacker4x(InstructionSet::Scalar) + } +} + +impl BitPacker for BitPacker4x { + const BLOCK_LEN: usize = BLOCK_LEN; + + fn new() -> Self { + Self::new_sse() + .or_else(Self::new_neon) + .unwrap_or_else(Self::new_scalar) + } + + fn compress(&self, decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => { + sse3::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + } + } + } + + fn compress_sorted( + &self, + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => sse3::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + } + } + } + + fn compress_strictly_sorted( + &self, + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => sse3::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + } + } + } + + fn decompress(&self, compressed: &[u8], decompressed: &mut [u32], num_bits: u8) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => { + sse3::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + } + } + } + + fn decompress_strictly_sorted( + &self, + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => sse3::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + } + } + } + + fn decompress_sorted( + &self, + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => sse3::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + } + } + } + + fn num_bits(&self, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => sse3::UnsafeBitPackerImpl::num_bits(decompressed), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::num_bits(decompressed), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::num_bits(decompressed), + } + } + } + + fn num_bits_sorted(&self, initial: u32, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => { + sse3::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + } + } + } + + fn num_bits_strictly_sorted(&self, initial: Option, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::SSE3 => { + sse3::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + } + } + } +} + +#[cfg(any( + target_arch = "x86_64", + all(target_arch = "aarch64", target_endian = "little") +))] +#[cfg(any())] +mod tests { + use super::BLOCK_LEN; + use super::scalar; + use crate::bitpacker_internal::Available; + use crate::tests::test_util_compatible; + use crate::{BitPacker, BitPacker4x}; + + #[cfg(target_arch = "x86_64")] + #[test] + fn test_compatible_sse3() { + use super::sse3; + if sse3::UnsafeBitPackerImpl::available() { + test_util_compatible::( + BLOCK_LEN, + ); + } + } + + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + #[test] + fn test_compatible_neon() { + use super::neon; + if neon::UnsafeBitPackerImpl::available() { + test_util_compatible::( + BLOCK_LEN, + ); + } + } + + #[test] + fn test_delta_bit_width_32() { + let values = vec![i32::max_value() as u32 + 1; BitPacker4x::BLOCK_LEN]; + let bit_packer = BitPacker4x::new(); + let bit_width = bit_packer.num_bits_sorted(0, &values); + assert_eq!(bit_width, 32); + + let mut block = vec![0u8; BitPacker4x::compressed_block_size(bit_width)]; + bit_packer.compress_sorted(0, &values, &mut block, bit_width); + + let mut decoded_values = vec![0x10101010; BitPacker4x::BLOCK_LEN]; + bit_packer.decompress_sorted(0, &block, &mut decoded_values, bit_width); + + assert_eq!(values, decoded_values); + } + + #[test] + fn test_bit_width_32() { + let mut values = vec![i32::max_value() as u32 + 1; BitPacker4x::BLOCK_LEN]; + values[0] = 0; + let bit_packer = BitPacker4x::new(); + let bit_width = bit_packer.num_bits(&values); + assert_eq!(bit_width, 32); + + let mut block = vec![0u8; BitPacker4x::compressed_block_size(bit_width)]; + bit_packer.compress(&values, &mut block, bit_width); + + let mut decoded_values = vec![0x10101010; BitPacker4x::BLOCK_LEN]; + bit_packer.decompress(&block, &mut decoded_values, bit_width); + + assert_eq!(values, decoded_values); + } +} + +#[cfg(test)] +mod tests { + use super::{BLOCK_LEN, BitPacker4x}; + use crate::bitpacker_internal::BitPacker; + use bitpacking::{BitPacker as ExternalBitPacker, BitPacker4x as ExternalBitPacker4x}; + + fn mask_for_width(width: u8) -> u32 { + match width { + 0 => 0, + 32 => u32::MAX, + _ => (1u32 << width) - 1, + } + } + + fn raw_values(width: u8) -> Vec { + let mask = mask_for_width(width); + (0..BLOCK_LEN) + .map(|idx| ((idx * 17 + 3) as u32) & mask) + .collect() + } + + fn sorted_values(width: u8) -> (u32, Vec) { + if width == 0 { + return (11, vec![11; BLOCK_LEN]); + } + if width == 32 { + return (0, vec![u32::MAX; BLOCK_LEN]); + } + + let mask = mask_for_width(width).min(127); + let initial = 11u32; + let mut current = initial; + let values = (0..BLOCK_LEN) + .map(|idx| { + current += (idx as u32 * 7 + 1) & mask; + current + }) + .collect(); + (initial, values) + } + + fn strictly_sorted_values(width: u8) -> (Option, Vec) { + let mask = mask_for_width(width).min(127); + let mut current = 0u32; + let values = (0..BLOCK_LEN) + .map(|idx| { + if idx == 0 { + current = 0; + } else { + current += 1 + ((idx as u32 * 5) & mask); + } + current + }) + .collect(); + (None, values) + } + + #[test] + fn scalar_backend_matches_external_bitpacker4x() { + let scalar = BitPacker4x::new_scalar(); + let external = ExternalBitPacker4x::new(); + + for width in 0..=32 { + let values = raw_values(width); + assert_eq!(scalar.num_bits(&values), external.num_bits(&values)); + + let mut actual = vec![0u8; BitPacker4x::compressed_block_size(width)]; + let actual_len = scalar.compress(&values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker4x::compressed_block_size(width)]; + let expected_len = external.compress(&values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "raw width {width}"); + + let mut decoded = vec![0u32; BLOCK_LEN]; + assert_eq!(scalar.decompress(&actual, &mut decoded, width), actual_len); + assert_eq!(decoded, values); + + let (initial, values) = sorted_values(width); + assert_eq!( + scalar.num_bits_sorted(initial, &values), + external.num_bits_sorted(initial, &values) + ); + + let mut actual = vec![0u8; BitPacker4x::compressed_block_size(width)]; + let actual_len = scalar.compress_sorted(initial, &values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker4x::compressed_block_size(width)]; + let expected_len = external.compress_sorted(initial, &values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "sorted width {width}"); + + let mut decoded = vec![0u32; BLOCK_LEN]; + assert_eq!( + scalar.decompress_sorted(initial, &actual, &mut decoded, width), + actual_len + ); + assert_eq!(decoded, values); + } + } + + #[test] + fn scalar_backend_matches_external_strictly_sorted_bitpacker4x() { + let scalar = BitPacker4x::new_scalar(); + let external = ExternalBitPacker4x::new(); + + for width in 0..=16 { + let (initial, values) = strictly_sorted_values(width); + let num_bits = external.num_bits_strictly_sorted(initial, &values); + assert_eq!(scalar.num_bits_strictly_sorted(initial, &values), num_bits); + + let mut actual = vec![0u8; BitPacker4x::compressed_block_size(num_bits)]; + let actual_len = + scalar.compress_strictly_sorted(initial, &values, &mut actual, num_bits); + + let mut expected = vec![0u8; ExternalBitPacker4x::compressed_block_size(num_bits)]; + let expected_len = + external.compress_strictly_sorted(initial, &values, &mut expected, num_bits); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "strict width {width}"); + + let mut decoded = vec![0u32; BLOCK_LEN]; + assert_eq!( + scalar.decompress_strictly_sorted(initial, &actual, &mut decoded, num_bits), + actual_len + ); + assert_eq!(decoded, values); + } + } +} diff --git a/rust/compression/bitpacking/src/bitpacker_internal/bitpacker8x.rs b/rust/compression/bitpacking/src/bitpacker_internal/bitpacker8x.rs new file mode 100644 index 00000000000..b17edacabb2 --- /dev/null +++ b/rust/compression/bitpacking/src/bitpacker_internal/bitpacker8x.rs @@ -0,0 +1,872 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use super::BitPacker; + +use crate::bitpacker_internal::{Available, UnsafeBitPacker}; + +const BLOCK_LEN: usize = 32 * 8; + +#[cfg(target_arch = "x86_64")] +mod avx2 { + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + + use std::arch::x86_64::__m256i as DataType; + use std::arch::x86_64::_mm256_and_si256 as op_and; + use std::arch::x86_64::_mm256_lddqu_si256 as load_unaligned; + use std::arch::x86_64::_mm256_or_si256 as op_or; + use std::arch::x86_64::_mm256_set1_epi32 as set1; + use std::arch::x86_64::_mm256_slli_epi32 as left_shift_32; + use std::arch::x86_64::_mm256_srli_epi32 as right_shift_32; + use std::arch::x86_64::_mm256_storeu_si256 as store_unaligned; + + use std::arch::x86_64::{ + _mm256_add_epi32, _mm256_extract_epi32, _mm256_permute2f128_si256, _mm256_shuffle_epi32, + _mm256_slli_si256, _mm256_srli_si256, _mm256_sub_epi32, + }; + + #[allow(non_snake_case)] + unsafe fn or_collapse_to_u32(accumulator: DataType) -> u32 { + let a__b__c__d__e__f__g__h_ = accumulator; + let ______a__b________e__f = _mm256_srli_si256(a__b__c__d__e__f__g__h_, 8); + let a__b__ca_db_e__f__ge_hf = op_or(a__b__c__d__e__f__g__h_, ______a__b________e__f); + let ___a__b__ca____e__f__ge = _mm256_srli_si256(a__b__ca_db_e__f__ge_hf, 4); + let _________cadb______gehf = op_or(a__b__ca_db_e__f__ge_hf, ___a__b__ca____e__f__ge); + let cadb = _mm256_extract_epi32(_________cadb______gehf, 0); + let gehf = _mm256_extract_epi32(_________cadb______gehf, 4); + (cadb | gehf) as u32 + } + + unsafe fn compute_delta(curr: DataType, prev: DataType) -> DataType { + let left_shift = _mm256_slli_si256(curr, 4); + let curr_shift = _mm256_srli_si256(curr, 12); + let curr_right_only = _mm256_permute2f128_si256(curr_shift, curr_shift, 8); + let prev_shift = _mm256_srli_si256(prev, 12); + let sub_left = _mm256_permute2f128_si256(prev_shift, prev_shift, 3 | (8 << 4)); + let diff = op_or(left_shift, op_or(curr_right_only, sub_left)); + _mm256_sub_epi32(curr, diff) + } + + #[allow(non_snake_case)] + unsafe fn integrate_delta(prev: DataType, delta: DataType) -> DataType { + let offset_repeat = _mm256_shuffle_epi32(prev, 0xff); + let offset = _mm256_permute2f128_si256(offset_repeat, offset_repeat, 3 | (8 << 4)); + let a__b__c__d__e__f__g__h__ = delta; + let ______a__b________e__f__ = _mm256_slli_si256(delta, 8); + let a__b__ca_db_e__f__ge_fh_ = + _mm256_add_epi32(a__b__c__d__e__f__g__h__, ______a__b________e__f__); + let ___a__b__ca____e__f__ge_ = _mm256_slli_si256(a__b__ca_db_e__f__ge_fh_, 4); + let halved_prefix_sum = + _mm256_add_epi32(___a__b__ca____e__f__ge_, a__b__ca_db_e__f__ge_fh_); + let offsetted_halved_prefix_sum = _mm256_add_epi32(halved_prefix_sum, offset); + let select_last_low = _mm256_shuffle_epi32(offsetted_halved_prefix_sum, 0xff); + let high_offset = _mm256_permute2f128_si256(select_last_low, select_last_low, 8); + _mm256_add_epi32(high_offset, offsetted_halved_prefix_sum) + } + + unsafe fn add(left: DataType, right: DataType) -> DataType { + _mm256_add_epi32(left, right) + } + + unsafe fn sub(left: DataType, right: DataType) -> DataType { + _mm256_sub_epi32(left, right) + } + + declare_bitpacker!(target_feature(enable = "avx2")); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + is_x86_feature_detected!("avx2") + } + } +} + +#[cfg(all(target_arch = "aarch64", target_endian = "little"))] +mod neon { + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + use std::arch::aarch64::{ + uint32x4_t, vaddq_u32, vandq_u32, vdupq_n_u32, vextq_u32, vgetq_lane_u32, vld1q_u32, + vorrq_u32, vshlq_n_u32, vshrq_n_u32, vst1q_u32, vsubq_u32, + }; + + pub(crate) type DataType = [uint32x4_t; 2]; + + #[inline] + unsafe fn set1(el: i32) -> DataType { + let lanes = vdupq_n_u32(el as u32); + [lanes, lanes] + } + + #[inline] + unsafe fn right_shift_32(el: DataType) -> DataType { + const { + assert!(N >= 0); + assert!(N <= 32); + } + + match N { + 0 => el, + 1 => [vshrq_n_u32::<1>(el[0]), vshrq_n_u32::<1>(el[1])], + 2 => [vshrq_n_u32::<2>(el[0]), vshrq_n_u32::<2>(el[1])], + 3 => [vshrq_n_u32::<3>(el[0]), vshrq_n_u32::<3>(el[1])], + 4 => [vshrq_n_u32::<4>(el[0]), vshrq_n_u32::<4>(el[1])], + 5 => [vshrq_n_u32::<5>(el[0]), vshrq_n_u32::<5>(el[1])], + 6 => [vshrq_n_u32::<6>(el[0]), vshrq_n_u32::<6>(el[1])], + 7 => [vshrq_n_u32::<7>(el[0]), vshrq_n_u32::<7>(el[1])], + 8 => [vshrq_n_u32::<8>(el[0]), vshrq_n_u32::<8>(el[1])], + 9 => [vshrq_n_u32::<9>(el[0]), vshrq_n_u32::<9>(el[1])], + 10 => [vshrq_n_u32::<10>(el[0]), vshrq_n_u32::<10>(el[1])], + 11 => [vshrq_n_u32::<11>(el[0]), vshrq_n_u32::<11>(el[1])], + 12 => [vshrq_n_u32::<12>(el[0]), vshrq_n_u32::<12>(el[1])], + 13 => [vshrq_n_u32::<13>(el[0]), vshrq_n_u32::<13>(el[1])], + 14 => [vshrq_n_u32::<14>(el[0]), vshrq_n_u32::<14>(el[1])], + 15 => [vshrq_n_u32::<15>(el[0]), vshrq_n_u32::<15>(el[1])], + 16 => [vshrq_n_u32::<16>(el[0]), vshrq_n_u32::<16>(el[1])], + 17 => [vshrq_n_u32::<17>(el[0]), vshrq_n_u32::<17>(el[1])], + 18 => [vshrq_n_u32::<18>(el[0]), vshrq_n_u32::<18>(el[1])], + 19 => [vshrq_n_u32::<19>(el[0]), vshrq_n_u32::<19>(el[1])], + 20 => [vshrq_n_u32::<20>(el[0]), vshrq_n_u32::<20>(el[1])], + 21 => [vshrq_n_u32::<21>(el[0]), vshrq_n_u32::<21>(el[1])], + 22 => [vshrq_n_u32::<22>(el[0]), vshrq_n_u32::<22>(el[1])], + 23 => [vshrq_n_u32::<23>(el[0]), vshrq_n_u32::<23>(el[1])], + 24 => [vshrq_n_u32::<24>(el[0]), vshrq_n_u32::<24>(el[1])], + 25 => [vshrq_n_u32::<25>(el[0]), vshrq_n_u32::<25>(el[1])], + 26 => [vshrq_n_u32::<26>(el[0]), vshrq_n_u32::<26>(el[1])], + 27 => [vshrq_n_u32::<27>(el[0]), vshrq_n_u32::<27>(el[1])], + 28 => [vshrq_n_u32::<28>(el[0]), vshrq_n_u32::<28>(el[1])], + 29 => [vshrq_n_u32::<29>(el[0]), vshrq_n_u32::<29>(el[1])], + 30 => [vshrq_n_u32::<30>(el[0]), vshrq_n_u32::<30>(el[1])], + 31 => [vshrq_n_u32::<31>(el[0]), vshrq_n_u32::<31>(el[1])], + 32 => set1(0), + _ => core::hint::unreachable_unchecked(), + } + } + + #[inline] + unsafe fn left_shift_32(el: DataType) -> DataType { + const { + assert!(N >= 0); + assert!(N <= 32); + } + + match N { + 0 => el, + 1 => [vshlq_n_u32::<1>(el[0]), vshlq_n_u32::<1>(el[1])], + 2 => [vshlq_n_u32::<2>(el[0]), vshlq_n_u32::<2>(el[1])], + 3 => [vshlq_n_u32::<3>(el[0]), vshlq_n_u32::<3>(el[1])], + 4 => [vshlq_n_u32::<4>(el[0]), vshlq_n_u32::<4>(el[1])], + 5 => [vshlq_n_u32::<5>(el[0]), vshlq_n_u32::<5>(el[1])], + 6 => [vshlq_n_u32::<6>(el[0]), vshlq_n_u32::<6>(el[1])], + 7 => [vshlq_n_u32::<7>(el[0]), vshlq_n_u32::<7>(el[1])], + 8 => [vshlq_n_u32::<8>(el[0]), vshlq_n_u32::<8>(el[1])], + 9 => [vshlq_n_u32::<9>(el[0]), vshlq_n_u32::<9>(el[1])], + 10 => [vshlq_n_u32::<10>(el[0]), vshlq_n_u32::<10>(el[1])], + 11 => [vshlq_n_u32::<11>(el[0]), vshlq_n_u32::<11>(el[1])], + 12 => [vshlq_n_u32::<12>(el[0]), vshlq_n_u32::<12>(el[1])], + 13 => [vshlq_n_u32::<13>(el[0]), vshlq_n_u32::<13>(el[1])], + 14 => [vshlq_n_u32::<14>(el[0]), vshlq_n_u32::<14>(el[1])], + 15 => [vshlq_n_u32::<15>(el[0]), vshlq_n_u32::<15>(el[1])], + 16 => [vshlq_n_u32::<16>(el[0]), vshlq_n_u32::<16>(el[1])], + 17 => [vshlq_n_u32::<17>(el[0]), vshlq_n_u32::<17>(el[1])], + 18 => [vshlq_n_u32::<18>(el[0]), vshlq_n_u32::<18>(el[1])], + 19 => [vshlq_n_u32::<19>(el[0]), vshlq_n_u32::<19>(el[1])], + 20 => [vshlq_n_u32::<20>(el[0]), vshlq_n_u32::<20>(el[1])], + 21 => [vshlq_n_u32::<21>(el[0]), vshlq_n_u32::<21>(el[1])], + 22 => [vshlq_n_u32::<22>(el[0]), vshlq_n_u32::<22>(el[1])], + 23 => [vshlq_n_u32::<23>(el[0]), vshlq_n_u32::<23>(el[1])], + 24 => [vshlq_n_u32::<24>(el[0]), vshlq_n_u32::<24>(el[1])], + 25 => [vshlq_n_u32::<25>(el[0]), vshlq_n_u32::<25>(el[1])], + 26 => [vshlq_n_u32::<26>(el[0]), vshlq_n_u32::<26>(el[1])], + 27 => [vshlq_n_u32::<27>(el[0]), vshlq_n_u32::<27>(el[1])], + 28 => [vshlq_n_u32::<28>(el[0]), vshlq_n_u32::<28>(el[1])], + 29 => [vshlq_n_u32::<29>(el[0]), vshlq_n_u32::<29>(el[1])], + 30 => [vshlq_n_u32::<30>(el[0]), vshlq_n_u32::<30>(el[1])], + 31 => [vshlq_n_u32::<31>(el[0]), vshlq_n_u32::<31>(el[1])], + 32 => set1(0), + _ => core::hint::unreachable_unchecked(), + } + } + + #[inline] + unsafe fn op_or(left: DataType, right: DataType) -> DataType { + [vorrq_u32(left[0], right[0]), vorrq_u32(left[1], right[1])] + } + + #[inline] + unsafe fn op_and(left: DataType, right: DataType) -> DataType { + [vandq_u32(left[0], right[0]), vandq_u32(left[1], right[1])] + } + + #[inline] + unsafe fn load_unaligned(addr: *const DataType) -> DataType { + let ptr = addr.cast::(); + [vld1q_u32(ptr), vld1q_u32(ptr.add(4))] + } + + #[inline] + unsafe fn store_unaligned(addr: *mut DataType, data: DataType) { + let ptr = addr.cast::(); + vst1q_u32(ptr, data[0]); + vst1q_u32(ptr.add(4), data[1]); + } + + #[inline] + unsafe fn or_collapse_to_u32(accumulator: DataType) -> u32 { + vgetq_lane_u32(accumulator[0], 0) + | vgetq_lane_u32(accumulator[0], 1) + | vgetq_lane_u32(accumulator[0], 2) + | vgetq_lane_u32(accumulator[0], 3) + | vgetq_lane_u32(accumulator[1], 0) + | vgetq_lane_u32(accumulator[1], 1) + | vgetq_lane_u32(accumulator[1], 2) + | vgetq_lane_u32(accumulator[1], 3) + } + + #[inline] + unsafe fn compute_delta(curr: DataType, prev: DataType) -> DataType { + [ + vsubq_u32(curr[0], vextq_u32(prev[1], curr[0], 3)), + vsubq_u32(curr[1], vextq_u32(curr[0], curr[1], 3)), + ] + } + + #[allow(non_snake_case)] + #[inline] + unsafe fn integrate_half(base: u32, delta: uint32x4_t) -> uint32x4_t { + let base = vdupq_n_u32(base); + let zero = vdupq_n_u32(0); + let a__b__c__d_ = delta; + let ______a__b_ = vextq_u32(zero, a__b__c__d_, 2); + let a__b__ca_db = vaddq_u32(______a__b_, a__b__c__d_); + let ___a__b__ca = vextq_u32(zero, a__b__ca_db, 3); + let a_ab_abc_abcd = vaddq_u32(___a__b__ca, a__b__ca_db); + vaddq_u32(base, a_ab_abc_abcd) + } + + #[inline] + unsafe fn integrate_delta(prev: DataType, delta: DataType) -> DataType { + let low = integrate_half(vgetq_lane_u32(prev[1], 3), delta[0]); + let high = integrate_half(vgetq_lane_u32(low, 3), delta[1]); + [low, high] + } + + #[inline] + unsafe fn add(left: DataType, right: DataType) -> DataType { + [vaddq_u32(left[0], right[0]), vaddq_u32(left[1], right[1])] + } + + #[inline] + unsafe fn sub(left: DataType, right: DataType) -> DataType { + [vsubq_u32(left[0], right[0]), vsubq_u32(left[1], right[1])] + } + + declare_bitpacker!(target_feature(enable = "neon")); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + std::arch::is_aarch64_feature_detected!("neon") + } + } +} + +mod scalar { + use super::BLOCK_LEN; + use crate::bitpacker_internal::Available; + use std::ptr; + + pub(crate) type DataType = [u32; 8]; + + pub(crate) fn set1(el: i32) -> DataType { + [el as u32; 8] + } + + pub(crate) fn right_shift_32(el: DataType) -> DataType { + [ + el[0] >> N, + el[1] >> N, + el[2] >> N, + el[3] >> N, + el[4] >> N, + el[5] >> N, + el[6] >> N, + el[7] >> N, + ] + } + + pub(crate) fn left_shift_32(el: DataType) -> DataType { + [ + el[0] << N, + el[1] << N, + el[2] << N, + el[3] << N, + el[4] << N, + el[5] << N, + el[6] << N, + el[7] << N, + ] + } + + pub(crate) fn op_or(left: DataType, right: DataType) -> DataType { + [ + left[0] | right[0], + left[1] | right[1], + left[2] | right[2], + left[3] | right[3], + left[4] | right[4], + left[5] | right[5], + left[6] | right[6], + left[7] | right[7], + ] + } + + pub(crate) fn op_and(left: DataType, right: DataType) -> DataType { + [ + left[0] & right[0], + left[1] & right[1], + left[2] & right[2], + left[3] & right[3], + left[4] & right[4], + left[5] & right[5], + left[6] & right[6], + left[7] & right[7], + ] + } + + pub(crate) unsafe fn load_unaligned(addr: *const DataType) -> DataType { + ptr::read_unaligned(addr) + } + + pub(crate) unsafe fn store_unaligned(addr: *mut DataType, data: DataType) { + ptr::write_unaligned(addr, data); + } + + pub(crate) fn or_collapse_to_u32(accumulator: DataType) -> u32 { + ((accumulator[0] | accumulator[1]) | (accumulator[2] | accumulator[3])) + | ((accumulator[4] | accumulator[5]) | (accumulator[6] | accumulator[7])) + } + + fn compute_delta(curr: DataType, prev: DataType) -> DataType { + [ + curr[0].wrapping_sub(prev[7]), + curr[1].wrapping_sub(curr[0]), + curr[2].wrapping_sub(curr[1]), + curr[3].wrapping_sub(curr[2]), + curr[4].wrapping_sub(curr[3]), + curr[5].wrapping_sub(curr[4]), + curr[6].wrapping_sub(curr[5]), + curr[7].wrapping_sub(curr[6]), + ] + } + + fn integrate_delta(offset: DataType, delta: DataType) -> DataType { + let el0 = offset[7].wrapping_add(delta[0]); + let el1 = el0.wrapping_add(delta[1]); + let el2 = el1.wrapping_add(delta[2]); + let el3 = el2.wrapping_add(delta[3]); + let el4 = el3.wrapping_add(delta[4]); + let el5 = el4.wrapping_add(delta[5]); + let el6 = el5.wrapping_add(delta[6]); + let el7 = el6.wrapping_add(delta[7]); + [el0, el1, el2, el3, el4, el5, el6, el7] + } + + pub(crate) fn add(left: DataType, right: DataType) -> DataType { + [ + left[0].wrapping_add(right[0]), + left[1].wrapping_add(right[1]), + left[2].wrapping_add(right[2]), + left[3].wrapping_add(right[3]), + left[4].wrapping_add(right[4]), + left[5].wrapping_add(right[5]), + left[6].wrapping_add(right[6]), + left[7].wrapping_add(right[7]), + ] + } + + pub(crate) fn sub(left: DataType, right: DataType) -> DataType { + [ + left[0].wrapping_sub(right[0]), + left[1].wrapping_sub(right[1]), + left[2].wrapping_sub(right[2]), + left[3].wrapping_sub(right[3]), + left[4].wrapping_sub(right[4]), + left[5].wrapping_sub(right[5]), + left[6].wrapping_sub(right[6]), + left[7].wrapping_sub(right[7]), + ] + } + + // The `allow(unused)` is here to put an attribute that has no effect. + // + // For other bitpackers, we enable a specific CPU instruction set, but for + // the scalar bitpacker none is required. + declare_bitpacker!(allow(unused)); + + impl Available for UnsafeBitPackerImpl { + fn available() -> bool { + true + } + } +} + +#[derive(Clone, Copy)] +enum InstructionSet { + #[cfg(target_arch = "x86_64")] + AVX2, + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + NEON, + Scalar, +} + +/// 8-wide bitpacker implementation. +/// +/// One block contains 256 integers. +#[derive(Clone, Copy)] +pub struct BitPacker8x(InstructionSet); + +impl BitPacker8x { + #[cfg(target_arch = "x86_64")] + pub(crate) fn new_avx2() -> Option { + avx2::UnsafeBitPackerImpl::available().then_some(BitPacker8x(InstructionSet::AVX2)) + } + + #[cfg(not(target_arch = "x86_64"))] + pub(crate) fn new_avx2() -> Option { + None + } + + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + pub(crate) fn new_neon() -> Option { + neon::UnsafeBitPackerImpl::available().then_some(BitPacker8x(InstructionSet::NEON)) + } + + #[cfg(not(all(target_arch = "aarch64", target_endian = "little")))] + pub(crate) fn new_neon() -> Option { + None + } + + pub(crate) fn new_scalar() -> Self { + BitPacker8x(InstructionSet::Scalar) + } +} + +impl BitPacker for BitPacker8x { + const BLOCK_LEN: usize = BLOCK_LEN; + + fn new() -> Self { + Self::new_avx2() + .or_else(Self::new_neon) + .unwrap_or_else(Self::new_scalar) + } + + fn compress(&self, decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => { + avx2::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::compress(decompressed, compressed, num_bits) + } + } + } + } + + fn compress_sorted( + &self, + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => avx2::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::compress_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + } + } + } + + fn compress_strictly_sorted( + &self, + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => avx2::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::compress_strictly_sorted( + initial, + decompressed, + compressed, + num_bits, + ), + } + } + } + + fn decompress(&self, compressed: &[u8], decompressed: &mut [u32], num_bits: u8) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => { + avx2::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::decompress(compressed, decompressed, num_bits) + } + } + } + } + + fn decompress_sorted( + &self, + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => avx2::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::decompress_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + } + } + } + + fn decompress_strictly_sorted( + &self, + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => avx2::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::decompress_strictly_sorted( + initial, + compressed, + decompressed, + num_bits, + ), + } + } + } + + fn num_bits(&self, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => avx2::UnsafeBitPackerImpl::num_bits(decompressed), + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => neon::UnsafeBitPackerImpl::num_bits(decompressed), + InstructionSet::Scalar => scalar::UnsafeBitPackerImpl::num_bits(decompressed), + } + } + } + + fn num_bits_sorted(&self, initial: u32, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => { + avx2::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::num_bits_sorted(initial, decompressed) + } + } + } + } + + fn num_bits_strictly_sorted(&self, initial: Option, decompressed: &[u32]) -> u8 { + unsafe { + match self.0 { + #[cfg(target_arch = "x86_64")] + InstructionSet::AVX2 => { + avx2::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + InstructionSet::NEON => { + neon::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + InstructionSet::Scalar => { + scalar::UnsafeBitPackerImpl::num_bits_strictly_sorted(initial, decompressed) + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::BitPacker8x; + use crate::bitpacker_internal::BitPacker; + use bitpacking::{BitPacker as ExternalBitPacker, BitPacker8x as ExternalBitPacker8x}; + + fn mask_for_width(width: u8) -> u32 { + match width { + 0 => 0, + 32 => u32::MAX, + _ => (1u32 << width) - 1, + } + } + + fn raw_values(width: u8, seed: u64) -> Vec { + let mask = mask_for_width(width); + let mut state = seed; + (0..BitPacker8x::BLOCK_LEN) + .map(|idx| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + match seed % 4 { + 0 => 0, + 1 => mask, + 2 => idx as u32 & mask, + _ => state as u32 & mask, + } + }) + .collect() + } + + fn sorted_values(width: u8, seed: u64) -> (u32, Vec) { + if width == 0 { + return (17, vec![17; BitPacker8x::BLOCK_LEN]); + } + if width == 32 { + return (0, vec![u32::MAX; BitPacker8x::BLOCK_LEN]); + } + + let mask = mask_for_width(width).min(127); + let mut state = seed; + let mut current = 17u32; + let values = (0..BitPacker8x::BLOCK_LEN) + .map(|_| { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + current += state as u32 & mask; + current + }) + .collect(); + (17, values) + } + + fn strictly_sorted_values(width: u8, seed: u64) -> (Option, Vec) { + let mask = mask_for_width(width).min(127); + let mut state = seed; + let mut current = 0u32; + let values = (0..BitPacker8x::BLOCK_LEN) + .map(|idx| { + if idx == 0 { + current = 0; + } else { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + current += 1 + (state as u32 & mask); + } + current + }) + .collect(); + (None, values) + } + + fn assert_raw_compatible(ours: BitPacker8x, external: ExternalBitPacker8x) { + for width in 0..=32 { + for seed in [0, 1, 2, 123456789] { + let values = raw_values(width, seed); + assert_eq!(ours.num_bits(&values), external.num_bits(&values)); + + let mut actual = vec![0u8; BitPacker8x::compressed_block_size(width)]; + let actual_len = ours.compress(&values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker8x::compressed_block_size(width)]; + let expected_len = external.compress(&values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "raw width {width} seed {seed}"); + + let mut decoded = vec![0u32; BitPacker8x::BLOCK_LEN]; + assert_eq!(ours.decompress(&actual, &mut decoded, width), actual_len); + assert_eq!(decoded, values); + } + } + } + + fn assert_sorted_compatible(ours: BitPacker8x, external: ExternalBitPacker8x) { + for width in 0..=32 { + for seed in [0, 1, 2, 123456789] { + let (initial, values) = sorted_values(width, seed); + assert_eq!( + ours.num_bits_sorted(initial, &values), + external.num_bits_sorted(initial, &values) + ); + + let mut actual = vec![0u8; BitPacker8x::compressed_block_size(width)]; + let actual_len = ours.compress_sorted(initial, &values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker8x::compressed_block_size(width)]; + let expected_len = external.compress_sorted(initial, &values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "sorted width {width} seed {seed}"); + + let mut decoded = vec![0u32; BitPacker8x::BLOCK_LEN]; + assert_eq!( + ours.decompress_sorted(initial, &actual, &mut decoded, width), + actual_len + ); + assert_eq!(decoded, values); + } + } + } + + fn assert_strictly_sorted_compatible(ours: BitPacker8x, external: ExternalBitPacker8x) { + for width in 0..=16 { + for seed in [0, 1, 2, 123456789] { + let (initial, values) = strictly_sorted_values(width, seed); + let num_bits = external.num_bits_strictly_sorted(initial, &values); + assert_eq!(ours.num_bits_strictly_sorted(initial, &values), num_bits); + + let mut actual = vec![0u8; BitPacker8x::compressed_block_size(num_bits)]; + let actual_len = + ours.compress_strictly_sorted(initial, &values, &mut actual, num_bits); + + let mut expected = vec![0u8; ExternalBitPacker8x::compressed_block_size(num_bits)]; + let expected_len = + external.compress_strictly_sorted(initial, &values, &mut expected, num_bits); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "strict width {width} seed {seed}"); + + let mut decoded = vec![0u32; BitPacker8x::BLOCK_LEN]; + assert_eq!( + ours.decompress_strictly_sorted(initial, &actual, &mut decoded, num_bits), + actual_len + ); + assert_eq!(decoded, values); + } + } + } + + #[test] + fn bitpacker8x_raw_compatible_with_external_bitpacking() { + assert_raw_compatible(BitPacker8x::new(), ExternalBitPacker8x::new()); + } + + #[test] + fn bitpacker8x_sorted_compatible_with_external_bitpacking() { + assert_sorted_compatible(BitPacker8x::new(), ExternalBitPacker8x::new()); + } + + #[test] + fn scalar_backend_matches_external_bitpacker8x() { + let scalar = BitPacker8x::new_scalar(); + let external = ExternalBitPacker8x::new(); + + assert_raw_compatible(scalar, external); + assert_sorted_compatible(scalar, external); + } + + #[test] + fn scalar_backend_matches_external_strictly_sorted_bitpacker8x() { + let scalar = BitPacker8x::new_scalar(); + let external = ExternalBitPacker8x::new(); + + assert_strictly_sorted_compatible(scalar, external); + } + + #[cfg(all(target_arch = "aarch64", target_endian = "little"))] + #[test] + fn neon_backend_matches_external_bitpacker8x() { + if let Some(neon) = BitPacker8x::new_neon() { + let external = ExternalBitPacker8x::new(); + + assert_raw_compatible(neon, external); + assert_sorted_compatible(neon, external); + assert_strictly_sorted_compatible(neon, external); + } + } +} diff --git a/rust/compression/bitpacking/src/bitpacker_internal/macros.rs b/rust/compression/bitpacking/src/bitpacker_internal/macros.rs new file mode 100644 index 00000000000..e79686fe9af --- /dev/null +++ b/rust/compression/bitpacking/src/bitpacker_internal/macros.rs @@ -0,0 +1,669 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +macro_rules! pack_unpack_with_bits { + + ($name:ident, $n:expr, $cpufeature:meta) => { + + + mod $name { + + use crunchy::unroll; + use super::BLOCK_LEN; + use super::{Sink, Transformer}; + use super::{DataType, + set1, + right_shift_32, + left_shift_32, + op_or, + op_and, + load_unaligned, + store_unaligned}; + + const NUM_BITS: usize = $n; + const NUM_BYTES_PER_BLOCK: usize = NUM_BITS * BLOCK_LEN / 8; + + #[$cpufeature] + pub(crate) unsafe fn pack(input_arr: &[u32], output_arr: &mut [u8], mut delta_computer: TDeltaComputer) -> usize { + assert_eq!(input_arr.len(), BLOCK_LEN, "Input block too small {}, (expected {})", input_arr.len(), BLOCK_LEN); + assert!(output_arr.len() >= NUM_BYTES_PER_BLOCK, "Output array too small (numbits {}). {} <= {}", NUM_BITS, output_arr.len(), NUM_BYTES_PER_BLOCK); + + let input_ptr = input_arr.as_ptr().cast::(); + let mut output_ptr = output_arr.as_mut_ptr().cast::(); + let mut out_register: DataType = delta_computer.transform(load_unaligned(input_ptr)); + + unroll! { + for iter in 0..30 { + const i: usize = 1 + iter; + + const bits_filled: usize = i * NUM_BITS; + const inner_cursor: usize = bits_filled % 32; + const remaining: usize = 32 - inner_cursor; + + let offset_ptr = input_ptr.add(i); + let in_register: DataType = delta_computer.transform(load_unaligned(offset_ptr)); + + out_register = + if inner_cursor > 0 { + op_or(out_register, left_shift_32::<{inner_cursor as i32}>(in_register)) + } else { + in_register + }; + + if remaining <= NUM_BITS { + store_unaligned(output_ptr, out_register); + output_ptr = output_ptr.add(1); + if 0 < remaining && remaining < NUM_BITS { + out_register = right_shift_32::<{remaining as i32}>(in_register); + } + } + } + } + let in_register: DataType = delta_computer.transform(load_unaligned(input_ptr.add(31))); + out_register = if 32 - NUM_BITS > 0 { + op_or(out_register, left_shift_32::<{32 - NUM_BITS as i32}>(in_register)) + } else { + op_or(out_register, in_register) + }; + store_unaligned(output_ptr, out_register); + + NUM_BYTES_PER_BLOCK + } + + #[$cpufeature] + pub(crate) unsafe fn unpack(compressed: &[u8], mut output: Output) -> usize { + + assert!(compressed.len() >= NUM_BYTES_PER_BLOCK, "Compressed array seems too small. ({} < {}) ", compressed.len(), NUM_BYTES_PER_BLOCK); + + let mut input_ptr = compressed.as_ptr().cast::(); + + let mask_scalar: u32 = ((1u64 << NUM_BITS) - 1u64) as u32; + let mask = set1(mask_scalar as i32); + + let mut in_register: DataType = load_unaligned(input_ptr); + + let out_register = op_and(in_register, mask); + output.process(out_register); + + unroll! { + for iter in 0..31 { + const i: usize = iter + 1; + + const inner_cursor: usize = (i * NUM_BITS) % 32; + const inner_capacity: usize = 32 - inner_cursor; + + let shifted_in_register = if inner_cursor != 0 { + right_shift_32::<{inner_cursor as i32}>(in_register) + } else { + in_register + }; + let mut out_register: DataType = op_and(shifted_in_register, mask); + + // We consumed our current quadruplets entirely. + // We therefore read another one. + if inner_capacity <= NUM_BITS && i != 31 { + input_ptr = input_ptr.add(1); + in_register = load_unaligned(input_ptr); + + // This quadruplets is actually cutting one of + // our `DataType`. We need to read the next one. + if inner_capacity < NUM_BITS { + let shifted = if inner_capacity != 0 { + left_shift_32::<{inner_capacity as i32}>(in_register) + } else { + in_register + }; + let masked = op_and(shifted, mask); + out_register = op_or(out_register, masked); + } + } + + output.process(out_register); + } + } + + + NUM_BYTES_PER_BLOCK + } + } + } +} + +macro_rules! pack_unpack_with_bits_32 { + ($cpufeature:meta) => { + mod pack_unpack_with_bits_32 { + use super::BLOCK_LEN; + use super::{DataType, load_unaligned, store_unaligned}; + use super::{Sink, Transformer}; + use crunchy::unroll; + + const NUM_BITS: usize = 32; + const NUM_BYTES_PER_BLOCK: usize = NUM_BITS * BLOCK_LEN / 8; + + #[$cpufeature] + pub(crate) unsafe fn pack( + input_arr: &[u32], + output_arr: &mut [u8], + mut delta_computer: TDeltaComputer, + ) -> usize { + assert_eq!( + input_arr.len(), + BLOCK_LEN, + "Input block too small {}, (expected {})", + input_arr.len(), + BLOCK_LEN + ); + assert!( + output_arr.len() >= NUM_BYTES_PER_BLOCK, + "Output array too small (numbits {}). {} <= {}", + NUM_BITS, + output_arr.len(), + NUM_BYTES_PER_BLOCK + ); + + let input_ptr: *const DataType = input_arr.as_ptr().cast::(); + let output_ptr = output_arr.as_mut_ptr().cast::(); + unroll! { + for i in 0..32 { + let input_offset_ptr = input_ptr.add(i); + let output_offset_ptr = output_ptr.add(i); + let input_register = load_unaligned(input_offset_ptr); + let output_register = delta_computer.transform(input_register); + store_unaligned(output_offset_ptr, output_register); + } + } + NUM_BYTES_PER_BLOCK + } + + #[$cpufeature] + pub(crate) unsafe fn unpack( + compressed: &[u8], + mut output: Output, + ) -> usize { + assert!( + compressed.len() >= NUM_BYTES_PER_BLOCK, + "Compressed array seems too small. ({} < {}) ", + compressed.len(), + NUM_BYTES_PER_BLOCK + ); + let input_ptr = compressed.as_ptr().cast::(); + for i in 0..32 { + let input_offset_ptr = input_ptr.add(i); + let in_register: DataType = load_unaligned(input_offset_ptr); + output.process(in_register); + } + NUM_BYTES_PER_BLOCK + } + } + }; +} + +macro_rules! declare_bitpacker { + ($cpufeature:meta) => { + use super::super::UnsafeBitPacker; + use super::super::most_significant_bit; + use crunchy::unroll; + + pack_unpack_with_bits!(pack_unpack_with_bits_1, 1, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_2, 2, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_3, 3, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_4, 4, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_5, 5, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_6, 6, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_7, 7, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_8, 8, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_9, 9, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_10, 10, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_11, 11, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_12, 12, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_13, 13, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_14, 14, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_15, 15, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_16, 16, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_17, 17, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_18, 18, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_19, 19, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_20, 20, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_21, 21, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_22, 22, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_23, 23, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_24, 24, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_25, 25, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_26, 26, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_27, 27, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_28, 28, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_29, 29, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_30, 30, $cpufeature); + pack_unpack_with_bits!(pack_unpack_with_bits_31, 31, $cpufeature); + pack_unpack_with_bits_32!($cpufeature); + + unsafe fn compress_generic( + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + delta_computer: DeltaComputer, + ) -> usize { + match num_bits { + 0 => 0, + 1 => pack_unpack_with_bits_1::pack(decompressed, compressed, delta_computer), + 2 => pack_unpack_with_bits_2::pack(decompressed, compressed, delta_computer), + 3 => pack_unpack_with_bits_3::pack(decompressed, compressed, delta_computer), + 4 => pack_unpack_with_bits_4::pack(decompressed, compressed, delta_computer), + 5 => pack_unpack_with_bits_5::pack(decompressed, compressed, delta_computer), + 6 => pack_unpack_with_bits_6::pack(decompressed, compressed, delta_computer), + 7 => pack_unpack_with_bits_7::pack(decompressed, compressed, delta_computer), + 8 => pack_unpack_with_bits_8::pack(decompressed, compressed, delta_computer), + 9 => pack_unpack_with_bits_9::pack(decompressed, compressed, delta_computer), + 10 => pack_unpack_with_bits_10::pack(decompressed, compressed, delta_computer), + 11 => pack_unpack_with_bits_11::pack(decompressed, compressed, delta_computer), + 12 => pack_unpack_with_bits_12::pack(decompressed, compressed, delta_computer), + 13 => pack_unpack_with_bits_13::pack(decompressed, compressed, delta_computer), + 14 => pack_unpack_with_bits_14::pack(decompressed, compressed, delta_computer), + 15 => pack_unpack_with_bits_15::pack(decompressed, compressed, delta_computer), + 16 => pack_unpack_with_bits_16::pack(decompressed, compressed, delta_computer), + 17 => pack_unpack_with_bits_17::pack(decompressed, compressed, delta_computer), + 18 => pack_unpack_with_bits_18::pack(decompressed, compressed, delta_computer), + 19 => pack_unpack_with_bits_19::pack(decompressed, compressed, delta_computer), + 20 => pack_unpack_with_bits_20::pack(decompressed, compressed, delta_computer), + 21 => pack_unpack_with_bits_21::pack(decompressed, compressed, delta_computer), + 22 => pack_unpack_with_bits_22::pack(decompressed, compressed, delta_computer), + 23 => pack_unpack_with_bits_23::pack(decompressed, compressed, delta_computer), + 24 => pack_unpack_with_bits_24::pack(decompressed, compressed, delta_computer), + 25 => pack_unpack_with_bits_25::pack(decompressed, compressed, delta_computer), + 26 => pack_unpack_with_bits_26::pack(decompressed, compressed, delta_computer), + 27 => pack_unpack_with_bits_27::pack(decompressed, compressed, delta_computer), + 28 => pack_unpack_with_bits_28::pack(decompressed, compressed, delta_computer), + 29 => pack_unpack_with_bits_29::pack(decompressed, compressed, delta_computer), + 30 => pack_unpack_with_bits_30::pack(decompressed, compressed, delta_computer), + 31 => pack_unpack_with_bits_31::pack(decompressed, compressed, delta_computer), + 32 => pack_unpack_with_bits_32::pack(decompressed, compressed, delta_computer), + _ => { + panic!("Num bits must be <= 32. Was {}.", num_bits); + } + } + } + + pub trait Transformer { + unsafe fn transform(&mut self, data: DataType) -> DataType; + } + + struct NoDelta; + + impl Transformer for NoDelta { + #[inline] + unsafe fn transform(&mut self, current: DataType) -> DataType { + current + } + } + + struct DeltaComputer { + pub previous: DataType, + } + + impl Transformer for DeltaComputer { + #[inline] + unsafe fn transform(&mut self, current: DataType) -> DataType { + let result = compute_delta(current, self.previous); + self.previous = current; + result + } + } + + struct StrictDeltaComputer { + pub previous: DataType, + } + + impl Transformer for StrictDeltaComputer { + #[inline] + unsafe fn transform(&mut self, current: DataType) -> DataType { + let result = compute_delta(current, self.previous); + self.previous = current; + sub(result, set1(1)) + } + } + + pub trait Sink { + unsafe fn process(&mut self, data_type: DataType); + } + + struct Store { + output_ptr: *mut DataType, + } + + impl Store { + fn new(output_ptr: *mut DataType) -> Store { + Store { output_ptr } + } + } + + struct DeltaIntegrate { + current: DataType, + output_ptr: *mut DataType, + } + + impl DeltaIntegrate { + unsafe fn new(initial: u32, output_ptr: *mut DataType) -> DeltaIntegrate { + DeltaIntegrate { + current: set1(initial as i32), + output_ptr, + } + } + } + + impl Sink for DeltaIntegrate { + #[inline] + unsafe fn process(&mut self, delta: DataType) { + self.current = integrate_delta(self.current, delta); + store_unaligned(self.output_ptr, self.current); + self.output_ptr = self.output_ptr.add(1); + } + } + + struct StrictDeltaIntegrate { + current: DataType, + output_ptr: *mut DataType, + } + + impl StrictDeltaIntegrate { + unsafe fn new(initial: u32, output_ptr: *mut DataType) -> StrictDeltaIntegrate { + StrictDeltaIntegrate { + current: set1(initial as i32), + output_ptr, + } + } + } + + impl Sink for StrictDeltaIntegrate { + #[inline] + unsafe fn process(&mut self, delta: DataType) { + self.current = integrate_delta(self.current, add(delta, set1(1))); + store_unaligned(self.output_ptr, self.current); + self.output_ptr = self.output_ptr.add(1); + } + } + + impl Sink for Store { + #[inline] + unsafe fn process(&mut self, out_register: DataType) { + store_unaligned(self.output_ptr, out_register); + self.output_ptr = self.output_ptr.add(1); + } + } + + #[inline] + unsafe fn decompress_to( + compressed: &[u8], + mut sink: Output, + num_bits: u8, + ) -> usize { + match num_bits { + 0 => { + let zero = set1(0i32); + for _ in 0..32 { + sink.process(zero); + } + 0 + } + 1 => pack_unpack_with_bits_1::unpack(compressed, sink), + 2 => pack_unpack_with_bits_2::unpack(compressed, sink), + 3 => pack_unpack_with_bits_3::unpack(compressed, sink), + 4 => pack_unpack_with_bits_4::unpack(compressed, sink), + 5 => pack_unpack_with_bits_5::unpack(compressed, sink), + 6 => pack_unpack_with_bits_6::unpack(compressed, sink), + 7 => pack_unpack_with_bits_7::unpack(compressed, sink), + 8 => pack_unpack_with_bits_8::unpack(compressed, sink), + 9 => pack_unpack_with_bits_9::unpack(compressed, sink), + 10 => pack_unpack_with_bits_10::unpack(compressed, sink), + 11 => pack_unpack_with_bits_11::unpack(compressed, sink), + 12 => pack_unpack_with_bits_12::unpack(compressed, sink), + 13 => pack_unpack_with_bits_13::unpack(compressed, sink), + 14 => pack_unpack_with_bits_14::unpack(compressed, sink), + 15 => pack_unpack_with_bits_15::unpack(compressed, sink), + 16 => pack_unpack_with_bits_16::unpack(compressed, sink), + 17 => pack_unpack_with_bits_17::unpack(compressed, sink), + 18 => pack_unpack_with_bits_18::unpack(compressed, sink), + 19 => pack_unpack_with_bits_19::unpack(compressed, sink), + 20 => pack_unpack_with_bits_20::unpack(compressed, sink), + 21 => pack_unpack_with_bits_21::unpack(compressed, sink), + 22 => pack_unpack_with_bits_22::unpack(compressed, sink), + 23 => pack_unpack_with_bits_23::unpack(compressed, sink), + 24 => pack_unpack_with_bits_24::unpack(compressed, sink), + 25 => pack_unpack_with_bits_25::unpack(compressed, sink), + 26 => pack_unpack_with_bits_26::unpack(compressed, sink), + 27 => pack_unpack_with_bits_27::unpack(compressed, sink), + 28 => pack_unpack_with_bits_28::unpack(compressed, sink), + 29 => pack_unpack_with_bits_29::unpack(compressed, sink), + 30 => pack_unpack_with_bits_30::unpack(compressed, sink), + 31 => pack_unpack_with_bits_31::unpack(compressed, sink), + 32 => pack_unpack_with_bits_32::unpack(compressed, sink), + _ => { + panic!("Num bits must be <= 32. Was {}.", num_bits); + } + } + } + + pub struct UnsafeBitPackerImpl; + + impl UnsafeBitPacker for UnsafeBitPackerImpl { + const BLOCK_LEN: usize = BLOCK_LEN; + + #[$cpufeature] + unsafe fn compress(decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize { + compress_generic(decompressed, compressed, num_bits, NoDelta) + } + + #[$cpufeature] + unsafe fn compress_sorted( + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + let delta_computer = DeltaComputer { + previous: set1(initial as i32), + }; + compress_generic(decompressed, compressed, num_bits, delta_computer) + } + + #[$cpufeature] + unsafe fn compress_strictly_sorted( + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize { + // to allow encoding [0, 1, 2, ..], we need to permit an initial value "lower" than + // zero. To get a clean api, that value is None, but in practice, as we work on + // wrapping integers, u32::MAX/-1 does the job just fine. + let initial = initial.unwrap_or(u32::MAX); + let delta_computer = StrictDeltaComputer { + previous: set1(initial as i32), + }; + compress_generic(decompressed, compressed, num_bits, delta_computer) + } + + #[$cpufeature] + unsafe fn decompress( + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + assert!( + decompressed.len() >= BLOCK_LEN, + "The output array is not large enough : ({} >= {})", + decompressed.len(), + BLOCK_LEN + ); + let output_ptr = decompressed.as_mut_ptr().cast::(); + let output = Store::new(output_ptr); + decompress_to(compressed, output, num_bits) + } + + #[$cpufeature] + unsafe fn decompress_sorted( + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + assert!( + decompressed.len() >= BLOCK_LEN, + "The output array is not large enough : ({} >= {})", + decompressed.len(), + BLOCK_LEN + ); + let output_ptr = decompressed.as_mut_ptr().cast::(); + let output = DeltaIntegrate::new(initial, output_ptr); + decompress_to(compressed, output, num_bits) + } + + #[$cpufeature] + unsafe fn decompress_strictly_sorted( + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize { + assert!( + decompressed.len() >= BLOCK_LEN, + "The output array is not large enough : ({} >= {})", + decompressed.len(), + BLOCK_LEN + ); + let initial = initial.unwrap_or(u32::MAX); + let output_ptr = decompressed.as_mut_ptr().cast::(); + let output = StrictDeltaIntegrate::new(initial, output_ptr); + decompress_to(compressed, output, num_bits) + } + + #[$cpufeature] + unsafe fn num_bits(decompressed: &[u32]) -> u8 { + assert_eq!( + decompressed.len(), + BLOCK_LEN, + "`decompressed`'s len is not `BLOCK_LEN={}`", + BLOCK_LEN + ); + let data: *const DataType = decompressed.as_ptr().cast::(); + let mut accumulator = load_unaligned(data); + unroll! { + for iter in 0..31 { + let i = iter + 1; + let newvec = load_unaligned(data.add(i)); + accumulator = op_or(accumulator, newvec); + } + } + most_significant_bit(or_collapse_to_u32(accumulator)) + } + + #[$cpufeature] + unsafe fn num_bits_sorted(initial: u32, decompressed: &[u32]) -> u8 { + assert_eq!( + decompressed.len(), + BLOCK_LEN, + "`decompressed`'s len is not `BLOCK_LEN={}`", + BLOCK_LEN + ); + let initial_vec = set1(initial as i32); + let data: *const DataType = decompressed.as_ptr().cast::(); + + let first = load_unaligned(data); + let mut accumulator = compute_delta(load_unaligned(data), initial_vec); + let mut previous = first; + + unroll! { + for iter in 0..30 { + let i = iter + 1; + let current = load_unaligned(data.add(i)); + let delta = compute_delta(current, previous); + accumulator = op_or(accumulator, delta); + previous = current; + } + } + let current = load_unaligned(data.add(31)); + let delta = compute_delta(current, previous); + accumulator = op_or(accumulator, delta); + most_significant_bit(or_collapse_to_u32(accumulator)) + } + + #[$cpufeature] + unsafe fn num_bits_strictly_sorted(initial: Option, decompressed: &[u32]) -> u8 { + assert_eq!( + decompressed.len(), + BLOCK_LEN, + "`decompressed`'s len is not `BLOCK_LEN={}`", + BLOCK_LEN + ); + let initial = initial.unwrap_or(u32::MAX); + let initial_vec = set1(initial as i32); + let one = set1(1); + let data: *const DataType = decompressed.as_ptr().cast::(); + + let first = load_unaligned(data); + let mut accumulator = sub(compute_delta(load_unaligned(data), initial_vec), one); + let mut previous = first; + + unroll! { + for iter in 0..30 { + let i = iter + 1; + let current = load_unaligned(data.add(i)); + let delta = sub(compute_delta(current, previous), one); + accumulator = op_or(accumulator, delta); + previous = current; + } + } + let current = load_unaligned(data.add(31)); + let delta = sub(compute_delta(current, previous), one); + accumulator = op_or(accumulator, delta); + most_significant_bit(or_collapse_to_u32(accumulator)) + } + } + + #[cfg(any())] + mod tests { + use super::super::UnsafeBitPacker; + use super::UnsafeBitPackerImpl; + use crate::Available; + use crate::tests::{DeltaKind, test_suite_compress_decompress}; + + #[test] + fn test_num_bits() { + if UnsafeBitPackerImpl::available() { + for num_bits in 0..32 { + for pos in 0..32 { + let mut vals = [0u32; UnsafeBitPackerImpl::BLOCK_LEN]; + if num_bits > 0 { + vals[pos] = 1 << (num_bits - 1); + } + assert_eq!( + unsafe { UnsafeBitPackerImpl::num_bits(&vals[..]) }, + num_bits + ); + } + } + } + } + + #[test] + fn test_bitpacker() { + if UnsafeBitPackerImpl::available() { + test_suite_compress_decompress::(DeltaKind::NoDelta); + } + } + + #[test] + fn test_bitpacker_delta() { + if UnsafeBitPackerImpl::available() { + test_suite_compress_decompress::(DeltaKind::Delta); + } + } + + #[test] + fn test_bitpacker_strict_delta() { + if UnsafeBitPackerImpl::available() { + test_suite_compress_decompress::(DeltaKind::StrictDelta); + } + } + } + }; +} diff --git a/rust/compression/bitpacking/src/bitpacker_internal/mod.rs b/rust/compression/bitpacking/src/bitpacker_internal/mod.rs new file mode 100644 index 00000000000..80803e50ec8 --- /dev/null +++ b/rust/compression/bitpacking/src/bitpacker_internal/mod.rs @@ -0,0 +1,147 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +// Lance-owned u32 SIMD bitpacking kernels. +// +// This is adapted from the MIT-licensed `bitpacking` crate so Lance can keep the +// hot FTS posting-list bitpacking implementation inside lance-bitpacking while +// preserving byte compatibility with the existing 4x format. + +#![allow(dead_code)] +#![allow(unsafe_op_in_unsafe_fn)] +#![allow(clippy::redundant_pub_crate)] +#![allow(clippy::upper_case_acronyms)] +#![allow(clippy::use_self)] + +#[macro_use] +mod macros; + +mod bitpacker4x; +mod bitpacker8x; + +pub use bitpacker4x::BitPacker4x; +pub use bitpacker8x::BitPacker8x; + +pub(crate) trait Available { + fn available() -> bool; +} + +pub(crate) trait UnsafeBitPacker { + const BLOCK_LEN: usize; + + unsafe fn compress(decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize; + + unsafe fn compress_sorted( + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize; + + unsafe fn compress_strictly_sorted( + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize; + + unsafe fn decompress(compressed: &[u8], decompressed: &mut [u32], num_bits: u8) -> usize; + + unsafe fn decompress_sorted( + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize; + + unsafe fn decompress_strictly_sorted( + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize; + + unsafe fn num_bits(decompressed: &[u32]) -> u8; + + unsafe fn num_bits_sorted(initial: u32, decompressed: &[u32]) -> u8; + + unsafe fn num_bits_strictly_sorted(initial: Option, decompressed: &[u32]) -> u8; +} + +/// Block bitpacker for fixed-size `u32` blocks. +/// +/// Implementations own runtime SIMD dispatch and use caller-provided buffers. +/// Packed bytes are stable for a given implementation and bit width. +pub trait BitPacker: Sized + Clone + Copy { + /// Number of `u32` values in one physical block. + const BLOCK_LEN: usize; + + /// Select the best supported implementation for the current CPU. + /// + /// Lance uses SIMD backends when available and falls back to a scalar + /// backend otherwise, matching the existing allocation-free call shape used + /// by the upstream `bitpacking` crate. + fn new() -> Self; + + /// Compress one full block of raw values into `compressed`. + fn compress(&self, decompressed: &[u32], compressed: &mut [u8], num_bits: u8) -> usize; + + /// Delta-compress one full non-decreasing block into `compressed`. + fn compress_sorted( + &self, + initial: u32, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize; + + /// Delta-compress one full strictly increasing block into `compressed`. + fn compress_strictly_sorted( + &self, + initial: Option, + decompressed: &[u32], + compressed: &mut [u8], + num_bits: u8, + ) -> usize; + + /// Decompress one raw block into `decompressed`. + fn decompress(&self, compressed: &[u8], decompressed: &mut [u32], num_bits: u8) -> usize; + + /// Decompress one delta-compressed non-decreasing block. + fn decompress_sorted( + &self, + initial: u32, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize; + + /// Decompress one delta-compressed strictly increasing block. + fn decompress_strictly_sorted( + &self, + initial: Option, + compressed: &[u8], + decompressed: &mut [u32], + num_bits: u8, + ) -> usize; + + /// Return the minimum bit width needed to represent a full raw block. + fn num_bits(&self, decompressed: &[u32]) -> u8; + + /// Return the minimum bit width needed to represent deltas in a full block. + fn num_bits_sorted(&self, initial: u32, decompressed: &[u32]) -> u8; + + /// Return the minimum bit width needed to represent strict deltas in a full block. + fn num_bits_strictly_sorted(&self, initial: Option, decompressed: &[u32]) -> u8; + + /// Return the byte size of one compressed block at `num_bits`. + #[must_use] + fn compressed_block_size(num_bits: u8) -> usize { + Self::BLOCK_LEN * num_bits as usize / 8 + } +} + +#[inline] +fn most_significant_bit(value: u32) -> u8 { + (u32::BITS - value.leading_zeros()) as u8 +} diff --git a/rust/compression/bitpacking/src/lib.rs b/rust/compression/bitpacking/src/lib.rs index 0101c4a1df0..4c2458fb153 100644 --- a/rust/compression/bitpacking/src/lib.rs +++ b/rust/compression/bitpacking/src/lib.rs @@ -14,7 +14,11 @@ // https://github.com/spiraldb/fastlanes/blob/8e0ff374f815d919d0c0ebdccf5ffd9e6dc7d663/LICENSE use arrayref::{array_mut_ref, array_ref}; -use core::mem::size_of; +use core::mem::{MaybeUninit, size_of}; + +mod bitpacker_internal; + +pub use bitpacker_internal::{BitPacker, BitPacker4x, BitPacker8x}; pub const FL_ORDER: [usize; 8] = [0, 4, 2, 6, 1, 5, 3, 7]; @@ -51,7 +55,7 @@ macro_rules! pack { // Special case for W=T, we can just copy the input value directly to the packed value. paste!(seq_t!(row in $T { let idx = index(row, $lane); - $packed[<$T>::LANES * row + $lane] = __kernel__!(idx); + $packed[<$T>::LANES * row + $lane].write(__kernel__!(idx)); })); } else { // A mask of W bits. @@ -82,7 +86,7 @@ macro_rules! pack { #[allow(unused_assignments)] if next_word > curr_word { - $packed[<$T>::LANES * curr_word + $lane] = tmp; + $packed[<$T>::LANES * curr_word + $lane].write(tmp); let remaining_bits: usize = ((row + 1) * $W) % T; // Keep the remaining bits for the next packed value. tmp = src >> $W - remaining_bits; @@ -196,8 +200,66 @@ pub trait BitPacking: FastLanes { unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]); } -impl BitPacking for u8 { - unsafe fn unchecked_pack(width: usize, input: &[Self], output: &mut [Self]) { +/// Bitpacking kernels that can initialize previously uninitialized output storage. +pub trait BitPackingUninit: BitPacking { + /// Packs into potentially uninitialized output storage. + /// + /// # Safety + /// The input and output lengths have the same requirements as + /// [`BitPacking::unchecked_pack`]. Every output element is initialized on return. + unsafe fn unchecked_pack_uninit(width: usize, input: &[Self], output: &mut [MaybeUninit]); + + /// Unpacks into potentially uninitialized output storage. + /// + /// # Safety + /// The input and output lengths have the same requirements as + /// [`BitPacking::unchecked_unpack`]. Every output element is initialized on return. + unsafe fn unchecked_unpack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ); +} + +macro_rules! impl_bitpacking_compat { + ($ty:ty) => { + impl BitPacking for $ty { + unsafe fn unchecked_pack(width: usize, input: &[Self], output: &mut [Self]) { + let output = unsafe { + core::slice::from_raw_parts_mut( + output.as_mut_ptr().cast::>(), + output.len(), + ) + }; + unsafe { ::unchecked_pack_uninit(width, input, output) }; + } + + unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]) { + let output = unsafe { + core::slice::from_raw_parts_mut( + output.as_mut_ptr().cast::>(), + output.len(), + ) + }; + unsafe { + ::unchecked_unpack_uninit(width, input, output) + }; + } + } + }; +} + +impl_bitpacking_compat!(u8); +impl_bitpacking_compat!(u16); +impl_bitpacking_compat!(u32); +impl_bitpacking_compat!(u64); + +impl BitPackingUninit for u8 { + unsafe fn unchecked_pack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( output.len(), @@ -252,7 +314,11 @@ impl BitPacking for u8 { } } - unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]) { + unsafe fn unchecked_unpack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( input.len(), @@ -269,7 +335,7 @@ impl BitPacking for u8 { match width { 0 => { // A zero-width packed chunk implies all zeros. - output.fill(0); + output.fill(MaybeUninit::new(0)); } 1 => unpack_8_1( array_ref![input, 0, 1024 / 8], @@ -309,8 +375,12 @@ impl BitPacking for u8 { } } -impl BitPacking for u16 { - unsafe fn unchecked_pack(width: usize, input: &[Self], output: &mut [Self]) { +impl BitPackingUninit for u16 { + unsafe fn unchecked_pack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( output.len(), @@ -398,7 +468,11 @@ impl BitPacking for u16 { } } - unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]) { + unsafe fn unchecked_unpack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( input.len(), @@ -414,7 +488,7 @@ impl BitPacking for u16 { match width { 0 => { - output.fill(0); + output.fill(MaybeUninit::new(0)); } 1 => unpack_16_1( array_ref![input, 0, 1024 / 16], @@ -487,8 +561,12 @@ impl BitPacking for u16 { } } -impl BitPacking for u32 { - unsafe fn unchecked_pack(width: usize, input: &[Self], output: &mut [Self]) { +impl BitPackingUninit for u32 { + unsafe fn unchecked_pack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( output.len(), @@ -642,7 +720,11 @@ impl BitPacking for u32 { } } - unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]) { + unsafe fn unchecked_unpack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( input.len(), @@ -658,7 +740,7 @@ impl BitPacking for u32 { match width { 0 => { - output.fill(0); + output.fill(MaybeUninit::new(0)); } 1 => unpack_32_1( array_ref![input, 0, 1024 / 32], @@ -797,8 +879,12 @@ impl BitPacking for u32 { } } -impl BitPacking for u64 { - unsafe fn unchecked_pack(width: usize, input: &[Self], output: &mut [Self]) { +impl BitPackingUninit for u64 { + unsafe fn unchecked_pack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( output.len(), @@ -1083,7 +1169,11 @@ impl BitPacking for u64 { } } - unsafe fn unchecked_unpack(width: usize, input: &[Self], output: &mut [Self]) { + unsafe fn unchecked_unpack_uninit( + width: usize, + input: &[Self], + output: &mut [MaybeUninit], + ) { let packed_len = 128 * width / size_of::(); debug_assert_eq!( input.len(), @@ -1099,7 +1189,7 @@ impl BitPacking for u64 { match width { 0 => { - output.fill(0); + output.fill(MaybeUninit::new(0)); } 1 => unpack_64_1( array_ref![input, 0, 1024 / 64], @@ -1371,10 +1461,10 @@ impl BitPacking for u64 { macro_rules! unpack_8 { ($name:ident, $bits:expr) => { - fn $name(input: &[u8; 1024 * $bits / u8::T], output: &mut [u8; 1024]) { + fn $name(input: &[u8; 1024 * $bits / u8::T], output: &mut [MaybeUninit; 1024]) { for lane in 0..u8::LANES { unpack!(u8, $bits, input, lane, |$idx, $elem| { - output[$idx] = $elem; + output[$idx].write($elem); }); } } @@ -1392,7 +1482,7 @@ unpack_8!(unpack_8_8, 8); macro_rules! pack_8 { ($name:ident, $bits:expr) => { - fn $name(input: &[u8; 1024], output: &mut [u8; 1024 * $bits / u8::T]) { + fn $name(input: &[u8; 1024], output: &mut [MaybeUninit; 1024 * $bits / u8::T]) { for lane in 0..u8::LANES { pack!(u8, $bits, output, lane, |$idx| { input[$idx] }); } @@ -1410,10 +1500,10 @@ pack_8!(pack_8_8, 8); macro_rules! unpack_16 { ($name:ident, $bits:expr) => { - fn $name(input: &[u16; 1024 * $bits / u16::T], output: &mut [u16; 1024]) { + fn $name(input: &[u16; 1024 * $bits / u16::T], output: &mut [MaybeUninit; 1024]) { for lane in 0..u16::LANES { unpack!(u16, $bits, input, lane, |$idx, $elem| { - output[$idx] = $elem; + output[$idx].write($elem); }); } } @@ -1439,7 +1529,7 @@ unpack_16!(unpack_16_16, 16); macro_rules! pack_16 { ($name:ident, $bits:expr) => { - fn $name(input: &[u16; 1024], output: &mut [u16; 1024 * $bits / u16::T]) { + fn $name(input: &[u16; 1024], output: &mut [MaybeUninit; 1024 * $bits / u16::T]) { for lane in 0..u16::LANES { pack!(u16, $bits, output, lane, |$idx| { input[$idx] }); } @@ -1466,10 +1556,10 @@ pack_16!(pack_16_16, 16); macro_rules! unpack_32 { ($name:ident, $bit_width:expr) => { - fn $name(input: &[u32; 1024 * $bit_width / u32::T], output: &mut [u32; 1024]) { + fn $name(input: &[u32; 1024 * $bit_width / u32::T], output: &mut [MaybeUninit; 1024]) { for lane in 0..u32::LANES { unpack!(u32, $bit_width, input, lane, |$idx, $elem| { - output[$idx] = $elem + output[$idx].write($elem); }); } } @@ -1511,7 +1601,10 @@ unpack_32!(unpack_32_32, 32); macro_rules! pack_32 { ($name:ident, $bits:expr) => { - fn $name(input: &[u32; 1024], output: &mut [u32; 1024 * $bits / u32::BITS as usize]) { + fn $name( + input: &[u32; 1024], + output: &mut [MaybeUninit; 1024 * $bits / u32::BITS as usize], + ) { for lane in 0..u32::LANES { pack!(u32, $bits, output, lane, |$idx| { input[$idx] }); } @@ -1554,10 +1647,10 @@ pack_32!(pack_32_32, 32); macro_rules! unpack_64 { ($name:ident, $bit_width:expr) => { - fn $name(input: &[u64; 1024 * $bit_width / u64::T], output: &mut [u64; 1024]) { + fn $name(input: &[u64; 1024 * $bit_width / u64::T], output: &mut [MaybeUninit; 1024]) { for lane in 0..u64::LANES { unpack!(u64, $bit_width, input, lane, |$idx, $elem| { - output[$idx] = $elem + output[$idx].write($elem); }); } } @@ -1632,7 +1725,10 @@ unpack_64!(unpack_64_64, 64); macro_rules! pack_64 { ($name:ident, $bits:expr) => { - fn $name(input: &[u64; 1024], output: &mut [u64; 1024 * $bits / u64::BITS as usize]) { + fn $name( + input: &[u64; 1024], + output: &mut [MaybeUninit; 1024 * $bits / u64::BITS as usize], + ) { for lane in 0..u64::LANES { pack!(u64, $bits, output, lane, |$idx| { input[$idx] }); } @@ -1709,6 +1805,7 @@ pack_64!(pack_64_64, 64); #[cfg(test)] mod test { use super::*; + use bitpacking::{BitPacker as ExternalBitPacker, BitPacker4x as ExternalBitPacker4x}; use core::array; // a fast random number generator pub struct XorShift { @@ -1730,6 +1827,104 @@ mod test { } } + fn mask_for_width(width: u8) -> u32 { + match width { + 0 => 0, + 32 => u32::MAX, + _ => (1u32 << width) - 1, + } + } + + fn raw_bitpacker4x_case(width: u8, seed: u64) -> Vec { + let mask = mask_for_width(width); + let mut rng = XorShift::new(seed); + (0..BitPacker4x::BLOCK_LEN) + .map(|idx| match seed % 4 { + 0 => 0, + 1 => mask, + 2 => idx as u32 & mask, + _ => (rng.next() as u32) & mask, + }) + .collect() + } + + fn sorted_bitpacker4x_case(width: u8, seed: u64) -> (u32, Vec) { + if width == 0 { + return (17, vec![17; BitPacker4x::BLOCK_LEN]); + } + if width == 32 { + return (0, vec![u32::MAX; BitPacker4x::BLOCK_LEN]); + } + + let mask = mask_for_width(width).min(127); + let mut rng = XorShift::new(seed); + let mut current = 17u32; + let values = (0..BitPacker4x::BLOCK_LEN) + .map(|_| { + current += (rng.next() as u32) & mask; + current + }) + .collect(); + (17, values) + } + + #[test] + fn test_bitpacker4x_raw_compatible_with_external_bitpacking() { + let ours = BitPacker4x::new(); + let external = ExternalBitPacker4x::new(); + + for width in 0..=32 { + for seed in [0, 1, 2, 123456789] { + let values = raw_bitpacker4x_case(width, seed); + assert_eq!(ours.num_bits(&values), external.num_bits(&values)); + + let mut actual = vec![0u8; BitPacker4x::compressed_block_size(width)]; + let actual_len = ours.compress(&values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker4x::compressed_block_size(width)]; + let expected_len = external.compress(&values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "width {width} seed {seed}"); + + let mut decoded = vec![0u32; BitPacker4x::BLOCK_LEN]; + let consumed = ours.decompress(&actual, &mut decoded, width); + assert_eq!(consumed, actual_len); + assert_eq!(decoded, values); + } + } + } + + #[test] + fn test_bitpacker4x_sorted_compatible_with_external_bitpacking() { + let ours = BitPacker4x::new(); + let external = ExternalBitPacker4x::new(); + + for width in 0..=32 { + for seed in [0, 1, 2, 123456789] { + let (initial, values) = sorted_bitpacker4x_case(width, seed); + assert_eq!( + ours.num_bits_sorted(initial, &values), + external.num_bits_sorted(initial, &values) + ); + + let mut actual = vec![0u8; BitPacker4x::compressed_block_size(width)]; + let actual_len = ours.compress_sorted(initial, &values, &mut actual, width); + + let mut expected = vec![0u8; ExternalBitPacker4x::compressed_block_size(width)]; + let expected_len = external.compress_sorted(initial, &values, &mut expected, width); + + assert_eq!(actual_len, expected_len); + assert_eq!(actual, expected, "width {width} seed {seed}"); + + let mut decoded = vec![0u32; BitPacker4x::BLOCK_LEN]; + let consumed = ours.decompress_sorted(initial, &actual, &mut decoded, width); + assert_eq!(consumed, actual_len); + assert_eq!(decoded, values); + } + } + } + // a macro version of this function generalize u8, u16, u32, u64 takes very long time for a test build, so I // write it for each type separately fn pack_unpack_u8(bit_width: usize) { @@ -1739,13 +1934,16 @@ mod test { *value = (rng.next() % (1 << bit_width)) as u8; } - let mut packed = vec![0; 1024 * bit_width / 8]; + let mut packed = vec![MaybeUninit::uninit(); 1024 * bit_width / 8]; for lane in 0..u8::LANES { // Always loop over lanes first. This is what the compiler vectorizes. pack!(u8, bit_width, packed, lane, |$pos| { values[$pos] }); } + // The pack kernel writes every element of the packed output. + let packed = + unsafe { core::slice::from_raw_parts(packed.as_ptr().cast::(), packed.len()) }; let mut unpacked: [u8; 1024] = [0; 1024]; for lane in 0..u8::LANES { @@ -1765,13 +1963,16 @@ mod test { *value = (rng.next() % (1 << bit_width)) as u16; } - let mut packed = vec![0; 1024 * bit_width / 16]; + let mut packed = vec![MaybeUninit::uninit(); 1024 * bit_width / 16]; for lane in 0..u16::LANES { // Always loop over lanes first. This is what the compiler vectorizes. pack!(u16, bit_width, packed, lane, |$pos| { values[$pos] }); } + // The pack kernel writes every element of the packed output. + let packed = + unsafe { core::slice::from_raw_parts(packed.as_ptr().cast::(), packed.len()) }; let mut unpacked: [u16; 1024] = [0; 1024]; for lane in 0..u16::LANES { @@ -1791,13 +1992,16 @@ mod test { *value = (rng.next() % (1 << bit_width)) as u32; } - let mut packed = vec![0; 1024 * bit_width / 32]; + let mut packed = vec![MaybeUninit::uninit(); 1024 * bit_width / 32]; for lane in 0..u32::LANES { // Always loop over lanes first. This is what the compiler vectorizes. pack!(u32, bit_width, packed, lane, |$pos| { values[$pos] }); } + // The pack kernel writes every element of the packed output. + let packed = + unsafe { core::slice::from_raw_parts(packed.as_ptr().cast::(), packed.len()) }; let mut unpacked: [u32; 1024] = [0; 1024]; for lane in 0..u32::LANES { @@ -1823,13 +2027,16 @@ mod test { } } - let mut packed = vec![0; 1024 * bit_width / 64]; + let mut packed = vec![MaybeUninit::uninit(); 1024 * bit_width / 64]; for lane in 0..u64::LANES { // Always loop over lanes first. This is what the compiler vectorizes. pack!(u64, bit_width, packed, lane, |$pos| { values[$pos] }); } + // The pack kernel writes every element of the packed output. + let packed = + unsafe { core::slice::from_raw_parts(packed.as_ptr().cast::(), packed.len()) }; let mut unpacked: [u64; 1024] = [0; 1024]; for lane in 0..u64::LANES { @@ -2025,6 +2232,10 @@ mod test { for value in &mut values { *value = rng.next(); } + } else { + for value in &mut values { + *value = rng.next() % (1 << bit_width); + } } let mut packed = vec![0; 1024 * bit_width / u64::T]; unsafe { diff --git a/rust/compression/fsst/Cargo.toml b/rust/compression/fsst/Cargo.toml index 6a0537eec64..7056896e3b9 100644 --- a/rust/compression/fsst/Cargo.toml +++ b/rust/compression/fsst/Cargo.toml @@ -16,11 +16,8 @@ arrow-array.workspace = true rand.workspace = true [dev-dependencies] -arrow-array.workspace = true test-log.workspace = true tokio.workspace = true -rand_xoshiro = { workspace = true } -lance-datagen = { workspace = true } [[example]] name = "benchmark" diff --git a/rust/compression/fsst/examples/benchmark.rs b/rust/compression/fsst/examples/benchmark.rs index c6031a378d5..f71abeefedd 100644 --- a/rust/compression/fsst/examples/benchmark.rs +++ b/rust/compression/fsst/examples/benchmark.rs @@ -5,7 +5,7 @@ use std::fs::File; use std::io::{BufRead, BufReader}; use arrow_array::StringArray; -use fsst::fsst::{compress, decompress, FSST_SYMBOL_TABLE_SIZE}; +use fsst::fsst::{FSST_SYMBOL_TABLE_SIZE, compress, decompress}; use rand::Rng; const TEST_NUM: usize = 20; @@ -56,7 +56,10 @@ fn benchmark(file_path: &str) { let mut decompression_out_bufs = vec![]; let mut decompression_out_offsets_bufs = vec![]; for _ in 0..TEST_NUM { - let this_decom_out_buf = vec![0u8; BUFFER_SIZE * 3]; + // `decompress` requires the output buffer to be at least 8x the compressed input (a 1-byte + // code can expand to an 8-byte symbol). The compressed buffer is at most `BUFFER_SIZE`, so + // `BUFFER_SIZE * 8` is a safe upper bound. + let this_decom_out_buf = vec![0u8; BUFFER_SIZE * 8]; let this_decom_out_offsets_buf = vec![0i32; BUFFER_SIZE * 3]; decompression_out_bufs.push(this_decom_out_buf); decompression_out_offsets_bufs.push(this_decom_out_offsets_buf); diff --git a/rust/compression/fsst/src/fsst.rs b/rust/compression/fsst/src/fsst.rs index dbbe7d880bc..c5b619ba2d2 100644 --- a/rust/compression/fsst/src/fsst.rs +++ b/rust/compression/fsst/src/fsst.rs @@ -3,7 +3,7 @@ // the first 32-bits of a FSST compressed file is the FSST magic number const FSST_MAGIC: u64 = 0x46535354 << 32; // "FSST" - // when the code is FSST_ESC, the next byte should be interpreted as is +// when the code is FSST_ESC, the next byte should be interpreted as is const FSST_ESC: u8 = 255; // when building symbol table, we have a maximum of 512 symbols, so we can use 9 bits to represent the code const FSST_CODE_BITS: u16 = 9; @@ -48,6 +48,7 @@ pub const FSST_SYMBOL_TABLE_SIZE: usize = 8 + 256 * 8 + 256; // 8 bytes for the use arrow_array::OffsetSizeTrait; use rand::rngs::StdRng; use rand::{Rng, SeedableRng}; +use std::cell::Cell; use std::cmp::Ordering; use std::collections::BinaryHeap; use std::collections::HashSet; @@ -57,6 +58,12 @@ use std::ptr; #[inline] fn fsst_unaligned_load_unchecked(v: *const u8) -> u64 { + // SAFETY: the caller must guarantee that `v` points to at least 8 readable bytes. All callers + // uphold this: `compress_bulk` loads from a 520-byte stack buffer at an offset < 511 (leaving + // >= 8 bytes), `build_symbol_table` guards the load with `word.len() > 7 && curr < word.len() - 7`, + // `find_longest_symbol_from_char_slice` copies into a stack `[u8; 8]` before loading, and + // `FsstDecoder::init` reads symbols from a `symbol_table` buffer already validated to be + // exactly `FSST_SYMBOL_TABLE_SIZE` bytes. unsafe { ptr::read_unaligned(v as *const u64) } } @@ -801,6 +808,121 @@ fn compress_bulk( Ok(()) } +fn offset_to_usize(offset: T) -> io::Result { + offset.to_usize().ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!( + "FSST offset (as usize {}) is negative or exceeds {}", + offset.as_usize(), + T::MAX_OFFSET + ), + ) + }) +} + +fn validate_offsets(offsets: &[T], compressed_len: usize) -> io::Result<()> { + let Some((first, rest)) = offsets.split_first() else { + return Ok(()); + }; + let mut previous = offset_to_usize(*first)?; + if previous > compressed_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "FSST offset[0] = {previous} is out of bounds for compressed buffer of length {compressed_len}" + ), + )); + } + for (index, offset) in rest.iter().enumerate() { + let current = offset_to_usize(*offset)?; + let position = index + 1; + if current < previous { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("FSST offset at position {position} decreases: {current} < {previous}"), + )); + } + if current > compressed_len { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "FSST offset at position {position} = {current} is out of bounds for compressed buffer of length {compressed_len}" + ), + )); + } + previous = current; + } + Ok(()) +} + +fn encode_offset(value: usize) -> io::Result { + T::from_usize(value).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("FSST decompressed size {value} does not fit in the offset type"), + ) + }) +} + +#[inline(always)] +fn write_symbol(out: &mut [u8], out_curr: usize, symbol: u64) { + debug_assert!( + out_curr.checked_add(8).is_some_and(|end| end <= out.len()), + "FSST symbol write at {out_curr} overflows buffer of length {}", + out.len() + ); + // SAFETY: `FsstDecoder::init` rejected any declared `lens[i]` outside 1..=8 and left + // undeclared slots at 0, so every code advances `out_curr` by at most 8. Combined with + // the 8x output-buffer check, `out_curr + 8 <= out.len()` holds for every write. + unsafe { + ptr::write_unaligned(out.as_mut_ptr().add(out_curr) as *mut u64, symbol); + } +} + +fn store_out_byte(out: &mut [u8], out_curr: usize, byte: u8) { + debug_assert!( + out_curr < out.len(), + "FSST literal write at {out_curr} overflows buffer of length {}", + out.len() + ); + // SAFETY: same 8x + `lens[code] <= 8` proof as `write_symbol`. A literal + // writes one byte at `out_curr` after a run that advanced by at most 8 + // bytes per consumed input byte. + unsafe { + *out.get_unchecked_mut(out_curr) = byte; + } +} + +/// Consume `FSST_ESC` at `in_curr` and emit its payload byte. +/// +/// Returns `false` when the payload would leave the current value interval +/// `[in_curr, in_end)`, including a dangling escape at the last byte. +#[inline(always)] +fn emit_escape( + compressed_strs: &[u8], + in_curr: &mut usize, + in_end: usize, + out: &mut [u8], + out_curr: &mut usize, +) -> bool { + let payload_index = *in_curr + 1; + if payload_index >= in_end { + return false; + } + store_out_byte(out, *out_curr, compressed_strs[payload_index]); + *out_curr += 1; + *in_curr += 2; + true +} + +fn missing_escape_payload_error() -> io::Error { + io::Error::new( + io::ErrorKind::InvalidData, + "FSST escape is missing a payload byte inside the current value", + ) +} + fn decompress_bulk( decoder: &FsstDecoder, compressed_strs: &[u8], @@ -810,14 +932,28 @@ fn decompress_bulk( out_pos: &mut usize, out_offsets_len: &mut usize, ) -> io::Result<()> { + validate_offsets(offsets, compressed_strs.len())?; + let symbols = decoder.symbols; let lens = decoder.lens; + // SAFETY invariant shared by every `unsafe` block in this closure: + // - `out` is sized to at least 8x `compressed_strs` (checked in `FsstDecoder::init`). + // `init` also rejects any declared symbol length outside 1..=8, so each consumed + // input byte yields at most 8 output bytes and `out_curr + 8 <= out.len()` at every + // 8-byte write, including the final one. + // - Offsets have been normalized with `to_usize` and checked to be non-decreasing + // and within `compressed_strs.len()`, so `in_curr + 4 <= in_end` implies the + // `read_unaligned::` is in bounds. + let corrupt_escape = Cell::new(false); let mut decompress = |mut in_curr: usize, in_end: usize, out_curr: &mut usize| { // Do SIMD operation here by 4 bytes while in_curr + 4 <= in_end { let next_block; let mut code; let mut len; + // SAFETY: the loop guard proves `in_curr + 4 <= in_end`. Per the closure-level + // invariant, `in_end <= compressed_strs.len()` is a trusted precondition (not checked + // here), so the 4-byte read is in bounds for well-formed input. unsafe { next_block = ptr::read_unaligned(compressed_strs.as_ptr().add(in_curr) as *const u32); @@ -828,40 +964,28 @@ fn decompress_bulk( // 0th byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 1st byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 2nd byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 3rd byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; } else { @@ -870,137 +994,104 @@ fn decompress_bulk( // 0th byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 1st byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 2nd byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; - // escape byte - in_curr += 2; - out[*out_curr] = compressed_strs[in_curr - 1]; - *out_curr += 1; + // ESC is the last byte of this 4-byte window; its payload is the next + // byte and may lie outside the current value. + if !emit_escape(compressed_strs, &mut in_curr, in_end, out, out_curr) { + corrupt_escape.set(true); + return; + } } else if first_escape_pos == 2 { // 0th byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; // 1st byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; - // escape byte + // payload is inside the 4-byte window (`in_curr + 4 <= in_end`) in_curr += 2; - out[*out_curr] = compressed_strs[in_curr - 1]; + store_out_byte(out, *out_curr, compressed_strs[in_curr - 1]); *out_curr += 1; } else if first_escape_pos == 1 { // 0th byte code = compressed_strs[in_curr] as usize; len = lens[code] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += len; - // escape byte in_curr += 2; - out[*out_curr] = compressed_strs[in_curr - 1]; + store_out_byte(out, *out_curr, compressed_strs[in_curr - 1]); *out_curr += 1; } else { - // escape byte in_curr += 2; - out[*out_curr] = compressed_strs[in_curr - 1]; + store_out_byte(out, *out_curr, compressed_strs[in_curr - 1]); *out_curr += 1; } } } - // handle the remaining bytes - if in_curr + 2 <= in_end { - out[*out_curr] = compressed_strs[in_curr + 1]; - if compressed_strs[in_curr] != FSST_ESC { - let code = compressed_strs[in_curr] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); + while in_curr < in_end { + if compressed_strs[in_curr] == FSST_ESC { + if !emit_escape(compressed_strs, &mut in_curr, in_end, out, out_curr) { + corrupt_escape.set(true); + return; } + } else { + let code = compressed_strs[in_curr] as usize; + write_symbol(out, *out_curr, symbols[code]); in_curr += 1; *out_curr += lens[code] as usize; - if compressed_strs[in_curr] != FSST_ESC { - let code = compressed_strs[in_curr] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); - } - in_curr += 1; - *out_curr += lens[code] as usize; - } else { - in_curr += 2; - out[*out_curr] = compressed_strs[in_curr - 1]; - *out_curr += 1; - } - } else { - in_curr += 2; - *out_curr += 1; - } - } - - if in_curr < in_end { - // last code cannot be an escape code - let code = compressed_strs[in_curr] as usize; - unsafe { - let src = symbols[code]; - ptr::write_unaligned(out.as_mut_ptr().add(*out_curr) as *mut u64, src); } - *out_curr += lens[code] as usize; } }; let mut out_curr = *out_pos; - out_offsets[0] = T::from_usize(*out_pos).unwrap(); + if offsets.is_empty() { + out.resize(out_curr, 0); + out_offsets.clear(); + *out_offsets_len = 0; + return Ok(()); + } + + out_offsets[0] = encode_offset(*out_pos)?; for i in 1..offsets.len() { + // `validate_offsets` already proved these convert and stay in range. let in_curr = offsets[i - 1].as_usize(); let in_end = offsets[i].as_usize(); decompress(in_curr, in_end, &mut out_curr); - out_offsets[i] = T::from_usize(out_curr).unwrap(); + if corrupt_escape.get() { + return Err(missing_escape_payload_error()); + } + out_offsets[i] = encode_offset(out_curr)?; } out.resize(out_curr, 0); - out_offsets.resize(offsets.len(), T::from_usize(0).unwrap()); + out_offsets.resize(offsets.len(), encode_offset(0)?); *out_pos = out_curr; *out_offsets_len = offsets.len(); Ok(()) @@ -1169,14 +1260,6 @@ impl FsstDecoder { out_buf: &[u8], out_offsets_buf: &[T], ) -> io::Result<()> { - let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().unwrap()); - if st_info & FSST_MAGIC != FSST_MAGIC { - return Err(io::Error::new( - io::ErrorKind::InvalidData, - "the input buffer is not a valid FSST compressed data", - )); - } - if symbol_table.len() != FSST_SYMBOL_TABLE_SIZE { return Err(io::Error::new( io::ErrorKind::InvalidInput, @@ -1187,9 +1270,33 @@ impl FsstDecoder { )); } + let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidData, + "FSST symbol table is too short to contain a header", + ) + })?); + if st_info & FSST_MAGIC != FSST_MAGIC { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "the input buffer is not a valid FSST compressed data", + )); + } + self.decoder_switch_on = (st_info & (1 << 24)) != 0; - // when decoder_switch_on is true, we make sure the out_buf is at least 3 times the size of the in_buf, - if self.decoder_switch_on && in_buf.len() * 3 > out_buf.len() { + // A single 1-byte code can decode to a symbol of up to MAX_SYMBOL_LENGTH (8) bytes, so the + // decoded output can be up to 8x the input. `decompress_bulk` also relies on this bound: it + // writes a full 8-byte word per code (advancing only by the symbol length), so the output + // buffer must be large enough that even the final write stays in bounds. Require out_buf to + // be at least 8x in_buf. `checked_mul` guards against `in_buf.len() * 8` wrapping on 32-bit + // targets (an input >= 512 MiB would otherwise bypass the check); treat overflow as too + // small. + if self.decoder_switch_on + && in_buf + .len() + .checked_mul(8) + .is_none_or(|needed| needed > out_buf.len()) + { return Err(io::Error::new( io::ErrorKind::InvalidInput, "output buffer too small for FSST decoder", @@ -1221,7 +1328,16 @@ impl FsstDecoder { pos += 8; } for i in 0..symbol_num as usize { - self.lens[i] = symbol_table[pos]; + let len = symbol_table[pos]; + if !(1..=MAX_SYMBOL_LENGTH as u8).contains(&len) { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "FSST symbol length at index {i} is {len}, expected 1..={MAX_SYMBOL_LENGTH}" + ), + )); + } + self.lens[i] = len; pos += 1; } Ok(()) @@ -1235,6 +1351,7 @@ impl FsstDecoder { out_offsets_buf: &mut Vec, ) -> io::Result<()> { if !self.decoder_switch_on { + validate_offsets(in_offsets_buf, in_buf.len())?; out_buf.resize(in_buf.len(), 0); out_buf.copy_from_slice(in_buf); out_offsets_buf.resize(in_offsets_buf.len(), T::from_usize(0).unwrap()); @@ -1288,9 +1405,14 @@ pub fn compress( // the following 32 bits after FSST_MAGIC contains information about FSST encoding, such as decoder_switch_on, suffix_lim, terminator, n_symbols // when the decoder_switch_on is off in the in_buf header, `decompress` first make sure the out_buf is at least the same size as the in_buf, then simply copy the // input data to the output -// when the decoder_switch_on is on, `decompress` first make sure the out_buf is at least 3 times the size of the in_buf, then start decoding the -// data using the symbol table +// when the decoder_switch_on is on, `decompress` first make sure the out_buf is at least 8 times the size of the in_buf, then start decoding the +// data using the symbol table. The 8x bound is required for correctness: a 1-byte code can expand to +// an 8-byte symbol, and the decode loop writes a full 8-byte word per code, so a smaller buffer can +// be written out of bounds. // the out_offsets_buf should be at least the same size as the in_offsets_buf, otherwise an error is returned +// the symbol_table, compressed bytes, and offsets are untrusted: declared symbol lengths must be +// 1..=8 and offsets must be a non-decreasing sequence of values that fit in usize and lie within +// the compressed buffer. Corrupt input returns InvalidData instead of writing out of bounds. // the symbol_table is the same symbol table created by `compression` pub fn decompress( symbol_table: &[u8], @@ -1644,4 +1766,338 @@ But exactly how the acquaintance and friendship came about, we cannot say."; ); } } + + // Build a genuinely FSST-compressed (decoder_switch_on) buffer to exercise the decode-side + // output-buffer size contract. Returns (symbol_table, compressed_bytes, compressed_offsets). + fn compress_paragraph() -> ([u8; FSST_SYMBOL_TABLE_SIZE], Vec, Vec) { + let test_input = TEST_PARAGRAPH.repeat((1024 * 1024) / TEST_PARAGRAPH.len()); + let lines_vec = test_input.lines().collect::>(); + let string_array = StringArray::from(lines_vec); + let mut compress_output_buf: Vec = vec![0; string_array.value_data().len()]; + let mut compress_offset_buf: Vec = vec![0; string_array.value_offsets().len()]; + let mut symbol_table = [0; FSST_SYMBOL_TABLE_SIZE]; + compress( + symbol_table.as_mut(), + string_array.value_data(), + string_array.value_offsets(), + &mut compress_output_buf, + &mut compress_offset_buf, + ) + .unwrap(); + (symbol_table, compress_output_buf, compress_offset_buf) + } + + // The decoder writes a full 8-byte word per code, so the output buffer must be at least 8x the + // compressed input. A buffer sized below 8x must be rejected rather than written out of bounds. + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_undersized_output_buffer() { + let (symbol_table, compressed, compressed_offsets) = compress_paragraph(); + // Sanity check: this input actually engaged FSST compression (decoder_switch_on). + let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().unwrap()); + assert!(st_info & (1 << 24) != 0, "expected decoder_switch_on input"); + + // One byte short of the 8x requirement must be rejected. + let mut too_small = vec![0u8; compressed.len() * 8 - 1]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut too_small, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput); + + // Exactly 8x is the tight bound and must succeed. + let mut exact = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut exact, + &mut out_offsets, + ) + .unwrap(); + } + + fn declared_lens_range(symbol_table: &[u8]) -> std::ops::Range { + let n_symbols = (u64::from_ne_bytes(symbol_table[..8].try_into().unwrap()) & 255) as usize; + let start = 8 + n_symbols * 8; + start..start + n_symbols + } + + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_corrupt_symbol_length() { + let (mut symbol_table, compressed, compressed_offsets) = compress_paragraph(); + let lens = declared_lens_range(&symbol_table); + assert!(!lens.is_empty(), "expected at least one declared symbol"); + symbol_table[lens.start] = 9; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("symbol length"), + "unexpected error: {err}" + ); + } + + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_zero_declared_symbol_length() { + let (mut symbol_table, compressed, compressed_offsets) = compress_paragraph(); + let lens = declared_lens_range(&symbol_table); + symbol_table[lens.start] = 0; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("symbol length"), + "unexpected error: {err}" + ); + } + + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_out_of_range_offset() { + let (symbol_table, compressed, mut compressed_offsets) = compress_paragraph(); + let last = compressed_offsets.len() - 1; + compressed_offsets[last] = i32::try_from(compressed.len()).unwrap() + 1; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("out of bounds"), + "unexpected error: {err}" + ); + } + + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_decreasing_offset() { + let (symbol_table, compressed, mut compressed_offsets) = compress_paragraph(); + assert!(compressed_offsets.len() >= 2); + if compressed_offsets[0] == 0 { + compressed_offsets[0] = 1; + } + compressed_offsets[1] = compressed_offsets[0] - 1; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("decreases"), + "unexpected error: {err}" + ); + } + + #[test_log::test(tokio::test)] + async fn test_decompress_rejects_negative_offset() { + let (symbol_table, compressed, mut compressed_offsets) = compress_paragraph(); + compressed_offsets[0] = -1; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("negative") || err.to_string().contains("out of bounds"), + "unexpected error: {err}" + ); + } + + #[test_log::test(tokio::test)] + async fn test_decompress_accepts_max_symbol_length() { + let (symbol_table, compressed, compressed_offsets) = compress_paragraph(); + let lens = declared_lens_range(&symbol_table); + assert!( + symbol_table[lens].contains(&(MAX_SYMBOL_LENGTH as u8)), + "expected encoder to emit at least one 8-byte symbol" + ); + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap(); + } + + #[test_log::test(tokio::test)] + async fn test_undeclared_code_does_not_overflow() { + let (symbol_table, mut compressed, compressed_offsets) = compress_paragraph(); + let n_symbols = (u64::from_ne_bytes(symbol_table[..8].try_into().unwrap()) & 255) as usize; + if n_symbols >= 255 { + return; + } + let undeclared = n_symbols as u8; + if let Some(byte) = compressed.iter_mut().find(|byte| **byte != FSST_ESC) { + *byte = undeclared; + } + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap(); + } + + fn switch_off_roundtrip() -> ([u8; FSST_SYMBOL_TABLE_SIZE], Vec, Vec) { + let input = b"raw"; + let offsets = [0_i32, 3]; + let mut table = [0_u8; FSST_SYMBOL_TABLE_SIZE]; + let mut compressed = vec![0; input.len()]; + let mut compressed_offsets = vec![0_i32; offsets.len()]; + compress( + &mut table, + input, + &offsets, + &mut compressed, + &mut compressed_offsets, + ) + .unwrap(); + let st_info = u64::from_ne_bytes(table[..8].try_into().unwrap()); + assert!(st_info & (1 << 24) == 0, "expected decoder_switch_on off"); + (table, compressed, compressed_offsets) + } + + #[test] + fn test_decompress_rejects_corrupt_offsets_when_switch_off() { + let (table, compressed, _) = switch_off_roundtrip(); + let corrupt_offsets = [-1_i32, 99]; + let mut out = vec![0; compressed.len()]; + let mut out_offsets = vec![0_i32; corrupt_offsets.len()]; + let err = decompress( + &table, + &compressed, + &corrupt_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn test_decompress_switch_off_accepts_valid_offsets() { + let (table, compressed, compressed_offsets) = switch_off_roundtrip(); + let mut out = vec![0; compressed.len()]; + let mut out_offsets = vec![0_i32; compressed_offsets.len()]; + decompress( + &table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .unwrap(); + assert_eq!(out, compressed); + assert_eq!(out_offsets, compressed_offsets); + } + + fn assert_missing_escape_payload(table: &[u8], bytes: &[u8], offsets: &[i32]) { + let mut out = vec![0_u8; bytes.len() * 8]; + let mut out_offsets = vec![0_i32; offsets.len()]; + let err = decompress(table, bytes, offsets, &mut out, &mut out_offsets).unwrap_err(); + assert_eq!(err.kind(), std::io::ErrorKind::InvalidData); + assert!( + err.to_string().contains("escape"), + "unexpected error: {err}" + ); + } + + #[test] + fn test_decompress_rejects_dangling_escape_in_scalar_tail() { + let (table, _, _) = compress_paragraph(); + assert_missing_escape_payload(&table, &[0, FSST_ESC], &[0, 2]); + assert_missing_escape_payload(&table, &[FSST_ESC], &[0, 1]); + } + + #[test] + fn test_decompress_rejects_dangling_escape_in_fast_path() { + let (table, _, _) = compress_paragraph(); + // 4-byte window ending in ESC: payload sits at index 4, outside in_end. + assert_missing_escape_payload(&table, &[0, 0, 0, FSST_ESC], &[0, 4]); + } + + #[test] + fn test_decompress_rejects_escape_payload_from_next_value() { + let (table, _, _) = compress_paragraph(); + // First value ends on ESC; the next value's first byte must not be stolen as payload. + assert_missing_escape_payload(&table, &[0, 0, 0, FSST_ESC, b'X'], &[0, 4, 5]); + } + + #[test] + fn test_decompress_accepts_escape_with_payload() { + let (table, _, _) = compress_paragraph(); + let bytes = [FSST_ESC, b'A']; + let offsets = [0_i32, 2]; + let mut out = vec![0_u8; bytes.len() * 8]; + let mut out_offsets = vec![0_i32; offsets.len()]; + decompress(&table, &bytes, &offsets, &mut out, &mut out_offsets).unwrap(); + assert_eq!(&out[..], b"A"); + assert_eq!(out_offsets, [0, 1]); + } + + #[test] + fn test_decompress_accepts_fast_path_escape_with_payload() { + let (table, _, _) = compress_paragraph(); + let bytes = [0, 0, 0, FSST_ESC, b'Z']; + let offsets = [0_i32, 5]; + let mut out = vec![0_u8; bytes.len() * 8]; + let mut out_offsets = vec![0_i32; offsets.len()]; + decompress(&table, &bytes, &offsets, &mut out, &mut out_offsets).unwrap(); + assert_eq!(out_offsets[0], 0); + assert_eq!(*out.last().unwrap(), b'Z'); + assert_eq!(out_offsets[1], i32::try_from(out.len()).unwrap()); + } } diff --git a/rust/examples/Cargo.toml b/rust/examples/Cargo.toml index b77a0a7d062..fa6d0676655 100644 --- a/rust/examples/Cargo.toml +++ b/rust/examples/Cargo.toml @@ -38,7 +38,7 @@ arrow-select = { workspace = true } clap = { workspace = true, features = ["derive"] } itertools = { workspace = true } futures = { workspace = true } -lance = { workspace = true, features = ["aws", "azure", "gcp", "oss", "huggingface", "tencent"] } +lance = { workspace = true, features = ["aws", "azure", "gcp", "oss", "huggingface", "tencent", "goosefs"] } lance-index = { workspace = true } lance-core = { workspace = true } lance-linalg = { workspace = true } @@ -46,9 +46,9 @@ lance-datagen = { workspace = true } object_store = {workspace = true} tempfile = { workspace = true } tokio = { workspace = true } -all_asserts = "2.3.1" -env_logger = "0.11.7" +all_asserts.workspace = true +env_logger.workspace = true hf-hub = "0.4.2" -parquet = "57.1" +parquet = { workspace = true } tokenizers = "0.15.2" rand.workspace = true diff --git a/rust/examples/src/full_text_search.rs b/rust/examples/src/full_text_search.rs index e614ee17f5a..8269f590ee8 100644 --- a/rust/examples/src/full_text_search.rs +++ b/rust/examples/src/full_text_search.rs @@ -15,10 +15,10 @@ use arrow::datatypes::UInt64Type; use arrow_schema::{DataType, Field, Schema}; use itertools::Itertools; use lance::Dataset; -use lance_datagen::{array, RowCount}; +use lance::index::DatasetIndexExt; +use lance_datagen::{RowCount, array}; use lance_index::scalar::inverted::flat_full_text_search; use lance_index::scalar::{FullTextSearchQuery, InvertedIndexParams}; -use lance_index::DatasetIndexExt; use object_store::path::Path; #[tokio::main] diff --git a/rust/examples/src/hnsw.rs b/rust/examples/src/hnsw.rs index 0c0f705d42e..52c15bd0851 100644 --- a/rust/examples/src/hnsw.rs +++ b/rust/examples/src/hnsw.rs @@ -9,7 +9,7 @@ use std::collections::HashSet; use std::sync::Arc; -use arrow::array::{types::Float32Type, Array, FixedSizeListArray}; +use arrow::array::{Array, FixedSizeListArray, types::Float32Type}; use arrow::array::{AsArray, FixedSizeListBuilder, Float32Builder}; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; @@ -21,8 +21,8 @@ use lance_index::vector::v3::subindex::IvfSubIndex; use lance_index::vector::{ flat::storage::FlatFloatStorage, hnsw::{ - builder::{HnswBuildParams, HnswQueryParams}, HNSW, + builder::{HnswBuildParams, HnswQueryParams}, }, }; use lance_linalg::distance::DistanceType; @@ -129,6 +129,7 @@ async fn main() { lower_bound: None, upper_bound: None, dist_q_c: 0.0, + use_acorn: false, }; let results: HashSet = hnsw .search_basic(q.clone(), k, ¶ms, None, vector_store.as_ref()) diff --git a/rust/examples/src/ivf_hnsw.rs b/rust/examples/src/ivf_hnsw.rs index 34bd4cbca7f..c1898e10682 100644 --- a/rust/examples/src/ivf_hnsw.rs +++ b/rust/examples/src/ivf_hnsw.rs @@ -5,17 +5,18 @@ //! //! run with `cargo run --release --example hnsw` #![allow(clippy::print_stdout)] -use arrow::array::types::Float32Type; use arrow::array::AsArray; +use arrow::array::types::Float32Type; use clap::Parser; use futures::TryStreamExt; +use lance::Dataset; use lance::dataset::ProjectionRequest; +use lance::index::DatasetIndexExt; use lance::index::vector::VectorIndexParams; -use lance::Dataset; +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/examples/src/llm_dataset_creation.rs b/rust/examples/src/llm_dataset_creation.rs index 6a5ebbc0dd6..7f981156b4d 100644 --- a/rust/examples/src/llm_dataset_creation.rs +++ b/rust/examples/src/llm_dataset_creation.rs @@ -17,11 +17,11 @@ use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::RecordBatch; use arrow::record_batch::RecordBatchReader; use futures::StreamExt; -use hf_hub::{api::sync::Api, Repo, RepoType}; +use hf_hub::{Repo, RepoType, api::sync::Api}; use lance::dataset::WriteParams; use parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder; -use rand::seq::SliceRandom; use rand::SeedableRng; +use rand::seq::SliceRandom; use std::error::Error; use std::fs::File; use std::io::Write; @@ -139,24 +139,24 @@ impl Iterator for WikiTextBatchReader { fn next(&mut self) -> Option { loop { // If we have a current reader, try to get next batch - if let Some(reader) = &mut self.current_reader { - if let Some(batch_result) = reader.next() { - return Some(batch_result.and_then(|batch| self.process_batch(&batch))); - } + if let Some(reader) = &mut self.current_reader + && let Some(batch_result) = reader.next() + { + return Some(batch_result.and_then(|batch| self.process_batch(&batch))); } // If no current reader or current reader is exhausted, try to get next reader - if self.current_reader_idx < self.parquet_readers.len() { - if let Some(builder) = self.parquet_readers[self.current_reader_idx].take() { - match builder.build() { - Ok(reader) => { - self.current_reader = Some(Box::new(reader)); - self.current_reader_idx += 1; - continue; - } - Err(e) => { - return Some(Err(arrow::error::ArrowError::ExternalError(Box::new(e)))) - } + if self.current_reader_idx < self.parquet_readers.len() + && let Some(builder) = self.parquet_readers[self.current_reader_idx].take() + { + match builder.build() { + Ok(reader) => { + self.current_reader = Some(Box::new(reader)); + self.current_reader_idx += 1; + continue; + } + Err(e) => { + return Some(Err(arrow::error::ArrowError::ExternalError(Box::new(e)))); } } } diff --git a/rust/examples/src/write_read_ds.rs b/rust/examples/src/write_read_ds.rs index d76f603e80d..0b07aa00c16 100644 --- a/rust/examples/src/write_read_ds.rs +++ b/rust/examples/src/write_read_ds.rs @@ -6,9 +6,9 @@ use arrow::array::UInt32Array; use arrow::datatypes::{DataType, Field, Schema}; use arrow::record_batch::{RecordBatch, RecordBatchIterator}; use futures::StreamExt; +use lance::Dataset; use lance::dataset::{WriteMode, WriteParams}; use lance::io::ObjectStore; -use lance::Dataset; use lance_core::utils::tempfile::TempStrDir; use std::sync::Arc; diff --git a/rust/lance-arrow/Cargo.toml b/rust/lance-arrow/Cargo.toml index 0caedbc47d2..21513e638d8 100644 --- a/rust/lance-arrow/Cargo.toml +++ b/rust/lance-arrow/Cargo.toml @@ -17,11 +17,13 @@ crate-type = ["cdylib", "rlib"] 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 } +bytemuck = { workspace = true } bytes = { workspace = true } +futures = { workspace = true } half = { workspace = true } jsonb ={ workspace = true } num-traits = { workspace = true } diff --git a/rust/lance-arrow/README.md b/rust/lance-arrow/README.md index 8c3bedac3f3..f5feccaa191 100644 --- a/rust/lance-arrow/README.md +++ b/rust/lance-arrow/README.md @@ -1,7 +1,7 @@ # lance-arrow -`lance-arrow` is a internal sub-crate, containing [Apache-Arrow](https://github.com/apache/arrow-rs) -extensions used by [Lance](https://github.com/lancedb/lance). +`lance-arrow` is an internal sub-crate, containing [Apache-Arrow](https://github.com/apache/arrow-rs) +extensions used by [Lance](https://github.com/lance-format/lance). **Important Note**: This crate is **not intended for external usage**. diff --git a/rust/lance-arrow/src/bfloat16.rs b/rust/lance-arrow/src/bfloat16.rs index 0364afe300f..74c7f259a40 100644 --- a/rust/lance-arrow/src/bfloat16.rs +++ b/rust/lance-arrow/src/bfloat16.rs @@ -6,13 +6,13 @@ use std::fmt::Formatter; use std::slice; -use arrow_array::{builder::BooleanBufferBuilder, Array, FixedSizeBinaryArray}; -use arrow_buffer::MutableBuffer; +use arrow_array::{Array, FixedSizeBinaryArray, builder::BooleanBufferBuilder}; +use arrow_buffer::{Buffer, MutableBuffer}; use arrow_data::ArrayData; use arrow_schema::{ArrowError, DataType, Field as ArrowField}; use half::bf16; -use crate::{FloatArray, ARROW_EXT_NAME_KEY}; +use crate::{ARROW_EXT_NAME_KEY, FloatArray}; /// The name of the bfloat16 extension in Arrow metadata pub const BFLOAT16_EXT_NAME: &str = "lance.bfloat16"; @@ -144,6 +144,11 @@ impl FromIterator> for BFloat16Array { .len(len) .add_buffer(buffer.into()) .null_bit_buffer(null_buffer); + // SAFETY: the value buffer contains exactly `2 * len` bytes (two bytes + // pushed per iteration of the loop above, including the zero-fill for + // null slots), which matches the `FixedSizeBinary(2)` storage layout. + // The null bit buffer, when present, has `len` bits appended above, so + // its length covers the array's logical range. let array_data = unsafe { array_data.build_unchecked() }; Self { inner: FixedSizeBinaryArray::from(array_data), @@ -159,17 +164,20 @@ impl FromIterator for BFloat16Array { impl From> for BFloat16Array { fn from(data: Vec) -> Self { - let mut buffer = MutableBuffer::with_capacity(data.len() * 2); - - let bytes = data.iter().flat_map(|val| { - let bytes = val.to_bits().to_le_bytes(); - bytes.to_vec() - }); - - buffer.extend(bytes); + let len = data.len(); + // Zero-copy: `bf16` is `#[repr(transparent)]` over `u16` and derives + // `bytemuck::Pod`, so `cast_vec` reinterprets the allocation in place — + // no per-element copy or heap alloc. The crate-root `compile_error!` + // pins `target_endian = "little"`, so the resulting bytes match the + // `FixedSizeBinary(2)` on-disk order Lance writes elsewhere. + let raw: Vec = bytemuck::cast_vec(data); let array_data = ArrayData::builder(DataType::FixedSizeBinary(2)) - .len(data.len()) - .add_buffer(buffer.into()); + .len(len) + .add_buffer(Buffer::from_vec(raw)); + // SAFETY: the value buffer contains exactly `2 * len` bytes — one + // `u16` per element after the layout-compatible cast — matching the + // `FixedSizeBinary(2)` storage layout. No null buffer is attached, so + // every element is logically valid. let array_data = unsafe { array_data.build_unchecked() }; Self { inner: FixedSizeBinaryArray::from(array_data), @@ -268,12 +276,64 @@ mod from_arrow { impl FloatArray for FixedSizeBinaryArray { type FloatType = BFloat16Type; + /// Returns the underlying `bf16` values as a borrowed slice. + /// + /// # Preconditions + /// + /// - `value_length()` must be 2 (the `FixedSizeBinary(2)` storage shape + /// used by [`BFloat16Array`]). Asserted at entry. + /// - The value buffer must be at least 2-byte aligned. Lance's in-tree + /// constructors always satisfy this: value buffers are built either via + /// `MutableBuffer` (aligned to arrow-buffer's `ALIGNMENT` constant, ≥32 + /// bytes) or via `Buffer::from_vec::` (aligned to `align_of::()` + /// == 2); both meet `bf16`'s 2-byte requirement. Externally-built + /// `FixedSizeBinaryArray`s arriving via FFI, IPC, or + /// `Buffer::from_custom_allocation` are not required by arrow-rs to be + /// aligned beyond a single byte; passing one to this method violates the + /// precondition. A `debug_assert` below catches such inputs in debug and + /// test builds. + /// + /// # Endianness + /// + /// `lance-arrow` is gated on `target_endian = "little"` at the crate root, + /// so this method always returns values in the same byte order Lance writes + /// (see [`BFloat16Array::value`] and the [`FromIterator`] impls). fn as_slice(&self) -> &[bf16] { assert_eq!( self.value_length(), 2, "BFloat16 arrays must use FixedSizeBinary(2) storage" ); + debug_assert_eq!( + (self.value_data().as_ptr() as usize) % std::mem::align_of::(), + 0, + "BFloat16 value buffer must be at least 2-byte aligned" + ); + // SAFETY: + // - The assert above pins `value_size == 2`, so `value_data().len() / 2` + // equals the array's logical element count. + // `FixedSizeBinaryArray::From` constructs its value buffer + // as `buffers[0].slice_with_length(offset * 2, len * 2)` (arrow-array + // `fixed_size_binary_array.rs`), so `value_data()` already returns + // the offset-adjusted slice. Do not replace `value_data()` with an + // accessor that returns the un-sliced backing buffer. + // - `bf16` is `#[repr(transparent)]` over `u16` (size 2, alignment 2); + // every `u16` bit pattern is a valid `bf16`, so any byte content + // yields a defined value — never UB. + // - Alignment is the caller's responsibility per the precondition + // documented above. The `debug_assert_eq!` immediately preceding this + // block catches violations in debug and test builds only — release + // builds rely on callers honoring the precondition. arrow-rs + // declares `FixedSizeBinary(n)`'s + // `BufferSpec::FixedWidth { alignment: align_of::() == 1 }` + // (arrow-data `data.rs`), so arrow-rs alone does not guarantee + // 2-byte alignment. Lance's in-tree construction paths build value + // buffers via `MutableBuffer` (arrow-buffer `ALIGNMENT` constant, + // ≥32 bytes) or `Buffer::from_vec::` (2-byte aligned), both of + // which satisfy `bf16`'s 2-byte requirement. + // - The returned slice borrows from `self`; the underlying ref-counted, + // immutable Arrow buffer cannot be mutated or freed for the slice's + // lifetime. unsafe { slice::from_raw_parts( self.value_data().as_ptr() as *const bf16, @@ -301,6 +361,16 @@ mod tests { assert_eq!(array, array2); assert_eq!(array.len(), 3); + // Pin the raw little-endian bytes emitted by `From>` (rewritten to + // reinterpret the Vec via `bytemuck::cast_vec`), so a layout/byte-order + // regression is caught directly rather than only through Debug formatting. + // bf16 is the high 16 bits of the f32: 1.0->0x3F80, 2.0->0x4000, 3.0->0x4040. + let inner = array2.clone().into_inner(); + let raw_bytes: Vec = (0..inner.len()) + .flat_map(|i| inner.value(i).to_vec()) + .collect(); + assert_eq!(raw_bytes, vec![0x80, 0x3F, 0x00, 0x40, 0x40, 0x40]); + let expected_fmt = "BFloat16Array\n[\n 1.0,\n 2.0,\n 3.0,\n]"; assert_eq!(expected_fmt, format!("{:?}", array)); 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/deepcopy.rs b/rust/lance-arrow/src/deepcopy.rs index 98801357878..0d859ef1bd9 100644 --- a/rust/lance-arrow/src/deepcopy.rs +++ b/rust/lance-arrow/src/deepcopy.rs @@ -3,9 +3,10 @@ use std::sync::Arc; -use arrow_array::{make_array, Array, RecordBatch}; +use arrow_array::{Array, RecordBatch, make_array}; use arrow_buffer::{BooleanBuffer, Buffer, NullBuffer}; -use arrow_data::{transform::MutableArrayData, ArrayData, ArrayDataBuilder}; +use arrow_data::{ArrayData, ArrayDataBuilder, transform::MutableArrayData}; +use arrow_schema::DataType; pub fn deep_copy_buffer(buffer: &Buffer) -> Buffer { Buffer::from(buffer.as_slice()) @@ -14,6 +15,15 @@ pub fn deep_copy_buffer(buffer: &Buffer) -> Buffer { pub fn deep_copy_nulls(nulls: Option<&NullBuffer>) -> Option { let nulls = nulls?; let bit_buffer = deep_copy_buffer(nulls.inner().inner()); + // SAFETY: `null_count` is taken from the source `NullBuffer`, which already + // upheld `NullBuffer::new_unchecked`'s invariant — the unset-bit count over + // the logical bit slice `[bit_offset, bit_offset + bit_len)`. `NullBuffer::slice` + // adjusts only `BooleanBuffer::bit_offset` / `bit_len` and never byte-advances + // the inner `Buffer`, so `deep_copy_buffer` (which copies the source `Buffer`'s + // `as_slice()` view from byte 0) reproduces the exact bit pattern at the same + // bit offsets; the unset-bit count is therefore preserved. `BooleanBuffer::new` + // panics (does not UB) if `bit_offset + bit_len > 8 * buffer.len()`, and the + // copy has the same length, so that check still passes. Some(unsafe { NullBuffer::new_unchecked( BooleanBuffer::new(bit_buffer, nulls.offset(), nulls.len()), @@ -37,6 +47,19 @@ pub fn deep_copy_array_data(data: &ArrayData) -> ArrayData { .iter() .map(deep_copy_array_data) .collect::>(); + // SAFETY: `build_unchecked` inherits `ArrayData::new_unchecked`'s contract — + // `(data_type, len, offset, nulls, buffers, child_data)` must form a valid + // Arrow array. This call reproduces `data` structurally: `data_type`, `len`, + // and `offset` are forwarded unchanged; each buffer is replaced by a byte- + // identical copy of its offset-applied `as_slice()` view (the output buffer + // is `MutableBuffer`-allocated, at least as aligned as the source); `nulls` + // is deep-copied with the same bit offset/length and unset-bit count (see + // `deep_copy_nulls`); `child_data` is recursively cloned with the same + // guarantee. Every value-level invariant the source upheld — UTF-8 validity, + // monotonic offsets, in-bounds dictionary indices, run-end monotonicity, + // struct child-length matching — therefore transfers to the copy. If the + // source `ArrayData` was itself constructed via `new_unchecked` with an + // invalid payload, this function faithfully reproduces that invalidity. unsafe { ArrayDataBuilder::new(data_type) .len(len) @@ -69,8 +92,18 @@ pub fn deep_copy_array_data_sliced(data: &ArrayData) -> ArrayData { // Use MutableArrayData to efficiently copy just the slice let mut mutable = MutableArrayData::new(vec![data], false, data.len()); - // Copy from offset to offset+len (the visible slice) - mutable.extend(0, data.offset(), data.offset() + data.len()); + // Which index this takes depends on the layout, because arrow's extenders + // disagree about who applies `ArrayData::offset()`. The bit-packed one adds + // it to the raw values buffer itself, so an offset-applied index there + // reads from twice the offset and runs off the end of the buffer. Every + // other layout either reads through an already-offset-applied view or + // forwards the index to children carrying their own offsets, and takes the + // absolute index it has always been given. + let start = match data.data_type() { + DataType::Boolean => 0, + _ => data.offset(), + }; + mutable.extend(0, start, start + data.len()); // Freeze into immutable ArrayData mutable.freeze() @@ -94,12 +127,118 @@ 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}; + use arrow_array::{Array, BooleanArray, Int32Array, RecordBatch, StringArray}; + use arrow_data::ArrayDataBuilder; use arrow_schema::{DataType, Field, Schema}; + #[test] + fn raw_sliced_fixed_size_list_data_keeps_its_child_offset() { + // `ArrayData::slice` records the offset on the parent and leaves the + // child whole -- a shape `FixedSizeListArray::slice` never produces, + // but a legal one this public helper can be handed directly. Arrow's + // extender forwards `start * size` to that unsliced child and adds + // nothing, so the index it gets has to be the absolute one; this is + // here to stop the boolean fix from being generalised over it. + let child = Int32Array::from(vec![10, 11, 20, 21]).to_data(); + let field = Arc::new(Field::new_list_field(DataType::Int32, false)); + let data = ArrayDataBuilder::new(DataType::FixedSizeList(field, 2)) + .len(2) + .add_child_data(child) + .build() + .unwrap(); + let sliced = data.slice(1, 1); + + let copied = super::deep_copy_array_data_sliced(&sliced); + let copied_child = Int32Array::from(copied.child_data()[0].clone()); + assert_eq!(copied_child.values().as_ref(), &[20, 21]); + } + + #[test] + fn raw_sliced_struct_of_fixed_size_list_reaches_the_right_values() { + // Slicing raw struct data pushes the window into the struct's children, + // and a fixed-size-list child takes it as its own parent offset with + // its values left whole. Two levels of offset, and the absolute index + // has to remain right through both of them. + let values = Int32Array::from(vec![10, 11, 20, 21, 30, 31]).to_data(); + let item = Arc::new(Field::new_list_field(DataType::Int32, false)); + let list = ArrayDataBuilder::new(DataType::FixedSizeList(item, 2)) + .len(3) + .add_child_data(values) + .build() + .unwrap(); + let field = Arc::new(Field::new("l", list.data_type().clone(), false)); + let data = ArrayDataBuilder::new(DataType::Struct(vec![field].into())) + .len(3) + .add_child_data(list) + .build() + .unwrap(); + let sliced = data.slice(2, 1); + + let copied = super::deep_copy_array_data_sliced(&sliced); + let copied_values = Int32Array::from(copied.child_data()[0].child_data()[0].clone()); + assert_eq!(copied_values.values().as_ref(), &[30, 31]); + } + + #[test] + fn raw_parent_offset_struct_keeps_the_selected_child_row() { + // A struct whose children are whole and whose window is the parent + // offset: arrow's struct extender forwards the index to those children + // untouched, so it has to be the absolute one. + let field = Arc::new(Field::new("a", DataType::Int32, false)); + let data = ArrayDataBuilder::new(DataType::Struct(vec![field].into())) + .len(1) + .offset(1) + .add_child_data(Int32Array::from(vec![10, 20]).to_data()) + .build() + .unwrap(); + + let copied = super::deep_copy_array_data_sliced(&data); + let copied_child = Int32Array::from(copied.child_data()[0].clone()); + assert_eq!(copied_child.values().as_ref(), &[20]); + } + + #[test] + fn sliced_boolean_deep_copy_reads_from_the_slice() { + // A boolean slice keeps its `ArrayData::offset()` -- the buffer cannot + // advance by a fraction of a byte -- so a copy that adds the offset on + // top of what `MutableArrayData` already applies reads past the end of + // the values buffer and panics. + let array = BooleanArray::from(vec![true, false, true, false, true, false, true, false]); + for (offset, len) in [(0usize, 8usize), (1, 7), (3, 5), (7, 1)] { + let sliced = array.slice(offset, len); + let copied = super::deep_copy_array_sliced(&sliced); + let copied = copied.as_any().downcast_ref::().unwrap(); + let expected: Vec = (0..len).map(|i| sliced.value(i)).collect(); + let actual: Vec = (0..len).map(|i| copied.value(i)).collect(); + assert_eq!(actual, expected, "offset={offset} len={len}"); + } + } + + #[test] + fn sliced_boolean_deep_copy_keeps_its_nulls() { + let array = BooleanArray::from(vec![ + Some(true), + None, + Some(false), + Some(true), + None, + Some(false), + ]); + let sliced = array.slice(1, 4); + let copied = super::deep_copy_array_sliced(&sliced); + let copied = copied.as_any().downcast_ref::().unwrap(); + let expected: Vec> = (0..sliced.len()) + .map(|i| (!sliced.is_null(i)).then(|| sliced.value(i))) + .collect(); + let actual: Vec> = (0..copied.len()) + .map(|i| (!copied.is_null(i)).then(|| copied.value(i))) + .collect(); + assert_eq!(actual, expected); + } + #[test] fn test_deep_copy_sliced_array_with_nulls() { let array = Arc::new(Int32Array::from(vec![ diff --git a/rust/lance-arrow/src/floats.rs b/rust/lance-arrow/src/floats.rs index fcacbdd7eb5..ad5f3768980 100644 --- a/rust/lance-arrow/src/floats.rs +++ b/rust/lance-arrow/src/floats.rs @@ -12,16 +12,16 @@ use std::{ }; use arrow_array::{ - types::{Float16Type, Float32Type, Float64Type}, Array, FixedSizeBinaryArray, Float16Array, Float32Array, Float64Array, + types::{Float16Type, Float32Type, Float64Type}, }; use arrow_schema::{DataType, Field}; use half::{bf16, f16}; use num_traits::{AsPrimitive, Bounded, Float, FromPrimitive}; use super::bfloat16::{BFloat16Array, BFloat16Type}; -use crate::bfloat16::is_bfloat16_field; use crate::Result; +use crate::bfloat16::is_bfloat16_field; /// Float data type. /// @@ -184,6 +184,22 @@ pub trait FloatArray: Array + Clone + 'static { type FloatType: ArrowFloatType; /// Returns a reference to the underlying data as a slice. + /// + /// # Panics + /// + /// Implementations may panic if the array's storage shape does not match + /// the expected element layout. In particular, the `bf16` impl panics if + /// `value_length() != 2` (the `FixedSizeBinary(2)` shape required by + /// `BFloat16Array`). + /// + /// # Preconditions + /// + /// Implementations may impose additional invariants on the underlying + /// buffer. The `bf16` impl requires the value buffer to be at least + /// 2-byte aligned — satisfied automatically by every in-tree Lance + /// constructor, but external callers passing externally-built arrays + /// (FFI, IPC, `Buffer::from_custom_allocation`) must ensure alignment. + /// See the impl's docstring for details. fn as_slice(&self) -> &[T::Native]; /// Construct an array from a vector of values. diff --git a/rust/lance-arrow/src/ipc.rs b/rust/lance-arrow/src/ipc.rs new file mode 100644 index 00000000000..8b6e5cf41fe --- /dev/null +++ b/rust/lance-arrow/src/ipc.rs @@ -0,0 +1,620 @@ +// 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] == IPC_CONTINUATION; + 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}" + ))), + } +} + +// --------------------------------------------------------------------------- +// Aligned IPC sections +// --------------------------------------------------------------------------- + +/// Byte alignment that each IPC section's stream start is padded to. +/// +/// When several IPC streams are concatenated into one larger blob (e.g. a +/// cache entry), a section that starts at an arbitrary offset would leave its +/// array data misaligned. [`FileDecoder`] with `require_alignment = false` +/// then silently copies each buffer into a freshly aligned allocation on +/// every read, defeating zero-copy. Padding each section start to a 64-byte +/// boundary keeps the decoded buffers borrowed directly from the input. +pub const IPC_SECTION_ALIGNMENT: usize = 64; + +/// Number of zero-padding bytes needed to advance `pos` to the next +/// [`IPC_SECTION_ALIGNMENT`] boundary. +fn section_padding(pos: usize) -> usize { + (IPC_SECTION_ALIGNMENT - (pos % IPC_SECTION_ALIGNMENT)) % IPC_SECTION_ALIGNMENT +} + +/// A [`Write`] adapter that counts the bytes written through it. +struct CountingWriter<'a> { + inner: &'a mut dyn Write, + count: usize, +} + +impl Write for CountingWriter<'_> { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + let n = self.inner.write(buf)?; + self.count += n; + Ok(n) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.inner.flush() + } +} + +/// Write zero padding so the next byte lands on an [`IPC_SECTION_ALIGNMENT`] +/// boundary, advancing `pos` past it. +fn write_section_padding(writer: &mut dyn Write, pos: &mut usize) -> Result<(), ArrowError> { + let pad = section_padding(*pos); + if pad > 0 { + const ZEROS: [u8; IPC_SECTION_ALIGNMENT] = [0u8; IPC_SECTION_ALIGNMENT]; + writer + .write_all(&ZEROS[..pad]) + .map_err(|e| ArrowError::IoError(e.to_string(), e))?; + *pos += pad; + } + Ok(()) +} + +/// Write `batch` as a 64-byte-aligned single-batch Arrow IPC section. +/// +/// `pos` is the absolute byte offset of `writer` within the enclosing blob. +/// Zero padding is written first so the IPC stream begins on an +/// [`IPC_SECTION_ALIGNMENT`] boundary, then the stream itself. `pos` is +/// advanced past both the padding and the stream so the caller can write +/// further aligned sections. +/// +/// Paired with [`read_ipc_section_at`]. For the decoded buffers to be borrowed +/// zero-copy, the blob must ultimately be read back from a buffer whose base +/// address is at least 64-byte aligned. +pub fn write_ipc_section( + writer: &mut dyn Write, + pos: &mut usize, + batch: &RecordBatch, +) -> Result<(), ArrowError> { + write_section_padding(writer, pos)?; + + let mut counting = CountingWriter { + inner: writer, + count: 0, + }; + write_ipc_stream(batch, &mut counting)?; + *pos += counting.count; + Ok(()) +} + +/// Read a single [`RecordBatch`] from an aligned IPC section at `offset`. +/// +/// Skips the alignment padding written by [`write_ipc_section`], then reads +/// the stream, advancing `offset` past the section (padding + stream + EOS). +/// +/// Zero-copy: array buffers borrow from `data`'s allocation when `data`'s base +/// address is at least 64-byte aligned (see [`write_ipc_section`]). +pub fn read_ipc_section_at(data: &Bytes, offset: &mut usize) -> Result { + *offset += section_padding(*offset); + read_ipc_stream_single_at(data, offset) +} + +/// Write `batches` as a single 64-byte-aligned multi-batch Arrow IPC section. +/// +/// Like [`write_ipc_section`] but emits every batch from `iter` into one IPC +/// stream (schema + N batches + EOS). `iter` must yield at least one batch. +/// Paired with [`read_ipc_section_batches_at`]. +pub fn write_ipc_section_batches( + writer: &mut dyn Write, + pos: &mut usize, + iter: I, +) -> Result<(), ArrowError> +where + I: IntoIterator, +{ + write_section_padding(writer, pos)?; + + let mut counting = CountingWriter { + inner: writer, + count: 0, + }; + write_ipc_stream_batches(iter, &mut counting)?; + *pos += counting.count; + Ok(()) +} + +/// Read all [`RecordBatch`]es from an aligned multi-batch IPC section at +/// `offset`, advancing `offset` past the section (padding + stream + EOS). +/// +/// Zero-copy: array buffers borrow from `data`'s allocation when `data`'s base +/// address is at least 64-byte aligned (see [`write_ipc_section_batches`]). +pub fn read_ipc_section_batches_at( + data: &Bytes, + offset: &mut usize, +) -> Result, ArrowError> { + *offset += section_padding(*offset); + read_ipc_stream_at(data, offset) +} + +#[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)); + } + } + + /// Allocate a [`Bytes`] whose base address is 64-byte aligned, modelling a + /// backend that reads cache entries into an aligned buffer. A plain + /// `Bytes::from(vec)` only guarantees the allocator's alignment for `u8`. + fn aligned_bytes(payload: &[u8]) -> Bytes { + let mut v = vec![0u8; payload.len() + IPC_SECTION_ALIGNMENT]; + let pad = section_padding(v.as_ptr() as usize); + v[pad..pad + payload.len()].copy_from_slice(payload); + Bytes::from(v).slice(pad..pad + payload.len()) + } + + #[test] + fn test_aligned_ipc_sections_are_zero_copy() { + // A LargeBinary column exercises the i64-offset buffer whose 8-byte + // alignment requirement triggers a realigning memcpy when misaligned. + let blocks = arrow_array::LargeBinaryArray::from_vec(vec![&b"hello"[..], b"world"]); + let section_a = RecordBatch::try_from_iter([("a", Arc::new(blocks) as ArrayRef)]).unwrap(); + let section_b = record_batch!(("b", Int64, [10i64, 20, 30, 40, 50])).unwrap(); + + let mut buf = Vec::new(); + // Arbitrary, deliberately non-64-aligned preamble so the first section + // must be padded rather than landing at offset 0 by luck. + buf.extend_from_slice(&[0xABu8; 7]); + let mut pos = buf.len(); + // The first section's stream begins after padding the 7-byte preamble + // up to the next 64-byte boundary. + assert_eq!(7 + section_padding(7), IPC_SECTION_ALIGNMENT); + write_ipc_section(&mut buf, &mut pos, §ion_a).unwrap(); + write_ipc_section(&mut buf, &mut pos, §ion_b).unwrap(); + + let data = aligned_bytes(&buf); + assert_eq!( + section_padding(data.as_ptr() as usize), + 0, + "base not aligned" + ); + + let mut offset = 7; + let read_a = read_ipc_section_at(&data, &mut offset).unwrap(); + let read_b = read_ipc_section_at(&data, &mut offset).unwrap(); + assert_eq!(read_a, section_a); + assert_eq!(read_b, section_b); + + let data_base = data.as_ptr() as usize; + let data_end = data_base + data.len(); + for batch in [&read_a, &read_b] { + for buffer in batch.column(0).to_data().buffers() { + let ptr = buffer.as_ptr() as usize; + assert!( + ptr >= data_base && ptr < data_end, + "section buffer at {ptr:#x} was realigned out of the input \ + [{data_base:#x}..{data_end:#x}) — misaligned section", + ); + } + } + } + + #[test] + fn test_aligned_multi_batch_section_roundtrip_zero_copy() { + // A multi-batch section (e.g. IVF SQ storage chunks) must round-trip + // every batch and decode the first batch's buffers zero-copy. + let b1 = record_batch!(("v", Int64, [1i64, 2, 3])).unwrap(); + let b2 = record_batch!(("v", Int64, [4i64, 5])).unwrap(); + let b3 = record_batch!(("v", Int64, [6i64])).unwrap(); + + let mut buf = vec![0xCDu8; 5]; + let mut pos = buf.len(); + write_ipc_section_batches(&mut buf, &mut pos, [b1.clone(), b2.clone(), b3.clone()]) + .unwrap(); + + let data = aligned_bytes(&buf); + let mut offset = 5; + let read = read_ipc_section_batches_at(&data, &mut offset).unwrap(); + assert_eq!(read, vec![b1, b2, b3]); + assert_eq!(offset, buf.len(), "offset should land at section end"); + + let data_base = data.as_ptr() as usize; + let data_end = data_base + data.len(); + for buffer in read[0].column(0).to_data().buffers() { + let ptr = buffer.as_ptr() as usize; + assert!( + ptr >= data_base && ptr < data_end, + "first batch buffer at {ptr:#x} was realigned out of the input", + ); + } + } +} diff --git a/rust/lance-arrow/src/json.rs b/rust/lance-arrow/src/json.rs index d11240ff7a4..3fadce1f30c 100644 --- a/rust/lance-arrow/src/json.rs +++ b/rust/lance-arrow/src/json.rs @@ -6,9 +6,15 @@ use std::convert::TryFrom; use std::sync::Arc; -use arrow_array::builder::LargeBinaryBuilder; -use arrow_array::{Array, ArrayRef, LargeBinaryArray, LargeStringArray, RecordBatch, StringArray}; -use arrow_schema::{ArrowError, DataType, Field as ArrowField, Schema}; +use arrow_array::builder::{GenericStringBuilder, LargeBinaryBuilder}; +use arrow_array::cast::AsArray; +use arrow_array::{ + Array, ArrayRef, FixedSizeListArray, GenericStringArray, LargeBinaryArray, LargeListArray, + LargeStringArray, ListArray, MapArray, OffsetSizeTrait, RecordBatch, StringArray, StructArray, +}; +use arrow_schema::{ArrowError, DataType, Field as ArrowField, Fields, Schema}; +use jsonb::OwnedJsonb; +use jsonb::jsonpath::{JsonPath, Selector}; use crate::ARROW_EXT_NAME_KEY; @@ -55,6 +61,88 @@ pub fn has_json_fields(field: &ArrowField) -> bool { } } +/// Check if a field or any of its descendants is an Arrow JSON field +pub fn has_arrow_json_fields(field: &ArrowField) -> bool { + if is_arrow_json_field(field) { + return true; + } + + match field.data_type() { + DataType::Struct(fields) => fields.iter().any(|f| has_arrow_json_fields(f)), + DataType::List(f) | DataType::LargeList(f) | DataType::FixedSizeList(f, _) => { + has_arrow_json_fields(f) + } + DataType::Map(f, _) => has_arrow_json_fields(f), + _ => false, + } +} + +/// The Arrow representation holding a JSON field's values. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum JsonEncoding { + /// JSONB bytes: [`JSON_EXT_NAME`] over `LargeBinary`. Lance stores JSON + /// this way. + Jsonb, + /// JSON text: [`ARROW_JSON_EXT_NAME`] over `Utf8` or `LargeUtf8`. Callers + /// write JSON this way, and reads return it this way by default. + Text, +} + +impl JsonEncoding { + /// The encoding of `field`'s values, or `None` when `field` does not hold + /// JSON. + pub fn of_field(field: &ArrowField) -> Option { + if is_json_field(field) { + Some(Self::Jsonb) + } else if is_arrow_json_field(field) { + Some(Self::Text) + } else { + None + } + } +} + +/// The values of a JSON column, readable as JSONB or as text whichever +/// encoding they arrived in. +/// +/// Code that computes on JSON, such as tokenizers and indices, reads values +/// through this type, so its behavior does not depend on whether they come +/// from storage or from a caller. +#[derive(Debug, Clone, Copy)] +pub struct JsonValues<'a> { + encoding: JsonEncoding, + array: &'a ArrayRef, +} + +impl<'a> JsonValues<'a> { + /// Read `array` as the values of `field`, or `None` when `field` does not + /// hold JSON. + pub fn try_new(field: &ArrowField, array: &'a ArrayRef) -> Option { + JsonEncoding::of_field(field).map(|encoding| Self { encoding, array }) + } + + pub fn encoding(&self) -> JsonEncoding { + self.encoding + } + + /// The values as JSONB. Text values are parsed, so invalid JSON fails. + pub fn to_jsonb(&self) -> Result { + match self.encoding { + JsonEncoding::Jsonb => Ok(self.array.as_binary::().clone()), + JsonEncoding::Text => Ok(JsonArray::try_from(self.array.clone())?.into_inner()), + } + } + + /// The values as JSON text: the input array when it already holds text, + /// otherwise `LargeUtf8` decoded from JSONB. + pub fn to_text(&self) -> ArrayRef { + match self.encoding { + JsonEncoding::Jsonb => Arc::new(decode_jsonb_array::(self.array.as_binary())), + JsonEncoding::Text => self.array.clone(), + } + } +} + /// Create a JSON field with the appropriate extension metadata pub fn json_field(name: &str, nullable: bool) -> ArrowField { let mut field = ArrowField::new(name, DataType::LargeBinary, nullable); @@ -129,28 +217,13 @@ impl JsonArray { return Ok(None); } - let jsonb_bytes = self.inner.value(i); - get_json_path(jsonb_bytes, path).map_err(|e| { - ArrowError::InvalidArgumentError(format!("Failed to extract JSONPath: {}", e)) - }) + let path = parse_json_path(path)?; + Ok(select_json_path(self.inner.value(i), &path)?.map(|value| value.to_string())) } /// Convert to Arrow string array (JSON as UTF-8) pub fn to_arrow_json(&self) -> ArrayRef { - let mut builder = arrow_array::builder::StringBuilder::new(); - - for i in 0..self.inner.len() { - if self.inner.is_null(i) { - builder.append_null(); - } else { - let jsonb_bytes = self.inner.value(i); - let json_str = decode_json(jsonb_bytes); - builder.append_value(&json_str); - } - } - - // Return as UTF-8 string array (Arrow represents JSON as strings) - Arc::new(builder.finish()) + Arc::new(decode_jsonb_array::(&self.inner)) } pub fn len(&self) -> usize { @@ -179,23 +252,7 @@ impl TryFrom<&StringArray> for JsonArray { type Error = ArrowError; fn try_from(array: &StringArray) -> Result { - let mut builder = LargeBinaryBuilder::with_capacity(array.len(), array.value_data().len()); - - for i in 0..array.len() { - if array.is_null(i) { - builder.append_null(); - } else { - let json_str = array.value(i); - let encoded = encode_json(json_str).map_err(|e| { - ArrowError::InvalidArgumentError(format!("Failed to encode JSON: {}", e)) - })?; - builder.append_value(&encoded); - } - } - - Ok(Self { - inner: builder.finish(), - }) + encode_json_text_array(array) } } @@ -211,24 +268,40 @@ impl TryFrom<&LargeStringArray> for JsonArray { type Error = ArrowError; fn try_from(array: &LargeStringArray) -> Result { - let mut builder = LargeBinaryBuilder::with_capacity(array.len(), array.value_data().len()); + encode_json_text_array(array) + } +} - for i in 0..array.len() { - if array.is_null(i) { - builder.append_null(); - } else { - let json_str = array.value(i); +fn encode_json_text_array( + array: &GenericStringArray, +) -> Result { + let mut builder = LargeBinaryBuilder::with_capacity(array.len(), array.value_data().len()); + for value in array { + match value { + Some(json_str) => { let encoded = encode_json(json_str).map_err(|e| { ArrowError::InvalidArgumentError(format!("Failed to encode JSON: {}", e)) })?; builder.append_value(&encoded); } + None => builder.append_null(), } + } + Ok(JsonArray { + inner: builder.finish(), + }) +} - Ok(Self { - inner: builder.finish(), - }) +fn decode_jsonb_array(array: &LargeBinaryArray) -> GenericStringArray { + let mut builder = + GenericStringBuilder::::with_capacity(array.len(), array.value_data().len()); + for value in array { + match value { + Some(jsonb_bytes) => builder.append_value(decode_json(jsonb_bytes)), + None => builder.append_null(), + } } + builder.finish() } impl TryFrom for JsonArray { @@ -272,43 +345,280 @@ pub fn decode_json(jsonb_bytes: &[u8]) -> String { raw_jsonb.to_string() } -/// Extract JSONPath value from JSONB -fn get_json_path( +/// Parse a JSONPath expression. +pub fn parse_json_path(path: &str) -> Result, ArrowError> { + jsonb::jsonpath::parse_json_path(path.as_bytes()) + .map_err(|e| ArrowError::InvalidArgumentError(format!("Invalid JSONPath '{path}': {e}"))) +} + +/// Select the value at `path` in a JSONB value. +/// +/// A path matching several values selects them together as one JSON array, +/// so no match is silently dropped. +pub fn select_json_path( jsonb_bytes: &[u8], - path: &str, -) -> Result, Box> { - let json_path = jsonb::jsonpath::parse_json_path(path.as_bytes())?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); + path: &JsonPath<'_>, +) -> Result, ArrowError> { + Selector::new(jsonb::RawJsonb::new(jsonb_bytes)) + .select_value(path) + .map_err(|e| { + ArrowError::InvalidArgumentError(format!("Failed to select JSONPath {path}: {e}")) + }) +} - let values = selector.select_values(&json_path)?; - if values.is_empty() { - Ok(None) - } else { - Ok(Some(values[0].to_string())) - } +/// Whether `path` matches any value in a JSONB value. +pub fn json_path_exists(jsonb_bytes: &[u8], path: &JsonPath<'_>) -> Result { + Selector::new(jsonb::RawJsonb::new(jsonb_bytes)) + .exists(path) + .map_err(|e| { + ArrowError::InvalidArgumentError(format!("Failed to match JSONPath {path}: {e}")) + }) +} + +/// Select every value matched by `path` in a JSONB value. +pub fn select_json_path_values( + jsonb_bytes: &[u8], + path: &JsonPath<'_>, +) -> Result, ArrowError> { + Selector::new(jsonb::RawJsonb::new(jsonb_bytes)) + .select_values(path) + .map_err(|e| { + ArrowError::InvalidArgumentError(format!("Failed to select JSONPath {path}: {e}")) + }) } /// Convert an Arrow JSON field to Lance JSON field (with JSONB storage) pub fn arrow_json_to_lance_json(field: &ArrowField) -> ArrowField { if is_arrow_json_field(field) { - // Convert Arrow JSON (Utf8/LargeUtf8) to Lance JSON (LargeBinary) - // Preserve all metadata from the original field - let mut new_field = - ArrowField::new(field.name(), DataType::LargeBinary, field.is_nullable()); - - // Copy all metadata from the original field - let mut metadata = field.metadata().clone(); - // Add/override the extension metadata for Lance JSON - metadata.insert(ARROW_EXT_NAME_KEY.to_string(), JSON_EXT_NAME.to_string()); - - new_field = new_field.with_metadata(metadata); - new_field - } else { - field.clone() + return field_with_extension(field, DataType::LargeBinary, JSON_EXT_NAME); + } + + let data_type = match field.data_type() { + DataType::Struct(fields) => { + let fields = fields + .iter() + .map(|field| Arc::new(arrow_json_to_lance_json(field))) + .collect::>(); + DataType::Struct(Fields::from(fields)) + } + DataType::List(item) => DataType::List(Arc::new(arrow_json_to_lance_json(item))), + DataType::LargeList(item) => DataType::LargeList(Arc::new(arrow_json_to_lance_json(item))), + DataType::FixedSizeList(item, size) => { + DataType::FixedSizeList(Arc::new(arrow_json_to_lance_json(item)), *size) + } + DataType::Map(entries, keys_sorted) => { + DataType::Map(Arc::new(arrow_json_to_lance_json(entries)), *keys_sorted) + } + _ => return field.clone(), + }; + + field_with_data_type(field, data_type) +} + +/// Convert a Lance JSON field to Arrow JSON field. +pub fn lance_json_to_arrow_json(field: &ArrowField) -> ArrowField { + if is_json_field(field) { + return field_with_extension(field, DataType::Utf8, ARROW_JSON_EXT_NAME); + } + + let data_type = match field.data_type() { + DataType::Struct(fields) => { + let fields = fields + .iter() + .map(|field| Arc::new(lance_json_to_arrow_json(field))) + .collect::>(); + DataType::Struct(Fields::from(fields)) + } + DataType::List(item) => DataType::List(Arc::new(lance_json_to_arrow_json(item))), + DataType::LargeList(item) => DataType::LargeList(Arc::new(lance_json_to_arrow_json(item))), + DataType::FixedSizeList(item, size) => { + DataType::FixedSizeList(Arc::new(lance_json_to_arrow_json(item)), *size) + } + DataType::Map(entries, keys_sorted) => { + DataType::Map(Arc::new(lance_json_to_arrow_json(entries)), *keys_sorted) + } + _ => return field.clone(), + }; + + field_with_data_type(field, data_type) +} + +fn field_with_data_type(field: &ArrowField, data_type: DataType) -> ArrowField { + ArrowField::new(field.name(), data_type, field.is_nullable()) + .with_metadata(field.metadata().clone()) +} + +fn field_with_extension( + field: &ArrowField, + data_type: DataType, + extension_name: &str, +) -> ArrowField { + let mut metadata = field.metadata().clone(); + metadata.insert(ARROW_EXT_NAME_KEY.to_string(), extension_name.to_string()); + ArrowField::new(field.name(), data_type, field.is_nullable()).with_metadata(metadata) +} + +fn convert_json_array( + field: &ArrowField, + array: &ArrayRef, + convert_leaf: &F, +) -> Result<(ArrowField, ArrayRef, bool), ArrowError> +where + F: Fn(&ArrowField, &ArrayRef) -> Result, ArrowError>, +{ + if let Some((field, array)) = convert_leaf(field, array)? { + return Ok((field, array, true)); + } + + match field.data_type() { + DataType::Struct(fields) => { + let struct_array = array.as_struct(); + let mut new_fields = Vec::with_capacity(fields.len()); + let mut new_columns = Vec::with_capacity(fields.len()); + let mut changed = false; + + for (field, column) in fields.iter().zip(struct_array.columns()) { + let (new_field, new_column, field_changed) = + convert_json_array(field, column, convert_leaf)?; + changed |= field_changed; + new_fields.push(Arc::new(new_field)); + new_columns.push(new_column); + } + + if changed { + let fields = Fields::from(new_fields); + let new_field = field_with_data_type(field, DataType::Struct(fields.clone())); + let new_array = + StructArray::new(fields, new_columns, struct_array.nulls().cloned()); + Ok((new_field, Arc::new(new_array) as ArrayRef, true)) + } else { + Ok((field.clone(), array.clone(), false)) + } + } + DataType::List(item) => { + let list_array: &ListArray = array.as_list(); + let (new_item, new_values, changed) = + convert_json_array(item, list_array.values(), convert_leaf)?; + if changed { + let new_field = + field_with_data_type(field, DataType::List(Arc::new(new_item.clone()))); + let new_array = ListArray::new( + Arc::new(new_item), + list_array.offsets().clone(), + new_values, + list_array.nulls().cloned(), + ); + Ok((new_field, Arc::new(new_array) as ArrayRef, true)) + } else { + Ok((field.clone(), array.clone(), false)) + } + } + DataType::LargeList(item) => { + let list_array: &LargeListArray = array.as_list(); + let (new_item, new_values, changed) = + convert_json_array(item, list_array.values(), convert_leaf)?; + if changed { + let new_field = + field_with_data_type(field, DataType::LargeList(Arc::new(new_item.clone()))); + let new_array = LargeListArray::new( + Arc::new(new_item), + list_array.offsets().clone(), + new_values, + list_array.nulls().cloned(), + ); + Ok((new_field, Arc::new(new_array) as ArrayRef, true)) + } else { + Ok((field.clone(), array.clone(), false)) + } + } + DataType::FixedSizeList(item, size) => { + let list_array: &FixedSizeListArray = array.as_fixed_size_list(); + let (new_item, new_values, changed) = + convert_json_array(item, list_array.values(), convert_leaf)?; + if changed { + let new_field = field_with_data_type( + field, + DataType::FixedSizeList(Arc::new(new_item.clone()), *size), + ); + let new_array = FixedSizeListArray::try_new_with_length( + Arc::new(new_item), + *size, + new_values, + list_array.nulls().cloned(), + list_array.len(), + )?; + Ok((new_field, Arc::new(new_array) as ArrayRef, true)) + } else { + Ok((field.clone(), array.clone(), false)) + } + } + DataType::Map(entries, keys_sorted) => { + let map_array = array + .as_any() + .downcast_ref::() + .expect("DataType::Map array must be MapArray"); + let entries_array = Arc::new(map_array.entries().clone()) as ArrayRef; + let (new_entries, new_entries_array, changed) = + convert_json_array(entries, &entries_array, convert_leaf)?; + if changed { + let entries_struct = new_entries_array + .as_any() + .downcast_ref::() + .expect("Map entries must be StructArray") + .clone(); + let new_field = field_with_data_type( + field, + DataType::Map(Arc::new(new_entries.clone()), *keys_sorted), + ); + let new_array = MapArray::new( + Arc::new(new_entries), + map_array.offsets().clone(), + entries_struct, + map_array.nulls().cloned(), + *keys_sorted, + ); + Ok((new_field, Arc::new(new_array) as ArrayRef, true)) + } else { + Ok((field.clone(), array.clone(), false)) + } + } + _ => Ok((field.clone(), array.clone(), false)), } } +fn convert_arrow_json_array( + field: &ArrowField, + array: &ArrayRef, +) -> Result<(ArrowField, ArrayRef, bool), ArrowError> { + convert_json_array(field, array, &|field, array| { + if is_arrow_json_field(field) { + let json_array = JsonArray::try_from(array.clone())?; + Ok(Some(( + arrow_json_to_lance_json(field), + Arc::new(json_array.into_inner()) as ArrayRef, + ))) + } else { + Ok(None) + } + }) +} + +fn convert_lance_json_array( + field: &ArrowField, + array: &ArrayRef, +) -> Result<(ArrowField, ArrayRef, bool), ArrowError> { + convert_json_array(field, array, &|field, array| { + if is_json_field(field) { + Ok(Some(( + lance_json_to_arrow_json(field), + Arc::new(decode_jsonb_array::(array.as_binary())) as ArrayRef, + ))) + } else { + Ok(None) + } + }) +} + /// Convert a RecordBatch with Lance JSON columns (JSONB) back to Arrow JSON format (strings) pub fn convert_lance_json_to_arrow( batch: &arrow_array::RecordBatch, @@ -320,50 +630,11 @@ pub fn convert_lance_json_to_arrow( for (i, field) in schema.fields().iter().enumerate() { let column = batch.column(i); + let (new_field, new_column, changed) = convert_lance_json_array(field, column)?; - if is_json_field(field) { - needs_conversion = true; - - // Convert the field back to Arrow JSON (Utf8) - let mut new_field = ArrowField::new(field.name(), DataType::Utf8, field.is_nullable()); - let mut metadata = field.metadata().clone(); - // Change from lance.json to arrow.json - metadata.insert( - ARROW_EXT_NAME_KEY.to_string(), - ARROW_JSON_EXT_NAME.to_string(), - ); - new_field.set_metadata(metadata); - new_fields.push(new_field); - - // Convert the data from JSONB to JSON strings - if batch.num_rows() == 0 { - // For empty batches, create an empty String array - let empty_strings = arrow_array::builder::StringBuilder::new().finish(); - new_columns.push(Arc::new(empty_strings) as ArrayRef); - } else { - // Convert JSONB back to JSON strings - // Downcast is guaranteed to succeed since is_json_field verified the type - let binary_array = column - .as_any() - .downcast_ref::() - .expect("Lance JSON field must be LargeBinaryArray"); - - let mut builder = arrow_array::builder::StringBuilder::new(); - for i in 0..binary_array.len() { - if binary_array.is_null(i) { - builder.append_null(); - } else { - let jsonb_bytes = binary_array.value(i); - let json_str = decode_json(jsonb_bytes); - builder.append_value(&json_str); - } - } - new_columns.push(Arc::new(builder.finish()) as ArrayRef); - } - } else { - new_fields.push(field.as_ref().clone()); - new_columns.push(column.clone()); - } + needs_conversion |= changed; + new_fields.push(new_field); + new_columns.push(new_column); } if needs_conversion { @@ -389,40 +660,11 @@ pub fn convert_json_columns( for (i, field) in schema.fields().iter().enumerate() { let column = batch.column(i); + let (new_field, new_column, changed) = convert_arrow_json_array(field, column)?; - if is_arrow_json_field(field) { - needs_conversion = true; - - // Convert the field metadata - new_fields.push(arrow_json_to_lance_json(field)); - - // Convert the data from JSON strings to JSONB - if batch.num_rows() == 0 { - // For empty batches, create an empty LargeBinary array - let empty_binary = LargeBinaryBuilder::new().finish(); - new_columns.push(Arc::new(empty_binary) as ArrayRef); - } else { - // Convert non-empty data - // is_arrow_json_field guarantees type is Utf8 or LargeUtf8 - let json_array = - if let Some(string_array) = column.as_any().downcast_ref::() { - JsonArray::try_from(string_array)? - } else { - let large_string_array = column - .as_any() - .downcast_ref::() - .expect("Arrow JSON field must be Utf8 or LargeUtf8"); - JsonArray::try_from(large_string_array)? - }; - - let binary_array = json_array.into_inner(); - - new_columns.push(Arc::new(binary_array) as ArrayRef); - } - } else { - new_fields.push(field.as_ref().clone()); - new_columns.push(column.clone()); - } + needs_conversion |= changed; + new_fields.push(new_field); + new_columns.push(new_column); } if needs_conversion { @@ -496,6 +738,41 @@ mod tests { let age = json_array.json_path(1, "$.user.age").unwrap(); assert_eq!(age, None); + + // Several matches are selected together, as the SQL JSON functions do. + let json_array = + JsonArray::try_from_iter(vec![Some(r#"{"a": [{"b": 1}, {"b": 2}]}"#)]).unwrap(); + let bs = json_array.json_path(0, "$.a[*].b").unwrap(); + assert_eq!(bs, Some("[1,2]".to_string())); + } + + #[test] + fn test_json_values_read_either_encoding() { + let docs = vec![Some(r#"{"a":1}"#), None]; + let text: ArrayRef = Arc::new(StringArray::from(docs.clone())); + let jsonb: ArrayRef = Arc::new(JsonArray::try_from_iter(docs).unwrap().into_inner()); + let mut text_field = ArrowField::new("j", DataType::Utf8, true); + text_field.set_metadata(std::collections::HashMap::from([( + ARROW_EXT_NAME_KEY.to_string(), + ARROW_JSON_EXT_NAME.to_string(), + )])); + let jsonb_field = json_field("j", true); + + let from_text = JsonValues::try_new(&text_field, &text).unwrap(); + let from_jsonb = JsonValues::try_new(&jsonb_field, &jsonb).unwrap(); + assert_eq!(from_text.encoding(), JsonEncoding::Text); + assert_eq!(from_jsonb.encoding(), JsonEncoding::Jsonb); + + assert_eq!(&from_text.to_jsonb().unwrap(), jsonb.as_binary::()); + assert_eq!(&from_jsonb.to_jsonb().unwrap(), jsonb.as_binary::()); + assert_eq!(&from_text.to_text(), &text); + let decoded = from_jsonb.to_text(); + let decoded = decoded.as_string::(); + assert_eq!(decoded.value(0), r#"{"a":1}"#); + assert!(decoded.is_null(1)); + + let plain = ArrowField::new("j", DataType::Utf8, true); + assert!(JsonValues::try_new(&plain, &text).is_none()); } #[test] @@ -546,6 +823,128 @@ mod tests { } } + #[test] + fn test_convert_nested_json_columns() { + use arrow_buffer::{OffsetBuffer, ScalarBuffer}; + + let uri_field = Arc::new(ArrowField::new("uri", DataType::Utf8, false)); + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + ARROW_EXT_NAME_KEY.to_string(), + ARROW_JSON_EXT_NAME.to_string(), + ); + let extra_field = + Arc::new(ArrowField::new("extra", DataType::Utf8, true).with_metadata(metadata)); + let item_fields = Fields::from(vec![uri_field, extra_field]); + + let values = StructArray::new( + item_fields.clone(), + vec![ + Arc::new(StringArray::from(vec![Some("a.jpg"), Some("b.jpg")])) as ArrayRef, + Arc::new(StringArray::from(vec![ + Some(r#"{"codec":"h264"}"#), + None::<&str>, + ])) as ArrayRef, + ], + None, + ); + let item = Arc::new(ArrowField::new("item", DataType::Struct(item_fields), true)); + let media = ListArray::new( + item, + OffsetBuffer::new(ScalarBuffer::from(vec![0, 1, 2])), + Arc::new(values), + None, + ); + let schema = Arc::new(Schema::new(vec![ArrowField::new( + "media", + media.data_type().clone(), + true, + )])); + let batch = RecordBatch::try_new(schema, vec![Arc::new(media) as ArrayRef]).unwrap(); + + assert!(has_arrow_json_fields(batch.schema().field(0))); + + let converted = convert_json_columns(&batch).unwrap(); + let converted_schema = converted.schema(); + let DataType::List(item) = converted_schema.field(0).data_type() else { + panic!("expected list field"); + }; + let DataType::Struct(fields) = item.data_type() else { + panic!("expected struct item"); + }; + assert!(is_json_field(&fields[1])); + + let list_array: &ListArray = converted.column(0).as_list(); + let values = list_array.values().as_struct(); + let extra = values + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(decode_json(extra.value(0)).contains("h264")); + assert!(extra.is_null(1)); + + let logical = convert_lance_json_to_arrow(&converted).unwrap(); + let logical_schema = logical.schema(); + let DataType::List(item) = logical_schema.field(0).data_type() else { + panic!("expected list field"); + }; + let DataType::Struct(fields) = item.data_type() else { + panic!("expected struct item"); + }; + assert!(is_arrow_json_field(&fields[1])); + + let list_array: &ListArray = logical.column(0).as_list(); + let values = list_array.values().as_struct(); + let extra = values + .column(1) + .as_any() + .downcast_ref::() + .unwrap(); + assert!(extra.value(0).contains("h264")); + assert!(extra.is_null(1)); + } + + #[test] + fn test_convert_fixed_size_list_zero_json_preserves_length() { + let mut metadata = std::collections::HashMap::new(); + metadata.insert( + ARROW_EXT_NAME_KEY.to_string(), + ARROW_JSON_EXT_NAME.to_string(), + ); + let item = Arc::new(ArrowField::new("item", DataType::Utf8, true).with_metadata(metadata)); + let values = Arc::new(StringArray::from(Vec::>::new())) as ArrayRef; + let lists = FixedSizeListArray::try_new_with_length(item, 0, values, None, 3).unwrap(); + let schema = Arc::new(Schema::new(vec![ArrowField::new( + "lists", + lists.data_type().clone(), + true, + )])); + let batch = RecordBatch::try_new(schema, vec![Arc::new(lists) as ArrayRef]).unwrap(); + + let converted = convert_json_columns(&batch).unwrap(); + assert_eq!(converted.num_rows(), 3); + assert_eq!(converted.column(0).len(), 3); + + let converted_schema = converted.schema(); + let DataType::FixedSizeList(item, size) = converted_schema.field(0).data_type() else { + panic!("expected fixed size list field"); + }; + assert_eq!(*size, 0); + assert!(is_json_field(item)); + + let logical = convert_lance_json_to_arrow(&converted).unwrap(); + assert_eq!(logical.num_rows(), 3); + assert_eq!(logical.column(0).len(), 3); + + let logical_schema = logical.schema(); + let DataType::FixedSizeList(item, size) = logical_schema.field(0).data_type() else { + panic!("expected fixed size list field"); + }; + assert_eq!(*size, 0); + assert!(is_arrow_json_field(item)); + } + #[test] fn test_has_json_fields() { // Test direct JSON field @@ -998,10 +1397,7 @@ mod tests { // Invalid JSONPath syntax should return error let result = json_array.json_path(0, "invalid path without $"); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Failed to extract JSONPath")); + assert!(result.unwrap_err().to_string().contains("Invalid JSONPath")); } #[test] diff --git a/rust/lance-arrow/src/lib.rs b/rust/lance-arrow/src/lib.rs index 0a61a407395..391da293ab5 100644 --- a/rust/lance-arrow/src/lib.rs +++ b/rust/lance-arrow/src/lib.rs @@ -5,16 +5,26 @@ //! //! To improve Arrow-RS ergonomic +#![warn(clippy::undocumented_unsafe_blocks)] + +// lance-arrow reinterprets value bytes as native numeric types in +// `FloatArray::as_slice` for `bf16` (rust/lance-arrow/src/bfloat16.rs), which +// requires the host byte order to match the on-disk byte order Lance writes. +// Lance writes little-endian; building on a big-endian target would silently +// produce wrong numeric values. +#[cfg(not(target_endian = "little"))] +compile_error!("lance-arrow only supports little-endian targets"); + use std::sync::Arc; use std::{collections::HashMap, ptr::NonNull}; use arrow_array::{ - cast::AsArray, Array, ArrayRef, ArrowNumericType, FixedSizeBinaryArray, FixedSizeListArray, - GenericListArray, LargeListArray, ListArray, OffsetSizeTrait, PrimitiveArray, RecordBatch, - StructArray, UInt32Array, UInt8Array, + Array, ArrayRef, ArrowNumericType, FixedSizeBinaryArray, FixedSizeListArray, GenericListArray, + LargeListArray, ListArray, MapArray, OffsetSizeTrait, PrimitiveArray, RecordBatch, StructArray, + UInt8Array, UInt32Array, cast::AsArray, }; use arrow_array::{ - new_null_array, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, + Float32Array, Float64Array, Int8Array, Int16Array, Int32Array, Int64Array, new_null_array, }; use arrow_buffer::MutableBuffer; use arrow_data::ArrayDataBuilder; @@ -30,11 +40,12 @@ pub mod floats; use crate::list::ListArrayExt; pub use floats::*; -pub mod cast; +pub mod ipc; pub mod json; pub mod list; pub mod memory; pub mod scalar; +pub mod stream; pub mod r#struct; /// Arrow extension metadata key for extension name @@ -51,22 +62,16 @@ pub const BLOB_V2_EXT_NAME: &str = "lance.blob.v2"; /// Metadata key for overriding the dedicated blob size threshold (in bytes) pub const BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY: &str = "lance-encoding:blob-dedicated-size-threshold"; +/// Metadata key for overriding the inline blob size threshold (in bytes) +pub const BLOB_INLINE_SIZE_THRESHOLD_META_KEY: &str = "lance-encoding:blob-inline-size-threshold"; +/// Metadata key for overriding the maximum size (in bytes) of a packed blob sidecar file +pub const BLOB_PACK_FILE_SIZE_THRESHOLD_META_KEY: &str = + "lance-encoding:blob-pack-file-size-threshold"; type Result = std::result::Result; pub trait DataTypeExt { - /// Returns true if the data type is binary-like, such as (Large)Utf8 and (Large)Binary. - /// - /// ``` - /// use lance_arrow::*; - /// use arrow_schema::DataType; - /// - /// assert!(DataType::Utf8.is_binary_like()); - /// assert!(DataType::Binary.is_binary_like()); - /// assert!(DataType::LargeUtf8.is_binary_like()); - /// assert!(DataType::LargeBinary.is_binary_like()); - /// assert!(!DataType::Int32.is_binary_like()); - /// ``` + /// Returns true if the data type is binary-like, such as Utf8, Binary, or the large and/or view variants. fn is_binary_like(&self) -> bool; /// Returns true if the data type is a struct. @@ -93,7 +98,10 @@ pub trait DataTypeExt { impl DataTypeExt for DataType { fn is_binary_like(&self) -> bool { use DataType::*; - matches!(self, Utf8 | Binary | LargeUtf8 | LargeBinary) + matches!( + self, + Utf8 | Binary | LargeUtf8 | LargeBinary | Utf8View | BinaryView + ) } fn is_struct(&self) -> bool { @@ -296,7 +304,7 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float32Array::from_iter_values( + Arc::new(Float32Array::from_iter( self.values() .as_any() .downcast_ref::() @@ -304,7 +312,7 @@ impl FixedSizeListArrayExt for FixedSizeListArray { "Fail to cast primitive array to Int8Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f32)), + .map(|x| x.map(|y| y as f32)), )), self.nulls().cloned(), )), @@ -315,15 +323,15 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float32Array::from_iter_values( + Arc::new(Float32Array::from_iter( self.values() .as_any() .downcast_ref::() .ok_or(ArrowError::ParseError( - "Fail to cast primitive array to Int8Type".to_string(), + "Fail to cast primitive array to Int16Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f32)), + .map(|x| x.map(|y| y as f32)), )), self.nulls().cloned(), )), @@ -334,15 +342,15 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float32Array::from_iter_values( + Arc::new(Float32Array::from_iter( self.values() .as_any() .downcast_ref::() .ok_or(ArrowError::ParseError( - "Fail to cast primitive array to Int8Type".to_string(), + "Fail to cast primitive array to Int32Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f32)), + .map(|x| x.map(|y| y as f32)), )), self.nulls().cloned(), )), @@ -353,15 +361,15 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float64Array::from_iter_values( + Arc::new(Float64Array::from_iter( self.values() .as_any() .downcast_ref::() .ok_or(ArrowError::ParseError( - "Fail to cast primitive array to Int8Type".to_string(), + "Fail to cast primitive array to Int64Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f64)), + .map(|x| x.map(|y| y as f64)), )), self.nulls().cloned(), )), @@ -372,15 +380,15 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float64Array::from_iter_values( + Arc::new(Float64Array::from_iter( self.values() .as_any() .downcast_ref::() .ok_or(ArrowError::ParseError( - "Fail to cast primitive array to Int8Type".to_string(), + "Fail to cast primitive array to UInt8Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f64)), + .map(|x| x.map(|y| y as f64)), )), self.nulls().cloned(), )), @@ -391,15 +399,15 @@ impl FixedSizeListArrayExt for FixedSizeListArray { field.is_nullable(), )), *size, - Arc::new(Float64Array::from_iter_values( + Arc::new(Float64Array::from_iter( self.values() .as_any() .downcast_ref::() .ok_or(ArrowError::ParseError( - "Fail to cast primitive array to Int8Type".to_string(), + "Fail to cast primitive array to UInt32Type".to_string(), ))? .into_iter() - .filter_map(|x| x.map(|y| y as f64)), + .map(|x| x.map(|y| y as f64)), )), self.nulls().cloned(), )), @@ -433,13 +441,9 @@ pub trait FixedSizeBinaryArrayExt { /// let int_values = UInt8Array::from_iter(0..10); /// let fixed_size_list_arr = FixedSizeBinaryArray::try_new_from_values(&int_values, 2).unwrap(); /// assert_eq!(fixed_size_list_arr, - /// FixedSizeBinaryArray::from(vec![ - /// Some(vec![0, 1].as_slice()), - /// Some(vec![2, 3].as_slice()), - /// Some(vec![4, 5].as_slice()), - /// Some(vec![6, 7].as_slice()), - /// Some(vec![8, 9].as_slice()) - /// ])) + /// FixedSizeBinaryArray::try_from_iter( + /// [[0u8, 1], [2, 3], [4, 5], [6, 7], [8, 9]].into_iter() + /// ).unwrap()) /// ``` fn try_new_from_values(values: &UInt8Array, stride: i32) -> Result; } @@ -463,7 +467,25 @@ pub fn iter_str_array(arr: &dyn Array) -> Box> match arr.data_type() { DataType::Utf8 => Box::new(arr.as_string::().iter()), DataType::LargeUtf8 => Box::new(arr.as_string::().iter()), - _ => panic!("Expecting Utf8 or LargeUtf8, found {:?}", arr.data_type()), + DataType::Utf8View => Box::new(arr.as_string_view().iter()), + _ => panic!( + "Expecting Utf8, LargeUtf8 or Utf8View, found {:?}", + arr.data_type() + ), + } +} + +pub fn iter_binary_array( + arr: &dyn Array, +) -> Result> + Send + '_>> { + match arr.data_type() { + DataType::Binary => Ok(Box::new(arr.as_binary::().iter())), + DataType::LargeBinary => Ok(Box::new(arr.as_binary::().iter())), + DataType::BinaryView => Ok(Box::new(arr.as_binary_view().iter())), + DataType::FixedSizeBinary(_) => Ok(Box::new(arr.as_fixed_size_binary().iter())), + data_type => Err(ArrowError::InvalidArgumentError(format!( + "Expecting a binary type, found {data_type}" + ))), } } @@ -838,6 +860,30 @@ fn project_array(array: &ArrayRef, target_field: &Field) -> Result { list_arr.nulls().cloned(), ))) } + // A nullable entries field fails MapArray::try_new unconditionally, + // so a (schema-invalid) map declared that way keeps the clone + // fallthrough it always had rather than gaining a new error. + DataType::Map(entries_field, sorted) if !entries_field.is_nullable() => { + let map_arr = array.as_map(); + let DataType::Struct(entry_fields) = entries_field.data_type() else { + return Err(ArrowError::SchemaError(format!( + "Map entries field must be a struct, got {}", + entries_field.data_type() + ))); + }; + let projected_entries = project(map_arr.entries(), entry_fields)?; + // try_new re-checks the entries invariants (a non-null entries + // struct, two entry columns, offset bounds); null keys are ruled + // out one level down, by the struct rebuild against the + // non-nullable key field. + Ok(Arc::new(MapArray::try_new( + entries_field.clone(), + map_arr.offsets().clone(), + projected_entries, + map_arr.nulls().cloned(), + *sorted, + )?)) + } _ => Ok(array.clone()), } } @@ -1009,40 +1055,25 @@ fn merge_list_struct(left: &dyn Array, right: &dyn Array) -> Arc { } } -/// Helper function to normalize validity buffers -/// Returns None for all-null validity (placeholder structs) -fn normalize_validity( - validity: Option<&arrow_buffer::NullBuffer>, -) -> Option<&arrow_buffer::NullBuffer> { - validity.and_then(|v| { - if v.null_count() == v.len() { - None - } else { - Some(v) - } - }) -} - -/// Helper function to merge validity buffers from two struct arrays -/// Returns None only if both arrays are null at the same position +/// Helper function to merge validity buffers from two struct arrays. /// -/// Special handling for placeholder structs (all-null validity) +/// A row is valid if it is valid in either input. +/// An absent validity buffer means all rows are valid, an all-null buffer acts as the identity for this merge. fn merge_struct_validity( left_validity: Option<&arrow_buffer::NullBuffer>, right_validity: Option<&arrow_buffer::NullBuffer>, ) -> Option { - // Normalize both validity buffers (convert all-null to None) - let left_normalized = normalize_validity(left_validity); - let right_normalized = normalize_validity(right_validity); - - match (left_normalized, right_normalized) { + match (left_validity, right_validity) { // Fast paths: no computation needed - (None, None) => None, - (Some(left), None) => Some(left.clone()), - (None, Some(right)) => Some(right.clone()), + (None, _) | (_, None) => None, (Some(left), Some(right)) => { - // Fast path: if both have no nulls, can return either one - if left.null_count() == 0 && right.null_count() == 0 { + if left.null_count() == 0 || right.null_count() == 0 { + return None; + } + if left.null_count() == left.len() { + return Some(right.clone()); + } + if right.null_count() == right.len() { return Some(left.clone()); } @@ -1153,6 +1184,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 @@ -1231,22 +1268,23 @@ fn merge(left_struct_array: &StructArray, right_struct_array: &StructArray) -> S if left_list.data_type().is_struct() && right_list.data_type().is_struct() => { - // If there is nothing to merge just use the left field + // Identical inner types: nothing to merge, keep the left column. if left_list.data_type() == right_list.data_type() { fields.push(left_field.as_ref().clone()); columns.push(left_column.clone()); + } else { + // The struct fields differ, so merge them structurally. merge_list_struct + // only succeeds when both lists share offsets or one side is all-null; + // it panics otherwise. + let merged_sub_array = merge_list_struct(&left_column, &right_column); + + fields.push(Field::new( + left_field.name(), + merged_sub_array.data_type().clone(), + left_field.is_nullable(), + )); + columns.push(merged_sub_array); } - // If we have two List and they have different sets of fields then - // we can merge them if the offsets arrays are the same. Otherwise, we - // have to consider it an error. - let merged_sub_array = merge_list_struct(&left_column, &right_column); - - fields.push(Field::new( - left_field.name(), - merged_sub_array.data_type().clone(), - left_field.is_nullable(), - )); - columns.push(merged_sub_array); } // otherwise, just use the field on the left hand side _ => { @@ -1348,11 +1386,14 @@ fn merge_with_schema( let right_sub_array = right_columns[right_idx].as_struct(); let merged_sub_array = merge_with_schema(left_sub_array, right_sub_array, child_fields); - output_fields.push(Field::new( - field.name(), - merged_sub_array.data_type().clone(), - field.is_nullable(), - )); + output_fields.push( + Field::new( + field.name(), + merged_sub_array.data_type().clone(), + field.is_nullable(), + ) + .with_metadata(field.metadata().clone()), + ); columns.push(Arc::new(merged_sub_array) as ArrayRef); } DataType::List(child_field) => { @@ -1371,9 +1412,12 @@ fn merge_with_schema( ); let merged_validity = merge_struct_validity(left_list.nulls(), right_list.nulls()); + // `trimmed_values` starts at the first used value, so offsets + // must be shifted to match or `ListArray::new` panics when the + // input list was sliced (e.g. from a filtered batch). let merged_list = ListArray::new( child_field.clone(), - left_list.offsets().clone(), + left_list.trimmed_offsets(), merged_values, merged_validity, ); @@ -1398,7 +1442,7 @@ fn merge_with_schema( merge_struct_validity(left_list.nulls(), right_list.nulls()); let merged_list = LargeListArray::new( child_field.clone(), - left_list.offsets().clone(), + left_list.trimmed_offsets(), merged_values, merged_validity, ); @@ -1558,10 +1602,70 @@ impl BufferExt for arrow_buffer::Buffer { #[cfg(test)] mod tests { use super::*; - use arrow_array::{new_empty_array, new_null_array, ListArray, StringArray}; - use arrow_array::{Float32Array, Int32Array, StructArray}; + use arrow_array::{ + BinaryArray, BinaryViewArray, FixedSizeBinaryArray, Float32Array, Int32Array, + LargeBinaryArray, ListArray, NullArray, StringArray, StructArray, new_empty_array, + new_null_array, + }; use arrow_buffer::OffsetBuffer; + #[test] + fn test_convert_to_floating_point_preserves_inner_nulls() { + // A FixedSizeList with a null inner element must convert to a + // FixedSizeList with the null kept in place. Dropping it would + // shorten the values array and shift every later element (and, when the + // remaining count is not a multiple of the list size, panic). + let values = Int8Array::from(vec![Some(1), None, Some(3), Some(4)]); + let fsl = FixedSizeListArray::new( + Arc::new(Field::new("item", DataType::Int8, true)), + 2, + Arc::new(values), + None, + ); + + let converted = fsl.convert_to_floating_point().unwrap(); + + assert_eq!(converted.len(), 2); + let conv_values = converted + .values() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(conv_values.len(), 4); + assert_eq!(conv_values.value(0), 1.0); + assert!(conv_values.is_null(1)); + assert_eq!(conv_values.value(2), 3.0); + assert_eq!(conv_values.value(3), 4.0); + } + + #[test] + fn test_convert_to_floating_point_preserves_inner_nulls_f64_arm() { + // The Float64-producing arms (Int64/UInt8/UInt32) share the same fix as the + // Float32 arms; cover one representative (UInt8 -> Float64) so both branch + // families are exercised. + let values = UInt8Array::from(vec![Some(10u8), None, Some(30), Some(40)]); + let fsl = FixedSizeListArray::new( + Arc::new(Field::new("item", DataType::UInt8, true)), + 2, + Arc::new(values), + None, + ); + + let converted = fsl.convert_to_floating_point().unwrap(); + + assert_eq!(converted.len(), 2); + let conv_values = converted + .values() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(conv_values.len(), 4); + assert_eq!(conv_values.value(0), 10.0); + assert!(conv_values.is_null(1)); + assert_eq!(conv_values.value(2), 30.0); + assert_eq!(conv_values.value(3), 40.0); + } + #[test] fn test_merge_recursive() { let a_array = Int32Array::from(vec![Some(1), Some(2), Some(3)]); @@ -1779,6 +1883,105 @@ mod tests { assert_eq!(merged, expected); } + #[test] + fn test_merge_list_struct_identical_schema() { + // Merging two batches whose `List` columns have identical types + // should yield a single column equal to the input (there is nothing to + // merge), not a struct with the field pushed twice. + let x_field = Arc::new(Field::new("x", DataType::Int32, true)); + let item_field = Arc::new(Field::new( + "item", + DataType::Struct(Fields::from(vec![x_field.clone()])), + true, + )); + let schema = Arc::new(Schema::new(vec![Field::new( + "list_struct", + DataType::List(item_field.clone()), + true, + )])); + + let build_list = |values: Vec| { + let len = values.len(); + let item_struct = Arc::new(StructArray::new( + Fields::from(vec![x_field.clone()]), + vec![Arc::new(Int32Array::from(values))], + None, + )); + ListArray::new( + item_field.clone(), + OffsetBuffer::from_lengths([len]), + item_struct, + None, + ) + }; + + // Distinct values so the equality assertion proves the left column is kept, + // not that some column with the same values happens to be present. + let left = + RecordBatch::try_new(schema.clone(), vec![Arc::new(build_list(vec![1, 2]))]).unwrap(); + let right = RecordBatch::try_new(schema, vec![Arc::new(build_list(vec![3, 4]))]).unwrap(); + + let merged = left.merge(&right).unwrap(); + + // Exactly one column, equal to the left input: the field must not be duplicated. + assert_eq!(merged.num_columns(), 1); + assert_eq!(merged, left); + } + + #[test] + fn test_merge_nested_list_struct_identical_schema() { + // A `List` nested inside a struct reaches the identical-type + // short-circuit through the recursive `merge` call. The recursion must + // keep the field exactly once, equal to the left input. + let x_field = Arc::new(Field::new("x", DataType::Int32, true)); + let item_field = Arc::new(Field::new( + "item", + DataType::Struct(Fields::from(vec![x_field.clone()])), + true, + )); + let companies_field = Arc::new(Field::new( + "companies", + DataType::List(item_field.clone()), + true, + )); + let schema = Arc::new(Schema::new(vec![Field::new( + "outer", + DataType::Struct(Fields::from(vec![companies_field.clone()])), + true, + )])); + + let build_outer = |x: i32| { + // One list row: [{x}]. + let item_struct = Arc::new(StructArray::new( + Fields::from(vec![x_field.clone()]), + vec![Arc::new(Int32Array::from(vec![x]))], + None, + )); + let companies = Arc::new(ListArray::new( + item_field.clone(), + OffsetBuffer::from_lengths([1]), + item_struct, + None, + )); + StructArray::new( + Fields::from(vec![companies_field.clone()]), + vec![companies], + None, + ) + }; + + // Distinct values so equality proves the left column is kept. + let left = RecordBatch::try_new(schema.clone(), vec![Arc::new(build_outer(10))]).unwrap(); + let right = RecordBatch::try_new(schema, vec![Arc::new(build_outer(20))]).unwrap(); + + let merged = left.merge(&right).unwrap(); + + // The recursive identical-type merge keeps exactly one `companies` field, + // with no double-push inside the nested struct. + assert_eq!(merged.column(0).as_struct().num_columns(), 1); + assert_eq!(merged, left); + } + #[test] fn test_byte_width_opt() { assert_eq!(DataType::Int32.byte_width_opt(), Some(4)); @@ -1905,6 +2108,60 @@ mod tests { ); } + #[test] + fn test_project_rebuilds_sliced_map() { + // A sliced MapArray keeps its full entries array behind sliced + // offsets and validity; the Map projection arm must rebuild it + // without renormalizing either. + let entry_fields = Fields::from(vec![ + Field::new("key", DataType::Utf8, false), + Field::new("value", DataType::Int32, true), + ]); + let entries_field = Arc::new(Field::new( + "entries", + DataType::Struct(entry_fields.clone()), + false, + )); + let entries = StructArray::new( + entry_fields, + vec![ + Arc::new(StringArray::from(vec!["k0", "k1", "k2"])) as ArrayRef, + Arc::new(Int32Array::from(vec![1, 2, 3])) as ArrayRef, + ], + None, + ); + let map = MapArray::new( + entries_field.clone(), + OffsetBuffer::new(vec![0, 1, 1, 3].into()), + entries, + Some(arrow_buffer::NullBuffer::from(vec![true, false, true])), + false, + ); + let schema = Arc::new(Schema::new(vec![Field::new( + "m", + DataType::Map(entries_field, false), + true, + )])); + let batch = RecordBatch::try_new(schema.clone(), vec![Arc::new(map) as ArrayRef]).unwrap(); + + // Rows 1..3: a null slot and a two-entry slot, offsets not zero-based. + let projected = batch + .slice(1, 2) + .project_by_schema(schema.as_ref()) + .unwrap(); + let map = projected.column(0).as_map(); + assert!(map.is_null(0)); + assert!(map.is_valid(1)); + assert_eq!(map.value_length(1), 2); + assert_eq!( + map.value(1) + .column(1) + .as_primitive::() + .values(), + &[2, 3] + ); + } + #[test] fn test_project_preserves_struct_validity() { // Test that projecting a struct array preserves its validity (fix for issue #4385) @@ -1983,6 +2240,72 @@ mod tests { assert_eq!(width_values.value(0), 300); assert_eq!(width_values.value(1), 200); assert!(width_values.is_null(2)); // width is null when right struct was null + + // An all-null validity buffer is data, not a placeholder meaning "this side has no + // validity": merging two of them keeps the rows null. + let all_null_left = StructArray::new( + Fields::from(vec![Field::new("height", DataType::Int32, true)]), + vec![Arc::new(Int32Array::from(vec![None, None])) as ArrayRef], + Some(vec![false, false].into()), + ); + let all_null_right = StructArray::new( + Fields::from(vec![Field::new("width", DataType::Int32, true)]), + vec![Arc::new(Int32Array::from(vec![None, None])) as ArrayRef], + Some(vec![false, false].into()), + ); + + let merged = merge(&all_null_left, &all_null_right); + assert_eq!(merged.null_count(), 2); + + // An all-null side is the identity of the merge, so the other side decides each row. + let partial_left = StructArray::new( + Fields::from(vec![Field::new("height", DataType::Int32, true)]), + vec![Arc::new(Int32Array::from(vec![Some(1), None])) as ArrayRef], + Some(vec![true, false].into()), + ); + let merged = merge(&partial_left, &all_null_right); + assert!(!merged.is_null(0)); + assert!(merged.is_null(1)); + + // A missing validity buffer means all rows are valid, which absorbs an all-null side. + let all_valid_left = StructArray::new( + Fields::from(vec![Field::new("height", DataType::Int32, true)]), + vec![Arc::new(Int32Array::from(vec![1, 2])) as ArrayRef], + None, + ); + let merged = merge(&all_valid_left, &all_null_right); + assert_eq!(merged.null_count(), 0); + + // An explicit all-valid buffer has the same semantics as a missing buffer. + let all_valid: arrow_buffer::NullBuffer = vec![true, true].into(); + let partial: arrow_buffer::NullBuffer = vec![true, false].into(); + assert!(merge_struct_validity(Some(&all_valid), Some(&partial)).is_none()); + assert!(merge_struct_validity(Some(&partial), Some(&all_valid)).is_none()); + } + + #[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] @@ -2284,6 +2607,118 @@ mod tests { assert_eq!(merged_array.len(), 2); } + #[test] + fn test_merge_with_schema_sliced_list_struct() { + test_merge_with_schema_sliced_list_struct_generic::(); + } + + #[test] + fn test_merge_with_schema_sliced_large_list_struct() { + test_merge_with_schema_sliced_list_struct_generic::(); + } + + // Regression for #6580: merge_with_schema panicked when the left list was a + // sliced view whose offsets did not start at zero (common after a filtered + // scan). Cloning those offsets alongside `trimmed_values` produced offsets + // larger than the trimmed child, panicking in `(Large)ListArray::new`. + fn test_merge_with_schema_sliced_list_struct_generic() { + let make_list_dtype = |item_field: Arc| { + if O::IS_LARGE { + DataType::LargeList(item_field) + } else { + DataType::List(item_field) + } + }; + + // Build a List with two rows of 5 items each, then slice away + // the first row so the remaining list's offsets start at 5, not 0. + let struct_fields_a = Fields::from(vec![Field::new("a", DataType::Int32, true)]); + let left_values = Arc::new(StructArray::new( + struct_fields_a.clone(), + vec![Arc::new(Int32Array::from_iter_values(0..10)) as ArrayRef], + None, + )); + let full_list = GenericListArray::::new( + Arc::new(Field::new("item", DataType::Struct(struct_fields_a), true)), + OffsetBuffer::::from_lengths([5, 5]), + left_values, + None, + ); + let sliced_left = full_list.slice(1, 1); + assert_eq!(sliced_left.offsets()[0].as_usize(), 5); + assert_eq!(sliced_left.offsets()[1].as_usize(), 10); + + let struct_fields_b = Fields::from(vec![Field::new("b", DataType::Int32, true)]); + let right_values = Arc::new(StructArray::new( + struct_fields_b.clone(), + vec![Arc::new(Int32Array::from_iter_values(100..105)) as ArrayRef], + None, + )); + let right_list = GenericListArray::::new( + Arc::new(Field::new("item", DataType::Struct(struct_fields_b), true)), + OffsetBuffer::::from_lengths([5]), + right_values, + None, + ); + + let target_item_field = Arc::new(Field::new( + "item", + DataType::Struct(Fields::from(vec![ + Field::new("a", DataType::Int32, true), + Field::new("b", DataType::Int32, true), + ])), + true, + )); + let target_fields = Fields::from(vec![Field::new( + "items", + make_list_dtype(target_item_field), + true, + )]); + + let left_batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new( + "items", + sliced_left.data_type().clone(), + true, + )])), + vec![Arc::new(sliced_left) as ArrayRef], + ) + .unwrap(); + let right_batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new( + "items", + right_list.data_type().clone(), + true, + )])), + vec![Arc::new(right_list) as ArrayRef], + ) + .unwrap(); + + let merged = left_batch + .merge_with_schema(&right_batch, &Schema::new(target_fields.to_vec())) + .unwrap(); + + let merged_list = merged + .column_by_name("items") + .unwrap() + .as_any() + .downcast_ref::>() + .unwrap(); + assert_eq!(merged_list.len(), 1); + assert_eq!(merged_list.value_length(0).as_usize(), 5); + let merged_struct = merged_list.values().as_struct(); + assert_eq!(merged_struct.num_columns(), 2); + let a = merged_struct + .column_by_name("a") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + // After shifting offsets to zero, values 5..10 should be first. + let a_vals: Vec = a.iter().map(|v| v.unwrap()).collect(); + assert_eq!(a_vals, vec![5, 6, 7, 8, 9]); + } + #[test] fn test_project_by_schema_list_struct_reorder() { // Test that project_by_schema correctly reorders fields inside List @@ -2524,4 +2959,43 @@ mod tests { &Int32Array::from(vec![1, 2]) as &dyn Array ); } + + #[test] + fn test_iter_binary_array_accepts_binary_variants() { + let binary = BinaryArray::from(vec![b"a".as_slice(), b"bc"]); + assert_eq!( + iter_binary_array(&binary).unwrap().collect::>(), + vec![Some(b"a".as_slice()), Some(b"bc".as_slice())] + ); + + let large_binary = LargeBinaryArray::from(vec![b"x".as_slice(), b"yz"]); + assert_eq!( + iter_binary_array(&large_binary) + .unwrap() + .collect::>(), + vec![Some(b"x".as_slice()), Some(b"yz".as_slice())] + ); + + let binary_view = BinaryViewArray::from(vec![b"1".as_slice(), b"23"]); + assert_eq!( + iter_binary_array(&binary_view).unwrap().collect::>(), + vec![Some(b"1".as_slice()), Some(b"23".as_slice())] + ); + + let fixed_size = + FixedSizeBinaryArray::try_from_iter([b"abcd", b"efgh"].into_iter()).unwrap(); + assert_eq!( + iter_binary_array(&fixed_size).unwrap().collect::>(), + vec![Some(b"abcd".as_slice()), Some(b"efgh".as_slice())] + ); + } + + #[test] + fn test_iter_binary_array_rejects_non_binary() { + let int_array = Int32Array::from(vec![1, 2, 3]); + let Err(error) = iter_binary_array(&int_array) else { + panic!("expected an error for non-binary array"); + }; + assert!(error.to_string().contains("Expecting a binary type")); + } } diff --git a/rust/lance-arrow/src/list.rs b/rust/lance-arrow/src/list.rs index 0c24fc579da..06b0fc592cf 100644 --- a/rust/lance-arrow/src/list.rs +++ b/rust/lance-arrow/src/list.rs @@ -23,6 +23,16 @@ pub trait ListArrayExt { /// behaves similarly to `values()` except it slices the array so that it starts at /// the first list offset and ends at the last list offset. fn trimmed_values(&self) -> Arc; + /// The offset type of the underlying list array. + type Offset: OffsetSizeTrait; + /// Returns offsets shifted so the first offset is zero, matching + /// [`Self::trimmed_values`]. + /// + /// Sliced list arrays (e.g. a filtered batch) keep offsets that reference the + /// original values buffer, so combining them with trimmed values produces + /// offsets that exceed the values length. Use this together with + /// `trimmed_values` when constructing a new list array. + fn trimmed_offsets(&self) -> OffsetBuffer; } impl ListArrayExt for GenericListArray { @@ -90,6 +100,20 @@ impl ListArrayExt for GenericListArray .unwrap_or(0); self.values().slice(first_value, last_value - first_value) } + + type Offset = OffsetSize; + + fn trimmed_offsets(&self) -> OffsetBuffer { + let offsets = self.offsets(); + let Some(&first) = offsets.first() else { + return offsets.clone(); + }; + if first == OffsetSize::zero() { + return offsets.clone(); + } + let shifted: Vec = offsets.iter().map(|&o| o - first).collect(); + OffsetBuffer::new(ScalarBuffer::from(shifted)) + } } #[cfg(test)] diff --git a/rust/lance-arrow/src/scalar.rs b/rust/lance-arrow/src/scalar.rs index f32a831648b..6003eb13497 100644 --- a/rust/lance-arrow/src/scalar.rs +++ b/rust/lance-arrow/src/scalar.rs @@ -1,10 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow_array::{make_array, ArrayRef}; +use arrow_array::{ArrayRef, UInt64Array, make_array}; use arrow_buffer::Buffer; -use arrow_data::{transform::MutableArrayData, ArrayDataBuilder}; +use arrow_data::ArrayDataBuilder; use arrow_schema::{ArrowError, DataType}; +use arrow_select::take::take; use crate::DataTypeExt; @@ -19,10 +20,7 @@ pub fn extract_scalar_value(array: &ArrayRef, idx: usize) -> Result { )); } - let data = array.to_data(); - let mut mutable = MutableArrayData::new(vec![&data], /*use_nulls=*/ true, 1); - mutable.extend(0, idx, idx + 1); - Ok(make_array(mutable.freeze())) + take(array.as_ref(), &UInt64Array::from(vec![idx as u64]), None) } fn read_u32(buf: &[u8], offset: &mut usize) -> Result { @@ -195,7 +193,10 @@ pub fn try_inline_value(scalar: &ArrayRef) -> Option> { mod tests { use std::sync::Arc; - use arrow_array::{cast::AsArray, BooleanArray, FixedSizeBinaryArray, Int32Array, StringArray}; + use arrow_array::{ + BooleanArray, DictionaryArray, FixedSizeBinaryArray, Int8Array, Int32Array, StringArray, + cast::AsArray, types::Int8Type, + }; use super::*; @@ -212,6 +213,24 @@ mod tests { ); } + #[test] + fn test_extract_scalar_value_from_full_dictionary() { + let values = Arc::new(StringArray::from( + (0..=i8::MAX) + .map(|value| format!("value-{value}")) + .collect::>(), + )); + let keys = Int8Array::from((0..=i8::MAX).collect::>()); + let array: ArrayRef = Arc::new(DictionaryArray::::new(keys, values)); + + let scalar = extract_scalar_value(&array, i8::MAX as usize).unwrap(); + + let scalar = scalar.as_dictionary::(); + assert_eq!(scalar.len(), 1); + assert_eq!(scalar.key(0), Some(i8::MAX as usize)); + assert_eq!(scalar.values().len(), i8::MAX as usize + 1); + } + #[test] fn test_scalar_value_buffer_utf8_round_trip() { let scalar: ArrayRef = Arc::new(StringArray::from(vec!["hello"])); diff --git a/rust/lance-arrow/src/stream.rs b/rust/lance-arrow/src/stream.rs new file mode 100644 index 00000000000..2d0c57bca33 --- /dev/null +++ b/rust/lance-arrow/src/stream.rs @@ -0,0 +1,553 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Utilities for working with streams of [`RecordBatch`]. + +use arrow_array::RecordBatch; +use arrow_schema::{ArrowError, SchemaRef}; +use futures::stream::{self, Stream, StreamExt}; +use std::pin::Pin; + +use crate::deepcopy::deep_copy_batch_sliced; + +/// Rechunks a stream of [`RecordBatch`] so that each output batch has +/// approximately `target_bytes` of array data. +/// +/// Small input batches are accumulated (by concatenation) until at least +/// `min_bytes` of data has been collected. If the resulting batch exceeds +/// `max_bytes`, it is sliced into roughly equal pieces of ~`max_bytes` +/// (assuming uniform row sizes). +pub fn rechunk_stream_by_size( + input: S, + input_schema: SchemaRef, + min_bytes: usize, + max_bytes: usize, +) -> impl Stream> +where + S: Stream>, + E: From, +{ + rechunk_stream_by_size_inner(input, input_schema, min_bytes, max_bytes, false) +} + +/// Like [`rechunk_stream_by_size`] but deep-copies slices so that +/// `get_array_memory_size` reflects the true size of each output batch. +/// +/// After a normal `RecordBatch::slice`, the backing buffers are shared with +/// the original batch, so `get_array_memory_size` still reports the full +/// parent size. This variant deep-copies every slice produced during the +/// splitting phase, which allows the stream to detect and re-split slices +/// that still exceed `max_bytes` (e.g. because a single row is much larger +/// than average). +/// +/// The deep copy is a last resort and potentially expensive for large +/// batches. However, it is only performed when a batch actually needs to be +/// sliced — batches that are already within the target range pass through at +/// zero cost. Use this only when the hard cap on `max_bytes` is a +/// correctness requirement, not merely a performance hint. +pub fn rechunk_stream_by_size_deep_copy( + input: S, + input_schema: SchemaRef, + min_bytes: usize, + max_bytes: usize, +) -> impl Stream> +where + S: Stream>, + E: From, +{ + rechunk_stream_by_size_inner(input, input_schema, min_bytes, max_bytes, true) +} + +fn rechunk_stream_by_size_inner( + input: S, + input_schema: SchemaRef, + min_bytes: usize, + max_bytes: usize, + deep_copy: bool, +) -> impl Stream> +where + S: Stream>, + E: From, +{ + stream::try_unfold( + RechunkState { + input: Box::pin(input), + accumulated: Vec::new(), + acc_bytes: 0, + done: false, + input_schema, + min_bytes, + max_bytes, + deep_copy, + }, + |mut state| async move { + if state.done && state.accumulated.is_empty() { + return Ok(None); + } + + // Pull batches until we reach the byte target or exhaust input. + // 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(); + state.accumulated.push(batch); + } + Some(Err(e)) => return Err(e), + None => { + state.done = true; + } + } + } + + if state.accumulated.is_empty() { + return Ok(None); + } + + // Fast path: if the first accumulated batch already meets the + // byte threshold, deliver it directly instead of concatenating + // everything together (which would just get sliced back apart). + if state.accumulated.len() > 1 + && state.accumulated[0].get_array_memory_size() >= state.min_bytes + { + let b = state.accumulated.remove(0); + state.acc_bytes -= b.get_array_memory_size(); + return Ok(Some((b, state))); + } + + let batch = if state.accumulated.len() == 1 { + state.accumulated.pop().unwrap() + } else { + let b = + arrow_select::concat::concat_batches(&state.input_schema, &state.accumulated) + .map_err(E::from)?; + state.accumulated.clear(); + b + }; + state.acc_bytes = 0; + + // Slice the batch into ~max_bytes pieces assuming uniform row sizes. + let mut slices = + slice_batch(batch, state.max_bytes, state.deep_copy).map_err(E::from)?; + + if slices.len() == 1 { + Ok(Some((slices.pop().unwrap(), state))) + } else { + let first = slices.remove(0); + + // Stash leftover slices for subsequent iterations. + for a in &slices { + state.acc_bytes += a.get_array_memory_size(); + } + state.accumulated = slices; + + Ok(Some((first, state))) + } + }, + ) +} + +/// Slice a batch into pieces of at most `max_bytes`. +/// +/// When `deep_copy` is false, slices share buffers with the original batch +/// and `get_array_memory_size` will still report the parent buffer size. +/// This is fine when the caller only needs approximate sizing. +/// +/// When `deep_copy` is true, each slice is deep-copied so that +/// `get_array_memory_size` reflects the true size. If a deep-copied slice +/// still exceeds `max_bytes` (due to non-uniform row sizes), it is +/// recursively split until every piece is within budget or contains only a +/// single row. +fn slice_batch( + batch: RecordBatch, + max_bytes: usize, + deep_copy: bool, +) -> Result, ArrowError> { + let batch_bytes = batch.get_array_memory_size(); + let num_rows = batch.num_rows(); + + if batch_bytes <= max_bytes { + return Ok(vec![batch]); + } + + if num_rows <= 1 { + // A single row cannot be split further, but the size just measured may + // belong to its source buffer rather than to the row: + // `get_array_memory_size` reports whole buffers, and a slice shares + // them. Returning it uncopied hands the caller that inflated figure -- + // a 100 KB row inside a 200 MB buffer measures as 200 MB, and a caller + // budgeting on it concludes the row cannot fit anywhere. + if deep_copy { + return Ok(vec![deep_copy_batch_sliced(&batch)?]); + } + return Ok(vec![batch]); + } + + let rows_per_chunk = (max_bytes as u64 * num_rows as u64 / batch_bytes as u64).max(1) as usize; + + let mut result = Vec::new(); + let mut offset = 0; + while offset < num_rows { + let len = rows_per_chunk.min(num_rows - offset); + let slice = batch.slice(offset, len); + if deep_copy { + let copied = deep_copy_batch_sliced(&slice)?; + // Recurse: the deep-copied slice has accurate sizes, so if it + // still exceeds max_bytes we can split further. + result.extend(slice_batch(copied, max_bytes, true)?); + } else { + result.push(slice); + } + offset += len; + } + + Ok(result) +} + +/// Internal state for [`rechunk_stream`]. +/// +/// Kept as a named struct so the `try_unfold` closure stays readable. +struct RechunkState { + input: Pin>, + accumulated: Vec, + acc_bytes: usize, + done: bool, + input_schema: SchemaRef, + min_bytes: usize, + max_bytes: usize, + deep_copy: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + use arrow_array::Int32Array; + use arrow_schema::{DataType, Field, Schema}; + use futures::executor::block_on; + + /// A single row that shares a large source buffer must report its own + /// size, not the buffer's. Callers budget on this figure, and an inflated + /// one makes a small row look impossible to place. + #[test] + fn a_single_row_slice_reports_its_own_size() { + use arrow_array::LargeStringArray; + + let row = "x".repeat(100 * 1024); + let values: Vec<&str> = (0..2000).map(|_| row.as_str()).collect(); + let schema = Arc::new(Schema::new(vec![Field::new( + "text", + DataType::LargeUtf8, + false, + )])); + let batch = + RecordBatch::try_new(schema, vec![Arc::new(LargeStringArray::from(values))]).unwrap(); + let whole = batch.get_array_memory_size(); + + let one_row = batch.slice(7, 1); + assert_eq!( + one_row.get_array_memory_size(), + whole, + "a slice shares its source buffer, which is the reason this test exists" + ); + + // Budget far below the row so the split recurses to the single-row floor. + let chunks = slice_batch(one_row, 1024, true).unwrap(); + assert_eq!(chunks.len(), 1); + let measured = chunks[0].get_array_memory_size(); + assert_eq!(chunks[0].num_rows(), 1); + assert!( + measured < whole / 100, + "single row still measured as {measured} bytes against a {whole} byte source" + ); + } + + fn make_batch(num_rows: usize) -> RecordBatch { + let schema = test_schema(); + let values: Vec = (0..num_rows as i32).collect(); + RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(values))]).unwrap() + } + + fn test_schema() -> SchemaRef { + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])) + } + + fn collect_rechunked( + batches: Vec, + min_bytes: usize, + max_bytes: usize, + ) -> Vec { + let input = stream::iter(batches.into_iter().map(Ok::<_, ArrowError>)); + let rechunked = rechunk_stream_by_size(input, test_schema(), min_bytes, max_bytes); + block_on(rechunked.collect::>()) + .into_iter() + .map(|r| r.unwrap()) + .collect() + } + + fn total_rows(batches: &[RecordBatch]) -> usize { + batches.iter().map(|b| b.num_rows()).sum() + } + + #[test] + fn test_empty_stream() { + let result = collect_rechunked(vec![], 100, 200); + assert!(result.is_empty()); + } + + #[test] + fn test_single_batch_passthrough() { + let batch = make_batch(100); + let bytes = batch.get_array_memory_size(); + // Batch is between min and max — should pass through as-is. + let result = collect_rechunked(vec![batch], bytes / 2, bytes * 2); + assert_eq!(result.len(), 1); + assert_eq!(result[0].num_rows(), 100); + } + + #[test] + fn test_small_batches_concatenated() { + let one_batch_bytes = make_batch(10).get_array_memory_size(); + let batches: Vec<_> = (0..8).map(|_| make_batch(10)).collect(); + // min = 5 batches worth, max = 10 batches worth. + let result = collect_rechunked(batches, one_batch_bytes * 5, one_batch_bytes * 10); + assert_eq!(total_rows(&result), 80); + // Should have been concatenated into fewer batches than the 8 inputs. + assert!( + result.len() < 8, + "expected fewer output batches, got {}", + result.len() + ); + } + + #[test] + fn test_large_batch_sliced() { + let batch = make_batch(1000); + let bytes = batch.get_array_memory_size(); + let result = collect_rechunked(vec![batch], bytes / 8, bytes / 4); + assert_eq!(total_rows(&result), 1000); + assert!( + result.len() >= 4, + "expected at least 4 slices, got {}", + result.len() + ); + } + + #[test] + fn test_sliced_leftovers_are_not_recombined() { + // Key test for the fast-path optimisation. When a large batch is + // sliced, leftover slices should be delivered one-at-a-time without + // being concatenated back together. We verify this by checking that + // every output buffer pointer falls inside the original batch's + // allocation (i.e. they are all zero-copy slices, not fresh copies). + let batch = make_batch(1000); + let bytes = batch.get_array_memory_size(); + let orig_data = batch.column(0).to_data(); + let orig_buf = &orig_data.buffers()[0]; + let orig_start = orig_buf.as_ptr() as usize; + let orig_end = orig_start + orig_buf.len(); + + let result = collect_rechunked(vec![batch], bytes / 8, bytes / 4); + + assert_eq!(total_rows(&result), 1000); + assert!(result.len() >= 4); + + for (i, b) in result.iter().enumerate() { + let ptr = b.column(0).to_data().buffers()[0].as_ptr() as usize; + assert!( + ptr >= orig_start && ptr < orig_end, + "slice {i} buffer at {ptr:#x} is outside the original allocation \ + [{orig_start:#x}, {orig_end:#x}) — it was re-concatenated" + ); + } + } + + #[test] + fn test_flush_remainder_on_stream_end() { + // Data below min_bytes should still be flushed when the stream ends. + let batch = make_batch(10); + let bytes = batch.get_array_memory_size(); + let result = collect_rechunked(vec![batch], bytes * 100, bytes * 200); + assert_eq!(result.len(), 1); + assert_eq!(result[0].num_rows(), 10); + } + + #[test] + fn test_large_then_small_batches() { + // After a large batch is fully drained, subsequent small batches + // should be accumulated normally. + let large = make_batch(1000); + let small_bytes = make_batch(10).get_array_memory_size(); + let batches = vec![ + large, + make_batch(10), + make_batch(10), + make_batch(10), + make_batch(10), + make_batch(10), + ]; + let result = collect_rechunked(batches, small_bytes * 3, small_bytes * 100); + assert_eq!(total_rows(&result), 1050); + // The large batch should appear (possibly sliced) followed by + // concatenated small batches, so we should have fewer output batches + // than the 6 inputs. + assert!(result.len() < 6); + } + + #[test] + fn test_row_preservation_across_slicing() { + // Verify that every input row appears exactly once in the output + // and in the correct order after slicing. + let batch = make_batch(237); // odd count to exercise remainder slice + let bytes = batch.get_array_memory_size(); + let result = collect_rechunked(vec![batch], bytes / 8, bytes / 5); + + assert_eq!(total_rows(&result), 237); + + let values: Vec = result + .iter() + .flat_map(|b| { + b.column(0) + .as_any() + .downcast_ref::() + .unwrap() + .values() + .iter() + .copied() + }) + .collect(); + let expected: Vec = (0..237).collect(); + 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() + ); + } + + /// Build a batch with one variable-length string column. + /// Every row is `small_size` bytes except the row at index `big_row_idx` + /// which is `big_size` bytes. + fn make_variable_batch( + num_rows: usize, + small_size: usize, + big_row_idx: usize, + big_size: usize, + ) -> RecordBatch { + let schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, false)])); + let values: Vec = (0..num_rows) + .map(|i| { + if i == big_row_idx { + "X".repeat(big_size) + } else { + "x".repeat(small_size) + } + }) + .collect(); + let array = arrow_array::StringArray::from(values); + RecordBatch::try_new(schema, vec![Arc::new(array)]).unwrap() + } + + fn variable_schema() -> SchemaRef { + Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, false)])) + } + + fn collect_rechunked_variable( + batches: Vec, + min_bytes: usize, + max_bytes: usize, + ) -> Vec { + let input = stream::iter(batches.into_iter().map(Ok::<_, ArrowError>)); + let rechunked = + rechunk_stream_by_size_deep_copy(input, variable_schema(), min_bytes, max_bytes); + block_on(rechunked.collect::>()) + .into_iter() + .map(|r| r.unwrap()) + .collect() + } + + #[test] + fn test_oversized_row_at_end() { + // 100 rows: 99 small (64 bytes each) + 1 large (100KiB) at the end. + let batch = make_variable_batch(100, 64, 99, 100 * 1024); + let max_bytes = 64 * 1024; + let result = collect_rechunked_variable(vec![batch], 0, max_bytes); + assert_eq!(total_rows(&result), 100); + for (i, b) in result.iter().enumerate() { + let size = b.get_array_memory_size(); + assert!( + size <= max_bytes || b.num_rows() == 1, + "batch {i} has {size} bytes (max {max_bytes}) and {} rows", + b.num_rows() + ); + } + } + + #[test] + fn test_oversized_row_at_start() { + // 100 rows: 1 large (100KiB) at the start + 99 small (64 bytes each). + let batch = make_variable_batch(100, 64, 0, 100 * 1024); + let max_bytes = 64 * 1024; + let result = collect_rechunked_variable(vec![batch], 0, max_bytes); + assert_eq!(total_rows(&result), 100); + for (i, b) in result.iter().enumerate() { + let size = b.get_array_memory_size(); + assert!( + size <= max_bytes || b.num_rows() == 1, + "batch {i} has {size} bytes (max {max_bytes}) and {} rows", + b.num_rows() + ); + } + } + + #[test] + fn test_oversized_row_in_middle() { + // 100 rows: 1 large (100KiB) in the middle + 99 small (64 bytes each). + let batch = make_variable_batch(100, 64, 50, 100 * 1024); + let max_bytes = 64 * 1024; + let result = collect_rechunked_variable(vec![batch], 0, max_bytes); + assert_eq!(total_rows(&result), 100); + for (i, b) in result.iter().enumerate() { + let size = b.get_array_memory_size(); + assert!( + size <= max_bytes || b.num_rows() == 1, + "batch {i} has {size} bytes (max {max_bytes}) and {} rows", + b.num_rows() + ); + } + } + + #[test] + fn test_error_propagation() { + let input = stream::iter(vec![ + Ok(make_batch(10)), + Err(ArrowError::ComputeError("boom".into())), + Ok(make_batch(10)), + ]); + let rechunked = rechunk_stream_by_size(input, test_schema(), 1, usize::MAX); + let results: Vec> = block_on(rechunked.collect()); + assert!(results.iter().any(|r| r.is_err())); + } +} diff --git a/rust/lance-arrow/src/struct.rs b/rust/lance-arrow/src/struct.rs index 3ba09d7f0df..4dee5032bfd 100644 --- a/rust/lance-arrow/src/struct.rs +++ b/rust/lance-arrow/src/struct.rs @@ -3,7 +3,7 @@ //! Extension to arrow struct arrays -use arrow_array::{cast::AsArray, make_array, Array, StructArray}; +use arrow_array::{Array, StructArray, cast::AsArray, make_array}; use arrow_buffer::NullBuffer; use arrow_data::{ArrayData, ArrayDataBuilder}; use arrow_schema::ArrowError; @@ -124,7 +124,7 @@ impl StructArrayExt for StructArray { #[cfg(test)] mod tests { - use arrow_array::{cast::AsArray, make_array, Array, Int32Array, StructArray}; + use arrow_array::{Array, Int32Array, StructArray, cast::AsArray, make_array}; use arrow_schema::{DataType, Field, Fields}; use std::sync::Arc; diff --git a/rust/lance-core/Cargo.toml b/rust/lance-core/Cargo.toml index 3ca71e524c3..ececf0aa44f 100644 --- a/rust/lance-core/Cargo.toml +++ b/rust/lance-core/Cargo.toml @@ -14,20 +14,20 @@ rust-version.workspace = true [dependencies] arrow-array.workspace = true arrow-buffer.workspace = true +arrow-data.workspace = true arrow-schema.workspace = true async-trait.workspace = true lance-arrow.workspace = true -byteorder.workspace = true +blake3.workspace = true bytes.workspace = true -chrono.workspace = true datafusion-common = { workspace = true, optional = true } datafusion-sql = { workspace = true, optional = true } -deepsize.workspace = true +lance-derive.workspace = true futures.workspace = true -itertools.workspace = true libc.workspace = true -mock_instant.workspace = true +libm.workspace = true moka.workspace = true +quick_cache = "0.6" num_cpus = "1.0" object_store = { workspace = true } pin-project.workspace = true @@ -38,24 +38,28 @@ serde_json.workspace = true snafu.workspace = true tempfile.workspace = true tokio.workspace = true -tokio-stream.workspace = true tokio-util.workspace = true tracing.workspace = true +twox-hash.workspace = true url.workspace = true log.workspace = true -# This is used to detect CPU features at runtime. -# See src/utils/cpu.rs -[target.'cfg(all(any(target_arch = "aarch64", target_arch = "loongarch64"), target_os = "linux"))'.dependencies] -libc = { version = "0.2" } - [dev-dependencies] -lance-testing.workspace = true +criterion.workspace = true proptest.workspace = true rstest.workspace = true +tokio-stream.workspace = true [features] +# Capture Rust backtraces in error types. When disabled (the default), +# the backtrace field is zero-sized with no overhead. At runtime, capture +# is still gated by RUST_BACKTRACE=1. +backtrace = [] datafusion = ["dep:datafusion-common", "dep:datafusion-sql"] +[[bench]] +name = "cache_keys" +harness = false + [lints] workspace = true diff --git a/rust/lance-core/benches/cache_keys.rs b/rust/lance-core/benches/cache_keys.rs new file mode 100644 index 00000000000..10f3917f10e --- /dev/null +++ b/rust/lance-core/benches/cache_keys.rs @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::any::Any; +use std::borrow::Cow; +use std::hash::{BuildHasher, RandomState}; +use std::hint::black_box; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Weak}; + +use async_trait::async_trait; +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; +use futures::FutureExt; +use lance_core::cache::{ + CacheKey, CacheKeySchema, CacheNamespace, KeyBuilder, LanceCache, WeakLanceCache, +}; + +struct PageKey { + column_index: u32, + page_index: u64, +} + +impl CacheKey for PageKey { + type ValueType = Vec; + + fn key(&self) -> Cow<'_, str> { + format!("{}-{}", self.column_index, self.page_index).into() + } + + fn type_name() -> &'static str { + "bench.Page" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("bench.page-key", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u32(self.column_index); + builder.write_u64(self.page_index); + } +} + +#[derive(Clone, Eq, Hash, PartialEq)] +struct LegacyPhysicalKey { + namespace: Arc, + logical_key: Arc, + type_name: &'static str, +} + +type LegacyEntryValue = Arc; + +#[derive(Clone)] +struct LegacyEntry { + value: LegacyEntryValue, + size_bytes: usize, +} + +#[async_trait] +trait LegacyBackend: Send + Sync { + async fn get(&self, key: &LegacyPhysicalKey) -> Option; + async fn insert(&self, key: &LegacyPhysicalKey, value: LegacyEntryValue, size_bytes: usize); +} + +struct LegacyMokaBackend { + cache: moka::future::Cache, +} + +impl LegacyMokaBackend { + fn with_capacity(capacity: usize) -> Self { + let cache = moka::future::Cache::builder() + .max_capacity(capacity as u64) + .weigher(|key: &LegacyPhysicalKey, entry: &LegacyEntry| { + std::mem::size_of::() + .saturating_add(key.logical_key.len()) + .saturating_add(entry.size_bytes) + .try_into() + .unwrap_or(u32::MAX) + }) + .support_invalidation_closures() + .build(); + Self { cache } + } +} + +#[async_trait] +impl LegacyBackend for LegacyMokaBackend { + async fn get(&self, key: &LegacyPhysicalKey) -> Option { + self.cache.get(key).await.map(|entry| entry.value) + } + + async fn insert(&self, key: &LegacyPhysicalKey, value: LegacyEntryValue, size_bytes: usize) { + self.cache + .insert(key.clone(), LegacyEntry { value, size_bytes }) + .await; + } +} + +#[derive(Clone)] +struct LegacyCache { + backend: Arc, + namespace: Arc, + hits: Arc, + misses: Arc, +} + +impl LegacyCache { + fn with_capacity(capacity: usize) -> Self { + Self { + backend: Arc::new(LegacyMokaBackend::with_capacity(capacity)), + namespace: Arc::from(""), + hits: Arc::new(AtomicU64::new(0)), + misses: Arc::new(AtomicU64::new(0)), + } + } + + fn with_key_prefix(&self, segment: &str) -> Self { + Self { + backend: self.backend.clone(), + namespace: Arc::from(format!("{}{segment}/", self.namespace)), + hits: self.hits.clone(), + misses: self.misses.clone(), + } + } + + fn physical_key(&self, key: &PageKey) -> LegacyPhysicalKey { + let logical_key = key.key(); + LegacyPhysicalKey { + namespace: self.namespace.clone(), + logical_key: Arc::from(logical_key.as_ref()), + type_name: PageKey::type_name(), + } + } + + async fn insert(&self, key: &PageKey, value: Arc>) { + let size_bytes = std::mem::size_of::>() + + value.capacity() + + std::mem::size_of::() * 2; + self.backend + .insert(&self.physical_key(key), value, size_bytes) + .boxed() + .await; + } + + async fn get(&self, key: &PageKey) -> Option>> { + async { + let Some(value) = self.backend.get(&self.physical_key(key)).await else { + self.misses.fetch_add(1, Ordering::Relaxed); + return None; + }; + match value.downcast::>() { + Ok(value) => { + self.hits.fetch_add(1, Ordering::Relaxed); + Some(value) + } + Err(_) => { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } + .boxed() + .await + } +} + +struct LegacyWeakCache { + backend: Weak, + namespace: Arc, + hits: Arc, + misses: Arc, +} + +impl LegacyWeakCache { + fn from(cache: &LegacyCache) -> Self { + Self { + backend: Arc::downgrade(&cache.backend), + namespace: cache.namespace.clone(), + hits: cache.hits.clone(), + misses: cache.misses.clone(), + } + } + + async fn get(&self, key: &PageKey) -> Option>> { + let backend = self.backend.upgrade()?; + let logical_key = key.key(); + let physical_key = LegacyPhysicalKey { + namespace: self.namespace.clone(), + logical_key: Arc::from(logical_key.as_ref()), + type_name: PageKey::type_name(), + }; + let Some(value) = backend.get(&physical_key).await else { + self.misses.fetch_add(1, Ordering::Relaxed); + return None; + }; + match value.downcast::>() { + Ok(value) => { + self.hits.fetch_add(1, Ordering::Relaxed); + Some(value) + } + Err(_) => { + self.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } +} + +fn benchmark_key_preparation(c: &mut Criterion) { + let key = PageKey { + column_index: 17, + page_index: 42, + }; + let outer_hashes = RandomState::new(); + let prefixes: [(&str, Arc); 2] = [ + ("short", Arc::from("dataset")), + ("long", Arc::from("p".repeat(1024))), + ]; + let mut group = c.benchmark_group("cache_key_preparation"); + + for (case, prefix) in prefixes { + let namespace = CacheNamespace::root().child(&prefix); + + group.bench_with_input(BenchmarkId::new("legacy", case), &prefix, |b, prefix| { + b.iter(|| { + let logical_key = key.key(); + let physical_key = LegacyPhysicalKey { + namespace: Arc::clone(prefix), + logical_key: Arc::from(logical_key.as_ref()), + type_name: PageKey::type_name(), + }; + black_box(outer_hashes.hash_one(physical_key)) + }) + }); + + group.bench_function(BenchmarkId::new("blake3_typed", case), |b| { + b.iter(|| { + let mut builder = + KeyBuilder::new(namespace, PageKey::stable_type_id(), PageKey::schema()); + key.write_key(&mut builder); + black_box(outer_hashes.hash_one(builder.finish())) + }) + }); + } + + group.finish(); +} + +fn benchmark_namespace_derivation(c: &mut Criterion) { + let root = CacheNamespace::root(); + let long_segment = "p".repeat(160); + let mut group = c.benchmark_group("cache_namespace_derivation"); + + group.bench_function("root", |b| { + b.iter(|| black_box(CacheNamespace::root())); + }); + group.bench_with_input( + BenchmarkId::new("child", "short"), + &"dataset", + |b, segment| { + b.iter(|| black_box(root.child(black_box(segment)))); + }, + ); + group.bench_with_input( + BenchmarkId::new("child", "long"), + &long_segment, + |b, segment| { + b.iter(|| black_box(root.child(black_box(segment)))); + }, + ); + + group.finish(); +} + +fn benchmark_cache_operations(c: &mut Criterion) { + const CAPACITY: usize = 64 * 1024; + const ROTATING_KEYS: u64 = 512; + + let runtime = tokio::runtime::Runtime::new().unwrap(); + let prefix = "p".repeat(1024); + let legacy = LegacyCache::with_capacity(CAPACITY).with_key_prefix(&prefix); + let legacy_weak = LegacyWeakCache::from(&legacy); + let fixed = LanceCache::with_capacity(CAPACITY).with_key_prefix(&prefix); + let fixed_weak = WeakLanceCache::from(&fixed); + let hit_key = PageKey { + column_index: 17, + page_index: 42, + }; + runtime.block_on(async { + legacy.insert(&hit_key, Arc::new(vec![1_u8; 32])).await; + fixed + .insert_with_key(&hit_key, Arc::new(vec![1_u8; 32])) + .await; + }); + + let mut group = c.benchmark_group("cache_operations"); + group.bench_function(BenchmarkId::new("strong_warmed_hit", "legacy"), |b| { + b.to_async(&runtime) + .iter(|| legacy.get(black_box(&hit_key))); + }); + group.bench_function(BenchmarkId::new("strong_warmed_hit", "fixed"), |b| { + b.to_async(&runtime) + .iter(|| fixed.get_with_key(black_box(&hit_key))); + }); + group.bench_function(BenchmarkId::new("weak_warmed_hit", "legacy"), |b| { + b.to_async(&runtime) + .iter(|| legacy_weak.get(black_box(&hit_key))); + }); + group.bench_function(BenchmarkId::new("weak_warmed_hit", "fixed"), |b| { + b.to_async(&runtime) + .iter(|| fixed_weak.get_with_key(black_box(&hit_key))); + }); + + let values: Vec<_> = (0..16).map(|value| Arc::new(vec![value; 32])).collect(); + let next_legacy_insert = AtomicU64::new(0); + group.bench_function(BenchmarkId::new("bounded_rotating_insert", "legacy"), |b| { + b.to_async(&runtime).iter(|| { + let sequence = next_legacy_insert.fetch_add(1, Ordering::Relaxed); + let page_index = sequence % ROTATING_KEYS; + let value = Arc::clone(&values[sequence as usize % values.len()]); + let legacy = &legacy; + async move { + legacy + .insert( + &PageKey { + column_index: 17, + page_index, + }, + value, + ) + .await; + } + }); + }); + let next_fixed_insert = AtomicU64::new(0); + group.bench_function(BenchmarkId::new("bounded_rotating_insert", "fixed"), |b| { + b.to_async(&runtime).iter(|| { + let sequence = next_fixed_insert.fetch_add(1, Ordering::Relaxed); + let page_index = sequence % ROTATING_KEYS; + let value = Arc::clone(&values[sequence as usize % values.len()]); + let fixed = &fixed; + async move { + fixed + .insert_with_key( + &PageKey { + column_index: 17, + page_index, + }, + value, + ) + .await; + } + }); + }); + + group.finish(); +} + +criterion_group!( + benches, + benchmark_key_preparation, + benchmark_namespace_derivation, + benchmark_cache_operations +); +criterion_main!(benches); diff --git a/rust/lance-core/src/cache.rs b/rust/lance-core/src/cache.rs deleted file mode 100644 index d771fe84f68..00000000000 --- a/rust/lance-core/src/cache.rs +++ /dev/null @@ -1,809 +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::{ - atomic::{AtomicU64, Ordering}, - Arc, -}; - -use futures::{Future, FutureExt}; -use moka::future::Cache; -use snafu::location; - -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 { - message: "Failed to retrieve error from cache loader".into(), - location: location!(), - }), - } - } - } - } - - 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 { - message: "Failed to receive error from cache init function".to_string(), - location: location!(), - }), - } - } - } - } 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..013f4e262e7 --- /dev/null +++ b/rust/lance-core/src/cache/backend.rs @@ -0,0 +1,145 @@ +// 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 entry type they operate on. Most callers should +//! use [`LanceCache`](super::LanceCache) instead of interacting with +//! backends directly. +//! +//! # Migrating custom backends +//! +//! Cache keys are opaque 16-byte values. Store +//! [`InternalCacheKey::as_bytes`] directly instead of decomposing a logical +//! prefix, key string, and Rust type name. The physical namespace must also +//! include [`CACHE_KEY_FORMAT`](super::CACHE_KEY_FORMAT), so a future key +//! protocol produces cold misses instead of aliases. Persistent or tiered +//! backends can route serializable values with [`CacheCodec::type_id`]. +//! +//! Prefix invalidation and key inventory are intentionally not part of this +//! interface: one-way digests cannot support either operation without +//! retaining the logical strings that fixed-size keys are designed to remove. +//! Existing callers should migrate removed symbols as follows: +//! - replace `with_backend_and_prefix(backend, prefix)` with +//! [`LanceCache::with_backend`](super::LanceCache::with_backend) followed by +//! [`LanceCache::with_key_prefix`](super::LanceCache::with_key_prefix); +//! - replace `invalidate_prefix` with [`LanceCache::clear`](super::LanceCache::clear) +//! when clearing the shared backend is acceptable, or rotate a versioned +//! namespace to leave older entries to age out; +//! - remove uses of `prefix`, `keys`, and session key-inventory methods; opaque +//! keys have no readable or enumerable equivalent. + +use std::any::Any; +use std::pin::Pin; +use std::sync::Arc; + +use async_trait::async_trait; +use futures::Future; + +use crate::Result; +use crate::deepsize::Context; + +use super::{CacheCodec, InternalCacheKey}; + +/// A type-erased cache entry. +pub type CacheEntry = Arc; + +/// 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. + /// + /// `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. + /// + /// 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`. + /// + /// Implementations should deduplicate concurrent loads for the same key + /// so the loader runs at most once, unless caching is disabled. Disabled + /// caches may invoke each caller's loader independently. + /// + /// 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. + 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 as a `DeepSizeOf` fallback when exact entry traversal is unavailable. + /// 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 + } + + /// Weighted capacity in bytes, or `None` when the backend has no fixed + /// capacity or cannot report it. Lets callers skip loading entries that + /// could never be retained. + fn capacity_bytes(&self) -> Option { + None + } + + /// Computes the size of the entries currently held in memory. + /// + /// `size_of_entry` threads a shared [`Context`] through each value so + /// allocations shared by multiple entries are counted once. It returns + /// `None` when the value's concrete type was not registered by + /// [`LanceCache`](super::LanceCache); implementations should use the + /// entry's declared eviction size as a fallback in that case. + /// + /// Backends that can enumerate their in-memory entries should include the + /// physical key footprint in the returned total. The default returns + /// `None`, causing `LanceCache` to use [`approx_size_bytes`](Self::approx_size_bytes). + fn deep_size_of_entries( + &self, + _context: &mut Context, + _size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option, + ) -> Option { + None + } +} diff --git a/rust/lance-core/src/cache/backend_uri.rs b/rust/lance-core/src/cache/backend_uri.rs new file mode 100644 index 00000000000..33daa6adbe3 --- /dev/null +++ b/rust/lance-core/src/cache/backend_uri.rs @@ -0,0 +1,265 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! URI-based configuration for cache backends. +//! +//! [`build_from_uri`] parses a compact string form such as +//! `moka://?capacity=1073741824` into a [`BackendConfig`] and hands it to +//! the registry. This gives Python/Java bindings and configuration files a +//! single-string representation of a backend without having to expose a +//! typed builder for every backend. +//! +//! Grammar (intentionally a subset of RFC 3986 — Lance only needs a +//! predictable, unambiguous form): +//! +//! ```text +//! uri ::= scheme ":" hier ( "?" query )? +//! scheme ::= ALPHA ( ALPHA | DIGIT | "+" | "-" | "." )* +//! hier ::= "//" authority path? -- e.g. moka://?..., other:///path?... +//! | path -- e.g. moka:capacity=... (rare) +//! authority ::= *( any char except "/" | "?" ) +//! path ::= *( any char except "?" ) +//! query ::= pair ( "&" pair )* +//! pair ::= key "=" value -- both percent-decoded +//! ``` +//! +//! Mapping to [`BackendConfig`]: +//! +//! * `scheme` becomes `kind`. +//! * The joined `authority + path` (with any leading `//` stripped) is stored +//! under the option key `path` when non-empty. Empty-authority absolute +//! paths such as `backend:///tmp/cache` keep their leading `/`; host-style +//! paths such as `backend://localhost:6379/0` are stored as +//! `localhost:6379/0`. If the query already contains a `path` key, the +//! URI-supplied path wins and the parser errors on the conflict. +//! * Each `key=value` pair from the query becomes an entry in `options`. +//! Duplicate keys are rejected. + +use std::sync::Arc; + +use super::backend::CacheBackend; +use super::registry::{BackendConfig, build_from_config, normalize_backend_kind}; +use crate::{Error, Result}; + +/// Parse `uri` into a [`BackendConfig`] and build the backend registered +/// under its `scheme`. +/// +/// Returns an error if: +/// * the URI cannot be parsed, +/// * no backend is registered for that scheme, or +/// * the constructor itself fails. +pub fn build_from_uri(uri: &str) -> Result> { + let config = parse_backend_uri(uri)?; + build_from_config(&config) +} + +/// Parse `uri` into a [`BackendConfig`] without touching the registry. +/// +/// See the module docs for the accepted grammar. +pub fn parse_backend_uri(uri: &str) -> Result { + let (scheme, rest) = split_scheme(uri)?; + let (path, query) = split_path_query(rest); + + let mut config = BackendConfig::new(&scheme)?; + + let normalized_path = normalize_path(path); + if !normalized_path.is_empty() { + config.options.insert("path".to_string(), normalized_path); + } + + if let Some(query) = query { + for raw_pair in query.split('&') { + if raw_pair.is_empty() { + continue; + } + let (raw_key, raw_value) = raw_pair.split_once('=').ok_or_else(|| { + Error::invalid_input(format!( + "cache backend uri {:?}: query pair {:?} is missing '='", + uri, raw_pair + )) + })?; + let key = percent_decode(raw_key).map_err(|err| { + Error::invalid_input(format!( + "cache backend uri {:?}: cannot decode query key {:?}: {}", + uri, raw_key, err + )) + })?; + let value = percent_decode(raw_value).map_err(|err| { + Error::invalid_input(format!( + "cache backend uri {:?}: cannot decode query value {:?}: {}", + uri, raw_value, err + )) + })?; + if config.options.contains_key(&key) { + return Err(Error::invalid_input(format!( + "cache backend uri {:?}: option {:?} is set more than once", + uri, key + ))); + } + config.options.insert(key, value); + } + } + + Ok(config) +} + +fn split_scheme(uri: &str) -> Result<(String, &str)> { + let colon = uri.find(':').ok_or_else(|| { + Error::invalid_input(format!("cache backend uri {:?} is missing ':'", uri)) + })?; + let scheme = &uri[..colon]; + let scheme = normalize_backend_kind(scheme) + .map_err(|err| Error::invalid_input(format!("cache backend uri {:?}: {}", uri, err)))?; + Ok((scheme, &uri[colon + 1..])) +} + +fn split_path_query(rest: &str) -> (&str, Option<&str>) { + match rest.split_once('?') { + Some((path, query)) => (path, Some(query)), + None => (rest, None), + } +} + +/// Strip leading `//authority/` boilerplate and return the useful path +/// component. Empty authorities (e.g. `moka://`) yield an empty path, while +/// empty-authority absolute paths (e.g. `disk:///tmp/cache`) retain their +/// leading slash. +fn normalize_path(raw: &str) -> String { + let Some(without_marker) = raw.strip_prefix("//") else { + return raw.to_string(); + }; + if without_marker.is_empty() { + return String::new(); + } + without_marker.to_string() +} + +fn percent_decode(input: &str) -> std::result::Result { + let bytes = input.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'%' => { + if i + 2 >= bytes.len() { + return Err(format!("truncated percent-escape at offset {}", i)); + } + let hi = decode_hex_digit(bytes[i + 1])?; + let lo = decode_hex_digit(bytes[i + 2])?; + out.push((hi << 4) | lo); + i += 3; + } + b => { + out.push(b); + i += 1; + } + } + } + String::from_utf8(out).map_err(|err| err.to_string()) +} + +fn decode_hex_digit(b: u8) -> std::result::Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(10 + b - b'a'), + b'A'..=b'F' => Ok(10 + b - b'A'), + _ => Err(format!( + "invalid hex digit {:?} in percent-escape", + b as char + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_authority_only() { + let cfg = parse_backend_uri("moka://?capacity=1073741824").unwrap(); + assert_eq!(cfg.kind, "moka"); + assert_eq!( + cfg.options.get("capacity").map(String::as_str), + Some("1073741824") + ); + assert!(!cfg.options.contains_key("path")); + } + + #[test] + fn test_parse_path_and_query() { + let cfg = parse_backend_uri("example:///var/lance/cache?capacity=10G").unwrap(); + assert_eq!(cfg.kind, "example"); + assert_eq!( + cfg.options.get("path").map(String::as_str), + Some("/var/lance/cache") + ); + assert_eq!(cfg.options.get("capacity").map(String::as_str), Some("10G")); + } + + #[test] + fn test_parse_host_style() { + // Redis-style URI with host:port + path segment. All of it lives + // under the "path" option; the backend is responsible for + // interpreting it. + let cfg = parse_backend_uri("redis://localhost:6379/0?prefix=lance").unwrap(); + assert_eq!(cfg.kind, "redis"); + assert_eq!( + cfg.options.get("path").map(String::as_str), + Some("localhost:6379/0"), + ); + assert_eq!(cfg.options.get("prefix").map(String::as_str), Some("lance")); + } + + #[test] + fn test_scheme_is_lowercased() { + // Different upper/lower cases must resolve to the same registry + // key, otherwise `Moka://` and `moka://` would look up different + // backends. + let cfg = parse_backend_uri("MOKA://?capacity=1").unwrap(); + assert_eq!(cfg.kind, "moka"); + } + + #[test] + fn test_percent_decoding() { + let cfg = parse_backend_uri("kv://?prefix=a%2Fb&name=hello%20world&token=a+b%2Bc").unwrap(); + assert_eq!(cfg.options.get("prefix").map(String::as_str), Some("a/b")); + assert_eq!( + cfg.options.get("name").map(String::as_str), + Some("hello world") + ); + assert_eq!(cfg.options.get("token").map(String::as_str), Some("a+b+c")); + } + + #[test] + fn test_empty_query_pair_is_skipped() { + // A trailing "&" should not cause a spurious "" pair to appear. + let cfg = parse_backend_uri("moka://?capacity=1&").unwrap(); + assert_eq!(cfg.options.len(), 1); + } + + #[test] + fn test_missing_scheme_errors() { + let err = parse_backend_uri("no-scheme-here").unwrap_err(); + assert!(err.to_string().contains("missing ':'")); + } + + #[test] + fn test_invalid_scheme_errors() { + // Digit-leading schemes are invalid per RFC 3986 and would clash + // with URI-like values elsewhere in the config. + let err = parse_backend_uri("1moka://").unwrap_err(); + assert!(err.to_string().contains("must start with an ASCII letter")); + } + + #[test] + fn test_duplicate_option_errors() { + let err = parse_backend_uri("moka://?capacity=1&capacity=2").unwrap_err(); + assert!(err.to_string().contains("more than once")); + } + + #[test] + fn test_query_pair_without_equals_errors() { + let err = parse_backend_uri("moka://?capacity").unwrap_err(); + assert!(err.to_string().contains("missing '='")); + } +} diff --git a/rust/lance-core/src/cache/codec.rs b/rust/lance-core/src/cache/codec.rs new file mode 100644 index 00000000000..eff1b5e2b31 --- /dev/null +++ b/rust/lance-core/src/cache/codec.rs @@ -0,0 +1,553 @@ +// 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. +//! +//! # Wire format +//! +//! Every serialized entry begins with a small hand-framed **envelope** so the +//! reader can validate it before trusting the body: +//! +//! ```text +//! [magic: 4B = b"LCE1"] +//! [envelope_version: u8] +//! [type_id_len: u16 LE][type_id: utf8] # stable, author-assigned +//! [type_version: u32 LE] # per-type body schema version +//! +//! ``` +//! +//! The envelope is deliberately *not* protobuf: it is the most +//! stability-critical part, must parse robustly against arbitrary bytes +//! (including data written by older, pre-stabilization builds), and never +//! changes shape. Bodies use protobuf headers, where field-number evolution +//! pays off. +//! +//! # Decode outcome +//! +//! Deserialization never propagates a parse failure as a hard error into the +//! cache path. Anything the reader cannot confidently interpret — absent or +//! wrong magic, an unknown `envelope_version`, a `type_id` mismatch, an +//! unsupported `type_version`, or a body decode error — becomes +//! [`CacheDecode::Miss`]. A backend turns `Miss` into a normal cache miss and +//! recomputes the value. This is what lets data written by an older format +//! self-heal: it simply fails the magic check and is regenerated. + +use std::io::Write; +use std::sync::Arc; + +use bytes::Bytes; + +use crate::{Error, Result}; + +use super::{CacheEntryReader, CacheEntryWriter}; + +// --------------------------------------------------------------------------- +// Envelope +// --------------------------------------------------------------------------- + +/// Magic bytes that prefix every stabilized cache entry. +/// +/// An ASCII tag (`0x4C 0x43 0x45 0x31`) chosen so it cannot collide with any +/// pre-stabilization blob: those began with either a small little-endian +/// length (tens of bytes) or a small tag byte, never these values. +/// +/// Exported so backends can cheaply identify Lance cache entries (e.g. when +/// scanning a persistent store at startup) without hardcoding the bytes — +/// prefer [`has_cache_envelope`] over comparing against this directly. +pub const MAGIC: [u8; 4] = *b"LCE1"; + +/// Returns `true` if `data` begins with the cache-entry [`MAGIC`]. +/// +/// A cheap prefix check for backends that need to recognize Lance cache +/// entries without fully [`deserialize`](CacheCodec::deserialize)-ing them. A +/// `true` result only means the framing looks like ours; the entry can still +/// decode to a [`Miss`](CacheDecode::Miss) (e.g. wrong `type_id`). +pub fn has_cache_envelope(data: &[u8]) -> bool { + data.get(..MAGIC.len()) == Some(&MAGIC[..]) +} + +/// Version of the envelope framing itself. Bumped only if the outer frame +/// (magic/version/type_id/type_version layout) ever changes — expected never. +const ENVELOPE_VERSION: u8 = 1; + +/// Parsed envelope borrowed from the input bytes. +struct ParsedEnvelope<'a> { + type_id: &'a str, + type_version: u32, + /// Offset of the first body byte within the input. + body_offset: usize, +} + +/// Parse and validate the envelope at the start of `data`. +/// +/// Returns `None` for anything that is not a well-formed envelope this build +/// understands (wrong/absent magic, unknown `envelope_version`, truncation, +/// non-utf8 `type_id`). Callers translate `None` into [`CacheDecode::Miss`]. +fn parse_envelope(data: &Bytes) -> Option> { + let bytes = data.as_ref(); + let mut off = 0usize; + + let magic = bytes.get(off..off + 4)?; + if magic != MAGIC { + return None; + } + off += 4; + + if *bytes.get(off)? != ENVELOPE_VERSION { + return None; + } + off += 1; + + let type_id_len = u16::from_le_bytes(bytes.get(off..off + 2)?.try_into().ok()?) as usize; + off += 2; + + let type_id = std::str::from_utf8(bytes.get(off..off + type_id_len)?).ok()?; + off += type_id_len; + + let type_version = u32::from_le_bytes(bytes.get(off..off + 4)?.try_into().ok()?); + off += 4; + + Some(ParsedEnvelope { + type_id, + type_version, + body_offset: off, + }) +} + +/// Write the envelope for `type_id`/`type_version`, returning the number of +/// bytes written (the body's starting offset). +fn write_envelope(writer: &mut dyn Write, type_id: &str, type_version: u32) -> Result { + let type_id_len = u16::try_from(type_id.len()).map_err(|_| { + Error::io(format!( + "cache codec type_id too long ({} bytes, max {})", + type_id.len(), + u16::MAX + )) + })?; + + writer.write_all(&MAGIC)?; + writer.write_all(&[ENVELOPE_VERSION])?; + writer.write_all(&type_id_len.to_le_bytes())?; + writer.write_all(type_id.as_bytes())?; + writer.write_all(&type_version.to_le_bytes())?; + + Ok(4 + 1 + 2 + type_id.len() + 4) +} + +// --------------------------------------------------------------------------- +// CacheDecode — first-class cache-miss outcome +// --------------------------------------------------------------------------- + +/// Why a cache entry could not be decoded into the expected type. +/// +/// Carried by [`CacheDecode::Miss`] so backends can emit targeted metrics +/// (e.g. distinguish "evicting due to a stale format" from "type collision") +/// without re-parsing. Every reason maps to the same behavior — recompute via +/// the loader — so callers that don't care can ignore it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CacheMissReason { + /// Absent or wrong magic, unknown `envelope_version`, truncated framing, or + /// a non-utf8 `type_id`. Typically an entry written by a pre-stabilization + /// or otherwise foreign build. + InvalidEnvelope, + /// Well-formed envelope, but its `type_id` names a different entry type than + /// the codec reading it. + TypeMismatch, + /// Written by a newer build whose `type_version` this build does not + /// understand and must not attempt to interpret. + VersionTooNew, + /// Envelope validated, but the body failed to decode (truncation, a + /// malformed protobuf header, an IPC error, etc.). + BodyError, +} + +/// Outcome of deserializing a cache entry. +/// +/// `Miss` means the bytes could not be confidently decoded into `T`; the +/// [`CacheMissReason`] says why. A backend treats any `Miss` exactly like a key +/// that was never present: recompute via the loader. +#[derive(Debug)] +pub enum CacheDecode { + Hit(T), + Miss(CacheMissReason), +} + +impl CacheDecode { + pub fn hit(self) -> Option { + match self { + Self::Hit(v) => Some(v), + Self::Miss(_) => None, + } + } +} + +// --------------------------------------------------------------------------- +// CacheCodecImpl — trait for serializable cache entry types +// --------------------------------------------------------------------------- + +/// Serialization trait for cache entries. +/// +/// **Experimental**: the serialized format is not yet covered by a stability +/// guarantee and may change between releases. When it does stabilize, the +/// rules are: `TYPE_ID`, protobuf field numbers, and enum values are +/// append-only forever; format changes that protobuf cannot express +/// transparently bump [`CURRENT_VERSION`](Self::CURRENT_VERSION). +/// +/// 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`]. +/// +/// The envelope (magic/version/type_id/type_version) is written and validated +/// by the [`CacheCodec`] wrapper. [`serialize`](Self::serialize) writes only +/// the body — a header followed by sections in a fixed, version-keyed order — +/// and [`deserialize`](Self::deserialize) reads them back in that same order. +/// The read sequence mirroring the write sequence for each `type_version` is +/// the invariant the implementor owns. +pub trait CacheCodecImpl: Send + Sync { + /// Stable identity for this entry type. **Must not change once shipped.** + /// This is a deliberate author-assigned string, not `std::any::type_name` + /// (which is not stable across compiler versions). + const TYPE_ID: &'static str; + + /// Body schema version this build writes. Bump when the body layout + /// changes in a way protobuf field additions cannot express transparently + /// (adding/removing/reordering sections, a raw-blob encoding change, etc.). + const CURRENT_VERSION: u32; + + /// Write the body: a header, then sections in a fixed order. + fn serialize(&self, writer: &mut CacheEntryWriter<'_>) -> Result<()>; + + /// Reconstruct from the body. Branch on + /// [`reader.version()`](CacheEntryReader::version) for backward compat; + /// sections are read in write order. + fn deserialize(reader: &mut CacheEntryReader<'_>) -> 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` carries the entry's stable `type_id`/`version` plus 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 { + type_id: &'static str, + version: u32, + serialize_body: fn(&ArcAny, &mut CacheEntryWriter<'_>) -> Result<()>, + deserialize_body: fn(&mut CacheEntryReader<'_>) -> Result, +} + +impl std::fmt::Debug for CacheCodec { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CacheCodec") + .field("type_id", &self.type_id) + .field("version", &self.version) + .finish_non_exhaustive() + } +} + +fn serialize_via_impl( + any: &ArcAny, + writer: &mut CacheEntryWriter<'_>, +) -> 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( + reader: &mut CacheEntryReader<'_>, +) -> Result { + let val = T::deserialize(reader)?; + Ok(Arc::new(val) as ArcAny) +} + +impl CacheCodec { + /// Create a `CacheCodec` from explicit body 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. the orphan rule prevents it). `type_id` and `version` + /// play the same role as the corresponding [`CacheCodecImpl`] constants. + pub fn new( + type_id: &'static str, + version: u32, + serialize_body: fn(&ArcAny, &mut CacheEntryWriter<'_>) -> Result<()>, + deserialize_body: fn(&mut CacheEntryReader<'_>) -> Result, + ) -> Self { + Self { + type_id, + version, + serialize_body, + deserialize_body, + } + } + + /// Create a `CacheCodec` from a [`CacheCodecImpl`] implementation. + pub fn from_impl() -> Self { + Self { + type_id: T::TYPE_ID, + version: T::CURRENT_VERSION, + serialize_body: serialize_via_impl::, + deserialize_body: deserialize_via_impl::, + } + } + + /// Return the stable entry type identity. + /// + /// Persistent and tiered backends can use this metadata to route encoded + /// values without retaining a readable logical cache key. + pub const fn type_id(&self) -> &'static str { + self.type_id + } + + /// Serialize `value` into `writer`: envelope first, then the body. + pub fn serialize(&self, value: &ArcAny, writer: &mut dyn Write) -> Result<()> { + let body_offset = write_envelope(writer, self.type_id, self.version)?; + let mut entry_writer = CacheEntryWriter::with_pos(writer, body_offset); + (self.serialize_body)(value, &mut entry_writer) + } + + /// Deserialize an entry from `data`. + /// + /// Never fails: any non-fatal failure to interpret the bytes becomes a + /// [`CacheDecode::Miss`] with the reason why (see [`CacheMissReason`]). + /// Reading from an in-memory [`Bytes`] cannot do I/O, so there is no fault + /// channel — a miss is the only non-`Hit` outcome. + pub fn deserialize(&self, data: &Bytes) -> CacheDecode { + let Some(envelope) = parse_envelope(data) else { + log::debug!("cache entry rejected: missing or invalid envelope"); + return CacheDecode::Miss(CacheMissReason::InvalidEnvelope); + }; + + if envelope.type_id != self.type_id { + log::debug!( + "cache entry type_id mismatch: got {:?}, expected {:?}", + envelope.type_id, + self.type_id + ); + return CacheDecode::Miss(CacheMissReason::TypeMismatch); + } + + // A version newer than this build writes was produced by a newer build + // whose body layout we cannot assume to understand. Older/equal versions + // are the impl's responsibility to handle (branching on reader.version()). + if envelope.type_version > self.version { + log::debug!( + "cache entry {:?} has unsupported type_version {} (this build writes {})", + self.type_id, + envelope.type_version, + self.version + ); + return CacheDecode::Miss(CacheMissReason::VersionTooNew); + } + + let mut reader = CacheEntryReader::new(data, envelope.body_offset, envelope.type_version); + match (self.deserialize_body)(&mut reader) { + Ok(value) => CacheDecode::Hit(value), + Err(e) => { + log::debug!( + "cache entry {:?} v{} failed to decode: {e}", + self.type_id, + envelope.type_version + ); + CacheDecode::Miss(CacheMissReason::BodyError) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A trivial codec used to exercise the envelope and miss semantics + /// without pulling in arrow-backed payloads. + #[derive(Debug, PartialEq)] + struct Widget { + n: u32, + } + + impl CacheCodecImpl for Widget { + const TYPE_ID: &'static str = "test.Widget"; + const CURRENT_VERSION: u32 = 1; + + fn serialize(&self, writer: &mut CacheEntryWriter<'_>) -> Result<()> { + writer.write_raw(&self.n.to_le_bytes()) + } + + fn deserialize(reader: &mut CacheEntryReader<'_>) -> Result { + let bytes = reader.read_raw()?; + let n = u32::from_le_bytes( + bytes + .as_ref() + .try_into() + .map_err(|_| Error::io("bad widget".to_string()))?, + ); + Ok(Self { n }) + } + } + + fn serialize_widget(widget: &Widget) -> Bytes { + let codec = CacheCodec::from_impl::(); + let any: ArcAny = Arc::new(Widget { n: widget.n }); + let mut buf = Vec::new(); + codec.serialize(&any, &mut buf).unwrap(); + Bytes::from(buf) + } + + /// The miss reason, or `None` if the decode was a hit. + fn miss_reason(data: &Bytes) -> Option { + match deserialize_widget(data) { + CacheDecode::Hit(_) => None, + CacheDecode::Miss(reason) => Some(reason), + } + } + + fn deserialize_widget(data: &Bytes) -> CacheDecode { + let codec = CacheCodec::from_impl::(); + match codec.deserialize(data) { + CacheDecode::Hit(any) => { + CacheDecode::Hit(Arc::try_unwrap(any.downcast::().unwrap()).unwrap()) + } + CacheDecode::Miss(reason) => CacheDecode::Miss(reason), + } + } + + #[test] + fn envelope_roundtrip_hits() { + let bytes = serialize_widget(&Widget { n: 0xDEADBEEF }); + // Sanity: the entry starts with the magic. + assert_eq!(&bytes[..4], b"LCE1"); + let decoded = deserialize_widget(&bytes).hit().unwrap(); + assert_eq!(decoded, Widget { n: 0xDEADBEEF }); + } + + #[test] + fn has_cache_envelope_detects_magic() { + let bytes = serialize_widget(&Widget { n: 1 }); + assert!(has_cache_envelope(&bytes)); + assert!(has_cache_envelope(&MAGIC)); // exactly the magic, nothing after + assert!(!has_cache_envelope(b"LCE")); // too short + assert!(!has_cache_envelope(b"JUNK and more")); + assert!(!has_cache_envelope(&[])); + } + + #[test] + fn wrong_magic_is_miss() { + let mut bytes = serialize_widget(&Widget { n: 7 }).to_vec(); + bytes[0] = b'X'; + assert_eq!( + miss_reason(&Bytes::from(bytes)), + Some(CacheMissReason::InvalidEnvelope) + ); + } + + #[test] + fn pre_stabilization_blob_is_miss() { + // An old unstable blob led with a small u64 LE length prefix (a JSON + // header of tens of bytes) — no magic. It must self-heal to a miss. + let mut blob = Vec::new(); + blob.extend_from_slice(&(42u64).to_le_bytes()); + blob.extend_from_slice(&[0u8; 42]); + assert_eq!( + miss_reason(&Bytes::from(blob)), + Some(CacheMissReason::InvalidEnvelope) + ); + + // A different unstable shape led with a small u8 tag (0/1/2). + assert_eq!( + miss_reason(&Bytes::from(vec![0u8, 1, 2, 3])), + Some(CacheMissReason::InvalidEnvelope) + ); + } + + #[test] + fn unknown_envelope_version_is_miss() { + let mut bytes = serialize_widget(&Widget { n: 7 }).to_vec(); + bytes[4] = 0xFF; // envelope_version byte + assert_eq!( + miss_reason(&Bytes::from(bytes)), + Some(CacheMissReason::InvalidEnvelope) + ); + } + + #[test] + fn type_id_mismatch_is_miss() { + // Hand-build an envelope with a foreign type_id but valid framing. + let mut buf = Vec::new(); + write_envelope(&mut buf, "some.OtherType", 1).unwrap(); + buf.extend_from_slice(&(4u64).to_le_bytes()); + buf.extend_from_slice(&99u32.to_le_bytes()); + assert_eq!( + miss_reason(&Bytes::from(buf)), + Some(CacheMissReason::TypeMismatch) + ); + } + + #[test] + fn unsupported_future_type_version_is_miss() { + // An entry written by a newer build (higher type_version) must miss + // rather than be misread by this build. + let mut buf = Vec::new(); + write_envelope(&mut buf, Widget::TYPE_ID, Widget::CURRENT_VERSION + 1).unwrap(); + lance_arrow::ipc::write_len_prefixed_bytes(&mut buf, &9u32.to_le_bytes()).unwrap(); + assert_eq!( + miss_reason(&Bytes::from(buf)), + Some(CacheMissReason::VersionTooNew) + ); + } + + #[test] + fn truncated_envelope_is_miss() { + let bytes = serialize_widget(&Widget { n: 7 }); + for cut in [0, 1, 4, 5, 7, 9] { + assert_eq!( + miss_reason(&bytes.slice(..cut.min(bytes.len()))), + Some(CacheMissReason::InvalidEnvelope), + "truncating to {cut} bytes should miss as InvalidEnvelope" + ); + } + } + + #[test] + fn body_decode_error_is_miss() { + // Valid envelope, but the body is too short for the widget. + let mut buf = Vec::new(); + write_envelope(&mut buf, Widget::TYPE_ID, Widget::CURRENT_VERSION).unwrap(); + buf.extend_from_slice(&(1u64).to_le_bytes()); + buf.push(0u8); + assert_eq!( + miss_reason(&Bytes::from(buf)), + Some(CacheMissReason::BodyError) + ); + } + + #[test] + fn reader_exposes_envelope_version() { + // type_version travels through the envelope to reader.version(). + let mut buf = Vec::new(); + write_envelope(&mut buf, Widget::TYPE_ID, 7).unwrap(); + let body_off = buf.len(); + // A widget body so the codec can decode it. + lance_arrow::ipc::write_len_prefixed_bytes(&mut buf, &5u32.to_le_bytes()).unwrap(); + let data = Bytes::from(buf); + + let mut r = CacheEntryReader::new(&data, body_off, 7); + assert_eq!(r.version(), 7); + assert_eq!(r.read_raw().unwrap().as_ref(), 5u32.to_le_bytes()); + } +} diff --git a/rust/lance-core/src/cache/entry_io.rs b/rust/lance-core/src/cache/entry_io.rs new file mode 100644 index 00000000000..47a6b6a9190 --- /dev/null +++ b/rust/lance-core/src/cache/entry_io.rs @@ -0,0 +1,392 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Streaming readers/writers for cache entry bodies. +//! +//! [`CacheCodecImpl`](super::CacheCodecImpl) bodies are written and read +//! through these wrappers. They keep serialization streaming (no buffering of +//! the whole entry) and reads zero-copy (sections borrow from the input +//! [`Bytes`]), while tracking the byte position needed to keep Arrow IPC +//! sections 64-byte aligned (see [`lance_arrow::ipc`]). +//! +//! Body layout primitives: +//! +//! ```text +//! HEADER : [header_len: u32 LE][header proto bytes] +//! ARROW_IPC : [pad to 64B][self-delimiting IPC stream] +//! RAW_BLOB : [len: u64 LE][bytes] +//! ``` + +use std::io::Write; + +use arrow_array::RecordBatch; +use bytes::Bytes; +use prost::Message; + +use crate::{Error, Result}; + +/// Writes a cache entry body: a header followed by sections, streaming +/// directly to the underlying writer. +/// +/// The envelope is written by the [`CacheCodec`](super::CacheCodec) wrapper +/// before this writer is handed to +/// [`CacheCodecImpl::serialize`](super::CacheCodecImpl::serialize). +pub struct CacheEntryWriter<'a> { + writer: &'a mut dyn Write, + /// Absolute byte offset within the entry, used to align IPC sections. + pos: usize, +} + +impl<'a> CacheEntryWriter<'a> { + /// Create a writer positioned at the start of an entry (offset 0). + /// + /// Use this for nested serialization into a standalone buffer. The + /// envelope-aware entry point is [`CacheCodec::serialize`](super::CacheCodec::serialize). + pub fn new(writer: &'a mut dyn Write) -> Self { + Self { writer, pos: 0 } + } + + /// Create a writer whose section alignment accounts for `pos` bytes + /// already written ahead of the body (i.e. the envelope). + pub(crate) fn with_pos(writer: &'a mut dyn Write, pos: usize) -> Self { + Self { writer, pos } + } + + /// Write a single discriminant byte (e.g. a variant tag). + pub fn write_u8(&mut self, value: u8) -> Result<()> { + self.writer.write_all(&[value])?; + self.pos += 1; + Ok(()) + } + + /// Write a protobuf header as `[len: u32 LE][bytes]`. + pub fn write_header(&mut self, header: &P) -> Result<()> { + let bytes = header.encode_to_vec(); + let len = u32::try_from(bytes.len()) + .map_err(|_| Error::io(format!("cache header too large: {} bytes", bytes.len())))?; + self.writer.write_all(&len.to_le_bytes())?; + self.writer.write_all(&bytes)?; + self.pos += 4 + bytes.len(); + Ok(()) + } + + /// Write `batch` as a 64-byte-aligned Arrow IPC section. + pub fn write_ipc(&mut self, batch: &RecordBatch) -> Result<()> { + lance_arrow::ipc::write_ipc_section(self.writer, &mut self.pos, batch) + .map_err(|e| Error::io(e.to_string())) + } + + /// Write `batches` as a single 64-byte-aligned multi-batch Arrow IPC + /// section. The iterator must yield at least one batch. + pub fn write_ipc_batches(&mut self, batches: I) -> Result<()> + where + I: IntoIterator, + { + lance_arrow::ipc::write_ipc_section_batches(self.writer, &mut self.pos, batches) + .map_err(|e| Error::io(e.to_string())) + } + + /// Write a raw blob as `[len: u64 LE][bytes]`. + /// + /// Only for byte payloads that already have their own stable, portable + /// encoding (e.g. a roaring bitmap, a varint-packed stream). + pub fn write_raw(&mut self, bytes: &[u8]) -> Result<()> { + lance_arrow::ipc::write_len_prefixed_bytes(self.writer, bytes) + .map_err(|e| Error::io(e.to_string()))?; + self.pos += 8 + bytes.len(); + Ok(()) + } + + /// The underlying writer, for a payload that carries its own framing. + /// + /// Use this only when the codec writes a self-delimiting or whole-body + /// payload — e.g. streaming a roaring bitmap as the entire body, where the + /// length prefix of [`write_raw`](Self::write_raw) would be redundant and + /// buffering to measure that length would force an extra copy. For + /// structured bodies prefer [`write_header`](Self::write_header) / + /// [`write_ipc`](Self::write_ipc) / [`write_raw`](Self::write_raw), which + /// give you versioning and 64-byte IPC alignment. + /// + /// Bytes written through this do **not** advance the section-alignment + /// position, so it must not be interleaved with [`write_ipc`](Self::write_ipc). + pub fn raw_writer(&mut self) -> &mut dyn Write { + self.writer + } +} + +/// Reads a cache entry body, tracking an offset into the input and exposing +/// the entry's `type_version` so implementors can branch for backward compat. +/// +/// All reads are zero-copy: returned [`Bytes`] and the buffers behind decoded +/// [`RecordBatch`]es borrow from the input allocation. +pub struct CacheEntryReader<'a> { + data: &'a Bytes, + offset: usize, + version: u32, +} + +impl<'a> CacheEntryReader<'a> { + /// Create a reader over `data`, starting at body byte `offset`, for an + /// entry written at `version`. + pub fn new(data: &'a Bytes, offset: usize, version: u32) -> Self { + Self { + data, + offset, + version, + } + } + + /// The `type_version` from the envelope. Branch on this for backward compat. + pub fn version(&self) -> u32 { + self.version + } + + /// Read a single discriminant byte written by [`CacheEntryWriter::write_u8`]. + pub fn read_u8(&mut self) -> Result { + let bytes = self.data.as_ref(); + let v = *bytes + .get(self.offset) + .ok_or_else(|| Error::io("cache entry: truncated, missing tag byte".to_string()))?; + self.offset += 1; + Ok(v) + } + + /// Read a protobuf header written by [`CacheEntryWriter::write_header`]. + pub fn read_header(&mut self) -> Result

    { + let bytes = self.data.as_ref(); + let len_end = self + .offset + .checked_add(4) + .filter(|&e| e <= bytes.len()) + .ok_or_else(|| Error::io("cache header: truncated length prefix".to_string()))?; + let len = u32::from_le_bytes(bytes[self.offset..len_end].try_into().unwrap()) as usize; + let data_end = len_end + .checked_add(len) + .filter(|&e| e <= bytes.len()) + .ok_or_else(|| Error::io("cache header: truncated body".to_string()))?; + let msg = P::decode(&bytes[len_end..data_end]) + .map_err(|e| Error::io(format!("cache header decode failed: {e}")))?; + self.offset = data_end; + Ok(msg) + } + + /// Read one [`RecordBatch`] from a 64-byte-aligned IPC section. + pub fn read_ipc(&mut self) -> Result { + lance_arrow::ipc::read_ipc_section_at(self.data, &mut self.offset) + .map_err(|e| Error::io(e.to_string())) + } + + /// Read all [`RecordBatch`]es from a 64-byte-aligned multi-batch IPC + /// section written by [`CacheEntryWriter::write_ipc_batches`]. + pub fn read_ipc_batches(&mut self) -> Result> { + lance_arrow::ipc::read_ipc_section_batches_at(self.data, &mut self.offset) + .map_err(|e| Error::io(e.to_string())) + } + + /// Read a raw blob written by [`CacheEntryWriter::write_raw`], zero-copy. + pub fn read_raw(&mut self) -> Result { + lance_arrow::ipc::read_len_prefixed_bytes_at(self.data, &mut self.offset) + .map_err(|e| Error::io(e.to_string())) + } + + /// The not-yet-consumed body bytes as a zero-copy slice. + /// + /// For a payload that carries its own framing and is parsed with the + /// codec's own cursor — the read counterpart of + /// [`CacheEntryWriter::raw_writer`]. For structured bodies prefer + /// [`read_header`](Self::read_header) / [`read_ipc`](Self::read_ipc) / + /// [`read_raw`](Self::read_raw). + pub fn body(&self) -> Bytes { + self.data.slice(self.offset..) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + use std::sync::Arc; + + use arrow_array::{Int32Array, UInt64Array}; + use arrow_schema::{DataType, Field, Schema}; + use lance_arrow::ipc::IPC_SECTION_ALIGNMENT; + + /// Write a body starting at entry offset `pos` and return the bytes. + /// + /// `pos` models the envelope the [`CacheCodec`](super::CacheCodec) wrapper + /// writes ahead of the body; it only affects section alignment. + fn write_body(pos: usize, f: impl FnOnce(&mut CacheEntryWriter<'_>)) -> Bytes { + let mut buf = Vec::new(); + let mut writer = CacheEntryWriter::with_pos(&mut buf, pos); + f(&mut writer); + Bytes::from(buf) + } + + fn int_batch(values: Vec) -> RecordBatch { + let schema = Schema::new(vec![Field::new("i", DataType::Int32, false)]); + RecordBatch::try_new(schema.into(), vec![Arc::new(Int32Array::from(values))]).unwrap() + } + + #[test] + fn test_u8_roundtrip_and_truncation() { + let data = write_body(0, |w| { + w.write_u8(7).unwrap(); + w.write_u8(255).unwrap(); + }); + assert_eq!(data.as_ref(), &[7, 255]); + + let mut reader = CacheEntryReader::new(&data, 0, 1); + assert_eq!(reader.read_u8().unwrap(), 7); + assert_eq!(reader.read_u8().unwrap(), 255); + + // A third read has nothing left and must say so rather than wrap around. + let message = reader.read_u8().unwrap_err().to_string(); + assert!(message.contains("missing tag byte"), "{message}"); + } + + /// Headers are framed as `[len: u32 LE][bytes]`; `u64` stands in for a real + /// header proto here (prost encodes it as `google.protobuf.UInt64Value`). + #[test] + fn test_header_roundtrip_is_length_prefixed() { + let data = write_body(0, |w| w.write_header(&1234u64).unwrap()); + + let encoded_len = 1234u64.encoded_len(); + assert_eq!( + u32::from_le_bytes(data[..4].try_into().unwrap()) as usize, + encoded_len + ); + assert_eq!(data.len(), 4 + encoded_len); + + let mut reader = CacheEntryReader::new(&data, 0, 1); + assert_eq!(reader.read_header::().unwrap(), 1234); + // The reader consumed exactly the prefix plus the payload. + assert!(reader.body().is_empty()); + } + + #[test] + fn test_read_header_rejects_truncated_length_prefix() { + let data = Bytes::from_static(&[0, 0]); + let message = CacheEntryReader::new(&data, 0, 1) + .read_header::() + .unwrap_err() + .to_string(); + assert!(message.contains("truncated length prefix"), "{message}"); + } + + #[test] + fn test_read_header_rejects_truncated_body() { + // Prefix claims 16 payload bytes; only 3 follow. + let mut data = 16u32.to_le_bytes().to_vec(); + data.extend_from_slice(&[1, 2, 3]); + let data = Bytes::from(data); + + let message = CacheEntryReader::new(&data, 0, 1) + .read_header::() + .unwrap_err() + .to_string(); + assert!(message.contains("truncated body"), "{message}"); + } + + /// A length prefix that is in range but whose payload is not valid protobuf + /// must surface as a decode error, not a panic inside prost. + #[test] + fn test_read_header_rejects_undecodable_payload() { + // Field 1 tagged as a varint, then a varint that never terminates. + let payload = [0x08u8, 0xFF, 0xFF, 0xFF]; + let mut data = (payload.len() as u32).to_le_bytes().to_vec(); + data.extend_from_slice(&payload); + let data = Bytes::from(data); + + let message = CacheEntryReader::new(&data, 0, 1) + .read_header::() + .unwrap_err() + .to_string(); + assert!(message.contains("decode failed"), "{message}"); + } + + #[test] + fn test_raw_roundtrip_leaves_the_rest_as_body() { + let data = write_body(0, |w| { + w.write_raw(&[1, 2, 3]).unwrap(); + w.raw_writer().write_all(&[9, 9]).unwrap(); + }); + // 8-byte length prefix + 3 payload + 2 trailing. + assert_eq!(data.len(), 13); + + let mut reader = CacheEntryReader::new(&data, 0, 1); + assert_eq!(reader.read_raw().unwrap().as_ref(), &[1, 2, 3]); + assert_eq!(reader.body().as_ref(), &[9, 9]); + } + + #[test] + fn test_reader_exposes_the_entry_version() { + let data = Bytes::from_static(&[0]); + assert_eq!(CacheEntryReader::new(&data, 0, 7).version(), 7); + } + + /// The reason `pos` is tracked at all: an IPC section must begin on a + /// 64-byte boundary *of the whole entry*, so the envelope bytes ahead of the + /// body count toward the padding. Writer and reader have to agree on that, + /// and only a non-multiple-of-64 prefix makes a disagreement visible. + #[test] + fn test_ipc_section_is_aligned_against_the_envelope() { + const ENVELOPE: usize = 13; + let batch = int_batch(vec![1, 2, 3]); + + let mut buf = vec![0xAAu8; ENVELOPE]; + let mut writer = CacheEntryWriter::with_pos(&mut buf, ENVELOPE); + writer.write_header(&1234u64).unwrap(); + writer.write_ipc(&batch).unwrap(); + let data = Bytes::from(buf); + + let header_end = ENVELOPE + 4 + 1234u64.encoded_len(); + let stream_start = header_end.next_multiple_of(IPC_SECTION_ALIGNMENT); + assert!(stream_start > header_end, "padding should be non-empty"); + assert!( + data[header_end..stream_start].iter().all(|b| *b == 0), + "the gap must be zero padding" + ); + + let mut reader = CacheEntryReader::new(&data, ENVELOPE, 1); + assert_eq!(reader.read_header::().unwrap(), 1234); + assert_eq!(reader.read_ipc().unwrap(), batch); + assert!(reader.body().is_empty()); + } + + #[test] + fn test_ipc_batches_roundtrip() { + let batches = vec![int_batch(vec![1, 2]), int_batch(vec![3])]; + let data = write_body(0, |w| w.write_ipc_batches(batches.clone()).unwrap()); + + let mut reader = CacheEntryReader::new(&data, 0, 1); + assert_eq!(reader.read_ipc_batches().unwrap(), batches); + } + + /// The shape a real codec writes: a discriminant, a header, an arrow section + /// and a blob. Each reader step has to pick up exactly where the previous one + /// stopped, and the arrow section still has to land on its boundary even + /// though a `write_u8` moved the position by one. + #[test] + fn test_mixed_sections_stay_in_sync() { + let schema = Schema::new(vec![Field::new("u", DataType::UInt64, false)]); + let batch = RecordBatch::try_new( + schema.into(), + vec![Arc::new(UInt64Array::from(vec![u64::MAX, 0]))], + ) + .unwrap(); + + let data = write_body(0, |w| { + w.write_u8(2).unwrap(); + w.write_header(&99u64).unwrap(); + w.write_ipc(&batch).unwrap(); + w.write_raw(&[7, 7, 7]).unwrap(); + }); + + let mut reader = CacheEntryReader::new(&data, 0, 1); + assert_eq!(reader.read_u8().unwrap(), 2); + assert_eq!(reader.read_header::().unwrap(), 99); + assert_eq!(reader.read_ipc().unwrap(), batch); + assert_eq!(reader.read_raw().unwrap().as_ref(), &[7, 7, 7]); + assert!(reader.body().is_empty()); + } +} diff --git a/rust/lance-core/src/cache/key.rs b/rust/lance-core/src/cache/key.rs new file mode 100644 index 00000000000..b7687d26459 --- /dev/null +++ b/rust/lance-core/src/cache/key.rs @@ -0,0 +1,476 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Canonical fixed-size cache key construction. +//! +//! Cache keys are BLAKE3 digests truncated to 128 bits. Logical key fields are +//! encoded with explicit type tags, fixed-width little-endian integers, and +//! length framing for variable-width values. This makes the pre-hash encoding +//! unambiguous and stable across processes, platforms, and builds. +//! +//! The digest is a cache identity, not an authentication or access-control +//! primitive: namespace derivation keys are deterministic and not secret. +//! Truncating to 128 bits gives generic birthday resistance of approximately +//! 64 bits. This protocol does not introduce a FIPS mode; BLAKE3 is the +//! repository's selected cache-key algorithm. + +use std::fmt; + +/// Storage namespace identifier for canonical cache keys. +/// +/// Persistent backends should include this identifier in their physical +/// namespace so future algorithm or framing changes produce cold misses. +pub const CACHE_KEY_FORMAT: &str = "blake3-128-v1"; + +const KEY_FORMAT_VERSION: u32 = 1; +const NAMESPACE_CONTEXT: &str = "lance-format/lance 2026-07-17 cache namespace v1"; +const NAMESPACE_DOMAIN: &[u8] = b"lance-cache-namespace\0"; +const ENTRY_DOMAIN: &[u8] = b"lance-cache-entry\0"; + +/// One-byte type discriminants in the stable key encoding. +#[derive(Clone, Copy)] +#[repr(u8)] +enum FieldTag { + U8 = 1, + U16 = 2, + U32 = 3, + U64 = 4, + I32 = 5, + I64 = 6, + Bool = 7, + Str = 8, + Bytes = 9, + FixedBytes = 10, + None = 11, + Some = 12, + Variant = 13, + Sequence = 14, +} + +impl FieldTag { + const fn as_u8(self) -> u8 { + self as u8 + } +} + +/// Versioned schema identity for fields emitted by a cache key. +/// +/// Change the version whenever the encoded fields or their meaning changes. +/// The identifier must be stable and globally unique to the logical layout. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CacheKeySchema { + id: &'static str, + version: u32, +} + +impl CacheKeySchema { + /// Compatibility schema used by the default string-key bridge. + pub const LEGACY_TEXT: Self = Self::new("lance.cache.legacy-text", 1); + + /// Create a stable schema identifier and encoding version. + pub const fn new(id: &'static str, version: u32) -> Self { + Self { id, version } + } + + /// Return the author-assigned schema identifier. + pub const fn id(self) -> &'static str { + self.id + } + + /// Return the schema encoding version. + pub const fn version(self) -> u32 { + self.version + } +} + +/// Opaque 128-bit key passed to cache backends. +/// +/// The byte representation is canonical. It can be persisted directly and is +/// independent of the host's native integer endianness. +#[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +pub struct InternalCacheKey([u8; 16]); + +impl InternalCacheKey { + /// Reconstruct a key from its canonical bytes. + pub const fn from_bytes(bytes: [u8; 16]) -> Self { + Self(bytes) + } + + /// Borrow the canonical byte representation. + pub const fn as_bytes(&self) -> &[u8; 16] { + &self.0 + } + + /// Consume the key and return its canonical bytes. + pub const fn into_bytes(self) -> [u8; 16] { + self.0 + } +} + +impl fmt::Debug for InternalCacheKey { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str("InternalCacheKey(")?; + for byte in self.0 { + write!(f, "{byte:02x}")?; + } + f.write_str(")") + } +} + +/// Pre-derived namespace key shared by entries in one logical cache scope. +#[derive(Clone, Copy, Debug)] +pub struct CacheNamespace([u8; 32]); + +impl CacheNamespace { + /// Construct the stable root namespace. + pub fn root() -> Self { + Self(blake3::derive_key(NAMESPACE_CONTEXT, b"")) + } + + /// Derive a child namespace from one framed hierarchy segment. + pub fn child(self, segment: &str) -> Self { + let mut hasher = blake3::Hasher::new_keyed(&self.0); + write_framed(&mut hasher, NAMESPACE_DOMAIN); + hasher.update(&KEY_FORMAT_VERSION.to_le_bytes()); + write_framed(&mut hasher, segment.as_bytes()); + Self(hasher.finalize().into()) + } +} + +/// Streams typed logical fields into a canonical cache key. +/// +/// Integer methods use little-endian fixed-width encoding. Variable-width +/// strings and bytes are type-tagged and length-prefixed. There is deliberately +/// no `usize` method because cache identities must not depend on target width. +/// +/// # Examples +/// +/// ``` +/// use lance_core::cache::{CacheKeySchema, CacheNamespace, KeyBuilder}; +/// +/// let namespace = CacheNamespace::root().child("dataset"); +/// let mut builder = KeyBuilder::new( +/// namespace, +/// "example.Page", +/// CacheKeySchema::new("example.page-key", 1), +/// ); +/// builder.write_u32(7); +/// builder.write_str("values"); +/// let key = builder.finish(); +/// assert_eq!(key.as_bytes().len(), 16); +/// ``` +pub struct KeyBuilder { + hasher: blake3::Hasher, +} + +impl KeyBuilder { + /// Start a key in a namespace with a stable value type and key schema. + pub fn new( + namespace: CacheNamespace, + stable_type_id: &'static str, + schema: CacheKeySchema, + ) -> Self { + let mut hasher = blake3::Hasher::new_keyed(&namespace.0); + write_framed(&mut hasher, ENTRY_DOMAIN); + hasher.update(&KEY_FORMAT_VERSION.to_le_bytes()); + write_framed(&mut hasher, stable_type_id.as_bytes()); + write_framed(&mut hasher, schema.id().as_bytes()); + hasher.update(&schema.version().to_le_bytes()); + Self { hasher } + } + + /// Append a tagged, fixed-width `u8`. + #[inline] + pub fn write_u8(&mut self, value: u8) { + self.hasher.update(&[FieldTag::U8.as_u8(), value]); + } + + /// Append a tagged, little-endian `u16`. + #[inline] + pub fn write_u16(&mut self, value: u16) { + let mut encoded = [0; 3]; + encoded[0] = FieldTag::U16.as_u8(); + encoded[1..].copy_from_slice(&value.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append a tagged, little-endian `u32`. + #[inline] + pub fn write_u32(&mut self, value: u32) { + let mut encoded = [0; 5]; + encoded[0] = FieldTag::U32.as_u8(); + encoded[1..].copy_from_slice(&value.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append a tagged, little-endian `u64`. + #[inline] + pub fn write_u64(&mut self, value: u64) { + let mut encoded = [0; 9]; + encoded[0] = FieldTag::U64.as_u8(); + encoded[1..].copy_from_slice(&value.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append a tagged, little-endian `i32`. + #[inline] + pub fn write_i32(&mut self, value: i32) { + let mut encoded = [0; 5]; + encoded[0] = FieldTag::I32.as_u8(); + encoded[1..].copy_from_slice(&value.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append a tagged, little-endian `i64`. + #[inline] + pub fn write_i64(&mut self, value: i64) { + let mut encoded = [0; 9]; + encoded[0] = FieldTag::I64.as_u8(); + encoded[1..].copy_from_slice(&value.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append a tagged boolean. + #[inline] + pub fn write_bool(&mut self, value: bool) { + self.hasher + .update(&[FieldTag::Bool.as_u8(), u8::from(value)]); + } + + /// Append a tagged, length-prefixed UTF-8 string. + #[inline] + pub fn write_str(&mut self, value: &str) { + self.write_variable(FieldTag::Str, value.as_bytes()); + } + + /// Append tagged, length-prefixed bytes. + #[inline] + pub fn write_bytes(&mut self, value: &[u8]) { + self.write_variable(FieldTag::Bytes, value); + } + + /// Append a tagged fixed-size byte array, including its length. + #[inline] + pub fn write_fixed_bytes(&mut self, value: &[u8; N]) { + self.write_variable(FieldTag::FixedBytes, value); + } + + /// Append the canonical marker for an absent optional value. + #[inline] + pub fn write_none(&mut self) { + self.hasher.update(&[FieldTag::None.as_u8()]); + } + + /// Append the canonical marker for a present optional value. + #[inline] + pub fn write_some(&mut self) { + self.hasher.update(&[FieldTag::Some.as_u8()]); + } + + /// Append a tagged enum variant ordinal. + #[inline] + pub fn write_variant(&mut self, variant: u32) { + let mut encoded = [0; 5]; + encoded[0] = FieldTag::Variant.as_u8(); + encoded[1..].copy_from_slice(&variant.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Append the length of a following sequence. + #[inline] + pub fn write_sequence_len(&mut self, len: u64) { + let mut encoded = [0; 9]; + encoded[0] = FieldTag::Sequence.as_u8(); + encoded[1..].copy_from_slice(&len.to_le_bytes()); + self.hasher.update(&encoded); + } + + /// Finalize and return the canonical 128-bit key. + #[inline] + pub fn finish(self) -> InternalCacheKey { + let hash = self.hasher.finalize(); + let mut bytes = [0; 16]; + bytes.copy_from_slice(&hash.as_bytes()[..16]); + InternalCacheKey(bytes) + } + + #[inline] + fn write_variable(&mut self, tag: FieldTag, value: &[u8]) { + self.hasher.update(&[tag.as_u8()]); + self.hasher.update(&encoded_len(value)); + self.hasher.update(value); + } +} + +#[inline] +fn write_framed(hasher: &mut blake3::Hasher, value: &[u8]) { + hasher.update(&encoded_len(value)); + hasher.update(value); +} + +#[inline] +fn encoded_len(value: &[u8]) -> [u8; 8] { + (value.len() as u64).to_le_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCHEMA: CacheKeySchema = CacheKeySchema::new("test.key", 1); + + fn builder() -> KeyBuilder { + KeyBuilder::new( + CacheNamespace::root().child("s3://bucket/dataset"), + "test.Value", + SCHEMA, + ) + } + + fn key_with(write: impl FnOnce(&mut KeyBuilder)) -> InternalCacheKey { + let mut key = builder(); + write(&mut key); + key.finish() + } + + #[test] + fn key_and_namespace_have_fixed_sizes() { + assert_eq!(std::mem::size_of::(), 16); + assert_eq!(std::mem::size_of::(), 32); + assert_eq!(std::mem::size_of::(), 1); + } + + #[test] + fn blake3_matches_official_empty_keyed_hash_vector() { + let key = *b"whats the Elvish word for friend"; + assert_eq!( + blake3::keyed_hash(&key, b"").as_bytes(), + &[ + 0x92, 0xb2, 0xb7, 0x56, 0x04, 0xed, 0x3c, 0x76, 0x1f, 0x9d, 0x6f, 0x62, 0x39, 0x2c, + 0x8a, 0x92, 0x27, 0xad, 0x0e, 0xa3, 0xf0, 0x95, 0x73, 0xe7, 0x83, 0xf1, 0x49, 0x8a, + 0x4e, 0xd6, 0x0d, 0x26, + ] + ); + } + + #[test] + fn typed_fields_and_boundaries_are_unambiguous() { + let cases = [ + key_with(|key| { + key.write_str("ab"); + key.write_str("c"); + }), + key_with(|key| { + key.write_str("a"); + key.write_str("bc"); + }), + key_with(|key| key.write_str("")), + key_with(|key| key.write_bytes(b"")), + key_with(|key| key.write_fixed_bytes(b"")), + key_with(|key| key.write_u8(1)), + key_with(|key| key.write_u16(1)), + key_with(|key| key.write_u32(1)), + key_with(|key| key.write_u64(1)), + key_with(|key| key.write_i32(1)), + key_with(|key| key.write_i64(1)), + key_with(|key| key.write_bool(false)), + key_with(|key| key.write_bool(true)), + key_with(KeyBuilder::write_none), + key_with(KeyBuilder::write_some), + key_with(|key| key.write_variant(0)), + key_with(|key| key.write_variant(1)), + ]; + assert_eq!(std::collections::BTreeSet::from(cases).len(), cases.len()); + + assert_ne!( + key_with(|key| { + key.write_sequence_len(2); + key.write_u32(1); + key.write_u32(2); + }), + key_with(|key| { + key.write_u32(1); + key.write_u32(2); + }) + ); + } + + #[test] + fn namespace_type_schema_and_version_are_domain_separated() { + let root = CacheNamespace::root(); + let namespace = root.child("dataset").child("index"); + let nested = KeyBuilder::new(namespace, "test.Value", SCHEMA).finish(); + let combined = KeyBuilder::new(root.child("dataset/index"), "test.Value", SCHEMA).finish(); + assert_ne!(nested, combined); + + assert_ne!( + nested, + KeyBuilder::new(namespace, "test.OtherValue", SCHEMA).finish() + ); + assert_ne!( + nested, + KeyBuilder::new( + namespace, + "test.Value", + CacheKeySchema::new("test.other-key", 1), + ) + .finish() + ); + assert_ne!( + nested, + KeyBuilder::new(namespace, "test.Value", CacheKeySchema::new("test.key", 2),).finish() + ); + + let tenant_a_memory = + KeyBuilder::new(root.child("tenant-a").child("memory"), "test.Value", SCHEMA).finish(); + assert_ne!( + tenant_a_memory, + KeyBuilder::new(root.child("tenant-b").child("memory"), "test.Value", SCHEMA,).finish() + ); + assert_ne!( + tenant_a_memory, + KeyBuilder::new( + root.child("tenant-a").child("persistent"), + "test.Value", + SCHEMA, + ) + .finish() + ); + } + + #[test] + fn integers_use_fixed_width_little_endian_encoding() { + let namespace = CacheNamespace::root().child("endianness"); + let mut key = KeyBuilder::new(namespace, "test.Value", SCHEMA); + key.write_u32(0x0102_0304); + let actual = key.finish(); + + let mut reference = blake3::Hasher::new_keyed(&namespace.0); + write_framed(&mut reference, ENTRY_DOMAIN); + reference.update(&KEY_FORMAT_VERSION.to_le_bytes()); + write_framed(&mut reference, b"test.Value"); + write_framed(&mut reference, SCHEMA.id().as_bytes()); + reference.update(&SCHEMA.version().to_le_bytes()); + reference.update(&[FieldTag::U32.as_u8(), 0x04, 0x03, 0x02, 0x01]); + let mut expected = [0; 16]; + expected.copy_from_slice(&reference.finalize().as_bytes()[..16]); + + assert_eq!(actual, InternalCacheKey::from_bytes(expected)); + } + + #[test] + fn key_has_stable_golden_vector() { + let mut key = builder(); + key.write_u32(7); + key.write_str("page"); + key.write_some(); + key.write_fixed_bytes(&[0xAB; 16]); + assert_eq!( + key.finish().into_bytes(), + [ + 0xc4, 0x38, 0xff, 0x22, 0x30, 0x55, 0x30, 0xfc, 0x74, 0x16, 0x38, 0xe9, 0x7d, 0x45, + 0xa5, 0x68, + ] + ); + } +} diff --git a/rust/lance-core/src/cache/mod.rs b/rust/lance-core/src/cache/mod.rs new file mode 100644 index 00000000000..e9e3a9bce59 --- /dev/null +++ b/rust/lance-core/src/cache/mod.rs @@ -0,0 +1,1485 @@ +// 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. +//! +//! 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 opaque, fixed-size [`InternalCacheKey`] values and +//! type-erased [`CacheEntry`] values. The typed wrapping is handled by +//! [`LanceCache`]. See the [`backend`] module for migration 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; +mod backend_uri; +pub mod codec; +mod entry_io; +mod key; +mod moka; +mod quick; +mod registry; + +pub use backend::{CacheBackend, CacheEntry}; +pub use backend_uri::{build_from_uri, parse_backend_uri}; +pub use codec::{ + CacheCodec, CacheCodecImpl, CacheDecode, CacheMissReason, MAGIC, has_cache_envelope, +}; +pub use entry_io::{CacheEntryReader, CacheEntryWriter}; +pub use key::{CACHE_KEY_FORMAT, CacheKeySchema, CacheNamespace, InternalCacheKey, KeyBuilder}; +pub use moka::MokaCacheBackend; +pub use quick::{QuickCacheBackend, QuickCacheShardPolicy, recommended_cache_shards}; +pub use registry::{BackendBuildFn, BackendConfig, build_from_config, register_backend}; + +use std::any::TypeId; +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::{ + Arc, RwLock, Weak, + atomic::{AtomicU64, Ordering}, +}; + +use futures::Future; + +use crate::{Error, Result}; + +pub use crate::deepsize::{Context, DeepSizeOf}; + +// --------------------------------------------------------------------------- +// CacheKey / UnsizedCacheKey — typed key traits for cache users +// --------------------------------------------------------------------------- + +/// Typed cache key for sized value types. +/// +/// Existing implementations can continue returning a logical string from +/// [`key`](Self::key). Performance-sensitive implementations should also +/// provide a stable schema and stream typed fields through +/// [`write_key`](Self::write_key), avoiding construction of that string. +/// +/// # 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. + /// + /// 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; + + /// Stable identity included in the physical key. + /// + /// The compatibility default preserves existing implementations by using + /// their author-assigned [`type_name`](Self::type_name). + fn stable_type_id() -> &'static str { + Self::type_name() + } + + /// Versioned schema for the logical key fields. + fn schema() -> CacheKeySchema { + CacheKeySchema::LEGACY_TEXT + } + + /// Stream the logical key fields into the canonical key builder. + /// + /// The compatibility default hashes the existing string key. In-tree hot + /// paths override this with typed, allocation-free field encoding. + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_str(self.key().as_ref()); + } + + /// 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; + + fn key(&self) -> Cow<'_, str>; + + /// Short, stable string identifying this value type. + /// See [`CacheKey::type_name`] for requirements. + fn type_name() -> &'static str; + + /// Stable identity included in the physical key. + fn stable_type_id() -> &'static str { + Self::type_name() + } + + /// Versioned schema for the logical key fields. + fn schema() -> CacheKeySchema { + CacheKeySchema::LEGACY_TEXT + } + + /// Stream the logical key fields into the canonical key builder. + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_str(self.key().as_ref()); + } +} + +// --------------------------------------------------------------------------- +// 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 +} + +type CacheEntrySizeAccessor = fn(&CacheEntry, &mut Context) -> Option; + +fn cache_entry_size_with_context(entry: &CacheEntry, context: &mut Context) -> Option +where + T: DeepSizeOf + Send + Sync + 'static, +{ + let value = entry.downcast_ref::()?; + let entry_ptr = Arc::as_ptr(entry) as *const () as usize; + if !context.mark_seen(entry_ptr) { + return Some(0); + } + Some( + std::mem::size_of_val(value) + + value.deep_size_of_children(context) + + std::mem::size_of::() * 2, + ) +} + +#[derive(Debug)] +struct CacheState { + backend: Arc, + hits: AtomicU64, + misses: AtomicU64, + entry_size_accessors: RwLock>, +} + +impl CacheState { + fn new(backend: Arc) -> Self { + Self { + backend, + hits: AtomicU64::new(0), + misses: AtomicU64::new(0), + entry_size_accessors: RwLock::new(HashMap::new()), + } + } + + fn entry_size(&self, value: &T) -> usize + where + T: DeepSizeOf + Send + Sync + 'static, + { + let type_id = TypeId::of::(); + let is_registered = self + .entry_size_accessors + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .contains_key(&type_id); + if !is_registered { + self.entry_size_accessors + .write() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .entry(type_id) + .or_insert(cache_entry_size_with_context::); + } + cache_entry_size(value) + } +} + +// --------------------------------------------------------------------------- +// 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 { + state: Arc, + namespace: key::CacheNamespace, +} + +impl std::fmt::Debug for LanceCache { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("LanceCache") + .field("backend", &self.state.backend) + .finish_non_exhaustive() + } +} + +impl DeepSizeOf for LanceCache { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + let state_ptr = Arc::as_ptr(&self.state) as usize; + if !context.mark_seen(state_ptr) { + return 0; + } + + let accessors = self + .state + .entry_size_accessors + .read() + .unwrap_or_else(|poisoned| poisoned.into_inner()) + .clone(); + self.state + .backend + .deep_size_of_entries(context, &|entry, context| { + accessors + .get(&entry.as_ref().type_id()) + .and_then(|size_of_entry| size_of_entry(entry, context)) + }) + .unwrap_or_else(|| self.state.backend.approx_size_bytes()) + } +} + +impl LanceCache { + pub fn with_capacity(capacity: usize) -> Self { + Self::with_backend(Arc::new(MokaCacheBackend::with_capacity(capacity))) + } + + /// Create a cache backed by a custom [`CacheBackend`]. + pub fn with_backend(backend: Arc) -> Self { + Self { + state: Arc::new(CacheState::new(backend)), + namespace: key::CacheNamespace::root(), + } + } + + pub fn no_cache() -> Self { + Self::with_backend(Arc::new(MokaCacheBackend::no_cache())) + } + + /// Derive a child namespace for all keys in the returned cache handle. + /// + /// Each call adds one framed hierarchy segment. Consequently, + /// `cache.with_key_prefix("a").with_key_prefix("b")` is deliberately + /// distinct from `cache.with_key_prefix("a/b")`. + pub fn with_key_prefix(&self, prefix: &str) -> Self { + Self { + state: self.state.clone(), + namespace: self.namespace.child(prefix), + } + } + + pub async fn size(&self) -> usize { + self.state.backend.num_entries().await + } + + pub fn approx_size(&self) -> usize { + self.state.backend.approx_num_entries() + } + + pub async fn size_bytes(&self) -> usize { + self.state.backend.size_bytes().await + } + + /// Weighted capacity in bytes, if the backend reports one. + pub fn capacity_bytes(&self) -> Option { + self.state.backend.capacity_bytes() + } + + // -- Stats / clear -------------------------------------------------------- + + pub async fn stats(&self) -> CacheStats { + CacheStats { + hits: self.state.hits.load(Ordering::Relaxed), + misses: self.state.misses.load(Ordering::Relaxed), + num_entries: self.state.backend.num_entries().await, + size_bytes: self.state.backend.size_bytes().await, + } + } + + pub async fn clear(&self) { + self.state.backend.clear().await; + self.state.hits.store(0, Ordering::Relaxed); + self.state.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, + { + let size = self.state.entry_size(metadata.as_ref()); + let key = self.sized_key(cache_key); + self.state + .backend + .insert(&key, metadata, size, K::codec()) + .await; + } + + pub async fn get_with_key(&self, cache_key: &K) -> Option> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let key = self.sized_key(cache_key); + let Some(entry) = self.state.backend.get(&key, K::codec()).await else { + self.state.misses.fetch_add(1, Ordering::Relaxed); + return None; + }; + match entry.downcast::() { + Ok(value) => { + self.state.hits.fetch_add(1, Ordering::Relaxed); + Some(value) + } + 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. + log::warn!( + "cache backend returned a value with the wrong concrete type for key type {:?}", + K::stable_type_id() + ); + self.state.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } + + 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, + { + self.get_or_insert_with_key_hit(cache_key, loader) + .await + .map(|(value, _)| value) + } + + /// Same as [`get_or_insert_with_key`](Self::get_or_insert_with_key), but + /// also returns a boolean indicating whether the loader was skipped for + /// this call. + /// + /// - `true` means this call did **not** execute the loader. That covers + /// both a true cache hit on an already-populated entry and a coalesced + /// concurrent load where an in-flight loader started by a different + /// caller produced the value. + /// - `false` means the loader ran on this call (a real cache miss). + /// + /// Callers that want strict "served from cache" semantics should treat + /// coalesced loads as misses; the current backend does not distinguish the + /// two cases. Prefer this over rolling a caller-side `Arc` + /// when the caller needs per-query hit/miss counters — the backend already + /// tracks this bit internally and this method just exposes it. + pub async fn get_or_insert_with_key_hit( + &self, + cache_key: K, + loader: F, + ) -> Result<(Arc, bool)> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + F: FnOnce() -> Fut + Send, + Fut: Future> + Send, + { + let key = self.sized_key(&cache_key); + let state = self.state.clone(); + let typed_loader = Box::pin(async move { + let value = Arc::new(loader().await?); + let size = state.entry_size(value.as_ref()); + Ok((value as CacheEntry, size)) + }); + + let (entry, was_cached) = self + .state + .backend + .get_or_insert(&key, typed_loader, K::codec()) + .await?; + let entry = entry.downcast::().map_err(|_| { + self.state.misses.fetch_add(1, Ordering::Relaxed); + Error::io(format!( + "cache backend returned a value with the wrong concrete type for key type {:?}", + K::stable_type_id() + )) + })?; + if was_cached { + self.state.hits.fetch_add(1, Ordering::Relaxed); + } else { + self.state.misses.fetch_add(1, Ordering::Relaxed); + } + Ok((entry, was_cached)) + } + + pub async fn insert_unsized_with_key(&self, cache_key: &K, metadata: Arc) + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let metadata = Arc::new(metadata); + let size = self.state.entry_size(metadata.as_ref()); + let key = self.unsized_key(cache_key); + self.state.backend.insert(&key, metadata, size, None).await; + } + + pub async fn get_or_insert_unsized_with_key( + &self, + cache_key: K, + loader: F, + ) -> Result> + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + F: FnOnce() -> Fut + Send, + Fut: Future>> + Send, + { + let key = self.unsized_key(&cache_key); + let state = self.state.clone(); + let typed_loader = Box::pin(async move { + let value = loader().await?; + let size = state.entry_size(&value); + Ok((Arc::new(value) as CacheEntry, size)) + }); + + let (entry, was_cached) = self + .state + .backend + .get_or_insert(&key, typed_loader, None) + .await?; + let entry = entry.downcast::>().map_err(|_| { + self.state.misses.fetch_add(1, Ordering::Relaxed); + Error::io(format!( + "cache backend returned a value with the wrong concrete type for unsized key type {:?}", + K::stable_type_id() + )) + })?; + if was_cached { + self.state.hits.fetch_add(1, Ordering::Relaxed); + } else { + self.state.misses.fetch_add(1, Ordering::Relaxed); + } + Ok(entry.as_ref().clone()) + } + + pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let key = self.unsized_key(cache_key); + let Some(entry) = self.state.backend.get(&key, None).await else { + self.state.misses.fetch_add(1, Ordering::Relaxed); + return None; + }; + match entry.downcast::>() { + Ok(value) => { + self.state.hits.fetch_add(1, Ordering::Relaxed); + Some(value.as_ref().clone()) + } + 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. + log::warn!( + "cache backend returned a value with the wrong concrete type for unsized key type {:?}", + K::stable_type_id() + ); + self.state.misses.fetch_add(1, Ordering::Relaxed); + None + } + } + } + + fn sized_key(&self, cache_key: &K) -> InternalCacheKey { + let mut builder = KeyBuilder::new(self.namespace, K::stable_type_id(), K::schema()); + cache_key.write_key(&mut builder); + builder.finish() + } + + fn unsized_key(&self, cache_key: &K) -> InternalCacheKey { + let mut builder = KeyBuilder::new(self.namespace, K::stable_type_id(), K::schema()); + cache_key.write_key(&mut builder); + builder.finish() + } +} + +// --------------------------------------------------------------------------- +// 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 { + state: Weak, + namespace: key::CacheNamespace, +} + +impl WeakLanceCache { + pub fn from(cache: &LanceCache) -> Self { + Self { + state: Arc::downgrade(&cache.state), + namespace: cache.namespace, + } + } + + pub fn with_key_prefix(&self, prefix: &str) -> Self { + Self { + state: self.state.clone(), + namespace: self.namespace.child(prefix), + } + } + + /// Weighted capacity in bytes, if the cache is alive and its backend + /// reports one. + pub fn capacity_bytes(&self) -> Option { + self.upgrade()?.capacity_bytes() + } + + pub async fn get_with_key(&self, cache_key: &K) -> Option> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + self.upgrade()?.get_with_key(cache_key).await + } + + pub async fn insert_with_key(&self, cache_key: &K, value: Arc) -> bool + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let Some(cache) = self.upgrade() else { + log::warn!("WeakLanceCache: cache no longer available, unable to insert item"); + return false; + }; + cache.insert_with_key(cache_key, value).await; + true + } + + /// 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, + { + self.get_or_insert_with_key_hit(cache_key, loader) + .await + .map(|(value, _)| value) + } + + /// Same as [`get_or_insert_with_key`](Self::get_or_insert_with_key), but + /// also returns a boolean indicating whether the loader was skipped for + /// this call. See [`LanceCache::get_or_insert_with_key_hit`] for the + /// coalesced-load caveat. + pub async fn get_or_insert_with_key_hit( + &self, + cache_key: K, + loader: F, + ) -> Result<(Arc, bool)> + where + K: CacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + F: FnOnce() -> Fut + Send, + Fut: Future> + Send, + { + let Some(cache) = self.upgrade() else { + log::warn!("WeakLanceCache: cache no longer available, computing without caching"); + return loader().await.map(|value| (Arc::new(value), false)); + }; + cache.get_or_insert_with_key_hit(cache_key, loader).await + } + + pub async fn get_unsized_with_key(&self, cache_key: &K) -> Option> + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + self.upgrade()?.get_unsized_with_key(cache_key).await + } + + pub async fn insert_unsized_with_key(&self, cache_key: &K, value: Arc) + where + K: UnsizedCacheKey, + K::ValueType: DeepSizeOf + Send + Sync + 'static, + { + let Some(cache) = self.upgrade() else { + log::warn!("WeakLanceCache: cache no longer available, unable to insert unsized item"); + return; + }; + cache.insert_unsized_with_key(cache_key, value).await; + } + + fn upgrade(&self) -> Option { + Some(LanceCache { + state: self.state.upgrade()?, + namespace: self.namespace, + }) + } +} + +// --------------------------------------------------------------------------- +// 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 std::collections::HashMap; + use std::pin::Pin; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc, + }; + use std::task::Poll; + use std::thread; + use std::time::Duration; + + use super::*; + + async fn report_first_pending( + future: F, + parked: tokio::sync::oneshot::Sender<()>, + ) -> F::Output + where + F: Future, + { + tokio::pin!(future); + let mut parked = Some(parked); + futures::future::poll_fn(|cx| match future.as_mut().poll(cx) { + Poll::Pending => { + if let Some(parked) = parked.take() { + let _ = parked.send(()); + } + Poll::Pending + } + Poll::Ready(output) => Poll::Ready(output), + }) + .await + } + + #[derive(Clone)] + struct VersionedTestKey { + id: u64, + } + + type TestKey = VersionedTestKey<1>; + type TestKeyV2 = VersionedTestKey<2>; + + impl VersionedTestKey { + fn new(id: u64) -> Self { + Self { id } + } + } + + impl CacheKey for VersionedTestKey { + type ValueType = Vec; + + fn key(&self) -> Cow<'_, str> { + self.id.to_string().into() + } + + fn type_name() -> &'static str { + "test.VecU32" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("test.vec-u32-key", SCHEMA_VERSION) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u64(self.id); + } + } + + struct SharedTestValue { + data: Arc>, + } + + impl DeepSizeOf for SharedTestValue { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.data.deep_size_of_children(context) + } + } + + struct SharedTestKey(u64); + + impl CacheKey for SharedTestKey { + type ValueType = SharedTestValue; + + fn key(&self) -> Cow<'_, str> { + self.0.to_string().into() + } + + fn type_name() -> &'static str { + "test.SharedValue" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("test.shared-value-key", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u64(self.0); + } + } + + struct ReentrantValue(LanceCache); + + impl DeepSizeOf for ReentrantValue { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.0.deep_size_of_children(context) + } + } + + struct ReentrantKey; + + impl CacheKey for ReentrantKey { + type ValueType = ReentrantValue; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed("reentrant") + } + + fn type_name() -> &'static str { + "test.ReentrantValue" + } + } + + #[derive(Clone, Copy, Debug)] + enum TestBackendKind { + Moka, + Quick, + } + + impl TestBackendKind { + fn cache(self, capacity: usize) -> LanceCache { + match self { + Self::Moka => LanceCache::with_capacity(capacity), + Self::Quick => { + LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(capacity))) + } + } + } + } + + struct LegacyBridgeKey(&'static str); + + impl CacheKey for LegacyBridgeKey { + type ValueType = Vec; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } + + fn type_name() -> &'static str { + "test.LegacyBridge" + } + } + + struct ExplicitBridgeKey(&'static str); + + impl CacheKey for ExplicitBridgeKey { + type ValueType = Vec; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } + + fn type_name() -> &'static str { + "test.LegacyBridge" + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_str(self.0); + } + } + + trait TestDynValue: DeepSizeOf + Send + Sync { + fn values(&self) -> &[u32]; + } + + impl TestDynValue for Vec { + fn values(&self) -> &[u32] { + self + } + } + + struct LegacyUnsizedBridgeKey(&'static str); + + impl UnsizedCacheKey for LegacyUnsizedBridgeKey { + type ValueType = dyn TestDynValue; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } + + fn type_name() -> &'static str { + "test.LegacyUnsizedBridge" + } + } + + struct ExplicitUnsizedBridgeKey(&'static str); + + impl UnsizedCacheKey for ExplicitUnsizedBridgeKey { + type ValueType = dyn TestDynValue; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed(self.0) + } + + fn type_name() -> &'static str { + "test.LegacyUnsizedBridge" + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_str(self.0); + } + } + + #[derive(Debug, Default)] + struct HashMapBackend { + entries: tokio::sync::Mutex>, + } + + #[async_trait::async_trait] + impl CacheBackend for HashMapBackend { + async fn get( + &self, + key: &InternalCacheKey, + _codec: Option, + ) -> Option { + self.entries + .lock() + .await + .get(key) + .map(|(entry, _)| entry.clone()) + } + + async fn insert( + &self, + key: &InternalCacheKey, + entry: CacheEntry, + size_bytes: usize, + _codec: Option, + ) { + self.entries.lock().await.insert(*key, (entry, size_bytes)); + } + + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + codec: Option, + ) -> Result<(CacheEntry, bool)> { + if let Some(entry) = self.get(key, codec).await { + return Ok((entry, true)); + } + let (entry, size_bytes) = loader.await?; + self.insert(key, entry.clone(), size_bytes, codec).await; + Ok((entry, false)) + } + + async fn clear(&self) { + self.entries.lock().await.clear(); + } + + async fn num_entries(&self) -> usize { + self.entries.lock().await.len() + } + + async fn size_bytes(&self) -> usize { + self.entries + .lock() + .await + .values() + .map(|(_, size_bytes)| size_bytes) + .sum() + } + } + + #[derive(Debug)] + struct WrongTypeBackend; + + #[async_trait::async_trait] + impl CacheBackend for WrongTypeBackend { + async fn get( + &self, + _key: &InternalCacheKey, + _codec: Option, + ) -> Option { + Some(Arc::new(String::from("wrong type"))) + } + + async fn insert( + &self, + _key: &InternalCacheKey, + _entry: CacheEntry, + _size_bytes: usize, + _codec: Option, + ) { + } + + async fn get_or_insert<'a>( + &self, + _key: &InternalCacheKey, + _loader: Pin> + Send + 'a>>, + _codec: Option, + ) -> Result<(CacheEntry, bool)> { + Ok((Arc::new(String::from("wrong type")), true)) + } + + async fn clear(&self) {} + + async fn num_entries(&self) -> usize { + 0 + } + + async fn size_bytes(&self) -> usize { + 0 + } + } + + #[tokio::test] + async fn typed_roundtrip_stats_clear_and_namespace_isolation() { + let cache = LanceCache::with_capacity(4096); + let left = cache.with_key_prefix("left"); + let right = cache.with_key_prefix("right"); + left.insert_with_key(&TestKey::new(7), Arc::new(vec![1, 2, 3])) + .await; + + assert_eq!( + left.get_with_key(&TestKey::new(7)).await.as_deref(), + Some(&vec![1, 2, 3]) + ); + assert!(right.get_with_key(&TestKey::new(7)).await.is_none()); + let stats = cache.stats().await; + assert_eq!((stats.hits, stats.misses, stats.num_entries), (1, 1, 1)); + + cache.clear().await; + let stats = left.stats().await; + assert_eq!((stats.hits, stats.misses, stats.num_entries), (0, 0, 0)); + } + + #[tokio::test] + async fn strong_and_weak_handles_share_state_and_namespace() { + let cache = LanceCache::with_capacity(4096); + let child = cache.with_key_prefix("child"); + let weak = WeakLanceCache::from(&child); + + assert!( + weak.insert_with_key(&TestKey::new(1), Arc::new(vec![1])) + .await + ); + assert_eq!( + child.get_with_key(&TestKey::new(1)).await.as_deref(), + Some(&vec![1]) + ); + child + .insert_with_key(&TestKey::new(2), Arc::new(vec![2])) + .await; + assert_eq!( + weak.get_with_key(&TestKey::new(2)).await.as_deref(), + Some(&vec![2]) + ); + assert_eq!((cache.stats().await.hits, cache.size().await), (2, 2)); + } + + #[tokio::test] + async fn nested_namespace_segments_do_not_alias_combined_segments() { + let cache = LanceCache::with_capacity(4096); + let nested = cache.with_key_prefix("a").with_key_prefix("b"); + let combined = cache.with_key_prefix("a/b"); + nested + .insert_with_key(&TestKey::new(1), Arc::new(vec![10])) + .await; + assert!(combined.get_with_key(&TestKey::new(1)).await.is_none()); + } + + #[tokio::test] + async fn schema_change_produces_a_cold_miss() { + let cache = LanceCache::with_capacity(4096); + cache + .insert_with_key(&TestKey::new(1), Arc::new(vec![10])) + .await; + assert!(cache.get_with_key(&TestKeyV2::new(1)).await.is_none()); + } + + #[tokio::test] + async fn get_or_insert_with_key_hit_reports_loader_execution() { + let cache = LanceCache::with_capacity(4096); + + // Cold: loader runs, was_cached = false. + let (value, was_cached) = cache + .get_or_insert_with_key_hit(TestKey::new(1), || async { Ok(vec![1, 2, 3]) }) + .await + .unwrap(); + assert_eq!(*value, vec![1, 2, 3]); + assert!(!was_cached); + + // Warm: loader must not run and was_cached = true. + let (value, was_cached) = cache + .get_or_insert_with_key_hit(TestKey::new(1), || async { + panic!("should not be called") + }) + .await + .unwrap(); + assert_eq!(*value, vec![1, 2, 3]); + assert!(was_cached); + } + + #[tokio::test] + async fn default_string_bridge_matches_explicit_legacy_encoding() { + let cache = LanceCache::with_capacity(4096); + cache + .insert_with_key(&LegacyBridgeKey("same"), Arc::new(vec![10])) + .await; + assert_eq!( + cache + .get_with_key(&ExplicitBridgeKey("same")) + .await + .as_deref(), + Some(&vec![10]) + ); + } + + #[tokio::test] + async fn unsized_default_string_bridge_matches_explicit_legacy_encoding() { + let cache = LanceCache::with_capacity(4096); + let value: Arc = Arc::new(vec![10, 20]); + cache + .insert_unsized_with_key(&LegacyUnsizedBridgeKey("same"), value) + .await; + + let cached = cache + .get_unsized_with_key(&ExplicitUnsizedBridgeKey("same")) + .await + .unwrap(); + assert_eq!(cached.values(), &[10, 20]); + } + + #[tokio::test] + async fn custom_backend_receives_opaque_keys_and_shared_clear() { + let backend = Arc::new(HashMapBackend::default()); + let cache = LanceCache::with_backend(backend.clone()); + let child = cache.with_key_prefix("child"); + let value = Arc::new(vec![1, 2, 3]); + let value_size = cache_entry_size(value.as_ref()); + + child.insert_with_key(&TestKey::new(7), value).await; + assert_eq!( + child.get_with_key(&TestKey::new(7)).await.as_deref(), + Some(&vec![1, 2, 3]) + ); + assert_eq!(backend.entries.lock().await.len(), 1); + assert_eq!(cache.size_bytes().await, value_size); + + cache.clear().await; + assert!(backend.entries.lock().await.is_empty()); + assert_eq!(child.stats().await.hits, 0); + } + + #[tokio::test] + async fn backend_type_collisions_are_contextual_misses_or_errors() { + let cache = LanceCache::with_backend(Arc::new(WrongTypeBackend)); + + assert!(cache.get_with_key(&TestKey::new(1)).await.is_none()); + let error = cache + .get_or_insert_with_key(TestKey::new(2), || async { Ok(vec![2]) }) + .await + .unwrap_err(); + assert!(error.to_string().contains("test.VecU32")); + let stats = cache.stats().await; + assert_eq!((stats.hits, stats.misses), (0, 2)); + } + + #[tokio::test] + async fn moka_weight_includes_the_fixed_physical_key() { + let value = Arc::new(vec![0_u32; 3]); + let expected = cache_entry_size(value.as_ref()) + .checked_add(std::mem::size_of::()) + .unwrap(); + let cache = LanceCache::with_capacity(expected * 2); + cache.insert_with_key(&TestKey::new(1), value).await; + assert_eq!(cache.size_bytes().await, expected); + } + + #[rstest::rstest] + #[case::moka(TestBackendKind::Moka)] + #[case::quick(TestBackendKind::Quick)] + #[tokio::test] + async fn deep_size_deduplicates_shared_entry_allocations( + #[case] backend_kind: TestBackendKind, + ) { + let cache = backend_kind.cache(1 << 20); + let shared_data = Arc::new(vec![0_u8; 1024]); + + for id in 0..2 { + let data = shared_data.clone(); + cache + .get_or_insert_with_key(SharedTestKey(id), || async move { + Ok(SharedTestValue { data }) + }) + .await + .unwrap(); + } + + let arc_overhead = std::mem::size_of::() * 2; + let shared_allocation = std::mem::size_of::>() + shared_data.capacity(); + let expected_entries = 2 * std::mem::size_of::() + + 2 * (std::mem::size_of::() + arc_overhead) + + shared_allocation; + + let weighted_size = cache.size_bytes().await; + assert_eq!(weighted_size, expected_entries + shared_allocation); + assert_eq!( + cache.deep_size_of(), + std::mem::size_of::() + expected_entries + ); + + let mut context = Context::new(); + assert_eq!(cache.deep_size_of_children(&mut context), expected_entries); + assert_eq!( + cache + .with_key_prefix("another-handle") + .deep_size_of_children(&mut context), + 0 + ); + } + + #[test] + fn sizing_can_reenter_the_same_cache() { + let (done_tx, done_rx) = mpsc::channel(); + let worker = thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let cache = LanceCache::with_capacity(4096); + cache + .insert_with_key(&ReentrantKey, Arc::new(ReentrantValue(cache.clone()))) + .await; + done_tx.send(()).unwrap(); + }); + }); + + done_rx + .recv_timeout(Duration::from_secs(1)) + .expect("cache insertion deadlocked during sizing"); + worker.join().unwrap(); + } + + #[tokio::test] + async fn no_cache_computes_each_time() { + let cache = LanceCache::no_cache(); + let loads = Arc::new(AtomicUsize::new(0)); + for _ in 0..2 { + let loads = loads.clone(); + let value = cache + .get_or_insert_with_key(TestKey::new(1), move || async move { + loads.fetch_add(1, Ordering::SeqCst); + Ok(vec![42]) + }) + .await + .unwrap(); + assert_eq!(value.as_slice(), &[42]); + } + assert_eq!(loads.load(Ordering::SeqCst), 2); + assert_eq!(cache.size().await, 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn single_flight_coalesces_success_after_contenders_are_parked() { + const CONTENDERS: usize = 4; + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let loader_calls = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Notify::new()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + + let owner = { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + let release = release.clone(); + tokio::spawn(async move { + cache + .get_or_insert_with_key(TestKey::new(10), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + release.notified().await; + Ok(vec![10]) + }) + .await + }) + }; + started_rx.await.unwrap(); + + let mut contenders = Vec::new(); + let mut parked = Vec::new(); + for _ in 0..CONTENDERS { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + let (parked_tx, parked_rx) = tokio::sync::oneshot::channel(); + parked.push(parked_rx); + contenders.push(tokio::spawn(async move { + report_first_pending( + cache.get_or_insert_with_key(TestKey::new(10), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![99]) + }), + parked_tx, + ) + .await + })); + } + for parked in parked { + parked + .await + .expect("contender completed instead of parking behind owner"); + } + assert_eq!(loader_calls.load(Ordering::SeqCst), 1); + assert!(contenders.iter().all(|handle| !handle.is_finished())); + + release.notify_one(); + assert_eq!(owner.await.unwrap().unwrap().as_slice(), &[10]); + for contender in contenders { + assert_eq!(contender.await.unwrap().unwrap().as_slice(), &[10]); + } + let stats = cache.stats().await; + assert_eq!((stats.hits, stats.misses), (CONTENDERS as u64, 1),); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn single_flight_coalesces_errors_after_contenders_are_parked() { + const CONTENDERS: usize = 4; + + let cache = Arc::new(LanceCache::with_capacity(4096)); + let loader_calls = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Notify::new()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + + let owner = { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + let release = release.clone(); + tokio::spawn(async move { + cache + .get_or_insert_with_key(TestKey::new(20), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + release.notified().await; + Err(Error::timeout("owner loader timed out")) + }) + .await + }) + }; + started_rx.await.unwrap(); + + let mut contenders = Vec::new(); + let mut parked = Vec::new(); + for _ in 0..CONTENDERS { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + let (parked_tx, parked_rx) = tokio::sync::oneshot::channel(); + parked.push(parked_rx); + contenders.push(tokio::spawn(async move { + report_first_pending( + cache.get_or_insert_with_key(TestKey::new(20), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::timeout("contender loader timed out")) + }), + parked_tx, + ) + .await + })); + } + for parked in parked { + parked + .await + .expect("contender completed instead of parking behind owner"); + } + assert_eq!(loader_calls.load(Ordering::SeqCst), 1); + assert!(contenders.iter().all(|handle| !handle.is_finished())); + + release.notify_one(); + assert!(matches!(owner.await.unwrap(), Err(Error::Timeout { .. }))); + for contender in contenders { + assert!(matches!( + contender.await.unwrap(), + Err(Error::Timeout { .. }) + )); + } + assert_eq!(loader_calls.load(Ordering::SeqCst), 1); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn single_flight_retries_after_the_owner_is_cancelled() { + let cache = Arc::new(LanceCache::with_capacity(4096)); + let loader_calls = Arc::new(AtomicUsize::new(0)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + + let owner = { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + tokio::spawn(async move { + cache + .get_or_insert_with_key(TestKey::new(30), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + Ok(vec![30]) + }) + .await + }) + }; + started_rx.await.unwrap(); + + let (parked_tx, parked_rx) = tokio::sync::oneshot::channel(); + let contender = { + let cache = cache.clone(); + let loader_calls = loader_calls.clone(); + tokio::spawn(async move { + report_first_pending( + cache.get_or_insert_with_key(TestKey::new(30), move || async move { + loader_calls.fetch_add(1, Ordering::SeqCst); + Ok(vec![31]) + }), + parked_tx, + ) + .await + }) + }; + parked_rx + .await + .expect("contender completed instead of parking behind owner"); + assert_eq!(loader_calls.load(Ordering::SeqCst), 1); + assert!(!contender.is_finished()); + + owner.abort(); + assert!(owner.await.unwrap_err().is_cancelled()); + let value = tokio::time::timeout(std::time::Duration::from_secs(5), contender) + .await + .expect("contender remained parked after owner cancellation") + .unwrap() + .unwrap(); + assert_eq!(value.as_slice(), &[31]); + assert_eq!(loader_calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn expired_weak_cache_degrades_without_retaining_state() { + let cache = LanceCache::with_capacity(4096); + let weak = WeakLanceCache::from(&cache); + drop(cache); + + assert!(weak.get_with_key(&TestKey::new(1)).await.is_none()); + assert!( + !weak + .insert_with_key(&TestKey::new(1), Arc::new(vec![1])) + .await + ); + let value = weak + .get_or_insert_with_key(TestKey::new(1), || async { Ok(vec![7]) }) + .await + .unwrap(); + assert_eq!(value.as_slice(), &[7]); + } +} diff --git a/rust/lance-core/src/cache/moka.rs b/rust/lance-core/src/cache/moka.rs new file mode 100644 index 00000000000..4a09b2147fa --- /dev/null +++ b/rust/lance-core/src/cache/moka.rs @@ -0,0 +1,431 @@ +// 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 crate::deepsize::Context; +use crate::error::CloneableError; + +use super::backend::{CacheBackend, CacheEntry}; +use super::{CacheCodec, InternalCacheKey}; + +/// Internal record stored in the moka cache. +#[derive(Clone, Debug)] +struct MokaCacheEntry { + entry: CacheEntry, + size_bytes: usize, +} + +/// Per-entry key cost for eviction. +pub(super) fn key_footprint(_key: &InternalCacheKey) -> usize { + std::mem::size_of::() +} + +fn physical_size(key: &InternalCacheKey, size_bytes: usize) -> usize { + key_footprint(key).saturating_add(size_bytes) +} + +/// Number of physical bytes represented by one Moka weight unit. +/// +/// Moka limits each entry's weight to `u32`, so capacities above 4 GiB need +/// coarser units to account for a single large entry without undercharging it. +fn weight_unit(capacity: usize) -> usize { + capacity.div_ceil(u32::MAX as usize).max(1) +} + +fn entry_weight(key: &InternalCacheKey, size_bytes: usize, weight_unit: usize) -> u32 { + physical_size(key, size_bytes) + .div_ceil(weight_unit) + .try_into() + .unwrap_or(u32::MAX) +} + +/// Default [`CacheBackend`] backed by a [moka](https://crates.io/crates/moka) cache. +/// +/// Nonzero capacities provide weighted eviction and concurrent-load deduplication. +/// A zero capacity invokes each loader independently without caching. +pub struct MokaCacheBackend { + cache: moka::future::Cache, + capacity: usize, + weight_unit: usize, +} + +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 weight_unit = weight_unit(capacity); + let capacity_weight = capacity.div_ceil(weight_unit) as u64; + let cache = moka::future::Cache::builder() + .max_capacity(capacity_weight) + .weigher(move |key: &InternalCacheKey, entry: &MokaCacheEntry| { + entry_weight(key, entry.size_bytes, weight_unit) + }) + .build(); + Self { + cache, + capacity, + weight_unit, + } + } + + pub fn no_cache() -> Self { + Self { + cache: moka::future::Cache::new(0), + capacity: 0, + weight_unit: 1, + } + } + + /// Configured weighted capacity in bytes. + pub fn capacity(&self) -> usize { + self.capacity + } + + fn weighted_size_bytes(&self) -> usize { + self.cache + .weighted_size() + .saturating_mul(self.weight_unit as u64) + .try_into() + .unwrap_or(usize::MAX) + } +} + +#[async_trait] +impl CacheBackend for MokaCacheBackend { + 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, + _codec: Option, + ) { + self.cache + .insert(*key, MokaCacheEntry { entry, size_bytes }) + .await; + } + + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + _codec: Option, + ) -> Result<(CacheEntry, bool)> { + // Avoid Moka's single-flight waiters when nothing can be cached. + if self.capacity == 0 { + return loader.await.map(|(entry, _)| (entry, false)); + } + + // 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); + loader + .await + .map(|(entry, size_bytes)| MokaCacheEntry { entry, size_bytes }) + .map_err(CloneableError) + }; + + let owned_key = *key; + match self.cache.try_get_with(owned_key, init).await { + Ok(record) => { + let was_cached = !was_miss.load(Ordering::Relaxed); + Ok((record.entry, was_cached)) + } + Err(error) => Err(Arc::unwrap_or_clone(error).0), + } + } + + 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.weighted_size_bytes() + } + + fn capacity_bytes(&self) -> Option { + Some(self.capacity) + } + + fn approx_num_entries(&self) -> usize { + self.cache.entry_count() as usize + } + + fn approx_size_bytes(&self) -> usize { + // `weighted_size()` can be stale without `run_pending_tasks()`, which + // is async and can't be called from this synchronous context. + self.weighted_size_bytes() + } + + fn deep_size_of_entries( + &self, + context: &mut Context, + size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option, + ) -> Option { + Some( + self.cache + .iter() + .map(|(key, record)| { + key_footprint(key.as_ref()) + + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes) + }) + .sum(), + ) + } +} + +#[cfg(test)] +mod tests { + use std::pin::pin; + + use futures::{FutureExt, poll}; + use tokio::sync::oneshot; + + use super::*; + + #[rstest::rstest] + #[case::zero_capacity_success(MokaCacheBackend::with_capacity(0), false)] + #[case::zero_capacity_error(MokaCacheBackend::with_capacity(0), true)] + #[case::no_cache_success(MokaCacheBackend::no_cache(), false)] + #[case::no_cache_error(MokaCacheBackend::no_cache(), true)] + #[tokio::test] + async fn zero_capacity_loaders_run_independently( + #[case] backend: MokaCacheBackend, + #[case] loader_fails: bool, + ) { + let key = InternalCacheKey::from_bytes([0; 16]); + let first_entry: CacheEntry = Arc::new(1_u64); + let second_entry: CacheEntry = Arc::new(2_u64); + let (release_tx, release_rx) = oneshot::channel(); + let entry = first_entry.clone(); + let mut first = pin!(backend.get_or_insert( + &key, + Box::pin(async move { + release_rx.await.unwrap(); + Ok((entry, 8)) + }), + None, + )); + assert!(poll!(first.as_mut()).is_pending()); + + let entry = second_entry.clone(); + let second = backend + .get_or_insert( + &key, + Box::pin(async move { + if loader_fails { + Err(crate::Error::invalid_input("independent loader failed")) + } else { + Ok((entry, 8)) + } + }), + None, + ) + .now_or_never() + .expect("a disabled cache must not wait for another loader on the same key"); + if loader_fails { + let error = second.unwrap_err(); + assert!(matches!(&error, crate::Error::InvalidInput { .. })); + assert!(error.to_string().contains("independent loader failed")); + } else { + let (entry, was_cached) = second.unwrap(); + assert!(Arc::ptr_eq(&entry, &second_entry)); + assert!(!was_cached); + } + + release_tx.send(()).unwrap(); + let (entry, was_cached) = first.await.unwrap(); + assert!(Arc::ptr_eq(&entry, &first_entry)); + assert!(!was_cached); + assert!(backend.get(&key, None).await.is_none()); + assert_eq!(backend.num_entries().await, 0); + assert_eq!(backend.size_bytes().await, 0); + } + + #[test] + fn entry_weights_are_exact_at_byte_granularity() { + let key = InternalCacheKey::from_bytes([0; 16]); + assert_eq!(weight_unit(4096), 1); + assert_eq!(entry_weight(&key, 7, 1), 23); + } + + #[test] + fn capacity_bytes_reports_configured_capacity() { + assert_eq!( + MokaCacheBackend::with_capacity(4096).capacity_bytes(), + Some(4096) + ); + assert_eq!(MokaCacheBackend::no_cache().capacity_bytes(), Some(0)); + } + + #[tokio::test] + async fn size_methods_use_constant_time_weighted_accounting() { + let backend = MokaCacheBackend::with_capacity(4096); + let key = InternalCacheKey::from_bytes([0; 16]); + let entry: CacheEntry = Arc::new(()); + let value_size = 7; + let expected = physical_size(&key, value_size); + + backend.insert(&key, entry, value_size, None).await; + + assert_eq!(backend.size_bytes().await, expected); + assert_eq!(backend.approx_size_bytes(), expected); + } + + #[cfg(target_pointer_width = "64")] + #[test] + fn entry_weights_scale_for_capacities_above_four_gibibytes() { + let key = InternalCacheKey::from_bytes([0; 16]); + let capacity = 6 * 1024 * 1024 * 1024; + let weight_unit = weight_unit(capacity); + assert_eq!(weight_unit, 2); + + let size_bytes = u32::MAX as usize + 1024; + let expected = physical_size(&key, size_bytes).div_ceil(weight_unit); + let weight = entry_weight(&key, size_bytes, weight_unit); + assert_eq!(weight as usize, expected); + assert_ne!(weight, u32::MAX); + } +} + +/// Registry identifier for the built-in Moka backend. +pub const MOKA_BACKEND_KIND: &str = "moka"; + +/// [`BackendBuildFn`](super::registry::BackendBuildFn) for [`MokaCacheBackend`]. +/// +/// Recognized options: +/// * `capacity` — total weighted capacity in bytes (`usize`). +/// This must be present and non-empty. +/// +/// Unknown options are rejected so typos surface immediately instead of +/// silently falling through to the default capacity. +pub(super) fn build_moka_backend( + config: &super::registry::BackendConfig, +) -> Result { + let mut capacity: Option = None; + for (key, value) in &config.options { + match key.as_str() { + "capacity" => { + if value.is_empty() { + return Err(crate::Error::invalid_input( + "moka cache backend: capacity must not be empty", + )); + } else { + capacity = Some(value.parse::().map_err(|err| { + crate::Error::invalid_input(format!( + "moka cache backend: cannot parse capacity {:?}: {}", + value, err + )) + })?); + } + } + other => { + return Err(crate::Error::invalid_input(format!( + "moka cache backend: unknown option {:?}", + other + ))); + } + } + } + let capacity = capacity.ok_or_else(|| { + crate::Error::invalid_input( + "moka cache backend: capacity is required; use moka://?capacity=", + ) + })?; + Ok(MokaCacheBackend::with_capacity(capacity)) +} + +pub(super) fn build_moka(config: &super::registry::BackendConfig) -> Result> { + Ok(Arc::new(build_moka_backend(config)?)) +} + +#[cfg(test)] +mod moka_registry_tests { + use super::super::backend_uri::{build_from_uri, parse_backend_uri}; + use super::super::registry::{BackendConfig, build_from_config, registry_test_lock}; + use super::*; + + #[test] + fn test_moka_builds_from_config() { + let _lock = registry_test_lock(); + let cfg = BackendConfig::new("moka") + .unwrap() + .with_option("capacity", "1048576"); + let backend = build_moka_backend(&cfg).unwrap(); + assert_eq!(backend.capacity(), 1048576); + let _backend = build_from_config(&cfg).unwrap(); + } + + #[test] + fn test_moka_builds_from_uri() { + let _lock = registry_test_lock(); + let cfg = parse_backend_uri("moka://?capacity=1048576").unwrap(); + let backend = build_moka_backend(&cfg).unwrap(); + assert_eq!(backend.capacity(), 1048576); + let _backend = build_from_uri("moka://?capacity=1048576").unwrap(); + } + + #[test] + fn test_moka_rejects_unknown_option() { + let _lock = registry_test_lock(); + let cfg = BackendConfig::new("moka") + .unwrap() + .with_option("mystery", "1"); + let err = build_from_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("unknown option")); + } + + #[test] + fn test_moka_rejects_bad_capacity() { + let _lock = registry_test_lock(); + let cfg = BackendConfig::new("moka") + .unwrap() + .with_option("capacity", "not-a-number"); + let err = build_from_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("cannot parse capacity")); + } + + #[test] + fn test_moka_rejects_missing_capacity() { + let _lock = registry_test_lock(); + let cfg = BackendConfig::new("moka").unwrap(); + let err = build_from_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("capacity is required")); + } + + #[test] + fn test_moka_rejects_empty_capacity() { + let _lock = registry_test_lock(); + let cfg = BackendConfig::new("moka") + .unwrap() + .with_option("capacity", ""); + let err = build_from_config(&cfg).unwrap_err(); + assert!(err.to_string().contains("capacity must not be empty")); + } +} diff --git a/rust/lance-core/src/cache/quick.rs b/rust/lance-core/src/cache/quick.rs new file mode 100644 index 00000000000..028c1cb2023 --- /dev/null +++ b/rust/lance-core/src/cache/quick.rs @@ -0,0 +1,607 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! [`CacheBackend`] backed by [quick_cache](https://crates.io/crates/quick_cache). +//! A hit takes a shard read lock, clones the cached value, and marks it as +//! accessed. There is no read-operation channel or inline eviction work. + +use std::pin::Pin; + +use async_trait::async_trait; +use futures::Future; + +use super::backend::{CacheBackend, CacheEntry}; +use super::moka::key_footprint; +use super::{CacheCodec, InternalCacheKey}; +use crate::Result; +use crate::deepsize::Context; + +#[derive(Clone)] +struct QuickEntry { + entry: CacheEntry, + size_bytes: usize, +} + +#[derive(Clone)] +struct EntryWeighter; + +impl quick_cache::Weighter for EntryWeighter { + fn weight(&self, key: &InternalCacheKey, value: &QuickEntry) -> u64 { + // Same accounting as the moka backend. + key_footprint(key).saturating_add(value.size_bytes).max(1) as u64 + } +} + +pub struct QuickCacheBackend { + cache: quick_cache::sync::Cache, + capacity: usize, +} + +/// Controls how a [`QuickCacheBackend`] divides its weight budget. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum QuickCacheShardPolicy { + /// Choose a shard count from the cache capacity and available parallelism. + #[default] + Recommended, + /// Give every entry access to one shared weight budget. + /// + /// This avoids capacity fragmentation for a small number of large, + /// unequal entries. Concurrent hits can still take the shard read lock. + Single, +} + +impl std::fmt::Debug for QuickCacheBackend { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("QuickCacheBackend") + .field("entry_count", &self.cache.len()) + .finish() + } +} + +/// Minimum weight budget (4 GiB) per shard: shards don't borrow capacity, and +/// an entry heavier than ~its shard's budget is silently refused admission. +const MIN_SHARD_SHARE: usize = 4 << 30; + +/// Recommended shard count: `min(cpus / 2, capacity / 4 GiB)`, power of two +/// in `[1, 1024]`. The cpu term bounds lock contention; the capacity term +/// keeps each shard's budget >= 4 GiB so large entries stay admissible. +/// Rounded down because quick_cache rounds requests up. +pub fn recommended_cache_shards(capacity: usize) -> usize { + let available_parallelism = std::thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(2); + recommended_cache_shards_for_parallelism(capacity, available_parallelism) +} + +fn recommended_cache_shards_for_parallelism( + capacity: usize, + available_parallelism: usize, +) -> usize { + let by_cpu = available_parallelism / 2; + let shards = (capacity / MIN_SHARD_SHARE).min(by_cpu).max(1); + let shards = if shards.is_power_of_two() { + shards + } else { + shards.next_power_of_two() / 2 + }; + shards.clamp(1, 1024) +} + +/// Assumed average entry size for pre-allocation sizing. +const ESTIMATED_AVG_ENTRY_BYTES: usize = 64 << 10; + +impl QuickCacheBackend { + /// Create a backend holding up to `capacity` bytes of weighted entries + /// (weight = key footprint + declared size), sharded per + /// [`recommended_cache_shards`]. + pub fn with_capacity(capacity: usize) -> Self { + Self::with_shard_policy(capacity, QuickCacheShardPolicy::Recommended) + } + + /// Create a weighted cache with an explicit shard policy. + /// + /// `capacity` bounds the sum of key footprints and declared entry sizes. + /// Each shard has an independent share of that bound. In addition, the + /// default Quick admission policy rejects an unpinned entry heavier than + /// approximately 97% of one shard's share. + /// + /// # Example + /// + /// ``` + /// use lance_core::cache::{QuickCacheBackend, QuickCacheShardPolicy}; + /// + /// let cache = QuickCacheBackend::with_shard_policy( + /// 8 << 30, + /// QuickCacheShardPolicy::Single, + /// ); + /// ``` + pub fn with_shard_policy(capacity: usize, shard_policy: QuickCacheShardPolicy) -> Self { + let shards = match shard_policy { + QuickCacheShardPolicy::Recommended => recommended_cache_shards(capacity), + QuickCacheShardPolicy::Single => 1, + }; + Self::with_shards(capacity, shards) + } + + fn with_shards(capacity: usize, shards: usize) -> Self { + // Floor protects the shard count from quick_cache's items-per-shard + // heuristic; ceiling bounds pre-allocation. + let estimated_items = (capacity / ESTIMATED_AVG_ENTRY_BYTES).clamp(shards * 32, 1_000_000); + let options = quick_cache::OptionsBuilder::new() + .estimated_items_capacity(estimated_items) + .weight_capacity(capacity as u64) + .shards(shards) + .build() + // Only errors when weight/item capacity is missing; both are set. + .expect("quick_cache options"); + let cache = quick_cache::sync::Cache::with_options( + options, + EntryWeighter, + Default::default(), + Default::default(), + ); + Self { cache, capacity } + } + + #[cfg(test)] + fn num_shards(&self) -> usize { + self.cache.num_shards() + } + + #[cfg(test)] + fn shard_index(&self, key: &InternalCacheKey) -> usize { + self.cache.shard_index(key) + } +} + +#[async_trait] +impl CacheBackend for QuickCacheBackend { + async fn get(&self, key: &InternalCacheKey, _codec: Option) -> Option { + self.cache.get(key).map(|v| v.entry) + } + + async fn insert( + &self, + key: &InternalCacheKey, + entry: CacheEntry, + size_bytes: usize, + _codec: Option, + ) { + self.cache.insert(*key, QuickEntry { entry, size_bytes }); + } + + async fn get_or_insert<'a>( + &self, + key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + _codec: Option, + ) -> Result<(CacheEntry, bool)> { + match self.cache.get_value_or_guard_async(key).await { + Ok(value) => Ok((value.entry, true)), + Err(guard) => { + let (entry, size_bytes) = loader.await?; + let _ = guard.insert(QuickEntry { + entry: entry.clone(), + size_bytes, + }); + Ok((entry, false)) + } + } + } + + async fn clear(&self) { + self.cache.clear(); + } + + async fn num_entries(&self) -> usize { + self.cache.len() + } + + async fn size_bytes(&self) -> usize { + self.cache.weight() as usize + } + + fn capacity_bytes(&self) -> Option { + Some(self.capacity) + } + + fn approx_num_entries(&self) -> usize { + self.cache.len() + } + + fn approx_size_bytes(&self) -> usize { + self.cache.weight() as usize + } + + fn deep_size_of_entries( + &self, + context: &mut Context, + size_of_entry: &dyn Fn(&CacheEntry, &mut Context) -> Option, + ) -> Option { + Some( + self.cache + .iter() + .map(|(key, record)| { + key_footprint(&key) + + size_of_entry(&record.entry, context).unwrap_or(record.size_bytes) + }) + .sum(), + ) + } +} + +#[cfg(test)] +mod tests { + use std::marker::PhantomData; + use std::sync::Arc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use super::*; + use crate::cache::{CacheKey, LanceCache}; + + const TEST_CAPACITY: usize = 1_000; + + 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] + fn entry_weight_includes_fixed_key() { + let key = InternalCacheKey::from_bytes([0; 16]); + let entry = QuickEntry { + entry: Arc::new(()), + size_bytes: 7, + }; + assert_eq!( + quick_cache::Weighter::weight(&EntryWeighter, &key, &entry), + 23 + ); + } + + #[test] + fn capacity_bytes_reports_configured_capacity() { + assert_eq!( + QuickCacheBackend::with_capacity(1 << 20).capacity_bytes(), + Some(1 << 20) + ); + } + + fn keys_in_shard( + cache: &QuickCacheBackend, + shard_index: usize, + count: usize, + ) -> Vec { + let mut keys = Vec::with_capacity(count); + for value in 0_u128.. { + let key = InternalCacheKey::from_bytes(value.to_le_bytes()); + if cache.shard_index(&key) == shard_index { + keys.push(key); + if keys.len() == count { + return keys; + } + } + } + unreachable!("the key space must contain enough keys for every shard") + } + + async fn load_declared_value( + cache: &QuickCacheBackend, + key: &InternalCacheKey, + value: usize, + size_bytes: usize, + loads: &AtomicUsize, + ) -> CacheEntry { + let (entry, _) = cache + .get_or_insert( + key, + Box::pin(async { + loads.fetch_add(1, Ordering::SeqCst); + Ok((Arc::new(value) as CacheEntry, size_bytes)) + }), + None, + ) + .await + .unwrap(); + assert_eq!(*entry.downcast_ref::().unwrap(), value); + entry + } + + #[test] + fn recommended_shards_cover_large_capacity_boundaries() { + assert_eq!(recommended_cache_shards_for_parallelism(8 << 30, 4), 2); + assert_eq!( + recommended_cache_shards_for_parallelism((8 << 30) - 1, 4), + 1 + ); + assert_eq!(recommended_cache_shards_for_parallelism(16 << 30, 8), 4); + assert_eq!( + recommended_cache_shards_for_parallelism((16 << 30) - 1, 8), + 2 + ); + } + + #[tokio::test] + async fn single_shard_avoids_fragmentation_for_unequal_entries() { + // Declared entry weights, including the 16-byte key, total 660. They + // fit the whole cache but not one 500-byte share of a two-shard cache. + let sizes = [168, 188, 256]; + let sharded = QuickCacheBackend::with_shards(TEST_CAPACITY, 2); + assert_eq!(sharded.num_shards(), 2); + let sharded_keys = keys_in_shard(&sharded, 0, sizes.len()); + let sharded_loads = AtomicUsize::new(0); + for _ in 0..3 { + for (value, (key, size_bytes)) in sharded_keys.iter().zip(sizes).enumerate() { + load_declared_value(&sharded, key, value, size_bytes, &sharded_loads).await; + } + } + assert!(sharded_loads.load(Ordering::SeqCst) > sizes.len()); + assert!(sharded.num_entries().await < sizes.len()); + assert!(sharded.size_bytes().await <= TEST_CAPACITY); + + let single = + QuickCacheBackend::with_shard_policy(TEST_CAPACITY, QuickCacheShardPolicy::Single); + let single_loads = AtomicUsize::new(0); + for _ in 0..3 { + for (value, (key, size_bytes)) in sharded_keys.iter().zip(sizes).enumerate() { + load_declared_value(&single, key, value, size_bytes, &single_loads).await; + } + } + assert_eq!(single_loads.load(Ordering::SeqCst), sizes.len()); + assert_eq!(single.num_entries().await, sizes.len()); + assert_eq!(single.size_bytes().await, 660); + } + + #[tokio::test] + async fn direct_insert_obeys_the_same_shard_budget() { + let sizes = [168, 188, 256]; + for (shards, expected_entries) in [(2, 2), (1, 3)] { + let cache = QuickCacheBackend::with_shards(TEST_CAPACITY, shards); + let keys = keys_in_shard(&cache, 0, sizes.len()); + for (value, (key, size_bytes)) in keys.iter().zip(sizes).enumerate() { + cache.insert(key, Arc::new(value), size_bytes, None).await; + } + assert_eq!(cache.num_entries().await, expected_entries); + assert!(cache.size_bytes().await <= TEST_CAPACITY); + } + } + + #[tokio::test] + async fn single_shard_preserves_capacity_and_hot_admission_limits() { + let cache = QuickCacheBackend::with_shards(TEST_CAPACITY, 1); + let keys = keys_in_shard(&cache, 0, 4); + + // The default hot target is 97% of capacity. Weight includes the key, + // so a declared size of 954 is admitted at weight 970, while 955 is not. + cache.insert(&keys[0], Arc::new(0_usize), 954, None).await; + assert!(cache.get(&keys[0], None).await.is_some()); + cache.clear().await; + cache.insert(&keys[1], Arc::new(1_usize), 955, None).await; + assert!(cache.get(&keys[1], None).await.is_none()); + + // Individually admissible entries whose working set exceeds the total + // budget must still reload and remain bounded. + let loads = AtomicUsize::new(0); + for _ in 0..2 { + for (value, key) in keys[1..].iter().enumerate() { + load_declared_value(&cache, key, value, 384, &loads).await; + } + } + assert!(loads.load(Ordering::SeqCst) > 3); + assert!(cache.size_bytes().await <= TEST_CAPACITY); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn concurrent_misses_share_a_load_and_retry_failure() { + let cache = Arc::new(QuickCacheBackend::with_shards(4096, 1)); + let key = InternalCacheKey::from_bytes([42; 16]); + let loads = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(tokio::sync::Notify::new()); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + + let owner = { + let cache = cache.clone(); + let loads = loads.clone(); + let release = release.clone(); + tokio::spawn(async move { + cache + .get_or_insert( + &key, + Box::pin(async move { + loads.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + release.notified().await; + Err(crate::Error::timeout("test loader failed")) + }), + None, + ) + .await + }) + }; + started_rx.await.unwrap(); + + let contender = { + let cache = cache.clone(); + let loads = loads.clone(); + tokio::spawn(async move { + cache + .get_or_insert( + &key, + Box::pin(async move { + loads.fetch_add(1, Ordering::SeqCst); + Ok((Arc::new(7_usize) as CacheEntry, 8)) + }), + None, + ) + .await + }) + }; + tokio::task::yield_now().await; + assert_eq!(loads.load(Ordering::SeqCst), 1); + release.notify_one(); + assert!(owner.await.unwrap().is_err()); + let (value, is_hit) = contender.await.unwrap().unwrap(); + assert_eq!(*value.downcast_ref::().unwrap(), 7); + assert!(!is_hit); + assert_eq!(loads.load(Ordering::SeqCst), 2); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn cancelled_loader_releases_concurrent_miss() { + let cache = Arc::new(QuickCacheBackend::with_shards(4096, 1)); + let key = InternalCacheKey::from_bytes([24; 16]); + let loads = Arc::new(AtomicUsize::new(0)); + let (started_tx, started_rx) = tokio::sync::oneshot::channel(); + + let owner = { + let cache = cache.clone(); + let loads = loads.clone(); + tokio::spawn(async move { + cache + .get_or_insert( + &key, + Box::pin(async move { + loads.fetch_add(1, Ordering::SeqCst); + let _ = started_tx.send(()); + std::future::pending::<()>().await; + Ok((Arc::new(1_usize) as CacheEntry, 8)) + }), + None, + ) + .await + }) + }; + started_rx.await.unwrap(); + + let contender = { + let cache = cache.clone(); + let loads = loads.clone(); + tokio::spawn(async move { + cache + .get_or_insert( + &key, + Box::pin(async move { + loads.fetch_add(1, Ordering::SeqCst); + Ok((Arc::new(2_usize) as CacheEntry, 8)) + }), + None, + ) + .await + }) + }; + tokio::task::yield_now().await; + assert_eq!(loads.load(Ordering::SeqCst), 1); + + owner.abort(); + assert!(owner.await.unwrap_err().is_cancelled()); + let (value, is_hit) = tokio::time::timeout(std::time::Duration::from_secs(1), contender) + .await + .expect("contender remained blocked after loader cancellation") + .unwrap() + .unwrap(); + assert_eq!(*value.downcast_ref::().unwrap(), 2); + assert!(!is_hit); + assert_eq!(loads.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn test_quick_backend_roundtrip_singleflight_and_eviction() { + // Capacity must be large relative to one entry: quick_cache shards + // its weight budget, and an entry heavier than its shard's share is + // not admitted at all. + const CAPACITY: usize = 1 << 20; + let item = Arc::new(vec![1u8, 2, 3]); + let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY))); + + // insert + get roundtrip and weighted accounting + cache + .insert_with_key(&TestKey::>::new("a"), item.clone()) + .await; + assert_eq!( + cache + .get_with_key(&TestKey::>::new("a")) + .await + .as_deref(), + Some(&vec![1u8, 2, 3]) + ); + assert_eq!(cache.approx_size(), 1); + assert!(cache.size_bytes().await > 0); + + // get_or_insert runs the loader only on a miss + let loads = Arc::new(AtomicUsize::new(0)); + for _ in 0..2 { + let loads = loads.clone(); + let value = cache + .get_or_insert_with_key(TestKey::>::new("b"), || async move { + loads.fetch_add(1, Ordering::SeqCst); + Ok(vec![7u8]) + }) + .await + .unwrap(); + assert_eq!(value.as_ref(), &vec![7u8]); + } + assert_eq!(loads.load(Ordering::SeqCst), 1); + + // capacity is enforced: overfill with 4x capacity of 16KiB entries + // and confirm eviction kept the weighted size within budget + for i in 0..256 { + cache + .insert_with_key( + &TestKey::>::new(&format!("fill-{i}")), + Arc::new(vec![0u8; 16 << 10]), + ) + .await; + } + assert!(cache.size_bytes().await <= CAPACITY); + assert!(cache.size().await < 258); + + cache.clear().await; + assert_eq!(cache.size().await, 0); + } + + #[tokio::test] + async fn test_quick_backend_tiny_capacity() { + // A tiny cache must not over-provision item metadata and must still + // admit and evict correctly within its weight budget. + const CAPACITY: usize = 64 << 10; + let cache = LanceCache::with_backend(Arc::new(QuickCacheBackend::with_capacity(CAPACITY))); + for i in 0..64 { + cache + .insert_with_key( + &TestKey::>::new(&format!("k-{i}")), + Arc::new(vec![0u8; 4 << 10]), + ) + .await; + } + assert!(cache.size_bytes().await <= CAPACITY); + assert!(cache.size().await >= 1); + let hit = cache + .get_with_key(&TestKey::>::new("k-63")) + .await + .is_some() + || cache + .get_with_key(&TestKey::>::new("k-62")) + .await + .is_some(); + assert!(hit, "recently inserted entries should be resident"); + } +} diff --git a/rust/lance-core/src/cache/registry.rs b/rust/lance-core/src/cache/registry.rs new file mode 100644 index 00000000000..f370bdd3f8f --- /dev/null +++ b/rust/lance-core/src/cache/registry.rs @@ -0,0 +1,372 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Pluggable cache-backend registry. +//! +//! A [`BackendConfig`] identifies which backend to build (`kind`) and carries +//! backend-specific string options. Backends are constructed through a +//! [`BackendBuildFn`] registered under a unique `kind`. Third-party crates +//! integrate by calling [`register_backend`] once at application startup; +//! [`build_from_config`] then locates the constructor and hands it the +//! config. +//! +//! The registry uses `HashMap` for options so it can be +//! represented naturally across FFI (Python `dict[str, str]`, Java +//! `Map`, etc.). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex, MutexGuard, OnceLock}; + +use super::backend::CacheBackend; +use super::moka::{MOKA_BACKEND_KIND, build_moka}; +use crate::{Error, Result}; + +/// Backend-independent configuration passed to a [`BackendBuildFn`]. +/// +/// `kind` selects which registered backend to construct; `options` carries +/// backend-specific key/value settings (e.g. `capacity`, `path`). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendConfig { + /// Registered backend identifier, e.g. `"moka"`. + pub kind: String, + /// Backend-specific string options. + pub options: HashMap, +} + +impl BackendConfig { + /// Build a config with no options. + pub fn new(kind: impl AsRef) -> Result { + Ok(Self { + kind: normalize_backend_kind(kind.as_ref())?, + options: HashMap::new(), + }) + } + + /// Insert a single option and return `self`, enabling chaining. + pub fn with_option(mut self, key: impl Into, value: impl Into) -> Self { + self.options.insert(key.into(), value.into()); + self + } +} + +/// Normalize and validate a cache backend kind. +/// +/// Backend kinds share the same syntax as URI schemes. They are matched +/// case-insensitively and stored as lowercase ASCII so registry lookups, +/// config dictionaries, and URI parsing all address the same key. +pub fn normalize_backend_kind(kind: &str) -> Result { + let mut chars = kind.chars(); + match chars.next() { + Some(c) if c.is_ascii_alphabetic() => {} + _ => { + return Err(Error::invalid_input(format!( + "cache backend kind {:?}: must start with an ASCII letter", + kind + ))); + } + } + for c in chars { + let ok = c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.'); + if !ok { + return Err(Error::invalid_input(format!( + "cache backend kind {:?}: invalid character {:?}", + kind, c + ))); + } + } + Ok(kind.to_ascii_lowercase()) +} + +/// Constructor signature for a cache backend. +/// +/// Constructors are synchronous. Backends that need async initialization +/// should surface a `try_new_blocking` shim (or equivalent) and call it here. +pub type BackendBuildFn = fn(&BackendConfig) -> Result>; + +fn registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +fn registry_lock() -> Result>> { + registry() + .lock() + .map_err(|_| Error::internal("cache backend registry mutex is poisoned")) +} + +#[cfg(test)] +fn registry_lock_for_test() -> MutexGuard<'static, HashMap> { + registry() + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +/// Register a constructor for a cache backend under `kind`. +/// +/// Returns `Err` if a non-built-in `kind` is already registered. Built-in +/// backends may be replaced so callers can mask a built-in implementation +/// (for example, a patched `"moka"` backend) without changing URI/config +/// strings elsewhere. +/// +/// Typical usage from a backend crate: +/// +/// ``` +/// # use std::sync::Arc; +/// # use lance_core::Result; +/// # use lance_core::cache::{BackendConfig, CacheBackend, MokaCacheBackend, register_backend}; +/// fn build_my_backend(_config: &BackendConfig) -> Result> { +/// Ok(Arc::new(MokaCacheBackend::with_capacity(1024))) +/// } +/// +/// # fn main() -> Result<()> { +/// register_backend("my-backend", build_my_backend)?; +/// # Ok(()) +/// # } +/// ``` +pub fn register_backend(kind: &str, build: BackendBuildFn) -> Result<()> { + let kind = normalize_backend_kind(kind)?; + insert_backend(&kind, build, builtin_backend(&kind).is_some()) +} + +fn insert_backend(kind: &str, build: BackendBuildFn, allow_replace: bool) -> Result<()> { + let mut map = registry_lock()?; + if map.contains_key(kind) && !allow_replace { + return Err(Error::invalid_input(format!( + "cache backend {:?} is already registered", + kind + ))); + } + map.insert(kind.to_string(), build); + Ok(()) +} + +fn builtin_backend(kind: &str) -> Option { + match kind { + MOKA_BACKEND_KIND => Some(build_moka), + _ => None, + } +} + +/// Look up the constructor for `config.kind` and build a backend. +/// +/// Returns `Err` if no backend has been registered under that identifier. +pub fn build_from_config(config: &BackendConfig) -> Result> { + ensure_builtin_backends()?; + let kind = normalize_backend_kind(&config.kind)?; + let config = BackendConfig { + kind: kind.clone(), + options: config.options.clone(), + }; + let build = { + let map = registry_lock()?; + map.get(&kind).copied() + }; + match build { + Some(build) => build(&config), + None => Err(Error::invalid_input(format!( + "unknown cache backend kind: {:?}", + kind + ))), + } +} + +/// Idempotently register the backends that ship with `lance-core`. +/// +/// Called by [`build_from_config`] (and, transitively, by +/// [`build_from_uri`](super::backend_uri::build_from_uri)) so a bare Lance +/// installation can build a Moka backend without the caller having to +/// register it. Third-party backends still have to opt in with their own +/// `register()` call. +/// +/// The check is against the current registry contents rather than a +/// process-once flag so that `#[cfg(test)]` helpers which snapshot and +/// restore the registry still see the built-in backend after they take +/// ownership. +fn ensure_builtin_backends() -> Result<()> { + let mut map = registry_lock()?; + if !map.contains_key(MOKA_BACKEND_KIND) + && let Some(build) = builtin_backend(MOKA_BACKEND_KIND) + { + map.insert(MOKA_BACKEND_KIND.to_string(), build); + } + Ok(()) +} + +/// Test-only helper: replace the registry with an empty map so tests can +/// exercise duplicate-registration logic without polluting the global one. +#[cfg(test)] +pub(super) fn take_registry_for_test() -> HashMap { + let mut map = registry_lock_for_test(); + std::mem::take(&mut *map) +} + +/// Test-only helper: restore a previously captured registry state. +#[cfg(test)] +pub(super) fn restore_registry_for_test(saved: HashMap) { + let mut map = registry_lock_for_test(); + *map = saved; +} + +#[cfg(test)] +pub(super) fn registry_test_lock() -> std::sync::MutexGuard<'static, ()> { + static M: OnceLock> = OnceLock::new(); + M.get_or_init(|| std::sync::Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +#[cfg(test)] +mod tests { + use super::*; + use async_trait::async_trait; + use std::pin::Pin; + + use crate::cache::InternalCacheKey; + use crate::cache::backend::CacheEntry; + use crate::cache::codec::CacheCodec; + use futures::Future; + + // A trivial no-op backend so tests do not depend on Moka or any other + // real backend. Every method returns "empty" / does nothing. + #[derive(Debug, Default)] + struct NullBackend; + + #[async_trait] + impl CacheBackend for NullBackend { + async fn get( + &self, + _key: &InternalCacheKey, + _codec: Option, + ) -> Option { + None + } + + async fn insert( + &self, + _key: &InternalCacheKey, + _entry: CacheEntry, + _size_bytes: usize, + _codec: Option, + ) { + } + + async fn get_or_insert<'a>( + &self, + _key: &InternalCacheKey, + loader: Pin> + Send + 'a>>, + _codec: Option, + ) -> crate::Result<(CacheEntry, bool)> { + let (entry, _size) = loader.await?; + Ok((entry, false)) + } + + async fn clear(&self) {} + async fn num_entries(&self) -> usize { + 0 + } + async fn size_bytes(&self) -> usize { + 0 + } + } + + fn build_null(_cfg: &BackendConfig) -> Result> { + Ok(Arc::new(NullBackend)) + } + + struct RegistryGuard { + // Hold the serialization lock for the full test. + _lock: std::sync::MutexGuard<'static, ()>, + saved: HashMap, + } + impl RegistryGuard { + fn new() -> Self { + Self { + _lock: registry_test_lock(), + saved: take_registry_for_test(), + } + } + } + impl Drop for RegistryGuard { + fn drop(&mut self) { + restore_registry_for_test(std::mem::take(&mut self.saved)); + } + } + + #[test] + fn test_register_and_build() { + let _guard = RegistryGuard::new(); + register_backend("null", build_null).unwrap(); + let backend = build_from_config(&BackendConfig::new("null").unwrap()).unwrap(); + // Backend is opaque; we just check that the constructor ran and + // gave us an Arc. + assert_eq!(Arc::strong_count(&backend), 1); + } + + #[test] + fn test_duplicate_registration_errors() { + let _guard = RegistryGuard::new(); + register_backend("dup", build_null).unwrap(); + let err = register_backend("dup", build_null).unwrap_err(); + assert!(err.to_string().contains("already registered")); + } + + #[test] + fn test_builtin_kind_can_be_overridden() { + let _guard = RegistryGuard::new(); + register_backend("moka", build_null).unwrap(); + let backend = build_from_config(&BackendConfig::new("moka").unwrap()).unwrap(); + assert_eq!(Arc::strong_count(&backend), 1); + } + + #[test] + fn test_unknown_kind_errors() { + let _guard = RegistryGuard::new(); + let err = build_from_config(&BackendConfig::new("missing").unwrap()).unwrap_err(); + assert!(err.to_string().contains("unknown cache backend kind")); + } + + #[test] + fn test_backend_kind_is_normalized() { + let _guard = RegistryGuard::new(); + register_backend("Echo.Backend", build_null).unwrap(); + let backend = build_from_config(&BackendConfig::new("echo.backend").unwrap()).unwrap(); + assert_eq!(Arc::strong_count(&backend), 1); + } + + #[test] + fn test_config_lookup_normalizes_direct_config() { + let _guard = RegistryGuard::new(); + fn build_echo(cfg: &BackendConfig) -> Result> { + assert_eq!(cfg.kind, "echo.backend"); + Ok(Arc::new(NullBackend)) + } + register_backend("echo.backend", build_echo).unwrap(); + let cfg = BackendConfig { + kind: "ECHO.Backend".to_string(), + options: HashMap::new(), + }; + build_from_config(&cfg).unwrap(); + } + + #[test] + fn test_invalid_backend_kind_errors() { + let err = register_backend("not a scheme", build_null).unwrap_err(); + assert!(err.to_string().contains("invalid character")); + let err = BackendConfig::new("1moka").unwrap_err(); + assert!(err.to_string().contains("must start with an ASCII letter")); + } + + #[test] + fn test_options_are_passed_through() { + let _guard = RegistryGuard::new(); + fn build_echo(cfg: &BackendConfig) -> Result> { + assert_eq!(cfg.options.get("capacity").map(String::as_str), Some("42")); + Ok(Arc::new(NullBackend)) + } + register_backend("echo", build_echo).unwrap(); + let cfg = BackendConfig::new("echo") + .unwrap() + .with_option("capacity", "42"); + build_from_config(&cfg).unwrap(); + } +} diff --git a/rust/lance-core/src/container/list.rs b/rust/lance-core/src/container/list.rs index c10b30abdb0..9d8205cb398 100644 --- a/rust/lance-core/src/container/list.rs +++ b/rust/lance-core/src/container/list.rs @@ -3,7 +3,7 @@ use std::collections::LinkedList; -use deepsize::DeepSizeOf; +use crate::deepsize::DeepSizeOf; /// A linked list that grows exponentially. It is used to store a large number of /// elements in a memory-efficient way. The list grows by doubling the capacity of @@ -134,7 +134,7 @@ impl ExpLinkedList { } impl DeepSizeOf for ExpLinkedList { - fn deep_size_of_children(&self, context: &mut deepsize::Context) -> usize { + fn deep_size_of_children(&self, context: &mut crate::deepsize::Context) -> usize { self.inner .iter() .map(|v| v.deep_size_of_children(context)) @@ -186,10 +186,10 @@ impl<'a, T> Iterator for ExpLinkedListIter<'a, T> { type Item = &'a T; fn next(&mut self) -> Option { - if let Some(inner_iter) = &mut self.inner_iter { - if let Some(v) = inner_iter.next() { - return Some(v); - } + if let Some(inner_iter) = &mut self.inner_iter + && let Some(v) = inner_iter.next() + { + return Some(v); } if let Some(inner) = self.inner.next() { self.inner_iter = Some(inner.iter()); @@ -224,10 +224,10 @@ impl Iterator for ExpLinkedListIntoIter { type Item = T; fn next(&mut self) -> Option { - if let Some(inner_iter) = &mut self.inner_iter { - if let Some(v) = inner_iter.next() { - return Some(v); - } + if let Some(inner_iter) = &mut self.inner_iter + && let Some(v) = inner_iter.next() + { + return Some(v); } if let Some(inner) = self.inner.next() { self.inner_iter = Some(inner.into_iter()); diff --git a/rust/lance-core/src/datatypes.rs b/rust/lance-core/src/datatypes.rs index 704c1c4dbe6..e2b5e4c9ce0 100644 --- a/rust/lance-core/src/datatypes.rs +++ b/rust/lance-core/src/datatypes.rs @@ -7,23 +7,25 @@ use std::collections::HashMap; use std::fmt::{self, Debug, Formatter}; use std::sync::{Arc, LazyLock}; +use crate::deepsize::DeepSizeOf; use arrow_array::ArrayRef; use arrow_schema::{DataType, Field as ArrowField, Fields, TimeUnit}; -use deepsize::DeepSizeOf; -use lance_arrow::bfloat16::{is_bfloat16_field, BFLOAT16_EXT_NAME}; +use lance_arrow::bfloat16::{BFLOAT16_EXT_NAME, is_bfloat16_field}; use lance_arrow::{ARROW_EXT_META_KEY, ARROW_EXT_NAME_KEY}; -use snafu::location; mod field; mod schema; use crate::{Error, Result}; pub use field::{ - BlobVersion, Encoding, Field, NullabilityComparison, OnTypeMismatch, SchemaCompareOptions, + BlobVersion, Encoding, Field, LANCE_FIELD_ID_KEY, LANCE_UNENFORCED_CLUSTERING_KEY_POSITION, + LANCE_UNENFORCED_PRIMARY_KEY, LANCE_UNENFORCED_PRIMARY_KEY_POSITION, NullabilityComparison, + OnTypeMismatch, SchemaCompareOptions, }; pub use schema::{ - escape_field_path_for_project, format_field_path, parse_field_path, BlobHandling, FieldRef, - OnMissing, Projectable, Projection, Schema, + BlobHandling, FieldRef, OnMissing, Projectable, Projection, Schema, + escape_field_path_for_project, format_field_path, format_field_path_minimal, parse_field_path, + validate_fixed_size_list_dimensions, }; pub static BLOB_DESC_FIELDS: LazyLock = LazyLock::new(|| { @@ -46,6 +48,90 @@ pub static BLOB_DESC_FIELD: LazyLock = LazyLock::new(|| { pub static BLOB_DESC_LANCE_FIELD: LazyLock = LazyLock::new(|| Field::try_from(&*BLOB_DESC_FIELD).unwrap()); +/// The minimal logical blob v2 fields accepted from writers. +/// +/// Logical values may also use [`BLOB_V2_LOGICAL_FIELDS`] when an external +/// object range is present. +pub static BLOB_V2_LOGICAL_MINIMAL_FIELDS: LazyLock = LazyLock::new(|| { + Fields::from(vec![ + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::Utf8, true), + ]) +}); + +/// The complete logical blob v2 fields used for writer input and rewrite output. +/// +/// `position` and `size` are an optional range within the external object named +/// by `uri`; when present, `size` must be greater than zero. Every non-null row +/// must set exactly one of `data` and `uri`. These fields do not describe +/// Lance-managed data, packed, or dedicated storage. +pub static BLOB_V2_LOGICAL_FIELDS: LazyLock = LazyLock::new(|| { + let mut fields = BLOB_V2_LOGICAL_MINIMAL_FIELDS + .iter() + .cloned() + .collect::>(); + fields.extend([ + Arc::new(ArrowField::new("position", DataType::UInt64, true)), + Arc::new(ArrowField::new("size", DataType::UInt64, true)), + ]); + Fields::from(fields) +}); + +/// The complete logical blob v2 struct type. +pub static BLOB_V2_LOGICAL_TYPE: LazyLock = + LazyLock::new(|| DataType::Struct(BLOB_V2_LOGICAL_FIELDS.clone())); + +/// Writer-prepared blob v2 fields consumed by the structural encoder. +/// +/// The populated fields depend on [`BlobKind`]: +/// +/// - [`BlobKind::Inline`] carries `data`; the encoder derives the stored +/// `position` and `size` from the out-of-line buffer it creates. +/// - [`BlobKind::Packed`] carries `blob_id`, `position`, and `blob_size`. +/// - [`BlobKind::Dedicated`] carries `blob_id` and `blob_size`; its stored +/// `position` is zero. +/// - [`BlobKind::Managed`] carries `uri`, `position`, and `blob_size`, with +/// a null `blob_id` for the writer's table base. [`BlobKind::ManagedWithBase`] +/// additionally carries an exact registered `blob_id`. +/// - [`BlobKind::External`] carries `uri`, optional `blob_id`, `position`, and +/// `blob_size`. A zero `blob_size` is resolved to the complete external object +/// length when read. +/// +/// `blob_size` is distinct from the logical `size`, which is only an optional +/// external-object range before preparation. For external blobs, `uri` is +/// normalized into the stable stored `blob_uri` field. +pub static BLOB_V2_PREPARED_FIELDS: LazyLock = LazyLock::new(|| { + Fields::from(vec![ + ArrowField::new("kind", DataType::UInt8, true), + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::Utf8, true), + ArrowField::new("blob_id", DataType::UInt32, true), + ArrowField::new("blob_size", DataType::UInt64, true), + ArrowField::new("position", DataType::UInt64, true), + ]) +}); + +/// The writer-prepared blob v2 struct type. +pub static BLOB_V2_PREPARED_TYPE: LazyLock = + LazyLock::new(|| DataType::Struct(BLOB_V2_PREPARED_FIELDS.clone())); + +/// Stored blob v2 descriptor fields. +/// +/// These field names are part of the stable file format. Their meaning depends +/// on `kind`: +/// +/// - [`BlobKind::Inline`]: `position` and `size` locate an out-of-line buffer in +/// the Lance data file. +/// - [`BlobKind::Packed`]: `blob_id` identifies a shared packed blob file, and +/// `position` and `size` locate a range within it. +/// - [`BlobKind::Dedicated`]: `blob_id` identifies a dedicated raw blob file, +/// `position` is zero, and `size` is the complete file length. +/// - [`BlobKind::Managed`]: `blob_uri` is relative to the data file's table base; +/// `blob_id` is unused (zero). [`BlobKind::ManagedWithBase`] instead selects an +/// exact registered base using `blob_id`. Both store a known byte range. +/// - [`BlobKind::External`]: `blob_uri` and `blob_id` identify the object, while +/// `position` and `size` select a range. A zero `size` is resolved to the +/// object's complete length when read. pub static BLOB_V2_DESC_FIELDS: LazyLock = LazyLock::new(|| { Fields::from(vec![ ArrowField::new("kind", DataType::UInt8, false), @@ -69,6 +155,96 @@ pub static BLOB_V2_DESC_FIELD: LazyLock = LazyLock::new(|| { pub static BLOB_V2_DESC_LANCE_FIELD: LazyLock = LazyLock::new(|| Field::try_from(&*BLOB_V2_DESC_FIELD).unwrap()); +/// The in-memory representation of a blob v2 struct. +#[non_exhaustive] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BlobV2Layout { + /// Writer input or rewrite output. + /// + /// Both the minimal `data, uri` fields and the complete + /// `data, uri, position, size` fields have this layout. + Logical, + /// Kind-aware writer intermediate consumed by the structural encoder. + Prepared, + /// Stable descriptor stored in Lance files and returned by descriptor scans. + Descriptor, +} + +impl BlobV2Layout { + /// Classify blob v2 child fields by name, type, order, and layout-specific + /// nullability requirements. + /// + /// Child metadata is not part of the representation. The complete logical + /// layout also accepts non-nullable `position` and `size` fields, matching + /// the existing writer-input contract. Descriptor child nullability is + /// ignored because it changed across released schemas; row nullness has + /// been represented by either parent struct validity or a nullable `kind` + /// child. + pub fn classify(fields: &Fields) -> Option { + if logical_blob_v2_fields_match(fields) { + Some(Self::Logical) + } else if blob_v2_fields_match(fields, &BLOB_V2_PREPARED_FIELDS, true) { + Some(Self::Prepared) + } else if blob_v2_fields_match(fields, &BLOB_V2_DESC_FIELDS, false) { + Some(Self::Descriptor) + } else { + None + } + } +} + +impl fmt::Display for BlobV2Layout { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + match self { + Self::Logical => write!(f, "logical"), + Self::Prepared => write!(f, "prepared"), + Self::Descriptor => write!(f, "descriptor"), + } + } +} + +fn blob_v2_field_matches( + actual: &ArrowField, + expected: &ArrowField, + compare_nullability: bool, +) -> bool { + actual.name() == expected.name() + && actual.data_type() == expected.data_type() + && (!compare_nullability || actual.is_nullable() == expected.is_nullable()) +} + +fn blob_v2_fields_match(actual: &Fields, expected: &Fields, compare_nullability: bool) -> bool { + actual.len() == expected.len() + && actual + .iter() + .zip(expected.iter()) + .all(|(actual, expected)| { + blob_v2_field_matches(actual.as_ref(), expected.as_ref(), compare_nullability) + }) +} + +fn logical_blob_v2_fields_match(fields: &Fields) -> bool { + if blob_v2_fields_match(fields, &BLOB_V2_LOGICAL_MINIMAL_FIELDS, true) { + return true; + } + fields.len() == BLOB_V2_LOGICAL_FIELDS.len() + && fields + .iter() + .zip(BLOB_V2_LOGICAL_FIELDS.iter()) + .enumerate() + .all(|(index, (actual, expected))| { + blob_v2_field_matches(actual.as_ref(), expected.as_ref(), index < 2) + }) +} + +/// Deprecated name for [`BLOB_V2_LOGICAL_FIELDS`]. +#[deprecated(note = "use BLOB_V2_LOGICAL_FIELDS")] +pub use self::BLOB_V2_LOGICAL_FIELDS as BLOB_V2_USER_FIELDS; + +/// Deprecated name for [`BLOB_V2_LOGICAL_TYPE`]. +#[deprecated(note = "use BLOB_V2_LOGICAL_TYPE")] +pub use self::BLOB_V2_LOGICAL_TYPE as BLOB_V2_USER_TYPE; + pub const BLOB_LOGICAL_TYPE: &str = "blob"; /// LogicalType is a string presentation of arrow type. @@ -95,7 +271,7 @@ impl LogicalType { self.0.starts_with("fixed_size_list:struct:") } - fn is_struct(&self) -> bool { + pub fn is_struct(&self) -> bool { self.0 == "struct" } @@ -123,16 +299,24 @@ 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), "ms" => Ok(TimeUnit::Millisecond), "us" => Ok(TimeUnit::Microsecond), "ns" => Ok(TimeUnit::Nanosecond), - _ => Err(Error::Arrow { - message: format!("Unsupported TimeUnit: {unit}"), - location: location!(), - }), + _ => Err(Error::arrow(format!("Unsupported TimeUnit: {unit}"))), } } @@ -156,8 +340,8 @@ impl TryFrom<&DataType> for LogicalType { DataType::Float64 => "double".to_string(), DataType::Decimal128(precision, scale) => format!("decimal:128:{precision}:{scale}"), DataType::Decimal256(precision, scale) => format!("decimal:256:{precision}:{scale}"), - DataType::Utf8 => "string".to_string(), - DataType::Binary => "binary".to_string(), + DataType::Utf8 | DataType::Utf8View => "string".to_string(), + DataType::Binary | DataType::BinaryView => "binary".to_string(), DataType::LargeUtf8 => "large_string".to_string(), DataType::LargeBinary => "large_binary".to_string(), DataType::Date32 => "date32:day".to_string(), @@ -174,13 +358,21 @@ impl TryFrom<&DataType> for LogicalType { DataType::Duration(tu) => format!("duration:{}", timeunit_to_str(tu)), DataType::Struct(_) => "struct".to_string(), DataType::Dictionary(key_type, value_type) => { - format!( - "dict:{}:{}:{}", - Self::try_from(value_type.as_ref())?.0, - Self::try_from(key_type.as_ref())?.0, - // Arrow C++ Dictionary has "ordered:bool" field, but it does not exist in `arrow-rs`. - false - ) + let value_logical = Self::try_from(value_type.as_ref())?.0; + let index_logical = Self::try_from(key_type.as_ref())?.0; + let logical_type = format!("dict:{value_logical}:{index_logical}:false"); + // `dict:{value}:{index}:false` inlines both component strings, but + // the parser splits on ':', requires exactly four segments, and + // resolves only leaf types - so a component carrying ':' of its own + // (decimal, timestamp, ...) or a struct/list value composes a string + // it can never parse back. `Field::data_type` unwraps that parse, so + // without this check `Schema::try_from` panics instead of erroring. + if DataType::try_from(&Self::from(logical_type.as_str())).is_err() { + return Err(Error::schema(format!( + "Cannot encode dictionary type {dt:?}: the logical type \"{logical_type}\" does not parse back" + ))); + } + logical_type } DataType::List(elem) => match elem.data_type() { DataType::Struct(_) => "list.struct".to_string(), @@ -195,6 +387,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:{}:{}", @@ -209,21 +403,15 @@ impl TryFrom<&DataType> for LogicalType { // because converting a rust arrow map field to the python arrow field will // lose the keys_sorted property. if *keys_sorted { - return Err(Error::Schema { - message: format!( - "Unsupported map data type with keys_sorted=true: {:?}", - dt - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Unsupported map data type with keys_sorted=true: {:?}", + dt + ))); } "map".to_string() } _ => { - return Err(Error::Schema { - message: format!("Unsupported data type: {:?}", dt), - location: location!(), - }) + return Err(Error::schema(format!("Unsupported data type: {:?}", dt))); } }; @@ -274,21 +462,14 @@ impl TryFrom<&LogicalType> for DataType { match splits[0] { "fixed_size_list" => { if splits.len() < 3 { - return Err(Error::Schema { - message: format!("Unsupported logical type: {}", lt), - location: location!(), - }); + return Err(Error::schema(format!("Unsupported logical type: {}", lt))); } - let size: i32 = - splits - .last() - .unwrap() - .parse::() - .map_err(|e: _| Error::Schema { - message: e.to_string(), - location: location!(), - })?; + let size: i32 = splits + .last() + .unwrap() + .parse::() + .map_err(|e: _| Error::schema(e.to_string()))?; let inner_type = splits[1..splits.len() - 1].join(":"); @@ -316,24 +497,20 @@ impl TryFrom<&LogicalType> for DataType { } "fixed_size_binary" => { if splits.len() != 2 { - Err(Error::Schema { - message: format!("Unsupported logical type: {}", lt), - location: location!(), - }) + Err(Error::schema(format!("Unsupported logical type: {}", lt))) } else { - let size: i32 = splits[1].parse::().map_err(|e: _| Error::Schema { - message: e.to_string(), - location: location!(), - })?; + let size: i32 = splits[1] + .parse::() + .map_err(|e: _| Error::schema(e.to_string()))?; Ok(FixedSizeBinary(size)) } } "dict" => { if splits.len() != 4 { - Err(Error::Schema { - message: format!("Unsupported dictionary type: {}", lt), - location: location!(), - }) + Err(Error::schema(format!( + "Unsupported dictionary type: {}", + lt + ))) } else { let value_type: Self = (&LogicalType::from(splits[1])).try_into()?; let index_type: Self = (&LogicalType::from(splits[2])).try_into()?; @@ -342,59 +519,46 @@ impl TryFrom<&LogicalType> for DataType { } "decimal" => { if splits.len() != 4 { - Err(Error::Schema { - message: format!("Unsupported decimal type: {}", lt), - location: location!(), - }) + Err(Error::schema(format!("Unsupported decimal type: {}", lt))) } else { - let bits: i16 = splits[1].parse::().map_err(|err| Error::Schema { - message: err.to_string(), - location: location!(), - })?; - let precision: u8 = - splits[2].parse::().map_err(|err| Error::Schema { - message: err.to_string(), - location: location!(), - })?; - let scale: i8 = splits[3].parse::().map_err(|err| Error::Schema { - message: err.to_string(), - location: location!(), - })?; + let bits: i16 = splits[1] + .parse::() + .map_err(|err| Error::schema(err.to_string()))?; + let precision: u8 = splits[2] + .parse::() + .map_err(|err| Error::schema(err.to_string()))?; + let scale: i8 = splits[3] + .parse::() + .map_err(|err| Error::schema(err.to_string()))?; if bits == 128 { Ok(Decimal128(precision, scale)) } else if bits == 256 { Ok(Decimal256(precision, scale)) } else { - Err(Error::Schema { - message: format!( - "Only Decimal128 and Decimal256 is supported. Found {bits}" - ), - location: location!(), - }) + Err(Error::schema(format!( + "Only Decimal128 and Decimal256 is supported. Found {bits}" + ))) } } } "timestamp" => { - if splits.len() != 3 { - Err(Error::Schema { - message: format!("Unsupported timestamp type: {}", lt), - location: location!(), - }) + if splits.len() < 3 { + Err(Error::schema(format!("Unsupported timestamp type: {}", lt))) } else { let timeunit = parse_timeunit(splits[1])?; - let tz: Option> = if splits[2] == "-" { + // A fixed-offset timezone such as "+08:00" contains a colon, so the + // trailing segments must be rejoined instead of read as a single one. + let tz_str = splits[2..].join(":"); + let tz: Option> = if tz_str == "-" { None } else { - Some(splits[2].into()) + Some(tz_str.into()) }; Ok(Timestamp(timeunit, tz)) } } - _ => Err(Error::Schema { - message: format!("Unsupported logical type: {}", lt), - location: location!(), - }), + _ => Err(Error::schema(format!("Unsupported logical type: {}", lt))), } } } @@ -410,18 +574,25 @@ pub struct Dictionary { } impl DeepSizeOf for Dictionary { - fn deep_size_of_children(&self, _context: &mut deepsize::Context) -> usize { + fn deep_size_of_children(&self, context: &mut crate::deepsize::Context) -> usize { self.values .as_ref() - .map(|v| v.get_array_memory_size()) + .map(|v| (v.as_ref() as &dyn arrow_array::Array).deep_size_of_children(context)) .unwrap_or(0) } } impl PartialEq for Dictionary { fn eq(&self, other: &Self) -> bool { + // Compare the values when both sides have them: the write path leaves + // offset/length at 0 until the dictionary is persisted, so one append + // can legitimately hold the same dictionary at two positions. + // `From<&pb::Dictionary>` yields no values at all, and comparing only + // the values made that case non-reflexive, so there offset and length + // stand in for the content. match (&self.values, &other.values) { (Some(a), Some(b)) => a == b, + (None, None) => self.offset == other.offset && self.length == other.length, _ => false, } } @@ -437,8 +608,27 @@ pub enum BlobKind { Packed = 1, /// Stored in a dedicated raw blob file; `blob_id` identifies the file, `size` is the full file length. Dedicated = 2, - /// Not stored by Lance; `blob_uri` holds an absolute external URI, offsets are zero. + /// Not stored by Lance data files. + /// + /// For external blobs: + /// - `blob_id == 0` means `blob_uri` is an absolute external URI. + /// - `blob_id > 0` means `blob_uri` is a path relative to `manifest.base_paths[blob_id]`. + /// + /// External blobs can have a position and a size. If the position is not set, + /// it defaults to 0, which points to the beginning of the blob. External = 3, + /// A Lance-owned immutable object, independent of the descriptor's data file. + /// `blob_uri` is relative to the data file's table base; `blob_id` is unused. + /// A local table base moves with the dataset, including its Managed objects. + /// `position`/`size` select a known range, and + /// zero size is an empty value rather than a request to discover its length. + /// Tables containing this kind require the Managed Blob reader and writer feature flags. + Managed = 4, + /// A Managed object relative to an explicitly registered table base. + /// `blob_id` is the exact base ID, including zero and `u32::MAX`. + /// This wire variant preserves the non-nullable descriptor layout while + /// distinguishing an explicit base from the implicit table base. + ManagedWithBase = 5, } impl TryFrom for BlobKind { @@ -450,10 +640,366 @@ impl TryFrom for BlobKind { 1 => Ok(Self::Packed), 2 => Ok(Self::Dedicated), 3 => Ok(Self::External), - other => Err(Error::InvalidInput { - source: format!("Unknown blob kind {other:?}").into(), - location: location!(), - }), + 4 => Ok(Self::Managed), + 5 => Ok(Self::ManagedWithBase), + other => Err(Error::invalid_input_source( + format!("Unknown blob kind {other:?}").into(), + )), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_schema::Schema as ArrowSchema; + use rstest::rstest; + + fn dict(offset: usize, length: usize, values: Option>) -> Dictionary { + Dictionary { + offset, + length, + values: values.map(|v| Arc::new(arrow_array::StringArray::from(v)) as ArrayRef), } } + + /// The manifest schema stores a dictionary as offset/length with no attached + /// values, so equality has to be reflexive over value-less dictionaries and + /// still separate different positions. `set_dictionary_values` leaves + /// offset/length at 0 until the dictionary is persisted, so for an in-memory + /// pair the values array is all there is to compare. + #[rstest] + #[case::reflexive_default(Dictionary::default(), Dictionary::default(), true)] + #[case::same_position_no_values(dict(4, 2, None), dict(4, 2, None), true)] + #[case::different_offset(dict(4, 2, None), dict(8, 2, None), false)] + #[case::different_length(dict(4, 2, None), dict(4, 3, None), false)] + #[case::same_values(dict(0, 0, Some(vec!["a", "b"])), dict(0, 0, Some(vec!["a", "b"])), true)] + #[case::different_values(dict(0, 0, Some(vec!["a", "b"])), dict(0, 0, Some(vec!["a", "c"])), false)] + #[case::values_on_one_side_only(dict(4, 2, Some(vec!["a", "b"])), dict(4, 2, None), false)] + // One side of a V1 append holds the incoming dictionary at 0/0 and the other + // holds the persisted one at its real position, so equal values must win + // over the differing offsets. + #[case::same_values_different_offset( + dict(0, 0, Some(vec!["a", "b"])), + dict(8, 2, Some(vec!["a", "b"])), + true + )] + fn test_dictionary_partial_eq_contract( + #[case] left: Dictionary, + #[case] right: Dictionary, + #[case] is_equal: bool, + ) { + assert_eq!(left == right, is_equal, "{left:?} vs {right:?}"); + assert_eq!(right == left, is_equal, "equality must be symmetric"); + } + + /// `From<&pb::Dictionary>` restores offset and length with no values, so two + /// schemas read back from a manifest have to compare equal. Otherwise merge + /// validation reports a phantom dictionary change for every such field. + #[test] + fn test_manifest_derived_dictionary_schemas_compare_equal() { + let arrow = ArrowSchema::new(vec![ArrowField::new( + "d", + DataType::Dictionary(Box::new(DataType::UInt32), Box::new(DataType::Utf8)), + true, + )]); + let mut left = Schema::try_from(&arrow).unwrap(); + left.fields[0].dictionary = Some(dict(12, 2, None)); + let right = left.clone(); + + assert_eq!(left, right); + let compare_dictionary = SchemaCompareOptions { + compare_dictionary: true, + ..Default::default() + }; + assert!(left.compare_with_options(&right, &compare_dictionary)); + assert_eq!(left.explain_difference(&right, &compare_dictionary), None); + } + + #[test] + fn test_classify_blob_v2_layouts() { + assert_eq!( + BlobV2Layout::classify(&BLOB_V2_LOGICAL_MINIMAL_FIELDS), + Some(BlobV2Layout::Logical) + ); + assert_eq!( + BlobV2Layout::classify(&BLOB_V2_LOGICAL_FIELDS), + Some(BlobV2Layout::Logical) + ); + assert_eq!( + BlobV2Layout::classify(&BLOB_V2_PREPARED_FIELDS), + Some(BlobV2Layout::Prepared) + ); + assert_eq!( + BlobV2Layout::classify(&BLOB_V2_DESC_FIELDS), + Some(BlobV2Layout::Descriptor) + ); + } + + #[test] + fn test_classify_blob_v2_layout_uses_structural_contract() { + let logical_with_required_range = Fields::from(vec![ + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::Utf8, true), + ArrowField::new("position", DataType::UInt64, false), + ArrowField::new("size", DataType::UInt64, false), + ]); + assert_eq!( + BlobV2Layout::classify(&logical_with_required_range), + Some(BlobV2Layout::Logical) + ); + + let prepared_with_child_metadata = + Fields::from( + BLOB_V2_PREPARED_FIELDS + .iter() + .map(|field| { + Arc::new(field.as_ref().clone().with_metadata(HashMap::from([( + "source".to_string(), + "test".to_string(), + )]))) + }) + .collect::>(), + ); + assert_eq!( + BlobV2Layout::classify(&prepared_with_child_metadata), + Some(BlobV2Layout::Prepared) + ); + + let nullable_descriptor = Fields::from( + BLOB_V2_DESC_FIELDS + .iter() + .map(|field| Arc::new(field.as_ref().clone().with_nullable(true))) + .collect::>(), + ); + assert_eq!( + BlobV2Layout::classify(&nullable_descriptor), + Some(BlobV2Layout::Descriptor) + ); + + let malformed_descriptor = Fields::from(vec![ + ArrowField::new("kind", DataType::UInt8, false), + ArrowField::new("position", DataType::UInt64, false), + ArrowField::new("size", DataType::UInt32, false), + ArrowField::new("blob_id", DataType::UInt32, false), + ArrowField::new("blob_uri", DataType::Utf8, false), + ]); + assert_eq!(BlobV2Layout::classify(&malformed_descriptor), None); + } + + /// The `timestamp::` encoding embeds the timezone verbatim, so a + /// fixed-offset zone such as `+08:00` contributes a colon of its own. Round-tripping + /// every timezone shape guards the parser against splitting on that colon. + #[rstest] + #[case::second_no_timezone(TimeUnit::Second, None)] + #[case::millisecond_no_timezone(TimeUnit::Millisecond, None)] + #[case::microsecond_no_timezone(TimeUnit::Microsecond, None)] + #[case::nanosecond_no_timezone(TimeUnit::Nanosecond, None)] + #[case::named_utc(TimeUnit::Microsecond, Some("UTC"))] + #[case::named_region(TimeUnit::Nanosecond, Some("America/Los_Angeles"))] + #[case::offset_without_colon(TimeUnit::Microsecond, Some("+0800"))] + #[case::offset_positive(TimeUnit::Microsecond, Some("+08:00"))] + #[case::offset_negative(TimeUnit::Millisecond, Some("-05:00"))] + #[case::offset_half_hour(TimeUnit::Nanosecond, Some("+05:30"))] + #[case::offset_zero(TimeUnit::Second, Some("+00:00"))] + fn test_timestamp_logical_type_round_trip( + #[case] timeunit: TimeUnit, + #[case] timezone: Option<&str>, + ) { + let data_type = DataType::Timestamp(timeunit, timezone.map(Arc::from)); + let logical_type = LogicalType::try_from(&data_type).unwrap(); + assert_eq!( + DataType::try_from(&logical_type).unwrap(), + data_type, + "logical type: {logical_type}" + ); + } + + /// The `dict:{value}:{index}:false` encoding inlines both component logical + /// strings, but the parser splits on ':' and requires exactly four segments. + /// These value types keep the count at four, so they must keep working. + #[rstest] + #[case::string(DataType::Utf8, DataType::Utf8)] + #[case::large_string(DataType::LargeUtf8, DataType::LargeUtf8)] + #[case::binary(DataType::Binary, DataType::Binary)] + #[case::large_binary(DataType::LargeBinary, DataType::LargeBinary)] + #[case::int32(DataType::Int32, DataType::Int32)] + #[case::double(DataType::Float64, DataType::Float64)] + // The View variants share the `string` and `binary` logical types, so they + // parse back as their non-View counterparts. That normalization is why the + // encoder checks whether the composed string parses, not whether it round + // trips to the same `DataType`. + #[case::utf8_view(DataType::Utf8View, DataType::Utf8)] + #[case::binary_view(DataType::BinaryView, DataType::Binary)] + fn test_dictionary_logical_type_round_trip( + #[case] value_type: DataType, + #[case] parsed_value_type: DataType, + ) { + let key = Box::new(DataType::UInt8); + let logical_type = + LogicalType::try_from(&DataType::Dictionary(key.clone(), Box::new(value_type))) + .unwrap(); + assert_eq!( + DataType::try_from(&logical_type).unwrap(), + DataType::Dictionary(key, Box::new(parsed_value_type)), + "logical type: {logical_type}" + ); + } + + /// Every integer index type has a colon-free logical string, so none of them + /// may be caught by the rejection above. + #[rstest] + fn test_dictionary_logical_type_accepts_every_integer_index( + #[values( + DataType::Int8, + DataType::Int16, + DataType::Int32, + DataType::Int64, + DataType::UInt8, + DataType::UInt16, + DataType::UInt32, + DataType::UInt64 + )] + key_type: DataType, + ) { + let data_type = DataType::Dictionary(Box::new(key_type), Box::new(DataType::Utf8)); + let logical_type = LogicalType::try_from(&data_type).unwrap(); + assert_eq!(DataType::try_from(&logical_type).unwrap(), data_type); + } + + #[rstest] + #[case::decimal(DataType::Decimal128(10, 2))] + #[case::date32(DataType::Date32)] + #[case::time32(DataType::Time32(TimeUnit::Second))] + #[case::timestamp(DataType::Timestamp(TimeUnit::Microsecond, None))] + #[case::timestamp_with_zone(DataType::Timestamp( + TimeUnit::Microsecond, + Some(Arc::from("+08:00")) + ))] + #[case::duration(DataType::Duration(TimeUnit::Second))] + #[case::fixed_size_binary(DataType::FixedSizeBinary(16))] + #[case::nested_dict(DataType::Dictionary(Box::new(DataType::UInt8), Box::new(DataType::Utf8)))] + // `struct` and `list` keep the segment count at four, but the parser has no + // arm for them: they only become a `DataType` through a field's children, + // which this encoding cannot carry. + #[case::struct_value(DataType::Struct(Fields::from(vec![ArrowField::new( + "x", + DataType::Int32, + true + )])))] + #[case::list_value(DataType::List(Arc::new(ArrowField::new("item", DataType::Int32, true))))] + fn test_dictionary_logical_type_rejects_unparsable_value(#[case] value_type: DataType) { + let data_type = DataType::Dictionary(Box::new(DataType::UInt8), Box::new(value_type)); + let err = LogicalType::try_from(&data_type) + .expect_err("a dictionary whose logical type cannot parse back must not encode"); + assert!( + matches!(err, Error::Schema { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("does not parse back"), + "unexpected error: {err}" + ); + } + + /// The index type is inlined in the same string, so an index type carrying + /// ':' is just as unparsable as such a value type. + #[rstest] + #[case::decimal_index(DataType::Decimal128(10, 2))] + #[case::dict_index(DataType::Dictionary(Box::new(DataType::UInt8), Box::new(DataType::Utf8)))] + fn test_dictionary_logical_type_rejects_unparsable_index(#[case] key_type: DataType) { + let data_type = DataType::Dictionary(Box::new(key_type), Box::new(DataType::Utf8)); + let err = LogicalType::try_from(&data_type) + .expect_err("a dictionary whose logical type cannot parse back must not encode"); + assert!( + matches!(err, Error::Schema { .. }), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("does not parse back"), + "unexpected error: {err}" + ); + } + + /// Pins the on-disk spelling as well as the round trip: the timezone is written + /// verbatim, which is why the parser cannot assume a fixed segment count. + #[rstest] + #[case::no_timezone(TimeUnit::Second, None, "timestamp:s:-")] + #[case::named_utc(TimeUnit::Millisecond, Some("UTC"), "timestamp:ms:UTC")] + #[case::offset_positive(TimeUnit::Microsecond, Some("+08:00"), "timestamp:us:+08:00")] + #[case::offset_negative(TimeUnit::Nanosecond, Some("-05:00"), "timestamp:ns:-05:00")] + fn test_timestamp_logical_type_encoding( + #[case] timeunit: TimeUnit, + #[case] timezone: Option<&str>, + #[case] expected: &str, + ) { + let data_type = DataType::Timestamp(timeunit, timezone.map(Arc::from)); + assert_eq!( + LogicalType::try_from(&data_type).unwrap().to_string(), + expected + ); + } + + /// Opening an existing dataset parses the stored string directly, so cover that + /// direction on its own rather than only as half of a round trip. + #[rstest] + #[case::offset_positive("timestamp:us:+08:00", TimeUnit::Microsecond, Some("+08:00"))] + #[case::offset_negative("timestamp:ms:-05:00", TimeUnit::Millisecond, Some("-05:00"))] + #[case::offset_half_hour("timestamp:ns:+05:30", TimeUnit::Nanosecond, Some("+05:30"))] + #[case::named_utc("timestamp:s:UTC", TimeUnit::Second, Some("UTC"))] + #[case::no_timezone("timestamp:us:-", TimeUnit::Microsecond, None)] + fn test_parse_timestamp_logical_type( + #[case] encoded: &str, + #[case] timeunit: TimeUnit, + #[case] timezone: Option<&str>, + ) { + assert_eq!( + DataType::try_from(&LogicalType::from(encoded)).unwrap(), + DataType::Timestamp(timeunit, timezone.map(Arc::from)) + ); + } + + /// `Field::data_type` unwraps the parse, so a timezone it cannot read surfaces as a + /// panic instead of an error. Walk the Arrow -> Lance -> Arrow trip end to end. + #[test] + fn test_field_round_trip_with_fixed_offset_timezone() { + let data_type = DataType::Timestamp(TimeUnit::Microsecond, Some("+08:00".into())); + let arrow_field = ArrowField::new("ts", data_type.clone(), true); + let field = Field::try_from(&arrow_field).unwrap(); + assert_eq!(field.data_type(), data_type); + assert_eq!(ArrowField::from(&field), arrow_field); + } + + /// The conversion a write performs, mixing both timezone spellings in one schema. + #[test] + fn test_schema_round_trip_with_fixed_offset_timezone() { + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new( + "ts_offset", + DataType::Timestamp(TimeUnit::Microsecond, Some("+08:00".into())), + true, + ), + ArrowField::new( + "ts_named", + DataType::Timestamp(TimeUnit::Nanosecond, Some("UTC".into())), + true, + ), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + assert_eq!(ArrowSchema::from(&schema), arrow_schema); + } + + /// A timezone cannot be recovered from fewer than three segments, and an unknown + /// time unit is still an error, so neither becomes collateral damage of the rejoin. + #[rstest] + #[case::unit_and_timezone_missing("timestamp")] + #[case::timezone_missing("timestamp:us")] + #[case::unknown_timeunit("timestamp:decade:UTC")] + #[case::unknown_timeunit_with_offset("timestamp:decade:+08:00")] + fn test_malformed_timestamp_logical_type_is_rejected(#[case] encoded: &str) { + assert!( + DataType::try_from(&LogicalType::from(encoded)).is_err(), + "expected {encoded} to be rejected" + ); + } } diff --git a/rust/lance-core/src/datatypes/field.rs b/rust/lance-core/src/datatypes/field.rs index a2f21585b34..17545a760c7 100644 --- a/rust/lance-core/src/datatypes/field.rs +++ b/rust/lance-core/src/datatypes/field.rs @@ -4,34 +4,34 @@ //! Lance Schema Field use std::{ - cmp::{max, Ordering}, + cmp::{Ordering, max}, collections::{HashMap, VecDeque}, fmt, sync::Arc, }; +use crate::deepsize::DeepSizeOf; use arrow_array::{ + ArrayRef, cast::AsArray, types::{ - Int16Type, Int32Type, Int64Type, Int8Type, UInt16Type, UInt32Type, UInt64Type, UInt8Type, + Int8Type, Int16Type, Int32Type, Int64Type, UInt8Type, UInt16Type, UInt32Type, UInt64Type, }, - ArrayRef, }; use arrow_schema::{DataType, Field as ArrowField}; -use deepsize::DeepSizeOf; use lance_arrow::{ + ARROW_EXT_NAME_KEY, BLOB_META_KEY, BLOB_V2_EXT_NAME, DataTypeExt, json::{is_arrow_json_field, is_json_field}, - DataTypeExt, ARROW_EXT_NAME_KEY, BLOB_META_KEY, BLOB_V2_EXT_NAME, }; -use snafu::location; use super::{ - schema::{compare_fields, explain_fields_difference}, Dictionary, LogicalType, Projection, + schema::{compare_fields, explain_fields_difference}, }; use crate::{ - datatypes::{BLOB_DESC_LANCE_FIELD, BLOB_V2_DESC_LANCE_FIELD}, Error, Result, + datatypes::{BLOB_DESC_LANCE_FIELD, BLOB_V2_DESC_LANCE_FIELD, BlobV2Layout}, + utils::parse::str_is_truthy, }; /// Use this config key in Arrow field metadata to indicate a column is a part of the primary key. @@ -49,6 +49,18 @@ pub const LANCE_UNENFORCED_PRIMARY_KEY: &str = "lance-schema:unenforced-primary- pub const LANCE_UNENFORCED_PRIMARY_KEY_POSITION: &str = "lance-schema:unenforced-primary-key:position"; +/// Use this config key in Arrow field metadata to specify the position of a clustering key column. +/// The value is a 1-based integer indicating the order within the composite clustering key. +/// Clustering key fields are ordered by this position value. +pub const LANCE_UNENFORCED_CLUSTERING_KEY_POSITION: &str = + "lance-schema:unenforced-clustering-key:position"; + +/// Use this config key in Arrow field metadata to specify the field id of the lance field. +/// The value should be non-negative i32 value. Any negative value will be seen as -1. +pub const LANCE_FIELD_ID_KEY: &str = "lance:field_id"; + +const PACKED_KEYS: [&str; 2] = ["packed", "lance-encoding:packed"]; + fn has_blob_v2_extension(field: &ArrowField) -> bool { field .metadata() @@ -141,6 +153,11 @@ pub struct Field { /// None means the field is not part of the primary key. /// Some(n) means this field is the nth column in the primary key. pub unenforced_primary_key_position: Option, + + /// Position of this field in the clustering key (1-based). + /// None means the field is not part of the clustering key. + /// Some(n) means this field is the nth column in the clustering key. + pub unenforced_clustering_key_position: Option, } impl Field { @@ -255,10 +272,15 @@ impl Field { } pub fn apply_projection(&self, projection: &Projection) -> Option { - // For Map types, we must preserve ALL children (entries struct with key/value) - // Map internal structure should not be subject to projection filtering - let children = if self.logical_type.is_map() { - // Map field: keep all children intact (entries struct and its key/value fields) + // Maps and blob descriptors are atomic physical layouts. Map children + // must remain together, while projected blob descriptor children may + // have synthetic IDs that cannot be selected independently. + let is_atomic_layout = self.logical_type.is_map() || self.is_blob(); + if is_atomic_layout && !projection.contains_field_id(self.id) { + return None; + } + + let children = if is_atomic_layout { self.children.clone() } else { self.children @@ -346,12 +368,22 @@ impl Field { self_name )); } - let children_differences = explain_fields_difference( - &self.children, - &expected.children, - options, - Some(&self_name), - ); + let children_differences = + if let Some(shared_child_count) = self.blob_v2_logical_shared_child_count(expected) { + explain_fields_difference( + &self.children[..shared_child_count], + &expected.children[..shared_child_count], + options, + Some(&self_name), + ) + } else { + explain_fields_difference( + &self.children, + &expected.children, + options, + Some(&self_name), + ) + }; if !children_differences.is_empty() { let children_differences = format!( "`{}` had mismatched children: {}", @@ -389,10 +421,20 @@ impl Field { } pub fn compare_with_options(&self, expected: &Self, options: &SchemaCompareOptions) -> bool { + let children_match = self + .blob_v2_logical_shared_child_count(expected) + .map(|shared_child_count| { + compare_fields( + &self.children[..shared_child_count], + &expected.children[..shared_child_count], + options, + ) + }) + .unwrap_or_else(|| compare_fields(&self.children, &expected.children, options)); self.name == expected.name && self.logical_type == expected.logical_type && Self::compare_nullability(expected.nullable, self.nullable, options) - && compare_fields(&self.children, &expected.children, options) + && children_match && (!options.compare_field_ids || self.id == expected.id) && (!options.compare_dictionary || self.dictionary == expected.dictionary) && (!options.compare_metadata || self.metadata == expected.metadata) @@ -521,6 +563,82 @@ impl Field { .get(ARROW_EXT_NAME_KEY) .map(|name| name == BLOB_V2_EXT_NAME) .unwrap_or(false) + || self.is_blob_v2_descriptor() + } + + /// Represent legacy blobs in this field tree as Blob v2 logical inputs, preserving their identities. + /// + /// The new logical children have unassigned IDs; callers persisting the schema + /// must assign them with [`Self::set_id`]. Existing Blob v2 fields are unchanged. + /// + /// ``` + /// # use lance_core::datatypes::Field; + /// # fn prepare(field: &mut Field) -> lance_core::Result<()> { + /// field.promote_blob_v2()?; + /// # Ok(()) + /// # } + /// ``` + pub fn promote_blob_v2(&mut self) -> Result<()> { + if !self.is_blob() { + for child in &mut self.children { + child.promote_blob_v2()?; + } + return Ok(()); + } + if self.is_blob_v2() { + return Ok(()); + } + self.logical_type = LogicalType::from("struct"); + self.encoding = None; + self.children = super::BLOB_V2_LOGICAL_MINIMAL_FIELDS + .iter() + .map(|field| Self::try_from(field.as_ref())) + .collect::>>()?; + self.metadata.remove(BLOB_META_KEY); + self.metadata + .insert(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string()); + Ok(()) + } + + fn blob_v2_layout(&self) -> Option { + if self.extension_name() != Some(BLOB_V2_EXT_NAME) { + return None; + } + let DataType::Struct(fields) = self.data_type() else { + return None; + }; + BlobV2Layout::classify(&fields) + } + + fn blob_v2_logical_shared_child_count(&self, other: &Self) -> Option { + if self.blob_v2_layout() == Some(BlobV2Layout::Logical) + && other.blob_v2_layout() == Some(BlobV2Layout::Logical) + { + Some(self.children.len().min(other.children.len())) + } else { + None + } + } + + fn is_blob_v2_descriptor(&self) -> bool { + self.metadata.contains_key(BLOB_META_KEY) + && self.logical_type == BLOB_V2_DESC_LANCE_FIELD.logical_type + && self.children.len() == BLOB_V2_DESC_LANCE_FIELD.children.len() + && self + .children + .iter() + .zip(BLOB_V2_DESC_LANCE_FIELD.children.iter()) + .all(|(child, expected)| { + child.name == expected.name && child.data_type() == expected.data_type() + }) + } + + // 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 @@ -539,6 +657,43 @@ impl Field { } } + /// Convert a blob field to the materialized binary payload view. + /// + /// The field keeps its name and id but uses `LargeBinary` with no children. + /// Blob v2 fields retain their extension marker internally so scan planning + /// can recognize the binary view before exposing a plain Arrow binary field. + pub fn binary_blob_mut(&mut self) { + if !self.is_blob() { + return; + } + let is_blob_v2 = self.is_blob_v2(); + + self.logical_type = LogicalType::try_from(&DataType::LargeBinary) + .expect("LargeBinary is always a valid logical type"); + self.children.clear(); + self.encoding = Some(Encoding::VarBinary); + if is_blob_v2 { + self.metadata.remove(BLOB_META_KEY); + for key in PACKED_KEYS { + self.metadata.remove(key); + } + self.metadata + .insert(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string()); + } + } + + /// Convert blob v2 fields in this field tree to their descriptor view. + pub fn unload_blobs_recursive(&mut self) { + if self.is_blob_v2() { + self.unloaded_mut(); + return; + } + + for child in &mut self.children { + child.unload_blobs_recursive(); + } + } + pub fn project(&self, path_components: &[&str]) -> Result { let mut f = Self { name: self.name.clone(), @@ -551,6 +706,7 @@ impl Field { children: vec![], dictionary: self.dictionary.clone(), unenforced_primary_key_position: self.unenforced_primary_key_position, + unenforced_clustering_key_position: self.unenforced_clustering_key_position, }; if path_components.is_empty() { // Project stops here, copy all the remaining children. @@ -631,15 +787,16 @@ impl Field { /// pub fn project_by_field(&self, other: &Self, on_type_mismatch: OnTypeMismatch) -> Result { if self.name != other.name { - return Err(Error::Schema { - message: format!( - "Attempt to project field by different names: {} and {}", - self.name, other.name, - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Attempt to project field by different names: {} and {}", + self.name, other.name, + ))); }; + 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) @@ -647,13 +804,10 @@ impl Field { || (dt.is_binary_like() && other_dt.is_binary_like()) => { if dt != other_dt { - return Err(Error::Schema { - message: format!( - "Attempt to project field by different types: {} and {}", - dt, other_dt, - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Attempt to project field by different types: {} and {}", + dt, other_dt, + ))); } Ok(self.clone()) } @@ -667,13 +821,10 @@ impl Field { let mut fields = vec![]; for other_field in other.children.iter() { let Some(child) = self.child(&other_field.name) else { - return Err(Error::Schema { - message: format!( - "Attempt to project non-existed field: {} on {}", - other_field.name, self, - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Attempt to project non-existed field: {} on {}", + other_field.name, self, + ))); }; fields.push(child.project_by_field(other_field, on_type_mismatch)?); } @@ -706,13 +857,10 @@ impl Field { Ok(self.clone()) } _ => match on_type_mismatch { - OnTypeMismatch::Error => Err(Error::Schema { - message: format!( - "Attempt to project incompatible fields: {} and {}", - self, other - ), - location: location!(), - }), + OnTypeMismatch::Error => Err(Error::schema(format!( + "Attempt to project incompatible fields: {} and {}", + self, other + ))), OnTypeMismatch::TakeSelf => Ok(self.clone()), }, } @@ -764,22 +912,31 @@ impl Field { pub(crate) fn do_intersection(&self, other: &Self, ignore_types: bool) -> Result { if self.name != other.name { - return Err(Error::Arrow { - message: format!( - "Attempt to intersect different fields: {} and {}", - self.name, other.name, - ), - location: location!(), - }); + return Err(Error::arrow(format!( + "Attempt to intersect different fields: {} and {}", + self.name, other.name, + ))); } if self.is_blob() != other.is_blob() { - return Err(Error::Arrow { - message: format!( - "Attempt to intersect blob and non-blob field: {}", - self.name - ), - location: location!(), + if ignore_types { + return Ok(if self.id >= 0 { + self.clone() + } else { + other.clone() + }); + } + return Err(Error::arrow(format!( + "Attempt to intersect blob and non-blob field: {}", + self.name + ))); + } + + if self.is_compatible_blob_projection(other) { + return Ok(if self.id >= 0 { + self.clone() + } else { + other.clone() }); } @@ -804,7 +961,7 @@ impl Field { .iter() .filter_map(|c| { if let Some(other_child) = other.child(&c.name) { - let intersection = c.intersection(other_child).ok()?; + let intersection = c.do_intersection(other_child, ignore_types).ok()?; Some(intersection) } else { None @@ -822,18 +979,16 @@ impl Field { children, dictionary: self.dictionary.clone(), unenforced_primary_key_position: self.unenforced_primary_key_position, + unenforced_clustering_key_position: self.unenforced_clustering_key_position, }; return Ok(f); } if (!ignore_types && self_type != other_type) || self.name != other.name { - return Err(Error::Arrow { - message: format!( - "Attempt to intersect different fields: ({}, {}) and ({}, {})", - self.name, self_type, other.name, other_type - ), - location: location!(), - }); + return Err(Error::arrow(format!( + "Attempt to intersect different fields: ({}, {}) and ({}, {})", + self.name, self_type, other.name, other_type + ))); } Ok(if self.id >= 0 { @@ -885,6 +1040,7 @@ impl Field { children, dictionary: self.dictionary.clone(), unenforced_primary_key_position: self.unenforced_primary_key_position, + unenforced_clustering_key_position: self.unenforced_clustering_key_position, }) } } @@ -918,13 +1074,10 @@ impl Field { } _ => { if self.data_type() != other.data_type() { - return Err(Error::Schema { - message: format!( - "Attempt to merge incompatible fields: {} and {}", - self, other - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Attempt to merge incompatible fields: {} and {}", + self, other + ))); } } } @@ -999,11 +1152,10 @@ impl Field { // Check if field has metadata `packed` set to true, this check is case insensitive. pub fn is_packed_struct(&self) -> bool { - const PACKED_KEYS: [&str; 2] = ["packed", "lance-encoding:packed"]; PACKED_KEYS.iter().any(|key| { self.metadata .get(*key) - .map(|value| value.eq_ignore_ascii_case("true")) + .map(|value| str_is_truthy(value)) .unwrap_or(false) }) } @@ -1019,6 +1171,10 @@ impl Field { pub fn is_unenforced_primary_key(&self) -> bool { self.unenforced_primary_key_position.is_some() } + + pub fn is_unenforced_clustering_key(&self) -> bool { + self.unenforced_clustering_key_position.is_some() + } } impl fmt::Display for Field { @@ -1050,6 +1206,14 @@ impl TryFrom<&ArrowField> for Field { fn try_from(field: &ArrowField) -> Result { let mut metadata = field.metadata().clone(); + let id = match metadata.remove(LANCE_FIELD_ID_KEY) { + Some(val) => val + .parse::() + .map_err(|e| Error::invalid_input(e.to_string()))? + .max(-1), + None => -1, + }; + let children = match field.data_type() { DataType::Struct(children) => children .iter() @@ -1065,34 +1229,27 @@ impl TryFrom<&ArrowField> for Field { // because converting a rust arrow map field to the python arrow field will // lose the keys_sorted property. if *keys_sorted { - return Err(Error::Schema { - message: "Unsupported map field with keys_sorted=true".to_string(), - location: location!(), - }); + return Err(Error::schema( + "Unsupported map field with keys_sorted=true".to_string(), + )); } // Validate Map entries follow Arrow specification let DataType::Struct(struct_fields) = entries.data_type() else { - return Err(Error::Schema { - message: "Map entries field must be a Struct".to_string(), - location: location!(), - }); + return Err(Error::schema( + "Map entries field must be a Struct".to_string(), + )); }; if struct_fields.len() < 2 { - return Err(Error::Schema { - message: "Map entries struct must contain both key and value fields" - .to_string(), - location: location!(), - }); + return Err(Error::schema( + "Map entries struct must contain both key and value fields".to_string(), + )); } let key_field = &struct_fields[0]; if key_field.is_nullable() { - return Err(Error::Schema { - message: format!( - "Map key field '{}' must be non-nullable according to Arrow Map specification", - key_field.name() - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Map key field '{}' must be non-nullable according to Arrow Map specification", + key_field.name() + ))); } vec![Self::try_from(entries.as_ref())?] } @@ -1105,9 +1262,12 @@ impl TryFrom<&ArrowField> for Field { // Backward compatibility: use 0 for legacy boolean flag metadata .get(LANCE_UNENFORCED_PRIMARY_KEY) - .filter(|s| matches!(s.to_lowercase().as_str(), "true" | "1" | "yes")) + .filter(|s| str_is_truthy(s)) .map(|_| 0) }); + let unenforced_clustering_key_position = metadata + .get(LANCE_UNENFORCED_CLUSTERING_KEY_POSITION) + .and_then(|s| s.parse::().ok()); let is_blob_v2 = has_blob_v2_extension(field); if is_blob_v2 { @@ -1118,6 +1278,12 @@ impl TryFrom<&ArrowField> for Field { // Check for JSON extension types (both Arrow and Lance) let logical_type = if is_arrow_json_field(field) || is_json_field(field) { + // A `json` field is stored as Lance JSONB whatever the input + // representation, so record the stored extension. + metadata.insert( + ARROW_EXT_NAME_KEY.to_string(), + lance_arrow::json::JSON_EXT_NAME.to_string(), + ); LogicalType::from("json") } else if is_blob_v2 { LogicalType::from("struct") @@ -1126,7 +1292,7 @@ impl TryFrom<&ArrowField> for Field { }; Ok(Self { - id: -1, + id, parent_id: -1, name: field.name().clone(), logical_type, @@ -1145,6 +1311,7 @@ impl TryFrom<&ArrowField> for Field { children, dictionary: None, unenforced_primary_key_position, + unenforced_clustering_key_position, }) } } @@ -1188,6 +1355,120 @@ mod tests { use arrow_schema::{Fields, TimeUnit}; use lance_arrow::BLOB_META_KEY; use std::collections::HashMap; + + use crate::datatypes::{BLOB_V2_LOGICAL_FIELDS, BLOB_V2_LOGICAL_MINIMAL_FIELDS}; + + fn blob_v2_logical_field(children: Fields) -> Field { + ArrowField::new("blob", DataType::Struct(children), true) + .with_metadata(HashMap::from([( + ARROW_EXT_NAME_KEY.to_string(), + BLOB_V2_EXT_NAME.to_string(), + )])) + .try_into() + .unwrap() + } + + #[test] + fn blob_v2_logical_shapes_are_compatible() { + let minimal = blob_v2_logical_field(BLOB_V2_LOGICAL_MINIMAL_FIELDS.clone()); + let complete = blob_v2_logical_field(BLOB_V2_LOGICAL_FIELDS.clone()); + let complete_required_range = blob_v2_logical_field(Fields::from( + BLOB_V2_LOGICAL_FIELDS + .iter() + .enumerate() + .map(|(index, field)| Arc::new(field.as_ref().clone().with_nullable(index < 2))) + .collect::>(), + )); + let options = SchemaCompareOptions::default(); + + for complete_shape in [&complete, &complete_required_range] { + assert!(minimal.compare_with_options(complete_shape, &options)); + assert!(complete_shape.compare_with_options(&minimal, &options)); + assert_eq!(minimal.explain_difference(complete_shape, &options), None); + assert_eq!(complete_shape.explain_difference(&minimal, &options), None); + } + + assert!(!complete.compare_with_options(&complete_required_range, &options)); + let ignore_nullability = SchemaCompareOptions { + compare_nullability: NullabilityComparison::Ignore, + ..Default::default() + }; + assert!(complete.compare_with_options(&complete_required_range, &ignore_nullability)); + + let complete_with_child_metadata = blob_v2_logical_field(Fields::from( + BLOB_V2_LOGICAL_FIELDS + .iter() + .enumerate() + .map(|(index, field)| { + let field = if index == 0 { + field.as_ref().clone().with_metadata(HashMap::from([( + "source".to_string(), + "test".to_string(), + )])) + } else { + field.as_ref().clone() + }; + Arc::new(field) + }) + .collect::>(), + )); + let compare_metadata = SchemaCompareOptions { + compare_metadata: true, + ..Default::default() + }; + assert!(!complete.compare_with_options(&complete_with_child_metadata, &compare_metadata)); + + let nested_minimal: Field = ArrowField::new( + "outer", + DataType::Struct(Fields::from(vec![ArrowField::from(&minimal)])), + true, + ) + .try_into() + .unwrap(); + let nested_complete: Field = ArrowField::new( + "outer", + DataType::Struct(Fields::from(vec![ArrowField::from(&complete)])), + true, + ) + .try_into() + .unwrap(); + assert!(nested_minimal.compare_with_options(&nested_complete, &options)); + assert!(nested_complete.compare_with_options(&nested_minimal, &options)); + } + + #[test] + fn malformed_blob_v2_logical_shapes_remain_incompatible() { + let minimal = blob_v2_logical_field(BLOB_V2_LOGICAL_MINIMAL_FIELDS.clone()); + let malformed = blob_v2_logical_field(Fields::from(vec![ + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::LargeUtf8, true), + ])); + let options = SchemaCompareOptions::default(); + + assert!(!minimal.compare_with_options(&malformed, &options)); + assert!(!malformed.compare_with_options(&minimal, &options)); + assert!(minimal.explain_difference(&malformed, &options).is_some()); + assert!(malformed.explain_difference(&minimal, &options).is_some()); + } + + #[test] + fn arrow_field_to_field_metadata() { + let mut metadata = HashMap::new(); + metadata.insert(LANCE_FIELD_ID_KEY.to_string(), "42".to_string()); + metadata.insert("custom".to_string(), "value".to_string()); + + let arrow_field = + ArrowField::new("a", DataType::Int32, false).with_metadata(metadata.clone()); + let field = Field::try_from(&arrow_field).unwrap(); + + assert_eq!(field.id, 42); + assert!(!field.metadata.contains_key(LANCE_FIELD_ID_KEY)); + assert_eq!( + field.metadata.get("custom").map(String::as_str), + Some("value") + ); + } + #[test] fn arrow_field_to_field() { for (name, data_type) in [ @@ -1247,6 +1528,23 @@ mod tests { } } + #[test] + fn test_view_types_stored_as_lance_base_types() { + let field = Field::try_from(&ArrowField::new("s", DataType::Utf8View, true)).unwrap(); + assert_eq!(field.data_type(), DataType::Utf8); + assert_eq!( + LogicalType::try_from(&DataType::Utf8View).unwrap().0, + "string" + ); + + let field = Field::try_from(&ArrowField::new("b", DataType::BinaryView, true)).unwrap(); + assert_eq!(field.data_type(), DataType::Binary); + assert_eq!( + LogicalType::try_from(&DataType::BinaryView).unwrap().0, + "binary" + ); + } + #[test] fn test_nested_types() { assert_eq!( @@ -1763,6 +2061,14 @@ mod tests { #[test] fn blob_unloaded_mut_selects_layout_from_metadata() { let metadata = HashMap::from([(BLOB_META_KEY.to_string(), "true".to_string())]); + let mut binary_field: Field = ArrowField::new("blob", DataType::LargeBinary, true) + .with_metadata(metadata.clone()) + .try_into() + .unwrap(); + binary_field.binary_blob_mut(); + assert!(binary_field.metadata.contains_key(BLOB_META_KEY)); + assert!(!binary_field.is_blob_v2()); + let mut field: Field = ArrowField::new("blob", DataType::LargeBinary, true) .with_metadata(metadata) .try_into() @@ -1770,6 +2076,12 @@ mod tests { field.unloaded_mut(); assert_eq!(field.children.len(), 2); assert_eq!(field.logical_type, BLOB_DESC_LANCE_FIELD.logical_type); + assert!(field.is_blob()); + assert!(!field.is_blob_v2()); + field.unloaded_mut(); + assert_eq!(field.children.len(), 2); + assert_eq!(field.logical_type, BLOB_DESC_LANCE_FIELD.logical_type); + assert!(!field.is_blob_v2()); let metadata = HashMap::from([(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string())]); @@ -1790,5 +2102,119 @@ mod tests { field.unloaded_mut(); assert_eq!(field.children.len(), 5); assert_eq!(field.logical_type, BLOB_V2_DESC_LANCE_FIELD.logical_type); + assert!(!field.metadata.contains_key(ARROW_EXT_NAME_KEY)); + assert!(field.is_blob_v2()); + field.unloaded_mut(); + assert_eq!(field.children.len(), 5); + assert_eq!(field.logical_type, BLOB_V2_DESC_LANCE_FIELD.logical_type); + assert!(!field.metadata.contains_key(ARROW_EXT_NAME_KEY)); + } + + #[test] + fn unload_blobs_recursive_only_unloads_blob_v2() { + let legacy_metadata = HashMap::from([(BLOB_META_KEY.to_string(), "true".to_string())]); + let blob_v2_metadata = + HashMap::from([(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string())]); + + let mut field: Field = ArrowField::new( + "parent", + DataType::Struct(Fields::from(vec![ + ArrowField::new("legacy_blob", DataType::LargeBinary, true) + .with_metadata(legacy_metadata), + ArrowField::new( + "blob_v2", + DataType::Struct( + vec![ + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::Utf8, true), + ] + .into(), + ), + true, + ) + .with_metadata(blob_v2_metadata), + ])), + true, + ) + .try_into() + .unwrap(); + + field.unload_blobs_recursive(); + + let legacy_blob = field + .children + .iter() + .find(|f| f.name == "legacy_blob") + .unwrap(); + assert_eq!( + legacy_blob.logical_type, + LogicalType::try_from(&DataType::LargeBinary).unwrap() + ); + assert_eq!(legacy_blob.children.len(), 0); + assert!(legacy_blob.metadata.contains_key(BLOB_META_KEY)); + + let blob_v2 = field.children.iter().find(|f| f.name == "blob_v2").unwrap(); + assert_eq!(blob_v2.logical_type, BLOB_V2_DESC_LANCE_FIELD.logical_type); + assert_eq!(blob_v2.children.len(), 5); + } + + #[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); + } + + #[test] + fn blob_descriptor_projection_preserves_synthetic_children() { + let metadata = + HashMap::from([(ARROW_EXT_NAME_KEY.to_string(), BLOB_V2_EXT_NAME.to_string())]); + let mut blob: Field = ArrowField::new( + "blob", + DataType::Struct( + vec![ + ArrowField::new("data", DataType::LargeBinary, true), + ArrowField::new("uri", DataType::Utf8, true), + ] + .into(), + ), + true, + ) + .with_metadata(metadata) + .try_into() + .unwrap(); + let mut next_id = 0; + blob.set_id(-1, &mut next_id); + + let schema = Arc::new(crate::datatypes::Schema { + fields: vec![blob], + metadata: HashMap::new(), + }); + let descriptor_schema = Projection::full(schema) + .with_blob_handling(crate::datatypes::BlobHandling::BlobsDescriptions) + .to_bare_schema(); + assert!( + descriptor_schema.fields[0] + .children + .iter() + .all(|child| child.id == -1) + ); + + let projected = Projection::full(Arc::new(descriptor_schema)).to_bare_schema(); + assert_eq!(projected.fields[0].children.len(), 5); } } diff --git a/rust/lance-core/src/datatypes/schema.rs b/rust/lance-core/src/datatypes/schema.rs index 528437341ca..0a132875ff5 100644 --- a/rust/lance-core/src/datatypes/schema.rs +++ b/rust/lance-core/src/datatypes/schema.rs @@ -9,17 +9,16 @@ use std::{ sync::Arc, }; +use crate::deepsize::DeepSizeOf; use arrow_array::RecordBatch; -use arrow_schema::{Field as ArrowField, Schema as ArrowSchema}; -use deepsize::DeepSizeOf; +use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema}; use lance_arrow::*; -use snafu::location; use super::field::{Field, OnTypeMismatch, SchemaCompareOptions}; use crate::{ - Error, Result, ROW_ADDR, ROW_ADDR_FIELD, ROW_CREATED_AT_VERSION, ROW_CREATED_AT_VERSION_FIELD, - ROW_ID, ROW_ID_FIELD, ROW_LAST_UPDATED_AT_VERSION, ROW_LAST_UPDATED_AT_VERSION_FIELD, - ROW_OFFSET, ROW_OFFSET_FIELD, WILDCARD, + Error, ROW_ADDR, ROW_ADDR_FIELD, ROW_CREATED_AT_VERSION, ROW_CREATED_AT_VERSION_FIELD, ROW_ID, + ROW_ID_FIELD, ROW_LAST_UPDATED_AT_VERSION, ROW_LAST_UPDATED_AT_VERSION_FIELD, ROW_OFFSET, + ROW_OFFSET_FIELD, Result, WILDCARD, }; /// Lance Schema. @@ -46,18 +45,16 @@ impl FieldRef<'_> { match self { FieldRef::ById(id) => { if schema.field_by_id(id).is_none() { - return Err(Error::InvalidInput { - source: format!("Field ID {} not found in schema", id).into(), - location: location!(), - }); + return Err(Error::invalid_input_source( + format!("Field ID {} not found in schema", id).into(), + )); } Ok(id) } FieldRef::ByPath(path) => { - let field = schema.field(path).ok_or_else(|| Error::InvalidInput { - source: format!("Field '{}' not found in schema", path).into(), - location: location!(), - })?; + let field = schema + .field(path) + .ok_or_else(|| Error::field_not_found(path, schema.field_paths()))?; Ok(field.id) } } @@ -88,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() { @@ -114,6 +110,29 @@ impl<'a> Iterator for SchemaFieldIterPreOrder<'a> { } } +/// Reject `FixedSizeList` types whose dimension is not a positive integer. +/// +/// The row count of a fixed-size list is derived by dividing the number of +/// child items by the dimension, so a zero dimension panics with a +/// divide-by-zero further down the write path (see issue #5102). A +/// `FixedSizeList` of a `FixedSizeList` over a primitive collapses into a +/// single leaf field, so the pre-order field walk never visits the inner list; +/// recurse through the nested list types here to catch an inner zero dimension. +/// +/// Shared by [`Schema::validate`] on the write path and the decoder's +/// field-scheduler builders on the read path. +pub fn validate_fixed_size_list_dimensions(field_name: &str, data_type: &DataType) -> Result<()> { + if let DataType::FixedSizeList(inner, dimension) = data_type { + if *dimension <= 0 { + return Err(Error::schema(format!( + "Field \"{field_name}\" contains a FixedSizeList with dimension {dimension}; dimension must be a positive integer" + ))); + } + validate_fixed_size_list_dimensions(field_name, inner.data_type())?; + } + Ok(()) +} + impl Schema { /// The unenforced primary key fields in the schema, ordered by position. /// @@ -138,6 +157,20 @@ impl Schema { pk_fields } + /// The unenforced clustering key fields in the schema, ordered by position. + /// + /// Fields are ordered by their explicit position value (1-based). + pub fn unenforced_clustering_key(&self) -> Vec<&Field> { + let mut ck_fields: Vec<&Field> = self + .fields_pre_order() + .filter(|f| f.is_unenforced_clustering_key()) + .collect(); + + ck_fields.sort_by_key(|f| f.unenforced_clustering_key_position.unwrap_or(0)); + + ck_fields + } + pub fn compare_with_options(&self, expected: &Self, options: &SchemaCompareOptions) -> bool { compare_fields(&self.fields, &expected.fields, options) && (!options.compare_metadata || self.metadata == expected.metadata) @@ -151,12 +184,11 @@ impl Schema { let mut differences = explain_fields_difference(&self.fields, &expected.fields, options, None); - if options.compare_metadata { - if let Some(difference) = + if options.compare_metadata + && let Some(difference) = explain_metadata_difference(&self.metadata, &expected.metadata) - { - differences.push(difference); - } + { + differences.push(difference); } if differences.is_empty() { @@ -173,11 +205,10 @@ impl Schema { pub fn check_compatible(&self, expected: &Self, options: &SchemaCompareOptions) -> Result<()> { if !self.compare_with_options(expected, options) { let difference = self.explain_difference(expected, options); - Err(Error::SchemaMismatch { - // unknown reason is messy but this shouldn't happen. - difference: difference.unwrap_or("unknown reason".to_string()), - location: location!(), - }) + // unknown reason is messy but this shouldn't happen. + Err(Error::schema_mismatch( + difference.unwrap_or("unknown reason".to_string()), + )) } else { Ok(()) } @@ -257,20 +288,14 @@ impl Schema { candidates .push(Field::try_from(ROW_LAST_UPDATED_AT_VERSION_FIELD.clone())?); } else { - return Err(Error::Schema { - message: format!( - "System column {} is currently not supported in projection", - first - ), - location: location!(), - }); + return Err(Error::schema(format!( + "System column {} is currently not supported in projection", + first + ))); } } } else if err_on_missing { - return Err(Error::Schema { - message: format!("Column {} does not exist", col.as_ref()), - location: location!(), - }); + return Err(Error::field_not_found(col.as_ref(), self.field_paths())); } } @@ -308,45 +333,50 @@ impl Schema { for field in self.fields.iter() { if field.name.contains('.') { - return Err(Error::Schema{message:format!( + return Err(Error::schema(format!( "Top level field {} cannot contain `.`. Maybe you meant to create a struct field?", field.name.clone() - ), location: location!(),}); + ))); } - let column_path = self - .field_ancestry_by_id(field.id) - .unwrap() - .iter() - .map(|f| f.name.as_str()) - .collect::>() - .join("."); - if !seen_names.insert(column_path.clone()) { - return Err(Error::Schema { - message: format!( - "Duplicate field name \"{}\" in schema:\n {:#?}", - column_path, self - ), - location: location!(), - }); + if !seen_names.insert(field.name.as_str()) { + return Err(Error::schema(format!( + "Duplicate field name \"{}\" in schema:\n {:#?}", + field.name, self + ))); } } + // A negative id is reserved for system use; the only ones a schema may + // carry are the hidden row lineage columns a data file stores next to + // the user columns, as top-level fields under their own names. A + // nested field reusing one of these ids fails the duplicate check. + let row_lineage_ids = self + .fields + .iter() + .filter(|field| crate::row_lineage_field_id(&field.name) == Some(field.id)) + .map(|field| field.id) + .collect::>(); + // Check for duplicate field ids let mut seen_ids = HashSet::new(); for field in self.fields_pre_order() { - if field.id < 0 { - return Err(Error::Schema { - message: format!("Field {} has a negative id {}", field.name, field.id), - location: location!(), - }); + if field.id < 0 && !row_lineage_ids.contains(&field.id) { + return Err(Error::schema(format!( + "Field {} has a negative id {}", + field.name, field.id + ))); } if !seen_ids.insert(field.id) { - return Err(Error::Schema { - message: format!("Duplicate field id {} in schema {:?}", field.id, self), - location: location!(), - }); + return Err(Error::schema(format!( + "Duplicate field id {} in schema {:?}", + field.id, self + ))); } + // The row count of a fixed-size list is derived by dividing the + // number of items by the dimension, so a zero dimension would + // panic with a divide-by-zero further down the write path. + validate_fixed_size_list_dimensions(&field.name, &field.data_type())?; } Ok(()) @@ -384,6 +414,27 @@ impl Schema { SchemaFieldIterPreOrder::new(self) } + /// Get all field paths in the schema as a list of strings. + /// + /// This returns all field paths in the schema, including nested fields. + /// For example, if there's a struct field "user" with a field "name", + /// this will return "user.name" as one of the paths. + pub fn field_paths(&self) -> Vec { + let mut paths = Vec::new(); + for field in self.fields_pre_order() { + let ancestry = self.field_ancestry_by_id(field.id); + if let Some(ancestry) = ancestry { + let path = ancestry + .iter() + .map(|f| f.name.as_str()) + .collect::>() + .join("."); + paths.push(path); + } + } + paths + } + /// Returns a new schema that only contains the fields in `column_ids`. /// /// This projection can filter out both top-level and nested fields @@ -431,23 +482,17 @@ impl Schema { for field in projection.fields.iter() { // Ensure the field is a top-level field (no dots in the name) if field.name.contains('.') { - return Err(Error::Schema { - message: format!( - "Field '{}' contains dots. project_by_schema only accepts top-level fields. \ - Use project() method for nested field paths.", - field.name - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Field '{}' contains dots. project_by_schema only accepts top-level fields. \ + Use project() method for nested field paths.", + field.name + ))); } if let Some(self_field) = self.field(&field.name) { new_fields.push(self_field.project_by_field(field, on_type_mismatch)?); } else if matches!(on_missing, OnMissing::Error) { - return Err(Error::Schema { - message: format!("Field {} not found", field.name), - location: location!(), - }); + return Err(Error::schema(format!("Field {} not found", field.name))); } } Ok(Self { @@ -458,17 +503,16 @@ impl Schema { /// Exclude the fields from `other` Schema, and returns a new Schema. pub fn exclude + Debug>(&self, schema: T) -> Result { - let other = schema.try_into().map_err(|_| Error::Schema { - message: "The other schema is not compatible with this schema".to_string(), - location: location!(), + let other = schema.try_into().map_err(|_| { + Error::schema("The other schema is not compatible with this schema".to_string()) })?; let mut fields = vec![]; for field in self.fields.iter() { if let Some(other_field) = other.field(&field.name) { - if field.data_type().is_nested() { - if let Some(f) = field.exclude(other_field) { - fields.push(f) - } + if field.data_type().is_nested() + && let Some(f) = field.exclude(other_field) + { + fields.push(f) } } else { fields.push(field.clone()); @@ -546,10 +590,7 @@ impl Schema { pub fn field_id(&self, column: &str) -> Result { self.field(column) .map(|f| f.id) - .ok_or_else(|| Error::Schema { - message: "Vector column not in schema".to_string(), - location: location!(), - }) + .ok_or_else(|| Error::schema("Vector column not in schema".to_string())) } pub fn top_level_field_ids(&self) -> Vec { @@ -633,12 +674,12 @@ impl Schema { // TODO: pub(crate) pub fn set_dictionary(&mut self, batch: &RecordBatch) -> Result<()> { for field in self.fields.as_mut_slice() { - let column = batch - .column_by_name(&field.name) - .ok_or_else(|| Error::Schema { - message: format!("column '{}' does not exist in the record batch", field.name), - location: location!(), - })?; + let column = batch.column_by_name(&field.name).ok_or_else(|| { + Error::schema(format!( + "column '{}' does not exist in the record batch", + field.name + )) + })?; field.set_dictionary(column); } Ok(()) @@ -677,13 +718,10 @@ impl Schema { // Validate this addition does not create any duplicate field names let field_names = self.fields.iter().map(|f| &f.name).collect::>(); if field_names.len() != self.fields.len() { - Err(Error::Internal { - message: format!( - "Attempt to add fields [{:?}] would lead to duplicate field names", - fields.iter().map(|f| f.name()).collect::>() - ), - location: location!(), - }) + Err(Error::internal(format!( + "Attempt to add fields [{:?}] would lead to duplicate field names", + fields.iter().map(|f| f.name()).collect::>() + ))) } else { Ok(()) } @@ -692,7 +730,8 @@ impl Schema { /// Merge this schema from the other schema. /// /// After merging, the field IDs from `other` schema will be reassigned, - /// following the fields in `self`. + /// following the fields in `self`. Schema metadata is combined, with values + /// from `self` taking precedence when both schemas contain the same key. pub fn merge>(&self, other: S) -> Result { let mut other: Self = other.try_into()?; other.reset_id(); @@ -713,12 +752,12 @@ impl Schema { merged_fields.push(field.clone()); } } - let metadata = self - .metadata - .iter() - .chain(other.metadata.iter()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); + let mut metadata = other.metadata; + metadata.extend( + self.metadata + .iter() + .map(|(key, value)| (key.clone(), value.clone())), + ); let schema = Self { fields: merged_fields, metadata, @@ -726,23 +765,110 @@ impl Schema { Ok(schema) } - pub fn all_fields_nullable(&self) -> bool { - SchemaFieldIterPreOrder::new(self).all(|f| f.nullable) - } - /// Returns the properly formatted path from root to the field. /// Field names containing dots are quoted (e.g., struct.`field.with.dot`) + /// + /// The result is suitable for SQL parsing. For a human-readable path + /// (e.g. for display in index metadata), use [`Self::field_path_minimal`]. pub fn field_path(&self, field_id: i32) -> Result { self.field_ancestry_by_id(field_id) .map(|ancestry| { let field_refs: Vec<&str> = ancestry.iter().map(|f| f.name.as_str()).collect(); format_field_path(&field_refs) }) - .ok_or_else(|| Error::Index { - message: format!("Could not find field ancestry for id {}", field_id), - location: location!(), + .ok_or_else(|| { + Error::index(format!("Could not find field ancestry for id {}", field_id)) }) } + + /// Returns the path from root to the field using *minimal* quoting. + /// + /// A segment is wrapped in backticks only when it contains a character that + /// [`parse_field_path`] treats specially (a `.` separator or a `` ` `` quote); + /// any other character — including hyphens — is left bare. Unlike + /// [`Self::field_path`] (which quotes for SQL-expression safety and so wraps + /// e.g. `my-col` in backticks), the result here is both human-readable and + /// round-trips back through `parse_field_path`, so it is safe to feed into + /// field-path APIs such as `drop_columns` / `update_field_metadata`. + /// + /// This is what should be exposed as the column name in index metadata. + /// + /// ``` + /// use arrow_schema::{DataType, Field, Fields, Schema as ArrowSchema}; + /// use lance_core::datatypes::{parse_field_path, Schema}; + /// + /// let arrow = ArrowSchema::new(vec![ + /// Field::new("my-col", DataType::Int32, false), + /// Field::new( + /// "parent", + /// DataType::Struct(Fields::from(vec![Field::new("child.x", DataType::Int32, true)])), + /// true, + /// ), + /// ]); + /// let schema = Schema::try_from(&arrow).unwrap(); + /// + /// // A hyphen is not special to `parse_field_path`, so it is left bare + /// // (unlike `field_path`, which would quote it as `` `my-col` ``). + /// let hyphen_id = schema.field("my-col").unwrap().id; + /// assert_eq!(schema.field_path_minimal(hyphen_id).unwrap(), "my-col"); + /// + /// // A `.` in a segment forces quoting so the path still round-trips. + /// let dotted_id = schema.field("parent").unwrap().children[0].id; + /// let path = schema.field_path_minimal(dotted_id).unwrap(); + /// assert_eq!(path, "parent.`child.x`"); + /// assert_eq!( + /// parse_field_path(&path).unwrap(), + /// vec!["parent".to_string(), "child.x".to_string()], + /// ); + /// ``` + pub fn field_path_minimal(&self, field_id: i32) -> Result { + self.field_ancestry_by_id(field_id) + .map(|ancestry| { + let field_refs: Vec<&str> = ancestry.iter().map(|f| f.name.as_str()).collect(); + format_field_path_minimal(&field_refs) + }) + .ok_or_else(|| { + Error::index(format!("Could not find field ancestry for id {}", field_id)) + }) + } + + pub fn verify_primary_key(&self) -> Result<()> { + let pk = self.unenforced_primary_key(); + for pk_col in pk.into_iter() { + if !pk_col.is_leaf() { + return Err(Error::schema(format!( + "Primary key column must be a leaf: {}", + pk_col + ))); + } + + if let Some(ancestors) = self.field_ancestry_by_id(pk_col.id) { + for ancestor in ancestors { + if ancestor.nullable { + return Err(Error::schema(format!( + "Primary key column and all its ancestors must not be nullable: {}", + ancestor + ))); + } + + if ancestor.logical_type.is_list() || ancestor.logical_type.is_large_list() { + return Err(Error::schema(format!( + "Primary key column must not be in a list type: {}", + ancestor + ))); + } + + if ancestor.logical_type.is_map() { + return Err(Error::schema(format!( + "Primary key column must not be in a map type: {}", + ancestor + ))); + } + } + } + } + Ok(()) + } } impl PartialEq for Schema { @@ -776,49 +902,7 @@ impl TryFrom<&ArrowSchema> for Schema { schema.set_field_id(None); schema.validate()?; - let pk = schema.unenforced_primary_key(); - for pk_col in pk.into_iter() { - if !pk_col.is_leaf() { - return Err(Error::Schema { - message: format!("Primary key column must be a leaf: {}", pk_col), - location: location!(), - }); - } - - if let Some(ancestors) = schema.field_ancestry_by_id(pk_col.id) { - for ancestor in ancestors { - if ancestor.nullable { - return Err(Error::Schema { - message: format!( - "Primary key column and all its ancestors must not be nullable: {}", - ancestor - ), - location: location!(), - }); - } - - if ancestor.logical_type.is_list() || ancestor.logical_type.is_large_list() { - return Err(Error::Schema { - message: format!( - "Primary key column must not be in a list type: {}", - ancestor - ), - location: location!(), - }); - } - - if ancestor.logical_type.is_map() { - return Err(Error::Schema { - message: format!( - "Primary key column must not be in a map type: {}", - ancestor - ), - location: location!(), - }); - } - } - } - } + schema.verify_primary_key()?; Ok(schema) } @@ -1024,7 +1108,7 @@ impl Projectable for Schema { } /// Specifies how to handle blob columns when projecting -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone, Default, PartialEq)] pub enum BlobHandling { /// Read all blobs as binary AllBinary, @@ -1046,6 +1130,17 @@ pub enum BlobHandling { } impl BlobHandling { + fn should_load_binary(&self, field: &Field) -> bool { + if !field.is_blob() { + return false; + } + match self { + Self::AllBinary => true, + Self::SomeBlobsBinary(set) | Self::SomeBinary(set) => set.contains(&(field.id as u32)), + Self::BlobsDescriptions | Self::AllDescriptions => false, + } + } + fn should_unload(&self, field: &Field) -> bool { // Blob v2 columns are Structs, so we need to treat any blob-marked field as unloadable // even if the physical data type is not binary-like. @@ -1061,10 +1156,34 @@ impl BlobHandling { } } + /// Whether `field` will be projected as a lightweight blob *description* + /// (offset + size) rather than its full binary value under this handling. + /// + /// A description is tiny and cheap to read eagerly; the full binary value is + /// not. Materialization heuristics use this to decide early vs late loading. + pub fn returns_description(&self, field: &Field) -> bool { + self.should_unload(field) + } + + /// Apply this blob handling policy to a projected field tree. + /// + /// Blob descriptor modes convert blob leaves to descriptor views. Binary + /// modes convert selected blob leaves to `LargeBinary`. Non-blob nested + /// fields are preserved while their children are handled recursively. pub fn unload_if_needed(&self, mut field: Field) -> Field { + if self.should_load_binary(&field) { + field.binary_blob_mut(); + return field; + } if self.should_unload(&field) { field.unloaded_mut(); + return field; } + field.children = field + .children + .into_iter() + .map(|child| self.unload_if_needed(child)) + .collect(); field } } @@ -1182,10 +1301,9 @@ impl Projection { Self::add_field_children(&mut self.field_ids, last_field); } } else if matches!(on_missing, OnMissing::Error) { - return Err(Error::InvalidInput { - source: format!("Column {} does not exist", column).into(), - location: location!(), - }); + return Err(Error::invalid_input_source( + format!("Column {} does not exist", column).into(), + )); } Ok(self) } @@ -1462,10 +1580,7 @@ impl Projection { /// The result is guaranteed to contain at least one element. pub fn parse_field_path(path: &str) -> Result> { if path.is_empty() { - return Err(Error::Schema { - message: "Field path cannot be empty".to_string(), - location: location!(), - }); + return Err(Error::schema("Field path cannot be empty".to_string())); } let mut result = Vec::new(); @@ -1486,13 +1601,13 @@ pub fn parse_field_path(path: &str) -> Result> { // End of quoted field in_quotes = false; // After closing quote, we should either see a dot or end of string - if let Some(&next_ch) = chars.peek() { - if next_ch != '.' { - return Err(Error::Schema { - message: format!("Invalid field path '{}': expected '.' or end of string after closing quote", path), - location: location!(), - }); - } + if let Some(&next_ch) = chars.peek() + && next_ch != '.' + { + return Err(Error::schema(format!( + "Invalid field path '{}': expected '.' or end of string after closing quote", + path + ))); } } } else if current.is_empty() { @@ -1500,21 +1615,18 @@ pub fn parse_field_path(path: &str) -> Result> { in_quotes = true; } else { // Quote in the middle of unquoted field name - return Err(Error::Schema { - message: format!( - "Invalid field path '{}': unexpected quote in the middle of field name", - path - ), - location: location!(), - }); + return Err(Error::schema(format!( + "Invalid field path '{}': unexpected quote in the middle of field name", + path + ))); } } '.' if !in_quotes => { if current.is_empty() { - return Err(Error::Schema { - message: format!("Invalid field path '{}': empty field name", path), - location: location!(), - }); + return Err(Error::schema(format!( + "Invalid field path '{}': empty field name", + path + ))); } result.push(current); current = String::new(); @@ -1526,28 +1638,25 @@ pub fn parse_field_path(path: &str) -> Result> { } if in_quotes { - return Err(Error::Schema { - message: format!("Invalid field path '{}': unclosed quote", path), - location: location!(), - }); + return Err(Error::schema(format!( + "Invalid field path '{}': unclosed quote", + path + ))); } if !current.is_empty() { result.push(current); } else if !result.is_empty() { - return Err(Error::Schema { - message: format!("Invalid field path '{}': trailing dot", path), - location: location!(), - }); + return Err(Error::schema(format!( + "Invalid field path '{}': trailing dot", + path + ))); } // This check is now redundant since we check for empty input at the beginning, // but keeping it for extra safety if result.is_empty() { - return Err(Error::Schema { - message: format!("Invalid field path '{}'", path), - location: location!(), - }); + return Err(Error::schema(format!("Invalid field path '{}'", path))); } Ok(result) @@ -1581,6 +1690,50 @@ pub fn format_field_path(fields: &[&str]) -> String { .join(".") } +/// Like [`format_field_path`], but quotes a segment only when strictly required +/// for the result to round-trip back through [`parse_field_path`]. +/// +/// `parse_field_path` only treats `.` (segment separator) and `` ` `` (quote) +/// specially, so those are the only characters that force quoting here. Notably +/// a hyphen does NOT force quoting (`my-col` stays `my-col`), unlike +/// `format_field_path` which quotes any non-identifier character for +/// SQL-expression safety. Use this for human-readable, round-trippable paths +/// (e.g. column names in index metadata); use `format_field_path` when the +/// result will be embedded in a SQL expression. +/// +/// ``` +/// use lance_core::datatypes::{format_field_path_minimal, parse_field_path}; +/// +/// // Plain identifiers and hyphenated names are left bare. +/// assert_eq!(format_field_path_minimal(&["parent", "my-col"]), "parent.my-col"); +/// // A `.` in a segment forces quoting. +/// assert_eq!(format_field_path_minimal(&["parent", "child.x"]), "parent.`child.x`"); +/// // Embedded backticks are escaped by doubling them. +/// assert_eq!(format_field_path_minimal(&["child`x"]), "`child``x`"); +/// +/// // Whatever it produces round-trips back through `parse_field_path`. +/// for segments in [vec!["parent", "my-col"], vec!["parent", "child.x"], vec!["child`x"]] { +/// let path = format_field_path_minimal(&segments); +/// assert_eq!(parse_field_path(&path).unwrap(), segments); +/// } +/// ``` +pub fn format_field_path_minimal(fields: &[&str]) -> String { + fields + .iter() + .map(|field| { + let needs_quoting = field.contains('.') || field.contains('`'); + if needs_quoting { + // Escape embedded backticks by doubling them, matching parse_field_path. + let escaped = field.replace('`', "``"); + format!("`{}`", escaped) + } else { + field.to_string() + } + }) + .collect::>() + .join(".") +} + /// Escape a field path for project /// /// Parses the field path and formats it for SQL usage. @@ -1721,6 +1874,11 @@ mod tests { vec!["simple".to_string()] ); + assert_eq!( + parse_field_path("tags[*]").unwrap(), + vec!["tags[*]".to_string()] + ); + // Quoted field at the end assert_eq!( parse_field_path("parent.`field.with.dot`").unwrap(), @@ -1757,6 +1915,73 @@ mod tests { ); } + #[test] + fn test_validate_top_level_names_without_field_id_lookup() { + let mut first = Field::new_arrow("first", DataType::Int32, false).unwrap(); + first.id = 0; + let mut second = Field::new_arrow("second", DataType::Int32, false).unwrap(); + second.id = 0; + let schema = Schema { + fields: vec![first, second], + metadata: HashMap::new(), + }; + + let error = schema.validate().unwrap_err(); + assert!(matches!(&error, Error::Schema { .. })); + assert!(error.to_string().contains("Duplicate field id 0")); + } + + #[test] + fn test_validate_admits_row_lineage_ids_only_at_top_level() { + let uint64_field = |name: &str, id: i32| { + let mut field = Field::new_arrow(name, DataType::UInt64, false).unwrap(); + field.id = id; + field + }; + let mut key = Field::new_arrow("i", DataType::Int32, false).unwrap(); + key.id = 0; + + // A hidden lineage column: top-level, under its own name's reserved id. + let schema = Schema { + fields: vec![key.clone(), uint64_field(ROW_ID, crate::ROW_ID_FIELD_ID)], + metadata: HashMap::new(), + }; + schema.validate().unwrap(); + + // Another lineage column's id does not belong to this name. + let schema = Schema { + fields: vec![ + key.clone(), + uint64_field(ROW_ID, crate::ROW_CREATED_AT_VERSION_FIELD_ID), + ], + metadata: HashMap::new(), + }; + let error = schema.validate().unwrap_err(); + assert!(matches!(error, Error::Schema { .. }), "{error}"); + assert!(error.to_string().contains("negative id"), "{error}"); + + // A data file stores lineage columns only at the top level. + let mut parent = Field::new_arrow( + "s", + DataType::Struct(ArrowFields::from(vec![ArrowField::new( + ROW_CREATED_AT_VERSION, + DataType::UInt64, + false, + )])), + true, + ) + .unwrap(); + parent.id = 1; + parent.children[0].id = crate::ROW_CREATED_AT_VERSION_FIELD_ID; + let schema = Schema { + fields: vec![key, parent], + metadata: HashMap::new(), + }; + let error = schema.validate().unwrap_err(); + assert!(matches!(error, Error::Schema { .. }), "{error}"); + assert!(error.to_string().contains("negative id"), "{error}"); + } + #[test] fn test_resolve_quoted_fields() { // Test that top-level fields with dots are rejected during validation @@ -1770,9 +1995,10 @@ mod tests { let schema_result = Schema::try_from(&arrow_schema_with_dots); assert!(schema_result.is_err()); let err = schema_result.unwrap_err(); - assert!(err - .to_string() - .contains("Top level field field.with.dots cannot contain `.`")); + assert!( + err.to_string() + .contains("Top level field field.with.dots cannot contain `.`") + ); // Test that nested fields with dots are allowed let arrow_schema = ArrowSchema::new(vec![ @@ -2186,6 +2412,35 @@ mod tests { assert_eq!(merged.max_field_id(), Some(9)); } + #[test] + fn test_merge_schema_metadata_preserves_self_values() { + let schema = Schema { + metadata: HashMap::from([ + ("shared".to_string(), "left".to_string()), + ("left_only".to_string(), "left".to_string()), + ]), + ..Default::default() + }; + let other = Schema { + metadata: HashMap::from([ + ("shared".to_string(), "right".to_string()), + ("right_only".to_string(), "right".to_string()), + ]), + ..Default::default() + }; + + let merged = schema.merge(&other).unwrap(); + + assert_eq!( + merged.metadata, + HashMap::from([ + ("shared".to_string(), "left".to_string()), + ("left_only".to_string(), "left".to_string()), + ("right_only".to_string(), "right".to_string()), + ]) + ); + } + #[test] fn test_merge_arrow_schema() { let arrow_schema = ArrowSchema::new(vec![ @@ -2454,93 +2709,20 @@ mod tests { assert!(res.is_none(), "Expected None, got {:?}", res); } - #[test] - pub fn test_all_fields_nullable() { - let test_cases = vec![ - ( - vec![], // empty schema - true, - ), - ( - vec![ - Field::new_arrow("a", DataType::Int32, true).unwrap(), - Field::new_arrow("b", DataType::Utf8, true).unwrap(), - ], // basic case - true, - ), - ( - vec![ - Field::new_arrow("a", DataType::Int32, false).unwrap(), - Field::new_arrow("b", DataType::Utf8, true).unwrap(), - ], - false, - ), - ( - // check nested schema, parent is nullable - vec![Field::new_arrow( - "struct", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "a", - DataType::Int32, - false, - )])), - true, - ) - .unwrap()], - false, - ), - ( - // check nested schema, child is nullable - vec![Field::new_arrow( - "struct", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "a", - DataType::Int32, - true, - )])), - false, - ) - .unwrap()], - false, - ), - ( - // check nested schema, all is nullable - vec![Field::new_arrow( - "struct", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "a", - DataType::Int32, - true, - )])), - true, - ) - .unwrap()], - true, - ), - ]; - - for (fields, expected) in test_cases { - let schema = Schema { - fields, - metadata: Default::default(), - }; - assert_eq!(schema.all_fields_nullable(), expected); - } - } - #[test] fn test_schema_unenforced_primary_key() { let cases = vec![ ArrowSchema::new(vec![ArrowField::new("a", DataType::Int32, false)]), - ArrowSchema::new(vec![ArrowField::new("a", DataType::Int32, false) - .with_metadata( + ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, false).with_metadata( vec![( "lance-schema:unenforced-primary-key".to_owned(), "true".to_owned(), )] .into_iter() .collect::>(), - )]), + ), + ]), ArrowSchema::new(vec![ ArrowField::new("a", DataType::Int32, false).with_metadata( vec![( @@ -2552,19 +2734,16 @@ mod tests { ), ArrowField::new( "b", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "f1", - DataType::Utf8, - false, - ) - .with_metadata( - vec![( - "lance-schema:unenforced-primary-key".to_owned(), - "true".to_owned(), - )] - .into_iter() - .collect::>(), - )])), + DataType::Struct(ArrowFields::from(vec![ + ArrowField::new("f1", DataType::Utf8, false).with_metadata( + vec![( + "lance-schema:unenforced-primary-key".to_owned(), + "true".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ])), false, ), ]), @@ -2591,37 +2770,24 @@ mod tests { #[test] fn test_schema_unenforced_primary_key_failures() { let cases = vec![ - ArrowSchema::new(vec![ArrowField::new("a", DataType::Int32, true) - .with_metadata( + ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, true).with_metadata( vec![( "lance-schema:unenforced-primary-key".to_owned(), "true".to_owned(), )] .into_iter() .collect::>(), - )]), - ArrowSchema::new(vec![ArrowField::new( - "b", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "f1", - DataType::Utf8, - false, - )])), - false, - ) - .with_metadata( - vec![( - "lance-schema:unenforced-primary-key".to_owned(), - "true".to_owned(), - )] - .into_iter() - .collect::>(), - )]), - ArrowSchema::new(vec![ArrowField::new( - "b", - DataType::Struct(ArrowFields::from(vec![ArrowField::new( - "f1", - DataType::Utf8, + ), + ]), + ArrowSchema::new(vec![ + ArrowField::new( + "b", + DataType::Struct(ArrowFields::from(vec![ArrowField::new( + "f1", + DataType::Utf8, + false, + )])), false, ) .with_metadata( @@ -2631,7 +2797,20 @@ mod tests { )] .into_iter() .collect::>(), - )])), + ), + ]), + ArrowSchema::new(vec![ArrowField::new( + "b", + DataType::Struct(ArrowFields::from(vec![ + ArrowField::new("f1", DataType::Utf8, false).with_metadata( + vec![( + "lance-schema:unenforced-primary-key".to_owned(), + "true".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ])), true, )]), ArrowSchema::new(vec![ArrowField::new( @@ -2659,10 +2838,12 @@ mod tests { for (idx, case) in cases.into_iter().enumerate() { let result = Schema::try_from(&case); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains(error_message_contains[idx])); + assert!( + result + .unwrap_err() + .to_string() + .contains(error_message_contains[idx]) + ); } } @@ -2772,4 +2953,273 @@ mod tests { assert_eq!(pk_fields[1].name, "e"); assert_eq!(pk_fields[2].name, "g"); } + + #[test] + fn test_project_with_suggestion() { + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("vector", ArrowDataType::Float32, false), + ArrowField::new("label", ArrowDataType::Utf8, true), + ArrowField::new("score", ArrowDataType::Float64, false), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + + // Typo: "vectr" is close to "vector" → should get suggestion + let err = schema.project(&["vectr"]).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("Did you mean 'vector'?"), + "Expected suggestion for 'vectr', got: {}", + msg + ); + // Should also list available fields + assert!( + msg.contains("Available fields:"), + "Expected available fields list, got: {}", + msg + ); + + // Completely wrong name → no suggestion but still lists fields + let err = schema.project(&["nonexistent_column"]).unwrap_err(); + let msg = err.to_string(); + assert!( + !msg.contains("Did you mean"), + "Should not suggest for completely different name, got: {}", + msg + ); + assert!( + msg.contains("Available fields:"), + "Expected available fields list even without suggestion, got: {}", + msg + ); + } + + #[test] + fn test_field_paths() { + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("id", ArrowDataType::Int32, false), + ArrowField::new("vector", ArrowDataType::Float32, false), + ArrowField::new("name", ArrowDataType::Utf8, true), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + let paths = schema.field_paths(); + assert!(paths.contains(&"id".to_string())); + assert!(paths.contains(&"vector".to_string())); + assert!(paths.contains(&"name".to_string())); + } + + #[test] + fn test_field_path_minimal() { + // A struct child whose own NAME contains a dot is the case that makes + // "just strip all backticks" wrong: it must stay quoted to round-trip. + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("mycol", ArrowDataType::Int32, false), + ArrowField::new("my_col", ArrowDataType::Int32, false), + ArrowField::new("my-col", ArrowDataType::Int32, false), + ArrowField::new( + "parent", + ArrowDataType::Struct(ArrowFields::from(vec![ + ArrowField::new("child-field", ArrowDataType::Int32, true), + ArrowField::new("child.x", ArrowDataType::Int32, true), + ArrowField::new("child`x", ArrowDataType::Int32, true), + ])), + true, + ), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + let id_of = |path: &str| schema.field(path).unwrap().id; + let child_id = |name: &str| { + schema + .field("parent") + .unwrap() + .children + .iter() + .find(|c| c.name == name) + .unwrap() + .id + }; + + // Plain identifiers: unchanged by either method. + assert_eq!(schema.field_path_minimal(id_of("mycol")).unwrap(), "mycol"); + assert_eq!( + schema.field_path_minimal(id_of("my_col")).unwrap(), + "my_col" + ); + + // Hyphen is NOT special to parse_field_path, so minimal quoting leaves it + // bare (field_path would quote it for SQL safety). + assert_eq!(schema.field_path(id_of("my-col")).unwrap(), "`my-col`"); + assert_eq!( + schema.field_path_minimal(id_of("my-col")).unwrap(), + "my-col" + ); + + // Nested hyphenated leaf: bare under minimal quoting. + assert_eq!( + schema.field_path_minimal(child_id("child-field")).unwrap(), + "parent.child-field" + ); + + // Nested leaf whose NAME contains a dot: MUST stay quoted so it + // round-trips through parse_field_path (this is the regression guard). + let dotted = schema.field_path_minimal(child_id("child.x")).unwrap(); + assert_eq!(dotted, "parent.`child.x`"); + assert_eq!( + parse_field_path(&dotted).unwrap(), + vec!["parent".to_string(), "child.x".to_string()] + ); + + // Nested leaf whose NAME contains a backtick: it must be quoted AND the + // backtick doubled so it round-trips through parse_field_path. + let backticked = schema.field_path_minimal(child_id("child`x")).unwrap(); + assert_eq!(backticked, "parent.`child``x`"); + assert_eq!( + parse_field_path(&backticked).unwrap(), + vec!["parent".to_string(), "child`x".to_string()] + ); + } + + #[test] + fn test_validate_rejects_zero_dimension_fixed_size_list() { + // A zero dimension divides-by-zero further down the write path (#5102) + let fsl = |dimension: i32| { + ArrowDataType::FixedSizeList( + Arc::new(ArrowField::new("item", ArrowDataType::Float32, true)), + dimension, + ) + }; + + let arrow_schema = ArrowSchema::new(vec![ArrowField::new("vec", fsl(0), true)]); + let err = Schema::try_from(&arrow_schema).unwrap_err(); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + + // Nested inside a struct is rejected too + let arrow_schema = ArrowSchema::new(vec![ArrowField::new( + "outer", + ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new( + "vec", + fsl(0), + true, + )])), + true, + )]); + let err = Schema::try_from(&arrow_schema).unwrap_err(); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + + // A zero-dimension FixedSizeList nested inside a positive-dimension + // FixedSizeList collapses into a single leaf field, so the inner + // dimension is not visited by the pre-order field walk and must still + // be rejected: FixedSizeList(FixedSizeList(Float32, 0), 4). + let nested = + ArrowDataType::FixedSizeList(Arc::new(ArrowField::new("inner", fsl(0), true)), 4); + let arrow_schema = ArrowSchema::new(vec![ArrowField::new("vec", nested, true)]); + let err = Schema::try_from(&arrow_schema).unwrap_err(); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + + // A positive dimension still validates, including nested lists + let arrow_schema = ArrowSchema::new(vec![ArrowField::new("vec", fsl(2), true)]); + assert!(Schema::try_from(&arrow_schema).is_ok()); + let nested_ok = + ArrowDataType::FixedSizeList(Arc::new(ArrowField::new("inner", fsl(2), true)), 4); + let arrow_schema = ArrowSchema::new(vec![ArrowField::new("vec", nested_ok, true)]); + assert!(Schema::try_from(&arrow_schema).is_ok()); + } + + #[test] + fn test_schema_unenforced_clustering_key() { + use crate::datatypes::field::LANCE_UNENFORCED_CLUSTERING_KEY_POSITION; + + // No clustering key fields + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, false), + ArrowField::new("b", DataType::Utf8, true), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + assert!(schema.unenforced_clustering_key().is_empty()); + + // Single clustering key field + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, false).with_metadata( + vec![( + LANCE_UNENFORCED_CLUSTERING_KEY_POSITION.to_owned(), + "1".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("b", DataType::Utf8, true), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + let ck = schema.unenforced_clustering_key(); + assert_eq!(ck.len(), 1); + assert_eq!(ck[0].name, "a"); + + // Clustering key fields can be nullable (unlike primary keys) + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("a", DataType::Int32, true).with_metadata( + vec![( + LANCE_UNENFORCED_CLUSTERING_KEY_POSITION.to_owned(), + "1".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + assert_eq!(schema.unenforced_clustering_key().len(), 1); + } + + #[test] + fn test_schema_unenforced_clustering_key_ordering() { + use crate::datatypes::field::LANCE_UNENFORCED_CLUSTERING_KEY_POSITION; + + // Fields ordered by position regardless of schema column order + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("c", DataType::Utf8, true).with_metadata( + vec![( + LANCE_UNENFORCED_CLUSTERING_KEY_POSITION.to_owned(), + "3".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("a", DataType::Int32, false).with_metadata( + vec![( + LANCE_UNENFORCED_CLUSTERING_KEY_POSITION.to_owned(), + "1".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("b", DataType::Int64, false).with_metadata( + vec![( + LANCE_UNENFORCED_CLUSTERING_KEY_POSITION.to_owned(), + "2".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("d", DataType::Float64, true), + ]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + let ck = schema.unenforced_clustering_key(); + assert_eq!(ck.len(), 3); + assert_eq!(ck[0].name, "a"); + assert_eq!(ck[1].name, "b"); + assert_eq!(ck[2].name, "c"); + } } diff --git a/rust/lance-core/src/deepsize.rs b/rust/lance-core/src/deepsize.rs new file mode 100644 index 00000000000..733d1ae1442 --- /dev/null +++ b/rust/lance-core/src/deepsize.rs @@ -0,0 +1,467 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +pub use lance_derive::DeepSizeOf; + +use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; +use std::mem::{size_of, size_of_val}; +use std::sync::atomic::{AtomicU64, AtomicUsize}; +use std::sync::{Arc, Mutex, RwLock}; + +use arrow_array::{Array, RecordBatch}; +use arrow_buffer::ArrowNativeType; +use arrow_data::ArrayData; + +pub struct Context { + seen: HashSet, +} + +impl Default for Context { + fn default() -> Self { + Self::new() + } +} + +impl Context { + pub fn new() -> Self { + Self { + seen: HashSet::new(), + } + } + + /// Returns true if this pointer was NOT previously seen (i.e., it's new). + pub fn mark_seen(&mut self, ptr: usize) -> bool { + self.seen.insert(ptr) + } +} + +pub trait DeepSizeOf { + fn deep_size_of(&self) -> usize { + size_of_val(self) + self.deep_size_of_children(&mut Context::new()) + } + + fn deep_size_of_children(&self, context: &mut Context) -> usize; +} + +// Primitives — no heap children +macro_rules! impl_deep_size_primitive { + ($($t:ty),*) => { + $( + impl DeepSizeOf for $t { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + 0 + } + } + )* + }; +} + +impl_deep_size_primitive!( + u8, + u16, + u32, + u64, + u128, + usize, + i8, + i16, + i32, + i64, + i128, + isize, + f32, + f64, + bool, + () +); + +impl DeepSizeOf for str { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + 0 + } +} + +impl DeepSizeOf for String { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + self.capacity() + } +} + +impl DeepSizeOf for bytes::Bytes { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + if context.mark_seen(self.as_ptr() as usize) { + self.len() + } else { + 0 + } + } +} + +impl DeepSizeOf for AtomicU64 { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + 0 + } +} + +impl DeepSizeOf for AtomicUsize { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + 0 + } +} + +impl DeepSizeOf for [T; N] { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.iter() + .map(|item| item.deep_size_of_children(context)) + .sum() + } +} + +impl DeepSizeOf for [T] { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + // The slice's own element bytes are accounted for by the owner (e.g. the + // `size_of_val` in the `Arc`/`Box` impls); here we only sum the heap + // children of each element. + self.iter() + .map(|item| item.deep_size_of_children(context)) + .sum() + } +} + +impl DeepSizeOf for RwLock { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.read() + .map(|val| val.deep_size_of_children(context)) + .unwrap_or(0) + } +} + +impl DeepSizeOf for Mutex { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.lock() + .map(|val| val.deep_size_of_children(context)) + .unwrap_or(0) + } +} + +// Tuples +macro_rules! impl_deep_size_tuple { + ($($name:ident),+) => { + impl<$($name: DeepSizeOf),+> DeepSizeOf for ($($name,)+) { + #[allow(non_snake_case)] + fn deep_size_of_children(&self, context: &mut Context) -> usize { + let ($($name,)+) = self; + 0 $(+ $name.deep_size_of_children(context))+ + } + } + }; +} + +impl_deep_size_tuple!(A, B); +impl_deep_size_tuple!(A, B, C); +impl_deep_size_tuple!(A, B, C, D); +impl_deep_size_tuple!(A, B, C, D, E); +impl_deep_size_tuple!(A, B, C, D, E, F); + +impl DeepSizeOf for Vec { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.capacity() * size_of::() + + self + .iter() + .map(|item| item.deep_size_of_children(context)) + .sum::() + } +} + +impl DeepSizeOf for Box { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + size_of_val(&**self) + (**self).deep_size_of_children(context) + } +} + +impl DeepSizeOf for Arc { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + if context.mark_seen(Self::as_ptr(self) as *const () as usize) { + size_of_val(&**self) + (**self).deep_size_of_children(context) + } else { + 0 + } + } +} + +impl DeepSizeOf for Option { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + match self { + Some(val) => val.deep_size_of_children(context), + None => 0, + } + } +} + +impl DeepSizeOf for HashMap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + // Each bucket holds a key-value pair plus hash metadata (~1 byte control per bucket). + // Robin hood / Swiss table capacity is always a power of 2. + let capacity_bytes = self.capacity() * (size_of::() + size_of::() + 1); + let children: usize = self + .iter() + .map(|(k, v)| k.deep_size_of_children(context) + v.deep_size_of_children(context)) + .sum(); + capacity_bytes + children + } +} + +impl DeepSizeOf for HashSet { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + let capacity_bytes = self.capacity() * (size_of::() + 1); + let children: usize = self.iter().map(|k| k.deep_size_of_children(context)).sum(); + capacity_bytes + children + } +} + +impl DeepSizeOf for BTreeMap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + // BTreeMap nodes have ~11 entries each. Rough estimate: per-entry overhead ~3 pointers. + let per_entry = size_of::() + size_of::() + 3 * size_of::(); + let overhead = self.len() * per_entry; + let children: usize = self + .iter() + .map(|(k, v)| k.deep_size_of_children(context) + v.deep_size_of_children(context)) + .sum(); + overhead + children + } +} + +impl DeepSizeOf for BTreeSet { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + let per_entry = size_of::() + 3 * size_of::(); + let overhead = self.len() * per_entry; + let children: usize = self.iter().map(|k| k.deep_size_of_children(context)).sum(); + overhead + children + } +} + +// Arrow types + +fn record_array_data(context: &mut Context, data: &ArrayData) -> usize { + let mut total = 0; + for buffer in data.buffers() { + if context.mark_seen(buffer.as_ptr() as usize) { + total += buffer.capacity(); + } + } + if let Some(nulls) = data.nulls() { + let null_buf = nulls.inner().inner(); + if context.mark_seen(null_buf.as_ptr() as usize) { + total += null_buf.capacity(); + } + } + for child in data.child_data() { + total += record_array_data(context, child); + } + total +} + +impl DeepSizeOf for dyn Array { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + // `to_data()` only clones Arc refs (no data copy) and allocates a small + // ArrayData metadata struct. This lets us walk buffer pointers for dedup. + // Cost is O(number_of_buffers), not O(data_size). + let data = self.to_data(); + record_array_data(context, &data) + } +} + +impl DeepSizeOf for RecordBatch { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.columns() + .iter() + .map(|col| col.deep_size_of_children(context)) + .sum() + } +} + +impl DeepSizeOf for arrow_buffer::ScalarBuffer +where + T: ArrowNativeType, +{ + fn deep_size_of_children(&self, context: &mut Context) -> usize { + // Track the underlying buffer pointer to avoid double-counting shared allocations. + // Use capacity() rather than len() * size_of::() because sliced buffers retain + // their full original allocation. + let buf = self.inner(); + if context.mark_seen(buf.as_ptr() as usize) { + buf.capacity() + } else { + 0 + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use arrow_array::{Int32Array, StringArray, StructArray}; + use arrow_schema::{DataType, Field, Fields, Schema}; + + #[test] + fn test_basic_record_batch() { + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(); + + let size = batch.deep_size_of(); + // Should at least include the buffer for 3 i32s + assert!(size >= 3 * size_of::()); + } + + #[test] + fn test_same_batch_dedup() { + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5]))], + ) + .unwrap(); + + let mut ctx = Context::new(); + let size_a = batch.deep_size_of_children(&mut ctx); + let size_b = batch.deep_size_of_children(&mut ctx); + + // First measurement should report buffer sizes + assert!(size_a > 0); + // Second measurement of the same batch should add nothing (buffers already seen) + assert_eq!(size_b, 0); + } + + #[test] + fn test_arc_dedup() { + let batch = Arc::new( + RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![Arc::new(Int32Array::from(vec![1, 2, 3]))], + ) + .unwrap(), + ); + let clone = Arc::clone(&batch); + + let mut ctx = Context::new(); + let size_a = batch.deep_size_of_children(&mut ctx); + let size_b = clone.deep_size_of_children(&mut ctx); + + assert!(size_a > 0); + assert_eq!(size_b, 0); + } + + #[test] + fn test_multi_column_shared_array() { + // Two columns pointing to the same Arc + let array: Arc = Arc::new(Int32Array::from(vec![10, 20, 30])); + let schema = Arc::new(Schema::new(vec![ + Field::new("a", DataType::Int32, false), + Field::new("b", DataType::Int32, false), + ])); + + // Single-column batch for reference + let one_col = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])), + vec![array.clone()], + ) + .unwrap(); + + // Two-column batch with the same Arc shared + let two_col = RecordBatch::try_new(schema, vec![array.clone(), array]).unwrap(); + + let mut ctx1 = Context::new(); + let size_one = one_col.deep_size_of_children(&mut ctx1); + + let mut ctx2 = Context::new(); + let size_two = two_col.deep_size_of_children(&mut ctx2); + + // Both should report the same size since the second column's Arc is + // already seen and contributes nothing + assert_eq!(size_one, size_two); + } + + #[test] + fn test_nested_struct_array() { + let int_array = Int32Array::from(vec![1, 2, 3]); + let str_array = StringArray::from(vec!["a", "b", "c"]); + let struct_array = StructArray::from(vec![ + ( + Arc::new(Field::new("x", DataType::Int32, false)), + Arc::new(int_array) as Arc, + ), + ( + Arc::new(Field::new("y", DataType::Utf8, false)), + Arc::new(str_array) as Arc, + ), + ]); + + let batch = RecordBatch::try_new( + Arc::new(Schema::new(vec![Field::new( + "s", + DataType::Struct(Fields::from(vec![ + Field::new("x", DataType::Int32, false), + Field::new("y", DataType::Utf8, false), + ])), + false, + )])), + vec![Arc::new(struct_array)], + ) + .unwrap(); + + let size = batch.deep_size_of(); + // Should include buffers for both child arrays + assert!(size > 3 * size_of::()); + } + + #[test] + fn test_std_types() { + assert_eq!(42u32.deep_size_of(), size_of::()); + + let s = String::from("hello"); + assert!(s.deep_size_of() >= size_of::() + 5); + + let v = vec![1u32, 2, 3]; + assert!(v.deep_size_of() >= size_of::>() + 3 * size_of::()); + + let a = Arc::new(42u32); + let b = Arc::clone(&a); + let mut ctx = Context::new(); + let size_a = a.deep_size_of_children(&mut ctx); + let size_b = b.deep_size_of_children(&mut ctx); + assert_eq!(size_a, size_of::()); + assert_eq!(size_b, 0); + } + + #[test] + fn test_derive_macro() { + use lance_derive::DeepSizeOf; + + #[derive(DeepSizeOf)] + struct Outer { + count: u64, + label: String, + inner: Inner, + } + + #[derive(DeepSizeOf)] + struct Inner { + values: Vec, + } + + let val = Outer { + count: 7, + label: String::from("hello"), + inner: Inner { + values: vec![1, 2, 3], + }, + }; + + let size = val.deep_size_of(); + // Must be at least the stack size + heap allocations for label + values + assert!(size >= std::mem::size_of::() + 5 + 3 * std::mem::size_of::()); + } +} diff --git a/rust/lance-core/src/error.rs b/rust/lance-core/src/error.rs index fe943e03a1d..ba16818a90e 100644 --- a/rust/lance-core/src/error.rs +++ b/rust/lance-core/src/error.rs @@ -1,87 +1,325 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::fmt; + use arrow_schema::ArrowError; -use snafu::{Location, Snafu}; +use snafu::{IntoError as _, Location, Snafu}; type BoxedError = Box; +#[cfg(feature = "backtrace")] +mod backtrace_support { + use std::backtrace::Backtrace; + + use snafu::{AsBacktrace, GenerateImplicitData}; + + #[derive(Debug)] + pub struct MaybeBacktrace(pub Option); + + impl GenerateImplicitData for MaybeBacktrace { + fn generate() -> Self { + Self(>::generate()) + } + } + + impl AsBacktrace for MaybeBacktrace { + fn as_backtrace(&self) -> Option<&Backtrace> { + self.0.as_ref() + } + } +} + +#[cfg(not(feature = "backtrace"))] +mod backtrace_support { + use std::backtrace::Backtrace; + + use snafu::{AsBacktrace, GenerateImplicitData}; + + #[derive(Debug)] + pub struct MaybeBacktrace; + + impl GenerateImplicitData for MaybeBacktrace { + fn generate() -> Self { + Self + } + } + + impl AsBacktrace for MaybeBacktrace { + fn as_backtrace(&self) -> Option<&Backtrace> { + None + } + } +} + +use backtrace_support::MaybeBacktrace; + +/// Error for when a requested field is not found in a schema. +/// +/// This error computes suggestions lazily (only when displayed) to avoid +/// computing Levenshtein distance when the error is created but never shown. +#[derive(Debug)] +pub struct FieldNotFoundError { + pub field_name: String, + pub candidates: Vec, +} + +impl fmt::Display for FieldNotFoundError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Field '{}' not found.", self.field_name)?; + let suggestion = + crate::levenshtein::find_best_suggestion(&self.field_name, &self.candidates); + if let Some(suggestion) = suggestion { + write!(f, " Did you mean '{}'?", suggestion)?; + } + write!(f, "\nAvailable fields: [")?; + for (i, candidate) in self.candidates.iter().take(10).enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "'{}'", candidate)?; + } + if self.candidates.len() > 10 { + let remaining = self.candidates.len() - 10; + write!(f, ", ... and {} more]", remaining)?; + } else { + write!(f, "]")?; + } + Ok(()) + } +} + +impl std::error::Error for FieldNotFoundError {} + +/// A manifest commit returned an error and its final outcome could not be +/// determined safely. +/// +/// This is wrapped in [`Error::Wrapped`] so Lance can expose a structured +/// source without adding a variant to the exhaustive public [`Error`] enum. +#[derive(Debug)] +pub struct CommitStatusUnknownError { + version: u64, + source: BoxedError, +} + +impl CommitStatusUnknownError { + /// Return the manifest version whose commit outcome is unknown. + pub fn version(&self) -> u64 { + self.version + } +} + +impl std::fmt::Display for CommitStatusUnknownError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "Commit result for version {} is unknown: the commit may or may not have been \ + applied; check the table state before retrying: {}", + self.version, self.source + ) + } +} + +impl std::error::Error for CommitStatusUnknownError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(self.source.as_ref()) + } +} + /// Allocates error on the heap and then places `e` into it. #[inline] pub fn box_error(e: impl std::error::Error + Send + Sync + 'static) -> BoxedError { Box::new(e) } +/// Why a writer is fenced. Both reasons are terminal, but callers must tell them +/// apart (a peer takeover vs. our own failure) rather than parse the message. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum FenceReason { + /// A successor writer claimed a higher epoch; this writer lost ownership. + PeerClaimedEpoch, + /// Our own WAL persistence failed, so in-memory state may have diverged from + /// the durable WAL. The writer must be reopened to replay. + PersistenceFailure, +} + +impl std::fmt::Display for FenceReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Stable strings — surfaced in error messages. + let s = match self { + Self::PeerClaimedEpoch => "peer claimed epoch", + Self::PersistenceFailure => "persistence failure", + }; + f.write_str(s) + } +} + #[derive(Debug, Snafu)] #[snafu(visibility(pub))] pub enum Error { #[snafu(display("Invalid user input: {source}, {location}"))] InvalidInput { source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Dataset already exists: {uri}, {location}"))] - DatasetAlreadyExists { uri: String, location: Location }, + DatasetAlreadyExists { + uri: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Append with different schema: {difference}, location: {location}"))] SchemaMismatch { difference: String, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Dataset at path {path} was not found: {source}, {location}"))] DatasetNotFound { path: String, source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Encountered corrupt file {path}: {source}, {location}"))] CorruptFile { path: object_store::path::Path, source: BoxedError, + #[snafu(implicit)] location: Location, - // TODO: add backtrace? + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Not supported: {source}, {location}"))] NotSupported { source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Commit conflict for version {version}: {source}, {location}"))] CommitConflict { version: u64, source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, + #[snafu(display("Incompatible transaction: {source}, {location}"))] + IncompatibleTransaction { + source: BoxedError, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Retryable commit conflict for version {version}: {source}, {location}"))] RetryableCommitConflict { version: u64, source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Too many concurrent writers. {message}, {location}"))] - TooMuchWriteContention { message: String, location: Location }, - #[snafu(display("Encountered internal error. Please file a bug report at https://github.com/lance-format/lance/issues. {message}, {location}"))] - Internal { message: String, location: Location }, + TooMuchWriteContention { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, + #[snafu(display("Operation timed out: {message}, {location}"))] + Timeout { + message: String, + #[snafu(implicit)] + location: Location, + }, + #[snafu(display( + "Encountered internal error. Please file a bug report at https://github.com/lance-format/lance/issues. {message}, {location}" + ))] + Internal { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("A prerequisite task failed: {message}, {location}"))] - PrerequisiteFailed { message: String, location: Location }, + PrerequisiteFailed { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Unprocessable: {message}, {location}"))] - Unprocessable { message: String, location: Location }, + Unprocessable { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("LanceError(Arrow): {message}, {location}"))] - Arrow { message: String, location: Location }, + Arrow { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("LanceError(Schema): {message}, {location}"))] - Schema { message: String, location: Location }, + Schema { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Not found: {uri}, {location}"))] - NotFound { uri: String, location: Location }, + NotFound { + uri: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("LanceError(IO): {source}, {location}"))] IO { source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("LanceError(Index): {message}, {location}"))] - Index { message: String, location: Location }, + Index { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Lance index not found: {identity}, {location}"))] IndexNotFound { identity: String, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Cannot infer storage location from: {message}"))] InvalidTableLocation { message: String }, @@ -89,13 +327,29 @@ pub enum Error { Stop, #[snafu(display("Wrapped error: {error}, {location}"))] Wrapped { + #[snafu(source)] error: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Cloned error: {message}, {location}"))] - Cloned { message: String, location: Location }, + Cloned { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Query Execution error: {message}, {location}"))] - Execution { message: String, location: Location }, + Execution { + message: String, + #[snafu(implicit)] + location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, + }, #[snafu(display("Ref is invalid: {message}"))] InvalidRef { message: String }, #[snafu(display("Ref conflict error: {message}"))] @@ -111,12 +365,18 @@ pub enum Error { message: String, major_version: u16, minor_version: u16, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, #[snafu(display("Namespace error: {source}, {location}"))] Namespace { source: BoxedError, + #[snafu(implicit)] location: Location, + #[snafu(implicit)] + backtrace: MaybeBacktrace, }, /// External error passed through from user code. /// @@ -125,71 +385,393 @@ pub enum Error { /// or inspected using [`Error::external_source`]. #[snafu(transparent)] External { source: BoxedError }, + + /// A requested field was not found in a schema. + #[snafu(transparent)] + FieldNotFound { source: FieldNotFoundError }, + + #[snafu(display( + "Spill disk cap of {cap_bytes} bytes exceeded; currently using {used_bytes} bytes, {location}" + ))] + DiskCapExceeded { + cap_bytes: u64, + used_bytes: u64, + #[snafu(implicit)] + location: Location, + }, + /// A writer has been fenced and must stop (see [`FenceReason`]). The message + /// keeps the `Writer fenced` prefix for legacy string consumers; new code + /// should match on [`Error::fence_reason`]. + #[snafu(display("Writer fenced ({reason}): {message}, {location}"))] + Fenced { + reason: FenceReason, + message: String, + #[snafu(implicit)] + location: Location, + }, + /// A write was refused to keep the writer inside its memory budget. + /// + /// Unlike every other write error this one is *expected* under load and + /// carries no data loss: the write was never accepted, so a caller that + /// retries once the flush pipeline drains loses nothing. Callers should + /// surface it as a retryable "busy" signal (HTTP 503), not a failure. + /// Match via [`Error::is_backpressure`] rather than on the message. + #[snafu(display("Write rejected by backpressure: {message}, {location}"))] + Backpressure { + message: String, + #[snafu(implicit)] + location: Location, + }, } impl Error { - pub fn corrupt_file( - path: object_store::path::Path, - message: impl Into, - location: Location, - ) -> Self { - let message: String = message.into(); - Self::CorruptFile { - path, - source: message.into(), - location, + /// Returns the captured Rust backtrace, if available. + /// + /// Requires the `backtrace` feature to be enabled at compile time + /// and `RUST_BACKTRACE=1` at runtime. + #[cfg(feature = "backtrace")] + pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + match self { + Self::InvalidInput { backtrace, .. } + | Self::DatasetAlreadyExists { backtrace, .. } + | Self::SchemaMismatch { backtrace, .. } + | Self::DatasetNotFound { backtrace, .. } + | Self::CorruptFile { backtrace, .. } + | Self::NotSupported { backtrace, .. } + | Self::CommitConflict { backtrace, .. } + | Self::IncompatibleTransaction { backtrace, .. } + | Self::RetryableCommitConflict { backtrace, .. } + | Self::TooMuchWriteContention { backtrace, .. } + | Self::Internal { backtrace, .. } + | Self::PrerequisiteFailed { backtrace, .. } + | Self::Unprocessable { backtrace, .. } + | Self::Arrow { backtrace, .. } + | Self::Schema { backtrace, .. } + | Self::NotFound { backtrace, .. } + | Self::IO { backtrace, .. } + | Self::Index { backtrace, .. } + | Self::IndexNotFound { backtrace, .. } + | Self::Wrapped { backtrace, .. } + | Self::Cloned { backtrace, .. } + | Self::Execution { backtrace, .. } + | Self::VersionConflict { backtrace, .. } + | Self::Namespace { backtrace, .. } => { + use snafu::AsBacktrace; + backtrace.as_backtrace() + } + // Variants without a backtrace field — listed explicitly so that + // adding a new variant with a backtrace field triggers a compiler error. + Self::InvalidTableLocation { .. } + | Self::Stop + | Self::InvalidRef { .. } + | Self::RefConflict { .. } + | Self::RefNotFound { .. } + | Self::Cleanup { .. } + | Self::VersionNotFound { .. } + | Self::External { .. } + | Self::FieldNotFound { .. } + | Self::Timeout { .. } + | Self::DiskCapExceeded { .. } + | Self::Fenced { .. } + | Self::Backpressure { .. } => None, } } - pub fn invalid_input(message: impl Into, location: Location) -> Self { - let message: String = message.into(); - Self::InvalidInput { - source: message.into(), - location, + /// Returns the captured Rust backtrace, if available. + /// + /// Always returns `None` when the `backtrace` feature is not enabled. + #[cfg(not(feature = "backtrace"))] + pub fn backtrace(&self) -> Option<&std::backtrace::Backtrace> { + None + } + + #[track_caller] + pub fn corrupt_file(path: object_store::path::Path, message: impl Into) -> Self { + CorruptFileSnafu { path }.into_error(message.into().into()) + } + + /// Reports a corrupt file when the caller only has a logical/section name + /// rather than the real file path (for example, a decoder that validates an + /// in-memory buffer and does not know where it came from). + /// + /// `name` is carried in the `path` field of the resulting [`Error::CorruptFile`] + /// variant and is NOT a filesystem path; callers that have the real path should + /// use [`Self::corrupt_file`] instead. + #[track_caller] + pub fn corrupt_file_named(name: &str, message: impl Into) -> Self { + Self::corrupt_file(object_store::path::Path::from(name), message) + } + + #[track_caller] + pub fn invalid_input(message: impl Into) -> Self { + InvalidInputSnafu.into_error(message.into().into()) + } + + #[track_caller] + pub fn invalid_input_source(source: BoxedError) -> Self { + InvalidInputSnafu.into_error(source) + } + + #[track_caller] + pub fn io(message: impl Into) -> Self { + IOSnafu.into_error(message.into().into()) + } + + /// A successor writer claimed a higher epoch; this writer lost ownership. + #[track_caller] + pub fn fenced_by_peer(message: impl Into) -> Self { + FencedSnafu { + reason: FenceReason::PeerClaimedEpoch, + message: message.into(), + } + .build() + } + + /// Our WAL persistence failed; in-memory state may have diverged from the + /// durable WAL, so the writer must be reopened to replay. + #[track_caller] + pub fn writer_poisoned(message: impl Into) -> Self { + FencedSnafu { + reason: FenceReason::PersistenceFailure, + message: message.into(), + } + .build() + } + + /// The [`FenceReason`] if this is [`Error::Fenced`], else `None`. Prefer this + /// over matching the error message to decide how to react to a fence. + pub fn fence_reason(&self) -> Option { + match self { + Self::Fenced { reason, .. } => Some(*reason), + _ => None, } } - pub fn io(message: impl Into, location: Location) -> Self { - let message: String = message.into(); - Self::IO { - source: message.into(), - location, + /// A write was refused because the writer is at its memory ceiling; the + /// data was never accepted. See [`Error::Backpressure`]. + #[track_caller] + pub fn backpressure(message: impl Into) -> Self { + BackpressureSnafu { + message: message.into(), } + .build() + } + + /// Whether this is [`Error::Backpressure`] — i.e. a retryable "writer is + /// full" signal rather than a real failure. Prefer this over matching the + /// error message. + pub fn is_backpressure(&self) -> bool { + matches!(self, Self::Backpressure { .. }) + } + + #[track_caller] + pub fn io_source(source: BoxedError) -> Self { + IOSnafu.into_error(source) + } + + #[track_caller] + pub fn dataset_already_exists(uri: impl Into) -> Self { + DatasetAlreadyExistsSnafu { uri: uri.into() }.build() + } + + #[track_caller] + pub fn dataset_not_found(path: impl Into, source: BoxedError) -> Self { + DatasetNotFoundSnafu { path: path.into() }.into_error(source) } + #[track_caller] pub fn version_conflict( message: impl Into, major_version: u16, minor_version: u16, - location: Location, ) -> Self { - let message: String = message.into(); - Self::VersionConflict { - message, + VersionConflictSnafu { + message: message.into(), major_version, minor_version, - location, } + .build() } + #[track_caller] pub fn not_found(uri: impl Into) -> Self { - Self::NotFound { - uri: uri.into(), - location: std::panic::Location::caller().to_snafu_location(), + NotFoundSnafu { uri: uri.into() }.build() + } + + /// Return whether this error or one of its typed sources is a missing object. + pub fn is_not_found(&self) -> bool { + match self { + Self::NotFound { .. } => true, + Self::Wrapped { error, .. } + if error.downcast_ref::().is_some() => + { + false + } + Self::IO { source, .. } | Self::Wrapped { error: source, .. } => { + error_source_is_not_found(source.as_ref()) + } + _ => false, + } + } + + #[track_caller] + pub fn wrapped(error: BoxedError) -> Self { + WrappedSnafu.into_error(error) + } + + #[track_caller] + pub fn schema(message: impl Into) -> Self { + SchemaSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn not_supported(message: impl Into) -> Self { + NotSupportedSnafu.into_error(message.into().into()) + } + + #[track_caller] + pub fn not_supported_source(source: BoxedError) -> Self { + NotSupportedSnafu.into_error(source) + } + + #[track_caller] + pub fn internal(message: impl Into) -> Self { + InternalSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn timeout(message: impl Into) -> Self { + TimeoutSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn namespace(message: impl Into) -> Self { + NamespaceSnafu.into_error(message.into().into()) + } + + #[track_caller] + pub fn namespace_source(source: Box) -> Self { + NamespaceSnafu.into_error(source) + } + + #[track_caller] + pub fn arrow(message: impl Into) -> Self { + ArrowSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn execution(message: impl Into) -> Self { + ExecutionSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn cloned(message: impl Into) -> Self { + ClonedSnafu { + message: message.into(), } + .build() } - pub fn schema(message: impl Into, location: Location) -> Self { - let message: String = message.into(); - Self::Schema { message, location } + #[track_caller] + pub fn schema_mismatch(difference: impl Into) -> Self { + SchemaMismatchSnafu { + difference: difference.into(), + } + .build() } - pub fn not_supported(message: impl Into, location: Location) -> Self { - let message: String = message.into(); - Self::NotSupported { - source: message.into(), - location, + #[track_caller] + pub fn unprocessable(message: impl Into) -> Self { + UnprocessableSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn too_much_write_contention(message: impl Into) -> Self { + TooMuchWriteContentionSnafu { + message: message.into(), } + .build() + } + + #[track_caller] + pub fn prerequisite_failed(message: impl Into) -> Self { + PrerequisiteFailedSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn index(message: impl Into) -> Self { + IndexSnafu { + message: message.into(), + } + .build() + } + + #[track_caller] + pub fn index_not_found(identity: impl Into) -> Self { + IndexNotFoundSnafu { + identity: identity.into(), + } + .build() + } + + #[track_caller] + pub fn commit_conflict_source(version: u64, source: BoxedError) -> Self { + CommitConflictSnafu { version }.into_error(source) + } + + #[track_caller] + pub fn retryable_commit_conflict_source(version: u64, source: BoxedError) -> Self { + RetryableCommitConflictSnafu { version }.into_error(source) + } + + #[track_caller] + pub fn commit_status_unknown_source(version: u64, source: BoxedError) -> Self { + Self::wrapped(box_error(CommitStatusUnknownError { version, source })) + } + + /// Return whether this error represents a commit whose final outcome could + /// not be determined safely. + pub fn is_commit_status_unknown(&self) -> bool { + matches!( + self, + Self::Wrapped { error, .. } + if error.downcast_ref::().is_some() + ) + } + + #[track_caller] + pub fn incompatible_transaction_source(source: BoxedError) -> Self { + IncompatibleTransactionSnafu.into_error(source) + } + + #[track_caller] + pub fn disk_cap_exceeded(cap_bytes: u64, used_bytes: u64) -> Self { + DiskCapExceededSnafu { + cap_bytes, + used_bytes, + } + .build() } /// Create an External error from a boxed error source. @@ -197,6 +779,16 @@ impl Error { Self::External { source } } + /// Create a FieldNotFound error with the given field name and available candidates. + pub fn field_not_found(field_name: impl Into, candidates: Vec) -> Self { + Self::FieldNotFound { + source: FieldNotFoundError { + field_name: field_name.into(), + candidates, + }, + } + } + /// Returns a reference to the external error source if this is an `External` variant. /// /// This allows downcasting to recover the original error type. @@ -218,6 +810,17 @@ impl Error { } } +fn error_source_is_not_found(source: &(dyn std::error::Error + 'static)) -> bool { + if let Some(error) = source.downcast_ref::() { + return error.is_not_found(); + } + if let Some(error) = source.downcast_ref::() { + return matches!(error, object_store::Error::NotFound { .. }) + || std::error::Error::source(error).is_some_and(error_source_is_not_found); + } + source.source().is_some_and(error_source_is_not_found) +} + pub trait LanceOptionExt { /// Unwraps an option, returning an internal error if the option is None. /// @@ -228,21 +831,7 @@ pub trait LanceOptionExt { impl LanceOptionExt for Option { #[track_caller] fn expect_ok(self) -> Result { - let location = std::panic::Location::caller().to_snafu_location(); - self.ok_or_else(|| Error::Internal { - message: "Expected option to have value".to_string(), - location, - }) - } -} - -pub trait ToSnafuLocation { - fn to_snafu_location(&'static self) -> snafu::Location; -} - -impl ToSnafuLocation for std::panic::Location<'static> { - fn to_snafu_location(&'static self) -> snafu::Location { - snafu::Location::new(self.file(), self.line(), self.column()) + self.ok_or_else(|| Error::internal("Expected option to have value")) } } @@ -262,10 +851,7 @@ impl From for Error { Err(source) => Self::External { source }, } } - other => Self::Arrow { - message: other.to_string(), - location: std::panic::Location::caller().to_snafu_location(), - }, + other => Self::arrow(other.to_string()), } } } @@ -273,29 +859,35 @@ impl From for Error { impl From<&ArrowError> for Error { #[track_caller] fn from(e: &ArrowError) -> Self { - Self::Arrow { - message: e.to_string(), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::arrow(e.to_string()) } } impl From for Error { #[track_caller] fn from(e: std::io::Error) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), + // A lance `Error` may have been wrapped in an `io::Error` (e.g. via + // `io::Error::other(Error::...)`) to cross an `AsyncWrite`/`AsyncRead` + // boundary. Recover it so typed errors such as `DiskCapExceeded` + // survive the round-trip instead of collapsing into an opaque `IO`. + if e.get_ref().is_some_and(|inner| inner.is::()) { + return *e + .into_inner() + .expect("checked Some above") + .downcast::() + .expect("checked type above"); } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: object_store::Error) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), + match e { + // source intentionally dropped; Error::NotFound carries only the path + object_store::Error::NotFound { path, .. } => Self::not_found(path), + other => Self::io_source(box_error(other)), } } } @@ -303,70 +895,49 @@ impl From for Error { impl From for Error { #[track_caller] fn from(e: prost::DecodeError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: prost::EncodeError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: prost::UnknownEnumValue) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: tokio::task::JoinError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: object_store::path::Error) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: url::ParseError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } impl From for Error { #[track_caller] fn from(e: serde_json::Error) -> Self { - Self::Arrow { - message: e.to_string(), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::arrow(e.to_string()) } } @@ -387,10 +958,7 @@ impl From for ArrowError { impl From for Error { #[track_caller] fn from(e: datafusion_sql::sqlparser::parser::ParserError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } @@ -398,10 +966,7 @@ impl From for Error { impl From for Error { #[track_caller] fn from(e: datafusion_sql::sqlparser::tokenizer::TokenizerError) -> Self { - Self::IO { - source: box_error(e), - location: std::panic::Location::caller().to_snafu_location(), - } + Self::io_source(box_error(e)) } } @@ -417,27 +982,50 @@ impl From for datafusion_common::DataFusionError { impl From for Error { #[track_caller] fn from(e: datafusion_common::DataFusionError) -> Self { - let location = std::panic::Location::caller().to_snafu_location(); match e { + // DataFusion wraps an error to attach end-user context and source + // spans (`Diagnostic`), a description of what was running + // (`Context`), or to report several failures at once + // (`Collection`). All three are display-transparent, so the + // category has to come from the error underneath; classifying the + // wrapper itself reports a malformed query as an internal failure. + datafusion_common::DataFusionError::Diagnostic(_, inner) + | datafusion_common::DataFusionError::Context(_, inner) => Self::from(*inner), + datafusion_common::DataFusionError::Collection(errors) => { + match errors.into_iter().next() { + // `Collection` reports the first error's message, so take + // its category too. + Some(first) => Self::from(first), + None => Self::execution("DataFusion returned an empty error collection"), + } + } datafusion_common::DataFusionError::SQL(..) | datafusion_common::DataFusionError::Plan(..) - | datafusion_common::DataFusionError::Configuration(..) => Self::InvalidInput { - source: box_error(e), - location, - }, - datafusion_common::DataFusionError::SchemaError(..) => Self::Schema { - message: e.to_string(), - location, - }, + | datafusion_common::DataFusionError::Configuration(..) + | datafusion_common::DataFusionError::SchemaError(..) => { + Self::invalid_input_source(box_error(e)) + } datafusion_common::DataFusionError::ArrowError(arrow_err, _) => Self::from(*arrow_err), - datafusion_common::DataFusionError::NotImplemented(..) => Self::NotSupported { - source: box_error(e), - location, - }, - datafusion_common::DataFusionError::Execution(..) => Self::Execution { - message: e.to_string(), - location, - }, + datafusion_common::DataFusionError::NotImplemented(..) => { + Self::not_supported_source(box_error(e)) + } + datafusion_common::DataFusionError::Execution(..) => Self::execution(e.to_string()), + datafusion_common::DataFusionError::Shared(shared) => { + // DataFusion shares an error across consumers (e.g. a join's + // build-side error fanned out to every probe partition) behind an + // `Arc`. If we are the sole owner we can recurse for full fidelity; + // otherwise re-wrap in `Shared` so the concrete error type is still + // reachable via `Error::source` / `downcast_ref`. + match std::sync::Arc::try_unwrap(shared) { + Ok(inner) => Self::from(inner), + Err(shared) => { + let rewrapped = datafusion_common::DataFusionError::Shared(shared); + Self::External { + source: box_error(rewrapped), + } + } + } + } datafusion_common::DataFusionError::External(source) => { // Try to downcast to lance_core::Error first match source.downcast::() { @@ -445,10 +1033,7 @@ impl From for Error { Err(source) => Self::External { source }, } } - _ => Self::IO { - source: box_error(e), - location, - }, + _ => Self::io_source(box_error(e)), } } } @@ -473,17 +1058,82 @@ pub fn get_caller_location() -> &'static std::panic::Location<'static> { /// Wrap an error in a new error type that implements Clone /// /// This is useful when two threads/streams share a common fallible source -/// The base error will always have the full error. Any cloned results will -/// only have Error::Cloned with the to_string of the base error. +/// The variants whose meaning callers read through an accessor rather than +/// through the message survive the clone: definite not-found errors keep typed +/// source-chain detection (`is_not_found`), fenced errors keep their +/// [`FenceReason`] (`fence_reason`), backpressure stays backpressure +/// (`is_backpressure`), and an unknown commit outcome keeps its version +/// (`is_commit_status_unknown`) with the typed source degraded to its message. +/// Timeout and I/O errors preserve their categories. Everything else becomes +/// `Error::Cloned` carrying the string representation. +/// +/// One gap: [`Error::External`] is not preserved, because its source is not +/// cloneable and rebuilding it from a message would leave `external_source` +/// returning `Some` while the downcast the caller wants fails. After a clone, +/// `external_source` and `into_external` no longer recover the original error. pub struct CloneableError(pub Error); +struct DisplayError(Error); + +impl fmt::Debug for DisplayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(self, f) + } +} + +impl fmt::Display for DisplayError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl std::error::Error for DisplayError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + Some(&self.0) + } +} + impl Clone for CloneableError { #[track_caller] fn clone(&self) -> Self { - Self(Error::Cloned { - message: self.0.to_string(), - location: std::panic::Location::caller().to_snafu_location(), - }) + // Same reasoning for `is_commit_status_unknown`, but its typed source is + // not cloneable, so the rebuild keeps the version and carries the + // source's own message, as the `NotFound` arm does. + if let Error::Wrapped { error, .. } = &self.0 + && let Some(unknown) = error.downcast_ref::() + { + let source = std::error::Error::source(unknown) + .map(|source| source.to_string()) + .unwrap_or_else(|| unknown.to_string()); + return Self(Error::commit_status_unknown_source( + unknown.version(), + box_error(DisplayError(Error::cloned(source))), + )); + } + match &self.0 { + Error::NotFound { uri, .. } => Self(Error::wrapped(Box::new(DisplayError( + Error::not_found(uri.clone()), + )))), + error if error.is_not_found() => Self(Error::wrapped(Box::new(DisplayError( + Error::not_found(error.to_string()), + )))), + Error::Timeout { message, .. } => Self(Error::timeout(message.clone())), + Error::IO { source, .. } => Self(Error::io(source.to_string())), + // Callers react to these via `fence_reason` / `is_backpressure` + // rather than the message; collapsing them into a cloned string + // would silently drop that signal across the clone boundary. + Error::Fenced { + reason, message, .. + } => Self( + FencedSnafu { + reason: *reason, + message: message.clone(), + } + .build(), + ), + Error::Backpressure { message, .. } => Self(Error::backpressure(message.clone())), + error => Self(Error::cloned(error.to_string())), + } } } @@ -499,8 +1149,94 @@ impl From> for CloneableResult { #[cfg(test)] mod test { use super::*; + use std::error::Error as _; use std::fmt; + /// The variants whose meaning lives in an accessor rather than in the + /// message: collapsing them into a cloned string drops the signal. + #[test] + fn cloneable_error_preserves_accessor_contracts() { + // Both reasons, because carrying `reason` through is the arm's whole job. + for (original, expected) in [ + ( + Error::fenced_by_peer("peer claimed epoch 7"), + FenceReason::PeerClaimedEpoch, + ), + ( + Error::writer_poisoned("wal flush failed"), + FenceReason::PersistenceFailure, + ), + ] { + let original = CloneableError(original); + assert_eq!(original.0.fence_reason(), Some(expected)); + assert_eq!(original.clone().0.fence_reason(), Some(expected)); + } + + let original = CloneableError(Error::backpressure("writer at memory ceiling")); + let cloned = original.clone(); + assert!(original.0.is_backpressure()); + assert!(cloned.0.is_backpressure()); + + let original = CloneableError(Error::commit_status_unknown_source( + 7, + box_error(DisplayError(Error::timeout("put timed out"))), + )); + let cloned = original.clone(); + // Cloning the clone must not degrade it further. + let twice = cloned.clone(); + assert!(original.0.is_commit_status_unknown()); + assert!(cloned.0.is_commit_status_unknown()); + assert!(!cloned.0.is_not_found()); + assert!(twice.0.is_commit_status_unknown()); + } + + #[test] + fn cloneable_error_preserves_not_found_contract() { + let original = CloneableError(Error::not_found("metadata.lance")); + let cloned = original.clone(); + let cloned_again = cloned.clone(); + assert!(matches!(original.0, Error::NotFound { .. })); + assert!(cloned.0.is_not_found()); + assert!(cloned_again.0.is_not_found()); + assert!(cloned.0.to_string().to_lowercase().contains("not found")); + assert!( + cloned_again + .0 + .to_string() + .to_lowercase() + .contains("not found") + ); + assert!( + format!("{:?}", cloned.0) + .to_lowercase() + .contains("not found") + ); + assert!(cloned.0.source().is_some_and(|source| source.is::() + || source.source().is_some_and(|source| source.is::()))); + let downstream_error = Error::wrapped(Box::new(Error::io_source(Box::new( + object_store::Error::Generic { + store: "N/A", + source: Box::new(cloned.0), + }, + )))); + assert!(downstream_error.is_not_found()); + assert!( + format!("{downstream_error:?}") + .to_lowercase() + .contains("not found") + ); + + let original = CloneableError(Error::timeout("metadata read timed out")); + let cloned = original.clone(); + assert!(matches!(original.0, Error::Timeout { .. })); + assert!(matches!(cloned.0, Error::Timeout { .. })); + + let original = CloneableError(Error::io("metadata read was denied")); + let cloned = original.clone(); + assert!(matches!(original.0, Error::IO { .. })); + assert!(matches!(cloned.0, Error::IO { .. })); + } + #[test] fn test_caller_location_capture() { let current_fn = get_caller_location(); @@ -516,13 +1252,48 @@ mod test { match f().unwrap_err() { Error::IO { location, .. } => { // +4 is the beginning of object_store::Error::Generic... - assert_eq!(location.line, current_fn.line() + 4, "{}", location) + assert_eq!(location.line(), current_fn.line() + 4, "{}", location) } #[allow(unreachable_patterns)] _ => panic!("expected ObjectStore error"), } } + #[test] + fn test_caller_location_capture_not_found() { + let current_fn = get_caller_location(); + let f: Box Result<()>> = Box::new(|| { + Err(object_store::Error::NotFound { + path: "some/path".to_string(), + source: "not found".into(), + })?; + Ok(()) + }); + match f().unwrap_err() { + Error::NotFound { location, .. } => { + // +2 is the beginning of object_store::Error::NotFound... + assert_eq!(location.line(), current_fn.line() + 2, "{}", location) + } + #[allow(unreachable_patterns)] + other => panic!("expected NotFound, got {:?}", other), + } + } + + #[test] + fn test_object_store_not_found_converts_to_not_found() { + let os_err = object_store::Error::NotFound { + path: "test/path".to_string(), + source: "no such file".into(), + }; + let lance_err: Error = os_err.into(); + match lance_err { + Error::NotFound { uri, .. } => { + assert_eq!(uri, "test/path"); + } + other => panic!("Expected NotFound, got {:?}", other), + } + } + #[derive(Debug)] struct MyCustomError { code: i32, @@ -537,6 +1308,52 @@ mod test { impl std::error::Error for MyCustomError {} + #[test] + fn test_io_error_recovers_wrapped_lance_error() { + // A lance Error wrapped in io::Error::other should round-trip back to + // the original variant rather than collapsing into Error::IO. + let io_err = std::io::Error::other(Error::disk_cap_exceeded(100, 50)); + let recovered: Error = io_err.into(); + match recovered { + Error::DiskCapExceeded { + cap_bytes, + used_bytes, + .. + } => { + assert_eq!(cap_bytes, 100); + assert_eq!(used_bytes, 50); + } + other => panic!("expected DiskCapExceeded, got {other:?}"), + } + } + + #[test] + fn test_io_error_without_lance_error_stays_io() { + // A plain io::Error (no wrapped lance Error) should become Error::IO. + let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "missing"); + let converted: Error = io_err.into(); + assert!(matches!(converted, Error::IO { .. })); + } + + #[test] + fn test_commit_status_unknown_is_structured_without_masking_as_not_found() { + let error = Error::commit_status_unknown_source( + 42, + box_error(Error::not_found("temporarily invisible manifest")), + ); + + assert!(error.is_commit_status_unknown()); + assert!(!error.is_not_found()); + assert!(error.to_string().contains("version 42 is unknown")); + let Error::Wrapped { error, .. } = error else { + panic!("commit-status-unknown must use the semver-compatible wrapper") + }; + let status = error + .downcast_ref::() + .expect("wrapper must retain the typed commit status"); + assert_eq!(status.version(), 42); + } + #[test] fn test_external_error_creation() { let custom_err = MyCustomError { @@ -568,7 +1385,7 @@ mod test { assert_eq!(recovered.code, 123); // Test that non-External variants return None - let io_err = Error::io("test", snafu::Location::new("test", 1, 1)); + let io_err = Error::io("test"); assert!(io_err.external_source().is_none()); } @@ -589,7 +1406,7 @@ mod test { } // Test that non-External variants return Err(self) - let io_err = Error::io("test", snafu::Location::new("test", 1, 1)); + let io_err = Error::io("test"); match io_err.into_external() { Err(Error::IO { .. }) => {} _ => panic!("Expected Err with IO variant"), @@ -632,6 +1449,114 @@ mod test { } } + #[cfg(feature = "datafusion")] + #[test] + fn test_datafusion_schema_error_is_invalid_input() { + // Schema errors from DataFusion (e.g., a filter referencing an unknown + // column) are user-input failures, not internal lance failures. They + // must surface as `Error::InvalidInput` so downstream FFI/Python + // bindings can map them to the right user-facing error code. + use datafusion_common::Column; + + let schema_err = datafusion_common::SchemaError::FieldNotFound { + field: Box::new(Column::from_name("missing_col")), + valid_fields: vec![], + }; + let df_err = + datafusion_common::DataFusionError::SchemaError(Box::new(schema_err), Box::new(None)); + let lance_err: Error = df_err.into(); + + match lance_err { + Error::InvalidInput { .. } => { + assert!( + lance_err.to_string().contains("missing_col"), + "expected the column name to survive in the error message, got: {lance_err}" + ); + } + _ => panic!("Expected InvalidInput variant, got {:?}", lance_err), + } + } + + /// DataFusion wraps errors to attach end-user context (`Diagnostic`), a + /// description of what was running (`Context`), or to report several at + /// once (`Collection`). All three are display-transparent, so a wrapped + /// user error looks exactly like an unwrapped one but would be classified + /// as an internal failure if the conversion matched on the wrapper. + #[cfg(feature = "datafusion")] + #[rstest::rstest] + #[case::diagnostic(|inner| datafusion_common::DataFusionError::Diagnostic( + Box::new(datafusion_common::Diagnostic::new_error("invalid function", None)), + Box::new(inner), + ))] + #[case::context(|inner| datafusion_common::DataFusionError::Context( + "type_coercion".to_string(), + Box::new(inner), + ))] + #[case::collection(|inner| datafusion_common::DataFusionError::Collection(vec![inner]))] + #[case::nested(|inner| datafusion_common::DataFusionError::Diagnostic( + Box::new(datafusion_common::Diagnostic::new_error("invalid function", None)), + Box::new(datafusion_common::DataFusionError::Context( + "type_coercion".to_string(), + Box::new(inner), + )), + ))] + fn test_datafusion_wrapped_plan_error_is_invalid_input( + #[case] wrap: fn(datafusion_common::DataFusionError) -> datafusion_common::DataFusionError, + ) { + let df_err = wrap(datafusion_common::DataFusionError::Plan( + "Invalid function 'no_such_function'".to_string(), + )); + let lance_err = Error::from(df_err); + + assert!( + matches!(lance_err, Error::InvalidInput { .. }), + "expected InvalidInput, got {lance_err:?}" + ); + assert!( + lance_err.to_string().contains("no_such_function"), + "expected the function name to survive, got: {lance_err}" + ); + } + + /// Unwrapping must classify by the inner error rather than assume the + /// wrapper always hides a user error. + #[cfg(feature = "datafusion")] + #[test] + fn test_datafusion_wrapped_internal_error_is_not_invalid_input() { + let df_err = datafusion_common::DataFusionError::Context( + "while running".to_string(), + Box::new(datafusion_common::DataFusionError::Internal( + "invariant violated".to_string(), + )), + ); + + assert!( + matches!(Error::from(df_err), Error::IO { .. }), + "an internal DataFusion failure must not be reported as user input" + ); + } + + /// A Lance error that round-trips through DataFusion keeps its own + /// category even when DataFusion wraps it on the way back. + #[cfg(feature = "datafusion")] + #[test] + fn test_wrapped_external_lance_error_keeps_its_category() { + let df_err = datafusion_common::DataFusionError::Context( + "while scanning".to_string(), + Box::new(datafusion_common::DataFusionError::from(Error::io( + "object store unavailable", + ))), + ); + + match Error::from(df_err) { + Error::IO { source, .. } => assert!( + source.to_string().contains("object store unavailable"), + "expected the original message, got: {source}" + ), + other => panic!("expected the original IO error, got {other:?}"), + } + } + #[cfg(feature = "datafusion")] #[test] fn test_datafusion_external_error_conversion() { @@ -679,10 +1604,7 @@ mod test { /// (e.g., via RecordBatchIterator) before being converted back. #[test] fn test_lance_error_roundtrip_through_arrow() { - let original = Error::invalid_input( - "test validation error", - snafu::Location::new("test.rs", 10, 1), - ); + let original = Error::invalid_input("test validation error"); // Simulate what happens when using ? in an Arrow context let arrow_err: ArrowError = original.into(); @@ -707,10 +1629,7 @@ mod test { #[cfg(feature = "datafusion")] #[test] fn test_lance_error_roundtrip_through_datafusion() { - let original = Error::invalid_input( - "test validation error", - snafu::Location::new("test.rs", 10, 1), - ); + let original = Error::invalid_input("test validation error"); // Simulate what happens when using ? in a DataFusion context let df_err: datafusion_common::DataFusionError = original.into(); @@ -726,4 +1645,104 @@ mod test { _ => panic!("Expected InvalidInput variant, got {:?}", recovered), } } + + /// Test that a typed error survives a multiply-owned `DataFusionError::Shared`. + /// + /// When DataFusion fans one error out to multiple consumers via `Arc`, we + /// cannot move the inner error out. The typed source must still be + /// reachable after conversion to `lance_core::Error`. + #[cfg(feature = "datafusion")] + #[test] + fn test_datafusion_shared_multi_owner_preserves_type() { + let custom_err = MyCustomError { + code: 42, + message: "shared typed error".to_string(), + }; + let marker = datafusion_common::DataFusionError::External(Box::new(custom_err)); + // Put it in an Arc and keep a second owner so try_unwrap fails. + let arc = std::sync::Arc::new(marker); + let _arc2 = arc.clone(); + let shared = datafusion_common::DataFusionError::Shared(arc); + + let lance_err: Error = shared.into(); + + // The concrete error must be discoverable via source chain. + let mut found = false; + let mut src: Option<&dyn std::error::Error> = Some(&lance_err); + while let Some(e) = src { + if e.downcast_ref::().is_some() { + found = true; + break; + } + src = e.source(); + } + assert!( + found, + "MyCustomError not found in source chain: {lance_err:?}" + ); + } + + #[test] + fn test_backtrace_accessor() { + // Verify that backtrace() returns the expected result based on feature state + let err = Error::io("test backtrace"); + let bt = err.backtrace(); + #[cfg(feature = "backtrace")] + { + // With the backtrace feature enabled, whether a backtrace is captured + // depends on the RUST_BACKTRACE env var at runtime. We just verify + // the accessor doesn't panic and returns a valid Option. + let _ = bt; + } + #[cfg(not(feature = "backtrace"))] + { + // Without the backtrace feature, this must always be None. + assert!(bt.is_none()); + } + } + + #[test] + fn test_backtrace_captured_when_feature_enabled() { + // Test that backtrace is actually captured when the feature is on and + // RUST_BACKTRACE=1 is set in the environment before the process starts. + // + // NOTE: std::backtrace::Backtrace caches the RUST_BACKTRACE env check, + // so set_var at runtime does not reliably enable capture. This test + // verifies the accessor works correctly in both cases: + // - If RUST_BACKTRACE=1 was set before the test binary started, we get Some. + // - If not, we get None (even with the feature on), which is expected. + #[cfg(feature = "backtrace")] + { + let err = Error::io("backtrace capture test"); + if std::env::var("RUST_BACKTRACE").is_ok() { + assert!( + err.backtrace().is_some(), + "Expected a backtrace when RUST_BACKTRACE=1 and backtrace feature is enabled" + ); + } + // When RUST_BACKTRACE is not set, backtrace() may return None even + // with the feature enabled — this is correct runtime gating behavior. + } + #[cfg(not(feature = "backtrace"))] + { + let err = Error::io("backtrace capture test"); + assert!(err.backtrace().is_none()); + } + } + + #[test] + fn test_backtrace_returns_none_for_variants_without_location() { + let err = Error::InvalidTableLocation { + message: "test".to_string(), + }; + assert!(err.backtrace().is_none()); + + let err = Error::InvalidRef { + message: "test".to_string(), + }; + assert!(err.backtrace().is_none()); + + let err = Error::Stop; + assert!(err.backtrace().is_none()); + } } diff --git a/rust/lance-core/src/levenshtein.rs b/rust/lance-core/src/levenshtein.rs new file mode 100644 index 00000000000..ebf5d890127 --- /dev/null +++ b/rust/lance-core/src/levenshtein.rs @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +/// Calculate the Levenshtein distance between two strings. +/// +/// The Levenshtein distance is a measure of the number of single-character edits +/// (insertions, deletions, or substitutions) required to change one word into the other. +/// +/// # Examples +/// +/// ``` +/// use lance_core::levenshtein::levenshtein_distance; +/// +/// assert_eq!(levenshtein_distance("kitten", "sitting"), 3); +/// assert_eq!(levenshtein_distance("hello", "hello"), 0); +/// ``` +pub fn levenshtein_distance(s1: &str, s2: &str) -> usize { + let s1_chars: Vec = s1.chars().collect(); + let s2_chars: Vec = s2.chars().collect(); + let m = s1_chars.len(); + let n = s2_chars.len(); + + if m == 0 { + return n; + } + if n == 0 { + return m; + } + + // Use two rows instead of full matrix for space efficiency + let mut prev_row: Vec = (0..=n).collect(); + let mut curr_row: Vec = vec![0; n + 1]; + + for (i, s1_char) in s1_chars.iter().enumerate() { + curr_row[0] = i + 1; + for (j, s2_char) in s2_chars.iter().enumerate() { + let cost = if s1_char == s2_char { 0 } else { 1 }; + curr_row[j + 1] = (prev_row[j + 1] + 1) + .min(curr_row[j] + 1) + .min(prev_row[j] + cost); + } + std::mem::swap(&mut prev_row, &mut curr_row); + } + + prev_row[n] +} + +/// Find the best suggestion from a list of options based on Levenshtein distance. +/// +/// Returns `Some(suggestion)` if there's an option where the Levenshtein distance +/// is at most 1/3 of the length of the input string (integer division). +/// Otherwise returns `None`. +/// +/// # Examples +/// +/// ``` +/// use lance_core::levenshtein::find_best_suggestion; +/// +/// let options = vec!["vector", "id", "name"]; +/// assert_eq!(find_best_suggestion("vacter", &options), Some("vector")); +/// assert_eq!(find_best_suggestion("hello", &options), None); +/// ``` +pub fn find_best_suggestion<'a, 'b>( + input: &'a str, + options: &'b [impl AsRef], +) -> Option<&'b str> { + let input_len = input.chars().count(); + if input_len == 0 { + return None; + } + + let threshold = input_len / 3; + let mut best_option: Option<(&'b str, usize)> = None; + for option in options { + let distance = levenshtein_distance(input, option.as_ref()); + if distance <= threshold { + match &best_option { + None => best_option = Some((option.as_ref(), distance)), + Some((_, best_distance)) => { + if distance < *best_distance { + best_option = Some((option.as_ref(), distance)); + } + } + } + } + } + + best_option.map(|(option, _)| option) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_levenshtein_distance() { + assert_eq!(levenshtein_distance("", ""), 0); + assert_eq!(levenshtein_distance("a", ""), 1); + assert_eq!(levenshtein_distance("", "a"), 1); + assert_eq!(levenshtein_distance("abc", "abc"), 0); + assert_eq!(levenshtein_distance("abc", ""), 3); + assert_eq!(levenshtein_distance("", "abc"), 3); + assert_eq!(levenshtein_distance("kitten", "sitting"), 3); + assert_eq!(levenshtein_distance("saturday", "sunday"), 3); + assert_eq!(levenshtein_distance("vector", "vectr"), 1); + assert_eq!(levenshtein_distance("vector", "vextor"), 1); + assert_eq!(levenshtein_distance("vector", "vvector"), 1); + assert_eq!(levenshtein_distance("abc", "xyz"), 3); + } + + #[test] + fn test_find_best_suggestion() { + let options = vec!["vector", "id", "name", "column", "table"]; + + assert_eq!(find_best_suggestion("vacter", &options), Some("vector")); + assert_eq!(find_best_suggestion("vectr", &options), Some("vector")); + assert_eq!(find_best_suggestion("tble", &options), Some("table")); + + // Should return None if no good match + assert_eq!(find_best_suggestion("hello", &options), None); + assert_eq!(find_best_suggestion("xyz", &options), None); + + // Should return None if input is too short + assert_eq!(find_best_suggestion("v", &options), None); + assert_eq!(find_best_suggestion("", &options), None); + + // Picks closest when multiple are close + assert_eq!( + find_best_suggestion("vecor", &["vector", "vendor"]), + Some("vector") + ); + } +} diff --git a/rust/lance-core/src/lib.rs b/rust/lance-core/src/lib.rs index 8c669eda223..2c93f8772a1 100644 --- a/rust/lance-core/src/lib.rs +++ b/rust/lance-core/src/lib.rs @@ -2,17 +2,22 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors #![cfg_attr(coverage, feature(coverage_attribute))] +// Allow the derive macro to reference `lance_core::deepsize` from within this crate. +extern crate self as lance_core; + use arrow_schema::{DataType, Field as ArrowField}; use std::sync::LazyLock; pub mod cache; pub mod container; pub mod datatypes; +pub mod deepsize; pub mod error; +pub mod levenshtein; pub mod traits; pub mod utils; -pub use error::{box_error, ArrowResult, Error, Result}; +pub use error::{ArrowResult, Error, FenceReason, Result, box_error}; /// Wildcard to indicate all non-system columns pub const WILDCARD: &str = "*"; @@ -46,6 +51,33 @@ pub static ROW_LAST_UPDATED_AT_VERSION_FIELD: LazyLock = pub static ROW_CREATED_AT_VERSION_FIELD: LazyLock = LazyLock::new(|| ArrowField::new(ROW_CREATED_AT_VERSION, DataType::UInt64, true)); +/// Field id of the hidden `_rowid` column a spilled row id sequence lives in. +/// +/// Field ids are `i32` and every negative value is reserved for system use: +/// `-1` is the unassigned sentinel, `-2` the tombstone for a field superseded by +/// a later data file, and `-3..=-5` the three row lineage columns. +pub const ROW_ID_FIELD_ID: i32 = -3; +/// Field id of the hidden `_row_created_at_version` column a spilled created-at +/// version sequence lives in. +pub const ROW_CREATED_AT_VERSION_FIELD_ID: i32 = -4; +/// Field id of the hidden `_row_last_updated_at_version` column a spilled +/// last-updated-at version sequence lives in. +pub const ROW_LAST_UPDATED_AT_VERSION_FIELD_ID: i32 = -5; + +/// The reserved field id of a row lineage column, by column name. +/// +/// These are the only negative field ids a data file schema may carry: a +/// lineage sequence spilled into the fragment's own data file is written as a +/// column under this id, next to the user columns. +pub fn row_lineage_field_id(column_name: &str) -> Option { + match column_name { + ROW_ID => Some(ROW_ID_FIELD_ID), + ROW_CREATED_AT_VERSION => Some(ROW_CREATED_AT_VERSION_FIELD_ID), + ROW_LAST_UPDATED_AT_VERSION => Some(ROW_LAST_UPDATED_AT_VERSION_FIELD_ID), + _ => None, + } +} + /// Check if a column name is a system column. /// /// System columns are virtual columns that are computed at read time and don't @@ -55,6 +87,9 @@ pub static ROW_CREATED_AT_VERSION_FIELD: LazyLock = /// - `_rowoffset`: The row offset /// - `_row_last_updated_at_version`: The version when the row was last updated /// - `_row_created_at_version`: The version when the row was created +/// +/// Write paths must reject a stored column named for one: the scanner injects +/// these itself, so a stored copy collides with the injected one on read. pub fn is_system_column(column_name: &str) -> bool { matches!( column_name, diff --git a/rust/lance-core/src/traits.rs b/rust/lance-core/src/traits.rs index 56905703a86..cc43d7e0d66 100644 --- a/rust/lance-core/src/traits.rs +++ b/rust/lance-core/src/traits.rs @@ -7,7 +7,7 @@ use std::fmt::Debug; use arrow_array::RecordBatch; -use crate::{datatypes::Schema, Result}; +use crate::{Result, datatypes::Schema}; /// `TakeRow` trait. /// diff --git a/rust/lance-core/src/utils.rs b/rust/lance-core/src/utils.rs index 663454e001b..c519fddaf3a 100644 --- a/rust/lance-core/src/utils.rs +++ b/rust/lance-core/src/utils.rs @@ -2,17 +2,22 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors pub mod address; +pub mod aimd; pub mod assume; pub mod backoff; pub mod bit; pub mod blob; +pub mod bloomfilter; pub mod cpu; pub mod deletion; +pub mod fragment_reuse; pub mod futures; pub mod hash; -pub mod mask; +pub mod io_stats; pub mod parse; pub mod path; +pub mod row_addr_remap; +pub mod stable_partition; pub mod tempfile; pub mod testing; pub mod tokio; diff --git a/rust/lance-core/src/utils/aimd.rs b/rust/lance-core/src/utils/aimd.rs new file mode 100644 index 00000000000..13bca4aff91 --- /dev/null +++ b/rust/lance-core/src/utils/aimd.rs @@ -0,0 +1,679 @@ +// 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 the ceiling; must be finite otherwise) +/// - 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<()> { + // Reject NaN and infinity first. The sign and ordering checks below + // compare with `<`/`>`, which are `false` for NaN and for a `+inf` on + // a field with no opposing bound, so a non-finite rate would otherwise + // slip through and silently disable throttling (a NaN rate makes the + // token bucket refill to full on every acquire) or, with a zero burst + // capacity, panic in `Duration::from_secs_f64`. + for (name, value) in [ + ("initial_rate", self.initial_rate), + ("min_rate", self.min_rate), + ("max_rate", self.max_rate), + ("decrease_factor", self.decrease_factor), + ("additive_increment", self.additive_increment), + ("throttle_threshold", self.throttle_threshold), + ] { + if !value.is_finite() { + return Err(crate::Error::invalid_input(format!( + "{name} must be finite, got {value}" + ))); + } + } + 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)" + )] + #[case::nan_initial_rate( + AimdConfig::default().with_initial_rate(f64::NAN), + "initial_rate must be finite" + )] + #[case::inf_initial_rate( + AimdConfig::default().with_initial_rate(f64::INFINITY), + "initial_rate must be finite" + )] + #[case::nan_min_rate( + AimdConfig::default().with_min_rate(f64::NAN), + "min_rate must be finite" + )] + #[case::nan_max_rate( + AimdConfig::default().with_max_rate(f64::NAN), + "max_rate must be finite" + )] + #[case::inf_max_rate( + AimdConfig::default().with_max_rate(f64::INFINITY), + "max_rate must be finite" + )] + #[case::nan_decrease_factor( + AimdConfig::default().with_decrease_factor(f64::NAN), + "decrease_factor must be finite" + )] + #[case::nan_additive_increment( + AimdConfig::default().with_additive_increment(f64::NAN), + "additive_increment must be finite" + )] + #[case::nan_throttle_threshold( + AimdConfig::default().with_throttle_threshold(f64::NAN), + "throttle_threshold must be finite" + )] + fn test_config_validation_rejects_invalid( + #[case] config: AimdConfig, + #[case] expected_msg: &str, + ) { + let err = config.validate().unwrap_err(); + assert!( + matches!(&err, crate::Error::InvalidInput { .. }), + "expected InvalidInput, got: {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-core/src/utils/assume.rs b/rust/lance-core/src/utils/assume.rs index 2560e9bf35e..05f5abb6dc9 100644 --- a/rust/lance-core/src/utils/assume.rs +++ b/rust/lance-core/src/utils/assume.rs @@ -1,29 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -/// A macro that combines debug_assert and std::hint::assert_unchecked for optimized assertions +/// Assert an invariant that should also be visible to the optimizer. /// -/// In debug builds, this will perform a normal assertion check. -/// In release builds, this will use hint::assert_unchecked which tells the compiler to assume -/// the condition is true without actually checking it. -/// -/// # Safety -/// -/// This macro is unsafe in release builds since it uses hint::assert_unchecked. -/// The caller must ensure the condition will always be true. +/// Unlike [`debug_assert!`], this remains checked in release builds. This is +/// required because the macro can be invoked from safe Rust and an invalid +/// assumption must not become undefined behavior. #[macro_export] macro_rules! assume { ($cond:expr) => { - debug_assert!($cond); - // SAFETY: The debug_assert ensures this is true in debug builds. - // In release builds, caller must ensure the condition holds. - unsafe { std::hint::assert_unchecked($cond); } + assert!($cond) }; ($cond:expr, $($arg:tt)+) => { - debug_assert!($cond, $($arg)+); - // SAFETY: The debug_assert ensures this is true in debug builds. - // In release builds, caller must ensure the condition holds. - unsafe { std::hint::assert_unchecked($cond); } + assert!($cond, $($arg)+) }; } @@ -31,11 +20,24 @@ macro_rules! assume { #[macro_export] macro_rules! assume_eq { ($left:expr, $right:expr) => { - debug_assert_eq!($left, $right); - unsafe { std::hint::assert_unchecked($left == $right); } + assert_eq!($left, $right) }; ($left:expr, $right:expr, $($arg:tt)+) => { - debug_assert_eq!($left, $right, $($arg)+); - unsafe { std::hint::assert_unchecked($left == $right); } + assert_eq!($left, $right, $($arg)+) }; } + +#[cfg(test)] +mod tests { + #[test] + fn assume_rejects_false_conditions() { + assert!(std::panic::catch_unwind(|| assume!(false)).is_err()); + assert!(std::panic::catch_unwind(|| assume!(false, "invalid condition")).is_err()); + } + + #[test] + fn assume_eq_rejects_unequal_values() { + assert!(std::panic::catch_unwind(|| assume_eq!(1, 2)).is_err()); + assert!(std::panic::catch_unwind(|| assume_eq!(1, 2, "invalid equality")).is_err()); + } +} diff --git a/rust/lance-core/src/utils/backoff.rs b/rust/lance-core/src/utils/backoff.rs index b30c757bb23..2b1f81e16be 100644 --- a/rust/lance-core/src/utils/backoff.rs +++ b/rust/lance-core/src/utils/backoff.rs @@ -77,6 +77,15 @@ impl Backoff { } } +/// Upper bound on the number of retry slots. +/// +/// Slots double each attempt to spread contending writers apart, but a hundred +/// or so already exceeds any realistic number of concurrent committers, so +/// further doubling only inflates the wait without reducing collisions. Capping +/// the count also bounds a single backoff to `(MAX_SLOTS - 1) * unit` instead of +/// letting it grow without limit as `attempt` climbs. +const MAX_SLOTS: u32 = 128; + /// SlotBackoff is a backoff strategy that randomly chooses a time slot to retry. /// /// This is useful when you have multiple tasks that can't overlap, and each @@ -85,7 +94,7 @@ impl Backoff { /// The `unit` represents the time it takes to complete one attempt. Future attempts /// are divided into time slots, and a random slot is chosen for the retry. The number /// of slots increases exponentially with each attempt. Initially, there are 4 slots, -/// then 8, then 16, and so on. +/// then 8, then 16, and so on, up to a fixed cap. /// /// Example: /// Suppose you have 10 tasks that can't overlap, each taking 1 second. The tasks @@ -138,10 +147,15 @@ impl SlotBackoff { } pub fn next_backoff(&mut self) -> Duration { - let num_slots = self.base.saturating_pow(self.attempt + self.starting_i); + let num_slots = self + .base + .saturating_pow(self.attempt.saturating_add(self.starting_i)) + .min(MAX_SLOTS); let slot_i = self.rng.random_range(0..num_slots); - self.attempt += 1; - Duration::from_millis((slot_i * self.unit) as u64) + self.attempt = self.attempt.saturating_add(1); + // Widen before multiplying: `unit` is the first-attempt latency, which + // can be large enough that a `u32` slot * unit product would overflow. + Duration::from_millis(slot_i as u64 * self.unit as u64) } } @@ -228,4 +242,50 @@ mod tests { assert_eq!(backoff.attempt(), 3); } } + + #[test] + fn test_slot_backoff_high_attempt_is_bounded() { + // Without the slot cap the wait grows unbounded with `attempt`. The cap + // holds every backoff to `(MAX_SLOTS - 1) * unit`. + let unit = 100_000; // 100s first attempt + let mut backoff = SlotBackoff::default().with_unit(unit); + let max_backoff = Duration::from_millis((MAX_SLOTS - 1) as u64 * unit as u64); + for _ in 0..40 { + assert!(backoff.next_backoff() <= max_backoff); + } + assert_eq!(backoff.attempt(), 40); + } + + #[test] + fn test_slot_backoff_large_unit_does_not_overflow() { + // With unit = u32::MAX, any slot >= 2 makes the old u32 `slot_i * unit` + // product overflow: a debug panic, or in release a wrap to a value that + // is no longer a multiple of unit. The u64 widening keeps every backoff + // an exact multiple of unit. Seed the RNG so the drawn slots — and thus + // this check — are deterministic rather than dependent on random draws. + let unit = u32::MAX; + let mut backoff = SlotBackoff::default().with_unit(unit); + backoff.rng = rand::rngs::SmallRng::seed_from_u64(0); + let mut saw_high_slot = false; + for _ in 0..64 { + let backoff_ms = backoff.next_backoff().as_millis(); + // `slot_i * unit` is always a multiple of unit; a wrapped u32 + // product is not. + assert_eq!(backoff_ms % unit as u128, 0, "{backoff_ms} wrapped"); + saw_high_slot |= backoff_ms >= 2 * unit as u128; + } + assert!(saw_high_slot, "expected a slot >= 2 in 64 seeded draws"); + } + + #[test] + fn test_slot_backoff_attempt_saturates() { + // At u32::MAX the counter must stay put rather than panic (debug) or + // wrap to 0 (release), which would restart the low-slot distribution. + let mut backoff = SlotBackoff { + attempt: u32::MAX, + ..Default::default() + }; + let _ = backoff.next_backoff(); + assert_eq!(backoff.attempt(), u32::MAX); + } } 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/blob.rs b/rust/lance-core/src/utils/blob.rs index b51a53895f6..c6701bea0ce 100644 --- a/rust/lance-core/src/utils/blob.rs +++ b/rust/lance-core/src/utils/blob.rs @@ -3,25 +3,98 @@ use object_store::path::Path; +use crate::{Error, Result}; + +/// Validate a Managed descriptor's object-relative path and known byte range. +pub fn validate_managed_reference(uri: &str, position: u64, size: u64) -> Result { + if uri.is_empty() + || uri.starts_with('/') + || uri.ends_with('/') + || uri.contains("://") + || uri.contains('\\') + || uri + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err(Error::invalid_input(format!( + "Managed blob_uri must be a non-empty canonical relative object path, got {uri:?}" + ))); + } + position.checked_add(size).ok_or_else(|| { + Error::invalid_input(format!( + "Managed blob range overflows u64: position={position}, size={size}" + )) + })?; + Path::parse(uri) + .map_err(|error| Error::invalid_input(format!("Invalid Managed blob_uri {uri:?}: {error}"))) +} + /// Format a blob sidecar path for a data file. /// -/// Layout: `//.blob` +/// Layout: `//.blob` /// - `base` is typically the dataset's data directory. /// - `data_file_key` is the stem of the data file (without extension). -/// - `blob_id` is the hex-encoded identifier assigned during write. +/// - `blob_id` is transformed via `reverse_bits()` before binary formatting. pub fn blob_path(base: &Path, data_file_key: &str, blob_id: u32) -> Path { - let file_name = format!("{:08x}.blob", blob_id); - base.child(data_file_key).child(file_name.as_str()) + let file_name = format!("{:032b}.blob", blob_id.reverse_bits()); + base.clone().join(data_file_key).join(file_name.as_str()) } #[cfg(test)] mod tests { use super::*; + use rstest::rstest; + + #[rstest] + #[case("")] + #[case("/data/a.blob")] + #[case("data/../a.blob")] + #[case("data/./a.blob")] + #[case("data//a.blob")] + #[case("data/a.blob/")] + #[case("s3://bucket/a.blob")] + #[case("data\\a.blob")] + fn managed_paths_reject_noncanonical_references(#[case] uri: &str) { + let error = validate_managed_reference(uri, 0, 1).unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("Managed blob_uri")); + } + + #[test] + fn managed_ranges_preserve_empty_values_and_reject_overflow() { + assert!(validate_managed_reference("data/a.blob", u64::MAX, 0).is_ok()); + let error = validate_managed_reference("data/a.blob", u64::MAX, 1).unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("position=18446744073709551615, size=1") + ); + } #[test] fn test_blob_path_formatting() { let base = Path::from("base"); let path = blob_path(&base, "deadbeef", 2); - assert_eq!(path.to_string(), "base/deadbeef/00000002.blob"); + assert_eq!( + path.to_string(), + "base/deadbeef/01000000000000000000000000000000.blob" + ); + } + + #[test] + fn test_blob_path_scattered_prefixes_for_sequential_ids() { + let base = Path::from("base"); + let p1 = blob_path(&base, "deadbeef", 1); + let p2 = blob_path(&base, "deadbeef", 2); + assert_ne!(p1.to_string(), p2.to_string()); + assert_eq!( + p1.to_string(), + "base/deadbeef/10000000000000000000000000000000.blob" + ); + assert_eq!( + p2.to_string(), + "base/deadbeef/01000000000000000000000000000000.blob" + ); } } diff --git a/rust/lance-core/src/utils/bloomfilter.rs b/rust/lance-core/src/utils/bloomfilter.rs new file mode 100644 index 00000000000..46cc272a694 --- /dev/null +++ b/rust/lance-core/src/utils/bloomfilter.rs @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Generic bloom filter primitives. +//! +//! These are storage-agnostic data structures with no Lance semantics, used by +//! higher-level crates (e.g. the bloom filter scalar index in `lance-index`). + +pub mod as_bytes; +pub mod sbbf; diff --git a/rust/lance-index/src/scalar/bloomfilter/as_bytes.rs b/rust/lance-core/src/utils/bloomfilter/as_bytes.rs similarity index 96% rename from rust/lance-index/src/scalar/bloomfilter/as_bytes.rs rename to rust/lance-core/src/utils/bloomfilter/as_bytes.rs index 370880e7b3d..86b9632ce39 100644 --- a/rust/lance-index/src/scalar/bloomfilter/as_bytes.rs +++ b/rust/lance-core/src/utils/bloomfilter/as_bytes.rs @@ -7,7 +7,7 @@ //! similar to parquet::data_type::AsBytes but without the external dependency. /// Trait to convert primitive types to byte slices -/// Reference: https://arrow.apache.org/rust/src/parquet/data_type.rs.html +/// Reference: pub trait AsBytes { /// Convert the value to a byte slice fn as_bytes(&self) -> impl AsRef<[u8]>; @@ -87,11 +87,7 @@ impl AsBytes for [u8] { impl AsBytes for bool { fn as_bytes(&self) -> impl AsRef<[u8]> { - if *self { - [1u8] - } else { - [0u8] - } + if *self { [1u8] } else { [0u8] } } } diff --git a/rust/lance-index/src/scalar/bloomfilter/sbbf.rs b/rust/lance-core/src/utils/bloomfilter/sbbf.rs similarity index 98% rename from rust/lance-index/src/scalar/bloomfilter/sbbf.rs rename to rust/lance-core/src/utils/bloomfilter/sbbf.rs index 50574768050..0d81326712e 100644 --- a/rust/lance-index/src/scalar/bloomfilter/sbbf.rs +++ b/rust/lance-core/src/utils/bloomfilter/sbbf.rs @@ -28,7 +28,7 @@ //! removed from Lance. //! -use crate::scalar::bloomfilter::as_bytes::AsBytes; +use super::as_bytes::AsBytes; use libm::lgamma; use std::error::Error; use std::fmt; @@ -253,11 +253,13 @@ impl Sbbf { } let data = bitset - .chunks_exact(4 * 8) + .as_chunks::<{ 4 * 8 }>() + .0 + .iter() .map(|chunk| { let mut block = Block::ZERO; - for (i, word) in chunk.chunks_exact(4).enumerate() { - block[i] = u32::from_le_bytes(word.try_into().unwrap()); + for (i, word) in chunk.as_chunks::<4>().0.iter().enumerate() { + block[i] = u32::from_le_bytes(*word); } block }) @@ -316,7 +318,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 +337,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 +454,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-core/src/utils/cpu.rs b/rust/lance-core/src/utils/cpu.rs index 4e7ab01871d..d1e8a498043 100644 --- a/rust/lance-core/src/utils/cpu.rs +++ b/rust/lance-core/src/utils/cpu.rs @@ -1,14 +1,32 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +use std::fmt; use std::sync::LazyLock; -/// A level of SIMD support for some feature +/// A level of SIMD support for some feature. +/// +/// `#[non_exhaustive]` so future tiers (e.g. AVX-512 BF16, AMX) can be added +/// without breaking external `match` consumers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] pub enum SimdSupport { None, Neon, Sse, + /// AVX (256-bit float ops) without FMA. + /// + /// This tier does not imply that AVX2 is absent: selecting [`Self::Avx2`] + /// requires both AVX2 and FMA, so a host with AVX2 but no FMA selects this + /// tier. Intel Sandy Bridge / Ivy Bridge are the typical hosts. + Avx, + /// AVX + FMA but no AVX2. + /// AMD Piledriver / Steamroller / FX-7500. + AvxFma, + /// AVX2 + FMA. Intel Haswell / AMD Excavator and later. + /// + /// Selecting this tier asserts FMA is present: the kernels it dispatches to + /// are `#[target_feature(enable = "avx,fma")]`. Avx2, Avx512, Avx512FP16, @@ -16,6 +34,132 @@ pub enum SimdSupport { Lasx, } +impl fmt::Display for SimdSupport { + /// Formats the tier name in lowercase, matching pyarrow's + /// `runtime_info().simd_level` convention. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let name = match self { + Self::None => "none", + Self::Neon => "neon", + Self::Sse => "sse", + Self::Avx => "avx", + Self::AvxFma => "avx_fma", + Self::Avx2 => "avx2", + Self::Avx512 => "avx512", + Self::Avx512FP16 => "avx512_fp16", + Self::Lsx => "lsx", + Self::Lasx => "lasx", + }; + f.write_str(name) + } +} + +/// Snapshot of the SIMD tier lance dispatches to on the current host, plus the +/// raw CPU features detected for diagnostic purposes. +/// +/// Mirrors the role of `pyarrow.runtime_info()`: a single, cheap call users can +/// make to verify which SIMD tier the runtime selected and what underlying +/// features the host advertises. Obtain one with [`simd_info()`]. +#[derive(Debug, Clone)] +pub struct SimdInfo { + /// The SIMD tier lance dispatches to at runtime on this host. + pub tier: SimdSupport, + /// The architecture name (e.g. "x86_64", "aarch64", "loongarch64"). + pub target_arch: &'static str, + /// Raw CPU feature flags detected on this host (x86_64 only; empty on + /// other architectures). Each entry is a feature name like "avx2", + /// "fma", "avx512f", "popcnt", etc. + pub host_features: Vec<&'static str>, +} + +/// Returns a snapshot of the SIMD tier lance is using on this host along with +/// the raw CPU feature flags that drove the decision. +/// +/// Useful for performance debugging and giving users a way to verify which +/// dispatch tier they are hitting without rebuilding lance. See [`SimdInfo`] +/// for the meaning of each field and [`SimdSupport`] for the tier values. +/// +/// # Examples +/// +/// ``` +/// use lance_core::utils::cpu::simd_info; +/// +/// let info = simd_info(); +/// println!("dispatching to {} on {}", info.tier, info.target_arch); +/// ``` +pub fn simd_info() -> SimdInfo { + SimdInfo { + tier: *SIMD_SUPPORT, + target_arch: std::env::consts::ARCH, + host_features: detect_host_features(), + } +} + +#[cfg(target_arch = "x86_64")] +fn detect_host_features() -> Vec<&'static str> { + // Each call must be inline: `is_x86_feature_detected!` does its own custom + // input parsing and rejects feature names received via a `macro_rules!` + // `:literal` metavariable on some toolchains. + let mut features = Vec::with_capacity(17); + if is_x86_feature_detected!("sse2") { + features.push("sse2"); + } + if is_x86_feature_detected!("sse3") { + features.push("sse3"); + } + if is_x86_feature_detected!("ssse3") { + features.push("ssse3"); + } + if is_x86_feature_detected!("sse4.1") { + features.push("sse4.1"); + } + if is_x86_feature_detected!("sse4.2") { + features.push("sse4.2"); + } + if is_x86_feature_detected!("popcnt") { + features.push("popcnt"); + } + if is_x86_feature_detected!("avx") { + features.push("avx"); + } + if is_x86_feature_detected!("avx2") { + features.push("avx2"); + } + if is_x86_feature_detected!("fma") { + features.push("fma"); + } + if is_x86_feature_detected!("f16c") { + features.push("f16c"); + } + if is_x86_feature_detected!("bmi1") { + features.push("bmi1"); + } + if is_x86_feature_detected!("bmi2") { + features.push("bmi2"); + } + if is_x86_feature_detected!("avx512f") { + features.push("avx512f"); + } + if is_x86_feature_detected!("avx512bw") { + features.push("avx512bw"); + } + if is_x86_feature_detected!("avx512cd") { + features.push("avx512cd"); + } + if is_x86_feature_detected!("avx512dq") { + features.push("avx512dq"); + } + if is_x86_feature_detected!("avx512vl") { + features.push("avx512vl"); + } + features +} + +#[cfg(not(target_arch = "x86_64"))] +fn detect_host_features() -> Vec<&'static str> { + Vec::new() +} + /// Support for SIMD operations pub static SIMD_SUPPORT: LazyLock = LazyLock::new(|| { #[cfg(all(target_arch = "aarch64", any(target_os = "ios", target_os = "tvos")))] @@ -42,8 +186,20 @@ pub static SIMD_SUPPORT: LazyLock = LazyLock::new(|| { } else { SimdSupport::Avx512 } - } else if is_x86_feature_detected!("avx2") { + } else if is_x86_feature_detected!("avx2") && is_x86_feature_detected!("fma") { + // FMA is checked explicitly: every kernel selected for this tier is + // `#[target_feature(enable = "avx,fma")]`, and AVX2 does not imply + // FMA in the ISA. Every shipping AVX2 part has FMA, so this only + // guards against a host that would otherwise take an FMA kernel + // without FMA. SimdSupport::Avx2 + } else if is_x86_feature_detected!("avx") && is_x86_feature_detected!("fma") { + // AMD Piledriver / Steamroller / FX-7500: 256-bit float ops + FMA but no AVX2. + SimdSupport::AvxFma + } else if is_x86_feature_detected!("avx") { + // This includes a possible AVX2 host without FMA because the Avx2 + // tier above requires both features. + SimdSupport::Avx } else { SimdSupport::None } @@ -58,6 +214,14 @@ pub static SIMD_SUPPORT: LazyLock = LazyLock::new(|| { SimdSupport::None } } + #[cfg(not(any( + target_arch = "aarch64", + target_arch = "x86_64", + target_arch = "loongarch64" + )))] + { + SimdSupport::None + } }); #[cfg(target_arch = "x86_64")] @@ -138,3 +302,68 @@ mod aarch64 { false } } + +#[cfg(test)] +mod tests { + use super::*; + use rstest::rstest; + + #[test] + fn simd_info_exposes_tier() { + let info = simd_info(); + assert_eq!(info.target_arch, std::env::consts::ARCH); + // Tier should match the detected SIMD support. + assert_eq!(info.tier, *SIMD_SUPPORT); + } + + #[cfg(target_arch = "x86_64")] + #[test] + fn simd_info_features_include_baseline() { + let info = simd_info(); + // The x86_64 ABI mandates SSE2, so it must always be present on this + // architecture. + assert!(info.host_features.contains(&"sse2")); + } + + #[cfg(not(target_arch = "x86_64"))] + #[test] + fn simd_info_features_empty_off_x86_64() { + let info = simd_info(); + assert!(info.host_features.is_empty()); + } + + /// The `Avx2` and `AvxFma` tiers both dispatch to kernels declared + /// `#[target_feature(enable = "avx,fma")]`, so neither may be selected on a + /// host without FMA. AVX2 does not imply FMA in the ISA, so the detection + /// checks it explicitly. (`Avx512*` is excluded: its kernels declare + /// `avx512f`, which is what `has_avx512` verifies.) + #[cfg(target_arch = "x86_64")] + #[test] + fn avx_fma_tiers_are_only_selected_when_fma_is_detected() { + if matches!(*SIMD_SUPPORT, SimdSupport::Avx2 | SimdSupport::AvxFma) { + assert!( + is_x86_feature_detected!("fma"), + "tier {} dispatches to avx,fma kernels but the host has no FMA", + *SIMD_SUPPORT + ); + } + } + + #[rstest] + #[case::none(SimdSupport::None, "none")] + #[case::neon(SimdSupport::Neon, "neon")] + #[case::sse(SimdSupport::Sse, "sse")] + #[case::avx(SimdSupport::Avx, "avx")] + #[case::avx_fma(SimdSupport::AvxFma, "avx_fma")] + #[case::avx2(SimdSupport::Avx2, "avx2")] + #[case::avx512(SimdSupport::Avx512, "avx512")] + #[case::avx512_fp16(SimdSupport::Avx512FP16, "avx512_fp16")] + #[case::lsx(SimdSupport::Lsx, "lsx")] + #[case::lasx(SimdSupport::Lasx, "lasx")] + fn simd_support_display_matches_lowercase_convention( + #[case] tier: SimdSupport, + #[case] expected: &str, + ) { + assert_eq!(tier.to_string(), expected); + } +} diff --git a/rust/lance-core/src/utils/deletion.rs b/rust/lance-core/src/utils/deletion.rs index be7934b918c..c7f8b142464 100644 --- a/rust/lance-core/src/utils/deletion.rs +++ b/rust/lance-core/src/utils/deletion.rs @@ -3,8 +3,8 @@ use std::{collections::HashSet, ops::Range, sync::Arc}; +use crate::deepsize::{Context, DeepSizeOf}; use arrow_array::BooleanArray; -use deepsize::{Context, DeepSizeOf}; use roaring::RoaringBitmap; /// Threshold for when a DeletionVector::Set should be promoted to a DeletionVector::Bitmap. @@ -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, @@ -297,7 +296,7 @@ impl From for DeletionVector { #[cfg_attr(coverage, coverage(off))] mod test { use super::*; - use deepsize::DeepSizeOf; + use crate::deepsize::DeepSizeOf; use rstest::rstest; fn set_dv(vals: impl IntoIterator) -> DeletionVector { @@ -403,9 +402,11 @@ mod test { #[test] fn test_build_predicate() { let addrs = [0u64, 1, 2, 3, 4]; - assert!(DeletionVector::NoDeletions - .build_predicate(addrs.iter()) - .is_none()); + assert!( + DeletionVector::NoDeletions + .build_predicate(addrs.iter()) + .is_none() + ); let pred = set_dv([1, 3]).build_predicate(addrs.iter()).unwrap(); assert_eq!( diff --git a/rust/lance-core/src/utils/fragment_reuse.rs b/rust/lance-core/src/utils/fragment_reuse.rs new file mode 100644 index 00000000000..44ce7f29a4c --- /dev/null +++ b/rust/lance-core/src/utils/fragment_reuse.rs @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Readers for a single fragment rewrite. Graph traversal belongs to the FRI reader. + +use std::sync::Arc; + +use async_trait::async_trait; + +use super::row_addr_remap::RowAddrRemap; +use crate::deepsize::{Context, DeepSizeOf}; +use crate::{Error, Result}; + +/// The read contract for one mapping, independent of index types and manifests. +/// +/// Row IDs are physical addresses. Results preserve input positions and `None` +/// denotes a deleted source row. Addresses outside the source fragments, including +/// out-of-range offsets, return [`Error::InvalidInput`]. Invalid persisted mapping +/// contents return [`Error::CorruptFile`]; storage errors propagate to the caller. +#[async_trait] +pub trait MappingReader: Send + Sync + std::fmt::Debug + DeepSizeOf { + /// Remap one physical row ID, or return `None` if the row was deleted. + async fn remap_row_id(&self, row_id: u64) -> Result>; + + /// Remap a batch, preserving input order and duplicates, with one result per input. + async fn remap_row_ids(&self, row_ids: &[u64]) -> Result>> { + let mut mapped = Vec::with_capacity(row_ids.len()); + for &row_id in row_ids { + mapped.push(self.remap_row_id(row_id).await?); + } + Ok(mapped) + } +} + +/// Ordered-compaction adapter for one rewrite group's existing bitmap/rank remap. +/// This adapter does not replace the legacy FRI reader or writer. +#[derive(Debug)] +pub struct OrderedCompactionMapping { + remap: Arc, +} + +impl OrderedCompactionMapping { + /// Use a remap whose fragment layout was validated when it was constructed. + pub fn new(remap: Arc) -> Self { + Self { remap } + } + + fn map_row_id(&self, row_id: u64) -> Result> { + self.remap.get(row_id).ok_or_else(|| { + Error::invalid_input(format!("row ID {row_id} is outside compaction mapping")) + }) + } +} + +impl DeepSizeOf for OrderedCompactionMapping { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.remap.deep_size_of_children(context) + } +} + +#[async_trait] +impl MappingReader for OrderedCompactionMapping { + async fn remap_row_id(&self, row_id: u64) -> Result> { + self.map_row_id(row_id) + } + + async fn remap_row_ids(&self, row_ids: &[u64]) -> Result>> { + row_ids + .iter() + .map(|&row_id| self.map_row_id(row_id)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::utils::address::RowAddress; + use crate::utils::row_addr_remap::GroupInputWithLayout; + use roaring::RoaringTreemap; + + #[derive(Debug, DeepSizeOf)] + struct SingleRowReader; + + #[async_trait] + impl MappingReader for SingleRowReader { + async fn remap_row_id(&self, row_id: u64) -> Result> { + if row_id == u64::MAX { + return Err(Error::invalid_input("invalid test row ID")); + } + Ok((row_id != 0).then_some(row_id)) + } + } + + #[tokio::test] + async fn default_batch_preserves_positions_and_propagates_errors() { + let reader = SingleRowReader; + assert_eq!( + reader.remap_row_ids(&[2, 0, 1, 2]).await.unwrap(), + vec![Some(2), None, Some(1), Some(2)], + ); + assert!(reader.remap_row_ids(&[]).await.unwrap().is_empty()); + let error = reader.remap_row_ids(&[1, u64::MAX]).await.unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("invalid test row ID")); + } + + #[tokio::test] + async fn compaction_reader_preserves_legacy_mapping_semantics() { + let address = |fragment, offset| u64::from(RowAddress::new_from_parts(fragment, offset)); + let remap = Arc::new( + RowAddrRemap::compact_with_layout([GroupInputWithLayout { + rewritten_old_row_addrs: RoaringTreemap::from_iter([ + address(1, 0), + address(1, 2), + address(2, 0), + ]), + old_frags: vec![(1, 3), (2, 1)], + new_frags: vec![(3, 2), (4, 1)], + }]) + .unwrap(), + ); + let reader = OrderedCompactionMapping::new(remap.clone()); + let input = [address(2, 0), address(1, 1), address(1, 0), address(1, 0)]; + let expected: Vec<_> = input.iter().map(|&a| remap.get(a).unwrap()).collect(); + assert_eq!(reader.remap_row_ids(&input).await.unwrap(), expected); + for (&row_id, &mapped) in input.iter().zip(&expected) { + assert_eq!(reader.remap_row_id(row_id).await.unwrap(), mapped); + } + assert!(reader.remap_row_ids(&[]).await.unwrap().is_empty()); + for invalid in [address(99, 0), address(1, 3)] { + for error in [ + reader.remap_row_id(invalid).await.unwrap_err(), + reader + .remap_row_ids(&[input[0], invalid]) + .await + .unwrap_err(), + ] { + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("outside compaction mapping")); + } + } + } +} diff --git a/rust/lance-core/src/utils/futures.rs b/rust/lance-core/src/utils/futures.rs index cdf7bbbe5b1..df799ca7257 100644 --- a/rust/lance-core/src/utils/futures.rs +++ b/rust/lance-core/src/utils/futures.rs @@ -7,8 +7,8 @@ use std::{ task::Waker, }; -use futures::{stream::BoxStream, Stream, StreamExt}; -use pin_project::pin_project; +use futures::{Stream, StreamExt, stream::BoxStream}; +use pin_project::{pin_project, pinned_drop}; use tokio::sync::Semaphore; use tokio_util::sync::PollSemaphore; @@ -33,6 +33,8 @@ struct InnerState<'a, T> { exhausted: bool, left_buffered: u32, right_buffered: u32, + left_dropped: bool, + right_dropped: bool, available_buffer: Option, } @@ -58,6 +60,8 @@ impl<'a, T: Clone> SharedStream<'a, T> { exhausted: false, left_buffered: 0, right_buffered: 0, + left_dropped: false, + right_dropped: false, available_buffer, }; @@ -75,6 +79,51 @@ impl<'a, T: Clone> SharedStream<'a, T> { } } +impl Drop for SharedStream<'_, T> { + fn drop(&mut self) { + let Ok(mut inner_state) = self.state.lock() else { + return; + }; + match self.side { + Side::Left => inner_state.left_dropped = true, + Side::Right => inner_state.right_dropped = true, + } + // The buffer only ever holds items destined for one side (a side + // polls the inner stream only after draining its own share), so any + // count still pending for this side sits at the front. Pop those + // items and release their capacity permits, or a bounded survivor + // deadlocks once the budget is exhausted. + let pending_for_self = match self.side { + Side::Left => std::mem::take(&mut inner_state.left_buffered), + Side::Right => std::mem::take(&mut inner_state.right_buffered), + }; + debug_assert!( + pending_for_self == 0 || inner_state.buffer.len() as u32 == pending_for_self, + "buffer holds items for both sides" + ); + for _ in 0..pending_for_self { + inner_state.buffer.pop_front(); + if let Some(available_buffer) = inner_state.available_buffer.as_mut() { + available_buffer.add_permits(1); + } + } + // If this side was mid-poll when its task went away, the inner + // stream holds a dead waker and the survivor would wait forever on + // `polling`. Let the survivor take over. + let was_polling = inner_state.polling == Some(self.side); + let to_wake = if was_polling { + inner_state.polling = None; + inner_state.waker.take() + } else { + None + }; + drop(inner_state); + if let Some(waker) = to_wake { + waker.wake(); + } + } +} + impl Stream for SharedStream<'_, T> { type Item = T; @@ -119,18 +168,18 @@ impl Stream for SharedStream<'_, T> { } else { None }; - if let Some(polling_side) = inner_state.polling.as_ref() { - if *polling_side != self.side { - // Another task is already polling the inner stream, so we don't need to do anything - - // Per rust docs: - // Note that on multiple calls to poll, only the Waker from the Context - // passed to the most recent call should be scheduled to receive a wakeup. - // - // So it is safe to replace a potentially stale waker here. - inner_state.waker = Some(cx.waker().clone()); - return std::task::Poll::Pending; - } + if let Some(polling_side) = inner_state.polling.as_ref() + && *polling_side != self.side + { + // Another task is already polling the inner stream, so we don't need to do anything + + // Per rust docs: + // Note that on multiple calls to poll, only the Waker from the Context + // passed to the most recent call should be scheduled to receive a wakeup. + // + // So it is safe to replace a potentially stale waker here. + inner_state.waker = Some(cx.waker().clone()); + return std::task::Poll::Pending; } inner_state.polling = Some(self.side); // Release the mutex here as polling the inner stream is potentially expensive @@ -149,21 +198,29 @@ impl Stream for SharedStream<'_, T> { inner_state.polling = None; } std::task::Poll::Ready(Some(item)) => { - // We got an item, forget the permit to mark that we can take one fewer items - if let Some(permit) = permit { - permit.forget(); - } inner_state.polling = None; - // Let the other side know an item is available - match self.side { - Side::Left => { - inner_state.right_buffered += 1; - } - Side::Right => { - inner_state.left_buffered += 1; - } + // Let the other side know an item is available. A dropped + // side never will be: skip the clone so neither a buffer + // entry nor its capacity permit is stranded. + let other_alive = match self.side { + Side::Left => !inner_state.right_dropped, + Side::Right => !inner_state.left_dropped, }; - inner_state.buffer.push_back(item.clone()); + if other_alive { + // We got an item, forget the permit to mark that we can take one fewer items + if let Some(permit) = permit { + permit.forget(); + } + match self.side { + Side::Left => { + inner_state.right_buffered += 1; + } + Side::Right => { + inner_state.left_buffered += 1; + } + }; + inner_state.buffer.push_back(item.clone()); + } } std::task::Poll::Pending => { should_wake = false; @@ -264,13 +321,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(); @@ -331,6 +439,19 @@ mod tests { assert_eq!(right.next().await, None); } + #[test] + fn test_shared_stream_replaces_waiting_waker() { + let inner_stream = futures::stream::pending::(); + let (mut left, mut right) = inner_stream.boxed().share(Capacity::Unbounded); + + let mut left_fut = left.next(); + assert!(is_pending(&mut left_fut)); + + let mut right_fut = right.next(); + assert!(is_pending(&mut right_fut)); + assert!(is_pending(&mut right_fut)); + } + #[tokio::test] async fn test_unbounded_shared_stream() { let (tx, rx) = tokio::sync::mpsc::channel::(10); @@ -388,4 +509,193 @@ 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)); + } + + /// If one side is dropped mid-stream, the survivor must still be able to + /// make progress. Two mechanisms used to wedge a bounded stream: items + /// buffered for the dropped side never released their capacity permits, + /// and every later fetch kept spending a permit on a clone nobody would + /// take, so the survivor stalled again one capacity later. The third + /// mechanism, a dropped side left recorded as the current poller, is + /// covered by the two tests below. + #[tokio::test] + async fn test_shared_stream_survivor_progresses_after_drop() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + for i in 0..6 { + tx.send(i).await.unwrap(); + } + let (left, mut right) = ReceiverStream::new(rx).boxed().share(Capacity::Bounded(2)); + + // Right races ahead by the full capacity; the two buffered items are + // destined for left and hold the only two permits. + assert_eq!(right.next().await.unwrap(), 0); + assert_eq!(right.next().await.unwrap(), 1); + let mut right_fut = right.next(); + assert!(is_pending(&mut right_fut)); + + drop(left); + + // The stranded permits must be released so right can keep reading, + // including past the original capacity budget: later fetches must + // stop buffering clones for the dropped side at all. + let item = tokio::time::timeout(std::time::Duration::from_secs(5), right_fut).await; + assert_eq!(item.expect("survivor wedged by dropped side").unwrap(), 2); + for expected in 3..6 { + let item = tokio::time::timeout(std::time::Duration::from_secs(5), right.next()).await; + assert_eq!( + item.expect("survivor wedged after capacity re-spent") + .unwrap(), + expected + ); + } + } + + #[tokio::test] + async fn test_shared_stream_dropped_poller_releases_inner_stream() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let (mut left, mut right) = ReceiverStream::new(rx).boxed().share(Capacity::Unbounded); + + // Left polls while the channel is empty, so it stays recorded as the + // poller and the inner stream keeps the waker it was polled with. + let mut left_fut = left.next(); + assert!(is_pending(&mut left_fut)); + drop(left_fut); + drop(left); + + // An item arriving for the survivor must not be lost to the dead + // side's stale poll. + tx.send(42).await.unwrap(); + let item = tokio::time::timeout(std::time::Duration::from_secs(5), right.next()).await; + assert_eq!( + item.expect("survivor wedged by dropped poller").unwrap(), + 42 + ); + } + struct FlagWaker(AtomicBool); + + impl futures::task::ArcWake for FlagWaker { + fn wake_by_ref(arc_self: &Arc) { + arc_self.0.store(true, Ordering::SeqCst); + } + } + + /// A survivor already parked behind `polling` has to be woken when the + /// recorded poller is dropped: the inner stream only holds the waker the + /// dropped side polled with, so nothing else will re-poll the survivor. + #[tokio::test] + async fn test_shared_stream_dropped_poller_wakes_parked_survivor() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + let (mut left, mut right) = ReceiverStream::new(rx).boxed().share(Capacity::Unbounded); + + // Left becomes the recorded poller and parks on the empty channel. + let mut left_fut = left.next(); + assert!(is_pending(&mut left_fut)); + drop(left_fut); + + // Right parks behind `polling == Some(Left)`, leaving its waker behind. + let flag = Arc::new(FlagWaker(AtomicBool::new(false))); + let flag_waker = futures::task::waker(flag.clone()); + let mut cx = std::task::Context::from_waker(&flag_waker); + let mut right_fut = right.next(); + assert!(right_fut.poll_unpin(&mut cx).is_pending()); + assert!(!flag.0.load(Ordering::SeqCst)); + + drop(left); + assert!( + flag.0.load(Ordering::SeqCst), + "dropping the poller must wake the parked survivor" + ); + + tx.send(42).await.unwrap(); + let item = tokio::time::timeout(std::time::Duration::from_secs(5), right_fut).await; + assert_eq!( + item.expect("survivor wedged by dropped poller").unwrap(), + 42 + ); + } + + /// With an unbounded capacity nothing blocks, so the only observable effect + /// of a drop is memory: the dropped side's buffered items must be freed and + /// no further clone queued for it. + #[tokio::test] + async fn test_shared_stream_unbounded_stops_buffering_for_dropped_side() { + let (tx, rx) = tokio::sync::mpsc::channel::(10); + for i in 0..4 { + tx.send(i).await.unwrap(); + } + let (left, mut right) = ReceiverStream::new(rx).boxed().share(Capacity::Unbounded); + + // Right reads ahead, so every item is also cloned into the buffer for left. + assert_eq!(right.next().await.unwrap(), 0); + assert_eq!(right.next().await.unwrap(), 1); + assert_eq!(right.state.lock().unwrap().buffer.len(), 2); + + drop(left); + assert!( + right.state.lock().unwrap().buffer.is_empty(), + "the dropped side's buffered items must be released" + ); + + for expected in 2..4 { + let item = tokio::time::timeout(std::time::Duration::from_secs(5), right.next()).await; + assert_eq!( + item.expect("survivor wedged after peer drop").unwrap(), + expected + ); + } + let state = right.state.lock().unwrap(); + assert!( + state.buffer.is_empty(), + "no clone may be queued for a dropped side" + ); + assert_eq!(state.left_buffered, 0); + } } diff --git a/rust/lance-core/src/utils/io_stats.rs b/rust/lance-core/src/utils/io_stats.rs new file mode 100644 index 00000000000..e2169d71ae3 --- /dev/null +++ b/rust/lance-core/src/utils/io_stats.rs @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::ops::Range; + +/// A sink that records I/O requests as they are submitted to storage. +/// +/// This lives in `lance-core` so that the encoding layer (`lance-encoding`) and +/// the I/O layer (`lance-io`) can both refer to it without depending on one +/// another. It lets a caller attach a lightweight counter to a file reader and +/// measure the exact bytes/IOPS performed for a bounded scope (e.g. a single +/// query); see `lance_io::scheduler::IoStats` for the concrete implementation. +/// +/// # When to use this +/// +/// Lance also exposes two *process-wide, cumulative* I/O accounting facilities: +/// the global scheduler counters (`lance_io::scheduler::iops_counter` / +/// `bytes_read_counter`) and the object-store `IOTracker` wrapper used in tests. +/// Both aggregate every read in the process and cannot attribute I/O to a single +/// bounded scope. Prefer an `IoStatsRecorder` when you need the *exact* I/O of +/// one operation (e.g. a single query): attach it to a reader with +/// `with_io_stats`, then read the snapshot when the scope ends. It re-uses the +/// reader's cached metadata, so measuring costs no extra file opens and does not +/// disturb the global counters. +pub trait IoStatsRecorder: std::fmt::Debug + Send + Sync { + /// Record one completed request, given the byte ranges as actually + /// submitted to storage (i.e. after any coalescing/splitting), so the + /// counts reflect physical I/O. + fn record_request(&self, ranges: &[Range]); +} diff --git a/rust/lance-core/src/utils/parse.rs b/rust/lance-core/src/utils/parse.rs index 7efea7cfc72..bba8fe7716e 100644 --- a/rust/lance-core/src/utils/parse.rs +++ b/rust/lance-core/src/utils/parse.rs @@ -9,3 +9,75 @@ pub fn str_is_truthy(val: &str) -> bool { | val.eq_ignore_ascii_case("yes") | val.eq_ignore_ascii_case("y") } + +/// Parse a string into an optional boolean value. +/// +/// Returns `Some(true)` for truthy values (1/true/on/yes/y, case-insensitive). +/// Returns `Some(false)` for falsy values (0/false/off/no/n, case-insensitive). +/// Returns `None` for unrecognized values. +pub fn str_to_bool(val: &str) -> Option { + if str_is_truthy(val) { + Some(true) + } else if val.eq_ignore_ascii_case("0") + || val.eq_ignore_ascii_case("false") + || val.eq_ignore_ascii_case("off") + || val.eq_ignore_ascii_case("no") + || val.eq_ignore_ascii_case("n") + { + Some(false) + } else { + None + } +} + +/// Parse an environment variable as a truthy-only boolean. +/// +/// Returns `default_value` if the env var is not set. +/// Returns `true` only for truthy values (1/true/on/yes/y, case-insensitive). +/// Returns `false` for all other set values. +pub fn parse_env_as_bool(env_var_name: &str, default_value: bool) -> bool { + std::env::var(env_var_name) + .ok() + .map(|value| str_is_truthy(value.trim())) + .unwrap_or(default_value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_str_to_bool_truthy() { + for val in [ + "1", "true", "True", "TRUE", "on", "ON", "yes", "YES", "y", "Y", + ] { + assert_eq!( + str_to_bool(val), + Some(true), + "expected Some(true) for {:?}", + val + ); + } + } + + #[test] + fn test_str_to_bool_falsy() { + for val in [ + "0", "false", "False", "FALSE", "off", "OFF", "no", "NO", "n", "N", + ] { + assert_eq!( + str_to_bool(val), + Some(false), + "expected Some(false) for {:?}", + val + ); + } + } + + #[test] + fn test_str_to_bool_unknown() { + for val in ["", "2", "maybe", "truthy", "nonsense"] { + assert_eq!(str_to_bool(val), None, "expected None for {:?}", val); + } + } +} diff --git a/rust/lance-core/src/utils/path.rs b/rust/lance-core/src/utils/path.rs index fb4ec56eb47..d8096dda925 100644 --- a/rust/lance-core/src/utils/path.rs +++ b/rust/lance-core/src/utils/path.rs @@ -11,7 +11,7 @@ impl LancePathExt for Path { fn child_path(&self, path: &Path) -> Path { let mut new_path = self.clone(); for part in path.parts() { - new_path = new_path.child(part); + new_path = new_path.clone().join(part); } new_path } diff --git a/rust/lance-core/src/utils/row_addr_remap.rs b/rust/lance-core/src/utils/row_addr_remap.rs new file mode 100644 index 00000000000..71047b9d78a --- /dev/null +++ b/rust/lance-core/src/utils/row_addr_remap.rs @@ -0,0 +1,1325 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Compact row-address remapping for compaction. +//! +//! Compaction rewrites rows into new fragments, so indices that store physical +//! row addresses need an old-address to new-address mapping without building an +//! O(total rows) `HashMap>`. +//! +//! Layout: +//! +//! * Old rows: `old_fragment_id -> (old_offsets, old_rows_before)` +//! * `old_offsets`: rewritten old row offsets in this old fragment. +//! * `old_rows_before`: rewritten row count before this old fragment. +//! * New rows: ordered new-fragment ranges +//! `(fragment_id, new_rows_before, physical_rows)` +//! * `new_rows_before`: rewritten row count before this new fragment. +//! +//! Lookup: +//! +//! * An address whose fragment was not rewritten returns `None`. +//! * For an address whose fragment was rewritten: +//! * Read `(old_offsets, old_rows_before)` from the old-row layout. +//! * If `offset` is outside the old fragment's physical row range, return +//! `None`; the direct-map representation would not contain that address. +//! * If a valid `offset` is not in `old_offsets`, return `Some(None)` +//! because the row was deleted. +//! * Otherwise, `old_offsets.rank(offset) - 1` is this row's 0-based +//! position among rewritten old rows in this old fragment. Add +//! `old_rows_before` to get `k`, the row's 0-based position among all +//! rewritten old rows. +//! * In the new-row layout, find the range +//! `(fragment_id, new_rows_before, physical_rows)` where +//! `new_rows_before <= k < new_rows_before + physical_rows`. +//! * The new address is `(fragment_id, k - new_rows_before)`. +//! +//! Ordering: +//! +//! Compact remap does not store each old-to-new row mapping. It computes `k` +//! from the old-row layout, then maps it to the k-th row written to the new +//! fragments. This requires the reader-to-writer pipeline to preserve row order. +//! +//! * `old_frag_ids` must match the order old fragments are read. Within each +//! old fragment, rewritten rows are interpreted by ascending old row offset. +//! * `new_frags` must match the order new rows are written. +//! * Current compaction satisfies this because it scans selected fragments in +//! order and writes the resulting stream without reordering rows. + +use crate::deepsize::{Context, DeepSizeOf}; +use crate::utils::address::RowAddress; +use crate::{Error, Result}; +use roaring::{RoaringBitmap, RoaringTreemap}; +use std::collections::{HashMap, HashSet}; +use std::hash::{BuildHasherDefault, Hasher}; +use std::mem::size_of; + +/// A queryable row-address remapping with the exact semantics of +/// `HashMap>::get(&addr).copied()`: +/// +/// * `None` — the address is not affected by this remap (keep it unchanged) +/// * `Some(None)` — the row was deleted +/// * `Some(Some(addr))` — the row moved to `addr` +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum RowAddrRemap { + /// Compact, `O(#fragments)` remap built from per-group rewritten-row + /// bitmaps and new-fragment layouts. + Compact(CompactRowAddrRemap), + /// Full materialized old-to-new address map. Uses `O(#rows)` memory. + Direct(HashMap>), +} + +impl RowAddrRemap { + pub fn compact(groups: impl IntoIterator) -> Result { + Ok(Self::Compact(CompactRowAddrRemap::new(groups)?)) + } + + /// Build a compact remap with physical row counts for exact validation of + /// addresses loaded from persisted fragment layouts. + #[doc(hidden)] + pub fn compact_with_layout( + groups: impl IntoIterator, + ) -> Result { + Ok(Self::Compact(CompactRowAddrRemap::new_with_layout(groups)?)) + } + + /// Build a remap from a fully materialized old-to-new address map. + pub fn direct(map: HashMap>) -> Self { + Self::Direct(map) + } + + /// Build an ordered remap chain, flattening nested chains and omitting + /// empty remaps. + pub fn chained(remaps: impl IntoIterator) -> Self { + let mut remaps = remaps + .into_iter() + .filter(|remap| !remap.is_empty()) + .collect::>(); + match remaps.len() { + 0 => Self::empty(), + 1 => remaps.pop().unwrap(), + _ => Self::Compact(CompactRowAddrRemap::chained(remaps)), + } + } + + /// An empty remap that leaves every address unchanged. + pub fn empty() -> Self { + Self::Direct(HashMap::new()) + } + + /// Look up `addr`. See [`RowAddrRemap`] for the tri-state return semantics. + #[inline] + pub fn get(&self, addr: u64) -> Option> { + match self { + Self::Compact(c) => c.get(addr), + Self::Direct(m) => m.get(&addr).copied(), + } + } + + /// Apply this remap to a batch in place. + /// + /// A `None` input remains deleted. An address missing from a remap remains + /// unchanged. Chained remaps are applied version-by-version so this path is + /// suitable for bulk index and transaction remapping without materializing + /// a composed per-row map. + pub fn remap_in_place(&self, row_addrs: &mut [Option]) { + match self { + Self::Compact(compact) => compact.remap_in_place(row_addrs), + Self::Direct(_) => { + for row_addr in row_addrs { + if let Some(addr) = *row_addr + && let Some(mapped) = self.get(addr) + { + *row_addr = mapped; + } + } + } + } + } + + pub fn is_empty(&self) -> bool { + match self { + Self::Compact(c) => c.is_empty(), + Self::Direct(m) => m.is_empty(), + } + } + + pub fn affected_fragments(&self) -> RoaringBitmap { + match self { + Self::Compact(c) => c.affected_fragments(), + Self::Direct(m) => RoaringBitmap::from_iter(m.keys().map(|addr| (addr >> 32) as u32)), + } + } + + /// Returns fragments this remap can prove have no surviving physical rows. + /// + /// A non-empty direct map cannot provide this proof because it does not + /// record the physical row count of each fragment. + pub fn fully_deleted_fragments(&self) -> Option { + match self { + Self::Compact(c) => c.fully_deleted_fragments(), + Self::Direct(m) if m.is_empty() => Some(RoaringBitmap::new()), + Self::Direct(_) => None, + } + } +} + +impl DeepSizeOf for RowAddrRemap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + match self { + Self::Compact(compact) => compact.deep_size_of_children(context), + Self::Direct(map) => map.deep_size_of_children(context), + } + } +} + +/// Input describing one rewrite group: the old row addresses that were +/// rewritten plus the fragment layout before/after the rewrite. +pub struct GroupInput { + /// Old row addresses that were read and re-written into the new fragments. + pub rewritten_old_row_addrs: RoaringTreemap, + /// Old fragment ids covered by this group. + pub old_frag_ids: Vec, + /// New fragments produced by this group, as `(fragment_id, physical_rows)`, + pub new_frags: Vec<(u32, u32)>, +} + +/// Internal compact-remap input that includes old-fragment physical row counts. +#[doc(hidden)] +pub struct GroupInputWithLayout { + pub rewritten_old_row_addrs: RoaringTreemap, + pub old_frags: Vec<(u32, u32)>, + pub new_frags: Vec<(u32, u32)>, +} + +/// Keep Roaring only when its serialized representation is substantially +/// smaller than either rank-friendly representation. This preserves compact +/// run containers while avoiding Roaring's linear word scan for dense rank. +/// Binary-copy compaction creates these runs with `RoaringTreemap::insert_range`, +/// and serialization preserves them without an explicit `optimize()` call. +const ROARING_SIZE_ADVANTAGE_FOR_RANK: usize = 4; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RankedOffsets { + /// Retained for highly compressible run layouts. + Roaring(RoaringBitmap), + /// Sorted rewritten offsets. Binary search returns membership and rank in + /// one operation. + Sparse(Vec), + /// Dense bits with the number of rewritten rows before every word. + Dense(DenseRankedOffsets), +} + +impl RankedOffsets { + fn try_new(offsets: RoaringBitmap, physical_rows: Option) -> Result { + let universe_rows = physical_rows.map(u64::from).unwrap_or_else(|| { + offsets + .max() + .map(|offset| u64::from(offset) + 1) + .unwrap_or(0) + }); + let word_count = usize::try_from(universe_rows.div_ceil(64)).map_err(|_| { + Error::invalid_input(format!( + "fragment row range {universe_rows} is too large for compact rank lookup" + )) + })?; + let sparse_bytes = usize::try_from(offsets.len()) + .ok() + .and_then(|len| len.checked_mul(size_of::())) + .ok_or_else(|| { + Error::invalid_input(format!( + "rewritten row count {} is too large for sparse rank lookup", + offsets.len() + )) + })?; + let dense_bytes = word_count + .checked_mul(size_of::() + size_of::()) + .ok_or_else(|| { + Error::invalid_input(format!( + "fragment row range {universe_rows} is too large for dense rank lookup" + )) + })?; + let rank_friendly_bytes = sparse_bytes.min(dense_bytes); + if offsets + .serialized_size() + .checked_mul(ROARING_SIZE_ADVANTAGE_FOR_RANK) + .is_some_and(|roaring_bytes| roaring_bytes < rank_friendly_bytes) + { + return Ok(Self::Roaring(offsets)); + } + if sparse_bytes <= dense_bytes { + return Ok(Self::Sparse(offsets.into_iter().collect())); + } + Ok(Self::Dense(DenseRankedOffsets::try_new( + offsets, word_count, + )?)) + } + + /// Return the zero-based rank when `offset` was rewritten. + #[inline] + fn rank_if_present(&self, offset: u32) -> Option { + match self { + Self::Roaring(offsets) => offsets.contains(offset).then(|| offsets.rank(offset) - 1), + Self::Sparse(offsets) => offsets.binary_search(&offset).ok().map(|rank| rank as u64), + Self::Dense(offsets) => offsets.rank_if_present(offset), + } + } + + fn is_empty(&self) -> bool { + match self { + Self::Roaring(offsets) => offsets.is_empty(), + Self::Sparse(offsets) => offsets.is_empty(), + Self::Dense(offsets) => offsets.words.is_empty(), + } + } +} + +impl DeepSizeOf for RankedOffsets { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + match self { + // Roaring does not expose its allocation capacity. Its serialized + // size is a stable proxy for the retained containers. + Self::Roaring(offsets) => offsets.serialized_size(), + Self::Sparse(offsets) => offsets.deep_size_of_children(context), + Self::Dense(offsets) => offsets.deep_size_of_children(context), + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct DenseRankedOffsets { + words: Vec, + rank_before_word: Vec, +} + +impl DenseRankedOffsets { + fn try_new(offsets: RoaringBitmap, word_count: usize) -> Result { + let mut words = vec![0u64; word_count]; + for offset in offsets { + let word_idx = (offset / 64) as usize; + let Some(word) = words.get_mut(word_idx) else { + return Err(Error::invalid_input(format!( + "rewritten row offset {offset} is outside dense rank word_count={word_count}" + ))); + }; + *word |= 1u64 << (offset % 64); + } + + let mut rank_before_word = Vec::with_capacity(word_count); + let mut rewritten_rows_before = 0u64; + for word in &words { + rank_before_word.push(u32::try_from(rewritten_rows_before).map_err(|_| { + Error::invalid_input(format!( + "rewritten row count {rewritten_rows_before} exceeds the row-address offset range" + )) + })?); + rewritten_rows_before += u64::from(word.count_ones()); + } + Ok(Self { + words, + rank_before_word, + }) + } + + #[inline] + fn rank_if_present(&self, offset: u32) -> Option { + let word_idx = (offset / 64) as usize; + let word = *self.words.get(word_idx)?; + let bit = 1u64 << (offset % 64); + if word & bit == 0 { + return None; + } + Some( + u64::from(self.rank_before_word[word_idx]) + u64::from((word & (bit - 1)).count_ones()), + ) + } +} + +impl DeepSizeOf for DenseRankedOffsets { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.words.deep_size_of_children(context) + + self.rank_before_word.deep_size_of_children(context) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct OldFragmentRemap { + group_idx: usize, + rewritten_offsets: RankedOffsets, + rewritten_rows_before: u64, + physical_rows: Option, +} + +impl DeepSizeOf for OldFragmentRemap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.rewritten_offsets.deep_size_of_children(context) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct GroupRemap { + /// New fragment ranges as `(fragment_id, rewritten_rows_before, physical_rows)`, + /// used to map a rewritten row's group-local index to its new address via binary search. + new_frag_row_ranges: Vec<(u32, u64, u32)>, +} + +impl GroupRemap { + fn new(input: GroupInput, group_idx: usize) -> Result<(Self, Vec<(u32, OldFragmentRemap)>)> { + Self::new_with_old_frags( + input.rewritten_old_row_addrs, + input.old_frag_ids.into_iter().map(|id| (id, None)), + input.new_frags, + group_idx, + ) + } + + fn new_with_layout( + input: GroupInputWithLayout, + group_idx: usize, + ) -> Result<(Self, Vec<(u32, OldFragmentRemap)>)> { + Self::new_with_old_frags( + input.rewritten_old_row_addrs, + input + .old_frags + .into_iter() + .map(|(id, rows)| (id, Some(rows))), + input.new_frags, + group_idx, + ) + } + + fn new_with_old_frags( + rewritten_old_row_addrs: RoaringTreemap, + old_frags: impl IntoIterator)>, + new_frags: Vec<(u32, u32)>, + group_idx: usize, + ) -> Result<(Self, Vec<(u32, OldFragmentRemap)>)> { + // `compute_new_addr` maps a rewritten row's group-local index by + // accumulating `physical_rows` in the caller-provided write order. + let mut new_frag_row_ranges = Vec::with_capacity(new_frags.len()); + let mut rewritten_rows_before = 0u64; + for (frag_id, physical_rows) in new_frags { + if physical_rows == 0 { + continue; + } + new_frag_row_ranges.push((frag_id, rewritten_rows_before, physical_rows)); + rewritten_rows_before += physical_rows as u64; + } + let total_new_rows = rewritten_rows_before; + + let mut per_frag: IntMap = rewritten_old_row_addrs + .bitmaps() + .map(|(frag_id, bitmap)| (frag_id, bitmap.clone())) + .collect(); + let old_frags = old_frags.into_iter().collect::>(); + let mut frags = Vec::with_capacity(old_frags.len()); + let mut seen_frag_ids = HashSet::with_capacity(old_frags.len()); + let mut rewritten_rows_before = 0u64; + for &(frag_id, physical_rows) in &old_frags { + if !seen_frag_ids.insert(frag_id) { + return Err(Error::invalid_input(format!( + "rewrite group {group_idx} contains old fragment {frag_id} more than once" + ))); + } + let bitmap = per_frag.remove(&frag_id).unwrap_or_default(); + if let Some(physical_rows) = physical_rows + && bitmap.max().is_some_and(|offset| offset >= physical_rows) + { + return Err(Error::invalid_input(format!( + "rewrite group {group_idx} contains a row offset outside old fragment {frag_id} with physical_rows={physical_rows}" + ))); + } + let num_rewritten_rows = bitmap.len(); + let rewritten_offsets = RankedOffsets::try_new(bitmap, physical_rows)?; + frags.push(( + frag_id, + OldFragmentRemap { + group_idx, + rewritten_offsets, + rewritten_rows_before, + physical_rows, + }, + )); + rewritten_rows_before += num_rewritten_rows; + } + // Rewritten old row addresses must reference only listed old fragments. + if !per_frag.is_empty() { + return Err(Error::invalid_input(format!( + "compaction rewrite group {group_idx} references rewritten old row addresses from fragments {:?} not in its old fragments {:?}", + per_frag.keys().collect::>(), + old_frags, + ))); + } + + // Rewritten old rows are mapped positionally onto the new rows, so the + // two counts must match exactly + let total_rewritten_old_rows = rewritten_old_row_addrs.len(); + if total_new_rows != total_rewritten_old_rows { + return Err(Error::invalid_input(format!( + "compaction rewrite group {group_idx} rewrote {total_rewritten_old_rows} old rows from fragments {:?} but the new fragments hold {total_new_rows} rows", + old_frags, + ))); + } + + Ok(( + Self { + new_frag_row_ranges, + }, + frags, + )) + } + + fn compute_new_addr(&self, rewritten_row_index: u64) -> u64 { + let idx = + match self + .new_frag_row_ranges + .binary_search_by(|(_, rewritten_rows_before, _)| { + rewritten_rows_before.cmp(&rewritten_row_index) + }) { + Ok(i) => i, + Err(i) => i - 1, + }; + let (frag_id, rewritten_rows_before, _rows) = self.new_frag_row_ranges[idx]; + let offset = (rewritten_row_index - rewritten_rows_before) as u32; + u64::from(RowAddress::new_from_parts(frag_id, offset)) + } +} + +impl DeepSizeOf for GroupRemap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.new_frag_row_ranges.deep_size_of_children(context) + } +} + +/// Hasher for this module's integer-keyed maps. +/// +/// `remap_row_id` probes `CompactRemapStep::frags` once per remap step per row +/// address, so at hundreds of millions of rows the default SipHash is a large +/// share of consolidation CPU. The keys are internal fragment ids, never +/// attacker-supplied, so the hash-flooding resistance it buys is worth nothing here. +/// +/// Only single-integer keys are hashed well: `write_u32`/`write_u64`/`write_usize` +/// replace the state rather than mixing into it, so a composite key would collide on +/// its last field alone. Correct either way — `Eq` still decides — but keep these maps +/// keyed by one integer. +#[derive(Clone, Copy)] +struct IntHasher(u64); + +/// FNV-1a offset basis, so the byte fallback does not absorb leading zero bytes. +const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325; + +impl Default for IntHasher { + #[inline] + fn default() -> Self { + Self(FNV_OFFSET_BASIS) + } +} + +impl Hasher for IntHasher { + /// Folds the high half down: multiplication only carries upward, so without this + /// the low bits — which hashbrown uses to pick the bucket — would ignore the high + /// bits of the key, and fragment ids strided by a power of two would all land in + /// one bucket. + #[inline] + fn finish(&self) -> u64 { + self.0 ^ (self.0 >> 32) + } + + #[inline] + fn write(&mut self, bytes: &[u8]) { + // Fallback for key types this module does not use. + for &b in bytes { + self.0 = (self.0 ^ u64::from(b)).wrapping_mul(0x0000_0100_0000_01b3); + } + } + + #[inline] + fn write_u32(&mut self, value: u32) { + self.0 = u64::from(value).wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + + #[inline] + fn write_u64(&mut self, value: u64) { + self.0 = value.wrapping_mul(0x9E37_79B9_7F4A_7C15); + } + + #[inline] + fn write_usize(&mut self, value: usize) { + self.write_u64(value as u64); + } +} + +/// `HashMap` over single-integer keys, hashed with [`IntHasher`]. +type IntMap = HashMap>; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct CompactRemapStep { + groups: Vec, + /// Old fragment id -> its bitmap/rank layout and rewrite group. Size is + /// O(#fragments), not rows. + frags: IntMap, +} + +impl CompactRemapStep { + fn new(groups: impl IntoIterator) -> Result { + let mut frags = IntMap::default(); + let mut group_remaps = Vec::new(); + for input in groups { + let gi = group_remaps.len(); + let (group_remap, group_frags) = GroupRemap::new(input, gi)?; + for (frag_id, frag) in group_frags { + if frags.insert(frag_id, frag).is_some() { + return Err(Error::invalid_input(format!( + "old fragment {frag_id} appears in more than one rewrite group, including group {gi}" + ))); + } + } + group_remaps.push(group_remap); + } + Ok(Self { + groups: group_remaps, + frags, + }) + } + + fn new_with_layout(groups: impl IntoIterator) -> Result { + let mut frags = IntMap::default(); + let mut group_remaps = Vec::new(); + for input in groups { + let gi = group_remaps.len(); + let (group_remap, group_frags) = GroupRemap::new_with_layout(input, gi)?; + for (frag_id, frag) in group_frags { + if frags.insert(frag_id, frag).is_some() { + return Err(Error::invalid_input(format!( + "old fragment {frag_id} appears in more than one rewrite group, including group {gi}" + ))); + } + } + group_remaps.push(group_remap); + } + Ok(Self { + groups: group_remaps, + frags, + }) + } + + #[inline] + pub fn get(&self, addr: u64) -> Option> { + let frag = (addr >> 32) as u32; + // Not in any rewrite group -> unaffected by this remap. + let old_frag = self.frags.get(&frag)?; + let offset = addr as u32; + if old_frag + .physical_rows + .is_some_and(|physical_rows| offset >= physical_rows) + { + return None; + } + let Some(rewritten_rank) = old_frag.rewritten_offsets.rank_if_present(offset) else { + return Some(None); + }; + let rewritten_row_index = old_frag.rewritten_rows_before + rewritten_rank; + Some(Some( + self.groups[old_frag.group_idx].compute_new_addr(rewritten_row_index), + )) + } + + pub fn is_empty(&self) -> bool { + self.groups.is_empty() + } + + fn fully_deleted_fragments(&self) -> Option { + // A group with any rewritten row moved at least one row. + if self + .frags + .values() + .any(|frag| !frag.rewritten_offsets.is_empty()) + { + return None; + } + Some(RoaringBitmap::from_iter(self.frags.keys().copied())) + } + + fn affected_fragments(&self) -> RoaringBitmap { + RoaringBitmap::from_iter(self.frags.keys().copied()) + } +} + +impl DeepSizeOf for CompactRemapStep { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.groups.deep_size_of_children(context) + self.frags.deep_size_of_children(context) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum RemapStep { + Compact(CompactRemapStep), + Direct(HashMap>), +} + +impl RemapStep { + fn get(&self, addr: u64) -> Option> { + match self { + Self::Compact(compact) => compact.get(addr), + Self::Direct(direct) => direct.get(&addr).copied(), + } + } + + fn is_empty(&self) -> bool { + match self { + Self::Compact(compact) => compact.is_empty(), + Self::Direct(direct) => direct.is_empty(), + } + } + + fn affected_fragments(&self) -> RoaringBitmap { + match self { + Self::Compact(compact) => compact.affected_fragments(), + Self::Direct(direct) => { + RoaringBitmap::from_iter(direct.keys().map(|addr| (addr >> 32) as u32)) + } + } + } + + fn fully_deleted_fragments(&self) -> Option { + match self { + Self::Compact(compact) => compact.fully_deleted_fragments(), + Self::Direct(direct) if direct.is_empty() => Some(RoaringBitmap::new()), + Self::Direct(_) => None, + } + } +} + +impl DeepSizeOf for RemapStep { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + match self { + Self::Compact(compact) => compact.deep_size_of_children(context), + Self::Direct(direct) => direct.deep_size_of_children(context), + } + } +} + +/// Compact remap backed by per-group rewritten row bitmaps + new-fragment layouts. +/// +/// Multiple remaps are retained as ordered private steps so a version chain +/// does not require another public [`RowAddrRemap`] variant. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CompactRowAddrRemap { + steps: Vec, +} + +impl CompactRowAddrRemap { + fn new(groups: impl IntoIterator) -> Result { + Ok(Self { + steps: vec![RemapStep::Compact(CompactRemapStep::new(groups)?)], + }) + } + + fn new_with_layout(groups: impl IntoIterator) -> Result { + Ok(Self { + steps: vec![RemapStep::Compact(CompactRemapStep::new_with_layout( + groups, + )?)], + }) + } + + fn chained(remaps: Vec) -> Self { + let mut steps = Vec::with_capacity(remaps.len()); + for remap in remaps { + match remap { + RowAddrRemap::Compact(compact) => steps.extend(compact.steps), + RowAddrRemap::Direct(direct) => steps.push(RemapStep::Direct(direct)), + } + } + Self { steps } + } + + #[inline] + pub fn get(&self, addr: u64) -> Option> { + let mut current = addr; + let mut was_affected = false; + for step in &self.steps { + match step.get(current) { + None => {} + Some(None) => return Some(None), + Some(Some(mapped)) => { + current = mapped; + was_affected = true; + } + } + } + was_affected.then_some(Some(current)) + } + + fn remap_in_place(&self, row_addrs: &mut [Option]) { + for step in &self.steps { + for row_addr in row_addrs.iter_mut() { + if let Some(addr) = *row_addr + && let Some(mapped) = step.get(addr) + { + *row_addr = mapped; + } + } + } + } + + pub fn is_empty(&self) -> bool { + self.steps.iter().all(RemapStep::is_empty) + } + + fn affected_fragments(&self) -> RoaringBitmap { + self.steps + .iter() + .fold(RoaringBitmap::new(), |mut affected, step| { + affected |= step.affected_fragments(); + affected + }) + } + + fn fully_deleted_fragments(&self) -> Option { + self.steps + .iter() + .try_fold(RoaringBitmap::new(), |mut deleted, step| { + deleted |= step.fully_deleted_fragments()?; + Some(deleted) + }) + } +} + +impl DeepSizeOf for CompactRowAddrRemap { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.steps.deep_size_of_children(context) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use proptest::{prop_assert, prop_assert_eq}; + + fn addr(frag: u32, offset: u32) -> u64 { + u64::from(RowAddress::new_from_parts(frag, offset)) + } + + #[derive(Clone, Copy)] + enum ExpectedRankedOffsets { + Sparse, + Dense, + Roaring, + } + + fn assert_layout_matches_legacy( + frag_id: u32, + physical_rows: u32, + rewritten_old_row_addrs: RoaringTreemap, + new_frags: Vec<(u32, u32)>, + expected_representation: ExpectedRankedOffsets, + ) { + let rewritten_addrs = rewritten_old_row_addrs.iter().collect::>(); + let new_addrs = new_frags + .iter() + .flat_map(|(new_frag_id, rows)| (0..*rows).map(|offset| addr(*new_frag_id, offset))) + .collect::>(); + assert_eq!(rewritten_addrs.len(), new_addrs.len()); + let expected_moved = rewritten_addrs + .iter() + .copied() + .zip(new_addrs) + .collect::>(); + + let remap = RowAddrRemap::compact_with_layout([GroupInputWithLayout { + rewritten_old_row_addrs, + old_frags: vec![(frag_id, physical_rows)], + new_frags, + }]) + .unwrap(); + + let RowAddrRemap::Compact(compact) = &remap else { + panic!("compact_with_layout must produce a compact remap"); + }; + let RemapStep::Compact(step) = &compact.steps[0] else { + panic!("compact_with_layout must produce a compact step"); + }; + let offsets = &step.frags[&frag_id].rewritten_offsets; + assert!(match expected_representation { + ExpectedRankedOffsets::Sparse => matches!(offsets, RankedOffsets::Sparse(_)), + ExpectedRankedOffsets::Dense => matches!(offsets, RankedOffsets::Dense(_)), + ExpectedRankedOffsets::Roaring => matches!(offsets, RankedOffsets::Roaring(_)), + }); + + for offset in 0..physical_rows { + let old_addr = addr(frag_id, offset); + assert_eq!( + remap.get(old_addr), + Some(expected_moved.get(&old_addr).copied()), + "mismatch at ({frag_id}, {offset})" + ); + } + assert_eq!(remap.get(addr(frag_id, physical_rows)), None); + assert_eq!(remap.get(addr(frag_id + 1, 0)), None); + } + + #[test] + fn test_sparse_ranked_offsets() { + let offsets = RankedOffsets::try_new( + RoaringBitmap::from_iter([1u32, 63, 511, 9_999]), + Some(10_000), + ) + .unwrap(); + assert!(matches!(offsets, RankedOffsets::Sparse(_))); + assert_eq!(offsets.rank_if_present(0), None); + assert_eq!(offsets.rank_if_present(1), Some(0)); + assert_eq!(offsets.rank_if_present(63), Some(1)); + assert_eq!(offsets.rank_if_present(511), Some(2)); + assert_eq!(offsets.rank_if_present(9_999), Some(3)); + } + + #[test] + fn test_dense_ranked_offsets_across_words() { + let rewritten = (0..1_024u32) + .filter(|offset| offset % 10 != 0) + .collect::(); + let offsets = RankedOffsets::try_new(rewritten.clone(), Some(1_024)).unwrap(); + assert!(matches!(offsets, RankedOffsets::Dense(_))); + + let mut expected_rank = 0u64; + for offset in 0..1_024 { + if rewritten.contains(offset) { + assert_eq!(offsets.rank_if_present(offset), Some(expected_rank)); + expected_rank += 1; + } else { + assert_eq!(offsets.rank_if_present(offset), None); + } + } + assert_eq!(expected_rank, rewritten.len()); + } + + #[test] + fn test_run_compressed_ranked_offsets() { + let mut rewritten = RoaringBitmap::new(); + rewritten.insert_range(100..9_900); + let offsets = RankedOffsets::try_new(rewritten, Some(10_000)).unwrap(); + assert!(matches!(offsets, RankedOffsets::Roaring(_))); + assert_eq!(offsets.rank_if_present(99), None); + assert_eq!(offsets.rank_if_present(100), Some(0)); + assert_eq!(offsets.rank_if_present(9_899), Some(9_799)); + assert_eq!(offsets.rank_if_present(9_900), None); + } + + #[test] + fn test_compact_with_layout_matches_legacy_across_rank_representations() { + assert_layout_matches_legacy( + 1, + 10_000, + RoaringTreemap::from_iter( + [1u32, 63, 511, 9_999] + .into_iter() + .map(|offset| addr(1, offset)), + ), + vec![(10, 2), (11, 2)], + ExpectedRankedOffsets::Sparse, + ); + + let dense = (0..1_024u32) + .filter(|offset| offset % 10 != 0) + .map(|offset| addr(2, offset)) + .collect::(); + let dense_rows = u32::try_from(dense.len()).unwrap(); + assert_layout_matches_legacy( + 2, + 1_024, + dense, + vec![(20, 400), (21, dense_rows - 400)], + ExpectedRankedOffsets::Dense, + ); + + // Binary-copy compaction captures complete fragment ranges with + // `RoaringTreemap::insert_range`, then persists that bitmap. The + // serialized round trip retains run containers without `optimize()`. + let mut captured = RoaringTreemap::new(); + captured.insert_range(addr(3, 100)..addr(3, 9_900)); + let mut serialized = Vec::with_capacity(captured.serialized_size()); + captured.serialize_into(&mut serialized).unwrap(); + let persisted = RoaringTreemap::deserialize_from(std::io::Cursor::new(serialized)).unwrap(); + assert_layout_matches_legacy( + 3, + 10_000, + persisted, + vec![(31, 5_000), (30, 4_800)], + ExpectedRankedOffsets::Roaring, + ); + } + + #[test] + fn test_compact_lookup() { + // Group A: out-of-order old frags [4, 3], split new frags (11 empty), + // some deletions. frag 4 (5 rows) keeps 0,2,4; frag 3 keeps 0,1, so the + // rewritten rows (4,0)(4,2)(4,4)(3,0)(3,1) go to new frags 10(2), 12(3). + // Group B is a fully-deleted fragment. + let group_a = GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([ + addr(4, 0), + addr(4, 2), + addr(4, 4), + addr(3, 0), + addr(3, 1), + ]), + old_frag_ids: vec![4, 3], + new_frags: vec![(10, 2), (11, 0), (12, 3)], + }; + let group_b = GroupInput { + rewritten_old_row_addrs: RoaringTreemap::new(), + old_frag_ids: vec![7], + new_frags: vec![], + }; + let remap = RowAddrRemap::compact([group_a, group_b]).unwrap(); + + // Moves, in rewrite order; frag 4 comes first despite the larger id. + assert_eq!(remap.get(addr(4, 0)), Some(Some(addr(10, 0)))); + assert_eq!(remap.get(addr(4, 2)), Some(Some(addr(10, 1)))); + // Rank 2 skips the zero-row new fragment 11 and lands in fragment 12. + assert_eq!(remap.get(addr(4, 4)), Some(Some(addr(12, 0)))); + assert_eq!(remap.get(addr(3, 0)), Some(Some(addr(12, 1)))); + assert_eq!(remap.get(addr(3, 1)), Some(Some(addr(12, 2)))); + // Deleted offsets inside a rewritten fragment. + assert_eq!(remap.get(addr(4, 1)), Some(None)); + assert_eq!(remap.get(addr(4, 3)), Some(None)); + // Covered but fully-deleted fragment -> Some(None), not None. + assert_eq!(remap.get(addr(7, 0)), Some(None)); + // Fragment in no group -> unaffected. + assert_eq!(remap.get(addr(9, 0)), None); + assert_eq!(remap.get(addr(4, 5)), Some(None)); + assert!(!remap.is_empty()); + } + + #[test] + fn test_fragment_sets() { + // Each deferred version deletes a different covered fragment. The + // chain must retain the flat direct map's union semantics. + let first_dead = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::new(), + old_frag_ids: vec![3], + new_frags: vec![], + }]) + .unwrap(); + let second_dead = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::new(), + old_frag_ids: vec![7], + new_frags: vec![], + }]) + .unwrap(); + let dead = RowAddrRemap::chained([first_dead.clone(), second_dead]); + assert_eq!( + dead.fully_deleted_fragments(), + Some(RoaringBitmap::from_iter([3u32, 7u32])) + ); + assert_eq!( + dead.affected_fragments(), + RoaringBitmap::from_iter([3u32, 7u32]) + ); + + // At least one rewritten row -> not fully deleted, but both covered + // fragments (including the fully-deleted frag 1) are still affected. + let alive = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([addr(0, 0)]), + old_frag_ids: vec![0, 1], + new_frags: vec![(10, 1)], + }]) + .unwrap(); + assert!(alive.fully_deleted_fragments().is_none()); + assert_eq!( + alive.affected_fragments(), + RoaringBitmap::from_iter([0u32, 1u32]) + ); + assert!( + RowAddrRemap::chained([first_dead, alive]) + .fully_deleted_fragments() + .is_none() + ); + } + + #[test] + fn test_direct_partial_map_is_not_fully_deleted() { + let remap = RowAddrRemap::direct(HashMap::from([(addr(5, 1), None)])); + + assert_eq!(remap.get(addr(5, 0)), None); + assert_eq!(remap.fully_deleted_fragments(), None); + } + + #[test] + fn test_chained_partial_direct_maps_are_not_fully_deleted() { + let remap = RowAddrRemap::chained([ + RowAddrRemap::direct(HashMap::from([(addr(5, 1), None)])), + RowAddrRemap::direct(HashMap::from([(addr(6, 1), None)])), + ]); + + assert!(matches!(remap, RowAddrRemap::Compact(_))); + assert_eq!(remap.get(addr(5, 0)), None); + assert_eq!(remap.get(addr(6, 0)), None); + assert_eq!(remap.fully_deleted_fragments(), None); + } + + #[test] + fn test_compact_rejects_rewritten_addrs_outside_old_frags() { + // Rewritten addresses reference frag 5, not in old_frags. The count + // still matches (2 == 2), so only the per-fragment split catches it. + let input = GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([addr(0, 0), addr(5, 0)]), + old_frag_ids: vec![0], + new_frags: vec![(10, 2)], + }; + assert!(RowAddrRemap::compact([input]).is_err()); + } + + #[test] + fn test_compact_preserves_explicit_fragment_order() { + let remap = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([addr(0, 0), addr(0, 1)]), + old_frag_ids: vec![0], + new_frags: vec![(12, 1), (11, 1)], + }]) + .unwrap(); + assert_eq!(remap.get(addr(0, 0)), Some(Some(addr(12, 0)))); + assert_eq!(remap.get(addr(0, 1)), Some(Some(addr(11, 0)))); + } + + #[test] + fn test_direct_and_empty() { + // Direct covers arbitrary maps the compact form can't express. + let mut map = HashMap::new(); + map.insert(addr(2, 0), Some(addr(9, 9))); + map.insert(addr(5, 1), None); + let remap = RowAddrRemap::direct(map); + assert_eq!(remap.get(addr(2, 0)), Some(Some(addr(9, 9)))); + assert_eq!(remap.get(addr(5, 1)), Some(None)); + assert_eq!(remap.get(addr(2, 1)), None); + // affected_fragments over an explicit map: the fragment of every key. + assert_eq!( + remap.affected_fragments(), + RoaringBitmap::from_iter([2u32, 5u32]) + ); + + let empty = RowAddrRemap::empty(); + assert!(empty.is_empty()); + assert_eq!(empty.get(addr(0, 0)), None); + } + + #[test] + fn test_chained_lookup_and_batch() { + let first = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([addr(0, 0), addr(0, 2)]), + old_frag_ids: vec![0], + new_frags: vec![(10, 2)], + }]) + .unwrap(); + let second = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: RoaringTreemap::from_iter([addr(10, 1)]), + old_frag_ids: vec![10], + new_frags: vec![(20, 1)], + }]) + .unwrap(); + let chain = RowAddrRemap::chained([first, second]); + + assert_eq!(chain.get(addr(0, 0)), Some(None)); + assert_eq!(chain.get(addr(0, 1)), Some(None)); + assert_eq!(chain.get(addr(0, 2)), Some(Some(addr(20, 0)))); + assert_eq!(chain.get(addr(1, 0)), None); + + let mut batch = vec![ + Some(addr(0, 0)), + Some(addr(0, 1)), + Some(addr(0, 2)), + Some(addr(1, 0)), + None, + ]; + chain.remap_in_place(&mut batch); + assert_eq!( + batch, + vec![None, None, Some(addr(20, 0)), Some(addr(1, 0)), None] + ); + } + + fn int_hash_of(key: &K) -> u64 { + use std::hash::BuildHasher; + BuildHasherDefault::::default().hash_one(key) + } + + #[test] + fn test_int_hasher_is_stateless_and_spreads_low_bits() { + // A `BuildHasherDefault` carries no seed, so a key must hash the same at insert, + // at lookup and after a resize; if it did not, a key could become unreachable. + for value in [0u32, 1, 7, 12345, 1 << 31, u32::MAX] { + assert_eq!(int_hash_of(&value), int_hash_of(&value)); + } + // `get` borrows the key, so the borrowed form has to agree with the owned one. + let key = 424242u32; + assert_eq!(int_hash_of(&key), int_hash_of(&&key)); + + // Multiplication only carries upward, so without the fold in `finish` the low + // bits would ignore the high bits of the key and every fragment id below would + // land in the same bucket. + let buckets = |ids: &[u32]| -> usize { + let table_mask = 2048 - 1; + ids.iter() + .map(|id| int_hash_of(id) & table_mask) + .collect::>() + .len() + }; + let strided: Vec = (0..1024).map(|i| i << 20).collect(); + let dense: Vec = (0..1024).collect(); + assert!( + buckets(&strided) > 512, + "power-of-two-strided fragment ids clustered into {} of 2048 buckets", + buckets(&strided) + ); + assert!(buckets(&dense) > 512, "dense fragment ids clustered"); + } + + #[test] + fn test_int_hasher_composite_keys_collide_on_last_field() { + // `write_u32`/`write_u64` replace the state instead of mixing into it, so a + // composite key keeps only its last integer field. Pinned because it is the + // documented reason these maps are single-integer-keyed: the map still answers + // correctly, it just degenerates to one bucket. + assert_eq!(int_hash_of(&(1u32, 5u32)), int_hash_of(&(999u32, 5u32))); + assert_eq!(int_hash_of(&("abc", 4u32)), int_hash_of(&("zzzzzz", 4u32))); + + let mut map: IntMap<(u32, u32), u32> = IntMap::default(); + for a in 0..48u32 { + for b in 0..48u32 { + map.insert((a, b), a * 1000 + b); + } + } + assert_eq!(map.len(), 48 * 48); + for a in 0..48u32 { + for b in 0..48u32 { + assert_eq!(map.get(&(a, b)), Some(&(a * 1000 + b))); + } + } + assert_eq!(map.get(&(48, 0)), None); + } + + #[test] + fn test_outputs_do_not_depend_on_fragment_insertion_order() { + // The maps are hashed with a fixed seed, so their iteration order is stable but + // different from the default hasher's. Nothing observable may depend on it. + let remap_over = |old_frag_ids: Vec| { + let rewritten = RoaringTreemap::from_iter(old_frag_ids.iter().map(|f| addr(*f, 0))); + let num_rows = old_frag_ids.len() as u32; + RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: rewritten, + old_frag_ids, + new_frags: vec![(1000, num_rows)], + }]) + .unwrap() + }; + let forward = remap_over(vec![3, 1, 4, 1 << 20, 2 << 20, 9, 700_000]); + let reverse = remap_over(vec![700_000, 9, 2 << 20, 1 << 20, 4, 1, 3]); + + // `old_frag_ids` order is load-bearing for the row-to-row mapping, so only the + // set-shaped outputs are comparable across the two. + assert_eq!( + forward.affected_fragments(), + reverse.affected_fragments(), + "affected fragments must not depend on insertion order" + ); + assert_eq!( + forward.fully_deleted_fragments(), + reverse.fully_deleted_fragments() + ); + } + + proptest::proptest! { + /// The compact remap must answer exactly like a materialized old-to-new map. A + /// wrong answer here silently points an index at the wrong physical row, so this + /// compares the two forms address by address over randomized rewrites. + #[test] + fn test_compact_matches_direct_over_random_rewrites( + // Fragment id shapes: dense, strided by a power of two (the integer hasher's + // worst case), and sparse. + frag_seeds in proptest::collection::vec((0..3usize, 0..64u32), 1..7), + rows_per_frag in 1..24u32, + keep in proptest::collection::vec(proptest::bool::weighted(0.75), 6 * 24), + gaps in proptest::collection::vec(1..4u32, 1..5), + ) { + let mut old_frag_ids: Vec = Vec::new(); + for (shape, seed) in &frag_seeds { + let frag_id = match shape { + 0 => *seed, + 1 => (*seed + 1) << 20, + _ => seed.wrapping_mul(7919), + }; + if !old_frag_ids.contains(&frag_id) { + old_frag_ids.push(frag_id); + } + } + + // Rewritten rows are read fragment by fragment in `old_frag_ids` order, and + // by ascending offset within each fragment. + let mut rewritten = RoaringTreemap::new(); + let mut read_order: Vec = Vec::new(); + let mut covered: Vec = Vec::new(); + for (frag_index, frag_id) in old_frag_ids.iter().enumerate() { + for offset in 0..rows_per_frag { + covered.push(addr(*frag_id, offset)); + if keep[frag_index * rows_per_frag as usize + offset as usize] { + rewritten.insert(addr(*frag_id, offset)); + read_order.push(addr(*frag_id, offset)); + } + } + } + let total_rewritten = read_order.len() as u32; + proptest::prop_assume!(total_rewritten > 0); + + // Spread the rewritten rows over ascending new fragment ids, every fragment + // non-empty so the row counts still add up. + let parts = gaps.len().min(total_rewritten as usize); + let base = total_rewritten / parts as u32; + let remainder = total_rewritten % parts as u32; + let mut new_frags: Vec<(u32, u32)> = Vec::with_capacity(parts); + let mut new_frag_id = 10_000u32; + for (part, gap) in gaps.iter().take(parts).enumerate() { + let rows = base + u32::from((part as u32) < remainder); + new_frags.push((new_frag_id, rows)); + new_frag_id += gap; + } + + // The same mapping, materialized one address at a time. + let mut direct: HashMap> = HashMap::new(); + let mut read_order_iter = read_order.iter(); + for (new_frag_id, rows) in &new_frags { + for offset in 0..*rows { + let old = read_order_iter.next().unwrap(); + direct.insert(*old, Some(addr(*new_frag_id, offset))); + } + } + prop_assert!(read_order_iter.next().is_none()); + for old in &covered { + direct.entry(*old).or_insert(None); + } + + let compact = RowAddrRemap::compact([GroupInput { + rewritten_old_row_addrs: rewritten, + old_frag_ids: old_frag_ids.clone(), + new_frags, + }]) + .unwrap(); + let direct = RowAddrRemap::direct(direct); + + for old in &covered { + prop_assert_eq!( + compact.get(*old), + direct.get(*old), + "address {:#x} in fragments {:?}", + old, + old_frag_ids + ); + } + // Fragments no group covers are left alone. + for frag_id in [0xFFFF_FFF0u32, 0x7EEE_EEEE] { + prop_assert_eq!(compact.get(addr(frag_id, 0)), None); + } + prop_assert_eq!(compact.affected_fragments(), direct.affected_fragments()); + } + } +} diff --git a/rust/lance-core/src/utils/stable_partition.rs b/rust/lance-core/src/utils/stable_partition.rs new file mode 100644 index 00000000000..38bfcf2c81d --- /dev/null +++ b/rust/lance-core/src/utils/stable_partition.rs @@ -0,0 +1,697 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Stable-partition row mapping for reordered rewrites. +//! +//! A reordered rewrite (e.g. reclustering) reads `n` source fragments in scan +//! order and distributes their live rows across `m` destination fragments, +//! preserving relative row order within each destination (a stable partition). +//! Unlike compaction, the destination of a row cannot be derived from row +//! order alone, so the rewrite records one label per physical source row: +//! +//! * The label of the `i`-th physical source row (concatenated scan order) is +//! the index of its destination in the rewrite's ordered destination list. +//! * A deleted source row is labeled NULL: it moved nowhere. +//! +//! Ordering: +//! +//! The labels do not carry destination offsets; offsets are reconstructed by +//! counting, which is only correct when the rewrite obeys all of: +//! +//! * Labels are recorded in source physical-row order: fragments in scan +//! order, offsets ascending within each fragment. +//! * Each destination receives its rows in that same source order, and rows +//! are never re-sorted within a destination afterwards. +//! * The ordered destination list is fixed for the whole rewrite. +//! +//! A rewrite that routes rows through parallel writers must merge each +//! destination's output back into source order before recording this +//! mapping; otherwise the derived offsets address the wrong rows. +//! +//! This contract is deliberate scope, not an oversight: the format +//! represents stable partitions only. A rewrite that sorts rows *within* a +//! destination is not representable here — with source rows `[a, b]` +//! written to one destination as `[b, a]`, both labels are equal, and +//! counting would assign the offsets backwards. Such a rewrite needs a +//! per-row final-offset (permutation) encoding, which would be a separate +//! format rather than a relaxation of this one. +//! +//! Because each destination is filled in source scan order, the destination +//! row offset of a live row equals the number of earlier source rows with the +//! same label. This module provides that arithmetic without materializing an +//! O(rows) address map: +//! +//! * [`CountsMatrix`] — for every fixed-size block of source rows, the +//! cumulative per-label row count through the end of that block, as a +//! dense `num_blocks * m` grid. A block is the unit of storage, IO and +//! caching for the label column. The grid size is exact and data +//! independent (~1.5 MB for 50M rows across 500 destinations, ~61 MB at a +//! 1B-row rewrite across 1000); the encoded header names the +//! representation so sparser encodings can be added later without breaking +//! readers. +//! * [`translate_in_block`] — point lookup: destination offset = cumulative +//! count before the row's block (one array read) + the label's rank within +//! the block prefix. Touches one block of labels. +//! * [`SweepTranslator`] — sequential translation: per-label counters seeded +//! from a block boundary, then `offset = counter[label]++` per row. O(1) +//! per row, used for bulk remapping and whole-unit decodes. +//! +//! Label storage and file IO live in `lance-index`; this module is pure +//! arithmetic over decoded label blocks and the counts matrix. + +use crate::deepsize::{Context, DeepSizeOf}; +use crate::{Error, Result}; +use arrow_buffer::NullBuffer; + +/// Default number of source rows per block: the counts granularity and the +/// unit of label IO. +pub const DEFAULT_BLOCK_ROWS: u32 = 64 * 1024; + +/// Labels are u16 destination indices, so one rewrite addresses at most +/// 65536 destinations. +pub const MAX_DESTINATIONS: u32 = u16::MAX as u32 + 1; + +const COUNTS_MAGIC: &[u8; 4] = b"LSPC"; +const COUNTS_VERSION: u32 = 1; +/// magic + version + repr + num_destinations + block_rows + total_rows +const COUNTS_HEADER_BYTES: usize = 4 + 4 + 4 + 4 + 4 + 8; +/// Representation tag in the encoded header. Only the dense grid is written +/// today; other tag values are reserved for future representations (e.g. +/// per-destination sparse postings) and rejected with a clear error by +/// current readers. +const REPR_DENSE: u32 = 0; + +fn corrupt(message: impl Into) -> Error { + Error::corrupt_file_named("stable_partition_counts", message) +} + +/// Cumulative per-destination row counts at every block boundary. +/// +/// Row `b` of the grid holds, for each destination label `d`, the number of +/// source rows labeled `d` in blocks `0..=b`. The final row therefore holds +/// the total row count of every destination. Deleted (NULL-labeled) rows are +/// not counted; the deleted count of a block is implied by +/// `block length - sum of per-label deltas`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CountsMatrix { + /// Number of destinations `m`; labels are `0..m`. + num_destinations: u32, + /// Source rows per block. Only the final block may be shorter. + block_rows: u32, + /// Total physical source rows, including deleted rows. + total_rows: u64, + /// `num_blocks * num_destinations` cumulative counts, row-major by block. + cumulative: Vec, +} + +impl CountsMatrix { + pub fn num_destinations(&self) -> u32 { + self.num_destinations + } + + pub fn block_rows(&self) -> u32 { + self.block_rows + } + + pub fn total_rows(&self) -> u64 { + self.total_rows + } + + pub fn num_blocks(&self) -> usize { + self.cumulative.len() / self.num_destinations as usize + } + + /// The block containing physical source row `row`. + pub fn block_of(&self, row: u64) -> usize { + (row / u64::from(self.block_rows)) as usize + } + + /// The physical source row range covered by `block`. + pub fn block_range(&self, block: usize) -> std::ops::Range { + let start = block as u64 * u64::from(self.block_rows); + let end = (start + u64::from(self.block_rows)).min(self.total_rows); + start..end + } + + fn block_row(&self, block: usize) -> &[u32] { + let m = self.num_destinations as usize; + &self.cumulative[block * m..(block + 1) * m] + } + + /// Rows labeled `label` in blocks before `block`: the destination offset + /// of the first `label`-row inside `block`. + pub fn count_before(&self, block: usize, label: u16) -> u32 { + if block == 0 { + 0 + } else { + self.block_row(block - 1)[label as usize] + } + } + + /// Per-label counters at the start of `block`, seeding a + /// [`SweepTranslator`]. + pub fn counters_at_block(&self, block: usize) -> Vec { + if block == 0 { + vec![0; self.num_destinations as usize] + } else { + self.block_row(block - 1).to_vec() + } + } + + /// Total rows of destination `label`; the final cumulative row. + pub fn total(&self, label: u16) -> u32 { + let blocks = self.num_blocks(); + if blocks == 0 { + 0 + } else { + self.block_row(blocks - 1)[label as usize] + } + } + + /// Total live (labeled) rows across all destinations. + pub fn total_live_rows(&self) -> u64 { + let blocks = self.num_blocks(); + if blocks == 0 { + 0 + } else { + self.block_row(blocks - 1) + .iter() + .map(|&c| u64::from(c)) + .sum() + } + } + + /// Deleted (NULL-labeled) rows in blocks `0..=block`. + pub fn deleted_through(&self, block: usize) -> u64 { + let rows_through = self.block_range(block).end; + let live_through: u64 = self.block_row(block).iter().map(|&c| u64::from(c)).sum(); + rows_through - live_through + } + + /// Check internal consistency: per-label counts must be non-decreasing + /// across blocks and each block's live rows must fit its row range. + /// O(num_blocks * num_destinations), no label IO. Called when a row map + /// is opened, so a corrupt counts buffer fails loudly instead of + /// translating rows to wrong addresses. + pub fn validate(&self) -> Result<()> { + let mut prev_live = 0u64; + for block in 0..self.num_blocks() { + if block > 0 { + let (prev, cur) = (self.block_row(block - 1), self.block_row(block)); + if prev.iter().zip(cur).any(|(p, c)| c < p) { + return Err(corrupt(format!( + "cumulative counts decrease at block {block}" + ))); + } + } + let live_through: u64 = self.block_row(block).iter().map(|&c| u64::from(c)).sum(); + let range = self.block_range(block); + let block_len = range.end - range.start; + if live_through - prev_live > block_len { + return Err(corrupt(format!( + "block {block} accounts for more live rows than its {block_len} rows" + ))); + } + prev_live = live_through; + } + Ok(()) + } + + /// Serialize to the on-disk form stored in the row map file's global + /// buffer: a fixed header naming the representation, then the cumulative + /// counts as little-endian u32. + pub fn encode(&self) -> Vec { + let mut buf = Vec::with_capacity(COUNTS_HEADER_BYTES + self.cumulative.len() * 4); + buf.extend_from_slice(COUNTS_MAGIC); + buf.extend_from_slice(&COUNTS_VERSION.to_le_bytes()); + buf.extend_from_slice(&REPR_DENSE.to_le_bytes()); + buf.extend_from_slice(&self.num_destinations.to_le_bytes()); + buf.extend_from_slice(&self.block_rows.to_le_bytes()); + buf.extend_from_slice(&self.total_rows.to_le_bytes()); + for count in &self.cumulative { + buf.extend_from_slice(&count.to_le_bytes()); + } + buf + } + + /// Decode the on-disk form, checking structure exactly: magic, version, a + /// supported representation, shape legality and precise payload length. + /// Content invariants (monotone counts, per-block row budgets) are + /// checked by [`Self::validate`]. + pub fn decode(buf: &[u8]) -> Result { + let header: &[u8; COUNTS_HEADER_BYTES] = buf + .get(..COUNTS_HEADER_BYTES) + .and_then(|header| header.try_into().ok()) + .ok_or_else(|| { + corrupt(format!( + "counts buffer is {} bytes, shorter than the {COUNTS_HEADER_BYTES}-byte header", + buf.len() + )) + })?; + if &header[0..4] != COUNTS_MAGIC { + return Err(corrupt("counts buffer has a bad magic number")); + } + let version = u32::from_le_bytes(header[4..8].try_into().unwrap()); + if version != COUNTS_VERSION { + return Err(corrupt(format!("unsupported counts version {version}"))); + } + let repr = u32::from_le_bytes(header[8..12].try_into().unwrap()); + if repr != REPR_DENSE { + return Err(corrupt(format!( + "unsupported counts representation {repr}; this reader only supports the dense grid ({REPR_DENSE})" + ))); + } + let num_destinations = u32::from_le_bytes(header[12..16].try_into().unwrap()); + let block_rows = u32::from_le_bytes(header[16..20].try_into().unwrap()); + let total_rows = u64::from_le_bytes(header[20..28].try_into().unwrap()); + Self::check_shape(num_destinations, block_rows)?; + let num_blocks = total_rows.div_ceil(u64::from(block_rows)); + num_blocks + .checked_mul(u64::from(num_destinations)) + .and_then(|cells| cells.checked_mul(4)) + .filter(|&bytes| bytes == (buf.len() - COUNTS_HEADER_BYTES) as u64) + .ok_or_else(|| { + corrupt(format!( + "counts buffer of {} bytes does not match {num_blocks} blocks of {num_destinations} destinations", + buf.len() + )) + })?; + let mut cumulative = + Vec::with_capacity((num_blocks * u64::from(num_destinations)) as usize); + for chunk in buf[COUNTS_HEADER_BYTES..].as_chunks::<4>().0 { + cumulative.push(u32::from_le_bytes(*chunk)); + } + Ok(Self { + num_destinations, + block_rows, + total_rows, + cumulative, + }) + } + + fn check_shape(num_destinations: u32, block_rows: u32) -> Result<()> { + if num_destinations == 0 || num_destinations > MAX_DESTINATIONS { + return Err(Error::invalid_input(format!( + "stable partition needs 1..={MAX_DESTINATIONS} destinations, got {num_destinations}" + ))); + } + if block_rows == 0 { + return Err(Error::invalid_input( + "stable partition block_rows must be positive", + )); + } + Ok(()) + } + + fn check_label(&self, label: u16) -> Result<()> { + if u32::from(label) >= self.num_destinations { + return Err(Error::invalid_input(format!( + "label {label} is outside the {} destinations of this stable partition", + self.num_destinations + ))); + } + Ok(()) + } +} + +impl DeepSizeOf for CountsMatrix { + fn deep_size_of_children(&self, _: &mut Context) -> usize { + self.cumulative.capacity() * std::mem::size_of::() + } +} + +/// Streaming builder for a [`CountsMatrix`]: feed one label per physical +/// source row in scan order. +#[derive(Debug)] +pub struct CountsMatrixBuilder { + num_destinations: u32, + block_rows: u32, + running: Vec, + cumulative: Vec, + total_rows: u64, + rows_in_block: u32, +} + +impl CountsMatrixBuilder { + pub fn try_new(num_destinations: u32, block_rows: u32) -> Result { + CountsMatrix::check_shape(num_destinations, block_rows)?; + Ok(Self { + num_destinations, + block_rows, + running: vec![0; num_destinations as usize], + cumulative: Vec::new(), + total_rows: 0, + rows_in_block: 0, + }) + } + + /// Record the label of the next physical source row; `None` marks a + /// deleted row. + pub fn push(&mut self, label: Option) -> Result<()> { + if let Some(label) = label { + if u32::from(label) >= self.num_destinations { + return Err(Error::invalid_input(format!( + "label {label} is outside the {} destinations of this stable partition", + self.num_destinations + ))); + } + let counter = &mut self.running[label as usize]; + *counter = counter.checked_add(1).ok_or_else(|| { + Error::invalid_input(format!( + "destination {label} exceeds the u32 row-offset range" + )) + })?; + } + self.total_rows += 1; + self.rows_in_block += 1; + if self.rows_in_block == self.block_rows { + self.cumulative.extend_from_slice(&self.running); + self.rows_in_block = 0; + } + Ok(()) + } + + pub fn finish(mut self) -> CountsMatrix { + if self.rows_in_block > 0 { + self.cumulative.extend_from_slice(&self.running); + } + CountsMatrix { + num_destinations: self.num_destinations, + block_rows: self.block_rows, + total_rows: self.total_rows, + cumulative: self.cumulative, + } + } +} + +/// Rank of `label` among the first `upto` labels of a block: the number of +/// valid entries equal to `label` in `values[..upto]`. +pub fn rank_label_prefix( + values: &[u16], + validity: Option<&NullBuffer>, + upto: usize, + label: u16, +) -> u32 { + match validity { + // NULL slots hold arbitrary values after an encoding round trip, so + // they must be masked out of the count. + Some(validity) if validity.null_count() > 0 => values[..upto] + .iter() + .enumerate() + .filter(|&(i, &value)| value == label && validity.is_valid(i)) + .count() as u32, + _ => values[..upto] + .iter() + .filter(|&&value| value == label) + .count() as u32, + } +} + +/// Translate one physical source row using its decoded label block. +/// +/// `values`/`validity` are the labels of `block` (exactly the rows of +/// [`CountsMatrix::block_range`]); `pos_in_block` addresses the row within +/// them. Returns `None` when the row was deleted at the source, otherwise +/// `(destination index, destination row offset)`. +pub fn translate_in_block( + counts: &CountsMatrix, + block: usize, + values: &[u16], + validity: Option<&NullBuffer>, + pos_in_block: usize, +) -> Result> { + let block_range = counts.block_range(block); + let block_len = (block_range.end - block_range.start) as usize; + if values.len() != block_len { + return Err(Error::invalid_input(format!( + "block {block} holds {block_len} labels but {} were supplied", + values.len() + ))); + } + if pos_in_block >= block_len { + return Err(Error::invalid_input(format!( + "row {pos_in_block} is outside block {block} of {block_len} rows" + ))); + } + if validity.is_some_and(|validity| !validity.is_valid(pos_in_block)) { + return Ok(None); + } + let label = values[pos_in_block]; + counts.check_label(label)?; + let offset = counts.count_before(block, label) + + rank_label_prefix(values, validity, pos_in_block, label); + Ok(Some((label, offset))) +} + +/// Sequential translator: seed per-label counters from a block boundary, then +/// feed every physical source row's label in order. +#[derive(Debug)] +pub struct SweepTranslator { + counters: Vec, +} + +impl SweepTranslator { + /// Counters positioned at the first row of `start_block`. + pub fn new(counts: &CountsMatrix, start_block: usize) -> Result { + if start_block >= counts.num_blocks().max(1) { + return Err(Error::invalid_input(format!( + "start block {start_block} is outside the {} blocks of this stable partition", + counts.num_blocks() + ))); + } + Ok(Self { + counters: counts.counters_at_block(start_block), + }) + } + + /// Translate the next row. `None` in, `None` out for deleted rows. + #[inline] + pub fn advance(&mut self, label: Option) -> Result> { + let Some(label) = label else { + return Ok(None); + }; + let counter = self.counters.get_mut(label as usize).ok_or_else(|| { + Error::invalid_input(format!( + "label {label} is outside the destinations of this stable partition" + )) + })?; + let offset = *counter; + *counter += 1; + Ok(Some((label, offset))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::rngs::StdRng; + use rand::{Rng, SeedableRng}; + + /// ~1/8 deleted rows, labels uniform over `m`. + fn random_labels(rng: &mut StdRng, rows: usize, m: u32) -> Vec> { + (0..rows) + .map(|_| { + if rng.random_ratio(1, 8) { + None + } else { + Some(rng.random_range(0..m) as u16) + } + }) + .collect() + } + + fn build(labels: &[Option], m: u32, block_rows: u32) -> CountsMatrix { + let mut builder = CountsMatrixBuilder::try_new(m, block_rows).unwrap(); + for &label in labels { + builder.push(label).unwrap(); + } + builder.finish() + } + + /// Reference translation: scan all labels with per-destination counters. + fn reference(labels: &[Option]) -> Vec> { + let mut counters = std::collections::HashMap::new(); + labels + .iter() + .map(|label| { + label.map(|label| { + let counter = counters.entry(label).or_insert(0u32); + let offset = *counter; + *counter += 1; + (label, offset) + }) + }) + .collect() + } + + fn block_slices( + labels: &[Option], + counts: &CountsMatrix, + block: usize, + ) -> (Vec, Option) { + let range = counts.block_range(block); + let block_labels = &labels[range.start as usize..range.end as usize]; + // NULL slots get a garbage value on purpose: after an encoding round + // trip their contents are undefined and must not affect ranks. + let values = block_labels + .iter() + .map(|label| label.unwrap_or(0xBEEF_u64 as u16)) + .collect(); + let has_nulls = block_labels.iter().any(Option::is_none); + let validity = + has_nulls.then(|| NullBuffer::from_iter(block_labels.iter().map(Option::is_some))); + (values, validity) + } + + #[test] + fn test_counts_matrix_hand_example() { + // 10 rows, block_rows=4 -> blocks of 4, 4, 2. m=3, and destination 1 + // receives nothing in blocks 0 and 2. + let labels = [ + Some(0), + Some(2), + None, + Some(2), // block 0: d0=1, d2=2, 1 deleted + Some(1), + Some(1), + Some(0), + Some(2), // block 1: cum d0=2, d1=2, d2=3 + None, + Some(0), // block 2: cum d0=3, d1=2, d2=3, 2 deleted total + ]; + let counts = build(&labels, 3, 4); + counts.validate().unwrap(); + assert_eq!(counts.num_blocks(), 3); + assert_eq!(counts.total_rows(), 10); + assert_eq!(counts.block_range(2), 8..10); + + assert_eq!(counts.count_before(0, 2), 0); + assert_eq!(counts.count_before(1, 2), 2); + assert_eq!(counts.count_before(2, 0), 2); + // Destination 1 received nothing in blocks 0 and 2: lookups around + // the gap see the count of its last change (or 0). + assert_eq!(counts.count_before(1, 1), 0); + assert_eq!(counts.count_before(3, 1), 2); + assert_eq!( + (counts.total(0), counts.total(1), counts.total(2)), + (3, 2, 3) + ); + assert_eq!(counts.total_live_rows(), 8); + assert_eq!(counts.deleted_through(0), 1); + assert_eq!(counts.deleted_through(2), 2); + assert_eq!(counts.counters_at_block(1), vec![1, 0, 2]); + + // Exact multiple of block_rows: no partial block. + let exact = build(&labels[..8], 3, 4); + assert_eq!(exact.num_blocks(), 2); + assert_eq!(exact.total_rows(), 8); + + let empty = build(&[], 3, 4); + assert_eq!(empty.num_blocks(), 0); + assert_eq!(empty.total(1), 0); + assert_eq!(empty.total_live_rows(), 0); + empty.validate().unwrap(); + } + + #[test] + fn test_encode_decode_round_trip() { + let mut rng = StdRng::seed_from_u64(7); + let labels = random_labels(&mut rng, 1000, 5); + let counts = build(&labels, 5, 64); + let decoded = CountsMatrix::decode(&counts.encode()).unwrap(); + assert_eq!(decoded, counts); + decoded.validate().unwrap(); + + let empty = build(&[], 5, 64); + assert_eq!(CountsMatrix::decode(&empty.encode()).unwrap(), empty); + + // Structural corruption is caught at decode: too short, bad magic, + // truncated payload, unknown representation tag. + assert!(CountsMatrix::decode(&[]).is_err()); + let mut bad_magic = counts.encode(); + bad_magic[0] = b'X'; + assert!(CountsMatrix::decode(&bad_magic).is_err()); + let mut truncated = counts.encode(); + truncated.pop(); + assert!(CountsMatrix::decode(&truncated).is_err()); + let mut bad_repr = counts.encode(); + bad_repr[8] = 9; + assert!(CountsMatrix::decode(&bad_repr).is_err()); + + // Content corruption is caught by validate: shrink a later block's + // cumulative count below an earlier one. + let mut decreasing = counts; + let m = decreasing.num_destinations as usize; + decreasing.cumulative[m] = 0; + assert!( + CountsMatrix::decode(&decreasing.encode()) + .unwrap() + .validate() + .is_err() + ); + } + + #[test] + fn test_builder_rejects_bad_shapes_and_labels() { + assert!(CountsMatrixBuilder::try_new(0, 64).is_err()); + assert!(CountsMatrixBuilder::try_new(MAX_DESTINATIONS + 1, 64).is_err()); + assert!(CountsMatrixBuilder::try_new(1, 0).is_err()); + let mut builder = CountsMatrixBuilder::try_new(3, 4).unwrap(); + assert!(builder.push(Some(3)).is_err()); + assert!(builder.push(Some(2)).is_ok()); + } + + #[test] + fn test_point_and_sweep_match_reference() { + let m = 5u32; + let mut rng = StdRng::seed_from_u64(42); + let labels = random_labels(&mut rng, 1000, m); + let counts = build(&labels, m, 64); + counts.validate().unwrap(); + let expected = reference(&labels); + + // Point lookups, block by block, against the reference map. + for block in 0..counts.num_blocks() { + let (values, validity) = block_slices(&labels, &counts, block); + let range = counts.block_range(block); + for pos in 0..(range.end - range.start) as usize { + let translated = + translate_in_block(&counts, block, &values, validity.as_ref(), pos).unwrap(); + assert_eq!( + translated, + expected[range.start as usize + pos], + "row {}", + range.start as usize + pos + ); + } + } + + // A sweep from the start and one from every block boundary. + for start_block in 0..counts.num_blocks() { + let mut sweep = SweepTranslator::new(&counts, start_block).unwrap(); + let start_row = counts.block_range(start_block).start as usize; + for (row, &label) in labels.iter().enumerate().skip(start_row) { + assert_eq!( + sweep.advance(label).unwrap(), + expected[row], + "row {row} from block {start_block}" + ); + } + } + } + + #[test] + fn test_translate_in_block_input_checks() { + let labels = [Some(0), Some(1), None, Some(0)]; + let counts = build(&labels, 2, 4); + let (values, validity) = block_slices(&labels, &counts, 0); + // Wrong slice length and out-of-range row are rejected. + assert!(translate_in_block(&counts, 0, &values[..3], None, 0).is_err()); + assert!(translate_in_block(&counts, 0, &values, validity.as_ref(), 4).is_err()); + // A label outside the destination list is data corruption. + assert!(translate_in_block(&counts, 0, &[0, 9, 0, 0], None, 1).is_err()); + assert!(SweepTranslator::new(&counts, 1).is_err()); + let mut sweep = SweepTranslator::new(&counts, 0).unwrap(); + assert!(sweep.advance(Some(9)).is_err()); + } +} diff --git a/rust/lance-core/src/utils/tempfile.rs b/rust/lance-core/src/utils/tempfile.rs index f4b804c5c70..a5a13ba26f1 100644 --- a/rust/lance-core/src/utils/tempfile.rs +++ b/rust/lance-core/src/utils/tempfile.rs @@ -268,12 +268,14 @@ impl Deref for TempStdFile { } } -/// A temporary file that is exposed as an object store path +/// A unique path to a temporary file, exposed as an object store path /// -/// This is a wrapper around [`TempFile`] that exposes the path as an object store path. -/// It is useful when you need to create a temporary file that is only used as an object store path. +/// Unlike [`TempFile`], this does not create an empty file. We create a +/// temporary directory and then construct a path inside it, following the +/// same pattern as [`TempStdPath`]. This avoids holding an open file handle, +/// which on Windows would prevent atomic renames to the same path. pub struct TempObjFile { - _tempfile: TempFile, + _tempdir: TempDir, path: ObjPath, } @@ -293,10 +295,10 @@ impl std::ops::Deref for TempObjFile { impl Default for TempObjFile { fn default() -> Self { - let tempfile = TempFile::default(); - let path = tempfile.obj_path(); + let tempdir = TempDir::default(); + let path = ObjPath::parse(format!("{}/some_file", tempdir.path_str())).unwrap(); Self { - _tempfile: tempfile, + _tempdir: tempdir, path, } } diff --git a/rust/lance-core/src/utils/testing.rs b/rust/lance-core/src/utils/testing.rs index f19d66f67e2..96f6dd9caad 100644 --- a/rust/lance-core/src/utils/testing.rs +++ b/rust/lance-core/src/utils/testing.rs @@ -10,14 +10,19 @@ use futures::stream::BoxStream; use futures::{StreamExt, TryStreamExt}; use object_store::path::Path; use object_store::{ - Error as OSError, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore, - PutMultipartOptions, PutOptions, PutPayload, PutResult, Result as OSResult, + CopyOptions, Error as OSError, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, + ObjectStore, PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, + Result as OSResult, }; 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 +126,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( @@ -144,12 +185,12 @@ impl ObjectStore for ProxyObjectStore { async fn get_opts(&self, location: &Path, options: GetOptions) -> OSResult { self.before_method("get_opts", location)?; - self.target.get_opts(location, options).await - } - - async fn get_range(&self, location: &Path, range: Range) -> OSResult { - self.before_method("get_range", location)?; - self.target.get_range(location, range).await + let is_head = options.head; + let mut result = self.target.get_opts(location, options).await?; + if is_head { + result.meta = self.transform_meta("head", result.meta)?; + } + Ok(result) } async fn get_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { @@ -157,15 +198,24 @@ impl ObjectStore for ProxyObjectStore { self.target.get_ranges(location, ranges).await } - async fn head(&self, location: &Path) -> OSResult { - self.before_method("head", location)?; - let meta = self.target.head(location).await?; - self.transform_meta("head", meta) - } - - async fn delete(&self, location: &Path) -> OSResult<()> { - self.before_method("delete", location)?; - self.target.delete(location).await + fn delete_stream( + &self, + locations: BoxStream<'static, OSResult>, + ) -> BoxStream<'static, OSResult> { + let policy = Arc::clone(&self.policy); + let checked = locations + .and_then(move |location| { + let result = { + let policy = policy.lock().unwrap(); + policy + .before_policies + .values() + .try_for_each(|policy| policy("delete", &location).map_err(OSError::from)) + }; + future::ready(result.map(|_| location)) + }) + .boxed(); + self.target.delete_stream(checked) } fn list(&self, prefix: Option<&Path>) -> BoxStream<'static, OSResult> { @@ -189,18 +239,62 @@ impl ObjectStore for ProxyObjectStore { self.target.list_with_delimiter(prefix).await } - async fn copy(&self, from: &Path, to: &Path) -> OSResult<()> { + async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> { self.before_method("copy", from)?; - self.target.copy(from, to).await + self.target.copy_opts(from, to, opts).await } - async fn rename(&self, from: &Path, to: &Path) -> OSResult<()> { + async fn rename_opts(&self, from: &Path, to: &Path, opts: RenameOptions) -> OSResult<()> { self.before_method("rename", from)?; - self.target.rename(from, to).await + self.target.rename_opts(from, to, opts).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_ranges(&self, location: &Path, ranges: &[Range]) -> OSResult> { + self.target.get_ranges(location, ranges).await + } + + fn delete_stream( + &self, + locations: BoxStream<'static, OSResult>, + ) -> BoxStream<'static, OSResult> { + self.target.delete_stream(locations) + } + + 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_if_not_exists(&self, from: &Path, to: &Path) -> OSResult<()> { - self.before_method("copy_if_not_exists", from)?; - self.target.copy_if_not_exists(from, to).await + async fn copy_opts(&self, from: &Path, to: &Path, opts: CopyOptions) -> OSResult<()> { + self.target.copy_opts(from, to, opts).await } } diff --git a/rust/lance-core/src/utils/tokio.rs b/rust/lance-core/src/utils/tokio.rs index 88a8d9d9d85..9137c3631c9 100644 --- a/rust/lance-core/src/utils/tokio.rs +++ b/rust/lance-core/src/utils/tokio.rs @@ -2,11 +2,9 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::sync::atomic::Ordering; -use std::sync::{atomic, LazyLock}; +use std::sync::{LazyLock, atomic}; use std::time::Duration; -use crate::Result; - use futures::{Future, FutureExt}; use tokio::runtime::{Builder, Runtime}; use tracing::Span; @@ -23,8 +21,8 @@ pub fn get_num_compute_intensive_cpus() -> usize { } fn calculate_num_compute_intensive_cpus() -> usize { - if let Ok(user_specified) = std::env::var("LANCE_CPU_THREADS") { - return user_specified.parse().unwrap(); + if let Ok(raw) = std::env::var("LANCE_CPU_THREADS") { + return parse_env_usize("LANCE_CPU_THREADS", &raw, 1).unwrap_or_else(|e| panic!("{e}")); } let cpus = num_cpus::get(); @@ -44,12 +42,38 @@ fn calculate_num_compute_intensive_cpus() -> usize { num_cpus::get() - *IO_CORE_RESERVATION } -pub static IO_CORE_RESERVATION: LazyLock = LazyLock::new(|| { - std::env::var("LANCE_IO_CORE_RESERVATION") - .unwrap_or("2".to_string()) +/// Parse an integer environment variable, rejecting values below `min`. +/// +/// The error names the variable, so a bad value is diagnosable instead of +/// surfacing as a bare `ParseIntError` or, for `LANCE_CPU_THREADS=0`, a panic +/// deep inside tokio's `max_blocking_threads`. +fn parse_env_usize(name: &str, raw: &str, min: usize) -> Result { + let value: usize = raw + .trim() .parse() - .unwrap() -}); + .map_err(|e| format!("environment variable {name} must be an integer, got {raw:?}: {e}"))?; + if value < min { + return Err(format!( + "environment variable {name} must be at least {min}, got {value}" + )); + } + Ok(value) +} + +/// Number of CPU cores held back for I/O and control tasks. +/// +/// Overridable via the `LANCE_IO_CORE_RESERVATION` environment variable; +/// defaults to `2` when unset. `0` is allowed (reserve nothing); +/// [`get_num_compute_intensive_cpus`] subtracts this from the core count to +/// size the compute pool. A non-integer value panics on first access with an +/// error naming the variable. +pub static IO_CORE_RESERVATION: LazyLock = + LazyLock::new(|| match std::env::var("LANCE_IO_CORE_RESERVATION") { + Ok(raw) => { + parse_env_usize("LANCE_IO_CORE_RESERVATION", &raw, 0).unwrap_or_else(|e| panic!("{e}")) + } + Err(_) => 2, + }); fn create_runtime() -> Runtime { Builder::new_multi_thread() @@ -68,11 +92,15 @@ static RUNTIME_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false); static ATFORK_INSTALLED: atomic::AtomicBool = atomic::AtomicBool::new(false); -fn global_cpu_runtime() -> &'static mut Runtime { +fn global_cpu_runtime() -> &'static Runtime { loop { let ptr = CPU_RUNTIME.load(Ordering::SeqCst); if !ptr.is_null() { - return unsafe { &mut *ptr }; + // SAFETY: `ptr` was produced by `Box::into_raw` below and is only ever + // reset to null by `atfork_tokio_child` in the forked child (single- + // threaded, async-signal context). The `Box` is never reclaimed, so the + // `Runtime` lives for the rest of the process. + return unsafe { &*ptr }; } if !RUNTIME_INSTALLED.fetch_or(true, Ordering::SeqCst) { break; @@ -84,7 +112,9 @@ fn global_cpu_runtime() -> &'static mut Runtime { } let new_ptr = Box::into_raw(Box::new(create_runtime())); CPU_RUNTIME.store(new_ptr, Ordering::SeqCst); - unsafe { &mut *new_ptr } + // SAFETY: `new_ptr` was just obtained from `Box::into_raw`, so it is non-null, + // aligned, and points to a live `Runtime` that is never reclaimed. + unsafe { &*new_ptr } } /// After a fork() operation, force re-creation of the BackgroundExecutor. Note: this function @@ -110,16 +140,133 @@ fn install_atfork() {} /// /// This can also be used to convert a big chunk of synchronous work into a future /// so that it can be run in parallel with something like StreamExt::buffered() -pub fn spawn_cpu Result + Send + 'static, R: Send + 'static>( +/// +/// # Only hand over substantial CPU work +/// +/// Dispatching to the pool has real overhead (a `spawn_blocking` hop plus a oneshot +/// channel round trip). As a rule of thumb the closure should be expected to do at +/// least ~100µs of CPU work; below that the thread-pool overhead is likely to +/// outweigh any parallelism benefit, and the work is better left inline. +/// +/// # The task must never wait on anything +/// +/// The CPU pool is sized to [`get_num_compute_intensive_cpus`], which is +/// `max(1, num_cpus - LANCE_IO_CORE_RESERVATION)`. On a big host that is plenty of +/// workers (e.g. 62 on a 64-core box), but in resource-constrained environments it can +/// collapse to a **single blocking thread** — on machines with `<= 3` visible CPUs +/// (1-vCPU VMs, CI runners, CPU-limited Kubernetes pods) the pool has exactly one +/// worker. A closure passed to `spawn_cpu` occupies one of these threads for its entire +/// lifetime, including any time it spends *parked*. So the closure must only consume +/// CPU and return; it must +/// **never** block, wait, or park. Concretely, the closure must not, directly or +/// transitively: +/// +/// * **No channels** — no blocking send/recv (`send_blocking`, blocking `recv`, etc.). +/// A full/empty channel parks the thread, and whatever would drain/fill the channel +/// may need the same pool to run. +/// * **No I/O** — no file, network, or object-store reads/writes, and no disk spills. +/// I/O parks the thread while making no progress on CPU work. +/// * **No locks** — no acquiring a contended lock (or any lock that is held across an +/// `.await` elsewhere). Waiting for the lock parks the thread. +/// * **No `block_on` / `.blocking_*`** — never drive or wait on another async task +/// from inside the closure. +/// +/// If any of these hold, the parked thread can starve the exact work that would +/// unblock it, deadlocking the whole pool with no timeout and no error — a silent +/// hang at 0% CPU. (See .) When work +/// needs to wait on a channel/lock/I/O, keep the waiting in an async task and only +/// hand the pure-CPU portion to `spawn_cpu`, e.g. build each batch with `spawn_cpu` +/// and dispatch it with `tx.send(batch).await` in the surrounding async code. +pub fn spawn_cpu< + E: std::error::Error + Send + 'static, + F: FnOnce() -> std::result::Result + Send + 'static, + R: Send + 'static, +>( func: F, -) -> impl Future> { - let (send, recv) = tokio::sync::oneshot::channel(); +) -> impl Future> { // Propagate the current span into the task let span = Span::current(); - global_cpu_runtime().spawn_blocking(move || { + let handle = global_cpu_runtime().spawn_blocking(move || { let _span_guard = span.enter(); - let result = func(); - let _ = send.send(result); + func() }); - recv.map(|res| res.unwrap()) + // Awaited through the join handle, not a result channel: a panic in `func` + // arrives as a `JoinError` still carrying its payload, so resuming it + // re-raises the original panic in the caller. Reporting the closure's + // outcome over a channel instead loses that -- the sender drops unsent and + // every panic in any `spawn_cpu` closure surfaces identically as an opaque + // `RecvError`, pointing here rather than at the fault. + handle.map(|res| match res { + Ok(result) => result, + Err(join_error) => match join_error.try_into_panic() { + Ok(panic) => std::panic::resume_unwind(panic), + // The CPU runtime outlives every caller, so its tasks are not + // cancelled out from under one. + Err(join_error) => panic!("spawn_cpu task failed: {join_error}"), + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A panic in the closure must reach the caller intact. + /// + /// Reporting the closure's outcome over a channel loses it: the sender + /// drops unsent and the caller can only see an opaque receive error, so + /// every panic in every `spawn_cpu` closure looks the same. + #[tokio::test] + async fn spawn_cpu_reraises_the_closure_panic() { + let hook = std::panic::take_hook(); + std::panic::set_hook(Box::new(|_| {})); + let joined = tokio::spawn(async { + spawn_cpu(|| -> std::result::Result<(), std::io::Error> { + panic!("the original message") + }) + .await + }) + .await; + std::panic::set_hook(hook); + + let payload = joined + .expect_err("the closure's panic propagates to the caller") + .into_panic(); + let message = payload + .downcast_ref::<&str>() + .copied() + .expect("the original payload survives"); + assert_eq!(message, "the original message"); + } + + // The env vars feed process-global `LazyLock`s that read once and are read + // in parallel by other tests, so the pure parser is tested directly rather + // than by mutating the environment. + + #[test] + fn parses_valid_value_and_trims_surrounding_whitespace() { + assert_eq!(parse_env_usize("VAR", "8", 1).unwrap(), 8); + assert_eq!(parse_env_usize("VAR", " 8 ", 1).unwrap(), 8); + } + + #[test] + fn rejects_non_integer_naming_the_variable() { + let err = parse_env_usize("LANCE_CPU_THREADS", "abc", 1).unwrap_err(); + assert!(err.contains("LANCE_CPU_THREADS"), "{err}"); + assert!(err.contains("must be an integer"), "{err}"); + } + + #[test] + fn rejects_value_below_minimum() { + // LANCE_CPU_THREADS=0 parses fine but would panic in tokio's + // max_blocking_threads(0); the minimum stops it at the boundary. + let err = parse_env_usize("LANCE_CPU_THREADS", "0", 1).unwrap_err(); + assert!(err.contains("at least 1"), "{err}"); + } + + #[test] + fn allows_zero_when_minimum_is_zero() { + // LANCE_IO_CORE_RESERVATION=0 is valid: no cores reserved for IO. + assert_eq!(parse_env_usize("VAR", "0", 0).unwrap(), 0); + } } diff --git a/rust/lance-core/src/utils/tracing.rs b/rust/lance-core/src/utils/tracing.rs index e126fade06d..e1f19e3c6f4 100644 --- a/rust/lance-core/src/utils/tracing.rs +++ b/rust/lance-core/src/utils/tracing.rs @@ -66,6 +66,7 @@ pub const AUDIT_TYPE_DELETION: &str = "deletion"; pub const AUDIT_TYPE_MANIFEST: &str = "manifest"; pub const AUDIT_TYPE_INDEX: &str = "index"; pub const AUDIT_TYPE_DATA: &str = "data"; +pub const AUDIT_TYPE_TRANSACTION: &str = "transaction"; pub const TRACE_FILE_CREATE: &str = "create"; pub const TRACE_IO_EVENTS: &str = "lance::io_events"; pub const IO_TYPE_OPEN_SCALAR: &str = "open_scalar_index"; @@ -84,3 +85,4 @@ pub const DATASET_DELETING_EVENT: &str = "deleting"; pub const DATASET_COMPACTING_EVENT: &str = "compacting"; pub const DATASET_CLEANING_EVENT: &str = "cleaning"; pub const DATASET_LOADING_EVENT: &str = "loading"; +pub const TRACE_OBJECT_STORE_THROTTLE: &str = "lance::object_store::throttle"; diff --git a/rust/lance-core/tests/cache_key_allocations.rs b/rust/lance-core/tests/cache_key_allocations.rs new file mode 100644 index 00000000000..ef73d485ef5 --- /dev/null +++ b/rust/lance-core/tests/cache_key_allocations.rs @@ -0,0 +1,166 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::alloc::{GlobalAlloc, Layout, System}; +use std::borrow::Cow; +use std::cell::Cell; +use std::hint::black_box; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use lance_core::cache::{CacheKey, CacheKeySchema, CacheNamespace, InternalCacheKey, KeyBuilder}; + +struct TrackingAllocator; + +thread_local! { + static TRACK_ALLOCATIONS: Cell = const { Cell::new(false) }; +} + +static ALLOCATION_COUNT: AtomicUsize = AtomicUsize::new(0); + +fn record_allocation() { + if TRACK_ALLOCATIONS.try_with(Cell::get).unwrap_or(false) { + ALLOCATION_COUNT.fetch_add(1, Ordering::Relaxed); + } +} + +unsafe impl GlobalAlloc for TrackingAllocator { + unsafe fn alloc(&self, layout: Layout) -> *mut u8 { + record_allocation(); + unsafe { System.alloc(layout) } + } + + unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 { + record_allocation(); + unsafe { System.alloc_zeroed(layout) } + } + + unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { + unsafe { System.dealloc(ptr, layout) } + } + + unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 { + record_allocation(); + unsafe { System.realloc(ptr, layout, new_size) } + } +} + +#[global_allocator] +static ALLOCATOR: TrackingAllocator = TrackingAllocator; + +fn measured_allocations(operation: impl FnOnce()) -> usize { + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + ALLOCATION_COUNT.store(0, Ordering::Relaxed); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(true)); + operation(); + TRACK_ALLOCATIONS.with(|tracking| tracking.set(false)); + ALLOCATION_COUNT.load(Ordering::Relaxed) +} + +fn prepare_key(namespace: CacheNamespace, key: &K) -> InternalCacheKey { + let mut builder = KeyBuilder::new(namespace, K::stable_type_id(), K::schema()); + key.write_key(&mut builder); + builder.finish() +} + +struct PageKey { + path: &'static str, + column_index: u32, + page_index: u64, +} + +impl CacheKey for PageKey { + type ValueType = u64; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed("unused") + } + + fn type_name() -> &'static str { + "allocation-test-page" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("allocation-test-page", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_str(self.path); + builder.write_u32(self.column_index); + builder.write_u64(self.page_index); + } +} + +struct OptionalUuidKey { + generation: u64, + uuid: Option<[u8; 16]>, +} + +impl CacheKey for OptionalUuidKey { + type ValueType = u64; + + fn key(&self) -> Cow<'_, str> { + Cow::Borrowed("unused") + } + + fn type_name() -> &'static str { + "allocation-test-optional-uuid" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("allocation-test-optional-uuid", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u64(self.generation); + match self.uuid { + Some(uuid) => { + builder.write_some(); + builder.write_fixed_bytes(&uuid); + } + None => builder.write_none(), + } + } +} + +#[test] +fn production_shaped_typed_keys_allocate_nothing_after_warmup() { + let namespace = CacheNamespace::root() + .child("tenant-with-a-long-stable-identifier") + .child("index-with-a-long-stable-identifier"); + let page = PageKey { + path: "indices/01999f62-c3c2-7d6f-820d-22e7db948f31/pages/000000000042.lance", + column_index: 17, + page_index: 42, + }; + let uuid = OptionalUuidKey { + generation: 9, + uuid: Some(*b"0123456789abcdef"), + }; + let no_uuid = OptionalUuidKey { + generation: 10, + uuid: None, + }; + + black_box(prepare_key(namespace, &page)); + black_box(prepare_key(namespace, &uuid)); + black_box(prepare_key(namespace, &no_uuid)); + + assert_eq!( + measured_allocations(|| { + black_box(prepare_key(namespace, &page)); + }), + 0 + ); + assert_eq!( + measured_allocations(|| { + black_box(prepare_key(namespace, &uuid)); + }), + 0 + ); + assert_eq!( + measured_allocations(|| { + black_box(prepare_key(namespace, &no_uuid)); + }), + 0 + ); +} diff --git a/rust/lance-datafusion/Cargo.toml b/rust/lance-datafusion/Cargo.toml index 47315ce4712..8b9c702d2fb 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 @@ -23,24 +24,32 @@ datafusion-physical-expr.workspace = true datafusion-substrait = {workspace = true, optional = true} datafusion.workspace = true futures.workspace = true +half.workspace = true jsonb = {workspace = true} lance-arrow.workspace = true lance-core = {workspace = true, features = ["datafusion"]} -lance-datagen.workspace = true -lance-geo = {workspace = true} +lance-datagen = {workspace = true, optional = true} +lance-geo = {workspace = true, optional = true} chrono.workspace = true log.workspace = true pin-project.workspace = true prost.workspace = true -snafu.workspace = true tokio.workspace = true tracing.workspace = true +[build-dependencies] +prost-build.workspace = true +protobuf-src = { workspace = true, optional = true } + [dev-dependencies] lance-datagen.workspace = true +rstest.workspace = true [features] +datagen = ["dep:lance-datagen"] +geo = ["dep:lance-geo"] substrait = ["dep:datafusion-substrait"] +protoc = ["dep:protobuf-src"] [lints] workspace = true diff --git a/rust/lance-datafusion/build.rs b/rust/lance-datafusion/build.rs new file mode 100644 index 00000000000..59f63c4b8ed --- /dev/null +++ b/rust/lance-datafusion/build.rs @@ -0,0 +1,27 @@ +// 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.protoc_arg("--experimental_allow_proto3_optional"); + prost_build.enable_type_names(); + prost_build.compile_protos( + &[ + "./protos/table_identifier.proto", + "./protos/filtered_read.proto", + ], + &["./protos"], + )?; + + Ok(()) +} diff --git a/rust/lance-datafusion/protos b/rust/lance-datafusion/protos new file mode 120000 index 00000000000..69d0d0d54b0 --- /dev/null +++ b/rust/lance-datafusion/protos @@ -0,0 +1 @@ +../../protos \ No newline at end of file diff --git a/rust/lance-datafusion/src/aggregate.rs b/rust/lance-datafusion/src/aggregate.rs new file mode 100644 index 00000000000..3b4ee96b719 --- /dev/null +++ b/rust/lance-datafusion/src/aggregate.rs @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Aggregate specification for DataFusion aggregates. + +use datafusion::logical_expr::Expr; + +use crate::planner::Planner; + +/// Aggregate specification with group by and aggregate expressions. +#[derive(Debug, Clone)] +pub struct Aggregate { + /// Expressions to group by (e.g., column references). + pub group_by: Vec, + /// Aggregate function expressions (e.g., SUM, COUNT, AVG). + /// Use `.alias()` on the expression to set output column names. + pub aggregates: Vec, +} + +impl Aggregate { + /// Create a new Aggregate. + pub fn new(group_by: Vec, aggregates: Vec) -> Self { + Self { + group_by, + aggregates, + } + } + + /// Compute column names required by this aggregate. + /// + /// For COUNT(*), this returns empty. For SUM(x), GROUP BY y, this returns [x, y]. + pub fn required_columns(&self) -> Vec { + let mut required_columns = Vec::new(); + for expr in self.group_by.iter().chain(self.aggregates.iter()) { + required_columns.extend(Planner::column_names_in_expr(expr)); + } + required_columns.sort(); + required_columns.dedup(); + required_columns + } +} diff --git a/rust/lance-datafusion/src/chunker.rs b/rust/lance-datafusion/src/chunker.rs index 8c2cc8ddadd..63523460899 100644 --- a/rust/lance-datafusion/src/chunker.rs +++ b/rust/lance-datafusion/src/chunker.rs @@ -7,12 +7,12 @@ use std::{collections::VecDeque, task::Context}; use arrow::compute::kernels; use arrow_array::RecordBatch; -use datafusion::physical_plan::{stream::RecordBatchStreamAdapter, SendableRecordBatchStream}; +use datafusion::physical_plan::{SendableRecordBatchStream, stream::RecordBatchStreamAdapter}; use datafusion_common::DataFusionError; -use futures::{ready, Stream, StreamExt, TryStreamExt}; +use futures::{Stream, StreamExt, TryStreamExt, ready}; -use lance_core::error::DataFusionResult; use lance_core::Result; +use lance_core::error::DataFusionResult; /// Wraps a [`SendableRecordBatchStream`] into a stream of RecordBatch chunks of /// a given size. This slices but does not copy any buffers. @@ -42,8 +42,8 @@ impl BatchReaderChunker { buffer_total - self.i } - async fn fill_buffer(&mut self) -> Result<()> { - while self.buffered_len() < self.output_size { + async fn fill_buffer(&mut self, output_size: usize) -> Result<()> { + while self.buffered_len() < output_size { match self.inner.next().await { Some(Ok(batch)) => self.buffered.push_back(batch), Some(Err(e)) => return Err(e.into()), @@ -54,7 +54,11 @@ impl BatchReaderChunker { } async fn next(&mut self) -> Option>> { - match self.fill_buffer().await { + self.next_sized(self.output_size).await + } + + async fn next_sized(&mut self, output_size: usize) -> Option>> { + match self.fill_buffer(output_size).await { Ok(_) => {} Err(e) => return Some(Err(e)), }; @@ -63,7 +67,7 @@ impl BatchReaderChunker { let mut rows_collected = 0; - while rows_collected < self.output_size { + while rows_collected < output_size { if let Some(batch) = self.buffered.pop_front() { // Skip empty batch if batch.num_rows() == 0 { @@ -72,7 +76,7 @@ impl BatchReaderChunker { let rows_remaining_in_batch = batch.num_rows() - self.i; let rows_to_take = - std::cmp::min(rows_remaining_in_batch, self.output_size - rows_collected); + std::cmp::min(rows_remaining_in_batch, output_size - rows_collected); if rows_to_take == rows_remaining_in_batch { // We're taking the whole batch, so we can just move it @@ -104,6 +108,53 @@ impl BatchReaderChunker { Some(Ok(batches)) } } + + async fn next_at_most(&mut self, output_size: usize) -> Option>> { + loop { + let batch = match self.buffered.pop_front() { + Some(batch) => batch, + None => match self.inner.next().await { + Some(Ok(batch)) => batch, + Some(Err(error)) => return Some(Err(error.into())), + None => return None, + }, + }; + + if batch.num_rows() == 0 { + continue; + } + + let rows_remaining_in_batch = batch.num_rows() - self.i; + let rows_to_take = rows_remaining_in_batch.min(output_size); + if rows_to_take == rows_remaining_in_batch { + let batch = if self.i == 0 { + batch + } else { + batch.slice(self.i, rows_to_take) + }; + self.i = 0; + return Some(Ok(vec![batch])); + } + + let output = batch.slice(self.i, rows_to_take); + self.i += rows_to_take; + self.buffered.push_front(batch); + return Some(Ok(vec![output])); + } + } +} + +struct VariableBatchReaderChunker { + chunker: BatchReaderChunker, + output_sizes: I, + is_done: bool, +} + +struct VariableBreakStreamState { + chunker: BatchReaderChunker, + output_sizes: I, + rows_remaining: Option, + is_done: bool, } struct BreakStreamState { @@ -186,6 +237,210 @@ pub fn chunk_stream( .boxed() } +/// Preserve input batch boundaries while inserting the requested row boundaries. +/// +/// The requested sizes must describe the complete input. Unlike +/// [`chunk_stream_with_sizes`], this does not combine adjacent input batches. It +/// only slices a batch when it crosses a requested boundary. +/// +/// # Example +/// +/// ``` +/// # use datafusion::physical_plan::SendableRecordBatchStream; +/// # use lance_datafusion::chunker::break_stream_with_sizes; +/// # fn split_stream(stream: SendableRecordBatchStream) { +/// let batches = break_stream_with_sizes(stream, vec![512, 512, 256]); +/// # drop(batches); +/// # } +/// ``` +pub fn break_stream_with_sizes( + stream: SendableRecordBatchStream, + output_sizes: I, +) -> Pin>> + Send>> +where + I: IntoIterator, + I::IntoIter: Send + 'static, +{ + let state = VariableBreakStreamState { + chunker: BatchReaderChunker::new(stream, 1), + output_sizes: output_sizes.into_iter(), + rows_remaining: None, + is_done: false, + }; + futures::stream::unfold(state, |mut state| async move { + if state.is_done { + return None; + } + + if state.rows_remaining.is_none() { + let Some(output_size) = state.output_sizes.next() else { + return match state.chunker.next_at_most(1).await { + None => None, + Some(Ok(_)) => { + state.is_done = true; + Some(( + Err(lance_core::Error::invalid_input( + "Input contained more rows than the requested chunk sizes", + )), + state, + )) + } + Some(Err(error)) => { + state.is_done = true; + Some((Err(error), state)) + } + }; + }; + if output_size == 0 { + state.is_done = true; + return Some(( + Err(lance_core::Error::invalid_input( + "Requested chunk sizes must be greater than zero", + )), + state, + )); + } + state.rows_remaining = Some(output_size); + } + + let Some(rows_remaining) = state.rows_remaining else { + state.is_done = true; + return Some(( + Err(lance_core::Error::internal( + "Requested chunk boundary was not initialized", + )), + state, + )); + }; + match state.chunker.next_at_most(rows_remaining).await { + Some(Ok(batches)) => { + let actual_size = batches.iter().map(RecordBatch::num_rows).sum::(); + let Some(rows_remaining) = rows_remaining.checked_sub(actual_size) else { + state.is_done = true; + return Some(( + Err(lance_core::Error::internal( + "A boundary-preserving chunk exceeded its requested row count", + )), + state, + )); + }; + state.rows_remaining = (rows_remaining > 0).then_some(rows_remaining); + Some((Ok(batches), state)) + } + Some(Err(error)) => { + state.is_done = true; + Some((Err(error), state)) + } + None => { + state.is_done = true; + Some(( + Err(lance_core::Error::invalid_input(format!( + "Input ended with {rows_remaining} rows remaining in a requested chunk" + ))), + state, + )) + } + } + }) + .boxed() +} + +/// Given a stream of record batches, yield chunks with the requested row counts. +/// +/// The requested sizes must describe the complete input. An error is returned if +/// the input ends early, contains additional rows, or a requested size is zero. +/// Sizes are consumed lazily as chunks are requested. +/// +/// # Example +/// +/// ``` +/// # use datafusion::physical_plan::SendableRecordBatchStream; +/// # use lance_datafusion::chunker::chunk_stream_with_sizes; +/// # fn split_stream(stream: SendableRecordBatchStream) { +/// let chunks = chunk_stream_with_sizes(stream, vec![512, 512, 256]); +/// # drop(chunks); +/// # } +/// ``` +pub fn chunk_stream_with_sizes( + stream: SendableRecordBatchStream, + output_sizes: I, +) -> Pin>> + Send>> +where + I: IntoIterator, + I::IntoIter: Send + 'static, +{ + let state = VariableBatchReaderChunker { + chunker: BatchReaderChunker::new(stream, 1), + output_sizes: output_sizes.into_iter(), + is_done: false, + }; + futures::stream::unfold(state, |mut state| async move { + if state.is_done { + return None; + } + + let Some(output_size) = state.output_sizes.next() else { + return match state.chunker.next_sized(1).await { + None => None, + Some(Ok(_)) => { + state.is_done = true; + Some(( + Err(lance_core::Error::invalid_input( + "Input contained more rows than the requested chunk sizes", + )), + state, + )) + } + Some(Err(error)) => { + state.is_done = true; + Some((Err(error), state)) + } + }; + }; + + if output_size == 0 { + state.is_done = true; + return Some(( + Err(lance_core::Error::invalid_input( + "Requested chunk sizes must be greater than zero", + )), + state, + )); + } + + match state.chunker.next_sized(output_size).await { + Some(Ok(batches)) => { + let actual_size = batches.iter().map(RecordBatch::num_rows).sum::(); + if actual_size == output_size { + Some((Ok(batches), state)) + } else { + state.is_done = true; + Some(( + Err(lance_core::Error::invalid_input(format!( + "Input ended after {actual_size} rows while filling a requested {output_size}-row chunk" + ))), + state, + )) + } + } + Some(Err(error)) => { + state.is_done = true; + Some((Err(error), state)) + } + None => { + state.is_done = true; + Some(( + Err(lance_core::Error::invalid_input(format!( + "Input ended before a requested {output_size}-row chunk could be filled" + ))), + state, + )) + } + } + }) + .boxed() +} + /// Given a stream of record batches, this will yield batches of a fixed size. /// /// This stream _will_ combine record batches and so it can be fairly expensive as it will @@ -311,12 +566,15 @@ where #[cfg(test)] mod tests { - use std::sync::Arc; + use std::sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }; use arrow::datatypes::{Int32Type, Int64Type}; use datafusion::physical_plan::stream::RecordBatchStreamAdapter; use futures::{StreamExt, TryStreamExt}; - use lance_datagen::{array, BatchCount, RowCount}; + use lance_datagen::{BatchCount, RowCount, array}; use crate::datagen::DatafusionDatagenExt; @@ -360,6 +618,82 @@ mod tests { assert_eq!(chunked[2].len(), 1); assert_eq!(chunked[2][0].num_rows(), 8); + let sizes_consumed = Arc::new(AtomicUsize::new(0)); + let requested_sizes = [9, 10, 9].into_iter().inspect({ + let sizes_consumed = sizes_consumed.clone(); + move |_| { + sizes_consumed.fetch_add(1, Ordering::SeqCst); + } + }); + let mut chunked = super::chunk_stream_with_sizes(make_stream(), requested_sizes); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 0); + let first_chunk = chunked.next().await.unwrap().unwrap(); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 1); + let mut chunked = chunked.try_collect::>().await.unwrap(); + chunked.insert(0, first_chunk); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 3); + assert_eq!( + chunked + .iter() + .map(|batches| batches.iter().map(|batch| batch.num_rows()).sum::()) + .collect::>(), + vec![9, 10, 9] + ); + + let error = super::chunk_stream_with_sizes(make_stream(), vec![10, 17]) + .try_collect::>() + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("more rows than the requested chunk sizes") + ); + + let error = super::chunk_stream_with_sizes(make_stream(), vec![10, 19]) + .try_collect::>() + .await + .unwrap_err(); + assert!(error.to_string().contains("ended after 18 rows")); + + let sizes_consumed = Arc::new(AtomicUsize::new(0)); + let requested_sizes = [9, 10, 9].into_iter().inspect({ + let sizes_consumed = sizes_consumed.clone(); + move |_| { + sizes_consumed.fetch_add(1, Ordering::SeqCst); + } + }); + let mut broken = super::break_stream_with_sizes(make_stream(), requested_sizes); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 0); + let first_batch = broken.next().await.unwrap().unwrap(); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 1); + let mut broken = broken.try_collect::>().await.unwrap(); + broken.insert(0, first_batch); + assert_eq!(sizes_consumed.load(Ordering::SeqCst), 3); + assert_eq!( + broken + .iter() + .map(|batches| batches.iter().map(|batch| batch.num_rows()).sum::()) + .collect::>(), + vec![9, 1, 5, 4, 9] + ); + + let error = super::break_stream_with_sizes(make_stream(), vec![27]) + .try_collect::>() + .await + .unwrap_err(); + assert!( + error + .to_string() + .contains("more rows than the requested chunk sizes") + ); + + let error = super::break_stream_with_sizes(make_stream(), vec![29]) + .try_collect::>() + .await + .unwrap_err(); + assert!(error.to_string().contains("1 rows remaining")); + let chunked = super::chunk_concat_stream(make_stream(), 10) .try_collect::>() .await diff --git a/rust/lance-datafusion/src/dataframe.rs b/rust/lance-datafusion/src/dataframe.rs index 2dc0950c82b..15d9797d17f 100644 --- a/rust/lance-datafusion/src/dataframe.rs +++ b/rust/lance-datafusion/src/dataframe.rs @@ -167,7 +167,7 @@ impl BatchStreamGrouper { // end of group, so we should return the current. Some(( vec![self.current_partition.take().unwrap()], - self.buffer.drain(..).collect(), + std::mem::take(&mut self.buffer), )) } else { // If there is no data in the unprocessed buffer, return None as we aren't finished. diff --git a/rust/lance-datafusion/src/datagen.rs b/rust/lance-datafusion/src/datagen.rs index e3370088a2a..c9d039c4deb 100644 --- a/rust/lance-datafusion/src/datagen.rs +++ b/rust/lance-datafusion/src/datagen.rs @@ -6,7 +6,7 @@ use std::sync::Arc; use arrow_array::RecordBatchReader; use datafusion::{ execution::SendableRecordBatchStream, - physical_plan::{stream::RecordBatchStreamAdapter, ExecutionPlan}, + physical_plan::{ExecutionPlan, stream::RecordBatchStreamAdapter}, }; use datafusion_common::DataFusionError; use futures::TryStreamExt; diff --git a/rust/lance-datafusion/src/exec.rs b/rust/lance-datafusion/src/exec.rs index 9eed7f92bfc..e48470acb91 100644 --- a/rust/lance-datafusion/src/exec.rs +++ b/rust/lance-datafusion/src/exec.rs @@ -6,6 +6,7 @@ use std::{ collections::HashMap, fmt::{self, Formatter}, + num::NonZero, sync::{Arc, Mutex, OnceLock}, time::Duration, }; @@ -14,49 +15,52 @@ use chrono::{DateTime, Utc}; use arrow_array::RecordBatch; use arrow_schema::Schema as ArrowSchema; -use datafusion::physical_plan::metrics::MetricType; use datafusion::{ - catalog::streaming::StreamingTable, + catalog::{TableProvider, streaming::StreamingTable}, dataframe::DataFrame, execution::{ + TaskContext, context::{SessionConfig, SessionContext}, disk_manager::DiskManagerBuilder, memory_pool::FairSpillPool, runtime_env::RuntimeEnvBuilder, - TaskContext, }, physical_plan::{ + DisplayAs, DisplayFormatType, ExecutionPlan, ExecutionPlanProperties, PlanProperties, + SendableRecordBatchStream, analyze::AnalyzeExec, + coalesce_partitions::CoalescePartitionsExec, display::DisplayableExecutionPlan, execution_plan::{Boundedness, CardinalityEffect, EmissionType}, metrics::MetricValue, + sorts::sort_preserving_merge::SortPreservingMergeExec, stream::RecordBatchStreamAdapter, streaming::PartitionStream, - DisplayAs, DisplayFormatType, ExecutionPlan, PlanProperties, SendableRecordBatchStream, }, }; -use datafusion_common::{DataFusionError, Statistics}; +use datafusion::{execution::memory_pool::TrackConsumersPool, physical_plan::metrics::MetricType}; +use datafusion_common::{DataFusionError, Statistics, utils::get_available_parallelism}; use datafusion_physical_expr::{EquivalenceProperties, Partitioning}; -use futures::{stream, StreamExt}; +use futures::{StreamExt, stream}; use lance_arrow::SchemaExt; use lance_core::{ + Error, Result, utils::{ futures::FinallyStreamExt, - tracing::{StreamTracingExt, EXECUTION_PLAN_RUN, TRACE_EXECUTION}, + tracing::{EXECUTION_PLAN_RUN, StreamTracingExt, TRACE_EXECUTION}, }, - Error, Result, }; use log::{debug, info, warn}; -use snafu::location; use tracing::Span; use crate::udf::register_functions; use crate::{ chunker::StrictBatchSizeStream, utils::{ - MetricsExt, BYTES_READ_METRIC, INDEX_COMPARISONS_METRIC, INDICES_LOADED_METRIC, - IOPS_METRIC, PARTS_LOADED_METRIC, REQUESTS_METRIC, + BYTES_READ_METRIC, INDEX_CACHE_HITS_METRIC, INDEX_CACHE_MISSES_METRIC, + INDEX_COMPARISONS_METRIC, INDICES_LOADED_METRIC, IOPS_METRIC, MetricsExt, + PARTS_LOADED_METRIC, REQUESTS_METRIC, }, }; @@ -72,7 +76,7 @@ pub struct OneShotExec { // We save off a copy of the schema to speed up formatting and so ExecutionPlan::schema & display_as // can still function after exhausted schema: Arc, - properties: PlanProperties, + properties: Arc, } impl OneShotExec { @@ -82,12 +86,12 @@ impl OneShotExec { Self { stream: Mutex::new(Some(stream)), schema: schema.clone(), - properties: PlanProperties::new( + properties: Arc::new(PlanProperties::new( EquivalenceProperties::new(schema), Partitioning::RoundRobinBatch(1), EmissionType::Incremental, Boundedness::Bounded, - ), + )), } } @@ -152,10 +156,6 @@ impl ExecutionPlan for OneShotExec { "OneShotExec" } - fn as_any(&self) -> &dyn std::any::Any { - self - } - fn schema(&self) -> arrow_schema::SchemaRef { self.schema.clone() } @@ -196,18 +196,14 @@ impl ExecutionPlan for OneShotExec { } } - fn statistics(&self) -> datafusion_common::Result { - Ok(Statistics::new_unknown(&self.schema)) - } - - fn properties(&self) -> &datafusion::physical_plan::PlanProperties { + fn properties(&self) -> &Arc { &self.properties } } struct TracedExec { input: Arc, - properties: PlanProperties, + properties: Arc, span: Span, } @@ -247,11 +243,7 @@ impl ExecutionPlan for TracedExec { "TracedExec" } - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn properties(&self) -> &PlanProperties { + fn properties(&self) -> &Arc { &self.properties } @@ -314,21 +306,36 @@ 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 = 150 * 1024 * 1024; +/// Maximum headroom reserved for one external sort's non-spillable merge phase. +const MAX_SORT_SPILL_RESERVATION_BYTES: u64 = 40 * 1024 * 1024; const DEFAULT_LANCE_MAX_TEMP_DIRECTORY_SIZE: u64 = 100 * 1024 * 1024 * 1024; // 100GB impl LanceExecutionOptions { + /// The number of partitions the DataFusion session will actually run with. + /// + /// When `target_partition` is not set we do not override the session config, + /// so the session falls back to DataFusion's default `target_partitions`, + /// which is the available parallelism (number of CPU cores). The memory pool + /// must be sized for that effective partition count, not for a single + /// partition, or sort-heavy plans exhaust the pool. + fn effective_target_partition(&self) -> u64 { + self.target_partition + .unwrap_or_else(get_available_parallelism) as u64 + } + pub fn mem_pool_size(&self) -> u64 { + let num_partitions = self.effective_target_partition(); 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) }) } @@ -353,6 +360,7 @@ impl LanceExecutionOptions { if !self.use_spilling { return false; } + // Presence enables the bypass; the value is not parsed as a boolean. std::env::var("LANCE_BYPASS_SPILLING") .map(|_| { info!("Bypassing spilling because LANCE_BYPASS_SPILLING is set"); @@ -369,14 +377,25 @@ pub fn new_session_context(options: &LanceExecutionOptions) -> SessionContext { session_config = session_config.with_target_partitions(target_partition); } if options.use_spilling() { + // Reserve sort/merge headroom for each spillable sort, using up to 40 MiB + // instead of DataFusion's 10 MiB default. Limit it to one third of the pool + // to leave room for input batches in small pools. This reservation comes + // out of the same pool; it does not guarantee that every batch will fit. + let sort_spill_reservation_bytes = + (options.mem_pool_size() / 3).min(MAX_SORT_SPILL_RESERVATION_BYTES) as usize; + session_config = + session_config.with_sort_spill_reservation_bytes(sort_spill_reservation_bytes); let disk_manager_builder = DiskManagerBuilder::default() .with_max_temp_directory_size(options.max_temp_directory_size()); runtime_env_builder = runtime_env_builder .with_disk_manager_builder(disk_manager_builder) - .with_memory_pool(Arc::new(FairSpillPool::new( - options.mem_pool_size() as usize + .with_memory_pool(Arc::new(TrackConsumersPool::new( + FairSpillPool::new(options.mem_pool_size() as usize), + NonZero::try_from(16).unwrap(), ))); } + // Without spilling, DataFusion's default UnboundedMemoryPool accepts all + // reservations. This bypasses the configured pool limit, not actual RAM limits. let runtime_env = runtime_env_builder.build_arc().unwrap(); let ctx = SessionContext::new_with_config_rt(session_config, runtime_env); @@ -391,7 +410,6 @@ struct SessionContextCacheKey { mem_pool_size: u64, max_temp_directory_size: u64, target_partition: Option, - use_spilling: bool, } impl SessionContextCacheKey { @@ -400,7 +418,6 @@ impl SessionContextCacheKey { mem_pool_size: options.mem_pool_size(), max_temp_directory_size: options.max_temp_directory_size(), target_partition: options.target_partition, - use_spilling: options.use_spilling(), } } } @@ -427,7 +444,13 @@ fn get_max_cache_size() -> usize { }) } +/// Reuses unbounded sessions, while giving each spilling caller a fresh bounded +/// memory pool so concurrent sorts cannot consume each other's headroom. pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext { + if options.use_spilling() { + return new_session_context(options); + } + let key = SessionContextCacheKey::from_options(options); let mut cache = get_session_cache() .lock() @@ -440,14 +463,13 @@ pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext { } // Evict least recently used entry if cache is full - if cache.len() >= get_max_cache_size() { - if let Some(lru_key) = cache + if cache.len() >= get_max_cache_size() + && let Some(lru_key) = cache .iter() .min_by_key(|(_, v)| v.last_access) .map(|(k, _)| k.clone()) - { - cache.remove(&lru_key); - } + { + cache.remove(&lru_key); } let context = new_session_context(options); @@ -461,16 +483,25 @@ pub fn get_session_context(options: &LanceExecutionOptions) -> SessionContext { context } +/// Returns a new task context to execute one plan with `options`. +/// +/// Every execution needs its own context: Lance operators treat a task +/// context's identity as the identity of one plan execution, for example to +/// share a MultiMatch prefilter mask only between the fields of one query. fn get_task_context( session_ctx: &SessionContext, options: &LanceExecutionOptions, ) -> Arc { - let mut state = session_ctx.state(); - if let Some(batch_size) = options.batch_size.as_ref() { - state.config_mut().options_mut().execution.batch_size = *batch_size; - } - - state.task_ctx() + // Build from the session state in place. `SessionContext::state` would clone + // the whole state (every function map, rule list and option) only to drop + // it, which is a measurable share of CPU for short queries. + let task_ctx = TaskContext::from(session_ctx); + let Some(batch_size) = options.batch_size else { + return Arc::new(task_ctx); + }; + let mut session_config = task_ctx.session_config().clone(); + session_config.options_mut().execution.batch_size = batch_size; + Arc::new(task_ctx.with_session_config(session_config)) } #[derive(Default, Clone, Debug, PartialEq, Eq)] @@ -490,10 +521,92 @@ pub struct ExecutionSummaryCounts { pub index_comparisons: usize, /// Additional metrics for more detailed statistics. These are subject to change in the future /// and should only be used for debugging purposes. + /// + /// Newer metrics (e.g. [`INDEX_CACHE_HITS_METRIC`], [`INDEX_CACHE_MISSES_METRIC`]) are added + /// here rather than as `pub` fields, so this struct stays backwards compatible for callers + /// that construct or destructure it. Prefer the typed accessors below. pub all_counts: HashMap, + /// Additional time metrics for more detailed statistics, stored in nanoseconds. + /// Operator baseline times use `_elapsed_compute` keys. + /// Timings may be nested and accumulate across concurrent work; they are not + /// exclusive stages that can be added to recover query wall time. + /// These are subject to change in the future and should only be used for debugging purposes. + pub all_times: HashMap, +} + +impl ExecutionSummaryCounts { + /// Number of index cache page lookups where the loader was not executed + /// (per-page granularity). + /// + /// A "hit" is any page-level lookup at an instrumented cache boundary that + /// did not run the loader on this call. That covers both a true cache hit + /// on an already-populated entry and a coalesced concurrent load where an + /// in-flight loader started by a different caller produced the value. + /// + /// Instrumented boundaries in this release: + /// BTree page, IVF partition (v2, `write_cache=true` scan path), inverted + /// posting list (grouped and per-token), inverted per-token metadata + /// (`PostingMetadataKey`), inverted phrase positions (`PositionKey`), + /// bitmap posting (Equals / Range / IsIn), ngram posting, and rtree page + /// / null slot. + /// + /// Caveats: + /// * IVF v2 streaming scans and legacy v1 IVF partitions run + /// `load_partition` with `write_cache=false`. Those loads always execute + /// the loader and never write the result back, so they are reported as a + /// miss on every call. See [`Self::index_cache_hit_ratio`]. + /// * A cold posting-list lookup on the grouped inverted layout can record + /// up to two misses (posting-list group + per-token metadata) for a + /// single term. + /// + /// Other index cache boundaries such as HNSW graph pages and quantizer + /// codebooks are not yet instrumented; a scan that only touches those + /// paths returns `0` here. + pub fn index_cache_hits(&self) -> usize { + self.all_counts + .get(INDEX_CACHE_HITS_METRIC) + .copied() + .unwrap_or(0) + } + + /// Number of index cache page lookups that had to execute the loader + /// (per-page granularity). + /// + /// A "miss" is any page-level lookup at an instrumented cache boundary + /// where the loader ran, i.e. the page was not resident and had to be + /// materialised (typically from storage). See + /// [`Self::index_cache_hits`] for the paired counter and the list of + /// instrumented boundaries. + pub fn index_cache_misses(&self) -> usize { + self.all_counts + .get(INDEX_CACHE_MISSES_METRIC) + .copied() + .unwrap_or(0) + } + + /// Ratio of index cache hits to total lookups. Returns `0.0` when no lookups + /// were recorded in this scan. + /// + /// This ratio only reflects paths that write their result back to the + /// index cache. Streaming scans (IVF v2 `write_cache=false` and legacy v1 + /// IVF `load_partition_stream`) intentionally bypass the cache and are + /// counted as misses on every call, so a workload dominated by streaming + /// vector scans will report a hit ratio near `0.0` regardless of cache + /// size. + pub fn index_cache_hit_ratio(&self) -> f32 { + // Widen to u128 before summing so a pathological (hits + misses) + // overflow can't panic in debug builds nor wrap in release builds. + let hits = self.index_cache_hits() as u128; + let total = hits + self.index_cache_misses() as u128; + if total == 0 { + 0.0 + } else { + hits as f32 / total as f32 + } + } } -fn visit_node(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) { +pub fn collect_execution_metrics(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) { if let Some(metrics) = node.metrics() { for (metric_name, count) in metrics.iter_counts() { match metric_name.as_ref() { @@ -512,6 +625,21 @@ fn visit_node(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) { } } } + 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(); + } + // Keep operator baselines separate: ANN elapsed time includes asynchronous waits, + // while operators such as SortExec report compute time. Summing them hides the stages. + if let Some(elapsed) = metrics.elapsed_compute() { + *counts + .all_times + .entry(format!("{}_elapsed_compute", node.name())) + .or_default() += elapsed; + } // 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() { @@ -523,7 +651,7 @@ fn visit_node(node: &dyn ExecutionPlan, counts: &mut ExecutionSummaryCounts) { } } for child in node.children() { - visit_node(child.as_ref(), counts); + collect_execution_metrics(child.as_ref(), counts); } } @@ -533,19 +661,23 @@ fn report_plan_summary_metrics(plan: &dyn ExecutionPlan, options: &LanceExecutio .map(|m| m.output_rows().unwrap_or(0)) .unwrap_or(0); let mut counts = ExecutionSummaryCounts::default(); - visit_node(plan, &mut counts); - tracing::info!( - target: TRACE_EXECUTION, - r#type = EXECUTION_PLAN_RUN, - plan_summary = display_plan_one_liner(plan), - output_rows, - iops = counts.iops, - requests = counts.requests, - bytes_read = counts.bytes_read, - indices_loaded = counts.indices_loaded, - parts_loaded = counts.parts_loaded, - index_comparisons = counts.index_comparisons, - ); + collect_execution_metrics(plan, &mut counts); + if !options.skip_logging { + tracing::info!( + target: TRACE_EXECUTION, + r#type = EXECUTION_PLAN_RUN, + plan_summary = display_plan_one_liner(plan), + output_rows, + iops = counts.iops, + requests = counts.requests, + bytes_read = counts.bytes_read, + indices_loaded = counts.indices_loaded, + parts_loaded = counts.parts_loaded, + index_comparisons = counts.index_comparisons, + index_cache_hits = counts.index_cache_hits(), + index_cache_misses = counts.index_cache_misses(), + ); + } if let Some(callback) = options.execution_stats_callback.as_ref() { callback(&counts); } @@ -599,14 +731,29 @@ pub fn execute_plan( let session_ctx = get_session_context(&options); - // NOTE: we are only executing the first partition here. Therefore, if - // the plan has more than one partition, we will be missing data. - assert_eq!(plan.properties().partitioning.partition_count(), 1); + // Coalesce to a single partition if the optimizer left more than one. + // EnforceDistribution may remove RepartitionExec(1) nodes when the parent + // declares UnspecifiedDistribution, leaving multi-partition plans here. + // + // If the plan carries an output ordering (e.g. a top-k `SortExec` whose + // result was later repartitioned to parallelize downstream operators), + // a plain `CoalescePartitionsExec` would scramble that order because it + // merges partitions in scheduling-dependent order. Use an order-preserving + // merge in that case instead, mirroring what `EnforceDistribution` itself + // does when it needs to merge an ordered, multi-partition plan. + let plan: Arc = if plan.properties().partitioning.partition_count() == 1 { + plan + } else if let Some(ordering) = plan.output_ordering() { + Arc::new(SortPreservingMergeExec::new(ordering.clone(), plan)) + } else { + Arc::new(CoalescePartitionsExec::new(plan)) + }; + let stream = plan.execute(0, get_task_context(&session_ctx, &options))?; let schema = stream.schema(); let stream = stream.finally(move || { - if !options.skip_logging { + if !options.skip_logging || options.execution_stats_callback.is_some() { report_plan_summary_metrics(plan.as_ref(), &options); } }); @@ -616,6 +763,22 @@ pub fn execute_plan( pub async fn analyze_plan( plan: Arc, options: LanceExecutionOptions, +) -> Result { + analyze_plan_with_context(plan, options, None).await +} + +/// Analyze a plan, optionally under a caller-provided [`TaskContext`]. +/// +/// When `task_context` is `Some`, the plan executes under it instead of the +/// context derived from `options`. Callers whose nodes read session-config +/// extensions at execution time (e.g. distributed routing identity) must pass +/// the context carrying those extensions; otherwise the nodes error during +/// `execute` and `AnalyzeExec` reports an empty, unexecuted plan tree instead +/// of surfacing the error. +pub async fn analyze_plan_with_context( + plan: Arc, + options: LanceExecutionOptions, + task_context: Option>, ) -> Result { // This is needed as AnalyzeExec launches a thread task per // partition, and we want these to be connected to the parent span @@ -626,21 +789,18 @@ pub async fn analyze_plan( let analyze = Arc::new(AnalyzeExec::new( true, true, - vec![MetricType::SUMMARY], + vec![MetricType::Summary], + None, plan, schema, )); let session_ctx = get_session_context(&options); + let task_context = task_context.unwrap_or_else(|| get_task_context(&session_ctx, &options)); assert_eq!(analyze.properties().partitioning.partition_count(), 1); let mut stream = analyze - .execute(0, get_task_context(&session_ctx, &options)) - .map_err(|err| { - Error::io( - format!("Failed to execute analyze plan: {}", err), - location!(), - ) - })?; + .execute(0, task_context) + .map_err(|err| Error::io(format!("Failed to execute analyze plan: {}", err)))?; // fully execute the plan while (stream.next().await).is_some() {} @@ -858,6 +1018,49 @@ impl SessionContextExt for SessionContext { } } +/// Scan a [`TableProvider`] into a single-partition [`SendableRecordBatchStream`]. +/// +/// Multi-partition providers are coalesced into a single partition. This adapts a +/// re-scannable provider back into the one stream the writer pipeline consumes; +/// re-scanning the same provider (e.g. on a write retry) yields a fresh stream. +/// +/// # Examples +/// +/// ``` +/// # use std::sync::Arc; +/// # use arrow_array::{Int32Array, RecordBatch}; +/// # use arrow_schema::{DataType, Field, Schema}; +/// # use datafusion::catalog::TableProvider; +/// # use datafusion::datasource::MemTable; +/// # use futures::TryStreamExt; +/// # use lance_datafusion::exec::provider_to_stream; +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); +/// let batch = +/// RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])?; +/// let provider: Arc = Arc::new(MemTable::try_new(schema, vec![vec![batch]])?); +/// +/// // A re-scannable provider yields a fresh stream on each call. +/// let batches: Vec = provider_to_stream(provider).await?.try_collect().await?; +/// assert_eq!(batches.iter().map(|b| b.num_rows()).sum::(), 3); +/// # Ok(()) +/// # } +/// ``` +pub async fn provider_to_stream( + provider: Arc, +) -> Result { + let ctx = SessionContext::new(); + let plan = provider.scan(&ctx.state(), None, &[], None).await?; + let plan: Arc = + if plan.properties().output_partitioning().partition_count() > 1 { + Arc::new(CoalescePartitionsExec::new(plan)) + } else { + plan + }; + Ok(plan.execute(0, ctx.task_ctx())?) +} + #[derive(Clone, Debug)] pub struct StrictBatchSizeExec { input: Arc, @@ -885,11 +1088,7 @@ impl ExecutionPlan for StrictBatchSizeExec { "StrictBatchSizeExec" } - fn as_any(&self) -> &dyn std::any::Any { - self - } - - fn properties(&self) -> &PlanProperties { + fn properties(&self) -> &Arc { self.input.properties() } @@ -929,7 +1128,132 @@ impl ExecutionPlan for StrictBatchSizeExec { fn partition_statistics( &self, partition: Option, - ) -> datafusion_common::Result { + ) -> datafusion_common::Result> { + self.input.partition_statistics(partition) + } + + fn cardinality_effect(&self) -> CardinalityEffect { + CardinalityEffect::Equal + } + + fn supports_limit_pushdown(&self) -> bool { + true + } +} + +/// Exec node that rechunks batches so no output batch exceeds `max_bytes`. +/// +/// # Why this exists +/// +/// DataFusion's sort operator must reserve memory for an entire input batch, +/// including estimated sort/merge overhead. It can spill buffered batches and +/// retry, but still fails if the new batch's reservation cannot fit. This is +/// separate from the historical allocation issues fixed by DataFusion PR #14644 +/// (). +/// +/// This node bounds input batch sizes before sorting, at the cost of potentially +/// expensive deep copies. It does not guarantee success for every memory pool: +/// spill/merge reservations and other consumers also need room in the pool. +/// +/// # Deep copy +/// +/// After slicing a RecordBatch, `get_array_memory_size` still reports the +/// size of the *original* backing buffers, not the slice. To get accurate +/// sizes the slices must be deep-copied. This is a last resort and can be +/// expensive for large batches, but the deep copy is only performed when a +/// batch actually needs to be sliced — batches that are already within the +/// target range pass through at zero cost. +/// +/// If a single row exceeds `max_bytes`, execution fails with an error. +#[derive(Clone, Debug)] +pub struct HardCapBatchSizeExec { + input: Arc, + max_bytes: usize, +} + +impl HardCapBatchSizeExec { + pub fn new(input: Arc, max_bytes: usize) -> Self { + Self { input, max_bytes } + } +} + +impl DisplayAs for HardCapBatchSizeExec { + fn fmt_as( + &self, + _t: datafusion::physical_plan::DisplayFormatType, + f: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + write!(f, "HardCapBatchSizeExec(max_bytes={})", self.max_bytes) + } +} + +impl ExecutionPlan for HardCapBatchSizeExec { + fn name(&self) -> &str { + "HardCapBatchSizeExec" + } + + fn properties(&self) -> &Arc { + self.input.properties() + } + + fn children(&self) -> Vec<&Arc> { + vec![&self.input] + } + + fn with_new_children( + self: Arc, + children: Vec>, + ) -> datafusion_common::Result> { + Ok(Arc::new(Self { + input: children[0].clone(), + max_bytes: self.max_bytes, + })) + } + + fn execute( + &self, + partition: usize, + context: Arc, + ) -> datafusion_common::Result { + let stream = self.input.execute(partition, context)?; + let schema = stream.schema(); + let max_bytes = self.max_bytes; + let rechunked = lance_arrow::stream::rechunk_stream_by_size_deep_copy( + stream, + schema.clone(), + 0, + max_bytes, + ); + // Check that no single-row batch exceeds the limit. + let validated = rechunked.map(move |result| { + let batch = result?; + if batch.num_rows() == 1 && batch.get_array_memory_size() > max_bytes { + return Err(DataFusionError::External(Box::new(Error::invalid_input( + format!( + "a single row is {} bytes which exceeds the maximum allowed batch \ + size of {} bytes", + batch.get_array_memory_size(), + max_bytes, + ), + )))); + } + Ok(batch) + }); + Ok(Box::pin(RecordBatchStreamAdapter::new(schema, validated))) + } + + fn maintains_input_order(&self) -> Vec { + vec![true] + } + + fn benefits_from_input_partitioning(&self) -> Vec { + vec![false] + } + + fn partition_statistics( + &self, + partition: Option, + ) -> datafusion_common::Result> { self.input.partition_statistics(partition) } @@ -945,6 +1269,15 @@ impl ExecutionPlan for StrictBatchSizeExec { #[cfg(test)] mod tests { use super::*; + use datafusion::execution::memory_pool::MemoryConsumer; + + use arrow_array::{Int32Array, cast::AsArray, record_batch, types::Int32Type}; + use arrow_select::concat::concat_batches; + use datafusion::execution::memory_pool::MemoryLimit; + use datafusion::physical_plan::sorts::sort::SortExec; + use datafusion_physical_expr::{LexOrdering, PhysicalSortExpr, expressions::col}; + use futures::TryStreamExt; + use rstest::rstest; // Serialize cache tests since they share global state static CACHE_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); @@ -973,9 +1306,9 @@ mod tests { assert_eq!(cache_guard.len(), 1); } - // Different options should create new entry + // Different non-spilling options should create a new entry. let opts2 = LanceExecutionOptions { - use_spilling: true, + target_partition: Some(1), ..Default::default() }; let _ctx2 = get_session_context(&opts2); @@ -985,6 +1318,30 @@ mod tests { } } + #[test] + fn test_spilling_executions_have_independent_memory_pools() { + let options = LanceExecutionOptions { + use_spilling: true, + mem_pool_size: Some(DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION), + target_partition: Some(1), + ..Default::default() + }; + let contexts: Vec<_> = (0..3).map(|_| get_session_context(&options)).collect(); + let mut reservations = Vec::with_capacity(contexts.len()); + + // Each reservation fits its own pool, but any two exceed a shared pool. + for context in &contexts { + let task_context = context.task_ctx(); + let pool = task_context.memory_pool(); + let reservation = MemoryConsumer::new("ExternalSorterMerge[0]").register(pool); + reservation.try_grow(100 * 1024 * 1024).unwrap(); + assert_eq!(pool.reserved(), 100 * 1024 * 1024); + reservations.push(reservation); + } + + assert_eq!(reservations.len(), 3); + } + #[test] fn test_session_context_cache_lru_eviction() { let _lock = CACHE_TEST_LOCK.lock().unwrap(); @@ -1048,4 +1405,291 @@ mod tests { ); } } + + #[test] + fn test_task_context_per_execution() { + let _lock = CACHE_TEST_LOCK.lock().unwrap(); + + let default_options = LanceExecutionOptions::default(); + let session_ctx = get_session_context(&default_options); + let task_ctx = get_task_context(&session_ctx, &default_options); + // Lance operators key per-execution state on the task context's identity. + assert!(!Arc::ptr_eq( + &task_ctx, + &get_task_context(&session_ctx, &default_options) + )); + assert_eq!(task_ctx.session_id(), session_ctx.session_id()); + assert_eq!( + task_ctx.session_config().batch_size(), + session_ctx.copied_config().batch_size() + ); + assert!(Arc::ptr_eq( + &task_ctx.runtime_env(), + &session_ctx.runtime_env() + )); + assert!(task_ctx.scalar_functions().contains_key("contains_tokens")); + + let spill_options = LanceExecutionOptions { + use_spilling: true, + mem_pool_size: Some(64 * 1024 * 1024), + target_partition: Some(3), + batch_size: Some(17), + ..Default::default() + }; + let spill_session_ctx = get_session_context(&spill_options); + let spill_task_ctx = get_task_context(&spill_session_ctx, &spill_options); + assert_eq!(spill_task_ctx.session_config().batch_size(), 17); + assert_eq!(spill_task_ctx.session_config().target_partitions(), 3); + assert!( + spill_task_ctx + .scalar_functions() + .contains_key("contains_tokens") + ); + // The batch size override applies to this execution only, and the + // execution still shares the session's memory pool. + assert_ne!(spill_session_ctx.copied_config().batch_size(), 17); + assert!(Arc::ptr_eq( + &spill_task_ctx.runtime_env(), + &spill_session_ctx.runtime_env() + )); + assert!(matches!( + spill_task_ctx.memory_pool().memory_limit(), + MemoryLimit::Finite(limit) if limit == 64 * 1024 * 1024 + )); + } + + #[rstest] + #[case::session_batch_size(None, &[6])] + #[case::batch_size_override(Some(4), &[4, 2])] + #[tokio::test] + async fn test_execute_plan_batch_size( + #[case] batch_size: Option, + #[case] expected_batch_rows: &[usize], + ) { + let batch = record_batch!(("x", Int32, [5, 3, 1, 4, 2, 0])).unwrap(); + let sort_expr = PhysicalSortExpr::new_default(col("x", &batch.schema()).unwrap()); + let plan = Arc::new(SortExec::new( + LexOrdering::new([sort_expr]).unwrap(), + Arc::new(OneShotExec::from_batch(batch.clone())), + )); + let options = LanceExecutionOptions { + batch_size, + ..Default::default() + }; + let batches: Vec = execute_plan(plan, options) + .unwrap() + .try_collect() + .await + .unwrap(); + let batch_rows: Vec = batches.iter().map(RecordBatch::num_rows).collect(); + assert_eq!(batch_rows, expected_batch_rows); + let sorted = concat_batches(&batch.schema(), &batches).unwrap(); + assert_eq!( + sorted["x"].as_primitive::(), + &Int32Array::from(vec![0, 1, 2, 3, 4, 5]) + ); + } + + #[test] + fn test_mem_pool_size_scales_with_partitions() { + let default_per_partition = DEFAULT_LANCE_MEM_POOL_SIZE_PER_PARTITION; + + // No partitions specified → the session runs with DataFusion's default + // target_partitions (available parallelism), so the pool must be sized + // for that effective partition count. + let opts = LanceExecutionOptions::default(); + assert_eq!( + opts.mem_pool_size(), + default_per_partition * get_available_parallelism() as u64 + ); + + // 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); + } + + #[test] + fn test_default_pool_fits_sort_merge_reservations_per_partition() { + let options = LanceExecutionOptions { + use_spilling: true, + ..Default::default() + }; + let num_partitions = options.effective_target_partition() as usize; + let session_ctx = new_session_context(&options); + let task_ctx = session_ctx.task_ctx(); + let pool = task_ctx.memory_pool(); + + // External sort merges cannot spill their own reservations. Keep three + // merge reservations per execution partition while leaving 30 MiB per + // partition for input batches and other operators. Before the default + // pool followed the effective partition count, the fourth reservation + // exhausted the single 150 MiB pool on machines with multiple cores. + let num_reservations = num_partitions * 3; + let mut reservations = Vec::with_capacity(num_reservations); + for _ in 0..num_reservations { + let reservation = MemoryConsumer::new("ExternalSorterMerge[0]").register(pool); + reservation + .try_grow(MAX_SORT_SPILL_RESERVATION_BYTES as usize) + .unwrap(); + reservations.push(reservation); + } + + assert_eq!( + pool.reserved(), + num_reservations * MAX_SORT_SPILL_RESERVATION_BYTES as usize + ); + } + + /// A marker a node reads from the session-config extensions at execute time. + #[derive(Debug)] + struct RequiredExtension; + + /// Execution node that only succeeds when [`RequiredExtension`] is present + /// on the task context's session config. This mirrors distributed routing + /// nodes that read a session-config identity extension during `execute`. + #[derive(Debug)] + struct NeedsExtensionExec { + properties: Arc, + /// Set once the node reaches execution with the extension present. + /// Observed by the test so that dropping context forwarding (which + /// makes `execute` error before this point) is detectable. + executed: Arc, + } + + impl NeedsExtensionExec { + fn new(executed: Arc) -> Self { + let schema = Arc::new(ArrowSchema::empty()); + Self { + properties: Arc::new(PlanProperties::new( + EquivalenceProperties::new(schema), + Partitioning::UnknownPartitioning(1), + EmissionType::Incremental, + Boundedness::Bounded, + )), + executed, + } + } + } + + impl DisplayAs for NeedsExtensionExec { + fn fmt_as(&self, _t: DisplayFormatType, f: &mut Formatter) -> fmt::Result { + write!(f, "NeedsExtensionExec") + } + } + + impl ExecutionPlan for NeedsExtensionExec { + fn name(&self) -> &str { + "NeedsExtensionExec" + } + fn properties(&self) -> &Arc { + &self.properties + } + fn children(&self) -> Vec<&Arc> { + vec![] + } + fn with_new_children( + self: Arc, + _children: Vec>, + ) -> datafusion_common::Result> { + Ok(self) + } + fn execute( + &self, + _partition: usize, + context: Arc, + ) -> datafusion_common::Result { + if context + .session_config() + .get_extension::() + .is_none() + { + return Err(DataFusionError::Execution( + "missing required session-config extension".to_string(), + )); + } + self.executed + .store(true, std::sync::atomic::Ordering::SeqCst); + let schema = self.schema(); + Ok(Box::pin(RecordBatchStreamAdapter::new( + schema, + stream::empty(), + ))) + } + } + + // Regression: analyze must run under a caller-provided TaskContext so nodes + // that read a session-config extension at execute time see it. Without the + // context the node errors and AnalyzeExec would otherwise report an empty, + // unexecuted plan tree. + #[tokio::test] + async fn test_analyze_plan_uses_provided_task_context() { + use std::sync::atomic::{AtomicBool, Ordering}; + + let executed = Arc::new(AtomicBool::new(false)); + let plan: Arc = Arc::new(NeedsExtensionExec::new(executed.clone())); + + // Default context lacks the extension: the node errors during execute + // (never reaching the `executed` flag), but AnalyzeExec absorbs that + // per-partition failure and reports an empty, unexecuted plan tree + // rather than propagating the error. This is the regression symptom. + let report = analyze_plan(plan.clone(), LanceExecutionOptions::default()) + .await + .expect("AnalyzeExec swallows the node's execute error into an Ok report"); + assert!( + report.contains("NeedsExtensionExec, metrics=[]"), + "expected an empty, unexecuted NeedsExtensionExec node, got: {report}" + ); + assert!( + !executed.load(Ordering::SeqCst), + "node must not execute successfully without the extension" + ); + + // A context carrying the extension executes the node successfully. + let options = LanceExecutionOptions::default(); + let session_ctx = get_session_context(&options); + let config = session_ctx + .task_ctx() + .session_config() + .clone() + .with_extension(Arc::new(RequiredExtension)); + let task_ctx = session_ctx.task_ctx(); + let task_ctx = Arc::new(TaskContext::new( + task_ctx.task_id(), + task_ctx.session_id(), + config, + task_ctx.scalar_functions().clone(), + task_ctx.higher_order_functions().clone(), + task_ctx.aggregate_functions().clone(), + task_ctx.window_functions().clone(), + task_ctx.runtime_env(), + )); + let report = analyze_plan_with_context(plan, options, Some(task_ctx)) + .await + .expect("analyze should succeed when the extension is present"); + assert!(report.contains("NeedsExtensionExec")); + // The node only reaches this flag when the supplied context is actually + // forwarded to `execute`; dropping the forwarding fails this assertion. + assert!( + executed.load(Ordering::SeqCst), + "supplied context must be forwarded so the node executes" + ); + } } diff --git a/rust/lance-datafusion/src/expr.rs b/rust/lance-datafusion/src/expr.rs index d7e6ce7a56a..b7e0d03d6ae 100644 --- a/rust/lance-datafusion/src/expr.rs +++ b/rust/lance-datafusion/src/expr.rs @@ -6,17 +6,118 @@ use std::sync::Arc; use arrow::compute::cast; -use arrow_array::{cast::AsArray, ArrayRef}; +use arrow_array::{ArrayRef, cast::AsArray}; use arrow_schema::{DataType, TimeUnit}; use datafusion_common::ScalarValue; +use half::f16; const MS_PER_DAY: i64 = 86400000; +/// The exact tie between the largest finite `f16` and the value that would +/// follow it. A tie goes to the even mantissa, which there is the one that does +/// not exist, so this magnitude and anything above it rounds to an infinity. +const F16_OVERFLOW_THRESHOLD: f64 = 65520.0; + +/// The finite `f16` nearest `value`, with a tie going to the even mantissa. +/// +/// Neither of `half`'s conversions is correctly rounded, in two different ways: +/// the software path truncates the low 32 bits of the `f64` mantissa before it +/// rounds ([half-rs#151]), and the x86 hardware path rounds through `f32` first +/// ([half-rs#116]). Both land within one step of the right answer, so this starts +/// from the software path, which at least does not vary by target, and then looks +/// at the two neighbours. Widening `f16` to `f64` is exact, so comparing the three +/// distances in `f64` decides exactly. +/// +/// The caller must have excluded the overflow range first. Truncation only ever +/// shrinks the magnitude, so below the threshold the starting point is finite. +/// +/// [half-rs#151]: https://github.com/VoidStarKat/half-rs/issues/151 +/// [half-rs#116]: https://github.com/VoidStarKat/half-rs/issues/116 +fn nearest_finite_f16(value: f64) -> f16 { + let start = f16::from_f64_const(value); + debug_assert!( + start.is_finite(), + "caller must reject the overflow range before calling: {value}" + ); + let mut best = start; + let mut best_distance = (value - start.to_f64()).abs(); + let start_bits = start.to_bits(); + // Stepping the bit pattern by one walks to the adjacent magnitude on either + // side of `start`, for negatives as well. Stepping off an end lands on an + // infinity or a NaN, which is not a candidate. + for candidate in [start_bits.wrapping_sub(1), start_bits.wrapping_add(1)].map(f16::from_bits) { + if !candidate.is_finite() { + continue; + } + let distance = (value - candidate.to_f64()).abs(); + let wins = + distance < best_distance || (distance == best_distance && candidate.to_bits() & 1 == 0); + if wins { + best = candidate; + best_distance = distance; + } + } + best +} + +/// Coerce a float to `f16`, rejecting a finite value that leaves the `f16` range +/// rather than saturating it to an infinity. +/// +/// A value inside the range lands on the `f16` grid and is inexact in a way that +/// shows: past 2048 the grid is coarser than the integers, so `= 2049` matches +/// rows holding 2048. The `Float32` and `Float64` arms below are inexact too, on +/// a finer grid, and that is not what the rejection is for. +/// +/// The rejection is for the literal changing kind. Saturated to an infinity, +/// `= 100000` matches rows that really hold infinity; collapsed to zero, +/// `= 1e-30` matches real zeros. The cost is the queries where saturating would +/// have been right: `< 100000` errors instead of returning every finite row. +/// This function cannot see the operator, so it cannot allow saturation only +/// where it is harmless, and an error the caller reports beats a wrong row set. +/// An infinite or NaN input converts faithfully and is kept. +/// +/// `Float64` to `Float32` still saturates. Reaching that takes a literal above +/// 1e38, while `f16` overflows at 65520, which an ordinary literal passes. +fn coerce_to_f16(value: f64) -> Option { + if value.is_nan() { + return Some(f16::NAN); + } + if value.is_infinite() { + return Some(if value.is_sign_positive() { + f16::INFINITY + } else { + f16::NEG_INFINITY + }); + } + if value.abs() >= F16_OVERFLOW_THRESHOLD { + return None; + } + let nearest = nearest_finite_f16(value); + // `f16::ZERO == f16::NEG_ZERO`, and so does `-0.0 == 0.0`, so a signed zero + // literal keeps its sign here and only a genuinely nonzero value is rejected. + if nearest == f16::ZERO && value != 0.0 { + return None; + } + Some(nearest) +} + // This is slightly tedious but when we convert expressions from SQL strings to logical // datafusion expressions there is no type coercion that happens. In other words "x = 7" // will always yield "x = 7_u64" regardless of the type of the column "x". As a result, we // need to do that literal coercion ourselves. pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option { + // A dictionary target coerces the value to the dictionary's value type and + // re-wraps it as a dictionary literal. Only an untyped `ScalarValue::Null` + // keeps its untyped form, matching the behavior for all other targets; a + // *typed* null (e.g. `Utf8(None)`) is coerced and wrapped like any other + // value so it produces a `Dictionary(..)` literal that matches the column. + if let DataType::Dictionary(key_type, value_type) = ty { + if matches!(value, ScalarValue::Null) { + return Some(value.clone()); + } + let inner = safe_coerce_scalar(value, value_type)?; + return Some(ScalarValue::Dictionary(key_type.clone(), Box::new(inner))); + } match value { ScalarValue::Int8(val) => match ty { DataType::Int8 => Some(value.clone()), @@ -35,6 +136,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option { val.and_then(|v| u64::try_from(v).map(|v| ScalarValue::UInt64(Some(v))).ok()) } + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(f32::from(v)))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(f64::from(v)))), _ => None, @@ -58,6 +162,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option { val.and_then(|v| u64::try_from(v).map(|v| ScalarValue::UInt64(Some(v))).ok()) } + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(f32::from(v)))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(f64::from(v)))), _ => None, @@ -86,6 +193,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v as f32))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(v as f64))), _ => None, @@ -114,9 +224,22 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v as f32))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(v as f64))), DataType::Decimal128(_, _) | DataType::Decimal256(_, _) => value.cast_to(ty).ok(), + DataType::Time32(TimeUnit::Second) => val.and_then(|v| { + i32::try_from(v) + .ok() + .map(|v| ScalarValue::Time32Second(Some(v))) + }), + DataType::Time32(TimeUnit::Millisecond) => val.and_then(|v| { + i32::try_from(v) + .ok() + .map(|v| ScalarValue::Time32Millisecond(Some(v))) + }), _ => None, }, ScalarValue::UInt8(val) => match ty { @@ -130,6 +253,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option val.map(|v| ScalarValue::UInt16(Some(u16::from(v)))), DataType::UInt32 => val.map(|v| ScalarValue::UInt32(Some(u32::from(v)))), DataType::UInt64 => val.map(|v| ScalarValue::UInt64(Some(u64::from(v)))), + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(f32::from(v)))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(f64::from(v)))), _ => None, @@ -149,6 +275,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option Some(value.clone()), DataType::UInt32 => val.map(|v| ScalarValue::UInt32(Some(u32::from(v)))), DataType::UInt64 => val.map(|v| ScalarValue::UInt64(Some(u64::from(v)))), + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(f32::from(v)))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(f64::from(v)))), _ => None, @@ -173,6 +302,9 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option Some(value.clone()), DataType::UInt64 => val.map(|v| ScalarValue::UInt64(Some(u64::from(v)))), // See above warning about lossy float conversion + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v as f32))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(v as f64))), _ => None, @@ -201,16 +333,31 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option Some(value.clone()), // See above warning about lossy float conversion + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v as f64).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v as f32))), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(v as f64))), _ => None, }, + ScalarValue::Float16(val) => match ty { + DataType::Float16 => Some(value.clone()), + DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v.to_f32()))), + DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(v.to_f64()))), + _ => None, + }, ScalarValue::Float32(val) => match ty { + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(f64::from(v)).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => Some(value.clone()), DataType::Float64 => val.map(|v| ScalarValue::Float64(Some(f64::from(v)))), _ => None, }, ScalarValue::Float64(val) => match ty { + DataType::Float16 => { + val.and_then(|v| coerce_to_f16(v).map(|v| ScalarValue::Float16(Some(v)))) + } DataType::Float32 => val.map(|v| ScalarValue::Float32(Some(v as f32))), DataType::Float64 => Some(value.clone()), _ => None, @@ -218,11 +365,19 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option match ty { DataType::Utf8 => Some(value.clone()), DataType::LargeUtf8 => Some(ScalarValue::LargeUtf8(val.clone())), + DataType::Utf8View => Some(ScalarValue::Utf8View(val.clone())), _ => None, }, ScalarValue::LargeUtf8(val) => match ty { DataType::Utf8 => Some(ScalarValue::Utf8(val.clone())), DataType::LargeUtf8 => Some(value.clone()), + DataType::Utf8View => Some(ScalarValue::Utf8View(val.clone())), + _ => None, + }, + ScalarValue::Utf8View(val) => match ty { + DataType::Utf8 => Some(ScalarValue::Utf8(val.clone())), + DataType::LargeUtf8 => Some(ScalarValue::LargeUtf8(val.clone())), + DataType::Utf8View => Some(value.clone()), _ => None, }, ScalarValue::Boolean(_) => match ty { @@ -408,6 +563,7 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option match ty { DataType::Binary => Some(ScalarValue::Binary(value.clone())), DataType::LargeBinary => Some(ScalarValue::LargeBinary(value.clone())), + DataType::BinaryView => Some(ScalarValue::BinaryView(value.clone())), DataType::FixedSizeBinary(len) => { if let Some(value) = value { if value.len() == *len as usize { @@ -421,16 +577,68 @@ pub fn safe_coerce_scalar(value: &ScalarValue, ty: &DataType) -> Option None, }, + ScalarValue::BinaryView(val) => match ty { + DataType::Binary => Some(ScalarValue::Binary(val.clone())), + DataType::LargeBinary => Some(ScalarValue::LargeBinary(val.clone())), + DataType::BinaryView => Some(value.clone()), + _ => None, + }, + ScalarValue::LargeBinary(_) => match ty { + DataType::LargeBinary => Some(value.clone()), + _ => None, + }, + ScalarValue::Decimal128(_, _, _) => match ty { + DataType::Decimal128(_, _) => value.cast_to(ty).ok(), + _ => None, + }, + ScalarValue::Decimal256(_, _, _) => match ty { + DataType::Decimal256(_, _) => value.cast_to(ty).ok(), + _ => None, + }, + ScalarValue::DurationSecond(_) + | ScalarValue::DurationMillisecond(_) + | ScalarValue::DurationMicrosecond(_) + | ScalarValue::DurationNanosecond(_) => match ty { + DataType::Duration(_) => value.cast_to(ty).ok(), + _ => None, + }, + // A dictionary-encoded literal (e.g. produced by DataFusion's dictionary + // cast in the scalar-index path) coerces by unwrapping its underlying value. + ScalarValue::Dictionary(_, inner) => safe_coerce_scalar(inner, ty), _ => None, } } #[cfg(test)] mod tests { + use arrow::datatypes::i256; + use super::*; #[test] fn test_temporal_coerce() { + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Int64(Some(5)), + &DataType::Time32(TimeUnit::Second), + ), + Some(ScalarValue::Time32Second(Some(5))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Int64(Some(5000)), + &DataType::Time32(TimeUnit::Millisecond), + ), + Some(ScalarValue::Time32Millisecond(Some(5000))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Int64(Some(i64::MAX)), + &DataType::Time32(TimeUnit::Second), + ), + None + ); + // Conversion from timestamps in one resolution to timestamps in another resolution is allowed // s->s assert_eq!( @@ -702,5 +910,378 @@ mod tests { ), Some(ScalarValue::Time64Nanosecond(Some(5000000000))) ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::DurationNanosecond(Some(2_000_000)), + &DataType::Duration(TimeUnit::Millisecond), + ), + Some(ScalarValue::DurationMillisecond(Some(2))) + ); + } + + #[test] + fn test_string_view_coerce() { + // Utf8 <-> Utf8View + assert_eq!( + safe_coerce_scalar(&ScalarValue::Utf8(Some("hi".into())), &DataType::Utf8View), + Some(ScalarValue::Utf8View(Some("hi".into()))) + ); + assert_eq!( + safe_coerce_scalar(&ScalarValue::Utf8View(Some("hi".into())), &DataType::Utf8), + Some(ScalarValue::Utf8(Some("hi".into()))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Utf8View(Some("hi".into())), + &DataType::LargeUtf8 + ), + Some(ScalarValue::LargeUtf8(Some("hi".into()))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::LargeUtf8(Some("hi".into())), + &DataType::Utf8View + ), + Some(ScalarValue::Utf8View(Some("hi".into()))) + ); + // identity + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Utf8View(Some("hi".into())), + &DataType::Utf8View + ), + Some(ScalarValue::Utf8View(Some("hi".into()))) + ); + // Binary <-> BinaryView + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Binary(Some(vec![1, 2, 3])), + &DataType::BinaryView + ), + Some(ScalarValue::BinaryView(Some(vec![1, 2, 3]))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::BinaryView(Some(vec![1, 2, 3])), + &DataType::Binary + ), + Some(ScalarValue::Binary(Some(vec![1, 2, 3]))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::BinaryView(Some(vec![1, 2, 3])), + &DataType::BinaryView + ), + Some(ScalarValue::BinaryView(Some(vec![1, 2, 3]))) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::LargeBinary(Some(vec![1, 2, 3])), + &DataType::LargeBinary + ), + Some(ScalarValue::LargeBinary(Some(vec![1, 2, 3]))) + ); + } + + /// Every numeric literal type reaches `Float16`. SQL only produces `Int64` + /// and `Float64`, but `safe_coerce_scalar` is public and the index layer + /// feeds it whatever the planner already coerced, including `Float16` + /// itself. + #[rstest::rstest] + #[case::int8(ScalarValue::Int8(Some(-2)))] + #[case::int16(ScalarValue::Int16(Some(-2)))] + #[case::int32(ScalarValue::Int32(Some(-2)))] + #[case::int64(ScalarValue::Int64(Some(-2)))] + #[case::float32(ScalarValue::Float32(Some(-2.0)))] + #[case::float64(ScalarValue::Float64(Some(-2.0)))] + #[case::float16(ScalarValue::Float16(Some(f16::from_f32(-2.0))))] + fn numeric_literals_coerce_to_f16(#[case] value: ScalarValue) { + assert_eq!( + safe_coerce_scalar(&value, &DataType::Float16), + Some(ScalarValue::Float16(Some(f16::from_f32(-2.0)))), + ); + } + + #[rstest::rstest] + #[case::uint8(ScalarValue::UInt8(Some(2)))] + #[case::uint16(ScalarValue::UInt16(Some(2)))] + #[case::uint32(ScalarValue::UInt32(Some(2)))] + #[case::uint64(ScalarValue::UInt64(Some(2)))] + fn unsigned_literals_coerce_to_f16(#[case] value: ScalarValue) { + assert_eq!( + safe_coerce_scalar(&value, &DataType::Float16), + Some(ScalarValue::Float16(Some(f16::from_f32(2.0)))), + ); + } + + /// A `Float16` literal also has to reach the wider float columns, so a + /// predicate written against one column type still filters another. + #[test] + fn test_f16_literal_widens() { + let half = ScalarValue::Float16(Some(f16::from_f32(0.5))); + assert_eq!( + safe_coerce_scalar(&half, &DataType::Float32), + Some(ScalarValue::Float32(Some(0.5))), + ); + assert_eq!( + safe_coerce_scalar(&half, &DataType::Float64), + Some(ScalarValue::Float64(Some(0.5))), + ); + assert_eq!(safe_coerce_scalar(&half, &DataType::Int32), None); + } + + /// Rounding inside the `f16` range is accepted; leaving the range is not. + /// Expected values are spelled as bits so a change in how the input is + /// rounded fails here rather than being recomputed by the same library call + /// the code under test uses. + #[rstest::rstest] + // 0.1 has no exact binary form, so it lands on the nearest f16, 0x2E66. + #[case::rounds(0.1, Some(0x2E66))] + // Past 2048 the f16 grid is coarser than the integers: 2049 is the exact + // midpoint of 2048 and 2050, and the tie goes to the even mantissa. This is + // the case the doc comment cites for `= 2049` matching rows holding 2048. + #[case::odd_integer_ties_down(2049.0, Some(0x6800))] + // Above that midpoint 2050 is the nearer neighbour, and the coercion picks it. + // `half`'s own conversion returns 2048 here, which is the defect + // `nearest_finite_f16` exists to correct. + #[case::just_above_a_tie(2049.001, Some(0x6801))] + #[case::largest_finite(65504.0, Some(0x7BFF))] + // Rounds down to the largest finite f16 rather than overflowing. + #[case::just_under_overflow(65519.0, Some(0x7BFF))] + #[case::smallest_subnormal(6e-8, Some(0x0001))] + // 65520 is the first value that rounds to infinity, not 65504. + #[case::overflow_threshold(65520.0, None)] + #[case::overflow(70000.0, None)] + #[case::negative_overflow(-70000.0, None)] + #[case::f32_max(f32::MAX as f64, None)] + // Underflows to zero rather than silently matching real zeros. + #[case::underflow(1e-30, None)] + #[case::negative_underflow(-1e-30, None)] + fn test_f16_range_edges(#[case] input: f64, #[case] expected: Option) { + let coerced = safe_coerce_scalar(&ScalarValue::Float64(Some(input)), &DataType::Float16); + match expected { + Some(bits) => assert_eq!( + coerced, + Some(ScalarValue::Float16(Some(f16::from_bits(bits)))), + ), + None => assert_eq!(coerced, None), + } + } + + /// A literal that is already infinite or NaN converts faithfully. Only a + /// finite value that overflowed is rejected. + #[test] + fn test_f16_keeps_non_finite_literals() { + for (input, expected) in [ + (f64::INFINITY, f16::INFINITY), + (f64::NEG_INFINITY, f16::NEG_INFINITY), + ] { + assert_eq!( + safe_coerce_scalar(&ScalarValue::Float64(Some(input)), &DataType::Float16), + Some(ScalarValue::Float16(Some(expected))), + ); + } + let nan = safe_coerce_scalar(&ScalarValue::Float64(Some(f64::NAN)), &DataType::Float16); + assert!(matches!(nan, Some(ScalarValue::Float16(Some(v))) if v.is_nan())); + } + + /// Both zeros survive as themselves. Signed-zero comparison semantics are a + /// separate problem (#5868); coercion must at least not erase the sign. + #[test] + fn test_f16_keeps_zero_sign() { + assert_eq!( + safe_coerce_scalar(&ScalarValue::Float64(Some(-0.0)), &DataType::Float16), + Some(ScalarValue::Float16(Some(f16::NEG_ZERO))), + ); + assert_eq!( + safe_coerce_scalar(&ScalarValue::Float64(Some(0.0)), &DataType::Float16), + Some(ScalarValue::Float16(Some(f16::ZERO))), + ); + assert_eq!( + safe_coerce_scalar(&ScalarValue::Int64(Some(0)), &DataType::Float16), + Some(ScalarValue::Float16(Some(f16::ZERO))), + ); + } + + /// Sweep every rounding decision the conversion can make instead of trusting + /// the handful of points named above: for each adjacent pair of finite `f16` + /// values, the exact midpoint and the two `f64` values either side of it. A + /// misrounding anywhere in the range shows up here, which is how the + /// `2049.001` case was found in the first place. + /// + /// The expectation is stated, not recomputed: below the midpoint the lower + /// neighbour, above it the upper one, at it the even mantissa. + #[test] + fn test_f16_rounds_to_nearest_even_across_the_whole_range() { + fn coerce(value: f64) -> Option { + match safe_coerce_scalar(&ScalarValue::Float64(Some(value)), &DataType::Float16) { + Some(ScalarValue::Float16(Some(v))) => Some(v), + // Rejected, which the underflow side of the range expects. + None => None, + other => panic!("expected a Float16 literal for {value}, got {other:?}"), + } + } + // A nonzero literal that lands on zero is rejected rather than coerced, + // so the smallest pair expects `None` on its lower side. + fn want(expected: f16, input: f64) -> Option { + if expected == f16::ZERO && input != 0.0 { + None + } else { + Some(expected) + } + } + + // 0x7BFF is the largest finite f16, so pairing each bit pattern with the + // next covers every adjacent finite pair on the positive side. Negatives + // are covered by the symmetry check below. + for lower_bits in 0..0x7BFFu16 { + let lower = f16::from_bits(lower_bits); + let upper = f16::from_bits(lower_bits + 1); + // Both operands are f16 widened to f64, so the average is exact. + let midpoint = (lower.to_f64() + upper.to_f64()) / 2.0; + let below = f64::from_bits(midpoint.to_bits() - 1); + let above = f64::from_bits(midpoint.to_bits() + 1); + + assert_eq!(coerce(below), want(lower, below), "just below {midpoint}"); + assert_eq!(coerce(above), want(upper, above), "just above {midpoint}"); + let even = if lower.to_bits() & 1 == 0 { + lower + } else { + upper + }; + assert_eq!(coerce(midpoint), want(even, midpoint), "at {midpoint}"); + } + } + + /// Sign is not part of the rounding decision, so negating the input negates + /// the result. This is what lets the sweep above cover only positives. + #[rstest::rstest] + #[case(0.1)] + #[case(2049.001)] + #[case(65504.0)] + #[case(6e-8)] + #[case(70000.0)] + #[case(1e-30)] + fn test_f16_coercion_is_sign_symmetric(#[case] magnitude: f64) { + let positive = + safe_coerce_scalar(&ScalarValue::Float64(Some(magnitude)), &DataType::Float16); + let negative = + safe_coerce_scalar(&ScalarValue::Float64(Some(-magnitude)), &DataType::Float16); + let flipped = match positive { + Some(ScalarValue::Float16(Some(v))) => Some(ScalarValue::Float16(Some(-v))), + other => other, + }; + assert_eq!(negative, flipped); + } + + #[test] + fn test_decimal_coerce() { + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Decimal128(Some(2), 10, 0), + &DataType::Decimal128(12, 2), + ), + Some(ScalarValue::Decimal128(Some(200), 12, 2)) + ); + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Decimal256(Some(i256::from_i128(2)), 76, 0), + &DataType::Decimal256(76, 2), + ), + Some(ScalarValue::Decimal256(Some(i256::from_i128(200)), 76, 2)) + ); + } + + #[test] + fn test_dictionary_coerce() { + let dict_ty = DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)); + + // A string literal coerces to a dictionary target by wrapping the + // coerced value in a dictionary scalar. + assert_eq!( + safe_coerce_scalar(&ScalarValue::Utf8(Some("com".to_string())), &dict_ty), + Some(ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(Some("com".to_string()))), + )) + ); + + // The inner value is coerced through to the dictionary value type, so a + // LargeUtf8 literal lands as a Utf8 value inside the dictionary. + assert_eq!( + safe_coerce_scalar(&ScalarValue::LargeUtf8(Some("com".to_string())), &dict_ty), + Some(ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(Some("com".to_string()))), + )) + ); + + // A dictionary literal round-trips back to its value type. + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(Some("com".to_string()))), + ), + &DataType::Utf8, + ), + Some(ScalarValue::Utf8(Some("com".to_string()))) + ); + + // A dictionary literal coerces to a dictionary target, adopting the + // target's key type. + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Dictionary( + Box::new(DataType::Int32), + Box::new(ScalarValue::Utf8(Some("com".to_string()))), + ), + &dict_ty, + ), + Some(ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(Some("com".to_string()))), + )) + ); + + // An untyped null keeps its untyped form for a dictionary target, just + // like for every other target type. + assert_eq!( + safe_coerce_scalar(&ScalarValue::Null, &dict_ty), + Some(ScalarValue::Null) + ); + + // A *typed* null (e.g. an API-built `Utf8(None)` literal, or an IN value + // already typed as Utf8) is still wrapped in the dictionary type so it + // matches the dictionary column. Returning a bare `Utf8(None)` here would + // leave `resolve_value` with a literal whose type does not line up with + // the column, breaking planning/evaluation the same way non-null strings + // used to break. + assert_eq!( + safe_coerce_scalar(&ScalarValue::Utf8(None), &dict_ty), + Some(ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(None)), + )) + ); + + // The inner null is coerced through to the dictionary value type as well, + // so a LargeUtf8 typed null lands as a Utf8 null inside the dictionary. + assert_eq!( + safe_coerce_scalar(&ScalarValue::LargeUtf8(None), &dict_ty), + Some(ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(None)), + )) + ); + + // A value that cannot be coerced to the dictionary value type fails. + assert_eq!( + safe_coerce_scalar( + &ScalarValue::Utf8(Some("com".to_string())), + &DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Int32)), + ), + None + ); } } diff --git a/rust/lance-datafusion/src/lib.rs b/rust/lance-datafusion/src/lib.rs index fa65a918191..ce67c5fdd1c 100644 --- a/rust/lance-datafusion/src/lib.rs +++ b/rust/lance-datafusion/src/lib.rs @@ -1,14 +1,28 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +pub mod aggregate; pub mod chunker; pub mod dataframe; +#[cfg(any(test, feature = "datagen"))] pub mod datagen; pub mod exec; pub mod expr; pub mod logical_expr; pub mod planner; pub mod projection; +pub mod pb { + #![allow(clippy::all)] + #![allow(non_upper_case_globals)] + #![allow(non_camel_case_types)] + #![allow(non_snake_case)] + #![allow(unused)] + #![allow(improper_ctypes)] + #![allow(clippy::upper_case_acronyms)] + #![allow(clippy::use_self)] + include!(concat!(env!("OUT_DIR"), "/lance.datafusion.rs")); +} +mod signed_zero; pub mod spill; pub mod sql; #[cfg(feature = "substrait")] diff --git a/rust/lance-datafusion/src/logical_expr.rs b/rust/lance-datafusion/src/logical_expr.rs index bcd81730d7f..7738de1b651 100644 --- a/rust/lance-datafusion/src/logical_expr.rs +++ b/rust/lance-datafusion/src/logical_expr.rs @@ -3,34 +3,26 @@ //! Extends logical expression. -use std::sync::Arc; - use arrow_schema::DataType; use crate::expr::safe_coerce_scalar; -use datafusion::logical_expr::{expr::ScalarFunction, BinaryExpr, Operator}; -use datafusion::logical_expr::{Between, ScalarUDF, ScalarUDFImpl}; +use datafusion::logical_expr::{Between, ScalarUDFImpl}; +use datafusion::logical_expr::{BinaryExpr, Operator}; use datafusion::prelude::*; use datafusion::scalar::ScalarValue; +use datafusion_functions::core::expr_ext::FieldAccessor; use datafusion_functions::core::getfield::GetFieldFunc; use lance_arrow::DataTypeExt; use lance_core::datatypes::Schema; use lance_core::{Error, Result}; -use snafu::location; /// Resolve a Value fn resolve_value(expr: &Expr, data_type: &DataType) -> Result { match expr { Expr::Literal(scalar_value, metadata) => { - Ok(Expr::Literal(safe_coerce_scalar(scalar_value, data_type).ok_or_else(|| Error::invalid_input( - format!("Received literal {expr} and could not convert to literal of type '{data_type:?}'"), - location!(), - ))?, metadata.clone())) + Ok(Expr::Literal(safe_coerce_scalar(scalar_value, data_type).ok_or_else(|| Error::invalid_input(format!("Received literal {expr} and could not convert to literal of type '{data_type:?}'")))?, metadata.clone())) } - _ => Err(Error::invalid_input( - format!("Expected a literal of type '{data_type:?}' but received: {expr}"), - location!(), - )), + _ => Err(Error::invalid_input(format!("Expected a literal of type '{data_type:?}' but received: {expr}"))), } } @@ -43,7 +35,7 @@ pub fn get_as_string_scalar_opt(expr: &Expr) -> Option<&str> { } } -/// Given a Expr::Column or Expr::GetIndexedField, get the data type of referenced +/// Given a column or nested `get_field` expression, get the data type of the referenced /// field in the schema. /// /// If the column is not found in the schema, return None. If the expression is @@ -58,14 +50,10 @@ pub fn resolve_column_type(expr: &Expr, schema: &Schema) -> Option { field_path.push(c.name.as_str()); break; } - Expr::ScalarFunction(udf) => { - if udf.name() == GetFieldFunc::default().name() { - let name = get_as_string_scalar_opt(&udf.args[1])?; - field_path.push(name); - current_expr = &udf.args[0]; - } else { - return None; - } + Expr::ScalarFunction(udf) if udf.name() == GetFieldFunc::default().name() => { + let name = get_as_string_scalar_opt(&udf.args[1])?; + field_path.push(name); + current_expr = &udf.args[0]; } _ => return None, } @@ -83,6 +71,24 @@ pub fn resolve_column_type(expr: &Expr, schema: &Schema) -> Option { Some(field.data_type()) } +fn is_literal_only_same_type_expr(expr: &Expr) -> bool { + match expr { + Expr::BinaryExpr(BinaryExpr { left, op, right }) => { + (op.is_numerical_operators() + || matches!( + op, + Operator::BitwiseShiftLeft + | Operator::BitwiseShiftRight + | Operator::StringConcat + )) + && is_literal_only_same_type_expr(left) + && is_literal_only_same_type_expr(right) + } + Expr::Literal(..) => true, + _ => false, + } +} + /// Resolve logical expression `expr`. /// /// Parameters @@ -122,16 +128,17 @@ pub fn resolve_expr(expr: &Expr, schema: &Schema) -> Result { op: *op, right: Box::new(resolve_value(right.as_ref(), &left_type)?), })), - // For cases complex expressions (not just literals) on right hand side like x = 1 + 1 + -2*2 - Expr::BinaryExpr(r) => Ok(Expr::BinaryExpr(BinaryExpr { - left: left.clone(), - op: *op, - right: Box::new(Expr::BinaryExpr(BinaryExpr { - left: coerce_expr(&r.left, &left_type).map(Box::new)?, - op: r.op, - right: coerce_expr(&r.right, &left_type).map(Box::new)?, - })), - })), + // Constant expressions need the column type applied to all of their literals. + Expr::BinaryExpr(_) if is_literal_only_same_type_expr(right) => { + Ok(Expr::BinaryExpr(BinaryExpr { + left: left.clone(), + op: *op, + right: Box::new(coerce_expr(right, &left_type)?), + })) + } + // Let DataFusion coerce other expressions. Recursively resolving them + // can narrow literals to an inner column's type before arithmetic promotes + // its operands, for example the 0.5 in `float_col = int_col + 0.5`. _ => Ok(expr.clone()), } } else if let Some(right_type) = resolve_column_type(right.as_ref(), schema) { @@ -201,53 +208,30 @@ pub fn coerce_filter_type_to_boolean(expr: Expr) -> Expr { match expr { // Coerce regexp_match to boolean by checking for non-null Expr::ScalarFunction(sf) if sf.func.name() == "regexp_match" => { - log::warn!("regexp_match now is coerced to boolean, this may be changed in the future, please use `regexp_like` instead"); + log::warn!( + "regexp_match now is coerced to boolean, this may be changed in the future, please use `regexp_like` instead" + ); Expr::IsNotNull(Box::new(Expr::ScalarFunction(sf))) } - // Recurse into boolean contexts so nested regexp_match terms are also coerced - Expr::BinaryExpr(BinaryExpr { left, op, right }) => Expr::BinaryExpr(BinaryExpr { - left: Box::new(coerce_filter_type_to_boolean(*left)), - op, - right: Box::new(coerce_filter_type_to_boolean(*right)), - }), + // Only boolean operands need coercion. Null checks and comparisons must + // preserve regexp_match's nullable list result. + Expr::BinaryExpr(BinaryExpr { left, op, right }) + if matches!(op, Operator::And | Operator::Or) => + { + Expr::BinaryExpr(BinaryExpr { + left: Box::new(coerce_filter_type_to_boolean(*left)), + op, + right: Box::new(coerce_filter_type_to_boolean(*right)), + }) + } Expr::Not(inner) => Expr::Not(Box::new(coerce_filter_type_to_boolean(*inner))), - Expr::IsNull(inner) => Expr::IsNull(Box::new(coerce_filter_type_to_boolean(*inner))), - Expr::IsNotNull(inner) => Expr::IsNotNull(Box::new(coerce_filter_type_to_boolean(*inner))), // Pass-through for all other nodes other => other, } } -// As part of the DF 37 release there are now two different ways to -// represent a nested field access in `Expr`. The old way is to use -// `Expr::field` which returns a `GetStructField` and the new way is -// to use `Expr::ScalarFunction` with a `GetFieldFunc` UDF. -// -// Currently, the old path leads to bugs in DF. This is probably a -// bug and will probably be fixed in a future version. In the meantime -// we need to make sure we are always using the new way to avoid this -// bug. This trait adds field_newstyle which lets us easily create -// logical `Expr` that use the new style. -pub trait ExprExt { - // Helper function to replace Expr::field in DF 37 since DF - // confuses itself with the GetStructField returned by Expr::field - fn field_newstyle(&self, name: &str) -> Expr; -} - -impl ExprExt for Expr { - fn field_newstyle(&self, name: &str) -> Expr { - Self::ScalarFunction(ScalarFunction { - func: Arc::new(ScalarUDF::new_from_impl(GetFieldFunc::default())), - args: vec![ - self.clone(), - Self::Literal(ScalarValue::Utf8(Some(name.to_string())), None), - ], - }) - } -} - /// Convert a field path string into a DataFusion expression. /// /// This function handles: @@ -284,29 +268,62 @@ pub fn field_path_to_expr(field_path: &str) -> Result { let parts = lance_core::datatypes::parse_field_path(field_path)?; if parts.is_empty() { - return Err(Error::invalid_input( - format!("Invalid empty field path: {}", field_path), - location!(), - )); + return Err(Error::invalid_input(format!( + "Invalid empty field path: {}", + field_path + ))); } - // Build the column expression, handling nested fields - let mut expr = col(&parts[0]); + // Build the column expression, handling nested fields. + let mut expr = Expr::Column(datafusion::common::Column::new_unqualified( + parts[0].clone(), + )); for part in &parts[1..] { - expr = expr.field_newstyle(part); + expr = expr.field(part.as_str()); } Ok(expr) } #[cfg(test)] -pub mod tests { +mod tests { use std::sync::Arc; use super::*; use arrow_schema::{Field, Schema as ArrowSchema}; - use datafusion_functions::core::expr_ext::FieldAccessor; + use datafusion::common::Column; + + #[test] + fn test_field_path_to_expr_preserves_case_sensitive_root_column() { + let expr = field_path_to_expr("VECTOR").unwrap(); + + assert_eq!(expr, Expr::Column(Column::new_unqualified("VECTOR"))); + } + + #[rstest::rstest] + #[case::nested("Parent.Child", "Parent", vec!["Child"])] + #[case::deeply_nested("Parent.Child.Leaf", "Parent", vec!["Child", "Leaf"])] + #[case::escaped_child("Parent.`Child.With.Dot`", "Parent", vec!["Child.With.Dot"])] + #[case::escaped_root("`Parent.With.Dot`.Child", "Parent.With.Dot", vec!["Child"])] + #[case::escaped_nested( + "`Parent.With.Dot`.`Child.With.Dot`.Leaf", + "Parent.With.Dot", + vec!["Child.With.Dot", "Leaf"] + )] + fn test_field_path_to_expr_preserves_nested_names( + #[case] path: &str, + #[case] root: &str, + #[case] children: Vec<&str>, + ) { + let expr = field_path_to_expr(path).unwrap(); + let mut expected = Expr::Column(Column::new_unqualified(root)); + for name in children { + expected = get_field(expected, name); + } + + assert_eq!(expr, expected); + } #[test] fn test_resolve_large_utf8() { @@ -404,6 +421,7 @@ pub mod tests { DataType::Struct( vec![ Field::new("str", DataType::Utf8, true), + Field::new("Child.With.Dot", DataType::Int32, true), Field::new( "st", DataType::Struct( @@ -432,6 +450,11 @@ pub mod tests { Some(DataType::Float64) ); + assert_eq!( + resolve_column_type(&field_path_to_expr("st.`Child.With.Dot`").unwrap(), &schema), + Some(DataType::Int32) + ); + assert_eq!(resolve_column_type(&col("x"), &schema), None); assert_eq!(resolve_column_type(&col("str"), &schema), None); assert_eq!(resolve_column_type(&col("float"), &schema), None); @@ -440,4 +463,86 @@ pub mod tests { None ); } + + #[test] + fn test_resolve_utf8view_literal_against_utf8_column() { + // Simulates DataFusion 43+ producing a Utf8View literal (e.g. from md5()) + // being compared against a Utf8 column stored in Lance. + let arrow_schema = ArrowSchema::new(vec![Field::new("hash", DataType::Utf8, false)]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + + let expr = Expr::BinaryExpr(BinaryExpr { + left: Box::new(Expr::Column("hash".to_string().into())), + op: Operator::Eq, + right: Box::new(Expr::Literal( + ScalarValue::Utf8View(Some("abc".to_string())), + None, + )), + }); + + let resolved = resolve_expr(&expr, &schema).unwrap(); + match resolved { + Expr::BinaryExpr(be) => { + assert_eq!( + be.right.as_ref(), + &Expr::Literal(ScalarValue::Utf8(Some("abc".to_string())), None) + ) + } + _ => unreachable!("Expected BinaryExpr"), + } + } + + #[test] + fn test_resolve_typed_null_against_dictionary_column() { + // A dictionary-encoded string column, e.g. a categorical field. + let dict_ty = DataType::Dictionary(Box::new(DataType::Int16), Box::new(DataType::Utf8)); + let arrow_schema = ArrowSchema::new(vec![Field::new("etld", dict_ty, true)]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + + // A typed null must be wrapped in the dictionary type, not left as a bare + // `Utf8(None)` literal sitting next to a `Dictionary(...)` column. + let expected_null = Expr::Literal( + ScalarValue::Dictionary(Box::new(DataType::Int16), Box::new(ScalarValue::Utf8(None))), + None, + ); + + // `etld = ` built directly via the API, as opposed to coming + // through SQL parsing. + let expr = Expr::BinaryExpr(BinaryExpr { + left: Box::new(Expr::Column("etld".to_string().into())), + op: Operator::Eq, + right: Box::new(Expr::Literal(ScalarValue::Utf8(None), None)), + }); + match resolve_expr(&expr, &schema).unwrap() { + Expr::BinaryExpr(be) => assert_eq!(be.right.as_ref(), &expected_null), + other => unreachable!("Expected BinaryExpr, got {other:?}"), + } + + // `etld IN ('a', )` — a typed value mixed with a typed null, + // both already typed as Utf8. Every list element is wrapped in the + // dictionary type. + let expr = Expr::in_list( + Expr::Column("etld".to_string().into()), + vec![ + Expr::Literal(ScalarValue::Utf8(Some("a".to_string())), None), + Expr::Literal(ScalarValue::Utf8(None), None), + ], + false, + ); + let expected = Expr::in_list( + Expr::Column("etld".to_string().into()), + vec![ + Expr::Literal( + ScalarValue::Dictionary( + Box::new(DataType::Int16), + Box::new(ScalarValue::Utf8(Some("a".to_string()))), + ), + None, + ), + expected_null, + ], + false, + ); + assert_eq!(resolve_expr(&expr, &schema).unwrap(), expected); + } } diff --git a/rust/lance-datafusion/src/planner.rs b/rust/lance-datafusion/src/planner.rs index d27efc386d0..d9f5c6bb339 100644 --- a/rust/lance-datafusion/src/planner.rs +++ b/rust/lance-datafusion/src/planner.rs @@ -7,20 +7,23 @@ use std::borrow::Cow; use std::collections::{BTreeSet, VecDeque}; use std::sync::Arc; -use crate::exec::{get_session_context, LanceExecutionOptions}; +use crate::exec::{LanceExecutionOptions, get_session_context}; use crate::expr::safe_coerce_scalar; use crate::logical_expr::{coerce_filter_type_to_boolean, get_as_string_scalar_opt, resolve_expr}; +use crate::signed_zero::{normalize_zero_comparisons, rewrite_signed_zero_comparisons}; 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::tree_node::{TreeNode, TreeNodeRecursion, TreeNodeVisitor}; +use datafusion::catalog::Session; use datafusion::common::DFSchema; +use datafusion::common::tree_node::{Transformed, TreeNode, TreeNodeRecursion, TreeNodeVisitor}; use datafusion::config::ConfigOptions; use datafusion::error::Result as DFResult; -use datafusion::execution::context::SessionState; +use datafusion::execution::context::{SessionContext, SessionState}; use datafusion::logical_expr::expr::ScalarFunction; use datafusion::logical_expr::planner::{ExprPlanner, PlannerResult, RawFieldAccessExpr}; use datafusion::logical_expr::{ @@ -39,20 +42,49 @@ use datafusion::sql::sqlparser::ast::{ use datafusion::{ common::Column, logical_expr::{Between, BinaryExpr, Like, Operator}, - physical_expr::execution_props::ExecutionProps, physical_plan::PhysicalExpr, prelude::Expr, 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; -use snafu::location; use chrono::Utc; use lance_core::{Error, Result}; +/// Encode a JSON string into a JSONB `LargeBinary` literal expression. +fn encode_jsonb(json_str: &str) -> Result { + let bytes = lance_arrow::json::encode_json(json_str) + .map_err(|e| Error::invalid_input(format!("Failed to encode JSONB: {e}")))?; + Ok(Expr::Literal(ScalarValue::LargeBinary(Some(bytes)), None)) +} + +// The escape in `LIKE/ILIKE ... ESCAPE ''` must be exactly one character. +// Reject empty or multi-character escape strings rather than silently treating +// them as "no escape" or truncating to the first character. +fn parse_like_escape_char(escape_char: &Option) -> Result> { + let Some(value) = escape_char else { + return Ok(None); + }; + let ValueWithSpan { + value: Value::SingleQuotedString(escape), + .. + } = value + else { + return Err(Error::invalid_input(format!( + "Invalid escape character in LIKE expression. Expected a single character wrapped with single quotes, got {value}" + ))); + }; + let mut chars = escape.chars(); + match (chars.next(), chars.next()) { + (Some(c), None) => Ok(Some(c)), + _ => Err(Error::invalid_input(format!( + "Invalid escape character in LIKE expression. Expected a single character, got '{escape}'" + ))), + } +} + #[derive(Debug, Clone, Eq, PartialEq, Hash)] struct CastListF16Udf { signature: Signature, @@ -67,10 +99,6 @@ impl CastListF16Udf { } impl ScalarUDFImpl for CastListF16Udf { - fn as_any(&self) -> &dyn std::any::Any { - self - } - fn name(&self) -> &str { "_cast_list_f16" } @@ -155,28 +183,39 @@ impl ScalarUDFImpl for CastListF16Udf { // Adapter that instructs datafusion how lance expects expressions to be interpreted struct LanceContextProvider { options: datafusion::config::ConfigOptions, - state: SessionState, + // Functions are looked up in the session's shared state rather than in a + // clone of it: planners are created several times per query, and cloning + // every function map and rule list for each one is a measurable share of + // CPU for short queries. + session: SessionContext, expr_planners: Vec>, } -impl Default for LanceContextProvider { - fn default() -> Self { - let ctx = get_session_context(&LanceExecutionOptions::default()); - let state = ctx.state(); - let expr_planners = state.expr_planners().to_vec(); - +impl LanceContextProvider { + fn new(session: SessionContext) -> Self { + let expr_planners = session.state_ref().read().expr_planners().to_vec(); Self { options: ConfigOptions::default(), - state, + session, expr_planners, } } + + fn with_state(&self, read: impl FnOnce(&SessionState) -> T) -> T { + read(&self.session.state_ref().read()) + } +} + +impl Default for LanceContextProvider { + fn default() -> Self { + Self::new(get_session_context(&LanceExecutionOptions::default())) + } } impl ContextProvider for LanceContextProvider { fn get_table_source( &self, - name: datafusion::sql::TableReference, + name: datafusion::common::TableReference, ) -> DFResult> { Err(datafusion::error::DataFusionError::NotImplemented(format!( "Attempt to reference inner table {} not supported", @@ -185,11 +224,18 @@ impl ContextProvider for LanceContextProvider { } fn get_aggregate_meta(&self, name: &str) -> Option> { - self.state.aggregate_functions().get(name).cloned() + self.with_state(|state| state.aggregate_functions().get(name).cloned()) } fn get_window_meta(&self, name: &str) -> Option> { - self.state.window_functions().get(name).cloned() + self.with_state(|state| state.window_functions().get(name).cloned()) + } + + fn get_higher_order_meta( + &self, + name: &str, + ) -> Option> { + self.with_state(|state| state.higher_order_functions().get(name).cloned()) } fn get_function_meta(&self, f: &str) -> Option> { @@ -197,7 +243,7 @@ impl ContextProvider for LanceContextProvider { // TODO: cast should go thru CAST syntax instead of UDF // Going thru UDF makes it hard for the optimizer to find no-ops "_cast_list_f16" => Some(Arc::new(ScalarUDF::new_from_impl(CastListF16Udf::new()))), - _ => self.state.scalar_functions().get(f).cloned(), + _ => self.with_state(|state| state.scalar_functions().get(f).cloned()), } } @@ -211,15 +257,19 @@ impl ContextProvider for LanceContextProvider { } fn udf_names(&self) -> Vec { - self.state.scalar_functions().keys().cloned().collect() + self.with_state(|state| state.scalar_functions().keys().cloned().collect()) } fn udaf_names(&self) -> Vec { - self.state.aggregate_functions().keys().cloned().collect() + self.with_state(|state| state.aggregate_functions().keys().cloned().collect()) } fn udwf_names(&self) -> Vec { - self.state.window_functions().keys().cloned().collect() + self.with_state(|state| state.window_functions().keys().cloned().collect()) + } + + fn higher_order_function_names(&self) -> Vec { + self.with_state(|state| state.higher_order_functions().keys().cloned().collect()) } fn get_expr_planners(&self) -> &[Arc] { @@ -316,26 +366,99 @@ impl Planner { BinaryOperator::NotEq => Operator::NotEq, BinaryOperator::And => Operator::And, BinaryOperator::Or => Operator::Or, + BinaryOperator::PGBitwiseShiftLeft => Operator::BitwiseShiftLeft, + BinaryOperator::PGBitwiseShiftRight => Operator::BitwiseShiftRight, _ => { - return Err(Error::invalid_input( - format!("Operator {op} is not supported"), - location!(), - )); + return Err(Error::invalid_input(format!( + "Operator {op} is not supported" + ))); } }) } + fn is_logical_binary_op(op: &BinaryOperator) -> bool { + matches!(op, BinaryOperator::And | BinaryOperator::Or) + } + + fn is_same_logical_binary_op(left: &BinaryOperator, right: &BinaryOperator) -> bool { + matches!( + (left, right), + (BinaryOperator::And, BinaryOperator::And) | (BinaryOperator::Or, BinaryOperator::Or) + ) + } + + fn flatten_logical_binary_exprs<'a>( + left: &'a SQLExpr, + op: &BinaryOperator, + right: &'a SQLExpr, + ) -> Vec<&'a SQLExpr> { + let mut leaves = Vec::new(); + let mut stack = vec![right, left]; + + while let Some(expr) = stack.pop() { + match expr { + SQLExpr::BinaryOp { + left, + op: child_op, + right, + } if Self::is_same_logical_binary_op(op, child_op) => { + stack.push(right.as_ref()); + stack.push(left.as_ref()); + } + _ => leaves.push(expr), + } + } + + leaves + } + + fn balanced_binary_expr(mut exprs: VecDeque, op: Operator) -> Result { + if exprs.is_empty() { + return Err(Error::invalid_input("Binary expression has no operands")); + } + + while exprs.len() > 1 { + let mut next = VecDeque::with_capacity(exprs.len().div_ceil(2)); + while let Some(left) = exprs.pop_front() { + if let Some(right) = exprs.pop_front() { + next.push_back(Expr::BinaryExpr(BinaryExpr::new( + Box::new(left), + op, + Box::new(right), + ))); + } else { + next.push_back(left); + } + } + exprs = next; + } + + exprs + .pop_front() + .ok_or_else(|| Error::invalid_input("Binary expression has no operands")) + } + fn binary_expr(&self, left: &SQLExpr, op: &BinaryOperator, right: &SQLExpr) -> Result { + let df_op = self.binary_op(op)?; + if Self::is_logical_binary_op(op) { + let leaves = Self::flatten_logical_binary_exprs(left, op, right); + let mut exprs = VecDeque::with_capacity(leaves.len()); + for leaf in leaves { + exprs.push_back(self.parse_sql_expr(leaf)?); + } + return Self::balanced_binary_expr(exprs, df_op); + } + Ok(Expr::BinaryExpr(BinaryExpr::new( Box::new(self.parse_sql_expr(left)?), - self.binary_op(op)?, + df_op, Box::new(self.parse_sql_expr(right)?), ))) } fn unary_expr(&self, op: &UnaryOperator, expr: &SQLExpr) -> Result { Ok(match op { - UnaryOperator::Not | UnaryOperator::PGBitwiseNot => { + UnaryOperator::Not | UnaryOperator::BitwiseNot => { Expr::Not(Box::new(self.parse_sql_expr(expr)?)) } @@ -347,10 +470,7 @@ impl Planner { Err(_) => lit(-n .parse::() .map_err(|_e| { - Error::invalid_input( - format!("negative operator can be only applied to integer and float operands, got: {n}"), - location!(), - ) + Error::invalid_input(format!("negative operator can be only applied to integer and float operands, got: {n}")) })?), }, _ => { @@ -360,10 +480,10 @@ impl Planner { } _ => { - return Err(Error::invalid_input( - format!("Unary operator '{:?}' is not supported", op), - location!(), - )); + return Err(Error::invalid_input(format!( + "Unary operator '{:?}' is not supported", + op + ))); } }) } @@ -378,12 +498,11 @@ impl Planner { }; if let Ok(n) = value.parse::() { Ok(lit(n)) + } else if let Ok(n) = value.parse::() { + Ok(lit(n)) } else { value.parse::().map(lit).map_err(|_| { - Error::invalid_input( - format!("'{value}' is not supported number value."), - location!(), - ) + Error::invalid_input(format!("'{value}' is not supported number value.")) }) } } @@ -405,10 +524,10 @@ impl Planner { fn parse_function_args(&self, func_args: &FunctionArg) -> Result { match func_args { FunctionArg::Unnamed(FunctionArgExpr::Expr(expr)) => self.parse_sql_expr(expr), - _ => Err(Error::invalid_input( - format!("Unsupported function args: {:?}", func_args), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unsupported function args: {:?}", + func_args + ))), } } @@ -422,29 +541,28 @@ impl Planner { match &func.args { FunctionArguments::List(args) => { if func.name.0.len() != 1 { - return Err(Error::invalid_input( - format!("Function name must have 1 part, got: {:?}", func.name.0), - location!(), - )); + return Err(Error::invalid_input(format!( + "Function name must have 1 part, got: {:?}", + func.name.0 + ))); } Ok(Expr::IsNotNull(Box::new( self.parse_function_args(&args.args[0])?, ))) } - _ => Err(Error::invalid_input( - format!("Unsupported function args: {:?}", &func.args), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unsupported function args: {:?}", + func.args + ))), } } fn parse_function(&self, function: SQLExpr) -> Result { - if let SQLExpr::Function(function) = &function { - if let Some(ObjectNamePart::Identifier(name)) = &function.name.0.first() { - if &name.value == "is_valid" { - return self.legacy_parse_function(function); - } - } + if let SQLExpr::Function(function) = &function + && let Some(ObjectNamePart::Identifier(name)) = &function.name.0.first() + && &name.value == "is_valid" + { + return self.legacy_parse_function(function); } let sql_to_rel = SqlToRel::new_with_options( &self.context_provider, @@ -463,7 +581,7 @@ impl Planner { let schema = DFSchema::try_from(self.schema.as_ref().clone())?; sql_to_rel .sql_to_expr(function, &schema, &mut planner_context) - .map_err(|e| Error::invalid_input(format!("Error parsing function: {e}"), location!())) + .map_err(|e| Error::invalid_input(format!("Error parsing function: {e}"))) } fn parse_type(&self, data_type: &SQLDataType) -> Result { @@ -505,7 +623,6 @@ impl Planner { _ => { return Err(Error::invalid_input( "Timezone not supported in timestamp".to_string(), - location!(), )); } }; @@ -517,10 +634,10 @@ impl Planner { Some(6) => TimeUnit::Microsecond, Some(9) => TimeUnit::Nanosecond, _ => { - return Err(Error::invalid_input( - format!("Unsupported datetime resolution: {:?}", resolution), - location!(), - )); + return Err(Error::invalid_input(format!( + "Unsupported datetime resolution: {:?}", + resolution + ))); } }; Ok(ArrowDataType::Timestamp(time_unit, None)) @@ -533,10 +650,10 @@ impl Planner { Some(6) => TimeUnit::Microsecond, Some(9) => TimeUnit::Nanosecond, _ => { - return Err(Error::invalid_input( - format!("Unsupported datetime resolution: {:?}", resolution), - location!(), - )); + return Err(Error::invalid_input(format!( + "Unsupported datetime resolution: {:?}", + resolution + ))); } }; Ok(ArrowDataType::Timestamp(time_unit, None)) @@ -545,21 +662,15 @@ impl Planner { ExactNumberInfo::PrecisionAndScale(precision, scale) => { Ok(ArrowDataType::Decimal128(*precision as u8, *scale as i8)) } - _ => Err(Error::invalid_input( - format!( - "Must provide precision and scale for decimal: {:?}", - number_info - ), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Must provide precision and scale for decimal: {:?}", + number_info + ))), }, - _ => Err(Error::invalid_input( - format!( - "Unsupported data type: {:?}. Supported types: {:?}", - data_type, SUPPORTED_TYPES - ), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unsupported data type: {:?}. Supported types: {:?}", + data_type, SUPPORTED_TYPES + ))), } } @@ -573,10 +684,7 @@ impl Planner { } } } - Err(Error::invalid_input( - "Field access could not be planned", - location!(), - )) + Err(Error::invalid_input("Field access could not be planned")) } fn parse_sql_expr(&self, expr: &SQLExpr) -> Result { @@ -605,13 +713,10 @@ impl Planner { let mut values = vec![]; let array_literal_error = |pos: usize, value: &_| { - Err(Error::invalid_input( - format!( - "Expected a literal value in array, instead got {} at position {}", - value, pos - ), - location!(), - )) + Err(Error::invalid_input(format!( + "Expected a literal value in array, instead got {} at position {}", + value, pos + ))) }; for (pos, expr) in elem.iter().enumerate() { @@ -652,10 +757,7 @@ impl Planner { for value in &mut values { if value.data_type() != data_type { - *value = safe_coerce_scalar(value, &data_type).ok_or_else(|| Error::invalid_input( - format!("Array expressions must have a consistent datatype. Expected: {}, got: {}", data_type, value.data_type()), - location!() - ))?; + *value = safe_coerce_scalar(value, &data_type).ok_or_else(|| Error::invalid_input(format!("Array expressions must have a consistent datatype. Expected: {}, got: {}", data_type, value.data_type())))?; } } Field::new("item", data_type, true) @@ -678,13 +780,26 @@ impl Planner { Ok(Expr::Literal(ScalarValue::List(Arc::new(values)), None)) } + // JSONB literal: jsonb '{"key": "value"}' + SQLExpr::TypedString(TypedString { + data_type: SQLDataType::JSONB, + value, + .. + }) => match &value.value { + Value::SingleQuotedString(s) | Value::DoubleQuotedString(s) => encode_jsonb(s), + _ => Err(Error::invalid_input( + "Expected a string value for JSONB literal", + )), + }, // For example, DATE '2020-01-01' - SQLExpr::TypedString(TypedString { data_type, value, .. }) => { + SQLExpr::TypedString(TypedString { + data_type, value, .. + }) => { let value = value.clone().into_string().expect_ok()?; - Ok(Expr::Cast(datafusion::logical_expr::Cast { - expr: Box::new(Expr::Literal(ScalarValue::Utf8(Some(value)), None)), - data_type: self.parse_type(data_type)?, - })) + Ok(Expr::Cast(datafusion::logical_expr::Cast::new( + Box::new(Expr::Literal(ScalarValue::Utf8(Some(value)), None)), + self.parse_type(data_type)?, + ))) } SQLExpr::IsFalse(expr) => Ok(Expr::IsFalse(Box::new(self.parse_sql_expr(expr)?))), SQLExpr::IsNotFalse(expr) => Ok(Expr::IsNotFalse(Box::new(self.parse_sql_expr(expr)?))), @@ -716,14 +831,7 @@ impl Planner { *negated, Box::new(self.parse_sql_expr(expr)?), Box::new(self.parse_sql_expr(pattern)?), - match escape_char { - Some(Value::SingleQuotedString(char)) => char.chars().next(), - Some(value) => return Err(Error::invalid_input( - format!("Invalid escape character in LIKE expression. Expected a single character wrapped with single quotes, got {}", value), - location!() - )), - None => None, - }, + parse_like_escape_char(escape_char)?, true, ))), SQLExpr::Like { @@ -736,16 +844,23 @@ impl Planner { *negated, Box::new(self.parse_sql_expr(expr)?), Box::new(self.parse_sql_expr(pattern)?), - match escape_char { - Some(Value::SingleQuotedString(char)) => char.chars().next(), - Some(value) => return Err(Error::invalid_input( - format!("Invalid escape character in LIKE expression. Expected a single character wrapped with single quotes, got {}", value), - location!() - )), - None => None, - }, + parse_like_escape_char(escape_char)?, false, ))), + // JSONB cast: CAST('...' AS JSONB) or '...'::jsonb + SQLExpr::Cast { + data_type: SQLDataType::JSONB, + expr: inner, + .. + } => match inner.as_ref() { + SQLExpr::Value(ValueWithSpan { + value: Value::SingleQuotedString(s) | Value::DoubleQuotedString(s), + .. + }) => encode_jsonb(s), + _ => Err(Error::invalid_input( + "CAST to JSONB only supports string literals", + )), + }, SQLExpr::Cast { expr, data_type, @@ -754,20 +869,17 @@ impl Planner { } => match kind { datafusion::sql::sqlparser::ast::CastKind::TryCast | datafusion::sql::sqlparser::ast::CastKind::SafeCast => { - Ok(Expr::TryCast(datafusion::logical_expr::TryCast { - expr: Box::new(self.parse_sql_expr(expr)?), - data_type: self.parse_type(data_type)?, - })) + Ok(Expr::TryCast(datafusion::logical_expr::TryCast::new( + Box::new(self.parse_sql_expr(expr)?), + self.parse_type(data_type)?, + ))) } - _ => Ok(Expr::Cast(datafusion::logical_expr::Cast { - expr: Box::new(self.parse_sql_expr(expr)?), - data_type: self.parse_type(data_type)?, - })), + _ => Ok(Expr::Cast(datafusion::logical_expr::Cast::new( + Box::new(self.parse_sql_expr(expr)?), + self.parse_type(data_type)?, + ))), }, - SQLExpr::JsonAccess { .. } => Err(Error::invalid_input( - "JSON access is not supported", - location!(), - )), + SQLExpr::JsonAccess { .. } => Err(Error::invalid_input("JSON access is not supported")), SQLExpr::CompoundFieldAccess { root, access_chain } => { let mut expr = self.parse_sql_expr(root)?; @@ -790,17 +902,13 @@ impl Planner { GetFieldAccess::ListIndex { key } } AccessExpr::Subscript(Subscript::Slice { .. }) => { - return Err(Error::invalid_input( - "Slice subscript is not supported", - location!(), - )); + return Err(Error::invalid_input("Slice subscript is not supported")); } _ => { // Handle other cases like JSON access // Note: JSON access is not supported in lance return Err(Error::invalid_input( "Only dot notation or index access is supported for field access", - location!(), )); } }; @@ -830,10 +938,9 @@ impl Planner { )); Ok(between) } - _ => Err(Error::invalid_input( - format!("Expression '{expr}' is not supported SQL in lance"), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Expression '{expr}' is not supported SQL in lance" + ))), } } @@ -847,10 +954,7 @@ impl Planner { let expr = self.parse_sql_expr(&ast_expr)?; let schema = Schema::try_from(self.schema.as_ref())?; let resolved = resolve_expr(&expr, &schema).map_err(|e| { - Error::invalid_input( - format!("Error resolving filter expression {filter}: {e}"), - location!(), - ) + Error::invalid_input(format!("Error resolving filter expression {filter}: {e}")) })?; Ok(coerce_filter_type_to_boolean(resolved)) @@ -916,16 +1020,73 @@ impl Planner { pub fn optimize_expr(&self, expr: Expr) -> Result { let df_schema = Arc::new(DFSchema::try_from(self.schema.as_ref().clone())?); - // DataFusion needs the simplify and coerce passes to be applied before + // DataFusion rewrites arrow_cast to Expr::Cast, whose Arrow kernel does not support + // integer-to-Time32 casts. Convert literal values with Lance's scalar coercion first. + let expr = expr + .transform_up(|expr| { + let coerced = match &expr { + Expr::ScalarFunction(ScalarFunction { func, args }) + if func.name() == "arrow_cast" => + { + match args.as_slice() { + [ + Expr::Literal(value, metadata), + Expr::Literal(ScalarValue::Utf8(Some(data_type)), _), + ] => data_type + .parse::() + .ok() + .filter(|data_type| matches!(data_type, ArrowDataType::Time32(_))) + .and_then(|data_type| { + if matches!(value, ScalarValue::Null) { + ScalarValue::try_new_null(&data_type).ok() + } else { + safe_coerce_scalar(value, &data_type) + } + }) + .map(|value| Expr::Literal(value, metadata.clone())), + _ => None, + } + } + _ => None, + }; + + Ok(match coerced { + Some(coerced) => Transformed::yes(coerced), + None => Transformed::no(expr), + }) + })? + .data; + + // DataFusion needs the coerce and simplify passes to be applied before // expressions can be handled by the physical planner. - let props = ExecutionProps::new().with_query_execution_start_time(Utc::now()); - let simplify_context = SimplifyContext::new(&props).with_schema(df_schema.clone()); + let simplify_context = SimplifyContext::builder() + .with_schema(df_schema.clone()) + .with_query_execution_start_time(Some(Utc::now())) + .build(); let simplifier = datafusion::optimizer::simplify_expressions::ExprSimplifier::new(simplify_context); - let expr = simplifier.simplify(expr)?; + // Coerce before simplify to match DataFusion's analyzer-before-optimizer pipeline. let expr = simplifier.coerce(expr, &df_schema)?; + // Fold each comparison's own operands and rewrite it before anything above + // it folds. `simplify` folds an operand and everything above it in one + // pass, so a fully constant predicate whose zero appears only as a result + // of folding never presents a zero literal to the rewrite: + // `-1.0 * 0.0 < (1.0 - 1.0)` answered `true` where IEEE says false, and a + // wrapper such as `IS TRUE` or a `CAST` did the same to the comparison's + // own result. + let expr = normalize_zero_comparisons(expr, &|operand| simplifier.simplify(operand))?; + + // Again after simplify, which is what expands `BETWEEN` into two + // comparisons and folds the casts `coerce` inserts, so those forms only + // become visible on this pass. + // + // Running the rewrite more than once is safe because its output is a fixed + // point of `optimize_expr`; `optimizing_twice_changes_nothing` pins that. + let expr = simplifier.simplify(expr)?; + let expr = rewrite_signed_zero_comparisons(expr)?; + Ok(expr) } @@ -939,6 +1100,16 @@ impl Planner { )?) } + /// Create a [`PhysicalExpr`] using the caller's DataFusion session. + pub fn create_physical_expr_with_session( + &self, + expr: &Expr, + session: &dyn Session, + ) -> Result> { + let df_schema = DFSchema::try_from(self.schema.as_ref().clone())?; + Ok(session.create_physical_expr(expr.clone(), &df_schema)?) + } + /// Collect the columns in the expression. /// /// The columns are returned in sorted order. @@ -987,13 +1158,9 @@ impl TreeNodeVisitor<'_> for ColumnCapturingVisitor { self.columns.insert(path); self.current_path.clear(); } - Expr::ScalarFunction(udf) => { - if udf.name() == GetFieldFunc::default().name() { - if let Some(name) = get_as_string_scalar_opt(&udf.args[1]) { - self.current_path.push_front(name.to_string()) - } else { - self.current_path.clear(); - } + Expr::ScalarFunction(udf) if udf.name() == GetFieldFunc::default().name() => { + if let Some(name) = get_as_string_scalar_opt(&udf.args[1]) { + self.current_path.push_front(name.to_string()) } else { self.current_path.clear(); } @@ -1010,22 +1177,21 @@ impl TreeNodeVisitor<'_> for ColumnCapturingVisitor { #[cfg(test)] mod tests { - use crate::logical_expr::ExprExt; - use super::*; use arrow::datatypes::Float64Type; use arrow_array::{ ArrayRef, BooleanArray, Float32Array, Int32Array, Int64Array, RecordBatch, StringArray, - StructArray, TimestampMicrosecondArray, TimestampMillisecondArray, - TimestampNanosecondArray, TimestampSecondArray, + StructArray, Time32SecondArray, TimestampMicrosecondArray, TimestampMillisecondArray, + TimestampNanosecondArray, TimestampSecondArray, UInt64Array, }; use arrow_schema::{DataType, Fields, Schema}; use datafusion::{ - logical_expr::{col, lit, Cast}, + logical_expr::{Cast, col, lit}, prelude::{array_element, get_field}, }; use datafusion_functions::core::expr_ext::FieldAccessor; + use rstest::rstest; #[test] fn test_parse_filter_simple() { @@ -1046,7 +1212,7 @@ mod tests { let expected = col("i") .gt(lit(3_i32)) - .and(col("st").field_newstyle("x").lt_eq(lit(5.0_f32))) + .and(col("st").field("x").lt_eq(lit(5.0_f32))) .and( col("s") .eq(lit("str-4")) @@ -1098,6 +1264,178 @@ mod tests { ); } + #[rstest] + #[case::not_equal_to_integer_expression( + "flag != (id > 0)", + [false, false, true] + )] + #[case::equal_to_float_expression("flag = (x > 1.0)", [false, false, false])] + #[case::expression_on_left("(id > 0) != flag", [false, false, true])] + #[case::literal_expression("flag != (1 > 0)", [false, true, false])] + #[case::boolean_literal("flag != TRUE", [false, true, false])] + fn test_parse_boolean_column_compared_to_expression( + #[case] filter: &str, + #[case] expected: [bool; 3], + ) { + let batch = arrow_array::record_batch!( + ("flag", Boolean, [true, false, true]), + ("id", Int32, [1, 0, -1]), + ("x", Float32, [0.5, 2.0, 1.0]) + ) + .unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner.parse_filter(filter).unwrap(); + let expr = planner.optimize_expr(expr).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let predicates = physical_expr.evaluate(&batch).unwrap(); + + assert_eq!( + predicates.into_array(0).unwrap().as_ref(), + &BooleanArray::from(expected.to_vec()) + ); + } + + #[rstest] + #[case::shift("u = (1 << 63)", [true, false, false])] + #[case::string_concat("s = ('a' || 'b')", [true, false, false])] + fn test_parse_comparison_to_same_type_literal_expression( + #[case] filter: &str, + #[case] expected: [bool; 3], + ) { + let batch = arrow_array::record_batch!( + ("u", UInt64, [1_u64 << 63, 3, 0]), + ("s", LargeUtf8, ["ab", "a", "b"]) + ) + .unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner.parse_filter(filter).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let predicates = physical_expr.evaluate(&batch).unwrap(); + + assert_eq!( + predicates.into_array(0).unwrap().as_ref(), + &BooleanArray::from(expected.to_vec()) + ); + } + + #[rstest] + #[case::float_literal("f = n + 0.5")] + #[case::float_literal_on_left("f = 0.5 + n")] + #[case::float_expression_on_left("n + 0.5 = f")] + #[case::wide_integer_literal("wide = small + 128")] + #[case::wide_integer_literal_on_left("wide = 128 + small")] + #[case::integer_expression_on_left("small + 128 = wide")] + fn test_parse_comparison_to_mixed_type_expression(#[case] filter: &str) { + let batch = arrow_array::record_batch!( + ("f", Float64, [Some(1.5), Some(1.5), None, Some(1.5)]), + ("n", Int64, [Some(1), Some(2), Some(1), None]), + ("wide", Int64, [Some(129), Some(129), Some(129), None]), + ("small", Int8, [Some(1), Some(2), None, Some(1)]) + ) + .unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner.parse_filter(filter).unwrap(); + let expr = planner.optimize_expr(expr).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let predicates = physical_expr.evaluate(&batch).unwrap(); + + assert_eq!( + predicates.into_array(batch.num_rows()).unwrap().as_ref(), + &BooleanArray::from(vec![Some(true), Some(false), None, None]) + ); + } + + #[test] + fn test_parse_filter_uint64_literal_above_i64_max() { + let value = u64::MAX - 1; + let batch = arrow_array::record_batch!(("id", UInt64, [1, value])).unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner.parse_filter(&format!("id = {value}")).unwrap(); + assert_eq!(expr, col("id").eq(lit(value))); + + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let predicates = physical_expr.evaluate(&batch).unwrap(); + assert_eq!( + predicates.into_array(0).unwrap().as_ref(), + &BooleanArray::from(vec![false, true]) + ); + + let expr = planner + .parse_expr("arrow_cast(NULL, 'Time32(Second)')") + .unwrap(); + let expr = planner.optimize_expr(expr).unwrap(); + assert_eq!(expr, Expr::Literal(ScalarValue::Time32Second(None), None)); + } + + #[test] + fn test_parse_deep_logical_filter() { + let planner = Planner::new(Arc::new(Schema::empty())); + + for op in ["AND", "OR"] { + let filter = std::iter::repeat_n("true", 1000) + .collect::>() + .join(&format!(" {op} ")); + + let expr = planner.parse_filter(&filter).unwrap(); + let optimized = planner.optimize_expr(expr).unwrap(); + + assert_eq!(optimized, lit(true)); + } + } + + #[derive(Debug, Eq, PartialEq, Hash)] + struct StrictFloat64Udf { + signature: Signature, + } + + impl StrictFloat64Udf { + fn new() -> Self { + Self { + signature: Signature::exact(vec![DataType::Float64], Volatility::Immutable), + } + } + } + + impl ScalarUDFImpl for StrictFloat64Udf { + fn name(&self) -> &str { + "strict_float64" + } + + fn signature(&self) -> &Signature { + &self.signature + } + + fn return_type(&self, _arg_types: &[DataType]) -> DFResult { + Ok(DataType::Float64) + } + + fn invoke_with_args(&self, args: ScalarFunctionArgs) -> DFResult { + let data_type = args.args[0].data_type(); + assert_eq!( + data_type, + DataType::Float64, + "strict_float64 expected Float64, got {data_type}" + ); + Ok(ColumnarValue::Scalar(ScalarValue::Float64(Some(0.0)))) + } + } + + #[test] + fn test_coerce_before_simplify() { + let planner = Planner::new(Arc::new(Schema::empty())); + let strict_float64 = Arc::new(ScalarUDF::new_from_impl(StrictFloat64Udf::new())); + let expr = Expr::ScalarFunction(ScalarFunction::new_udf(strict_float64, vec![lit(0_i64)])) + .eq(lit(0.0_f64)); + + let optimized = planner.optimize_expr(expr).unwrap(); + + planner.create_physical_expr(&optimized).unwrap(); + } + #[test] fn test_nested_col_refs() { let schema = Arc::new(Schema::new(vec![ @@ -1230,6 +1568,40 @@ mod tests { ); } + #[rstest] + #[case::right("value >> 32", Operator::BitwiseShiftRight, vec![0, 1, 3])] + #[case::left( + "value << 1", + Operator::BitwiseShiftLeft, + vec![0, 2_u64 << 32, ((3_u64 << 32) + 7) << 1] + )] + fn test_bitwise_shift_expressions( + #[case] sql: &str, + #[case] expected_op: Operator, + #[case] expected: Vec, + ) { + let input = vec![0, 1_u64 << 32, (3_u64 << 32) + 7]; + let batch = + RecordBatch::try_from_iter([("value", Arc::new(UInt64Array::from(input)) as ArrayRef)]) + .unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner.parse_expr(sql).unwrap(); + let Expr::BinaryExpr(binary_expr) = &expr else { + panic!("expected binary expression for {sql}, got {expr}"); + }; + assert_eq!(binary_expr.op, expected_op); + + let expr = planner.optimize_expr(expr).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let values = physical_expr + .evaluate(&batch) + .unwrap() + .into_array(batch.num_rows()) + .unwrap(); + assert_eq!(values.as_ref(), &UInt64Array::from(expected)); + } + #[test] fn test_negative_array_expressions() { let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int64, false)])); @@ -1308,6 +1680,36 @@ mod tests { ); } + #[test] + fn test_like_escape_char() { + let schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, true)])); + let planner = Planner::new(schema); + + // A valid single-character escape is captured for both LIKE and ILIKE. + for filter in ["s LIKE 'a!%' ESCAPE '!'", "s ILIKE 'a!%' ESCAPE '!'"] { + match planner.parse_filter(filter).unwrap() { + Expr::Like(like) => assert_eq!(like.escape_char, Some('!'), "{filter}"), + other => panic!("expected a LIKE expression for `{filter}`, got {other:?}"), + } + } + + // Empty and multi-character escapes are rejected rather than silently + // dropped or truncated to the first character. + for filter in [ + "s LIKE 'x' ESCAPE ''", + "s LIKE 'x' ESCAPE 'ab'", + "s ILIKE 'x' ESCAPE ''", + "s ILIKE 'x' ESCAPE 'ab'", + ] { + let err = planner.parse_filter(filter).unwrap_err(); + assert!( + err.to_string() + .contains("Invalid escape character in LIKE expression"), + "unexpected error for `{filter}`: {err}" + ); + } + } + #[test] fn test_sql_is_in() { let schema = Arc::new(Schema::new(vec![Field::new("s", DataType::Utf8, true)])); @@ -1464,7 +1866,7 @@ mod tests { match expr { Expr::BinaryExpr(BinaryExpr { right, .. }) => match right.as_ref() { - Expr::Cast(Cast { expr, data_type }) => { + Expr::Cast(Cast { expr, field }) => { match expr.as_ref() { Expr::Literal(ScalarValue::Utf8(Some(value_str)), _) => { assert_eq!(value_str, expected_value_str); @@ -1474,7 +1876,7 @@ mod tests { } _ => panic!("Expected cast to be applied to literal"), } - assert_eq!(data_type, expected_data_type); + assert_eq!(field.data_type(), expected_data_type); } _ => panic!("Expected right to be a cast"), }, @@ -1483,6 +1885,28 @@ mod tests { } } + #[test] + fn test_arrow_cast_int_literal_to_time32() { + let batch = RecordBatch::try_from_iter([( + "v", + Arc::new(Time32SecondArray::from(vec![3725, 3726])) as ArrayRef, + )]) + .unwrap(); + let planner = Planner::new(batch.schema()); + + let expr = planner + .parse_filter("v = arrow_cast(3726, 'Time32(Second)')") + .unwrap(); + let expr = planner.optimize_expr(expr).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let predicates = physical_expr.evaluate(&batch).unwrap(); + + assert_eq!( + predicates.into_array(0).unwrap().as_ref(), + &BooleanArray::from(vec![false, true]) + ); + } + #[test] fn test_sql_literals() { let cases = &[ @@ -1515,14 +1939,14 @@ mod tests { match expr { Expr::BinaryExpr(BinaryExpr { right, .. }) => match right.as_ref() { - Expr::Cast(Cast { expr, data_type }) => { + Expr::Cast(Cast { expr, field }) => { match expr.as_ref() { Expr::Literal(ScalarValue::Utf8(Some(value_str)), _) => { assert_eq!(value_str, expected_value_str); } _ => panic!("Expected cast to be applied to literal"), } - assert_eq!(data_type, expected_data_type); + assert_eq!(field.data_type(), expected_data_type); } _ => panic!("Expected right to be a cast"), }, @@ -1749,6 +2173,33 @@ mod tests { assert!(!ctx_provider.get_expr_planners().is_empty()); } + #[test] + fn test_lance_context_provider_resolves_session_functions() { + let session = crate::exec::new_session_context(&LanceExecutionOptions::default()); + let ctx_provider = LanceContextProvider::new(session.clone()); + assert!(ctx_provider.get_function_meta("contains_tokens").is_some()); + assert!(ctx_provider.get_function_meta("lower").is_some()); + assert!(ctx_provider.get_aggregate_meta("sum").is_some()); + assert!(ctx_provider.get_window_meta("row_number").is_some()); + assert!(ctx_provider.get_function_meta("registered_later").is_none()); + + // The provider reads the session's functions instead of a copy, so a + // function registered on the session afterwards resolves as well. + session.register_udf(datafusion::logical_expr::create_udf( + "registered_later", + vec![DataType::Utf8], + DataType::Utf8, + Volatility::Immutable, + Arc::new(|args: &[ColumnarValue]| Ok(args[0].clone())), + )); + assert!(ctx_provider.get_function_meta("registered_later").is_some()); + assert!( + ctx_provider + .udf_names() + .contains(&"registered_later".to_string()) + ); + } + #[test] fn test_regexp_match_and_non_empty_captions() { // Repro for a bug where regexp_match inside an AND chain wasn't coerced to boolean, @@ -1809,27 +2260,133 @@ mod tests { ); } + #[rstest] + #[case::bare("regexp_match(name, 'e[12]')", [false, true, true, false, false, false])] + #[case::is_not_null( + "regexp_match(name, 'e[12]') IS NOT NULL", + [false, true, true, false, false, false] + )] + #[case::is_null( + "regexp_match(name, 'e[12]') IS NULL", + [true, false, false, true, true, true] + )] + #[case::not_bare( + "NOT regexp_match(name, 'e[12]')", + [true, false, false, true, true, true] + )] + #[case::and_bare( + "regexp_match(name, 'e[12]') AND name <> 'name2'", + [false, true, false, false, false, false] + )] + #[case::or_bare( + "regexp_match(name, 'e[12]') OR name IS NULL", + [false, true, true, true, false, false] + )] + #[case::not_is_not_null( + "NOT (regexp_match(name, 'e[12]') IS NOT NULL)", + [true, false, false, true, true, true] + )] + #[case::and_is_null( + "regexp_match(name, 'e[12]') IS NULL AND name IS NOT NULL", + [true, false, false, false, true, true] + )] + #[case::or_is_not_null( + "regexp_match(name, 'e[12]') IS NOT NULL OR name IS NULL", + [false, true, true, true, false, false] + )] + fn test_regexp_match_filter_coercion(#[case] filter: &str, #[case] expected: [bool; 6]) { + let batch = arrow_array::record_batch!(( + "name", + Utf8, + [ + Some("name0"), + Some("name1"), + Some("name2"), + None, + Some("name4"), + Some("name5") + ] + )) + .unwrap(); + let planner = Planner::new(batch.schema()); + let expr = planner.parse_filter(filter).unwrap(); + let physical_expr = planner.create_physical_expr(&expr).unwrap(); + let result = physical_expr.evaluate(&batch).unwrap(); + + assert_eq!( + result.into_array(batch.num_rows()).unwrap().as_ref(), + &BooleanArray::from(expected.to_vec()) + ); + } + + #[rstest] + #[case::is_not_null("regexp_match(name, 'e[12]') IS NOT NULL")] + #[case::is_null("regexp_match(name, 'e[12]') IS NULL")] + #[case::comparison("regexp_match(name, 'e[12]') = regexp_match(name, 'e[12]')")] + fn test_regexp_match_preserves_value_contexts(#[case] filter: &str) { + let schema = Arc::new(Schema::new(vec![Field::new("name", DataType::Utf8, true)])); + let planner = Planner::new(schema); + + assert_eq!( + planner.parse_filter(filter).unwrap(), + planner.parse_expr(filter).unwrap() + ); + } + #[test] - fn test_regexp_match_infer_error_without_boolean_coercion() { - // With the fix applied, using parse_filter should coerce regexp_match to boolean - // even when nested in a larger AND expression, so this should plan successfully. - let schema = Arc::new(Schema::new(vec![ - Field::new("keywords", DataType::Utf8, true), - Field::new("natural_caption", DataType::Utf8, true), - Field::new("poetic_caption", DataType::Utf8, true), - ])); + fn test_jsonb_literals() { + let schema = Arc::new(Schema::new(vec![Field::new( + "j", + DataType::LargeBinary, + true, + )])); + let planner = Planner::new(schema); + let cases = [ + ("jsonb '{\"key\": \"value\"}'", r#"{"key":"value"}"#), + ("cast('{\"a\": 1}' as jsonb)", r#"{"a":1}"#), + ("'{\"a\": 1}'::jsonb", r#"{"a":1}"#), + ]; + for (sql, expected) in cases { + let ast = parse_sql_expr(sql).unwrap(); + let expr = planner.parse_sql_expr(&ast).unwrap(); + match expr { + Expr::Literal(ScalarValue::LargeBinary(Some(bytes)), _) => { + assert_eq!( + lance_arrow::json::decode_json(&bytes), + expected, + "failed for: {sql}" + ); + } + other => panic!("Expected LargeBinary literal for '{sql}', got: {other:?}"), + } + } + } + + #[test] + fn test_jsonb_literal_errors() { + let schema = Arc::new(Schema::new(vec![Field::new( + "j", + DataType::LargeBinary, + true, + )])); let planner = Planner::new(schema); - let expr = planner - .parse_filter( - "regexp_match(keywords, 'Liberty|revolution') AND \ - (natural_caption IS NOT NULL AND natural_caption <> '' AND \ - poetic_caption IS NOT NULL AND poetic_caption <> '')", - ) - .unwrap(); + // Invalid JSON content + let ast = parse_sql_expr("jsonb 'not valid json'").unwrap(); + let err = planner.parse_sql_expr(&ast).unwrap_err(); + assert!( + err.to_string().contains("Failed to encode JSONB"), + "expected JSONB encoding error, got: {err}" + ); - // Should not panic - let _physical = planner.create_physical_expr(&expr).unwrap(); + // CAST with non-literal expression + let ast = parse_sql_expr("cast(j as jsonb)").unwrap(); + let err = planner.parse_sql_expr(&ast).unwrap_err(); + assert!( + err.to_string() + .contains("CAST to JSONB only supports string literals"), + "got: {err}" + ); } } diff --git a/rust/lance-datafusion/src/projection.rs b/rust/lance-datafusion/src/projection.rs index f2f74e0f61f..a6a4ecf5c68 100644 --- a/rust/lance-datafusion/src/projection.rs +++ b/rust/lance-datafusion/src/projection.rs @@ -7,7 +7,6 @@ use datafusion::{logical_expr::Expr, physical_plan::projection::ProjectionExec}; use datafusion_common::{Column, DFSchema}; use datafusion_physical_expr::PhysicalExpr; use futures::TryStreamExt; -use snafu::location; use std::{ collections::{HashMap, HashSet}, sync::Arc, @@ -15,21 +14,30 @@ use std::{ use tracing::instrument; use lance_core::{ - datatypes::{OnMissing, Projectable, Projection, Schema}, - Error, Result, ROW_ADDR, ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION, - ROW_OFFSET, WILDCARD, + Error, ROW_ADDR, ROW_CREATED_AT_VERSION, ROW_ID, ROW_LAST_UPDATED_AT_VERSION, ROW_OFFSET, + Result, WILDCARD, + datatypes::{OnMissing, OnTypeMismatch, Projectable, Projection, Schema}, }; use crate::{ - exec::{execute_plan, LanceExecutionOptions, OneShotExec}, + exec::{LanceExecutionOptions, OneShotExec, execute_plan}, planner::Planner, }; +const SCORING_COLUMNS: [&str; 2] = ["_distance", "_score"]; + +fn canonical_scoring_column(name: &str) -> Option<&'static str> { + SCORING_COLUMNS + .into_iter() + .find(|scoring_column| name.eq_ignore_ascii_case(scoring_column)) +} + struct ProjectionBuilder { base: Arc, planner: Planner, output: HashMap, output_cols: Vec, + scoring_exprs: HashMap, physical_cols_set: HashSet, physical_cols: Vec, needs_row_id: bool, @@ -51,6 +59,7 @@ impl ProjectionBuilder { planner, output: HashMap::default(), output_cols: Vec::default(), + scoring_exprs: HashMap::default(), physical_cols_set: HashSet::default(), physical_cols: Vec::default(), needs_row_id: false, @@ -64,10 +73,10 @@ impl ProjectionBuilder { fn check_duplicate_column(&self, name: &str) -> Result<()> { if self.output.contains_key(name) { - return Err(Error::invalid_input( - format!("Duplicate column name: {}", name), - location!(), - )); + return Err(Error::invalid_input(format!( + "Duplicate column name: {}", + name + ))); } Ok(()) } @@ -76,6 +85,18 @@ impl ProjectionBuilder { self.check_duplicate_column(output_name)?; let expr = self.planner.parse_expr(raw_expr)?; + let expr = if Self::references_scoring_column(&expr) { + // A scoring name can refer to either a stored column or a search-generated + // Float32 column. Reparse and coerce once the physical input schema disambiguates it. + self.scoring_exprs + .insert(output_name.to_string(), raw_expr.to_string()); + expr + } else { + // Run simplification + coercion so that expressions like `coalesce(...)` + // (which DataFusion's physical evaluator expects to have been rewritten + // into a `CASE` expression by the simplifier) work correctly. + self.planner.optimize_expr(expr)? + }; // If the expression is a bare column reference to a system column, mark that we need it if let Expr::Column(Column { @@ -98,11 +119,23 @@ impl ProjectionBuilder { } for col in Planner::column_names_in_expr(&expr) { - if self.physical_cols_set.contains(&col) { + // Discovery can bind an exact provisional scoring field beside a mixed-case stored + // field. Load the stored field too so final-schema replanning can select the stored + // or search-generated field from the physical input. + let physical_col = if canonical_scoring_column(&col).is_some() { + self.base + .schema() + .field_case_insensitive(&col) + .map(|field| field.name.clone()) + .unwrap_or(col) + } else { + col + }; + if self.physical_cols_set.contains(&physical_col) { continue; } - self.physical_cols.push(col.clone()); - self.physical_cols_set.insert(col); + self.physical_cols.push(physical_col.clone()); + self.physical_cols_set.insert(physical_col); } self.output.insert(output_name.to_string(), expr.clone()); @@ -114,6 +147,12 @@ impl ProjectionBuilder { Ok(()) } + fn references_scoring_column(expr: &Expr) -> bool { + Planner::column_names_in_expr(expr) + .iter() + .any(|name| canonical_scoring_column(name).is_some()) + } + fn add_columns(&mut self, columns: &[(impl AsRef, impl AsRef)]) -> Result<()> { for (output_name, raw_expr) in columns { if raw_expr.as_ref() == WILDCARD { @@ -156,6 +195,7 @@ impl ProjectionBuilder { physical_projection, must_add_row_offset: self.must_add_row_offset, requested_output_expr: self.output_cols, + scoring_exprs: self.scoring_exprs, }) } } @@ -178,6 +218,9 @@ pub struct ProjectionPlan { /// The desired output columns pub requested_output_expr: Vec, + + /// Original SQL for scoring expressions that must be replanned against the physical schema. + scoring_exprs: HashMap, } impl ProjectionPlan { @@ -196,6 +239,14 @@ impl ProjectionPlan { fields.push(Arc::new( (*lance_core::ROW_CREATED_AT_VERSION_FIELD).clone(), )); + // Exact scoring fields are needed for initial parsing of schema-dependent functions, even + // beside a mixed-case stored field. The stored field is carried into the physical + // projection separately, and scoring expressions are replanned against the final schema. + for name in SCORING_COLUMNS { + if schema.field_with_name(name).is_err() { + fields.push(Arc::new(ArrowField::new(name, DataType::Float32, true))); + } + } ArrowSchema::new(fields) } @@ -258,8 +309,10 @@ impl ProjectionPlan { if lance_core::is_system_column(&field.name) { // Handle known system columns that can be included in projections if field.name == ROW_ID { + // Only `_rowoffset` needs `AddRowOffsetExec`. Requesting that node for + // `_rowid` leaves it without the `_rowaddr` column it reads, so the scan + // fails to plan with "Input plan does not have a _rowaddr column". with_row_id = true; - must_add_row_offset = true; } else if field.name == ROW_ADDR { with_row_addr = true; } else if field.name == ROW_OFFSET { @@ -273,10 +326,10 @@ impl ProjectionPlan { } else { // Regular data column - validate it exists in base schema if base.schema().field(&field.name).is_none() { - return Err(Error::invalid_input( - format!("Column '{}' not found in schema", field.name), - location!(), - )); + return Err(Error::invalid_input(format!( + "Column '{}' not found in schema", + field.name + ))); } data_fields.push(field.clone()); } @@ -288,6 +341,19 @@ impl ProjectionPlan { metadata: projection.metadata.clone(), }; + // Resolve the requested fields against the base schema first. + // `union_schema` records any field carrying a non-negative id without + // checking that the base knows that id, so a schema numbered + // independently of the dataset -- `Schema::try_from(&ArrowSchema)` gives + // every field a fresh id -- yields a projection that silently resolves + // to fewer columns than were asked for, or to the wrong ones. Matching + // by name and type here reports that instead. + let data_schema = base.schema().project_by_schema( + &data_schema, + OnMissing::Error, + OnTypeMismatch::Error, + )?; + // Calculate the physical projection from data columns only let mut physical_projection = Projection::empty(base).union_schema(&data_schema); physical_projection.with_row_id = with_row_id; @@ -309,6 +375,7 @@ impl ProjectionPlan { physical_projection, requested_output_expr: exprs, must_add_row_offset, + scoring_exprs: HashMap::default(), }) } @@ -335,6 +402,7 @@ impl ProjectionPlan { physical_projection, must_add_row_offset: false, requested_output_expr, + scoring_exprs: HashMap::default(), }) } @@ -349,9 +417,16 @@ impl ProjectionPlan { self.requested_output_expr .iter() .map(|output_column| { + let expr = if let Some(raw_expr) = self.scoring_exprs.get(&output_column.name) { + let planner = Planner::new(Arc::new(current_schema.clone())); + let expr = planner.parse_expr(raw_expr)?; + planner.optimize_expr(expr)? + } else { + output_column.expr.clone() + }; Ok(( datafusion::physical_expr::create_physical_expr( - &output_column.expr, + &expr, physical_df_schema.as_ref(), &Default::default(), )?, @@ -445,10 +520,7 @@ impl ProjectionPlan { )?; let batches = stream.try_collect::>().await?; if batches.len() != 1 { - Err(Error::Internal { - message: "Expected exactly one batch".to_string(), - location: location!(), - }) + Err(Error::internal("Expected exactly one batch".to_string())) } else { Ok(batches.into_iter().next().unwrap()) } @@ -459,8 +531,203 @@ impl ProjectionPlan { mod tests { use super::*; + use arrow_array::{ArrayRef, Float32Array, Int64Array}; + use arrow_schema::Fields; use lance_arrow::json::{is_json_field, json_field}; + #[test] + fn test_scoring_column_expression() { + for scoring_column in ["_distance", "_score"] { + for has_stored_column in [false, true] { + let base = if has_stored_column { + Arc::new( + Schema::try_from(&ArrowSchema::new(vec![ArrowField::new( + scoring_column, + DataType::Float64, + true, + )])) + .unwrap(), + ) + } else { + Arc::new(Schema::default()) + }; + let expression = format!("1 - {scoring_column}"); + let plan = + ProjectionPlan::from_expressions(base, &[("inverted", expression.as_str())]) + .unwrap(); + + if has_stored_column { + let stored_output = plan.output_schema().unwrap(); + assert_eq!(stored_output.field(0).data_type(), &DataType::Float64); + } + + let batch = RecordBatch::try_from_iter([( + scoring_column, + Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef, + )]) + .unwrap(); + + let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap(); + let values = physical_exprs[0] + .0 + .evaluate(&batch) + .unwrap() + .into_array(batch.num_rows()) + .unwrap(); + + assert_eq!( + values.as_ref(), + &Float32Array::from(vec![0.75, 0.25]), + "unexpected result for {scoring_column}", + ); + } + } + } + + #[test] + fn test_stored_scoring_column_does_not_break_other_expressions() { + for scoring_column in ["_distance", "_score"] { + let base = Arc::new( + Schema::try_from(&ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int64, false), + ArrowField::new(scoring_column, DataType::Float64, true), + ])) + .unwrap(), + ); + + ProjectionPlan::from_expressions(base, &[("incremented", "id + 1")]).unwrap(); + } + } + + #[test] + fn test_stored_scoring_column_is_case_insensitive() { + for (stored_name, requested_name) in [("_Distance", "_distance"), ("_Score", "_score")] { + let base = Arc::new( + Schema::try_from(&ArrowSchema::new(vec![ArrowField::new( + stored_name, + DataType::Float64, + true, + )])) + .unwrap(), + ); + let plan = + ProjectionPlan::from_expressions(base, &[("stored", requested_name)]).unwrap(); + + assert_eq!( + plan.output_schema().unwrap().field(0).data_type(), + &DataType::Float64, + ); + + let batch = RecordBatch::try_from_iter([( + requested_name, + Arc::new(Float32Array::from(vec![0.25, 0.75])) as ArrayRef, + )]) + .unwrap(); + let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap(); + assert_eq!( + physical_exprs[0] + .0 + .data_type(batch.schema().as_ref()) + .unwrap(), + DataType::Float32, + ); + } + } + + #[test] + fn test_generated_scoring_function_with_mixed_case_stored_column() { + for (stored_name, generated_name) in [("_Distance", "_distance"), ("_Score", "_score")] { + let base = Arc::new( + Schema::try_from(&ArrowSchema::new(vec![ArrowField::new( + stored_name, + DataType::Float64, + true, + )])) + .unwrap(), + ); + let expression = format!("coalesce(1 - {generated_name}, 0)"); + let plan = + ProjectionPlan::from_expressions(base, &[("normalized", expression.as_str())]) + .unwrap(); + let batch = RecordBatch::try_from_iter([( + generated_name, + Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef, + )]) + .unwrap(); + + let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap(); + let values = physical_exprs[0] + .0 + .evaluate(&batch) + .unwrap() + .into_array(batch.num_rows()) + .unwrap(); + assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0])); + } + } + + #[test] + fn test_scoring_column_function_expression() { + for scoring_column in ["_distance", "_score"] { + let expression = format!("coalesce(1 - {scoring_column}, 0)"); + let plan = ProjectionPlan::from_expressions( + Arc::new(Schema::default()), + &[("normalized", expression.as_str())], + ) + .unwrap(); + let batch = RecordBatch::try_from_iter([( + scoring_column, + Arc::new(Float32Array::from(vec![Some(0.25), None])) as ArrayRef, + )]) + .unwrap(); + + let physical_exprs = plan.to_physical_exprs(batch.schema().as_ref()).unwrap(); + let values = physical_exprs[0] + .0 + .evaluate(&batch) + .unwrap() + .into_array(batch.num_rows()) + .unwrap(); + assert_eq!(values.as_ref(), &Float32Array::from(vec![0.75, 0.0])); + } + } + + #[tokio::test] + async fn test_coalesce_in_column_map() { + // Regression test: `coalesce` in a column-map expression used to fail with + // "coalesce should have been simplified to case" because the parsed expression + // was passed straight to `create_physical_expr` without running the simplifier. + let arrow_schema = Arc::new(ArrowSchema::new(vec![ + ArrowField::new("col_a", DataType::Int64, true), + ArrowField::new("col_b", DataType::Int64, true), + ])); + let base_schema = Schema::try_from(arrow_schema.as_ref()).unwrap(); + let base = Arc::new(base_schema); + + let plan = + ProjectionPlan::from_expressions(base, &[("foo", "coalesce(col_a, col_b)")]).unwrap(); + + let batch = RecordBatch::try_new( + arrow_schema, + vec![ + Arc::new(Int64Array::from(vec![Some(1), None, Some(3), None])), + Arc::new(Int64Array::from(vec![Some(10), Some(20), None, None])), + ], + ) + .unwrap(); + + let projected = plan.project_batch(batch).await.unwrap(); + let foo = projected + .column(0) + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!( + foo.iter().collect::>(), + vec![Some(1), Some(20), Some(3), None], + ); + } + #[test] fn test_output_schema_preserves_json_extension_metadata() { let arrow_schema = ArrowSchema::new(vec![ @@ -479,4 +746,84 @@ mod tests { let output_field = output.field_with_name("meta").unwrap(); assert!(is_json_field(output_field)); } + + fn nested_base_schema() -> Schema { + Schema::try_from(&ArrowSchema::new(vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new( + "meta", + DataType::Struct(Fields::from(vec![ + ArrowField::new("a", DataType::Int32, true), + ArrowField::new("b", DataType::Int32, true), + ])), + true, + ), + ])) + .unwrap() + } + + /// A nested child the base does not have must be reported, not dropped. + /// + /// Only top-level names are checked before the projection is built, so + /// `meta` passes and the walk then records every id beneath it. `c` carries + /// a non-negative id the base never issued, which resolves to nothing: the + /// caller asked for two children, silently got one. + #[test] + fn test_from_schema_rejects_a_nested_child_the_base_does_not_have() { + let requested = Schema::try_from(&ArrowSchema::new(vec![ArrowField::new( + "meta", + DataType::Struct(Fields::from(vec![ + ArrowField::new("a", DataType::Int32, true), + ArrowField::new("c", DataType::Int32, true), + ])), + true, + )])) + .unwrap(); + + let error = ProjectionPlan::from_schema(Arc::new(nested_base_schema()), &requested) + .expect_err("a nested child outside the base schema must be rejected"); + assert!( + error.to_string().contains('c'), + "the error should name the offending child: {error}" + ); + } + + /// Ids must be resolved against the base, not trusted. + /// + /// A schema built on its own numbers from scratch, so `meta.b` here carries + /// the id the base issued to `meta.a`. Taken at face value the projection + /// reads the wrong column and reports no error at all. + #[test] + fn test_from_schema_resolves_independently_numbered_ids() { + let requested = Schema::try_from(&ArrowSchema::new(vec![ArrowField::new( + "meta", + DataType::Struct(Fields::from(vec![ArrowField::new( + "b", + DataType::Int32, + true, + )])), + true, + )])) + .unwrap(); + let base_schema = nested_base_schema(); + // Numbered alone, `b` takes an id the base issued to a different field. + let requested_b = requested.fields[0].children[0].id; + let base_b = base_schema.fields[1].children[1].id; + assert_ne!( + requested_b, base_b, + "the requested id must differ from the base's for this to bite" + ); + + let plan = ProjectionPlan::from_schema(Arc::new(base_schema), &requested).unwrap(); + let projected = plan.physical_projection.to_bare_schema(); + assert_eq!( + projected.fields[0] + .children + .iter() + .map(|child| child.name.as_str()) + .collect::>(), + vec!["b"], + "the projection must follow the requested name, not the colliding id" + ); + } } diff --git a/rust/lance-datafusion/src/signed_zero.rs b/rust/lance-datafusion/src/signed_zero.rs new file mode 100644 index 00000000000..1b642bc2a28 --- /dev/null +++ b/rust/lance-datafusion/src/signed_zero.rs @@ -0,0 +1,711 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Rewrites of comparisons against a floating point zero literal. + +use datafusion::error::Result as DFResult; +use datafusion::logical_expr::{BinaryExpr, Operator, expr::Between, expr::InList}; +use datafusion::prelude::Expr; +use datafusion::scalar::ScalarValue::{self, Float16, Float32, Float64}; +use datafusion_common::tree_node::{Transformed, TreeNode}; +use half::f16; +use lance_core::Result; + +/// Rewrite every comparison against a floating point zero literal into the form +/// that Arrow's total-order kernels answer the way IEEE 754 and SQL define it. +/// +/// Arrow sorts `-0.0` strictly below `+0.0` and compares the two encodings for +/// equality by bit pattern, while IEEE 754 and SQL treat them as one number. +/// Each comparison against a zero literal has an equivalent total-order form: +/// +/// | written | evaluated | +/// |------------------------------|------------------------------| +/// | `x < 0`, `x >= 0` | literal becomes `-0.0` | +/// | `x <= 0`, `x > 0` | literal becomes `+0.0` | +/// | `x = 0` | `x IN (-0.0, 0.0)` | +/// | `x != 0` | `x NOT IN (-0.0, 0.0)` | +/// | `x IN (0, ..)` | the missing encoding is added | +/// | `0 IN (a, b)` | `a IN (-0.0, 0.0) OR b IN (-0.0, 0.0)` | +/// | `x IS NOT DISTINCT FROM 0` | `x IS NOT NULL AND x IN (-0.0, 0.0)` | +/// | `x IS DISTINCT FROM 0` | `x IS NULL OR x NOT IN (-0.0, 0.0)` | +/// +/// Equality has to name both encodings because a scalar index keys on the bit +/// pattern: the btree and bitmap indices order candidates by `total_cmp`, and the +/// bloom filter hashes the value. +/// +/// Runs as the last step of [`crate::planner::Planner::optimize_expr`], after +/// coercion has given the literal the column's type and the simplifier has +/// expanded `BETWEEN` into two comparisons. Filters, computed output columns and +/// update expressions all compile through there, which is what keeps a filter and +/// a projected copy of the same predicate in agreement. +/// +/// NaN is out of scope. Arrow sorts it above every other value, so `x >= -0.0` +/// admits NaN where IEEE would not, and that holds for every comparison rather +/// than only the ones against zero. +pub fn rewrite_signed_zero_comparisons(expr: Expr) -> Result { + Ok(expr + .transform_up(|node| { + Ok(match rewrite_node(&node) { + Some(rewritten) => Transformed::yes(rewritten), + None => Transformed::no(node), + }) + })? + .data) +} + +/// Whether the rewrite acts on comparisons under `op`. +fn is_zero_sensitive(op: Operator) -> bool { + matches!( + op, + Operator::Lt + | Operator::LtEq + | Operator::Gt + | Operator::GtEq + | Operator::Eq + | Operator::NotEq + | Operator::IsDistinctFrom + | Operator::IsNotDistinctFrom + ) +} + +/// Fold each zero-sensitive comparison's own operands and rewrite it, bottom-up, +/// before anything above it has a chance to fold. +/// +/// [`rewrite_signed_zero_comparisons`] alone cannot reach a comparison whose zero +/// does not exist yet. `ExprSimplifier::simplify` folds an operand and everything +/// above it in one pass, so `-1.0 * 0.0 < (1.0 - 1.0)` goes straight to a boolean +/// decided by Arrow's total order, and a wrapper like `IS TRUE` or a `CAST` around +/// it does the same to the comparison's own result. +/// +/// Visiting bottom-up and folding only the operands of the node in hand is what +/// closes that: by the time any container is folded, every comparison inside it +/// already carries the corrected literal. This deliberately does not enumerate +/// which containers are allowed above a comparison. Enumerating them is what left +/// `IS TRUE`, `IS FALSE`, `= TRUE`, `CAST(.. AS BOOLEAN)` and `IN (TRUE)` exposed, +/// and any list would keep missing the next spelling. +pub fn normalize_zero_comparisons( + expr: Expr, + simplify: &dyn Fn(Expr) -> DFResult, +) -> Result { + // A literal is already folded, and an `IN` list can hold hundreds of them. + // Handing each one to the simplifier anyway roughly doubled planning time on + // large lists, for an operand that cannot change. + let fold = |operand: Expr| -> DFResult { + if matches!(operand, Expr::Literal(..)) { + return Ok(operand); + } + simplify(operand) + }; + Ok(expr + .transform_up(|node| { + let folded = match node { + Expr::BinaryExpr(BinaryExpr { left, op, right }) if is_zero_sensitive(op) => { + Expr::BinaryExpr(BinaryExpr { + left: Box::new(fold(*left)?), + op, + right: Box::new(fold(*right)?), + }) + } + Expr::Between(between) => Expr::Between(Between { + expr: Box::new(fold(*between.expr)?), + negated: between.negated, + low: Box::new(fold(*between.low)?), + high: Box::new(fold(*between.high)?), + }), + Expr::InList(in_list) => Expr::InList(InList { + expr: Box::new(fold(*in_list.expr)?), + list: in_list + .list + .into_iter() + .map(fold) + .collect::>>()?, + negated: in_list.negated, + }), + other => return Ok(Transformed::no(other)), + }; + Ok(match rewrite_node(&folded) { + Some(rewritten) => Transformed::yes(rewritten), + // The operands were still folded, so this is a change either way. + None => Transformed::yes(folded), + }) + })? + .data) +} + +/// Both encodings of a floating point zero, negative first. +/// +/// Returns `None` for anything else, including NULL, NaN, and integer zero. +fn zero_encodings(value: &ScalarValue) -> Option<(ScalarValue, ScalarValue)> { + match value { + Float16(Some(v)) if *v == f16::ZERO => { + Some((Float16(Some(f16::NEG_ZERO)), Float16(Some(f16::ZERO)))) + } + Float32(Some(v)) if *v == 0.0 => Some((Float32(Some(-0.0)), Float32(Some(0.0)))), + Float64(Some(v)) if *v == 0.0 => Some((Float64(Some(-0.0)), Float64(Some(0.0)))), + _ => None, + } +} + +/// Collect the terms of an `AND`/`OR` chain, in order, ignoring nesting. +fn flatten_chain<'a>(expr: &'a Expr, op: Operator, terms: &mut Vec<&'a Expr>) { + if let Expr::BinaryExpr(BinaryExpr { + left, + op: inner, + right, + }) = expr + && *inner == op + { + flatten_chain(left, op, terms); + flatten_chain(right, op, terms); + return; + } + terms.push(expr); +} + +/// True for the shape this rewrite emits for `=` and `!=`: a column tested +/// against both encodings of a floating point zero, negated or not. Only these +/// terms are deduplicated, so an expression the caller wrote twice is left alone. +fn is_zero_pair_over_column(expr: &Expr) -> bool { + let Expr::InList(InList { expr, list, .. }) = expr else { + return false; + }; + if !matches!(expr.as_ref(), Expr::Column(_)) { + return false; + } + let [Expr::Literal(first, _), ..] = list.as_slice() else { + return false; + }; + zero_encodings(first) + .is_some_and(|(negative, positive)| list_is_pair(list, &negative, &positive)) +} + +/// True when `list` is exactly the two encodings of a zero, negative first. +fn list_is_pair(list: &[Expr], negative: &ScalarValue, positive: &ScalarValue) -> bool { + let [Expr::Literal(first, _), Expr::Literal(second, _)] = list else { + return false; + }; + first == negative && second == positive +} + +/// The rewritten expression, or `None` when `expr` is not a comparison against a +/// floating point zero. +/// The encoding a zero bound needs to answer `op` correctly, or `None` when the +/// expression is not a zero literal. +fn rewrite_bound(bound: &Expr, op: Operator) -> Option { + let Expr::Literal(value, metadata) = bound else { + return None; + }; + let (negative, positive) = zero_encodings(value)?; + let encoding = match op { + Operator::GtEq => negative, + Operator::LtEq => positive, + _ => return None, + }; + Some(Expr::Literal(encoding, metadata.clone())) +} + +fn rewrite_node(expr: &Expr) -> Option { + match expr { + // DataFusion's simplifier expands an `IN` list of three or fewer values + // over a bare column back into an OR chain of equalities, so a second + // `optimize_expr` splits this rewrite's own output and re-runs it on each + // half. Both halves then produce the same list, and dropping the repeat is + // what makes the rewrite survive that round trip. + Expr::BinaryExpr(BinaryExpr { op, .. }) if matches!(op, Operator::Or | Operator::And) => { + let mut kept: Vec<&Expr> = Vec::new(); + flatten_chain(expr, *op, &mut kept); + let mut deduped: Vec<&Expr> = Vec::with_capacity(kept.len()); + for term in kept.iter() { + if is_zero_pair_over_column(term) && deduped.contains(term) { + continue; + } + deduped.push(term); + } + if deduped.len() == kept.len() { + return None; + } + deduped.into_iter().cloned().reduce(|left, right| match op { + Operator::Or => left.or(right), + _ => left.and(right), + }) + } + Expr::BinaryExpr(BinaryExpr { left, op, right }) => { + // `resolve_expr` accepts the literal on either side, and the + // operator mirrors when it sits on the left. + let (literal, other, op) = match (left.as_ref(), right.as_ref()) { + (_, Expr::Literal(..)) => (right.as_ref(), left.as_ref(), *op), + (Expr::Literal(..), _) => (left.as_ref(), right.as_ref(), op.swap()?), + _ => return None, + }; + let Expr::Literal(value, metadata) = literal else { + return None; + }; + let (negative, positive) = zero_encodings(value)?; + let zero = match op { + Operator::Lt | Operator::GtEq => negative, + Operator::LtEq | Operator::Gt => positive, + Operator::Eq | Operator::NotEq => { + return Some(Expr::InList(InList { + expr: Box::new(other.clone()), + list: vec![ + Expr::Literal(negative, metadata.clone()), + Expr::Literal(positive, metadata.clone()), + ], + negated: op == Operator::NotEq, + })); + } + Operator::IsNotDistinctFrom | Operator::IsDistinctFrom => { + // Both encodings have to be listed, and the null case has to + // stay decided rather than becoming NULL, so this pairs the + // list with `IS [NOT] TRUE`. `NULL IN (..)` is NULL, and + // `NULL IS TRUE` is false, which is what distinctness means + // for a null against a non-null literal. + // + // The list carries `other` once. An earlier version guarded + // this arm to a bare column so it could name `other` twice as + // `IS NOT NULL AND IN (..)`, but bailing out left the + // `filter_expr` path answering computed operands on Arrow's + // sign-sensitive order, which is wrong rows rather than an + // unsupported spelling. + // + // Always the non-negated list, so a second pass sees the same + // complete pair it would leave alone anywhere else. + let covered = Expr::InList(InList { + expr: Box::new(other.clone()), + list: vec![ + Expr::Literal(negative, metadata.clone()), + Expr::Literal(positive, metadata.clone()), + ], + negated: false, + }); + return Some(if op == Operator::IsDistinctFrom { + covered.is_not_true() + } else { + covered.is_true() + }); + } + _ => return None, + }; + Some(Expr::BinaryExpr(BinaryExpr { + left: Box::new(other.clone()), + op, + right: Box::new(Expr::Literal(zero, metadata.clone())), + })) + } + // `BETWEEN` normally reaches this rewrite already expanded into `>=` and + // `<=` by the simplifier. It survives unexpanded when every operand is + // constant, because then the simplifier expands and folds it in one pass + // and the comparison is gone before the post-pass looks. The bounds take + // the encodings their expanded operators would: `low` is a `>=` bound and + // `high` is a `<=` bound. + Expr::Between(between) => { + let low = rewrite_bound(&between.low, Operator::GtEq); + let high = rewrite_bound(&between.high, Operator::LtEq); + if low.is_none() && high.is_none() { + return None; + } + Some(Expr::Between(Between { + expr: between.expr.clone(), + negated: between.negated, + low: Box::new(low.unwrap_or_else(|| (*between.low).clone())), + high: Box::new(high.unwrap_or_else(|| (*between.high).clone())), + })) + } + Expr::InList(InList { + expr, + list, + negated, + }) => { + // A zero literal on the probe side needs the same treatment. The list + // elements are arbitrary expressions there, so expand into the + // equality form the binary arm already covers. A literal probe that is + // not a zero compares the same way against either encoding, so it + // needs no widening either. + if let Expr::Literal(value, metadata) = expr.as_ref() { + let (negative, positive) = zero_encodings(value)?; + // The expansion below puts a zero literal in front of exactly this + // list, so stop rather than expanding that term again. + if list_is_pair(list, &negative, &positive) { + return None; + } + let matches_any = list + .iter() + .map(|item| { + Expr::InList(InList { + expr: Box::new(item.clone()), + list: vec![ + Expr::Literal(negative.clone(), metadata.clone()), + Expr::Literal(positive.clone(), metadata.clone()), + ], + negated: false, + }) + }) + .reduce(Expr::or)?; + return Some(if *negated { + Expr::Not(Box::new(matches_any)) + } else { + matches_any + }); + } + Some(Expr::InList(InList { + expr: expr.clone(), + list: widen_zero_list(list)?, + negated: *negated, + })) + } + _ => None, + } +} + +/// Add the missing encoding next to every floating point zero in an `IN` list. +/// +/// Returns `None` when the list holds no zero, or already spells out both +/// encodings of each zero it holds. +fn widen_zero_list(list: &[Expr]) -> Option> { + // Most lists hold no zero, so collect what is missing before copying anything. + let mut missing: Vec = Vec::new(); + for item in list { + let Expr::Literal(value, metadata) = item else { + continue; + }; + let Some((negative, positive)) = zero_encodings(value) else { + continue; + }; + let counterpart = if *value == negative { + positive + } else { + negative + }; + // `ScalarValue` compares floats by bit pattern, so this distinguishes + // the two encodings rather than collapsing them. + let is_counterpart = + |other: &Expr| matches!(other, Expr::Literal(v, _) if *v == counterpart); + if list.iter().any(is_counterpart) || missing.iter().any(is_counterpart) { + continue; + } + missing.push(Expr::Literal(counterpart, metadata.clone())); + } + if missing.is_empty() { + return None; + } + let mut widened = Vec::with_capacity(list.len() + missing.len()); + widened.extend(list.iter().cloned()); + widened.append(&mut missing); + Some(widened) +} + +#[cfg(test)] +mod tests { + use datafusion::prelude::{col, lit}; + use rstest::rstest; + + use super::*; + + fn rewrite(expr: Expr) -> Expr { + rewrite_signed_zero_comparisons(expr).unwrap() + } + + fn compare(left: Expr, op: Operator, right: Expr) -> Expr { + Expr::BinaryExpr(BinaryExpr { + left: Box::new(left), + op, + right: Box::new(right), + }) + } + + #[rstest] + #[case::lt_from_positive(Operator::Lt, 0.0, -0.0)] + #[case::lt_from_negative(Operator::Lt, -0.0, -0.0)] + #[case::lt_eq_from_positive(Operator::LtEq, 0.0, 0.0)] + #[case::lt_eq_from_negative(Operator::LtEq, -0.0, 0.0)] + #[case::gt_from_positive(Operator::Gt, 0.0, 0.0)] + #[case::gt_from_negative(Operator::Gt, -0.0, 0.0)] + #[case::gt_eq_from_positive(Operator::GtEq, 0.0, -0.0)] + #[case::gt_eq_from_negative(Operator::GtEq, -0.0, -0.0)] + fn range_comparison_uses_the_encoding_for_the_operator( + #[case] op: Operator, + #[case] written: f64, + #[case] evaluated: f64, + ) { + assert_eq!( + rewrite(compare(col("x"), op, lit(written))), + compare(col("x"), op, lit(evaluated)) + ); + } + + #[rstest] + #[case::eq(Operator::Eq, false)] + #[case::not_eq(Operator::NotEq, true)] + fn equality_covers_both_encodings(#[case] op: Operator, #[case] negated: bool) { + assert_eq!( + rewrite(compare(col("x"), op, lit(0.0))), + Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(-0.0), lit(0.0)], + negated, + }) + ); + } + + #[test] + fn a_literal_on_the_left_mirrors_the_operator() { + // `0.0 > x` is `x < 0.0`, which evaluates against the negative encoding. + assert_eq!( + rewrite(compare(lit(0.0), Operator::Gt, col("x"))), + compare(col("x"), Operator::Lt, lit(-0.0)) + ); + } + + #[rstest] + #[case::float32(Float32(Some(-0.0)), Float32(Some(0.0)))] + #[case::float16(Float16(Some(f16::NEG_ZERO)), Float16(Some(f16::ZERO)))] + fn narrow_floats_are_rewritten_too( + #[case] written: ScalarValue, + #[case] evaluated: ScalarValue, + ) { + assert_eq!( + rewrite(compare( + col("x"), + Operator::LtEq, + Expr::Literal(written, None) + )), + compare(col("x"), Operator::LtEq, Expr::Literal(evaluated, None)) + ); + } + + #[test] + fn an_in_list_gains_the_missing_encoding() { + assert_eq!( + rewrite(Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(0.0), lit(5.0)], + negated: true, + })), + Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(0.0), lit(5.0), lit(-0.0)], + negated: true, + }) + ); + } + + #[test] + fn only_the_zero_comparison_in_a_conjunction_changes() { + assert_eq!( + rewrite(col("x").lt(lit(0.0)).and(col("y").eq(lit(1.0)))), + col("x").lt(lit(-0.0)).and(col("y").eq(lit(1.0))) + ); + } + + #[rstest] + #[case::non_zero(col("x").lt(lit(1.0)))] + #[case::integer_zero(col("x").eq(lit(0_i64)))] + #[case::null(compare(col("x"), Operator::Eq, Expr::Literal(Float64(None), None)))] + #[case::nan(col("x").lt(lit(f64::NAN)))] + #[case::column_on_both_sides(col("x").lt(col("y")))] + #[case::both_encodings_listed(Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(-0.0), lit(0.0)], + negated: false, + }))] + fn unrelated_comparisons_are_left_alone(#[case] expr: Expr) { + assert_eq!(rewrite(expr.clone()), expr); + } + + /// Distinctness has to stay decided for a null operand, and it has to name + /// the operand once so a computed one is not evaluated twice. + #[rstest] + #[case::is_not_distinct_from(Operator::IsNotDistinctFrom)] + #[case::is_distinct_from(Operator::IsDistinctFrom)] + fn distinct_from_lowers_through_a_null_defaulted_list(#[case] op: Operator) { + let covered = Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(-0.0), lit(0.0)], + negated: false, + }); + let expected = if op == Operator::IsDistinctFrom { + covered.is_not_true() + } else { + covered.is_true() + }; + assert_eq!(rewrite(compare(col("x"), op, lit(0.0))), expected); + } + + /// The operand does not have to be a column. Bailing out on anything else + /// used to leave `filter_expr` answering computed operands on Arrow's + /// sign-sensitive order, which returns wrong rows. + #[rstest] + #[case::is_not_distinct_from(Operator::IsNotDistinctFrom)] + #[case::is_distinct_from(Operator::IsDistinctFrom)] + fn distinct_from_rewrites_a_computed_operand(#[case] op: Operator) { + let computed = col("x") * lit(2.0); + let covered = Expr::InList(InList { + expr: Box::new(computed.clone()), + list: vec![lit(-0.0), lit(0.0)], + negated: false, + }); + let expected = if op == Operator::IsDistinctFrom { + covered.is_not_true() + } else { + covered.is_true() + }; + assert_eq!(rewrite(compare(computed, op, lit(0.0))), expected); + } + + /// Several paths optimize the same expression more than once, so every shape + /// the rewrite emits has to be a fixed point. + #[rstest] + #[case::lt(col("x").lt(lit(0.0)))] + #[case::gt_eq(col("x").gt_eq(lit(0.0)))] + #[case::eq(col("x").eq(lit(0.0)))] + #[case::not_eq(col("x").not_eq(lit(0.0)))] + #[case::in_list(Expr::InList(InList { + expr: Box::new(col("x")), + list: vec![lit(0.0), lit(5.0)], + negated: false, + }))] + #[case::zero_probe(Expr::InList(InList { + expr: Box::new(lit(0.0)), + list: vec![col("a"), col("b")], + negated: false, + }))] + #[case::zero_probe_over_literals(Expr::InList(InList { + expr: Box::new(lit(0.0)), + list: vec![col("a"), lit(0.0)], + negated: false, + }))] + #[case::is_not_distinct_from(compare(col("x"), Operator::IsNotDistinctFrom, lit(0.0)))] + #[case::is_distinct_from(compare(col("x"), Operator::IsDistinctFrom, lit(0.0)))] + fn rewriting_twice_changes_nothing(#[case] expr: Expr) { + let once = rewrite(expr); + assert_eq!(rewrite(once.clone()), once); + } + + #[rstest] + #[case::probe(false)] + #[case::negated_probe(true)] + fn a_zero_probe_expands_into_equalities(#[case] negated: bool) { + let covers = |column| { + Expr::InList(InList { + expr: Box::new(col(column)), + list: vec![lit(-0.0), lit(0.0)], + negated: false, + }) + }; + let matches_any = covers("a").or(covers("b")); + assert_eq!( + rewrite(Expr::InList(InList { + expr: Box::new(lit(0.0)), + list: vec![col("a"), col("b")], + negated, + })), + if negated { + Expr::Not(Box::new(matches_any)) + } else { + matches_any + } + ); + } + + #[test] + fn scalar_value_keeps_the_two_zero_encodings_apart() { + // The `IN` list widening decides "already listed" with this comparison. A + // DataFusion release that made the two encodings equal would silently stop + // it. + assert_ne!(Float64(Some(-0.0)), Float64(Some(0.0))); + assert_ne!(Float32(Some(-0.0)), Float32(Some(0.0))); + assert_ne!(Float16(Some(f16::NEG_ZERO)), Float16(Some(f16::ZERO))); + } + + /// The scan path optimizes the same expression twice, and the simplifier + /// expands a short `IN` list over a column back into an OR chain in between, + /// so a fixed point of the rewrite alone would not be enough. + #[rstest] + #[case::eq("value = 0.0")] + #[case::not_eq("value != 0.0")] + #[case::in_list("value IN (0.0, 1.0)")] + #[case::lt("value < 0.0")] + #[case::gt_eq("value >= 0.0")] + #[case::between("value BETWEEN -0.0 AND 0.0")] + // The dedup that makes the first three cases hold keys on the probe being a + // bare column, which is also what DataFusion requires before it shortens a + // list. This case fails if a release ever relaxes that. + #[case::non_column_probe("abs(value) = 0.0")] + // `IS [NOT] DISTINCT FROM` is missing because `Planner::parse_filter` rejects + // it as unsupported SQL; that arm is reachable only from a programmatically + // built expression, and `rewriting_twice_changes_nothing` covers it there. + fn optimizing_twice_changes_nothing(#[case] filter: &str) { + let schema = + std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( + "value", + arrow_schema::DataType::Float64, + true, + )])); + let planner = crate::planner::Planner::new(schema); + let once = planner + .optimize_expr(planner.parse_filter(filter).unwrap()) + .unwrap(); + assert_eq!(planner.optimize_expr(once.clone()).unwrap(), once); + } + + /// A comparison whose operands are all constant never reaches the rewrite if + /// the rewrite only runs after `simplify`: the simplifier folds it to a bare + /// boolean under Arrow's total order first, and there is nothing left to + /// repair. These fold to the IEEE answer only because the rewrite also runs + /// before `simplify`. + #[rstest] + #[case::lt("-1.0 * 0.0 < 0.0", false)] + #[case::eq("(-1.0 * 0.0) = 0.0", true)] + #[case::gt_eq("(-1.0 * 0.0) >= 0.0", true)] + #[case::not_eq("(-1.0 * 0.0) != 0.0", false)] + #[case::gt("(-1.0 * 0.0) > 0.0", false)] + #[case::lt_eq("(-1.0 * 0.0) <= 0.0", true)] + // The zero on the right is produced by folding rather than written, so these + // reach the rewrite only because the operands are folded before the + // comparison is. + #[case::folded_rhs_lt("-1.0 * 0.0 < (1.0 - 1.0)", false)] + #[case::folded_rhs_eq("(-1.0 * 0.0) = (1.0 - 1.0)", true)] + #[case::folded_rhs_gt_eq("(-1.0 * 0.0) >= (1.0 - 1.0)", true)] + #[case::folded_rhs_not_eq("(-1.0 * 0.0) != (1.0 - 1.0)", false)] + #[case::both_sides_folded("(0.0 * -1.0) < (1.0 - 1.0)", false)] + // `BETWEEN` and `IN` fold the same way, and a fully constant `BETWEEN` never + // reaches the rewrite already expanded, which is why the rewrite has its own + // arm for it. + #[case::folded_between("(-1.0 * 0.0) BETWEEN (1.0 - 1.0) AND 1.0", true)] + #[case::folded_in_list("(-1.0 * 0.0) IN ((1.0 - 1.0), 1.0)", true)] + #[case::folded_not_in_list("(-1.0 * 0.0) NOT IN ((1.0 - 1.0), 1.0)", false)] + // Nested under a connective, so the operand pass has to descend. + #[case::under_or("(-1.0 * 0.0) < (1.0 - 1.0) OR 1.0 > 2.0", false)] + #[case::under_not("NOT ((-1.0 * 0.0) < (1.0 - 1.0))", true)] + // Wrapped in something that folds the comparison's own result. These are why + // the operand folding walks every container instead of a list of allowed + // parents: each of these is a different spelling of the same exposure. + #[case::under_is_true("((-1.0 * 0.0) < (1.0 - 1.0)) IS TRUE", false)] + #[case::under_is_false("((-1.0 * 0.0) < (1.0 - 1.0)) IS FALSE", true)] + #[case::under_is_not_true("((-1.0 * 0.0) < (1.0 - 1.0)) IS NOT TRUE", true)] + #[case::under_eq_true("((-1.0 * 0.0) < (1.0 - 1.0)) = TRUE", false)] + #[case::under_cast("CAST(((-1.0 * 0.0) < (1.0 - 1.0)) AS BOOLEAN)", false)] + #[case::under_in_true("((-1.0 * 0.0) < (1.0 - 1.0)) IN (TRUE)", false)] + #[case::under_is_true_eq("((-1.0 * 0.0) = (1.0 - 1.0)) IS TRUE", true)] + #[case::under_nested_wrappers("NOT (((-1.0 * 0.0) < (1.0 - 1.0)) IS TRUE)", true)] + fn folded_constant_comparisons_use_ieee_semantics( + #[case] filter: &str, + #[case] expected: bool, + ) { + let schema = + std::sync::Arc::new(arrow_schema::Schema::new(vec![arrow_schema::Field::new( + "value", + arrow_schema::DataType::Float64, + true, + )])); + let planner = crate::planner::Planner::new(schema); + let optimized = planner + .optimize_expr(planner.parse_filter(filter).unwrap()) + .unwrap(); + assert_eq!( + optimized, + Expr::Literal(ScalarValue::Boolean(Some(expected)), None), + "filter: {filter}" + ); + } +} diff --git a/rust/lance-datafusion/src/spill.rs b/rust/lance-datafusion/src/spill.rs index 93f8a61cba5..749a9637fc5 100644 --- a/rust/lance-datafusion/src/spill.rs +++ b/rust/lance-datafusion/src/spill.rs @@ -9,13 +9,17 @@ use std::{ use arrow::ipc::{reader::StreamReader, writer::StreamWriter}; use arrow_array::RecordBatch; -use arrow_schema::{ArrowError, Schema}; +use arrow_schema::{ArrowError, Schema, SchemaRef}; use datafusion::{ - execution::SendableRecordBatchStream, physical_plan::stream::RecordBatchStreamAdapter, + catalog::{TableProvider, streaming::StreamingTable}, + execution::{SendableRecordBatchStream, TaskContext}, + physical_plan::{stream::RecordBatchStreamAdapter, streaming::PartitionStream}, }; use datafusion_common::DataFusionError; +use futures::StreamExt; use lance_arrow::memory::MemoryAccumulator; use lance_core::error::LanceOptionExt; +use lance_core::utils::tempfile::TempDir; /// Start a spill of Arrow data to a file that can be read later multiple times. /// @@ -60,6 +64,141 @@ pub fn create_replay_spill( (sender, receiver) } +/// Wrap a one-shot [`SendableRecordBatchStream`] in a re-scannable [`TableProvider`]. +/// +/// The source is drained in the background into a replayable spill. Two properties +/// keep this cheap for the common case: +/// +/// - **Memory-first.** Up to `memory_limit` bytes are buffered in memory; the spill +/// only touches disk once that budget is exceeded. A source that fits under the +/// limit never hits the filesystem. +/// - **Streaming replay.** A scan can start consuming batches as soon as they land, +/// before the source has finished draining — the first reader is not blocked +/// waiting for the whole source to buffer. +/// +/// Each scan of the returned provider replays the full source, which is what makes a +/// one-shot stream usable in the write retry loop. +/// +/// The provider reports no statistics — the source size is not known until it has +/// been fully drained — so callers that need source statistics (e.g. to drive join +/// ordering) should prefer a materialized or file-backed provider instead. +/// +/// # Examples +/// +/// ``` +/// # use std::sync::Arc; +/// # use arrow_array::{Int32Array, RecordBatch}; +/// # use arrow_schema::{DataType, Field, Schema}; +/// # use datafusion::execution::SendableRecordBatchStream; +/// # use datafusion::physical_plan::stream::RecordBatchStreamAdapter; +/// # use futures::TryStreamExt; +/// # use lance_datafusion::exec::provider_to_stream; +/// # use lance_datafusion::spill::spilling_table_provider; +/// # #[tokio::main] +/// # async fn main() -> Result<(), Box> { +/// let schema = Arc::new(Schema::new(vec![Field::new("a", DataType::Int32, false)])); +/// let batch = +/// RecordBatch::try_new(schema.clone(), vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])?; +/// // A one-shot stream can only be consumed once. +/// let source: SendableRecordBatchStream = Box::pin(RecordBatchStreamAdapter::new( +/// schema.clone(), +/// futures::stream::iter(vec![Ok(batch)]), +/// )); +/// +/// // Wrapping it makes it re-scannable: each scan replays the full source. +/// let provider = spilling_table_provider(source, 100 * 1024 * 1024).await?; +/// let first: Vec = provider_to_stream(provider.clone()).await?.try_collect().await?; +/// let second: Vec = provider_to_stream(provider).await?.try_collect().await?; +/// assert_eq!(first.iter().map(|b| b.num_rows()).sum::(), 3); +/// assert_eq!(second.iter().map(|b| b.num_rows()).sum::(), 3); +/// # Ok(()) +/// # } +/// ``` +pub async fn spilling_table_provider( + mut source: SendableRecordBatchStream, + memory_limit: usize, +) -> Result, DataFusionError> { + let schema = source.schema(); + let tmp_dir = tokio::task::spawn_blocking(TempDir::try_new) + .await + .map_err(|e| DataFusionError::Execution(format!("Failed to spawn temp dir task: {e}")))? + .map_err(|e| DataFusionError::Execution(format!("Failed to create temp dir: {e}")))?; + let tmp_path = tmp_dir.std_path().join("spill.arrows"); + let (mut sender, receiver) = create_replay_spill(tmp_path, schema.clone(), memory_limit); + + // Drain the one-shot source into the spill once, in the background. The spill + // tees to memory/disk so the first reader can consume batches as they arrive + // while later readers replay the complete source. + let drain_handle = tokio::task::spawn(async move { + let mut errored = false; + while let Some(res) = source.next().await { + match res { + Ok(batch) => { + if let Err(e) = sender.write(batch).await { + sender.send_error(e); + errored = true; + break; + } + } + Err(e) => { + sender.send_error(e); + errored = true; + break; + } + } + } + // Only finish on a clean drain. Calling finish() after an error would + // overwrite the original (replayable) error with a generic one, losing + // the source error's type (e.g. an external error from user code). + if !errored && let Err(err) = sender.finish().await { + sender.send_error(err); + } + sender + }); + + let partition = Arc::new(SpillPartition { + schema: schema.clone(), + receiver, + _tmp_dir: Arc::new(tmp_dir), + _drain_handle: Arc::new(drain_handle), + }); + Ok(Arc::new(StreamingTable::try_new(schema, vec![partition])?)) +} + +/// A [`PartitionStream`] backed by a replayable spill. +/// +/// Each call to [`PartitionStream::execute`] opens a fresh stream over the spill, +/// so the partition can be scanned repeatedly. The spill file and the background +/// task draining the source are kept alive for as long as this partition exists. +struct SpillPartition { + schema: SchemaRef, + receiver: SpillReceiver, + // The spilled data lives in this temp dir; dropping it deletes the spill file. + _tmp_dir: Arc, + // Keeps the background drain task (which owns the `SpillSender`) alive. The + // `SpillSender` must outlive the readers or they error out, so we hold the + // handle rather than detaching it. + _drain_handle: Arc>, +} + +impl std::fmt::Debug for SpillPartition { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SpillPartition") + .field("schema", &self.schema) + .finish() + } +} + +impl PartitionStream for SpillPartition { + fn schema(&self) -> &SchemaRef { + &self.schema + } + + fn execute(&self, _ctx: Arc) -> SendableRecordBatchStream { + self.receiver.read() + } +} + #[derive(Clone)] pub struct SpillReceiver { status_receiver: tokio::sync::watch::Receiver, @@ -250,7 +389,7 @@ impl Default for DataLocation { } } -/// A DataFusion error that be be emitted multiple times. We provide the +/// A DataFusion error that can be emitted multiple times. We provide the /// Original error first, and subsequent conversions provide a copy with a /// string representation of the original error. #[derive(Debug)] @@ -354,7 +493,7 @@ impl SpillSender { let (writer, batches_written) = match &mut self.state { SpillState::Buffering { batches, - ref mut memory_accumulator, + memory_accumulator, } => { memory_accumulator.record_batch(&batch); @@ -531,7 +670,7 @@ impl AsyncStreamReader { mod tests { use arrow_array::Int32Array; use arrow_schema::{DataType, Field}; - use futures::{poll, StreamExt, TryStreamExt}; + use futures::{StreamExt, TryStreamExt, poll}; use lance_core::utils::tempfile::{TempStdFile, TempStdPath}; use super::*; diff --git a/rust/lance-datafusion/src/sql.rs b/rust/lance-datafusion/src/sql.rs index 547badfdbfa..3ed420a0794 100644 --- a/rust/lance-datafusion/src/sql.rs +++ b/rust/lance-datafusion/src/sql.rs @@ -13,7 +13,6 @@ use datafusion::sql::sqlparser::{ }; use lance_core::{Error, Result}; -use snafu::location; #[derive(Debug, Default)] struct LanceDialect(GenericDialect); @@ -39,6 +38,10 @@ impl Dialect for LanceDialect { fn is_delimited_identifier_start(&self, ch: char) -> bool { ch == '`' } + + fn supports_bitwise_shift_operators(&self) -> bool { + self.0.supports_bitwise_shift_operators() + } } /// Parse sql filter to Expression. @@ -46,19 +49,16 @@ pub(crate) fn parse_sql_filter(filter: &str) -> Result { let sql = format!("SELECT 1 FROM t WHERE {filter}"); let statement = parse_statement(&sql)?; - let selection = if let Statement::Query(query) = &statement { - if let SetExpr::Select(s) = query.body.as_ref() { - s.selection.as_ref() - } else { - None - } + if let Statement::Query(query) = statement + && let SetExpr::Select(select) = *query.body + && let Some(expr) = select.selection + { + Ok(expr) } else { - None - }; - let expr = selection.ok_or_else(|| { - Error::invalid_input(format!("Filter is not valid: {filter}"), location!()) - })?; - Ok(expr.clone()) + Err(Error::invalid_input(format!( + "Filter is not valid: {filter}" + ))) + } } /// Parse a SQL expression to Expression. This is more lenient than parse_sql_filter @@ -67,23 +67,16 @@ pub(crate) fn parse_sql_expr(expr: &str) -> Result { let sql = format!("SELECT {expr} FROM t"); let statement = parse_statement(&sql)?; - let selection = if let Statement::Query(query) = &statement { - if let SetExpr::Select(s) = query.body.as_ref() { - if let SelectItem::UnnamedExpr(expr) = &s.projection[0] { - Some(expr) - } else { - None - } - } else { - None - } + if let Statement::Query(query) = statement + && let SetExpr::Select(select) = *query.body + && let Some(SelectItem::UnnamedExpr(expr)) = select.projection.into_iter().next() + { + Ok(expr) } else { - None - }; - let expr = selection.ok_or_else(|| { - Error::invalid_input(format!("Expression is not valid: {expr}"), location!()) - })?; - Ok(expr.clone()) + Err(Error::invalid_input(format!( + "Expression is not valid: {expr}" + ))) + } } fn parse_statement(statement: &str) -> Result { @@ -97,10 +90,7 @@ fn parse_statement(statement: &str) -> Result { let mut token_iter = tokenizer .tokenize() .map_err(|e| { - Error::invalid_input( - format!("Error tokenizing statement: {statement} ({e})"), - location!(), - ) + Error::invalid_input(format!("Error tokenizing statement: {statement} ({e})")) })? .into_iter(); let mut prev_token = token_iter.next().unwrap(); @@ -116,12 +106,7 @@ fn parse_statement(statement: &str) -> Result { Parser::new(&dialect) .with_tokens(tokens) .parse_statement() - .map_err(|e| { - Error::invalid_input( - format!("Error parsing statement: {statement} ({e})"), - location!(), - ) - }) + .map_err(|e| Error::invalid_input(format!("Error parsing statement: {statement} ({e})"))) } #[cfg(test)] diff --git a/rust/lance-datafusion/src/substrait.rs b/rust/lance-datafusion/src/substrait.rs index 54dc9be8808..f3dc9b4d3df 100644 --- a/rust/lance-datafusion/src/substrait.rs +++ b/rust/lance-datafusion/src/substrait.rs @@ -1,24 +1,60 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow_schema::Schema as ArrowSchema; +use arrow_schema::{DataType, Schema as ArrowSchema}; use datafusion::{execution::SessionState, logical_expr::Expr}; + +use crate::aggregate::Aggregate; +use datafusion_common::DFSchema; +use datafusion_substrait::extensions::Extensions; +use datafusion_substrait::logical_plan::consumer::{ + DefaultSubstraitConsumer, from_substrait_agg_func, from_substrait_rex, from_substrait_sorts, +}; use datafusion_substrait::substrait::proto::{ + AggregateRel, Expression, ExpressionReference, ExtendedExpression, NamedStruct, Plan, Type, expression::{ + Literal, RexType, field_reference::{ReferenceType, RootType}, - reference_segment, RexType, + literal::{LiteralType, PrecisionTimestamp}, + reference_segment, }, expression_reference::ExprType, function_argument::ArgType, + rel::RelType, r#type::{Kind, Struct}, - Expression, ExpressionReference, ExtendedExpression, NamedStruct, Type, }; use lance_core::{Error, Result}; use prost::Message; -use snafu::location; use std::collections::HashMap; use std::sync::Arc; +/// FixedSizeList has no Substrait producer support in datafusion-substrait. +/// Other unsupported types (Null, Float16) are encoded as UserDefined and +/// handled by `remove_extension_types` on the decode side. +fn is_substrait_compatible(data_type: &DataType) -> bool { + match data_type { + DataType::FixedSizeList(_, _) => false, + DataType::List(inner) => is_substrait_compatible(inner.data_type()), + DataType::Struct(fields) => fields + .iter() + .all(|f| is_substrait_compatible(f.data_type())), + _ => true, + } +} + +/// Removes top-level fields that contain data types that the Substrait +/// producer cannot encode (currently only FixedSizeList). +pub fn prune_schema_for_substrait(schema: &ArrowSchema) -> ArrowSchema { + ArrowSchema::new( + schema + .fields() + .iter() + .filter(|f| is_substrait_compatible(f.data_type())) + .cloned() + .collect::>(), + ) +} + /// Convert a DF Expr into a Substrait ExtendedExpressions message /// /// The schema needs to contain all of the fields that are referenced in the expression. @@ -62,24 +98,137 @@ fn count_fields(dtype: &Type) -> usize { } } +fn count_fields_without_list_children(dtype: &Type) -> usize { + match dtype.kind.as_ref().unwrap() { + Kind::Struct(struct_type) => { + struct_type + .types + .iter() + .map(count_fields_without_list_children) + .sum::() + + 1 + } + Kind::List(_) => 1, + _ => 1, + } +} + +fn append_nested_field_names( + substrait_type: &Type, + arrow_type: &DataType, + names: &mut Vec, +) -> Result<()> { + match substrait_type.kind.as_ref().unwrap() { + Kind::Struct(substrait_struct) => { + let DataType::Struct(arrow_fields) = arrow_type else { + return Err(Error::invalid_input_source( + format!( + "the provided substrait schema contained a struct where the input schema contained {arrow_type}" + ) + .into(), + )); + }; + if substrait_struct.types.len() != arrow_fields.len() { + return Err(Error::invalid_input_source( + format!( + "the provided substrait struct had {} fields but the corresponding input struct had {} fields", + substrait_struct.types.len(), + arrow_fields.len() + ) + .into(), + )); + } + for (substrait_field, arrow_field) in + substrait_struct.types.iter().zip(arrow_fields.iter()) + { + names.push(arrow_field.name().to_string()); + append_nested_field_names(substrait_field, arrow_field.data_type(), names)?; + } + } + Kind::List(substrait_list) => { + let (DataType::List(arrow_field) | DataType::LargeList(arrow_field)) = arrow_type + else { + return Err(Error::invalid_input_source( + format!( + "the provided substrait schema contained a list where the input schema contained {arrow_type}" + ) + .into(), + )); + }; + let substrait_element = substrait_list.r#type.as_ref().ok_or_else(|| { + Error::invalid_input_source( + "the provided substrait schema contained a list without an element type".into(), + ) + })?; + append_nested_field_names(substrait_element, arrow_field.data_type(), names)?; + } + _ => {} + } + Ok(()) +} + +fn normalize_substrait_names( + substrait_schema: &NamedStruct, + arrow_schema: &ArrowSchema, +) -> Result> { + let fields = substrait_schema.r#struct.as_ref().unwrap(); + let expected_names = fields.types.iter().map(count_fields).sum::(); + if substrait_schema.names.len() == expected_names { + return Ok(substrait_schema.names.clone()); + } + + // PyArrow stops emitting names below list element types, while DataFusion's + // Substrait consumer requires the complete depth-first list of struct names. + let expected_pyarrow_names = fields + .types + .iter() + .map(count_fields_without_list_children) + .sum::(); + if substrait_schema.names.len() != expected_pyarrow_names { + return Err(Error::invalid_input_source( + format!( + "the provided substrait schema had {} names but its types require either {} names or {} names when list children are omitted", + substrait_schema.names.len(), + expected_names, + expected_pyarrow_names + ) + .into(), + )); + } + + let mut names = Vec::with_capacity(expected_names); + let mut name_index = 0; + for (substrait_field, arrow_field) in fields.types.iter().zip(arrow_schema.fields().iter()) { + names.push(substrait_schema.names[name_index].clone()); + append_nested_field_names(substrait_field, arrow_field.data_type(), &mut names)?; + name_index += count_fields_without_list_children(substrait_field); + } + Ok(names) +} + fn remove_extension_types( substrait_schema: &NamedStruct, arrow_schema: Arc, ) -> Result<(NamedStruct, Arc, HashMap)> { let fields = substrait_schema.r#struct.as_ref().unwrap(); if fields.types.len() != arrow_schema.fields.len() { - return Err(Error::InvalidInput { - source: "the number of fields in the provided substrait schema did not match the number of fields in the input schema.".into(), - location: location!(), - }); + return Err(Error::invalid_input_source("the number of fields in the provided substrait schema did not match the number of fields in the input schema.".into())); } + let substrait_names = normalize_substrait_names(substrait_schema, arrow_schema.as_ref())?; let mut kept_substrait_fields = Vec::with_capacity(fields.types.len()); let mut kept_arrow_fields = Vec::with_capacity(arrow_schema.fields.len()); - let mut index_mapping = HashMap::with_capacity(arrow_schema.fields.len()); - let mut field_counter = 0; - let mut field_index = 0; + let mut name_index_mapping = HashMap::with_capacity(substrait_names.len()); + let mut field_index_mapping = HashMap::with_capacity(arrow_schema.fields.len()); + let mut kept_name_count = 0; + let mut name_index = 0; + let mut kept_field_count = 0; // TODO: this logic doesn't catch user defined fields inside of struct fields - for (substrait_field, arrow_field) in fields.types.iter().zip(arrow_schema.fields.iter()) { + for (field_index, (substrait_field, arrow_field)) in fields + .types + .iter() + .zip(arrow_schema.fields.iter()) + .enumerate() + { let num_fields = count_fields(substrait_field); let kind = substrait_field.kind.as_ref().unwrap(); @@ -91,21 +240,23 @@ fn remove_extension_types( _ => false, }; - if !substrait_schema.names[field_index].starts_with("__unlikely_name_placeholder") + if !substrait_names[name_index].starts_with("__unlikely_name_placeholder") && !is_user_defined { kept_substrait_fields.push(substrait_field.clone()); kept_arrow_fields.push(arrow_field.clone()); for i in 0..num_fields { - index_mapping.insert(field_index + i, field_counter + i); + name_index_mapping.insert(name_index + i, kept_name_count + i); } - field_counter += num_fields; + field_index_mapping.insert(field_index, kept_field_count); + kept_name_count += num_fields; + kept_field_count += 1; } - field_index += num_fields; + name_index += num_fields; } - let mut names = vec![String::new(); index_mapping.len()]; - for (old_idx, old_name) in substrait_schema.names.iter().enumerate() { - if let Some(new_idx) = index_mapping.get(&old_idx) { + let mut names = vec![String::new(); name_index_mapping.len()]; + for (old_idx, old_name) in substrait_names.iter().enumerate() { + if let Some(new_idx) = name_index_mapping.get(&old_idx) { names[*new_idx] = old_name.clone(); } } @@ -118,101 +269,190 @@ fn remove_extension_types( types: kept_substrait_fields, }), }; - Ok((new_substrait_schema, new_arrow_schema, index_mapping)) + Ok((new_substrait_schema, new_arrow_schema, field_index_mapping)) } -fn remap_expr_references(expr: &mut Expression, mapping: &HashMap) -> Result<()> { - match expr.rex_type.as_mut().unwrap() { +/// Substrait's optional message fields are `None` when a producer omits them, so unwrapping one +/// turns a malformed (or merely terse) filter into a panic. This walker runs on every filter, so +/// report the missing field instead. +fn missing_field(what: &str) -> Error { + Error::invalid_input(format!( + "filter expression was missing a required {what} field" + )) +} + +/// Substrait's deprecated `timestamp`/`timestamp_tz` literals are always microseconds, but +/// DataFusion takes their unit from `type_variation_reference` and reads the default 0 as +/// seconds. PyArrow emits exactly that, so filters silently matched the wrong rows. +/// +/// Rewrite them into the `precision_timestamp` forms, which state the unit. +#[allow(deprecated)] +fn normalize_deprecated_timestamp_literal(lit: &mut Literal) { + let precision = match lit.type_variation_reference { + // 0 is Substrait's default reference (microseconds); 1..=3 are DataFusion's ms/us/ns. + 0 | 2 => 6, + 1 => 3, + 3 => 9, + _ => return, + }; + let replacement = match lit.literal_type { + Some(LiteralType::Timestamp(value)) => { + LiteralType::PrecisionTimestamp(PrecisionTimestamp { precision, value }) + } + Some(LiteralType::TimestampTz(value)) => { + LiteralType::PrecisionTimestampTz(PrecisionTimestamp { precision, value }) + } + _ => return, + }; + lit.literal_type = Some(replacement); + lit.type_variation_reference = 0; +} + +/// Reject operators we cannot push down, normalize ambiguous literals, and remap field +/// references onto the schema `remove_extension_types` left behind. +fn normalize_expr(expr: &mut Expression, mapping: &HashMap) -> Result<()> { + match expr + .rex_type + .as_mut() + .ok_or_else(|| missing_field("expression"))? + { + RexType::Literal(lit) => { + normalize_deprecated_timestamp_literal(lit); + Ok(()) + } // Simple, no field references possible - RexType::Literal(_) | RexType::Nested(_) | RexType::DynamicParameter(_) => Ok(()), + RexType::Nested(_) | RexType::DynamicParameter(_) => Ok(()), // Enum literals are deprecated in Substrait and should only appear in older plans. #[allow(deprecated)] RexType::Enum(_) => Ok(()), // Complex operators not supported in filters RexType::WindowFunction(_) | RexType::Subquery(_) => Err(Error::invalid_input( "Window functions or subqueries not allowed in filter expression", - location!(), + )), + RexType::Lambda(_) | RexType::LambdaInvocation(_) => Err(Error::invalid_input( + "Lambda expressions not allowed in filter expression", )), // Pass through operators, nested children may have field references - RexType::ScalarFunction(ref mut func) => { + RexType::ScalarFunction(func) => { #[allow(deprecated)] for arg in &mut func.args { - remap_expr_references(arg, mapping)?; + normalize_expr(arg, mapping)?; } for arg in &mut func.arguments { - match arg.arg_type.as_mut().unwrap() { - ArgType::Value(expr) => remap_expr_references(expr, mapping)?, + match arg + .arg_type + .as_mut() + .ok_or_else(|| missing_field("function argument"))? + { + ArgType::Value(expr) => normalize_expr(expr, mapping)?, ArgType::Enum(_) | ArgType::Type(_) => {} } } Ok(()) } - RexType::IfThen(ref mut ifthen) => { - for clause in ifthen.ifs.iter_mut() { - remap_expr_references(clause.r#if.as_mut().unwrap(), mapping)?; - remap_expr_references(clause.then.as_mut().unwrap(), mapping)?; + RexType::IfThen(ifthen) => { + for (i, clause) in ifthen.ifs.iter_mut().enumerate() { + normalize_expr( + clause + .r#if + .as_mut() + .ok_or_else(|| missing_field("if clause condition"))?, + mapping, + )?; + match clause.then.as_mut() { + Some(then) => normalize_expr(then, mapping)?, + // Only the leading clause may omit `then`, in which case its condition is + // the case expression being matched against. + None if i == 0 => {} + None => return Err(missing_field("if clause result")), + } + } + if let Some(otherwise) = ifthen.r#else.as_mut() { + normalize_expr(otherwise, mapping)?; } - remap_expr_references(ifthen.r#else.as_mut().unwrap(), mapping)?; Ok(()) } - RexType::SwitchExpression(ref mut switch) => { + RexType::SwitchExpression(switch) => { for clause in switch.ifs.iter_mut() { - remap_expr_references(clause.then.as_mut().unwrap(), mapping)?; + if let Some(then) = clause.then.as_mut() { + normalize_expr(then, mapping)?; + } + } + if let Some(otherwise) = switch.r#else.as_mut() { + normalize_expr(otherwise, mapping)?; } - remap_expr_references(switch.r#else.as_mut().unwrap(), mapping)?; Ok(()) } - RexType::SingularOrList(ref mut orlist) => { + RexType::SingularOrList(orlist) => { for opt in orlist.options.iter_mut() { - remap_expr_references(opt, mapping)?; + normalize_expr(opt, mapping)?; } - remap_expr_references(orlist.value.as_mut().unwrap(), mapping)?; + normalize_expr( + orlist + .value + .as_mut() + .ok_or_else(|| missing_field("IN list value"))?, + mapping, + )?; Ok(()) } - RexType::MultiOrList(ref mut orlist) => { + RexType::MultiOrList(orlist) => { for opt in orlist.options.iter_mut() { for field in opt.fields.iter_mut() { - remap_expr_references(field, mapping)?; + normalize_expr(field, mapping)?; } } for val in orlist.value.iter_mut() { - remap_expr_references(val, mapping)?; + normalize_expr(val, mapping)?; } Ok(()) } - RexType::Cast(ref mut cast) => { - remap_expr_references(cast.input.as_mut().unwrap(), mapping)?; + RexType::Cast(cast) => { + normalize_expr( + cast.input + .as_mut() + .ok_or_else(|| missing_field("cast input"))?, + mapping, + )?; Ok(()) } - RexType::Selection(ref mut sel) => { - // Finally, the selection, which might actually have field references - let root_type = sel.root_type.as_mut().unwrap(); - // These types of references do not reference input fields so no remap needed + RexType::Selection(sel) => { + // Finally, the selection, which might actually have field references. + // An omitted root is a reference into the input, same as RootReference. if matches!( - root_type, - RootType::Expression(_) | RootType::OuterReference(_) + sel.root_type.as_mut(), + Some(RootType::Expression(_) | RootType::OuterReference(_)) ) { + // These types of references do not reference input fields so no remap needed return Ok(()); } - match sel.reference_type.as_mut().unwrap() { + match sel + .reference_type + .as_mut() + .ok_or_else(|| missing_field("field reference"))? + { ReferenceType::DirectReference(direct) => { - match direct.reference_type.as_mut().unwrap() { + match direct + .reference_type + .as_mut() + .ok_or_else(|| missing_field("reference segment"))? + { reference_segment::ReferenceType::ListElement(_) | reference_segment::ReferenceType::MapKey(_) => Err(Error::invalid_input( "map/list nested references not supported in pushdown filters", - location!(), )), reference_segment::ReferenceType::StructField(field) => { if field.child.is_some() { Err(Error::invalid_input( "nested references in pushdown filters not yet supported", - location!(), )) } else { if let Some(new_index) = mapping.get(&(field.field as usize)) { field.field = *new_index as i32; } else { - return Err(Error::invalid_input("pushdown filter referenced a field that is not yet supported by Substrait conversion", location!())); + return Err(Error::invalid_input( + "pushdown filter referenced a field that is not yet supported by Substrait conversion", + )); } Ok(()) } @@ -221,7 +461,6 @@ fn remap_expr_references(expr: &mut Expression, mapping: &HashMap) } ReferenceType::MaskedReference(_) => Err(Error::invalid_input( "masked references not yet supported in filter expressions", - location!(), )), } } @@ -238,31 +477,27 @@ pub async fn parse_substrait( ) -> Result { let envelope = ExtendedExpression::decode(expr)?; if envelope.referred_expr.is_empty() { - return Err(Error::InvalidInput { - source: "the provided substrait expression is empty (contains no expressions)".into(), - location: location!(), - }); + return Err(Error::invalid_input_source( + "the provided substrait expression is empty (contains no expressions)".into(), + )); } if envelope.referred_expr.len() > 1 { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "the provided substrait expression had {} expressions when only 1 was expected", envelope.referred_expr.len() ) .into(), - location: location!(), - }); + )); } let mut expr = match &envelope.referred_expr[0].expr_type { - None => Err(Error::InvalidInput { - source: "the provided substrait had an expression but was missing an expr_type".into(), - location: location!(), - }), + None => Err(Error::invalid_input_source( + "the provided substrait had an expression but was missing an expr_type".into(), + )), Some(ExprType::Expression(expr)) => Ok(expr.clone()), - _ => Err(Error::InvalidInput { - source: "the provided substrait was not a scalar expression".into(), - location: location!(), - }), + _ => Err(Error::invalid_input_source( + "the provided substrait was not a scalar expression".into(), + )), }?; // The Substrait may have come from a producer that uses extension types that DF doesn't support (e.g. @@ -272,19 +507,10 @@ pub async fn parse_substrait( let (substrait_schema, _, index_mapping) = remove_extension_types(envelope.base_schema.as_ref().unwrap(), input_schema.clone())?; - if substrait_schema.r#struct.as_ref().unwrap().types.len() - != envelope - .base_schema - .as_ref() - .unwrap() - .r#struct - .as_ref() - .unwrap() - .types - .len() - { - remap_expr_references(&mut expr, &index_mapping)?; - } + // Always walk the expression: this also rejects operators we cannot push down and + // normalizes literals. When no fields were removed the mapping is the identity, so the + // remap itself is a no-op. + normalize_expr(&mut expr, &index_mapping)?; substrait_schema } else { @@ -310,49 +536,230 @@ pub async fn parse_substrait( if expr_container.exprs.is_empty() { return Err(Error::invalid_input( "Substrait expression did not contain any expressions", - location!(), )); } if expr_container.exprs.len() > 1 { return Err(Error::invalid_input( "Substrait expression contained multiple expressions", - location!(), )); } Ok(expr_container.exprs.pop().unwrap().0) } +/// Parse Substrait Plan bytes containing an AggregateRel. +pub async fn parse_substrait_aggregate( + bytes: &[u8], + input_schema: Arc, + state: &SessionState, +) -> Result { + let plan = Plan::decode(bytes)?; + let (aggregate_rel, output_names) = extract_aggregate_from_plan(&plan)?; + let extensions = Extensions::try_from(&plan.extensions)?; + + let mut agg = + parse_aggregate_rel_with_extensions(&aggregate_rel, input_schema, state, &extensions) + .await?; + + // Apply aliases from RelRoot.names to expressions + if !output_names.is_empty() { + let num_groups = agg.group_by.len(); + for (i, expr) in agg.group_by.iter_mut().enumerate() { + if i < output_names.len() { + *expr = expr.clone().alias(&output_names[i]); + } + } + for (i, expr) in agg.aggregates.iter_mut().enumerate() { + let name_idx = num_groups + i; + if name_idx < output_names.len() { + *expr = expr.clone().alias(&output_names[name_idx]); + } + } + } + + Ok(agg) +} + +fn extract_aggregate_from_plan(plan: &Plan) -> Result<(Box, Vec)> { + if plan.relations.is_empty() { + return Err(Error::invalid_input("Substrait Plan has no relations")); + } + + let plan_rel = &plan.relations[0]; + let (rel, output_names) = match &plan_rel.rel_type { + Some(datafusion_substrait::substrait::proto::plan_rel::RelType::Root(root)) => { + (root.input.as_ref(), root.names.clone()) + } + Some(datafusion_substrait::substrait::proto::plan_rel::RelType::Rel(rel)) => { + (Some(rel), vec![]) + } + None => (None, vec![]), + }; + + let rel = rel.ok_or_else(|| Error::invalid_input("Plan relation has no input"))?; + + match &rel.rel_type { + Some(RelType::Aggregate(agg)) => Ok((agg.clone(), output_names)), + Some(other) => Err(Error::invalid_input(format!( + "Expected Substrait AggregateRel, got {:?}", + std::mem::discriminant(other) + ))), + None => Err(Error::invalid_input("Substrait Rel has no rel_type")), + } +} + +/// Parse an AggregateRel proto with provided extensions. +pub async fn parse_aggregate_rel_with_extensions( + aggregate_rel: &AggregateRel, + input_schema: Arc, + state: &SessionState, + extensions: &Extensions, +) -> Result { + let df_schema = DFSchema::try_from(input_schema.as_ref().clone())?; + let consumer = DefaultSubstraitConsumer::new(extensions, state); + let group_by = parse_groupings(aggregate_rel, &df_schema, &consumer).await?; + let aggregates = parse_measures(aggregate_rel, &df_schema, &consumer).await?; + + Ok(Aggregate::new(group_by, aggregates)) +} + +/// Parse an AggregateRel proto with default extensions. +pub async fn parse_aggregate_rel( + aggregate_rel: &AggregateRel, + input_schema: Arc, + state: &SessionState, +) -> Result { + let extensions = Extensions::default(); + parse_aggregate_rel_with_extensions(aggregate_rel, input_schema, state, &extensions).await +} + +async fn parse_groupings( + agg_rel: &AggregateRel, + schema: &DFSchema, + consumer: &DefaultSubstraitConsumer<'_>, +) -> Result> { + let mut group_exprs = Vec::new(); + + // First, handle the new-style grouping_expressions + expression_references + if !agg_rel.grouping_expressions.is_empty() { + for grouping in &agg_rel.groupings { + for expr_ref in &grouping.expression_references { + let idx = *expr_ref as usize; + if idx >= agg_rel.grouping_expressions.len() { + return Err(Error::invalid_input(format!( + "Grouping expression reference {} out of bounds (max: {})", + idx, + agg_rel.grouping_expressions.len() + ))); + } + let expr = &agg_rel.grouping_expressions[idx]; + let df_expr = from_substrait_rex(consumer, expr, schema) + .await + .map_err(|e| { + Error::invalid_input(format!("Failed to parse grouping expression: {}", e)) + })?; + group_exprs.push(df_expr); + } + } + } else { + // Fallback to deprecated inline grouping_expressions within each Grouping + #[allow(deprecated)] + for grouping in &agg_rel.groupings { + for expr in &grouping.grouping_expressions { + let df_expr = from_substrait_rex(consumer, expr, schema) + .await + .map_err(|e| { + Error::invalid_input(format!("Failed to parse grouping expression: {}", e)) + })?; + group_exprs.push(df_expr); + } + } + } + + Ok(group_exprs) +} + +async fn parse_measures( + agg_rel: &AggregateRel, + schema: &DFSchema, + consumer: &DefaultSubstraitConsumer<'_>, +) -> Result> { + let mut aggregates = Vec::new(); + + for measure in &agg_rel.measures { + if let Some(agg_func) = &measure.measure { + // Parse optional filter + let filter = if let Some(filter_expr) = &measure.filter { + let df_filter = from_substrait_rex(consumer, filter_expr, schema) + .await + .map_err(|e| { + Error::invalid_input(format!("Failed to parse measure filter: {}", e)) + })?; + Some(Box::new(df_filter)) + } else { + None + }; + + // Parse ordering (for ordered aggregates like ARRAY_AGG) + let order_by = from_substrait_sorts(consumer, &agg_func.sorts, schema) + .await + .map_err(|e| { + Error::invalid_input(format!("Failed to parse aggregate sorts: {}", e)) + })?; + + // Check for DISTINCT invocation + let distinct = matches!( + agg_func.invocation, + i if i == datafusion_substrait::substrait::proto::aggregate_function::AggregationInvocation::Distinct as i32 + ); + + // Convert Substrait AggregateFunction to DataFusion Expr + let df_expr = + from_substrait_agg_func(consumer, agg_func, schema, filter, order_by, distinct) + .await + .map_err(|e| { + Error::invalid_input(format!("Failed to parse aggregate function: {}", e)) + })?; + + aggregates.push(df_expr.as_ref().clone()); + } + } + + Ok(aggregates) +} + #[cfg(test)] mod tests { use std::sync::Arc; - use arrow_schema::{DataType, Field, Schema}; + use arrow_schema::{DataType, Field, Schema, TimeUnit}; use datafusion::{ execution::SessionState, - logical_expr::{BinaryExpr, Operator}, + logical_expr::{BinaryExpr, Case, Operator}, prelude::{Expr, SessionContext}, }; use datafusion_common::{Column, ScalarValue}; use datafusion_substrait::substrait::proto::{ + Expression, ExpressionReference, ExtendedExpression, FunctionArgument, NamedStruct, Type, + Version, expression::{ + FieldReference, IfThen, Literal, ReferenceSegment, RexType, ScalarFunction, field_reference::{ReferenceType, RootReference, RootType}, + if_then::IfClause, literal::LiteralType, reference_segment::{self, StructField}, - FieldReference, Literal, ReferenceSegment, RexType, ScalarFunction, }, expression_reference::ExprType, extensions::{ + SimpleExtensionDeclaration, SimpleExtensionUrn, simple_extension_declaration::{ExtensionFunction, MappingType}, - SimpleExtensionDeclaration, SimpleExtensionUri, SimpleExtensionUrn, }, function_argument::ArgType, - r#type::{Boolean, Kind, Nullability, Struct, I32}, - Expression, ExpressionReference, ExtendedExpression, FunctionArgument, NamedStruct, Type, - Version, + r#type::{Boolean, I32, Kind, Nullability, Struct}, }; use prost::Message; + use rstest::rstest; use crate::substrait::{encode_substrait, parse_substrait}; @@ -371,13 +778,6 @@ mod tests { git_hash: "".to_string(), producer: "unit-test".to_string(), }), - #[expect(deprecated)] - extension_uris: vec![ - SimpleExtensionUri { - extension_uri_anchor: 1, - uri: "https://github.com/substrait-io/substrait/blob/main/extensions/functions_comparison.yaml".to_string(), - } - ], extension_urns: vec![ SimpleExtensionUrn { extension_urn_anchor: 1, @@ -387,8 +787,6 @@ mod tests { extensions: vec![ SimpleExtensionDeclaration { mapping_type: Some(MappingType::ExtensionFunction(ExtensionFunction { - #[expect(deprecated)] - extension_uri_reference: 1, extension_urn_reference: 1, function_anchor: 1, name: "lt".to_string(), @@ -465,6 +863,211 @@ mod tests { assert_eq!(df_expr, expected); } + /// A base schema with no extension types needs no field pruning, which is the case that + /// used to skip validation entirely. + async fn parse_unpruned_expr(rex_type: RexType) -> lance_core::Result { + let expr = ExtendedExpression { + version: Some(Version { + major_number: 0, + minor_number: 63, + patch_number: 1, + git_hash: "".to_string(), + producer: "unit-test".to_string(), + }), + extension_urns: vec![], + extensions: vec![], + referred_expr: vec![ExpressionReference { + output_names: vec!["filter_mask".to_string()], + expr_type: Some(ExprType::Expression(Expression { + rex_type: Some(rex_type), + })), + }], + base_schema: Some(NamedStruct { + names: vec!["x".to_string()], + r#struct: Some(Struct { + types: vec![Type { + kind: Some(Kind::I32(I32 { + type_variation_reference: 0, + nullability: Nullability::Nullable as i32, + })), + }], + type_variation_reference: 0, + nullability: Nullability::Required as i32, + }), + }), + advanced_extensions: None, + expected_type_urls: vec![], + }; + + let schema = Arc::new(Schema::new(vec![Field::new("x", DataType::Int32, true)])); + parse_substrait(expr.encode_to_vec().as_slice(), schema, &session_state()).await + } + + #[tokio::test] + async fn test_unsupported_operator_rejected_without_pruning() { + let err = parse_unpruned_expr(RexType::Subquery(Box::default())) + .await + .expect_err("subqueries should be rejected in filter expressions"); + assert!( + err.to_string() + .contains("Window functions or subqueries not allowed in filter expression"), + "unexpected error: {err}" + ); + + let err = parse_unpruned_expr(RexType::Lambda(Box::default())) + .await + .expect_err("lambdas should be rejected in filter expressions"); + assert!( + err.to_string() + .contains("Lambda expressions not allowed in filter expression"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_unpruned_selection_without_explicit_root() { + let expr = parse_unpruned_expr(RexType::Selection(Box::new(FieldReference { + reference_type: Some(ReferenceType::DirectReference(ReferenceSegment { + reference_type: Some(reference_segment::ReferenceType::StructField(Box::new( + StructField { + field: 0, + child: None, + }, + ))), + })), + root_type: None, + }))) + .await + .unwrap(); + + assert_eq!(expr, Expr::Column(Column::new_unqualified("x"))); + } + + /// The deprecated literal is always microseconds, but DataFusion reads the default + /// variation reference as seconds. + #[rstest] + #[case::default_reference(0, TimeUnit::Microsecond)] + #[case::milli_reference(1, TimeUnit::Millisecond)] + #[case::micro_reference(2, TimeUnit::Microsecond)] + #[case::nano_reference(3, TimeUnit::Nanosecond)] + #[tokio::test] + async fn test_deprecated_timestamp_literal_units( + #[case] type_variation_reference: u32, + #[case] expected_unit: TimeUnit, + ) { + const MICROS: i64 = 1_704_247_200_000_000; + + #[allow(deprecated)] + let expr = parse_unpruned_expr(RexType::Literal(Literal { + nullable: false, + type_variation_reference, + literal_type: Some(LiteralType::Timestamp(MICROS)), + })) + .await + .unwrap(); + + let expected = match expected_unit { + TimeUnit::Millisecond => ScalarValue::TimestampMillisecond(Some(MICROS), None), + TimeUnit::Microsecond => ScalarValue::TimestampMicrosecond(Some(MICROS), None), + TimeUnit::Nanosecond => ScalarValue::TimestampNanosecond(Some(MICROS), None), + other => panic!("unexpected time unit {other:?}"), + }; + assert_eq!(expr, Expr::Literal(expected, None)); + } + + /// DataFusion has no consumer branch for the deprecated `timestamp_tz`, so it failed the + /// filter outright rather than answering wrongly. + #[tokio::test] + async fn test_deprecated_timestamp_tz_literal() { + const MICROS: i64 = 1_704_247_200_000_000; + + #[allow(deprecated)] + let expr = parse_unpruned_expr(RexType::Literal(Literal { + nullable: false, + type_variation_reference: 0, + literal_type: Some(LiteralType::TimestampTz(MICROS)), + })) + .await + .unwrap(); + + assert_eq!( + expr, + Expr::Literal( + ScalarValue::TimestampMicrosecond(Some(MICROS), Some("UTC".into())), + None + ) + ); + } + + /// Optional message fields that a producer may legitimately omit must not panic the walker. + #[tokio::test] + async fn test_unpruned_if_then_with_omitted_optional_fields() { + let condition = Expression { + rex_type: Some(RexType::Literal(Literal { + nullable: false, + type_variation_reference: 0, + literal_type: Some(LiteralType::Boolean(true)), + })), + }; + + // A leading clause without `then` supplies the case expression, and `else` is optional. + let expr = parse_unpruned_expr(RexType::IfThen(Box::new(IfThen { + ifs: vec![IfClause { + r#if: Some(condition), + then: None, + }], + r#else: None, + }))) + .await + .unwrap(); + + assert_eq!( + expr, + Expr::Case(Case { + expr: Some(Box::new(Expr::Literal( + ScalarValue::Boolean(Some(true)), + None + ))), + when_then_expr: vec![], + else_expr: None, + }) + ); + } + + /// DataFusion only tolerates an omitted `then` on the leading clause, so a later clause + /// missing one has to be rejected here rather than reaching the consumer. + #[tokio::test] + async fn test_unpruned_if_then_missing_nonleading_then() { + let condition = || Expression { + rex_type: Some(RexType::Literal(Literal { + nullable: false, + type_variation_reference: 0, + literal_type: Some(LiteralType::Boolean(true)), + })), + }; + + let err = parse_unpruned_expr(RexType::IfThen(Box::new(IfThen { + ifs: vec![ + IfClause { + r#if: Some(condition()), + then: Some(condition()), + }, + IfClause { + r#if: Some(condition()), + then: None, + }, + ], + r#else: None, + }))) + .await + .expect_err("a non-leading clause without `then` is not valid"); + + assert!( + err.to_string().contains("if clause result"), + "unexpected error: {err}" + ); + } + #[tokio::test] async fn test_expr_substrait_roundtrip() { let schema = arrow_schema::Schema::new(vec![Field::new("x", DataType::Int32, true)]); @@ -535,6 +1138,93 @@ mod tests { assert_substrait_roundtrip(schema, id_filter("test-id")).await; } + #[tokio::test] + async fn test_parse_substrait_with_pyarrow_list_struct_names() { + let schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + list_of_struct( + "items", + vec![ + Field::new("value", DataType::Float32, true), + Field::new("label", DataType::Utf8, true), + ], + ), + Field::new("checkpoint", DataType::Int64, true), + ])); + let expr = Expr::BinaryExpr(BinaryExpr { + left: Box::new(Expr::Column(Column::new_unqualified("checkpoint"))), + op: Operator::Eq, + right: Box::new(Expr::Literal(ScalarValue::Int64(Some(0)), None)), + }); + + let bytes = encode_substrait(expr.clone(), schema.clone(), &session_state()).unwrap(); + let mut envelope = ExtendedExpression::decode(bytes.as_slice()).unwrap(); + let base_schema = envelope.base_schema.as_mut().unwrap(); + assert_eq!( + base_schema.names, + ["id", "items", "value", "label", "checkpoint"] + ); + + // PyArrow omits names nested beneath list element types. + base_schema.names = ["id", "items", "checkpoint"] + .map(ToString::to_string) + .to_vec(); + let bytes = envelope.encode_to_vec(); + + let decoded = parse_substrait(bytes.as_slice(), schema, &session_state()) + .await + .unwrap(); + assert_eq!(decoded, expr); + } + + #[tokio::test] + async fn test_pyarrow_shallow_names_with_placeholder_before_filter() { + let list_field = list_of_struct( + "items", + vec![ + Field::new("value", DataType::Float32, true), + Field::new("label", DataType::Utf8, true), + ], + ); + let placeholder = "__unlikely_name_placeholder_0"; + let serialized_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + list_field.clone(), + Field::new(placeholder, DataType::Int8, true), + Field::new("checkpoint", DataType::Int64, true), + ])); + let input_schema = Arc::new(Schema::new(vec![ + Field::new("id", DataType::Int64, false), + list_field, + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 4), + true, + ), + Field::new("checkpoint", DataType::Int64, true), + ])); + let expr = Expr::BinaryExpr(BinaryExpr { + left: Box::new(Expr::Column(Column::new_unqualified("checkpoint"))), + op: Operator::Eq, + right: Box::new(Expr::Literal(ScalarValue::Int64(Some(0)), None)), + }); + + let bytes = encode_substrait(expr.clone(), serialized_schema, &session_state()).unwrap(); + let mut envelope = ExtendedExpression::decode(bytes.as_slice()).unwrap(); + envelope.base_schema.as_mut().unwrap().names = ["id", "items", placeholder, "checkpoint"] + .map(ToString::to_string) + .to_vec(); + + let decoded = parse_substrait( + envelope.encode_to_vec().as_slice(), + input_schema, + &session_state(), + ) + .await + .unwrap(); + assert_eq!(decoded, expr); + } + #[tokio::test] async fn test_substrait_roundtrip_with_list_struct_struct() { let schema = Schema::new(vec![ @@ -607,4 +1297,351 @@ mod tests { assert_substrait_roundtrip(schema, id_filter("test-id")).await; } + + #[tokio::test] + async fn test_substrait_roundtrip_with_null_and_float16_columns() { + // Float16 and Null are encoded as UserDefined types in Substrait. + // The decode side (remove_extension_types) strips them and remaps + // field references, so filters on other columns still work. + let schema = Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new("embedding", DataType::Float16, true), + Field::new("empty", DataType::Null, true), + Field::new("name", DataType::Utf8, true), + ]); + + assert_substrait_roundtrip(schema, id_filter("test-id")).await; + } + + #[tokio::test] + async fn test_substrait_roundtrip_with_fixed_size_list_column() { + // FixedSizeList has no Substrait producer support, so it must be + // pruned from the schema before encoding. Verify that a schema with + // FSL columns works when the filter references a different column. + use crate::substrait::prune_schema_for_substrait; + + let schema = Schema::new(vec![ + Field::new("id", DataType::Utf8, false), + Field::new( + "vector", + DataType::FixedSizeList(Arc::new(Field::new("item", DataType::Float32, true)), 128), + true, + ), + Field::new("name", DataType::Utf8, true), + ]); + + // Encoding with the full schema would fail, but pruning removes the FSL column + let pruned = prune_schema_for_substrait(&schema); + assert_eq!(pruned.fields().len(), 2); // id and name only + assert_substrait_roundtrip(pruned, id_filter("test-id")).await; + } + + // ==================== Aggregate parsing tests ==================== + + use datafusion_substrait::substrait::proto::{ + AggregateFunction, AggregateRel, Plan, PlanRel, Rel, RelRoot, + aggregate_function::AggregationInvocation, + aggregate_rel::{Grouping, Measure}, + rel::RelType, + }; + + /// Helper to create a field reference expression for a column index + fn agg_field_ref(field_index: i32) -> Expression { + Expression { + rex_type: Some(RexType::Selection(Box::new(FieldReference { + reference_type: Some(ReferenceType::DirectReference(ReferenceSegment { + reference_type: Some(reference_segment::ReferenceType::StructField(Box::new( + StructField { + field: field_index, + child: None, + }, + ))), + })), + root_type: Some(RootType::RootReference(RootReference {})), + }))), + } + } + + /// Create extension declaration for an aggregate function + fn agg_extension(anchor: u32, name: &str) -> SimpleExtensionDeclaration { + SimpleExtensionDeclaration { + mapping_type: Some(MappingType::ExtensionFunction(ExtensionFunction { + extension_urn_reference: 1, + function_anchor: anchor, + name: name.to_string(), + })), + } + } + + /// Helper to create a Substrait Plan with AggregateRel + fn create_aggregate_plan( + measures: Vec, + grouping_expressions: Vec, + groupings: Vec, + extensions: Vec, + ) -> Vec { + let aggregate_rel = AggregateRel { + common: None, + input: None, // Input is ignored for pushdown + groupings, + measures, + grouping_expressions, + advanced_extension: None, + }; + + let rel = Rel { + rel_type: Some(RelType::Aggregate(Box::new(aggregate_rel))), + }; + + // Wrap in a Plan to include extensions + let plan = Plan { + version: Some(Version { + major_number: 0, + minor_number: 63, + patch_number: 0, + git_hash: String::new(), + producer: "lance-test".to_string(), + }), + extension_urns: vec![SimpleExtensionUrn { + extension_urn_anchor: 1, + urn: "https://github.com/substrait-io/substrait/blob/main/extensions/functions_aggregate_generic.yaml".to_string(), + }], + extensions, + relations: vec![PlanRel { + rel_type: Some( + datafusion_substrait::substrait::proto::plan_rel::RelType::Root(RelRoot { + input: Some(rel), + names: vec![], + }), + ), + }], + advanced_extensions: None, + expected_type_urls: vec![], + parameter_bindings: vec![], + type_aliases: vec![], + }; + + plan.encode_to_vec() + } + + /// Create a COUNT(*) measure + fn count_star_measure(function_ref: u32) -> Measure { + Measure { + measure: Some(AggregateFunction { + function_reference: function_ref, + arguments: vec![], + options: vec![], + output_type: None, + phase: 0, + sorts: vec![], + invocation: AggregationInvocation::All as i32, + #[allow(deprecated)] + args: vec![], + }), + filter: None, + } + } + + /// Create a SUM/AVG/MIN/MAX measure on a column + fn simple_agg_measure(function_ref: u32, column_index: i32) -> Measure { + Measure { + measure: Some(AggregateFunction { + function_reference: function_ref, + arguments: vec![FunctionArgument { + arg_type: Some(ArgType::Value(agg_field_ref(column_index))), + }], + options: vec![], + output_type: None, + phase: 0, + sorts: vec![], + invocation: AggregationInvocation::All as i32, + #[allow(deprecated)] + args: vec![], + }), + filter: None, + } + } + + #[tokio::test] + async fn test_parse_substrait_aggregate_count_star() { + let bytes = create_aggregate_plan( + vec![count_star_measure(0)], + vec![], + vec![], + vec![agg_extension(0, "count")], + ); + + let schema = Arc::new(Schema::new(vec![ + Field::new("x", DataType::Int32, true), + Field::new("y", DataType::Int64, true), + ])); + + let result = + crate::substrait::parse_substrait_aggregate(&bytes, schema, &session_state()).await; + + let agg = result.expect("Failed to parse COUNT(*) aggregate"); + assert!(agg.group_by.is_empty(), "COUNT(*) should have no group by"); + assert_eq!(agg.aggregates.len(), 1, "Should have exactly one aggregate"); + + // Verify it's a COUNT aggregate + let agg_expr = &agg.aggregates[0]; + assert!( + agg_expr.schema_name().to_string().contains("count"), + "Expected COUNT aggregate, got: {}", + agg_expr.schema_name() + ); + } + + #[tokio::test] + async fn test_parse_substrait_aggregate_sum() { + let bytes = create_aggregate_plan( + vec![simple_agg_measure(0, 1)], // SUM on column index 1 (y) + vec![], + vec![], + vec![agg_extension(0, "sum")], + ); + + let schema = Arc::new(Schema::new(vec![ + Field::new("x", DataType::Int32, true), + Field::new("y", DataType::Int64, true), + ])); + + let result = + crate::substrait::parse_substrait_aggregate(&bytes, schema, &session_state()).await; + + let agg = result.expect("Failed to parse SUM aggregate"); + assert!(agg.group_by.is_empty(), "SUM should have no group by"); + assert_eq!(agg.aggregates.len(), 1, "Should have exactly one aggregate"); + + // Verify it's a SUM aggregate + let agg_expr = &agg.aggregates[0]; + assert!( + agg_expr.schema_name().to_string().contains("sum"), + "Expected SUM aggregate, got: {}", + agg_expr.schema_name() + ); + } + + #[tokio::test] + async fn test_parse_substrait_aggregate_sum_with_group_by() { + // SUM(y) GROUP BY x + let bytes = create_aggregate_plan( + vec![simple_agg_measure(0, 1)], // SUM on column index 1 (y) + vec![agg_field_ref(0)], // Group by column index 0 (x) + vec![Grouping { + #[allow(deprecated)] + grouping_expressions: vec![], + expression_references: vec![0], // Reference to first grouping_expression + }], + vec![agg_extension(0, "sum")], + ); + + let schema = Arc::new(Schema::new(vec![ + Field::new("x", DataType::Int32, true), + Field::new("y", DataType::Int64, true), + ])); + + let result = + crate::substrait::parse_substrait_aggregate(&bytes, schema, &session_state()).await; + + let agg = result.expect("Failed to parse SUM with GROUP BY"); + assert_eq!( + agg.group_by.len(), + 1, + "Should have exactly one group by expression" + ); + assert_eq!(agg.aggregates.len(), 1, "Should have exactly one aggregate"); + + // Verify group by is column x + let group_expr = &agg.group_by[0]; + assert!( + group_expr.schema_name().to_string().contains('x'), + "Expected group by on column x, got: {}", + group_expr.schema_name() + ); + + // Verify it's a SUM aggregate + let agg_expr = &agg.aggregates[0]; + assert!( + agg_expr.schema_name().to_string().contains("sum"), + "Expected SUM aggregate, got: {}", + agg_expr.schema_name() + ); + } + + #[tokio::test] + async fn test_parse_substrait_aggregate_multiple_aggregates() { + // COUNT(*) and SUM(y) + let bytes = create_aggregate_plan( + vec![count_star_measure(0), simple_agg_measure(1, 1)], + vec![], + vec![], + vec![agg_extension(0, "count"), agg_extension(1, "sum")], + ); + + let schema = Arc::new(Schema::new(vec![ + Field::new("x", DataType::Int32, true), + Field::new("y", DataType::Int64, true), + ])); + + let result = + crate::substrait::parse_substrait_aggregate(&bytes, schema, &session_state()).await; + + let agg = result.expect("Failed to parse multiple aggregates"); + assert!(agg.group_by.is_empty(), "Should have no group by"); + assert_eq!(agg.aggregates.len(), 2, "Should have two aggregates"); + + // Verify COUNT + assert!( + agg.aggregates[0] + .schema_name() + .to_string() + .contains("count"), + "Expected COUNT aggregate, got: {}", + agg.aggregates[0].schema_name() + ); + + // Verify SUM + assert!( + agg.aggregates[1].schema_name().to_string().contains("sum"), + "Expected SUM aggregate, got: {}", + 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-datafusion/src/udf.rs b/rust/lance-datafusion/src/udf.rs index 9117b67f82e..fc43de4a216 100644 --- a/rust/lance-datafusion/src/udf.rs +++ b/rust/lance-datafusion/src/udf.rs @@ -5,7 +5,7 @@ use arrow_array::{Array, ArrayRef, BooleanArray, StringArray}; use arrow_schema::DataType; -use datafusion::logical_expr::{create_udf, ScalarUDF, Volatility}; +use datafusion::logical_expr::{ScalarUDF, Volatility, create_udf}; use datafusion::prelude::SessionContext; use datafusion_functions::utils::make_scalar_function; use std::sync::{Arc, LazyLock}; @@ -27,7 +27,52 @@ pub fn register_functions(ctx: &SessionContext) { ctx.register_udf(json::json_array_contains_udf()); ctx.register_udf(json::json_array_length_udf()); // GEO functions + #[cfg(feature = "geo")] lance_geo::register_functions(ctx); + #[cfg(not(feature = "geo"))] + register_geo_stub_functions(ctx); +} + +/// When the `geo` feature is disabled, register stub UDFs for spatial SQL functions +/// so that users get a clear error mentioning the feature flag instead of +/// DataFusion's generic "Unknown function" error. +#[cfg(not(feature = "geo"))] +fn register_geo_stub_functions(ctx: &SessionContext) { + let geo_funcs = [ + "st_intersects", + "st_contains", + "st_within", + "st_touches", + "st_crosses", + "st_overlaps", + "st_covers", + "st_coveredby", + "st_distance", + "st_area", + "st_length", + ]; + + for name in geo_funcs { + let func_name = name.to_string(); + let stub = Arc::new(make_scalar_function( + move |_args: &[ArrayRef]| { + Err(datafusion::error::DataFusionError::Plan(format!( + "Function '{}' requires the `geo` feature. \ + Rebuild with `--features geo` to enable geospatial functions.", + func_name + ))) + }, + vec![], + )); + + ctx.register_udf(create_udf( + name, + vec![DataType::Binary, DataType::Binary], + DataType::Boolean, + Volatility::Immutable, + stub, + )); + } } /// This method checks whether a string contains all specified tokens. The tokens are separated by diff --git a/rust/lance-datafusion/src/udf/json.rs b/rust/lance-datafusion/src/udf/json.rs index fb91b53a2d7..33b88d26ea2 100644 --- a/rust/lance-datafusion/src/udf/json.rs +++ b/rust/lance-datafusion/src/udf/json.rs @@ -10,6 +10,7 @@ use datafusion::error::{DataFusionError, Result}; use datafusion::logical_expr::{ScalarUDF, Volatility}; use datafusion::physical_plan::ColumnarValue; use datafusion::prelude::create_udf; +use lance_arrow::json::{json_path_exists, select_json_path, select_json_path_values}; use std::sync::Arc; /// Represents the type of a JSONB value @@ -50,24 +51,6 @@ impl JsonbType { mod common { use super::*; - /// Key type for JSON field access - optimizes field/index parsing - #[derive(Debug, Clone)] - pub enum KeyType { - Field(String), - Index(usize), - } - - impl KeyType { - /// Parse a key string into either a field name or array index (once per operation) - pub fn parse(key: &str) -> Self { - if let Ok(index) = key.parse::() { - Self::Index(index) - } else { - Self::Field(key.to_string()) - } - } - } - /// Convert ColumnarValue arguments to ArrayRef vector /// /// Note: This implementation currently broadcasts scalars to arrays. @@ -132,25 +115,30 @@ mod common { } } - /// Get JSON field/element using pre-parsed key type (avoids repeated parsing) + /// Get a JSON field or array element by key. pub fn get_json_value_by_key( raw_jsonb: &jsonb::RawJsonb, - key_type: &KeyType, + key: &str, ) -> Result> { - match key_type { - KeyType::Field(field) => raw_jsonb - .get_by_name(field, false) - .map_err(|e| execution_error(format!("Failed to get field '{}': {}", field, e))), - KeyType::Index(index) => raw_jsonb.get_by_index(*index).map_err(|e| { - execution_error(format!("Failed to get array element [{}]: {}", index, e)) - }), + if raw_jsonb.is_object().unwrap_or(false) { + raw_jsonb + .get_by_name(key, false) + .map_err(|e| execution_error(format!("Failed to get field '{}': {}", key, e))) + } else if raw_jsonb.is_array().unwrap_or(false) { + match key.parse::() { + Ok(index) => raw_jsonb.get_by_index(index).map_err(|e| { + execution_error(format!("Failed to get array element [{}]: {}", index, e)) + }), + Err(_) => Ok(None), + } + } else { + Ok(None) } } /// Parse JSONPath with proper error handling (no false returns) pub fn parse_json_path(path: &str) -> Result> { - jsonb::jsonpath::parse_json_path(path.as_bytes()) - .map_err(|e| execution_error(format!("Invalid JSONPath '{}': {}", path, e))) + lance_arrow::json::parse_json_path(path).map_err(|e| execution_error(e.to_string())) } } @@ -316,8 +304,8 @@ fn json_extract_impl(args: &[ArrayRef]) -> Result { /// Implementation of json_extract_with_type function fn json_extract_with_type_impl(args: &[ArrayRef]) -> Result { - use arrow_array::builder::{LargeBinaryBuilder, UInt8Builder}; use arrow_array::StructArray; + use arrow_array::builder::{LargeBinaryBuilder, UInt8Builder}; common::validate_arg_count(args, 2, "json_extract_with_type")?; @@ -376,45 +364,39 @@ fn json_extract_with_type_impl(args: &[ArrayRef]) -> Result { fn extract_json_path_with_type(jsonb_bytes: &[u8], path: &str) -> Result, u8)>> { let json_path = common::parse_json_path(path)?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); - match selector.select_values(&json_path) { - Ok(values) => { - if values.is_empty() { - Ok(None) - } else { - // Get the first matched value - let owned_value = &values[0]; - let raw = owned_value.as_raw(); - - // Determine type using JsonbType enum - let jsonb_type = if raw.is_null().unwrap_or(false) { - JsonbType::Null - } else if raw.is_boolean().unwrap_or(false) { - JsonbType::Boolean - } else if raw.is_number().unwrap_or(false) { - // Try to determine if it's an integer or float - if raw.to_i64().is_ok() { - JsonbType::Int64 - } else { - JsonbType::Float64 - } - } else if raw.is_string().unwrap_or(false) { - JsonbType::String - } else if raw.is_array().unwrap_or(false) { - JsonbType::Array - } else if raw.is_object().unwrap_or(false) { - JsonbType::Object + match select_json_path(jsonb_bytes, &json_path) { + Ok(Some(owned_value)) => { + let raw = owned_value.as_raw(); + + // Determine type using JsonbType enum + let jsonb_type = if raw.is_null().unwrap_or(false) { + JsonbType::Null + } else if raw.is_boolean().unwrap_or(false) { + JsonbType::Boolean + } else if raw.is_number().unwrap_or(false) { + let is_float_storage = + matches!(raw.as_number(), Ok(Some(jsonb::Number::Float64(_)))); + if !is_float_storage && raw.is_i64().unwrap_or(false) { + JsonbType::Int64 } else { - JsonbType::String // default to string - }; + JsonbType::Float64 + } + } else if raw.is_string().unwrap_or(false) { + JsonbType::String + } else if raw.is_array().unwrap_or(false) { + JsonbType::Array + } else if raw.is_object().unwrap_or(false) { + JsonbType::Object + } else { + JsonbType::String // default to string + }; - // Return the JSONB bytes and type tag as u8 - Ok(Some((owned_value.clone().to_vec(), jsonb_type.as_u8()))) - } + // Return the JSONB bytes and type tag as u8 + Ok(Some((owned_value.to_vec(), jsonb_type.as_u8()))) } + Ok(None) => Ok(None), Err(e) => Err(common::execution_error(format!( - "Failed to select values from path '{}': {}", + "Failed to select value from path '{}': {}", path, e ))), } @@ -422,23 +404,15 @@ fn extract_json_path_with_type(jsonb_bytes: &[u8], path: &str) -> Result Result> { let json_path = common::parse_json_path(path)?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); - match selector.select_values(&json_path) { - Ok(values) => { - if values.is_empty() { - Ok(None) - } else { - // Return the first matched value - Ok(Some(values[0].to_string())) - } - } + match select_json_path(jsonb_bytes, &json_path) { + Ok(value) => Ok(value.map(|value| value.to_string())), Err(e) => Err(common::execution_error(format!( - "Failed to select values from path '{}': {}", + "Failed to select value from path '{}': {}", path, e ))), } @@ -497,9 +471,7 @@ fn json_exists_impl(args: &[ArrayRef]) -> Result { fn check_json_path_exists(jsonb_bytes: &[u8], path: &str) -> Result { let json_path = common::parse_json_path(path)?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); - match selector.exists(&json_path) { + match json_path_exists(jsonb_bytes, &json_path) { Ok(exists) => Ok(exists), Err(e) => Err(common::execution_error(format!( "Failed to check existence of path '{}': {}", @@ -547,10 +519,9 @@ fn json_get_impl(args: &[ArrayRef]) -> Result { builder.append_null(); } else if let Some(key) = common::get_string_value_at(key_array, i) { let jsonb_bytes = jsonb_array.value(i); - let key_type = common::KeyType::parse(key); let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - match common::get_json_value_by_key(&raw_jsonb, &key_type)? { + match common::get_json_value_by_key(&raw_jsonb, key)? { Some(value) => builder.append_value(value.as_raw().as_ref()), None => builder.append_null(), } @@ -601,10 +572,9 @@ fn json_get_string_impl(args: &[ArrayRef]) -> Result { builder.append_null(); } else if let Some(key) = common::get_string_value_at(key_array, i) { let jsonb_bytes = jsonb_array.value(i); - let key_type = common::KeyType::parse(key); let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - match common::get_json_value_by_key(&raw_jsonb, &key_type)? { + match common::get_json_value_by_key(&raw_jsonb, key)? { Some(value) => match json_value_to_string(value)? { Some(string_val) => builder.append_value(&string_val), None => builder.append_null(), @@ -658,10 +628,9 @@ fn json_get_int_impl(args: &[ArrayRef]) -> Result { builder.append_null(); } else if let Some(key) = common::get_string_value_at(key_array, i) { let jsonb_bytes = jsonb_array.value(i); - let key_type = common::KeyType::parse(key); let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - match common::get_json_value_by_key(&raw_jsonb, &key_type)? { + match common::get_json_value_by_key(&raw_jsonb, key)? { Some(value) => match json_value_to_int(value)? { Some(int_val) => builder.append_value(int_val), None => builder.append_null(), @@ -715,10 +684,9 @@ fn json_get_float_impl(args: &[ArrayRef]) -> Result { builder.append_null(); } else if let Some(key) = common::get_string_value_at(key_array, i) { let jsonb_bytes = jsonb_array.value(i); - let key_type = common::KeyType::parse(key); let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - match common::get_json_value_by_key(&raw_jsonb, &key_type)? { + match common::get_json_value_by_key(&raw_jsonb, key)? { Some(value) => match json_value_to_float(value)? { Some(float_val) => builder.append_value(float_val), None => builder.append_null(), @@ -772,10 +740,9 @@ fn json_get_bool_impl(args: &[ArrayRef]) -> Result { builder.append_null(); } else if let Some(key) = common::get_string_value_at(key_array, i) { let jsonb_bytes = jsonb_array.value(i); - let key_type = common::KeyType::parse(key); let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - match common::get_json_value_by_key(&raw_jsonb, &key_type)? { + match common::get_json_value_by_key(&raw_jsonb, key)? { Some(value) => match json_value_to_bool(value)? { Some(bool_val) => builder.append_value(bool_val), None => builder.append_null(), @@ -851,9 +818,7 @@ fn json_array_contains_impl(args: &[ArrayRef]) -> Result { fn check_array_contains(jsonb_bytes: &[u8], path: &str, value: &str) -> Result { let json_path = common::parse_json_path(path)?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); - match selector.select_values(&json_path) { + match select_json_path_values(jsonb_bytes, &json_path) { Ok(values) => { for v in values { // Convert to raw JSONB for direct access @@ -939,9 +904,7 @@ fn json_array_length_impl(args: &[ArrayRef]) -> Result { fn get_array_length(jsonb_bytes: &[u8], path: &str) -> Result> { let json_path = common::parse_json_path(path)?; - let raw_jsonb = jsonb::RawJsonb::new(jsonb_bytes); - let mut selector = jsonb::jsonpath::Selector::new(raw_jsonb); - match selector.select_values(&json_path) { + match select_json_path_values(jsonb_bytes, &json_path) { Ok(values) => { if values.is_empty() { return Ok(None); @@ -980,7 +943,7 @@ fn get_array_length(jsonb_bytes: &[u8], path: &str) -> Result> { mod tests { use super::*; use arrow_array::builder::LargeBinaryBuilder; - use arrow_array::{BooleanArray, Int64Array}; + use arrow_array::{BooleanArray, Float64Array, Int64Array}; fn create_test_jsonb(json_str: &str) -> Vec { jsonb::parse_value(json_str.as_bytes()).unwrap().to_vec() @@ -1010,28 +973,31 @@ mod tests { #[tokio::test] async fn test_json_extract_udf() -> Result<()> { - let json = r#"{"user": {"name": "Alice", "age": 30}}"#; + let json = r#"{"user": {"name": "Alice", "age": 30}, "tags": ["python", "ml"]}"#; let jsonb_bytes = create_test_jsonb(json); let mut binary_builder = LargeBinaryBuilder::new(); binary_builder.append_value(&jsonb_bytes); binary_builder.append_value(&jsonb_bytes); + binary_builder.append_value(&jsonb_bytes); binary_builder.append_null(); let jsonb_array = Arc::new(binary_builder.finish()); let path_array = Arc::new(StringArray::from(vec![ Some("$.user.name"), Some("$.user.age"), + Some("$.tags[*]"), Some("$.user.name"), ])); let result = json_extract_impl(&[jsonb_array, path_array])?; let string_array = result.as_any().downcast_ref::().unwrap(); - assert_eq!(string_array.len(), 3); + assert_eq!(string_array.len(), 4); assert_eq!(string_array.value(0), "\"Alice\""); assert_eq!(string_array.value(1), "30"); - assert!(string_array.is_null(2)); + assert_eq!(string_array.value(2), "[\"python\",\"ml\"]"); + assert!(string_array.is_null(3)); Ok(()) } @@ -1101,28 +1067,85 @@ mod tests { #[tokio::test] async fn test_json_get_int_udf() -> Result<()> { - let json = r#"{"int": 42, "str_num": "99", "bool": true}"#; + let json = r#"{"int": 42, "str_num": "99", "bool": true, "0": 7}"#; let jsonb_bytes = create_test_jsonb(json); let mut binary_builder = LargeBinaryBuilder::new(); - binary_builder.append_value(&jsonb_bytes); - binary_builder.append_value(&jsonb_bytes); - binary_builder.append_value(&jsonb_bytes); + for _ in 0..4 { + binary_builder.append_value(&jsonb_bytes); + } let jsonb_array = Arc::new(binary_builder.finish()); let key_array = Arc::new(StringArray::from(vec![ Some("int"), Some("str_num"), Some("bool"), + Some("0"), ])); let result = json_get_int_impl(&[jsonb_array, key_array])?; let int_array = result.as_any().downcast_ref::().unwrap(); - assert_eq!(int_array.len(), 3); + assert_eq!(int_array.len(), 4); assert_eq!(int_array.value(0), 42); assert_eq!(int_array.value(1), 99); assert_eq!(int_array.value(2), 1); // jsonb converts true to 1 + assert_eq!(int_array.value(3), 7); + + Ok(()) + } + + #[tokio::test] + async fn test_json_get_float_udf() -> Result<()> { + let json = r#"{ + "float_decimal": 1.5, + "float_neg": -2.5, + "float_int_value": 1.0, + "float_exp": 1e2, + "int_pos": 42, + "int_neg": -7, + "big_int": 9223372036854775808, + "str_num": "3.5", + "bool_true": true, + "null_val": null + }"#; + let jsonb_bytes = create_test_jsonb(json); + + let mut binary_builder = LargeBinaryBuilder::new(); + for _ in 0..11 { + binary_builder.append_value(&jsonb_bytes); + } + let jsonb_array = Arc::new(binary_builder.finish()); + let key_array = Arc::new(StringArray::from(vec![ + Some("float_decimal"), + Some("float_neg"), + Some("float_int_value"), + Some("float_exp"), + Some("int_pos"), + Some("int_neg"), + Some("big_int"), + Some("str_num"), + Some("bool_true"), + Some("null_val"), + Some("missing"), + ])); + + let result = json_get_float_impl(&[jsonb_array, key_array])?; + let float_array = result.as_any().downcast_ref::().unwrap(); + + assert_eq!(float_array.len(), 11); + assert_eq!(float_array.value(0), 1.5); + assert_eq!(float_array.value(1), -2.5); + assert_eq!(float_array.value(2), 1.0); + assert_eq!(float_array.value(3), 100.0); + assert_eq!(float_array.value(4), 42.0); + assert_eq!(float_array.value(5), -7.0); + // 2^63 is exactly representable in f64. + assert_eq!(float_array.value(6), 9223372036854775808.0); + assert_eq!(float_array.value(7), 3.5); + assert_eq!(float_array.value(8), 1.0); // jsonb converts true to 1.0 + assert!(float_array.is_null(9)); + assert!(float_array.is_null(10)); Ok(()) } @@ -1227,6 +1250,80 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_json_extract_with_type() -> Result<()> { + use arrow_array::StructArray; + use arrow_array::UInt8Array; + + let cases: &[(&str, JsonbType)] = &[ + (r#"{"v": 1}"#, JsonbType::Int64), + (r#"{"v": 0}"#, JsonbType::Int64), + (r#"{"v": -42}"#, JsonbType::Int64), + (r#"{"v": 9223372036854775807}"#, JsonbType::Int64), // i64::MAX + (r#"{"v": 9223372036854775808}"#, JsonbType::Float64), // i64::MAX + 1 + (r#"{"v": 1.0}"#, JsonbType::Float64), + (r#"{"v": 2.7}"#, JsonbType::Float64), + (r#"{"v": 1.5}"#, JsonbType::Float64), + (r#"{"v": -1.5}"#, JsonbType::Float64), + (r#"{"v": 1e2}"#, JsonbType::Float64), + ]; + + for (json, expected) in cases { + let bytes = create_test_jsonb(json); + let mut binary_builder = LargeBinaryBuilder::new(); + binary_builder.append_value(&bytes); + let jsonb_array: ArrayRef = Arc::new(binary_builder.finish()); + let path_array: ArrayRef = Arc::new(StringArray::from(vec![Some("$.v")])); + + let result = json_extract_with_type_impl(&[jsonb_array, path_array])?; + let struct_array = result.as_any().downcast_ref::().unwrap(); + let type_tags = struct_array + .column_by_name("type_tag") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + assert_eq!(type_tags.value(0), expected.as_u8()); + } + + Ok(()) + } + + #[tokio::test] + async fn test_json_extract_with_wildcard() -> Result<()> { + use arrow_array::StructArray; + use arrow_array::UInt8Array; + + let json = r#"{"items": [{"price": 1}, {"price": 2}]}"#; + let jsonb_bytes = create_test_jsonb(json); + + let mut binary_builder = LargeBinaryBuilder::new(); + binary_builder.append_value(&jsonb_bytes); + + let jsonb_array: ArrayRef = Arc::new(binary_builder.finish()); + let path_array: ArrayRef = Arc::new(StringArray::from(vec![Some("$.items[*].price")])); + + let result = json_extract_with_type_impl(&[jsonb_array, path_array])?; + let struct_array = result.as_any().downcast_ref::().unwrap(); + let values = struct_array + .column_by_name("value") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + let type_tags = struct_array + .column_by_name("type_tag") + .unwrap() + .as_any() + .downcast_ref::() + .unwrap(); + + assert_eq!(jsonb::RawJsonb::new(values.value(0)).to_string(), "[1,2]"); + assert_eq!(type_tags.value(0), JsonbType::Array.as_u8()); + + Ok(()) + } + #[tokio::test] async fn test_json_array_access() -> Result<()> { let json = r#"["first", "second", "third"]"#; @@ -1254,4 +1351,32 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn test_json_get_numeric_object_key() -> Result<()> { + let obj_bytes = create_test_jsonb(r#"{"0": "from_object", "1": 42}"#); + let arr_bytes = create_test_jsonb(r#"["zero", "one", "two"]"#); + + let mut binary_builder = LargeBinaryBuilder::new(); + binary_builder.append_value(&obj_bytes); + binary_builder.append_value(&arr_bytes); + binary_builder.append_value(&arr_bytes); + + let jsonb_array = Arc::new(binary_builder.finish()); + let key_array = Arc::new(StringArray::from(vec![ + Some("0"), // Numeric key on object: looks up the "0" field. + Some("0"), // Numeric key on array: looks up index 0. + Some("foo"), // Non-numeric key on array: no match. + ])); + + let result = json_get_string_impl(&[jsonb_array, key_array])?; + let string_array = result.as_any().downcast_ref::().unwrap(); + + assert_eq!(string_array.len(), 3); + assert_eq!(string_array.value(0), "from_object"); + assert_eq!(string_array.value(1), "zero"); + assert!(string_array.is_null(2)); + + Ok(()) + } } diff --git a/rust/lance-datafusion/src/utils.rs b/rust/lance-datafusion/src/utils.rs index 39d693bace8..e660c8ee44d 100644 --- a/rust/lance-datafusion/src/utils.rs +++ b/rust/lance-datafusion/src/utils.rs @@ -11,17 +11,17 @@ use background_iterator::BackgroundIterator; use datafusion::{ execution::RecordBatchStream, physical_plan::{ + SendableRecordBatchStream, metrics::{ Count, ExecutionPlanMetricsSet, Gauge, MetricBuilder, MetricValue, MetricsSet, Time, }, stream::RecordBatchStreamAdapter, - SendableRecordBatchStream, }, }; use datafusion_common::DataFusionError; -use futures::{stream, StreamExt, TryStreamExt}; -use lance_core::datatypes::Schema; +use futures::{StreamExt, TryStreamExt, stream}; use lance_core::Result; +use lance_core::datatypes::Schema; use tokio::task::spawn; pub mod background_iterator; @@ -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)), @@ -236,11 +244,14 @@ pub const INDICES_LOADED_METRIC: &str = "indices_loaded"; pub const PARTS_LOADED_METRIC: &str = "parts_loaded"; pub const PARTITIONS_RANKED_METRIC: &str = "partitions_ranked"; pub const INDEX_COMPARISONS_METRIC: &str = "index_comparisons"; +pub const INDEX_CACHE_HITS_METRIC: &str = "index_cache_hits"; +pub const INDEX_CACHE_MISSES_METRIC: &str = "index_cache_misses"; pub const FRAGMENTS_SCANNED_METRIC: &str = "fragments_scanned"; pub const RANGES_SCANNED_METRIC: &str = "ranges_scanned"; 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"; +pub const SCALAR_INDEX_SER_TIME_METRIC: &str = "serialization_time"; diff --git a/rust/lance-datafusion/src/utils/background_iterator.rs b/rust/lance-datafusion/src/utils/background_iterator.rs index d9f0458718e..27a8fdc15f2 100644 --- a/rust/lance-datafusion/src/utils/background_iterator.rs +++ b/rust/lance-datafusion/src/utils/background_iterator.rs @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use futures::ready; use futures::Stream; +use futures::ready; use std::{ future::Future, panic, diff --git a/rust/lance-datagen/Cargo.toml b/rust/lance-datagen/Cargo.toml index c192485b271..4a57f1f0804 100644 --- a/rust/lance-datagen/Cargo.toml +++ b/rust/lance-datagen/Cargo.toml @@ -17,17 +17,15 @@ arrow-schema = { workspace = true } chrono = { workspace = true } futures = { workspace = true } half = { workspace = true } -hex = "0.4.3" +hex.workspace = true rand = { workspace = true } rand_distr = { workspace = true } rand_xoshiro = { workspace = true } -random_word = { version = "0.5", features = ["en"] } [dev-dependencies] criterion = { workspace = true } - -[target.'cfg(target_os = "linux")'.dev-dependencies] -pprof = { workspace = true } +lance-testing.workspace = true +rstest.workspace = true [lib] bench = false diff --git a/rust/lance-datagen/benches/array_gen.rs b/rust/lance-datagen/benches/array_gen.rs index 337201bf0fe..899b349a996 100644 --- a/rust/lance-datagen/benches/array_gen.rs +++ b/rust/lance-datagen/benches/array_gen.rs @@ -1,18 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow_array::types::{Float32Type, Int16Type, Int32Type, Int64Type, Int8Type}; +use arrow_array::types::{Float32Type, Int8Type, Int16Type, Int32Type, Int64Type}; use criterion::{ - criterion_group, criterion_main, measurement::Measurement, BenchmarkGroup, Criterion, - Throughput, + BenchmarkGroup, Criterion, Throughput, criterion_group, criterion_main, + measurement::Measurement, }; use lance_datagen::{ - generator::ArrayGenerator, ArrayGeneratorExt, BatchCount, ByteCount, Dimension, - RoundingBehavior, + ArrayGeneratorExt, BatchCount, ByteCount, Dimension, RoundingBehavior, + generator::ArrayGenerator, }; #[cfg(target_os = "linux")] -use pprof::criterion::{Output, PProfProfiler}; +use lance_testing::pprof::{Output, PProfProfiler}; const NUM_BATCHES: u32 = 100; const KB_PER_BATCH: u64 = 128; diff --git a/rust/lance-datagen/src/generator.rs b/rust/lance-datagen/src/generator.rs index 068eb17c642..333592747e8 100644 --- a/rust/lance-datagen/src/generator.rs +++ b/rust/lance-datagen/src/generator.rs @@ -5,24 +5,22 @@ use std::{collections::HashMap, iter, marker::PhantomData, sync::Arc, sync::Lazy use arrow::{ array::{ArrayData, AsArray, Float32Builder, GenericBinaryBuilder, GenericStringBuilder}, - buffer::{BooleanBuffer, Buffer, OffsetBuffer, ScalarBuffer}, + buffer::{BooleanBuffer, Buffer, MutableBuffer, OffsetBuffer, ScalarBuffer}, datatypes::{ ArrowPrimitiveType, Float32Type, Int32Type, Int64Type, IntervalDayTime, IntervalMonthDayNano, UInt32Type, }, }; use arrow_array::{ - make_array, - types::{ArrowDictionaryKeyType, BinaryType, ByteArrayType, Utf8Type}, Array, BinaryArray, FixedSizeBinaryArray, FixedSizeListArray, Float32Array, LargeListArray, LargeStringArray, ListArray, MapArray, NullArray, OffsetSizeTrait, PrimitiveArray, RecordBatch, - RecordBatchOptions, RecordBatchReader, StringArray, StructArray, + RecordBatchOptions, RecordBatchReader, StringArray, StructArray, make_array, + types::{ArrowDictionaryKeyType, BinaryType, ByteArrayType, Utf8Type}, }; use arrow_schema::{ArrowError, DataType, Field, Fields, IntervalUnit, Schema, SchemaRef}; -use futures::{stream::BoxStream, StreamExt}; -use rand::{distr::Uniform, Rng, RngCore, SeedableRng}; +use futures::{StreamExt, stream::BoxStream}; +use rand::{Rng, RngCore, SeedableRng, distr::Uniform}; use rand_distr::Zipf; -use random_word; use self::array::rand_with_distribution; @@ -571,7 +569,19 @@ where }; self.leftover_count = ((self.leftover_count as u64 + length.0) % self.repeat as u64) as u32; self.leftover = values.last().copied().unwrap_or(T::default()); - Ok(Arc::new(ArrayType::from(values))) + let array = ArrayType::from(values); + // `ArrayType::from` uses the primitive type's default metadata. For + // timezone-aware timestamps this drops the timezone, so restore the + // generator's declared type when it differs. + if array.data_type() == &self.data_type { + return Ok(Arc::new(array)); + } + let data = array + .into_data() + .into_builder() + .data_type(self.data_type.clone()) + .build()?; + Ok(make_array(data)) } fn data_type(&self) -> &DataType { @@ -816,9 +826,9 @@ impl ArrayGenerator for RandomBytesGenerato rng: &mut rand_xoshiro::Xoshiro256PlusPlus, ) -> Result, ArrowError> { let num_bytes = length.0 * Self::byte_width()?; - let mut bytes = vec![0; num_bytes as usize]; - rng.fill_bytes(&mut bytes); - let bytes = ScalarBuffer::new(Buffer::from(bytes), 0, length.0 as usize); + let mut bytes = MutableBuffer::from_len_zeroed(num_bytes as usize); + rng.fill_bytes(bytes.as_slice_mut()); + let bytes = ScalarBuffer::new(bytes.into(), 0, length.0 as usize); Ok(Arc::new( PrimitiveArray::::new(bytes, None).with_data_type(self.data_type.clone()), )) @@ -1173,24 +1183,223 @@ impl ArrayGenerator for BinaryPrefixPlusCounterGenerator { } } -// Common English stop words placed at the front to be sampled more frequently +// Common English stop words placed at the front to be sampled more frequently. const STOP_WORDS: &[&str] = &[ "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", ]; +const ENGLISH_WORDS: &[&str] = &[ + "ability", + "able", + "about", + "above", + "accept", + "access", + "account", + "across", + "action", + "active", + "activity", + "actual", + "address", + "adjust", + "admin", + "advance", + "agent", + "align", + "allow", + "amount", + "analysis", + "answer", + "application", + "archive", + "array", + "asset", + "async", + "attribute", + "available", + "balance", + "batch", + "binary", + "bitmap", + "block", + "branch", + "buffer", + "build", + "cache", + "capacity", + "catalog", + "change", + "chunk", + "client", + "cluster", + "column", + "commit", + "common", + "compare", + "compile", + "compute", + "condition", + "config", + "connect", + "content", + "context", + "control", + "convert", + "copy", + "core", + "count", + "create", + "current", + "cursor", + "data", + "dataset", + "decode", + "default", + "delete", + "delta", + "depend", + "derive", + "design", + "detail", + "detect", + "device", + "direct", + "display", + "document", + "domain", + "drive", + "dynamic", + "encode", + "engine", + "error", + "event", + "example", + "execute", + "expand", + "expect", + "export", + "extend", + "feature", + "field", + "filter", + "final", + "finish", + "format", + "fragment", + "future", + "generate", + "global", + "group", + "handle", + "header", + "index", + "input", + "insert", + "inspect", + "instance", + "integer", + "internal", + "item", + "join", + "kernel", + "large", + "layer", + "layout", + "length", + "level", + "limit", + "linear", + "local", + "logical", + "lookup", + "manage", + "manifest", + "memory", + "merge", + "metric", + "model", + "module", + "namespace", + "native", + "node", + "normal", + "number", + "object", + "offset", + "option", + "output", + "package", + "page", + "parallel", + "parse", + "partition", + "pattern", + "physical", + "plan", + "policy", + "prefix", + "prepare", + "primary", + "process", + "profile", + "project", + "property", + "query", + "range", + "reader", + "record", + "region", + "registry", + "request", + "resolve", + "resource", + "result", + "return", + "row", + "runtime", + "scalar", + "scan", + "schema", + "search", + "segment", + "select", + "session", + "setting", + "source", + "stable", + "stage", + "state", + "static", + "storage", + "stream", + "string", + "struct", + "table", + "target", + "task", + "thread", + "token", + "trace", + "transform", + "type", + "update", + "upload", + "value", + "vector", + "version", + "view", + "write", + "writer", +]; + /// Word list with stop words at the front for Zipf sampling, computed once. static SENTENCE_WORDS: LazyLock> = LazyLock::new(|| { - let all_words = random_word::all(random_word::Lang::En); - let mut words = Vec::with_capacity(STOP_WORDS.len() + all_words.len()); + let mut words = Vec::with_capacity(STOP_WORDS.len() + ENGLISH_WORDS.len()); words.extend(STOP_WORDS.iter().copied()); - words.extend( - all_words - .iter() - .filter(|w| !STOP_WORDS.contains(w)) - .copied(), - ); + words.extend(ENGLISH_WORDS.iter().copied()); words }); @@ -1280,7 +1489,7 @@ struct RandomWordGenerator { impl RandomWordGenerator { pub fn new(is_large: bool) -> Self { - let words = random_word::all(random_word::Lang::En); + let words = ENGLISH_WORDS; Self { words, is_large } } } @@ -2197,9 +2406,10 @@ impl BatchGeneratorBuilder { if !batch_size_bytes.0.is_multiple_of(bytes_per_row) { match rounding { RoundingBehavior::ExactOrErr => { - return Err(ArrowError::NotYetImplemented( - format!("Exact rounding requested but not possible. Batch size requested {}, row size: {}", batch_size_bytes.0, bytes_per_row)) - ); + return Err(ArrowError::NotYetImplemented(format!( + "Exact rounding requested but not possible. Batch size requested {}, row size: {}", + batch_size_bytes.0, bytes_per_row + ))); } RoundingBehavior::RoundUp => { num_rows = RowCount::from(num_rows.0 + 1); @@ -2258,11 +2468,11 @@ const MS_PER_DAY: i64 = 86400000; pub mod array { - use arrow::datatypes::{Int16Type, Int64Type, Int8Type}; + use arrow::datatypes::{Int8Type, Int16Type, Int64Type}; use arrow_array::types::{ Decimal128Type, Decimal256Type, DurationMicrosecondType, DurationMillisecondType, DurationNanosecondType, DurationSecondType, Float16Type, Float32Type, Float64Type, - UInt16Type, UInt32Type, UInt64Type, UInt8Type, + UInt8Type, UInt16Type, UInt32Type, UInt64Type, }; use arrow_array::{ ArrowNativeTypeOp, BooleanArray, Date32Array, Date64Array, Time32MillisecondArray, @@ -2620,14 +2830,29 @@ pub mod array { Box::new(RandomIntervalGenerator::new(unit)) } + /// The default sampling range for temporal generators: the 365 days ending at + /// 2024-01-01T00:00:00Z (exclusive) + /// + /// The range must be a fixed anchor and not derived from the wall clock + /// (e.g. `Utc::now()`), otherwise the same RNG seed would generate different + /// values depending on when the generator was created, breaking + /// reproducibility (e.g. of saved fuzz inputs). Callers that need a + /// time-relative range can use the `*_in_range` variants. + fn default_temporal_range() -> (chrono::DateTime, chrono::DateTime) { + let end = chrono::DateTime::::from_timestamp(1_704_067_200, 0) + .expect("2024-01-01T00:00:00Z is a valid timestamp"); + let start = end - chrono::TimeDelta::try_days(365).expect("TimeDelta try_days"); + (start, end) + } + /// Create a generator of randomly sampled date32 values /// - /// Instead of sampling the entire range, all values will be drawn from the last year as this - /// is a more common use pattern + /// Instead of sampling the entire range, all values will be drawn from a fixed + /// one-year range (the 365 days ending at 2024-01-01 UTC) as this is a more + /// common use pattern. Use [`rand_date32_in_range`] to control the range. pub fn rand_date32() -> Box { - let now = chrono::Utc::now(); - let one_year_ago = now - chrono::TimeDelta::try_days(365).expect("TimeDelta try days"); - rand_date32_in_range(one_year_ago, now) + let (start, end) = default_temporal_range(); + rand_date32_in_range(start, end) } /// Create a generator of randomly sampled date32 values in the given range @@ -2655,12 +2880,12 @@ pub mod array { /// Create a generator of randomly sampled date64 values /// - /// Instead of sampling the entire range, all values will be drawn from the last year as this - /// is a more common use pattern + /// Instead of sampling the entire range, all values will be drawn from a fixed + /// one-year range (the 365 days ending at 2024-01-01 UTC) as this is a more + /// common use pattern. Use [`rand_date64_in_range`] to control the range. pub fn rand_date64() -> Box { - let now = chrono::Utc::now(); - let one_year_ago = now - chrono::TimeDelta::try_days(365).expect("TimeDelta try_days"); - rand_date64_in_range(one_year_ago, now) + let (start, end) = default_temporal_range(); + rand_date64_in_range(start, end) } /// Create a generator of randomly sampled timestamp values in the given range @@ -2716,10 +2941,14 @@ pub mod array { } } + /// Create a generator of randomly sampled timestamp values + /// + /// Instead of sampling the entire range, all values will be drawn from a fixed + /// one-year range (the 365 days ending at 2024-01-01 UTC) as this is a more + /// common use pattern. Use [`rand_timestamp_in_range`] to control the range. pub fn rand_timestamp(data_type: &DataType) -> Box { - let now = chrono::Utc::now(); - let one_year_ago = now - chrono::Duration::try_days(365).unwrap(); - rand_timestamp_in_range(one_year_ago, now, data_type) + let (start, end) = default_temporal_range(); + rand_timestamp_in_range(start, end, data_type) } /// Create a generator of randomly sampled date64 values @@ -2987,12 +3216,11 @@ pub fn rand_field(field: &Field) -> Box { array::rand_type(field.data_type()) }; - if let Some(cardinality_str) = field.metadata().get(CARDINALITY_KEY) { - if let Ok(cardinality) = cardinality_str.parse::() { - if cardinality > 0 { - generator = array::low_cardinality(generator, cardinality); - } - } + if let Some(cardinality_str) = field.metadata().get(CARDINALITY_KEY) + && let Ok(cardinality) = cardinality_str.parse::() + && cardinality > 0 + { + generator = array::low_cardinality(generator, cardinality); } generator @@ -3017,11 +3245,55 @@ pub fn rand(schema: &Schema) -> BatchGeneratorBuilder { #[cfg(test)] mod tests { - use arrow::datatypes::{Float32Type, Int16Type, Int8Type, UInt32Type}; - use arrow_array::{BooleanArray, Float32Array, Int16Array, Int32Array, Int8Array, UInt32Array}; + use arrow::datatypes::{Float32Type, Int8Type, Int16Type, TimeUnit, UInt32Type}; + use arrow_array::{ + BooleanArray, Date32Array, Date64Array, Float32Array, Int8Array, Int16Array, Int32Array, + TimestampMicrosecondArray, TimestampMillisecondArray, TimestampNanosecondArray, + TimestampSecondArray, UInt32Array, + }; + use rstest::rstest; use super::*; + #[rstest] + #[case::float16(DataType::Float16)] + #[case::decimal128(DataType::Decimal128(38, 10))] + #[case::decimal256(DataType::Decimal256(76, 10))] + fn test_random_bytes_generator_alignment(#[case] data_type: DataType) { + for length in [0, 3] { + let generated = array::rand_type(&data_type) + .generate_default(RowCount::from(length)) + .unwrap(); + assert_eq!(generated.data_type(), &data_type); + assert_eq!(generated.len(), length as usize); + } + } + + #[test] + fn test_timestamp_timezone_is_preserved() { + let data_type = DataType::Timestamp(TimeUnit::Millisecond, Some("UTC".into())); + let mut generator = array::rand_type(&data_type); + let generated = generator.generate_default(RowCount::from(2)).unwrap(); + assert_eq!(generated.data_type(), &data_type); + + let fields = Fields::from(vec![Field::new("timestamp", data_type, true)]); + let mut generator = array::rand_struct(fields.clone()); + let generated = generator.generate_default(RowCount::from(2)).unwrap(); + assert_eq!(generated.data_type(), &DataType::Struct(fields)); + } + + #[test] + fn test_fn_gen_propagates_array_data_build_error() { + // FnGen constructors are internal. Use an incompatible declared type to + // verify that ArrayDataBuilder validation failures are propagated. + let mut generator = FnGen::::new_unknown_size(DataType::Utf8, |_| 0, 1); + + assert!(matches!( + generator.generate_default(RowCount::from(1)), + Err(ArrowError::InvalidArgumentError(_)) + )); + } + #[test] fn test_step() { let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(DEFAULT_SEED.0); @@ -3191,9 +3463,75 @@ mod tests { assert_eq!( *genn.generate(RowCount::from(3), &mut rng).unwrap(), arrow_array::BinaryArray::from_iter_values([ - vec![174, 178], - vec![64, 122, 207, 248], - vec![124, 3, 58] + vec![111, 9, 80], + vec![86, 118, 13, 209], + vec![68, 33, 202] + ]) + ); + } + + #[test] + fn test_rng_temporal_deterministic() { + // The default temporal generators must not depend on the wall clock: the + // same seed must produce the same values no matter when the generator is + // created (https://github.com/lance-format/lance/issues/7913). These + // exact values pin both the RNG stream and the fixed default sampling + // range (the 365 days ending at 2024-01-01 UTC). + fn gen_values(mut genn: Box) -> Arc { + let mut rng = rand_xoshiro::Xoshiro256PlusPlus::seed_from_u64(DEFAULT_SEED.0); + genn.generate(RowCount::from(3), &mut rng).unwrap() + } + + assert_eq!( + *gen_values(array::rand_date32()), + Date32Array::from(vec![19655, 19474, 19717]) + ); + assert_eq!( + *gen_values(array::rand_date64()), + Date64Array::from(vec![ + 1_698_192_000_000, + 1_682_553_600_000, + 1_703_548_800_000 + ]) + ); + assert_eq!( + *gen_values(array::rand_timestamp(&DataType::Timestamp( + TimeUnit::Second, + None + ))), + TimestampSecondArray::from(vec![1_698_211_127, 1_682_585_540, 1_703_559_286]) + ); + assert_eq!( + *gen_values(array::rand_timestamp(&DataType::Timestamp( + TimeUnit::Millisecond, + None + ))), + TimestampMillisecondArray::from(vec![ + 1_698_211_127_056, + 1_682_585_540_319, + 1_703_559_286_487 + ]) + ); + assert_eq!( + *gen_values(array::rand_timestamp(&DataType::Timestamp( + TimeUnit::Microsecond, + None + ))), + TimestampMicrosecondArray::from(vec![ + 1_698_211_127_056_596, + 1_682_585_540_319_384, + 1_703_559_286_487_645 + ]) + ); + assert_eq!( + *gen_values(array::rand_timestamp(&DataType::Timestamp( + TimeUnit::Nanosecond, + None + ))), + TimestampNanosecondArray::from(vec![ + 1_698_211_127_056_596_085, + 1_682_585_540_319_384_548, + 1_703_559_286_487_645_287 ]) ); } diff --git a/rust/lance-derive/Cargo.toml b/rust/lance-derive/Cargo.toml new file mode 100644 index 00000000000..a51660c83a7 --- /dev/null +++ b/rust/lance-derive/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "lance-derive" +version.workspace = true +edition.workspace = true +authors.workspace = true +license.workspace = true +repository.workspace = true +readme.workspace = true +description = "Derive macros for Lance" +keywords.workspace = true +categories.workspace = true + +[lib] +proc-macro = true + +[dependencies] +proc-macro2.workspace = true +quote.workspace = true +syn.workspace = true + +[lints] +workspace = true diff --git a/rust/lance-derive/src/lib.rs b/rust/lance-derive/src/lib.rs new file mode 100644 index 00000000000..d0486133ddc --- /dev/null +++ b/rust/lance-derive/src/lib.rs @@ -0,0 +1,119 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use proc_macro::TokenStream; +use quote::quote; +use syn::{Data, DeriveInput, Fields, parse_macro_input}; + +/// Derive macro for the `DeepSizeOf` trait. +/// +/// Generates an implementation that sums the `deep_size_of_children` of all +/// fields (for structs) or the active variant's fields (for enums). +#[proc_macro_derive(DeepSizeOf)] +pub fn derive_deep_size_of(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + let name = &input.ident; + let generics = &input.generics; + + // Add DeepSizeOf bounds to all type parameters + let mut bounded_generics = generics.clone(); + for param in &mut bounded_generics.params { + if let syn::GenericParam::Type(ref mut type_param) = *param { + type_param + .bounds + .push(syn::parse_quote!(lance_core::deepsize::DeepSizeOf)); + } + } + let (impl_generics, _, where_clause) = bounded_generics.split_for_impl(); + let (_, ty_generics, _) = generics.split_for_impl(); + + let body = match &input.data { + Data::Struct(data) => generate_struct_body(&data.fields), + Data::Enum(data) => { + let arms: Vec<_> = data + .variants + .iter() + .map(|variant| { + let variant_ident = &variant.ident; + match &variant.fields { + Fields::Unit => { + quote! { Self::#variant_ident => 0 } + } + Fields::Unnamed(fields) => { + let bindings: Vec<_> = (0..fields.unnamed.len()) + .map(|i| { + syn::Ident::new( + &format!("__field_{}", i), + proc_macro2::Span::call_site(), + ) + }) + .collect(); + let sum = bindings.iter().map(|b| { + quote! { lance_core::deepsize::DeepSizeOf::deep_size_of_children(#b, __context) } + }); + quote! { + Self::#variant_ident(#(#bindings),*) => { + 0 #(+ #sum)* + } + } + } + Fields::Named(fields) => { + let field_names: Vec<_> = + fields.named.iter().map(|f| &f.ident).collect(); + let sum = field_names.iter().map(|f| { + quote! { lance_core::deepsize::DeepSizeOf::deep_size_of_children(#f, __context) } + }); + quote! { + Self::#variant_ident { #(#field_names),* } => { + 0 #(+ #sum)* + } + } + } + } + }) + .collect(); + quote! { + match self { + #(#arms),* + } + } + } + Data::Union(_) => { + return syn::Error::new_spanned(&input, "DeepSizeOf cannot be derived for unions") + .to_compile_error() + .into(); + } + }; + + let expanded = quote! { + impl #impl_generics lance_core::deepsize::DeepSizeOf for #name #ty_generics #where_clause { + fn deep_size_of_children(&self, __context: &mut lance_core::deepsize::Context) -> usize { + #body + } + } + }; + + TokenStream::from(expanded) +} + +fn generate_struct_body(fields: &Fields) -> proc_macro2::TokenStream { + match fields { + Fields::Named(fields) => { + let field_sizes = fields.named.iter().map(|f| { + let name = &f.ident; + quote! { lance_core::deepsize::DeepSizeOf::deep_size_of_children(&self.#name, __context) } + }); + quote! { 0 #(+ #field_sizes)* } + } + Fields::Unnamed(fields) => { + let field_sizes = (0..fields.unnamed.len()).map(|i| { + let index = syn::Index::from(i); + quote! { lance_core::deepsize::DeepSizeOf::deep_size_of_children(&self.#index, __context) } + }); + quote! { 0 #(+ #field_sizes)* } + } + Fields::Unit => { + quote! { 0 } + } + } +} diff --git a/rust/lance-encoding/Cargo.toml b/rust/lance-encoding/Cargo.toml index c8f23f9b914..2f2490cfb84 100644 --- a/rust/lance-encoding/Cargo.toml +++ b/rust/lance-encoding/Cargo.toml @@ -25,41 +25,35 @@ lance-bitpacking = { workspace = true, optional = true } bytes.workspace = true futures.workspace = true fsst.workspace = true -hex = "0.4.3" +hex.workspace = true itertools.workspace = true log.workspace = true num-traits.workspace = true prost.workspace = true hyperloglogplus.workspace = true -prost-types.workspace = true -rand.workspace = true -snafu.workspace = true -strum = { workspace =true, features = ["derive"] } tokio.workspace = true tracing.workspace = true xxhash-rust = { version = "0.8.15", features = ["xxh3"] } -bytemuck = { version = "1.14", features = ["extern_crate_alloc"] } +bytemuck.workspace = true byteorder.workspace = true lz4 = { version = "1", optional = true } zstd = { version = "0.13", optional = true } [dev-dependencies] -lance-testing.workspace = true lance-datagen.workspace = true arrow-ord.workspace = true rand.workspace = true rstest.workspace = true test-log.workspace = true criterion = { workspace = true } +lance-testing.workspace = true rand_xoshiro = { workspace = true } proptest.workspace = true +serial_test.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 } +protobuf-src = { workspace = true, optional = true } [features] default = ["lz4", "zstd", "bitpacking"] @@ -72,6 +66,9 @@ zstd = ["dep:zstd"] # docs.rs uses an older version of Ubuntu that does not have the necessary protoc version features = ["protoc"] +[package.metadata.cargo-machete] +ignored = ["prost"] + [[bench]] name = "decoder" harness = false diff --git a/rust/lance-encoding/benches/buffer.rs b/rust/lance-encoding/benches/buffer.rs index b552bcca04d..04e0718a79f 100644 --- a/rust/lance-encoding/benches/buffer.rs +++ b/rust/lance-encoding/benches/buffer.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use criterion::{black_box, criterion_group, criterion_main, Criterion, Throughput}; +use std::hint::black_box; + +use criterion::{Criterion, Throughput, criterion_group, criterion_main}; use lance_encoding::buffer::LanceBuffer; const NUM_VALUES: &[usize] = &[1024 * 1024, 32 * 1024, 8 * 1024]; @@ -63,7 +65,7 @@ fn bench_zip(c: &mut Criterion) { criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10) - .with_profiler(pprof::criterion::PProfProfiler::new(100, pprof::criterion::Output::Flamegraph(None))); + .with_profiler(lance_testing::pprof::PProfProfiler::new(100, lance_testing::pprof::Output::Flamegraph(None))); targets = bench_zip); // Non-linux version does not support pprof. diff --git a/rust/lance-encoding/benches/common/mod.rs b/rust/lance-encoding/benches/common/mod.rs new file mode 100644 index 00000000000..3fc77f4f03b --- /dev/null +++ b/rust/lance-encoding/benches/common/mod.rs @@ -0,0 +1,277 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use arrow_schema::DataType; +use lance_core::{Error, Result, datatypes::Field}; +use lance_encoding::{ + compression::{ + BlockCompressor, CompressionStrategy, field_metadata_params, finalize_miniblock_compressor, + reject_packed_struct_per_value, try_bitpacking_block, try_bitpacking_miniblock, + try_byte_stream_split_miniblock, try_fixed_packed_struct_miniblock, try_fixed_u8_rle_block, + try_fixed_u8_rle_miniblock, try_general_block, try_raw_block, + try_raw_fixed_size_list_miniblock, try_raw_fixed_width_miniblock, try_raw_per_value, + try_uncompressed_fixed_width_miniblock, try_variable_packed_struct_per_value, + try_variable_width_miniblock, try_variable_width_per_value, + }, + compression_config::{CompressionFieldParams, CompressionParams}, + data::DataBlock, + encoder::{ + ColumnIndexSequence, FieldEncoder, FieldEncodingContext, FieldEncodingStrategy, + structural::{ + PrimitiveFieldEncoding, PrimitivePageEncoding, try_create_binary_blob, try_create_list, + try_create_map, try_create_struct, try_create_structural_blob, + try_create_structural_fixed_size_list, + }, + }, + encodings::logical::primitive::{fullzip::PerValueCompressor, miniblock::MiniBlockCompressor}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BenchEncoding { + Array, + StructuralU16, + StructuralU32, +} + +impl std::fmt::Display for BenchEncoding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Array => "array", + Self::StructuralU16 => "structural-u16", + Self::StructuralU32 => "structural-u32", + }) + } +} + +#[derive(Debug, Clone)] +struct BenchCompressionStrategy { + encoding: BenchEncoding, + params: CompressionParams, +} + +impl BenchCompressionStrategy { + fn field_params(&self, field: &Field) -> CompressionFieldParams { + let mut params = self + .params + .get_field_params(&field.name, &field.data_type()); + let mut metadata = field_metadata_params(field); + if self.encoding == BenchEncoding::StructuralU16 + && metadata + .minichunk_size + .is_some_and(|size| size >= 32 * 1024) + { + metadata.minichunk_size = None; + } + params.merge(&metadata); + params + } +} + +impl CompressionStrategy for BenchCompressionStrategy { + fn create_miniblock_compressor( + &self, + field: &Field, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + let compressor = + if let Some(compressor) = try_uncompressed_fixed_width_miniblock(data, ¶ms) { + compressor + } else if let Some(compressor) = try_byte_stream_split_miniblock(data, ¶ms) { + compressor + } else if let Some(compressor) = try_fixed_u8_rle_miniblock(data, ¶ms) { + compressor + } else if let Some(compressor) = try_bitpacking_miniblock(data) { + compressor + } else if let Some(compressor) = try_raw_fixed_width_miniblock(data) { + compressor + } else if let Some(compressor) = try_variable_width_miniblock(field, data, ¶ms)? { + compressor + } else if let Some(compressor) = try_fixed_packed_struct_miniblock(data)? { + compressor + } else if let Some(compressor) = try_raw_fixed_size_list_miniblock(data) { + compressor + } else { + return Err(Error::not_supported_source( + format!( + "Mini-block compression not yet supported for block type {}", + data.name() + ) + .into(), + )); + }; + finalize_miniblock_compressor(data, compressor, ¶ms) + } + + fn create_per_value( + &self, + field: &Field, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + if let Some(compressor) = try_raw_per_value(data) { + return Ok(compressor); + } + let packed = match self.encoding { + BenchEncoding::StructuralU16 => reject_packed_struct_per_value(field, data)?, + BenchEncoding::StructuralU32 => { + try_variable_packed_struct_per_value(Arc::new(self.clone()), field, data)? + } + BenchEncoding::Array => unreachable!(), + }; + if let Some(compressor) = packed { + return Ok(compressor); + } + if let Some(compressor) = try_variable_width_per_value(field, data, ¶ms)? { + return Ok(compressor); + } + Err(Error::not_supported_source( + format!( + "Per-value compression not yet supported for block type {}", + data.name() + ) + .into(), + )) + } + + fn create_block_compressor( + &self, + field: &Field, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + if self.encoding == BenchEncoding::StructuralU32 + && let Some(compressor) = try_fixed_u8_rle_block(data, ¶ms)? + { + return Ok(compressor); + } + if let Some(compressor) = try_bitpacking_block(data) { + return Ok(compressor); + } + if self.encoding == BenchEncoding::StructuralU32 + && let Some(compressor) = try_general_block(data, ¶ms)? + { + return Ok(compressor); + } + if let Some(compressor) = try_raw_block(data) { + return Ok(compressor); + } + Err(Error::not_supported_source( + format!( + "Block compression not yet supported for block type {}", + data.name() + ) + .into(), + )) + } +} + +#[derive(Debug)] +struct BenchFieldEncodingStrategy { + encoding: BenchEncoding, + primitive: PrimitiveFieldEncoding, +} + +impl FieldEncodingStrategy for BenchFieldEncodingStrategy { + fn create_field_encoder( + &self, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, + ) -> Result> { + if let Some(encoder) = + try_create_binary_blob(&self.primitive, field, column_index, context)? + { + return Ok(encoder); + } + if self.encoding == BenchEncoding::StructuralU32 + && let Some(encoder) = + try_create_structural_blob(&self.primitive, field, column_index, context)? + { + return Ok(encoder); + } + if field.is_blob() { + return Err(Error::invalid_input_source( + format!( + "Blob encoding is not available for field '{}' with data type {}", + field.name, + field.data_type() + ) + .into(), + )); + } + if self.encoding == BenchEncoding::StructuralU32 { + if let Some(encoder) = try_create_map(field, column_index, context)? { + return Ok(encoder); + } + if let Some(encoder) = + try_create_structural_fixed_size_list(field, column_index, context)? + { + return Ok(encoder); + } + } + if let Some(encoder) = self.primitive.try_create(field, column_index, context)? { + return Ok(encoder); + } + if self.encoding == BenchEncoding::StructuralU16 { + if matches!( + field.data_type(), + DataType::FixedSizeList(item, _) + if matches!(item.data_type(), DataType::Struct(_)) + ) { + return Err(Error::not_supported_source( + "FixedSizeList is not enabled by the selected file format".into(), + )); + } + if matches!(field.data_type(), DataType::Map(_, _)) { + return Err(Error::not_supported_source( + "Map data type is not enabled by the selected file format".into(), + )); + } + } + if let Some(encoder) = try_create_list(field, column_index, context)? { + return Ok(encoder); + } + if let Some(encoder) = try_create_struct(field, column_index, context)? { + return Ok(encoder); + } + Err(Error::not_supported_source( + format!( + "{} has no field encoding for '{}' with data type {}", + self.encoding, + field.name, + field.data_type() + ) + .into(), + )) + } +} + +pub fn encoding_strategy(encoding: BenchEncoding) -> Box { + if encoding == BenchEncoding::Array { + return Box::new(lance_encoding::encoder::ArrayFieldEncodingStrategy::new()); + } + + let compression = Arc::new(BenchCompressionStrategy { + encoding, + params: CompressionParams::default(), + }); + let page_encodings = match encoding { + BenchEncoding::StructuralU16 => vec![ + PrimitivePageEncoding::reject_sparse(), + PrimitivePageEncoding::dense_u16(compression), + ], + BenchEncoding::StructuralU32 => vec![ + PrimitivePageEncoding::reject_sparse(), + PrimitivePageEncoding::constant(), + PrimitivePageEncoding::dense_u32(compression), + ], + BenchEncoding::Array => unreachable!(), + }; + Box::new(BenchFieldEncodingStrategy { + encoding, + primitive: PrimitiveFieldEncoding::new(page_encodings), + }) +} diff --git a/rust/lance-encoding/benches/decoder.rs b/rust/lance-encoding/benches/decoder.rs index c59ef23820a..fa831b69b25 100644 --- a/rust/lance-encoding/benches/decoder.rs +++ b/rust/lance-encoding/benches/decoder.rs @@ -1,25 +1,42 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{collections::HashMap, sync::Arc}; +use std::{collections::HashMap, hint::black_box, sync::Arc}; use arrow_array::{RecordBatch, UInt32Array}; +#[cfg(feature = "bitpacking")] +use arrow_buffer::ArrowNativeType; use arrow_schema::{DataType, Field, Schema, TimeUnit}; use arrow_select::take::take; -use criterion::{criterion_group, criterion_main, Criterion}; +#[cfg(feature = "bitpacking")] +use bytemuck::Pod; +use criterion::{Criterion, criterion_group, criterion_main}; use futures::StreamExt; +#[cfg(feature = "bitpacking")] +use lance_bitpacking::BitPacking; use lance_core::cache::LanceCache; use lance_datagen::ArrayGeneratorExt; +#[cfg(feature = "bitpacking")] +use lance_encoding::buffer::LanceBuffer; +#[cfg(feature = "bitpacking")] +use lance_encoding::compression::BlockDecompressor; +#[cfg(feature = "bitpacking")] +use lance_encoding::data::{BlockInfo, DataBlock, FixedWidthDataBlock}; +#[cfg(feature = "bitpacking")] +use lance_encoding::encodings::physical::bitpacking::{ELEMS_PER_CHUNK, InlineBitpacking}; use lance_encoding::{ decoder::{ - create_decode_stream, DecodeBatchScheduler, DecoderConfig, DecoderPlugins, FilterExpression, + DecodeBatchScheduler, DecoderConfig, DecoderPlugins, EncodedBatchLayout, FilterExpression, + create_decode_stream, }, - encoder::{default_encoding_strategy, encode_batch, EncodingOptions}, - version::LanceFileVersion, + encoder::{EncodingOptions, encode_batch}, }; use tokio::sync::mpsc::unbounded_channel; use rand::Rng; +pub mod common; +use common::{BenchEncoding, encoding_strategy}; + const PRIMITIVE_TYPES: &[DataType] = &[ DataType::Date32, DataType::Date64, @@ -49,6 +66,15 @@ const PRIMITIVE_TYPES: &[DataType] = &[ // schema doesn't yet parse them in the context of a fixed size list. const PRIMITIVE_TYPES_FOR_FSL: &[DataType] = &[DataType::Int8, DataType::Float32]; +fn encoded_batch_layout(encoding: BenchEncoding) -> EncodedBatchLayout { + match encoding { + BenchEncoding::Array => EncodedBatchLayout::Array, + BenchEncoding::StructuralU16 | BenchEncoding::StructuralU32 => { + EncodedBatchLayout::Structural + } + } +} + fn bench_decode(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); let mut group = c.benchmark_group("decode_primitive"); @@ -64,7 +90,7 @@ fn bench_decode(c: &mut Criterion) { .unwrap(); let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(data.schema().as_ref()).unwrap()); - let encoding_strategy = default_encoding_strategy(LanceFileVersion::default()); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU16); let encoded = rt .block_on(encode_batch( &data, @@ -81,7 +107,7 @@ fn bench_decode(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - LanceFileVersion::default(), + EncodedBatchLayout::Structural, Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -95,10 +121,14 @@ fn bench_decode_fsl(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); let mut group = c.benchmark_group("decode_fsl"); const NUM_BYTES: u64 = 1024 * 1024 * 128; - for version in [LanceFileVersion::V2_0, LanceFileVersion::V2_1] { + for encoding in [ + BenchEncoding::Array, + BenchEncoding::StructuralU16, + BenchEncoding::StructuralU32, + ] { for data_type in PRIMITIVE_TYPES_FOR_FSL { for dimension in [4, 16, 32, 64, 128] { - let nullable_choices: &[bool] = if version == LanceFileVersion::V2_0 { + let nullable_choices: &[bool] = if encoding == BenchEncoding::Array { &[false] } else { &[false, true] @@ -106,7 +136,7 @@ fn bench_decode_fsl(c: &mut Criterion) { for nullable in nullable_choices { let func_name = format!( "{:?}_{}_v{}_null{}", - data_type, dimension, version, nullable + data_type, dimension, encoding, nullable ) .to_lowercase(); group.throughput(criterion::Throughput::Bytes(NUM_BYTES)); @@ -129,7 +159,7 @@ fn bench_decode_fsl(c: &mut Criterion) { lance_core::datatypes::Schema::try_from(data.schema().as_ref()) .unwrap(), ); - let encoding_strategy = default_encoding_strategy(version); + let encoding_strategy = encoding_strategy(encoding); let encoded = rt .block_on(encode_batch( &data, @@ -145,7 +175,7 @@ fn bench_decode_fsl(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - version, + encoded_batch_layout(encoding), Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -195,7 +225,7 @@ fn bench_decode_str_with_dict_encoding(c: &mut Criterion) { let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(data.schema().as_ref()).unwrap()); - let encoding_strategy = default_encoding_strategy(LanceFileVersion::default()); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU16); let encoded = rt .block_on(encode_batch( &data, @@ -211,7 +241,7 @@ fn bench_decode_str_with_dict_encoding(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - LanceFileVersion::default(), + EncodedBatchLayout::Structural, Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -270,7 +300,7 @@ fn bench_decode_packed_struct(c: &mut Criterion) { RecordBatch::try_new(Arc::new(new_schema.clone()), data.columns().to_vec()).unwrap(); let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(&new_schema).unwrap()); - let encoding_strategy = default_encoding_strategy(LanceFileVersion::default()); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU32); let encoded = rt .block_on(encode_batch( &data, @@ -287,7 +317,7 @@ fn bench_decode_packed_struct(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - LanceFileVersion::default(), + EncodedBatchLayout::Structural, Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -296,7 +326,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"); @@ -327,7 +357,7 @@ fn bench_decode_str_with_fixed_size_binary_encoding(c: &mut Criterion) { let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(data.schema().as_ref()).unwrap()); - let encoding_strategy = default_encoding_strategy(LanceFileVersion::default()); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU16); let encoded = rt .block_on(encode_batch( &data, @@ -343,7 +373,7 @@ fn bench_decode_str_with_fixed_size_binary_encoding(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - LanceFileVersion::default(), + EncodedBatchLayout::Structural, Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -394,7 +424,7 @@ fn bench_decode_compressed(c: &mut Criterion) { let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(schema.as_ref()).unwrap()); // V2_2+ required for general compression - let encoding_strategy = default_encoding_strategy(LanceFileVersion::V2_2); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU32); // Encode once during setup let encoded = rt @@ -419,7 +449,7 @@ fn bench_decode_compressed(c: &mut Criterion) { &FilterExpression::no_filter(), Arc::::default(), false, - LanceFileVersion::V2_2, + EncodedBatchLayout::Structural, Some(Arc::new(LanceCache::no_cache())), )) .unwrap(); @@ -472,7 +502,7 @@ fn bench_decode_compressed_parallel(c: &mut Criterion) { let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(schema.as_ref()).unwrap()); - let encoding_strategy = default_encoding_strategy(LanceFileVersion::V2_2); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU32); let encoded = rt .block_on(encode_batch( @@ -535,7 +565,9 @@ fn bench_decode_compressed_parallel(c: &mut Criterion) { BATCH_SIZE, true, // is_structural for V2_2 false, + false, rx, + None, ) .unwrap(); @@ -557,14 +589,144 @@ fn bench_decode_compressed_parallel(c: &mut Criterion) { } } +#[cfg(feature = "bitpacking")] +fn make_inline_bitpacking_chunk(bit_width: usize) -> LanceBuffer +where + T: ArrowNativeType + BitPacking + Pod, +{ + let value_range = 1_usize << bit_width; + let values: Vec = (0..ELEMS_PER_CHUNK as usize) + .map(|i| T::from_usize((i * 31 + 7) % value_range).unwrap()) + .collect(); + let packed_words = ELEMS_PER_CHUNK as usize * bit_width / (std::mem::size_of::() * 8); + + let mut chunk = Vec::with_capacity(1 + packed_words); + chunk.push(T::from_usize(bit_width).unwrap()); + let payload_start = chunk.len(); + chunk.resize(payload_start + packed_words, T::from_usize(0).unwrap()); + unsafe { + BitPacking::unchecked_pack(bit_width, &values, &mut chunk[payload_start..]); + } + + LanceBuffer::reinterpret_vec(chunk) +} + +#[cfg(feature = "bitpacking")] +fn read_little_endian_header(bytes: &[u8]) -> usize { + bytes[..std::mem::size_of::()] + .iter() + .enumerate() + .fold(0_u64, |value, (idx, byte)| { + value | ((*byte as u64) << (idx * 8)) + }) as usize +} + +#[cfg(feature = "bitpacking")] +fn legacy_copy_unchunk(data: LanceBuffer, num_values: u64) -> DataBlock +where + T: ArrowNativeType + BitPacking + Pod, +{ + assert!(data.len() >= std::mem::size_of::()); + assert!(num_values <= ELEMS_PER_CHUNK); + + let chunk_in_u8 = data.to_vec(); + let bit_width_value = read_little_endian_header::(&chunk_in_u8); + let chunk = bytemuck::cast_slice(&chunk_in_u8[std::mem::size_of::()..]); + assert!(std::mem::size_of_val(chunk) == bit_width_value * ELEMS_PER_CHUNK as usize / 8); + + let mut decompressed = vec![T::from_usize(0).unwrap(); ELEMS_PER_CHUNK as usize]; + unsafe { + BitPacking::unchecked_unpack(bit_width_value, chunk, &mut decompressed); + } + + decompressed.truncate(num_values as usize); + DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_vec(decompressed), + bits_per_value: (std::mem::size_of::() * 8) as u64, + num_values, + block_info: BlockInfo::new(), + }) +} + +#[cfg(feature = "bitpacking")] +fn typed_view_unchunk(buffer: LanceBuffer, uncompressed_bits: u64, num_values: u64) -> DataBlock { + InlineBitpacking::new(uncompressed_bits) + .decompress(Some(buffer), num_values) + .unwrap() +} + +#[cfg(feature = "bitpacking")] +fn assert_same_fixed_width_payloads(legacy: &DataBlock, typed_view: &DataBlock) { + let legacy = legacy.as_fixed_width_ref().unwrap(); + let typed_view = typed_view.as_fixed_width_ref().unwrap(); + + assert_eq!(legacy.num_values, typed_view.num_values); + assert_eq!(legacy.bits_per_value, typed_view.bits_per_value); + assert_eq!(legacy.data.as_ref(), typed_view.data.as_ref()); +} + +#[cfg(feature = "bitpacking")] +fn bench_inline_bitpacking_case( + group: &mut criterion::BenchmarkGroup<'_, criterion::measurement::WallTime>, + name: &str, + bit_width: usize, +) where + T: ArrowNativeType + BitPacking + Pod, +{ + let buffer = make_inline_bitpacking_chunk::(bit_width); + let compressed_bytes = buffer.len() as u64; + let uncompressed_bits = (std::mem::size_of::() * 8) as u64; + group.throughput(criterion::Throughput::Bytes(compressed_bytes)); + + let legacy = legacy_copy_unchunk::(buffer.clone(), ELEMS_PER_CHUNK); + let typed_view = typed_view_unchunk(buffer.clone(), uncompressed_bits, ELEMS_PER_CHUNK); + assert_same_fixed_width_payloads(&legacy, &typed_view); + + group.bench_function(format!("{name}/legacy_copy/compressed_bytes"), |b| { + b.iter(|| { + let decoded = + legacy_copy_unchunk::(black_box(buffer.clone()), black_box(ELEMS_PER_CHUNK)); + let fixed = decoded.as_fixed_width().unwrap(); + black_box(fixed.data.as_ref()); + }) + }); + + group.bench_function(format!("{name}/typed_view/compressed_bytes"), |b| { + b.iter(|| { + let decoded = typed_view_unchunk( + black_box(buffer.clone()), + black_box(uncompressed_bits), + black_box(ELEMS_PER_CHUNK), + ); + let fixed = decoded.as_fixed_width().unwrap(); + black_box(fixed.data.as_ref()); + }) + }); +} + +#[cfg(feature = "bitpacking")] +fn bench_decode_inline_bitpacking_unchunk(c: &mut Criterion) { + let mut group = c.benchmark_group("decode_inline_bitpacking_unchunk"); + bench_inline_bitpacking_case::(&mut group, "u32_bw12_1024", 12); + bench_inline_bitpacking_case::(&mut group, "u64_bw23_1024", 23); + group.finish(); +} + +#[cfg(not(feature = "bitpacking"))] +fn bench_decode_inline_bitpacking_unchunk(c: &mut Criterion) { + let mut group = c.benchmark_group("decode_inline_bitpacking_unchunk"); + group.bench_function("bitpacking_feature_disabled", |b| b.iter(|| black_box(()))); + group.finish(); +} + #[cfg(target_os = "linux")] criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10) - .with_profiler(pprof::criterion::PProfProfiler::new(100, pprof::criterion::Output::Flamegraph(None))); + .with_profiler(lance_testing::pprof::PProfProfiler::new(100, lance_testing::pprof::Output::Flamegraph(None))); targets = bench_decode, bench_decode_fsl, bench_decode_str_with_dict_encoding, bench_decode_packed_struct, bench_decode_str_with_fixed_size_binary_encoding, bench_decode_compressed, - bench_decode_compressed_parallel); + bench_decode_compressed_parallel, bench_decode_inline_bitpacking_unchunk); // Non-linux version does not support pprof. #[cfg(not(target_os = "linux"))] @@ -572,5 +734,5 @@ criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10); targets = bench_decode, bench_decode_fsl, bench_decode_str_with_dict_encoding, bench_decode_packed_struct, - bench_decode_compressed, bench_decode_compressed_parallel); + bench_decode_compressed, bench_decode_compressed_parallel, bench_decode_inline_bitpacking_unchunk); criterion_main!(benches); diff --git a/rust/lance-encoding/benches/encoder.rs b/rust/lance-encoding/benches/encoder.rs index 6a0d5b94ad2..02ffd92083c 100644 --- a/rust/lance-encoding/benches/encoder.rs +++ b/rust/lance-encoding/benches/encoder.rs @@ -3,13 +3,28 @@ use std::{collections::HashMap, sync::Arc}; -use arrow_array::RecordBatch; +use arrow_array::{ArrayRef, BooleanArray, ListArray, RecordBatch}; +use arrow_buffer::{OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, Schema}; -use criterion::{criterion_group, criterion_main, Criterion}; -use lance_encoding::{ - encoder::{default_encoding_strategy, encode_batch, EncodingOptions}, - version::LanceFileVersion, -}; +use criterion::{Criterion, criterion_group, criterion_main}; +use lance_encoding::encoder::{EncodingOptions, encode_batch}; + +pub mod common; +use common::{BenchEncoding, encoding_strategy}; + +fn encode_batch_sync(rt: &tokio::runtime::Runtime, data: &RecordBatch) { + let lance_schema = + Arc::new(lance_core::datatypes::Schema::try_from(data.schema().as_ref()).unwrap()); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU32); + + rt.block_on(encode_batch( + data, + lance_schema, + encoding_strategy.as_ref(), + &EncodingOptions::default(), + )) + .unwrap(); +} fn bench_encode_compressed(c: &mut Criterion) { let rt = tokio::runtime::Runtime::new().unwrap(); @@ -53,7 +68,7 @@ fn bench_encode_compressed(c: &mut Criterion) { let lance_schema = Arc::new(lance_core::datatypes::Schema::try_from(schema.as_ref()).unwrap()); // V2_2+ required for general compression - let encoding_strategy = default_encoding_strategy(LanceFileVersion::V2_2); + let encoding_strategy = encoding_strategy(BenchEncoding::StructuralU32); group.throughput(criterion::Throughput::Elements( (NUM_ROWS * NUM_COLUMNS) as u64, @@ -75,17 +90,89 @@ fn bench_encode_compressed(c: &mut Criterion) { } } +fn make_boolean_list_batch(array: ArrayRef, nullable: bool) -> RecordBatch { + let item_field = Arc::new(Field::new("item", DataType::Boolean, true)); + let field = Field::new("values", DataType::List(item_field), nullable); + let schema = Arc::new(Schema::new(vec![field])); + RecordBatch::try_new(schema, vec![array]).unwrap() +} + +fn dense_boolean_list(num_rows: usize, booleans_per_list: usize) -> ArrayRef { + let mut offsets = Vec::with_capacity(num_rows + 1); + let mut values = Vec::with_capacity(num_rows * booleans_per_list); + offsets.push(0i32); + + for _ in 0..num_rows { + values.extend((0..booleans_per_list).map(|idx| idx % 2 == 0)); + offsets.push(values.len() as i32); + } + + Arc::new(ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(BooleanArray::from(values)), + None, + )) +} + +fn sparse_boolean_list( + num_rows: usize, + num_non_empty: usize, + booleans_per_list: usize, +) -> ArrayRef { + let step = num_rows / num_non_empty; + let mut offsets = Vec::with_capacity(num_rows + 1); + let mut values = Vec::with_capacity(num_non_empty * booleans_per_list); + offsets.push(0i32); + + let mut next_non_empty = step / 2; + for row in 0..num_rows { + if row == next_non_empty { + values.extend((0..booleans_per_list).map(|idx| idx % 2 == 0)); + next_non_empty += step; + } + offsets.push(values.len() as i32); + } + + Arc::new(ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(BooleanArray::from(values)), + None, + )) +} + +fn bench_encode_structural_pages(c: &mut Criterion) { + let rt = tokio::runtime::Runtime::new().unwrap(); + let mut group = c.benchmark_group("encode_structural_pages"); + + const NUM_ROWS: usize = 200_000; + const BOOLEANS_PER_LIST: usize = 8; + + let dense = make_boolean_list_batch(dense_boolean_list(NUM_ROWS, BOOLEANS_PER_LIST), false); + let sparse = + make_boolean_list_batch(sparse_boolean_list(NUM_ROWS, 10, BOOLEANS_PER_LIST), false); + + group.throughput(criterion::Throughput::Elements(NUM_ROWS as u64)); + group.bench_function("dense_boolean_list", |b| { + b.iter(|| encode_batch_sync(&rt, &dense)) + }); + group.bench_function("sparse_boolean_list", |b| { + b.iter(|| encode_batch_sync(&rt, &sparse)) + }); +} + #[cfg(target_os = "linux")] criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10) - .with_profiler(pprof::criterion::PProfProfiler::new(100, pprof::criterion::Output::Flamegraph(None))); - targets = bench_encode_compressed); + .with_profiler(lance_testing::pprof::PProfProfiler::new(100, lance_testing::pprof::Output::Flamegraph(None))); + targets = bench_encode_compressed, bench_encode_structural_pages); #[cfg(not(target_os = "linux"))] criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10); - targets = bench_encode_compressed); + targets = bench_encode_compressed, bench_encode_structural_pages); criterion_main!(benches); diff --git a/rust/lance-encoding/build.rs b/rust/lance-encoding/build.rs index 92bf497beeb..92fe0358910 100644 --- a/rust/lance-encoding/build.rs +++ b/rust/lance-encoding/build.rs @@ -8,7 +8,9 @@ fn main() -> Result<()> { #[cfg(feature = "protoc")] // Use vendored protobuf compiler if requested. - std::env::set_var("PROTOC", protobuf_src::protoc()); + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()); + } let mut prost_build = prost_build::Config::new(); prost_build.protoc_arg("--experimental_allow_proto3_optional"); diff --git a/rust/lance-encoding/src/array_encoding.rs b/rust/lance-encoding/src/array_encoding.rs new file mode 100644 index 00000000000..a76c6a186ec --- /dev/null +++ b/rust/lance-encoding/src/array_encoding.rs @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Encoding and decoding mechanisms described by [`crate::format::pb::ArrayEncoding`]. +//! +//! File versions decide which mechanisms to compose and accept. This module +//! contains only the reusable implementation of that persisted grammar. + +pub mod logical; +pub mod physical; +mod strategy; + +pub use strategy::ArrayFieldEncodingStrategy; diff --git a/rust/lance-encoding/src/previous/encodings/logical.rs b/rust/lance-encoding/src/array_encoding/logical.rs similarity index 100% rename from rust/lance-encoding/src/previous/encodings/logical.rs rename to rust/lance-encoding/src/array_encoding/logical.rs diff --git a/rust/lance-encoding/src/previous/encodings/logical/binary.rs b/rust/lance-encoding/src/array_encoding/logical/binary.rs similarity index 57% rename from rust/lance-encoding/src/previous/encodings/logical/binary.rs rename to rust/lance-encoding/src/array_encoding/logical/binary.rs index dc30cc3ac13..4a4ea45a534 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/binary.rs +++ b/rust/lance-encoding/src/array_encoding/logical/binary.rs @@ -4,14 +4,14 @@ use std::sync::Arc; use arrow_array::{ + Array, ArrayRef, GenericByteArray, GenericListArray, cast::AsArray, types::{BinaryType, ByteArrayType, LargeBinaryType, LargeUtf8Type, UInt8Type, Utf8Type}, - Array, ArrayRef, GenericByteArray, GenericListArray, }; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; -use lance_core::Result; +use futures::{FutureExt, future::BoxFuture}; +use lance_core::{Error, Result}; use log::trace; use crate::{ @@ -19,7 +19,7 @@ use crate::{ DecodeArrayTask, FilterExpression, MessageType, NextDecodeTask, PriorityRange, ScheduledScanLine, SchedulerContext, }, - previous::decoder::{DecoderReady, FieldScheduler, LogicalPageDecoder, SchedulingJob}, + decoder::{DecoderReady, DrainLimit, FieldScheduler, LogicalPageDecoder, SchedulingJob}, }; /// Wraps a varbin scheduler and uses a BinaryPageDecoder to cast @@ -41,7 +41,7 @@ impl SchedulingJob for BinarySchedulingJob<'_> { .decoders .into_iter() .map(|message| { - let decoder = message.into_legacy(); + let decoder = message.into_array(); MessageType::DecoderReady(DecoderReady { path: decoder.path, decoder: Box::new(BinaryPageDecoder { @@ -129,6 +129,10 @@ impl LogicalPageDecoder for BinaryPageDecoder { }) } + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + self.inner.max_rows_to_drain(num_rows, byte_budget) + } + fn data_type(&self) -> &DataType { &self.data_type } @@ -152,7 +156,7 @@ pub struct BinaryArrayDecoder { } impl BinaryArrayDecoder { - fn from_list_array(array: &GenericListArray) -> ArrayRef { + fn from_list_array(array: &GenericListArray) -> Result { let values = array .values() .as_primitive::() @@ -160,26 +164,99 @@ impl BinaryArrayDecoder { .inner() .clone(); let offsets = array.offsets().clone(); - Arc::new(GenericByteArray::::new( - offsets, - values, - array.nulls().cloned(), - )) + let array = GenericByteArray::::try_new(offsets, values, array.nulls().cloned())?; + Ok(Arc::new(array)) } } 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::())), - _ => panic!("Binary decoder does not support this data type"), + 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::())?, + other => { + return Err(Error::internal(format!( + "Binary decoder does not support data type {other}" + ))); + } + }; + // data_size is only tracked in the v2.1 structural decode path; the v2.0 array + // v2.0 path does not need it so we return 0. + Ok((result, 0)) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arrow_array::{ListArray, UInt8Array}; + use arrow_buffer::OffsetBuffer; + use arrow_schema::Field; + + use super::*; + use crate::decoder::DecodeArrayTask; + + struct StubDecodeTask { + array: ArrayRef, + } + + impl DecodeArrayTask for StubDecodeTask { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { + Ok((self.array, 0)) } } + + fn make_single_byte_list(value: u8) -> ListArray { + let offsets = OffsetBuffer::from_lengths([1_usize]); + let values: ArrayRef = Arc::new(UInt8Array::from(vec![value])); + ListArray::try_new( + Arc::new(Field::new("item", DataType::UInt8, false)), + offsets, + values, + None, + ) + .unwrap() + } + + #[test] + fn logical_utf8_decode_preserves_non_overflow_arrow_error() { + let list = make_single_byte_list(0xFF_u8); + let decoder = BinaryArrayDecoder { + inner: Box::new(StubDecodeTask { + array: Arc::new(list), + }), + data_type: DataType::Utf8, + }; + + let error = Box::new(decoder).decode().unwrap_err(); + let message = error.to_string(); + assert!(!message.contains("more than 2GiB of string/binary data")); + assert!(message.to_lowercase().contains("utf")); + } + + #[test] + fn logical_binary_decode_returns_internal_error_for_unsupported_type() { + let list = make_single_byte_list(b'x'); + let decoder = BinaryArrayDecoder { + inner: Box::new(StubDecodeTask { + array: Arc::new(list), + }), + data_type: DataType::Int32, + }; + + let error = Box::new(decoder).decode().unwrap_err(); + assert!(matches!(error, Error::Internal { .. })); + assert!( + error + .to_string() + .contains("Binary decoder does not support data type Int32") + ); + } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/blob.rs b/rust/lance-encoding/src/array_encoding/logical/blob.rs similarity index 74% rename from rust/lance-encoding/src/previous/encodings/logical/blob.rs rename to rust/lance-encoding/src/array_encoding/logical/blob.rs index 3d79df2b03d..6789125ac0d 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/blob.rs +++ b/rust/lance-encoding/src/array_encoding/logical/blob.rs @@ -4,30 +4,29 @@ use std::{collections::VecDeque, sync::Arc, vec}; use arrow_array::{ - cast::AsArray, types::UInt64Type, Array, ArrayRef, LargeBinaryArray, PrimitiveArray, - StructArray, UInt64Array, + Array, ArrayRef, LargeBinaryArray, PrimitiveArray, StructArray, UInt64Array, cast::AsArray, + types::UInt64Type, }; use arrow_buffer::{ BooleanBuffer, BooleanBufferBuilder, Buffer, NullBuffer, OffsetBuffer, ScalarBuffer, }; use arrow_schema::DataType; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt}; -use snafu::location; +use futures::{FutureExt, future::BoxFuture}; -use lance_core::{datatypes::BLOB_DESC_FIELDS, Error, Result}; +use lance_core::{Error, Result, datatypes::BLOB_DESC_FIELDS}; use crate::{ + EncodingsIo, buffer::LanceBuffer, decoder::{ DecodeArrayTask, FilterExpression, MessageType, NextDecodeTask, PriorityRange, ScheduledScanLine, SchedulerContext, }, + decoder::{DecoderReady, FieldScheduler, LogicalPageDecoder, SchedulingJob}, encoder::{EncodeTask, FieldEncoder, OutOfLineBuffers}, - format::pb::{column_encoding, Blob, ColumnEncoding}, - previous::decoder::{DecoderReady, FieldScheduler, LogicalPageDecoder, SchedulingJob}, + format::pb::{Blob, ColumnEncoding, column_encoding}, repdef::RepDefBuilder, - EncodingsIo, }; /// A field scheduler for large binary data @@ -68,7 +67,7 @@ impl SchedulingJob for BlobFieldSchedulingJob<'_> { let next_descriptions = self.descriptions_job.schedule_next(context, priority)?; let mut priority = priority.current_priority(); let decoders = next_descriptions.decoders.into_iter().map(|decoder| { - let decoder = decoder.into_legacy(); + let decoder = decoder.into_array(); let path = decoder.path; let mut decoder = decoder.decoder; let num_rows = decoder.num_rows(); @@ -78,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 { @@ -204,13 +203,16 @@ impl LogicalPageDecoder for BlobFieldDecoder { .zip(sizes.iter()) .map(|(p, s)| *p != 1 || *s != 0) .collect::(); - self.validity.push_back(validity); - self.rows_loaded = end as u64; + // Run the I/O before mutating decoder state, so a failed load + // leaves the decoder untouched and `wait_for_loaded` is the single, + // clean point of failure. let bytes = self .io .submit_request(ranges, self.base_priority + start as u64) .await?; + self.validity.push_back(validity); self.loaded.extend(bytes); + self.rows_loaded = end as u64; Ok(()) } .boxed() @@ -229,6 +231,16 @@ impl LogicalPageDecoder for BlobFieldDecoder { } fn drain(&mut self, num_rows: u64) -> Result { + if num_rows as usize > self.loaded.len() { + // `loaded` is populated by `wait_for_loaded`; guard the + // load-before-drain contract so a violation surfaces as an error + // rather than an out-of-bounds drain panic. + return Err(Error::internal(format!( + "BlobFieldDecoder was asked to drain {num_rows} rows but only \ + {} are loaded", + self.loaded.len(), + ))); + } let bytes = self.loaded.drain(0..num_rows as usize).collect::>(); let validity = self.drain_validity(num_rows as usize)?; self.rows_drained += num_rows; @@ -255,7 +267,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 @@ -273,11 +285,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 v2.0 array + // v2.0 path does not need it so we return 0. + Ok(( + Arc::new(LargeBinaryArray::new(offsets, data_buf, self.validity)), + 0, + )) } } @@ -293,12 +306,11 @@ impl BlobFieldEncoder { } fn write_bins(array: ArrayRef, external_buffers: &mut OutOfLineBuffers) -> Result { - let binarray = array - .as_binary_opt::() - .ok_or_else(|| Error::InvalidInput { - source: format!("Expected large_binary and received {}", array.data_type()).into(), - location: location!(), - })?; + let binarray = array.as_binary_opt::().ok_or_else(|| { + Error::invalid_input_source( + format!("Expected large_binary and received {}", array.data_type()).into(), + ) + })?; let mut positions = Vec::with_capacity(array.len()); let mut sizes = Vec::with_capacity(array.len()); let data = binarray.values(); @@ -306,7 +318,7 @@ impl BlobFieldEncoder { .nulls() .cloned() .unwrap_or(NullBuffer::new_valid(binarray.len())); - for (w, is_valid) in binarray.value_offsets().windows(2).zip(nulls.into_iter()) { + for (w, is_valid) in binarray.value_offsets().windows(2).zip(&nulls) { if is_valid { let start = w[0] as u64; let end = w[1] as u64; @@ -388,20 +400,27 @@ impl FieldEncoder for BlobFieldEncoder { } #[cfg(test)] -pub mod tests { +mod tests { use std::{ - collections::HashMap, + collections::{HashMap, VecDeque}, + ops::Range, sync::{Arc, LazyLock}, }; - use arrow_array::LargeBinaryArray; + use arrow_array::{LargeBinaryArray, PrimitiveArray, types::UInt64Type}; use arrow_schema::{DataType, Field}; + use bytes::Bytes; + use futures::{FutureExt, future::BoxFuture}; use lance_arrow::BLOB_META_KEY; + use lance_core::{Error, Result}; + use super::BlobFieldDecoder; use crate::{ + EncodingsIo, + decoder::LogicalPageDecoder, format::pb::column_encoding, - testing::{check_round_trip_encoding_of_data, check_specific_random, TestCases}, - version::LanceFileVersion, + testing::TestEncoding, + testing::{TestCases, check_round_trip_encoding_of_data, check_specific_random}, }; static BLOB_META: LazyLock> = LazyLock::new(|| { @@ -411,12 +430,20 @@ pub mod tests { .collect::>() }); + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_basic_blob() { + async fn test_basic_blob( + #[values(TestEncoding::Array, TestEncoding::StructuralU16)] encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let field = Field::new("", DataType::LargeBinary, false).with_metadata(BLOB_META.clone()); check_specific_random( field, - TestCases::basic().with_max_file_version(LanceFileVersion::V2_1), + TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]), ) .await; } @@ -427,10 +454,10 @@ pub mod tests { let val2: &[u8] = &[7, 8, 9]; let array = Arc::new(LargeBinaryArray::from(vec![Some(val1), None, Some(val2)])); let test_cases = TestCases::default() - .with_max_file_version(LanceFileVersion::V2_1) + .with_array_and_u16_encodings() .with_expected_encoding("packed_struct") - .with_verify_encoding(Arc::new(|cols, version| { - if version < &LanceFileVersion::V2_1 { + .with_verify_encoding(Arc::new(|cols, encoding| { + if *encoding == TestEncoding::Array { // In 2.0 we used a special "column encoding" to mark blob fields. In 2.1 we // don't do this and just rely on the regular page encoding. assert_eq!(cols.len(), 1); @@ -446,9 +473,9 @@ pub mod tests { .await; let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) - .with_verify_encoding(Arc::new(|cols, version| { - if version < &LanceFileVersion::V2_1 { + .with_structural_encodings() + .with_verify_encoding(Arc::new(|cols, encoding| { + if *encoding == TestEncoding::Array { assert_eq!(cols.len(), 1); let col = &cols[0]; assert!(!matches!( @@ -460,4 +487,58 @@ pub mod tests { // Don't use blob encoding if not requested check_round_trip_encoding_of_data(vec![array], &test_cases, Default::default()).await; } + + /// An `EncodingsIo` that rejects every request, simulating cloud storage + /// returning a retryable error (e.g. an exhausted HTTP 503 retry budget). + #[derive(Debug)] + struct FailingScheduler; + + impl EncodingsIo for FailingScheduler { + fn submit_request( + &self, + _ranges: Vec>, + _priority: u64, + ) -> BoxFuture<'static, Result>> { + std::future::ready(Err(Error::io("simulated HTTP 503 from cloud storage"))).boxed() + } + } + + /// A failed blob load must surface through `wait_for_loaded` and leave the + /// decoder untouched -- never half-advanced into a state where a later + /// `drain` reads rows that never loaded (which panicked with "range end + /// index N out of range for slice of length 0"). + #[test_log::test(tokio::test)] + async fn test_io_failure_leaves_blob_decoder_consistent() { + let num_rows = 8u64; + // `positions`/`sizes` only need `num_rows` entries; the failing + // scheduler rejects the request regardless of the ranges it is given. + let descs = PrimitiveArray::::from_iter_values(std::iter::repeat_n( + 0u64, + num_rows as usize, + )); + + let mut decoder = BlobFieldDecoder { + io: Arc::new(FailingScheduler), + unloaded_descriptions: None, + positions: descs.clone(), + sizes: descs, + num_rows, + loaded: VecDeque::new(), + validity: VecDeque::new(), + rows_loaded: 0, + rows_drained: 0, + base_priority: 0, + }; + + // `wait_for_loaded` propagates the I/O failure... + assert!(decoder.wait_for_loaded(num_rows - 1).await.is_err()); + + // ...and leaves no half-loaded state behind. + assert_eq!(decoder.rows_loaded, 0); + assert!(decoder.loaded.is_empty()); + assert!(decoder.validity.is_empty()); + + // A drain in this state errors instead of panicking on the empty buffer. + assert!(decoder.drain(num_rows).is_err()); + } } diff --git a/rust/lance-encoding/src/previous/encodings/logical/list.rs b/rust/lance-encoding/src/array_encoding/logical/list.rs similarity index 81% rename from rust/lance-encoding/src/previous/encodings/logical/list.rs rename to rust/lance-encoding/src/array_encoding/logical/list.rs index 8afd756567d..453e0c744c8 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/list.rs +++ b/rust/lance-encoding/src/array_encoding/logical/list.rs @@ -1,41 +1,58 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::{collections::VecDeque, ops::Range, sync::Arc}; +use std::{ + collections::VecDeque, + ops::Range, + sync::{Arc, OnceLock}, +}; use arrow_array::{ + Array, ArrayRef, BooleanArray, Int32Array, Int64Array, LargeListArray, ListArray, UInt64Array, cast::AsArray, new_empty_array, types::{Int32Type, Int64Type, UInt64Type}, - Array, ArrayRef, BooleanArray, Int32Array, Int64Array, LargeListArray, ListArray, UInt64Array, }; use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, Buffer, NullBuffer, OffsetBuffer}; use arrow_schema::{DataType, Field, Fields}; -use futures::{future::BoxFuture, FutureExt}; -use lance_core::{cache::LanceCache, Error, Result}; +use futures::{FutureExt, future::BoxFuture}; +use lance_core::{Error, Result, cache::LanceCache, utils::parse::str_is_truthy}; use log::trace; -use snafu::location; use tokio::task::JoinHandle; use crate::{ + EncodingsIo, + array_encoding::logical::r#struct::{SimpleStructDecoder, SimpleStructScheduler}, buffer::LanceBuffer, data::{BlockInfo, DataBlock, FixedWidthDataBlock}, decoder::{ - DecodeArrayTask, DecodeBatchScheduler, FilterExpression, ListPriorityRange, MessageType, - NextDecodeTask, PageEncoding, PriorityRange, ScheduledScanLine, SchedulerContext, + DecodeArrayTask, DecodeBatchScheduler, DrainLimit, FieldScheduler, FilterExpression, + ListPriorityRange, LogicalPageDecoder, MessageType, NextDecodeTask, PageEncoding, + PriorityRange, ScheduledScanLine, SchedulerContext, SchedulingJob, }, - encoder::{EncodeTask, EncodedColumn, EncodedPage, FieldEncoder, OutOfLineBuffers}, - format::pb, - previous::{ - decoder::{FieldScheduler, LogicalPageDecoder, SchedulingJob}, - encoder::{ArrayEncoder, EncodedArray}, - encodings::logical::r#struct::{SimpleStructDecoder, SimpleStructScheduler}, + encoder::{ + ArrayEncoder, EncodeTask, EncodedArray, EncodedColumn, EncodedPage, FieldEncoder, + OutOfLineBuffers, }, + format::pb, repdef::RepDefBuilder, utils::accumulation::AccumulationQueue, - EncodingsIo, }; +/// When set, indirect I/O in the 2.0 list scheduler bypasses the backpressure system. +/// +/// This can be a blunt instrument to avoid deadlocks in 2.0 scenarios +/// Set LANCE_BYPASS_INDIRECT_IO_BACKPRESSURE=1 to enable. +static BYPASS_INDIRECT_IO_BACKPRESSURE: OnceLock = OnceLock::new(); + +fn bypass_indirect_io_backpressure() -> bool { + *BYPASS_INDIRECT_IO_BACKPRESSURE.get_or_init(|| { + std::env::var("LANCE_BYPASS_INDIRECT_IO_BACKPRESSURE") + .map(|val| str_is_truthy(&val)) + .unwrap_or(false) + }) +} + // Scheduling lists is tricky. Imagine the following scenario: // // * There are 2000 offsets per offsets page @@ -295,8 +312,7 @@ fn decode_offsets( ); trace!( "List offsets range of {} lists maps to item range {:?}", - num_lists, - items_range + num_lists, items_range ); offsets_offset += num_offsets_to_norm as u32; if !items_range.is_empty() { @@ -332,7 +348,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); @@ -381,7 +397,7 @@ async fn indirect_schedule_task( for message in indirect_messages { for decoder in message.decoders { - let decoder = decoder.into_legacy(); + let decoder = decoder.into_array(); if !decoder.path.is_empty() { root_decoder.accept_child(decoder)?; } @@ -434,15 +450,16 @@ impl SchedulingJob for ListFieldSchedulingJob<'_> { let list_reqs = self.list_requests_iter.next(offsets_scheduled); trace!( "Scheduled {} offsets which maps to list requests: {:?}", - offsets_scheduled, - list_reqs + offsets_scheduled, list_reqs ); let null_offset_adjustment = list_reqs[0].null_offset_adjustment; // It shouldn't be possible for `list_reqs` to span more than one offsets page and so it shouldn't // be possible for the null_offset_adjustment to change - debug_assert!(list_reqs - .iter() - .all(|req| req.null_offset_adjustment == null_offset_adjustment)); + debug_assert!( + list_reqs + .iter() + .all(|req| req.null_offset_adjustment == null_offset_adjustment) + ); let num_rows = list_reqs.iter().map(|req| req.num_lists).sum::(); // offsets is a uint64 which is guaranteed to create one decoder on each call to schedule_next let next_offsets_decoder = next_offsets @@ -450,12 +467,17 @@ impl SchedulingJob for ListFieldSchedulingJob<'_> { .into_iter() .next() .unwrap() - .into_legacy() + .into_array() .decoder; let items_scheduler = self.scheduler.items_scheduler.clone(); let items_type = self.scheduler.items_field.data_type().clone(); - let io = context.io().clone(); + let base_io = context.io().clone(); + let io = if bypass_indirect_io_backpressure() { + base_io.with_bypass_backpressure().unwrap_or(base_io) + } else { + base_io + }; let cache = context.cache().clone(); // Immediately spawn the indirect scheduling @@ -615,14 +637,59 @@ struct ListDecodeTask { offset_type: DataType, } +fn oversized_batch_error( + items_field: &Field, + requested_range: Range, + decodable_prefix_end: u64, + num_items: u64, +) -> Error { + let prefix_detail = if decodable_prefix_end > requested_range.start { + format!( + "rows {}..{} fit, but requesting rows {}..{} would require {num_items} {}", + requested_range.start, + decodable_prefix_end, + requested_range.start, + requested_range.end, + if items_field.data_type() == &DataType::UInt8 { + "bytes" + } else { + "items" + } + ) + } else { + format!( + "requesting rows {}..{} would require {num_items} {}", + requested_range.start, + requested_range.end, + if items_field.data_type() == &DataType::UInt8 { + "bytes" + } else { + "items" + } + ) + }; + if items_field.data_type() == &DataType::UInt8 { + Error::not_supported(format!( + "Could not create array with more than 2GiB of string/binary data in a single batch \ + ({prefix_detail}). Please reduce the batch_size, set LANCE_DEFAULT_BATCH_SIZE to a \ + smaller value, or convert the column to large_string/large_binary." + )) + } else { + Error::not_supported(format!( + "Could not create a list array with more than i32::MAX items in a single batch \ + ({prefix_detail}). Please reduce the batch_size." + )) + } +} + 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())))?; @@ -641,33 +708,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 v2.0 array + // v2.0 path does not need it so we return 0. + Ok((array, 0)) } } @@ -757,11 +827,14 @@ impl LogicalPageDecoder for ListPageDecoder { } } if actual_num_rows < num_rows { - // TODO: We should be able to automatically - // shrink the read batch size if we detect the batches are going to be huge (maybe - // even achieve this with a read_batch_bytes parameter, though some estimation may - // still be required) - return Err(Error::NotSupported { source: format!("loading a batch of {} lists would require creating an array with over i32::MAX items and we don't yet support returning smaller than requested batches", num_rows).into(), location: location!() }); + let requested_range = self.rows_drained..self.rows_drained + num_rows; + let num_items = self.offsets[requested_range.end as usize] - item_start; + return Err(oversized_batch_error( + self.items_field.as_ref(), + requested_range, + self.rows_drained + actual_num_rows, + num_items, + )); } let offsets = self.offsets [self.rows_drained as usize..(self.rows_drained + actual_num_rows + 1) as usize] @@ -795,6 +868,55 @@ impl LogicalPageDecoder for ListPageDecoder { }) } + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + if num_rows == 0 { + return Ok(DrainLimit { rows: 0, bytes: 0 }); + } + let start_index = self.rows_drained as usize; + let end_index = start_index + num_rows as usize; + let start_offset = self.offsets[start_index]; + // The list's own offsets buffer counts items; when it is i32, the cumulative + // item count of one output array must stay within the budget. Offsets are + // rebased per output batch, so only the delta from `start_offset` counts. + let mut safe_rows = num_rows; + if self.offset_type != DataType::Int64 { + let max_offset = start_offset.saturating_add(byte_budget); + safe_rows = self.offsets[start_index + 1..=end_index] + .partition_point(|offset| *offset <= max_offset) as u64; + } + + // The item array has its own offset buffers with their own budget; shrink to + // the largest whole number of lists whose items also fit. + let mut item_bytes = 0u64; + let mut safe_end_index = start_index + safe_rows as usize; + let requested_items = self.offsets[safe_end_index] - start_offset; + if requested_items > 0 + && let Some(item_decoder) = &self.item_decoder + { + let item_limit = item_decoder.max_rows_to_drain(requested_items, byte_budget)?; + item_bytes = item_limit.bytes; + if item_limit.rows < requested_items { + let max_item_offset = start_offset + item_limit.rows; + safe_rows = self.offsets[start_index + 1..=safe_end_index] + .partition_point(|offset| *offset <= max_item_offset) + as u64; + safe_end_index = start_index + safe_rows as usize; + } + } + let items_consumed = self.offsets[safe_end_index] - start_offset; + let bytes = if self.offset_type != DataType::Int64 { + // One shared budget covers both nesting levels conservatively: charge + // whichever level consumes more. + items_consumed.max(item_bytes) + } else { + item_bytes + }; + Ok(DrainLimit { + rows: safe_rows, + bytes, + }) + } + fn num_rows(&self) -> u64 { self.num_rows } @@ -936,7 +1058,7 @@ impl ListOffsetsEncoder { description: PageEncoding::Legacy(description), num_rows, column_idx, - row_number: 0, // Legacy encoders do not use + row_number: 0, // V2.0 encoders do not use }) }) .map(|res_res| res_res.unwrap()) @@ -1256,3 +1378,128 @@ impl FieldEncoder for ListFieldEncoder { .boxed() } } + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use arrow_buffer::BooleanBuffer; + use arrow_schema::{DataType, Field}; + + use super::*; + use crate::decoder::LogicalPageDecoder; + + #[test] + fn oversized_binary_batch_error_is_actionable() { + let error = oversized_batch_error( + &Field::new("item", DataType::UInt8, false), + 32..160, + 96, + i32::MAX as u64 + 1, + ); + assert!( + error + .to_string() + .contains("more than 2GiB of string/binary data") + ); + assert!(error.to_string().contains("rows 32..96 fit")); + assert!(error.to_string().contains("requesting rows 32..160")); + assert!(error.to_string().contains("batch_size")); + assert!(error.to_string().contains("LANCE_DEFAULT_BATCH_SIZE")); + assert!(error.to_string().contains("large_string/large_binary")); + } + + #[test] + fn oversized_list_batch_error_is_actionable() { + let error = oversized_batch_error( + &Field::new("item", DataType::Int32, false), + 8..32, + 16, + i32::MAX as u64 + 1, + ); + let message = error.to_string(); + assert!(message.contains("list array")); + assert!(message.contains("more than i32::MAX items")); + assert!(message.contains("rows 8..16 fit")); + assert!(message.contains("requesting rows 8..32")); + } + + #[test] + fn list_decoder_overflow_reports_row_span() { + let mut decoder = ListPageDecoder { + unloaded: None, + offsets: Arc::<[u64]>::from(vec![0_u64, 1, 2, 3, 3 + i32::MAX as u64 + 1]), + validity: BooleanBuffer::from_iter([true, true, true, true]), + item_decoder: None, + num_rows: 4, + rows_drained: 2, + rows_loaded: 4, + items_field: Arc::new(Field::new("item", DataType::UInt8, false)), + offset_type: DataType::Int32, + data_type: DataType::List(Arc::new(Field::new("item", DataType::UInt8, false))), + }; + + let Err(error) = decoder.drain(2) else { + panic!("expected overflow error"); + }; + let message = error.to_string(); + assert!(message.contains("rows 2..3 fit")); + assert!(message.contains("requesting rows 2..4")); + assert!(message.contains(&(i32::MAX as u64 + 2).to_string())); + assert!(message.contains("batch_size")); + } + + fn list_page_decoder(offsets: Vec) -> ListPageDecoder { + let items_field = Arc::new(Field::new("item", DataType::Int32, true)); + ListPageDecoder { + unloaded: None, + validity: BooleanBuffer::new_set(offsets.len() - 1), + num_rows: offsets.len() as u64 - 1, + offsets: offsets.into(), + item_decoder: None, + rows_drained: 0, + rows_loaded: 0, + data_type: DataType::List(items_field.clone()), + items_field, + offset_type: DataType::Int32, + } + } + + const FULL_BUDGET: u64 = i32::MAX as u64; + + #[test] + fn test_list_page_limits_i32_offset_batches() { + let mut decoder = list_page_decoder(vec![0, i32::MAX as u64, i32::MAX as u64 + 1]); + let limit = decoder.max_rows_to_drain(2, FULL_BUDGET).unwrap(); + assert_eq!(limit.rows, 1); + assert_eq!(limit.bytes, i32::MAX as u64); + + // Offsets are rebased for each output batch, so the following row is safe. + decoder.rows_drained = 1; + let limit = decoder.max_rows_to_drain(1, FULL_BUDGET).unwrap(); + assert_eq!(limit.rows, 1); + assert_eq!(limit.bytes, 1); + } + + #[test] + fn test_list_page_stays_within_reduced_budget() { + // Mid-batch a page sees only the budget the batch has left; splitting is + // by actual consumption, not page boundaries. + let decoder = list_page_decoder(vec![0, 10, 20, 30, 40]); + let limit = decoder.max_rows_to_drain(4, 25).unwrap(); + assert_eq!(limit.rows, 2); + assert_eq!(limit.bytes, 20); + let limit = decoder.max_rows_to_drain(4, FULL_BUDGET).unwrap(); + assert_eq!(limit.rows, 4); + assert_eq!(limit.bytes, 40); + } + + #[test] + fn test_list_page_reports_zero_rows_for_single_row_over_i32_offset_limit() { + // The stream root emits a zero-row limit as a single-row batch, letting a + // genuinely oversized row fail at the arrow layer as it always did. + let decoder = list_page_decoder(vec![0, i32::MAX as u64 + 1]); + let limit = decoder.max_rows_to_drain(1, FULL_BUDGET).unwrap(); + assert_eq!(limit.rows, 0); + } +} diff --git a/rust/lance-encoding/src/previous/encodings/logical/primitive.rs b/rust/lance-encoding/src/array_encoding/logical/primitive.rs similarity index 82% rename from rust/lance-encoding/src/previous/encodings/logical/primitive.rs rename to rust/lance-encoding/src/array_encoding/logical/primitive.rs index 3326f62664b..66a6bbdcac4 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/array_encoding/logical/primitive.rs @@ -3,19 +3,21 @@ use std::{fmt::Debug, ops::Range, sync::Arc, vec}; -use arrow_array::{cast::AsArray, make_array, Array, ArrayRef}; +use arrow_array::{Array, ArrayRef, cast::AsArray, make_array}; use arrow_buffer::bit_util; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use log::trace; -use snafu::location; use crate::decoder::{ColumnBuffers, PageBuffers}; -use crate::previous::decoder::{FieldScheduler, LogicalPageDecoder, SchedulingJob}; -use crate::previous::encoder::ArrayEncodingStrategy; +use crate::decoder::{ + DrainLimit, FieldScheduler, I32_OFFSET_BYTE_BUDGET, LogicalPageDecoder, SchedulingJob, + has_i32_offsets, +}; +use crate::encoder::ArrayEncodingStrategy; use crate::utils::accumulation::AccumulationQueue; -use crate::{data::DataBlock, previous::encodings::physical::decoder_from_array_encoding}; -use lance_core::{datatypes::Field, Error, Result}; +use crate::{array_encoding::physical::decoder_from_array_encoding, data::DataBlock}; +use lance_core::{Error, Result, datatypes::Field}; use crate::{ decoder::{ @@ -134,8 +136,7 @@ impl SchedulingJob for PrimitiveFieldSchedulingJob<'_> { let mut cur_page = &self.scheduler.page_schedulers[self.page_idx]; trace!( "Current range is {:?} and current page has {} rows", - range, - cur_page.num_rows + range, cur_page.num_rows ); // Skip entire pages until we have some overlap with our next range while cur_page.num_rows + self.global_row_offset <= range.start { @@ -290,7 +291,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)?; @@ -315,10 +316,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 v2.0 array + // v2.0 path does not need it so we return 0. + Ok((array, 0)) } } @@ -343,10 +346,10 @@ impl LogicalPageDecoder for PrimitiveFieldDecoder { fn drain(&mut self, num_rows: u64) -> Result { if self.physical_decoder.as_ref().is_none() { - return Err(lance_core::Error::Internal { - message: format!("drain was called on primitive field decoder for data type {} on column {} but the decoder was never awaited", self.data_type, self.column_index), - location: location!(), - }); + return Err(lance_core::Error::internal(format!( + "drain was called on primitive field decoder for data type {} on column {} but the decoder was never awaited", + self.data_type, self.column_index + ))); } let rows_to_skip = self.rows_drained; @@ -368,6 +371,58 @@ impl LogicalPageDecoder for PrimitiveFieldDecoder { }) } + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + if num_rows == 0 || !has_i32_offsets(&self.data_type) { + return Ok(DrainLimit { + rows: num_rows, + bytes: 0, + }); + } + let Some(physical_decoder) = self.physical_decoder.as_ref() else { + return Err(lance_core::Error::internal(format!( + "max_rows_to_drain was called on primitive field decoder for data type {} on column {} but the decoder was never awaited", + self.data_type, self.column_index + ))); + }; + match physical_decoder.variable_width_bytes(self.rows_drained, num_rows)? { + Some(bytes) if bytes <= byte_budget => Ok(DrainLimit { + rows: num_rows, + bytes, + }), + Some(_) => { + // Largest prefix of the request that still fits the budget. + // `variable_width_bytes` is monotonically non-decreasing in the row + // count; zero rows always fit. + let mut fits = 0u64; + let mut too_big = num_rows; + while fits + 1 < too_big { + let mid = fits + (too_big - fits) / 2; + let mid_bytes = physical_decoder + .variable_width_bytes(self.rows_drained, mid)? + .unwrap_or(0); + if mid_bytes <= byte_budget { + fits = mid; + } else { + too_big = mid; + } + } + let bytes = physical_decoder + .variable_width_bytes(self.rows_drained, fits)? + .unwrap_or(0); + Ok(DrainLimit { rows: fits, bytes }) + } + // The encoding cannot report sizes. Only accept this page's rows when + // the batch has accumulated no variable-width bytes yet (the budget is + // still whole), and charge the entire budget so nothing stacks after + // them; otherwise contribute nothing so the batch ends before them. + None if byte_budget >= I32_OFFSET_BYTE_BUDGET => Ok(DrainLimit { + rows: num_rows, + bytes: byte_budget, + }), + None => Ok(DrainLimit { rows: 0, bytes: 0 }), + } + } + fn rows_loaded(&self) -> u64 { if self.unloaded_physical_decoder.is_some() { 0 @@ -439,15 +494,15 @@ impl PrimitiveFieldEncoder { description: PageEncoding::Legacy(description), num_rows: num_values, column_idx, - row_number: 0, // legacy encoders do not use + row_number: 0, // v2.0 encoders do not use }) }) .map(|res_res| { res_res.unwrap_or_else(|err| { - Err(Error::Internal { - message: format!("Encoding task failed with error: {:?}", err), - location: location!(), - }) + Err(Error::internal(format!( + "Encoding task failed with error: {:?}", + err + ))) }) }) .boxed()) @@ -455,6 +510,7 @@ impl PrimitiveFieldEncoder { // Creates an encode task, consuming all buffered data fn do_flush(&mut self, arrays: Vec) -> Result> { + DataBlock::validate_arrays(&arrays, &self.field.name)?; if arrays.len() == 1 { let array = arrays.into_iter().next().unwrap(); let size_bytes = array.get_buffer_memory_size(); diff --git a/rust/lance-encoding/src/previous/encodings/logical/struct.rs b/rust/lance-encoding/src/array_encoding/logical/struct.rs similarity index 85% rename from rust/lance-encoding/src/previous/encodings/logical/struct.rs rename to rust/lance-encoding/src/array_encoding/logical/struct.rs index 5a4dc60ee85..6153f1a0967 100644 --- a/rust/lance-encoding/src/previous/encodings/logical/struct.rs +++ b/rust/lance-encoding/src/array_encoding/logical/struct.rs @@ -9,17 +9,16 @@ use std::{ use crate::{ decoder::{ - DecodeArrayTask, FilterExpression, MessageType, NextDecodeTask, PriorityRange, + DecodeArrayTask, DrainLimit, FilterExpression, MessageType, NextDecodeTask, PriorityRange, ScheduledScanLine, SchedulerContext, }, - previous::decoder::{DecoderReady, FieldScheduler, LogicalPageDecoder, SchedulingJob}, + decoder::{DecoderReady, FieldScheduler, LogicalPageDecoder, SchedulingJob}, }; use arrow_array::{ArrayRef, StructArray}; use arrow_schema::{DataType, Field, Fields}; -use futures::{future::BoxFuture, stream::FuturesUnordered, FutureExt, StreamExt, TryStreamExt}; +use futures::{FutureExt, StreamExt, TryStreamExt, future::BoxFuture, stream::FuturesUnordered}; use lance_core::{Error, Result}; use log::trace; -use snafu::location; #[derive(Debug)] struct SchedulingJobWithStatus<'a> { @@ -57,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 v2.0 array + // 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, + )) } } @@ -207,8 +208,7 @@ impl SchedulingJob for SimpleStructSchedulerJob<'_> { let child_scan = next_child.job.schedule_next(scoped.context, priority)?; trace!( "Scheduled {} rows for child {}", - child_scan.rows_scheduled, - next_child.col_idx + child_scan.rows_scheduled, next_child.col_idx ); next_child.rows_scheduled += child_scan.rows_scheduled; next_child.rows_remaining -= child_scan.rows_scheduled; @@ -358,9 +358,7 @@ impl ChildState { async fn wait_for_loaded(&mut self, loaded_need: u64) -> Result<()> { trace!( "Struct child {} waiting for more than {} rows to be loaded and {} are fully loaded already", - self.field_index, - loaded_need, - self.rows_loaded, + self.field_index, loaded_need, self.rows_loaded, ); let mut fully_loaded = self.rows_popped; for (page_idx, next_decoder) in self.scheduled.iter_mut().enumerate() { @@ -371,10 +369,7 @@ impl ChildState { let need_for_page = (rows_in_page - 1).min(current_need); trace!( "Struct child {} page {} will wait until more than {} rows loaded from page with {} rows", - self.field_index, - page_idx, - need_for_page, - rows_in_page, + self.field_index, page_idx, need_for_page, rows_in_page, ); // We might only await part of a page. This is important for things // like the struct> case where we have one outer page, one @@ -386,10 +381,7 @@ impl ChildState { fully_loaded += now_loaded; trace!( "Struct child {} page {} await and now has {} loaded rows and we have {} fully loaded", - self.field_index, - page_idx, - now_loaded, - fully_loaded + self.field_index, page_idx, now_loaded, fully_loaded ); } else { fully_loaded += next_decoder.num_rows(); @@ -401,9 +393,7 @@ impl ChildState { self.rows_loaded = fully_loaded; trace!( "Struct child {} loaded {} new rows and now {} are loaded", - self.field_index, - fully_loaded, - self.rows_loaded + self.field_index, fully_loaded, self.rows_loaded ); Ok(()) } @@ -413,8 +403,7 @@ impl ChildState { trace!( "Draining {} rows from struct page with {} rows already drained", - num_rows, - self.rows_drained + num_rows, self.rows_drained ); let mut remaining = num_rows; let mut composite = CompositeDecodeTask { @@ -439,6 +428,31 @@ impl ChildState { composite.has_more = self.rows_drained != self.num_rows; Ok(composite) } + + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + let mut safe_rows = 0; + let mut bytes = 0u64; + let mut budget = byte_budget; + let mut remaining = num_rows; + for decoder in &self.scheduled { + if remaining == 0 { + break; + } + let rows_in_page = remaining.min(decoder.rows_left()); + let page_limit = decoder.max_rows_to_drain(rows_in_page, budget)?; + safe_rows += page_limit.rows; + bytes = bytes.saturating_add(page_limit.bytes); + if page_limit.rows < rows_in_page { + break; + } + budget = budget.saturating_sub(page_limit.bytes); + remaining -= rows_in_page; + } + Ok(DrainLimit { + rows: safe_rows, + bytes, + }) + } } // Wrapper around ChildState that orders using rows_unawaited @@ -531,7 +545,7 @@ impl LogicalPageDecoder for SimpleStructDecoder { .push_back(child.decoder); } else { // This decoder is intended for one of our children - let intended = self.children[child_idx as usize].scheduled.back_mut().ok_or_else(|| Error::Internal { message: format!("Decoder scheduled for child at index {} but we don't have any child at that index yet", child_idx), location: location!() })?; + let intended = self.children[child_idx as usize].scheduled.back_mut().ok_or_else(|| Error::internal(format!("Decoder scheduled for child at index {} but we don't have any child at that index yet", child_idx)))?; intended.accept_child(child)?; } Ok(()) @@ -558,16 +572,36 @@ impl LogicalPageDecoder for SimpleStructDecoder { }) } + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + // Each child column concatenates into its own Arrow array, so every child + // gets the full budget; the most constrained child bounds the batch. + self.children.iter().try_fold( + DrainLimit { + rows: num_rows, + bytes: 0, + }, + |limit, child| { + child + .max_rows_to_drain(num_rows, byte_budget) + .map(|child_limit| DrainLimit { + rows: limit.rows.min(child_limit.rows), + bytes: limit.bytes.max(child_limit.bytes), + }) + }, + ) + } + fn rows_loaded(&self) -> u64 { self.children.iter().map(|c| c.rows_loaded).min().unwrap() } fn rows_drained(&self) -> u64 { // All children should have the same number of rows drained - debug_assert!(self - .children - .iter() - .all(|c| c.rows_drained == self.children[0].rows_drained)); + debug_assert!( + self.children + .iter() + .all(|c| c.rows_drained == self.children[0].rows_drained) + ); self.children[0].rows_drained } @@ -592,7 +626,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 @@ -611,16 +645,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 v2.0 array + // 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.rs b/rust/lance-encoding/src/array_encoding/physical.rs similarity index 95% rename from rust/lance-encoding/src/previous/encodings/physical.rs rename to rust/lance-encoding/src/array_encoding/physical.rs index 83c9287403e..66af9e806df 100644 --- a/rust/lance-encoding/src/previous/encodings/physical.rs +++ b/rust/lance-encoding/src/array_encoding/physical.rs @@ -5,16 +5,16 @@ use arrow_schema::DataType; use lance_arrow::DataTypeExt; use crate::{ - buffer::LanceBuffer, - decoder::{PageBuffers, PageScheduler}, - encodings::physical::block::{CompressionConfig, CompressionScheme}, - format::pb::{self, PackedStruct}, - previous::encodings::physical::{ + array_encoding::physical::{ basic::BasicPageScheduler, binary::BinaryPageScheduler, bitmap::DenseBitmapScheduler, dictionary::DictionaryPageScheduler, fixed_size_list::FixedListScheduler, fsst::FsstPageScheduler, packed_struct::PackedStructPageScheduler, value::ValuePageScheduler, }, + buffer::LanceBuffer, + decoder::{PageBuffers, PageScheduler}, + encodings::physical::block::{CompressionConfig, CompressionScheme}, + format::pb::{self, PackedStruct}, }; pub mod basic; @@ -47,14 +47,12 @@ fn get_buffer(buffer_desc: &pb::Buffer, buffers: &PageBuffers) -> (u64, u64) { /// Convert a protobuf buffer encoding into a physical page scheduler fn get_buffer_decoder(encoding: &pb::Flat, buffers: &PageBuffers) -> Box { let (buffer_offset, buffer_size) = get_buffer(encoding.buffer.as_ref().unwrap(), buffers); - let compression_config: CompressionConfig = if encoding.compression.is_none() { - CompressionConfig::new(CompressionScheme::None, None) - } else { - let compression = encoding.compression.as_ref().unwrap(); - CompressionConfig::new( + let compression_config: CompressionConfig = match encoding.compression.as_ref() { + None => CompressionConfig::new(CompressionScheme::None, None), + Some(compression) => CompressionConfig::new( compression.scheme.as_str().parse().unwrap(), compression.level, - ) + ), }; match encoding.bits_per_value { 1 => Box::new(DenseBitmapScheduler::new(buffer_offset)), @@ -295,9 +293,9 @@ pub fn decoder_from_array_encoding( #[cfg(test)] mod tests { + use crate::array_encoding::physical::get_buffer_decoder; use crate::decoder::{ColumnBuffers, FileBuffers, PageBuffers}; use crate::format::pb; - use crate::previous::encodings::physical::get_buffer_decoder; #[test] fn test_get_buffer_decoder_for_compressed_buffer() { @@ -323,6 +321,9 @@ mod tests { positions_and_sizes: &[], }, ); - assert_eq!(format!("{:?}", page_scheduler).as_str(), "ValuePageScheduler { bytes_per_value: 1, buffer_offset: 0, buffer_size: 100, compression_config: CompressionConfig { scheme: Zstd, level: Some(0) } }"); + assert_eq!( + format!("{:?}", page_scheduler).as_str(), + "ValuePageScheduler { bytes_per_value: 1, buffer_offset: 0, buffer_size: 100, compression_config: CompressionConfig { scheme: Zstd, level: Some(0) } }" + ); } } diff --git a/rust/lance-encoding/src/previous/encodings/physical/basic.rs b/rust/lance-encoding/src/array_encoding/physical/basic.rs similarity index 93% rename from rust/lance-encoding/src/previous/encodings/physical/basic.rs rename to rust/lance-encoding/src/array_encoding/physical/basic.rs index 97fc8c18149..fa4c5824752 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/basic.rs +++ b/rust/lance-encoding/src/array_encoding/physical/basic.rs @@ -4,15 +4,15 @@ use std::sync::Arc; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use log::trace; use crate::{ + EncodingsIo, data::{AllNullDataBlock, BlockInfo, DataBlock, NullableDataBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, + encoder::{ArrayEncoder, EncodedArray}, format::ProtobufUtils, - previous::encoder::{ArrayEncoder, EncodedArray}, - EncodingsIo, }; use lance_core::Result; @@ -156,6 +156,16 @@ struct BasicPageDecoder { } impl PrimitivePageDecoder for BasicPageDecoder { + fn variable_width_bytes(&self, rows_to_skip: u64, num_rows: u64) -> Result> { + match &self.mode { + DataNullStatus::Some(decoders) => { + decoders.values.variable_width_bytes(rows_to_skip, num_rows) + } + DataNullStatus::All => Ok(Some(0)), + DataNullStatus::None(values) => values.variable_width_bytes(rows_to_skip, num_rows), + } + } + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { match &self.mode { DataNullStatus::Some(decoders) => { diff --git a/rust/lance-encoding/src/previous/encodings/physical/binary.rs b/rust/lance-encoding/src/array_encoding/physical/binary.rs similarity index 59% rename from rust/lance-encoding/src/previous/encodings/physical/binary.rs rename to rust/lance-encoding/src/array_encoding/physical/binary.rs index 2d0e2147d37..f614b1e2ed3 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/array_encoding/physical/binary.rs @@ -4,33 +4,52 @@ use core::panic; use std::sync::Arc; +use arrow_array::ArrayRef; use arrow_array::cast::AsArray; use arrow_array::types::UInt64Type; -use arrow_array::ArrayRef; -use arrow_buffer::{bit_util, BooleanBuffer, BooleanBufferBuilder, NullBuffer, ScalarBuffer}; +use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, NullBuffer, ScalarBuffer, bit_util}; use futures::TryFutureExt; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; +use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; use crate::buffer::LanceBuffer; use crate::data::{ BlockInfo, DataBlock, FixedWidthDataBlock, NullableDataBlock, VariableWidthBlock, }; +use crate::decoder::LogicalPageDecoder; +use crate::encoder::{ArrayEncoder, EncodedArray}; use crate::encodings::physical::block::{ BufferCompressor, CompressionConfig, GeneralBufferCompressor, }; use crate::format::ProtobufUtils; -use crate::previous::decoder::LogicalPageDecoder; -use crate::previous::encoder::{ArrayEncoder, EncodedArray}; -use crate::previous::encodings::logical::primitive::PrimitiveFieldDecoder; use crate::{ - decoder::{PageScheduler, PrimitivePageDecoder}, EncodingsIo, + decoder::{PageScheduler, PrimitivePageDecoder}, }; use arrow_array::{PrimitiveArray, UInt64Array}; use arrow_schema::DataType; -use lance_core::Result; +use lance_core::{Error, Result}; + +fn oversized_binary_batch_error(num_rows: u64, num_bytes: u64) -> Error { + Error::not_supported(format!( + "Could not create array with more than 2GiB of string/binary data in a single batch \ + ({} rows would require {} bytes). Please reduce the batch_size, set \ + LANCE_DEFAULT_BATCH_SIZE to a smaller value, or convert the column to \ + large_string/large_binary.", + num_rows, num_bytes + )) +} + +fn oversized_large_binary_batch_error(num_rows: u64, num_bytes: u64) -> Error { + Error::not_supported(format!( + "Could not create large_string/large_binary array in a single batch because {} rows \ + would require {} bytes, which exceeds i64::MAX. Please reduce the batch_size or set \ + LANCE_DEFAULT_BATCH_SIZE to a smaller value.", + num_rows, num_bytes + )) +} struct IndicesNormalizer { indices: Vec, @@ -57,7 +76,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 +85,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) { @@ -109,7 +139,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) } } @@ -201,11 +231,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(); @@ -251,6 +282,21 @@ struct BinaryPageDecoder { } impl PrimitivePageDecoder for BinaryPageDecoder { + fn variable_width_bytes(&self, rows_to_skip: u64, num_rows: u64) -> Result> { + if num_rows == 0 { + return Ok(Some(0)); + } + // `decoded_indices` holds one cumulative byte offset per row plus a final + // sentinel, so the value bytes for the requested rows are the difference + // between the bounding entries. Only value bytes count: Arrow's i32 + // limit constrains the final offset value, not the offset buffer size. + let value_bytes = self + .decoded_indices + .value((rows_to_skip + num_rows) as usize) + - self.decoded_indices.value(rows_to_skip as usize); + Ok(Some(value_bytes)) + } + // Continuing the example from BinaryPageScheduler // Suppose batch_size = 2. Then first, rows_to_skip=0, num_rows=2 // Need to scan 2 rows @@ -303,14 +349,25 @@ impl PrimitivePageDecoder for BinaryPageDecoder { // Normalize and cast (TODO: could fuse these into one pass for micro-optimization) let target_vec = target_offsets.values(); let start = target_vec[0]; - let offsets_buffer = - match bytes_per_offset { - 4 => ScalarBuffer::from_iter(target_vec.iter().map(|x| (x - start) as i32)) - .into_inner(), - 8 => ScalarBuffer::from_iter(target_vec.iter().map(|x| (x - start) as i64)) - .into_inner(), - _ => panic!("Unsupported offsets type"), - }; + let end = *target_vec.last().unwrap(); + let num_bytes = end - start; + let offsets_buffer = match bytes_per_offset { + 4 => { + if num_bytes > i32::MAX as u64 { + return Err(oversized_binary_batch_error(num_rows, num_bytes)); + } + ScalarBuffer::from_iter(target_vec.iter().map(|&offset| (offset - start) as i32)) + .into_inner() + } + 8 => { + if num_bytes > i64::MAX as u64 { + return Err(oversized_large_binary_batch_error(num_rows, num_bytes)); + } + ScalarBuffer::from_iter(target_vec.iter().map(|&offset| (offset - start) as i64)) + .into_inner() + } + _ => panic!("Unsupported offsets type"), + }; let bytes_to_skip = self.decoded_indices.value(rows_to_skip as usize); let num_bytes = self @@ -526,6 +583,38 @@ mod tests { use super::*; + #[derive(Debug)] + struct EmptyBytesDecoder; + + impl PrimitivePageDecoder for EmptyBytesDecoder { + fn decode(&self, _rows_to_skip: u64, _num_rows: u64) -> Result { + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value: 8, + data: LanceBuffer::empty(), + num_values: 0, + block_info: BlockInfo::new(), + })) + } + } + + #[derive(Debug)] + struct BytesDecoder { + bytes: Vec, + } + + impl PrimitivePageDecoder for BytesDecoder { + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { + let start = rows_to_skip as usize; + let end = start + num_rows as usize; + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value: 8, + data: LanceBuffer::from(self.bytes[start..end].to_vec()), + num_values: num_rows, + block_info: BlockInfo::new(), + })) + } + } + #[test] fn test_encode_indices_adjusts_nulls() { // Null entries in string arrays should be adjusted @@ -556,4 +645,229 @@ mod tests { ); assert_eq!(null_adjustment, 7); } + + #[test] + fn test_binary_overflow_error_is_actionable() { + let num_rows = 1; + let start = 100_u64; + let end = start + i32::MAX as u64 + 1; + let decoded_indices = UInt64Array::from(vec![start, end]); + let decoder = BinaryPageDecoder { + decoded_indices, + validity: BooleanBuffer::from_iter([true]), + offsets_type: DataType::Int32, + bytes_decoder: Box::new(EmptyBytesDecoder), + }; + + let error = decoder.decode(0, num_rows).unwrap_err(); + assert!(matches!(error, Error::NotSupported { .. })); + let message = error.to_string(); + assert!(message.contains("more than 2GiB of string/binary data")); + assert!(message.contains("batch_size")); + assert!(message.contains("LANCE_DEFAULT_BATCH_SIZE")); + assert!(message.contains("large_string/large_binary")); + } + + #[test] + fn test_large_binary_overflow_error_is_actionable() { + let num_rows = 1; + let start = 100_u64; + let end = start + i64::MAX as u64 + 1; + let decoded_indices = UInt64Array::from(vec![start, end]); + let decoder = BinaryPageDecoder { + decoded_indices, + validity: BooleanBuffer::from_iter([true]), + offsets_type: DataType::Int64, + bytes_decoder: Box::new(EmptyBytesDecoder), + }; + + let error = decoder.decode(0, num_rows).unwrap_err(); + assert!(matches!(error, Error::NotSupported { .. })); + let message = error.to_string(); + assert!(message.contains("large_string/large_binary")); + assert!(message.contains("exceeds i64::MAX")); + assert!(message.contains("batch_size")); + assert!(message.contains("LANCE_DEFAULT_BATCH_SIZE")); + } + + #[test] + fn test_large_binary_decode_success_path() { + let decoded_indices = UInt64Array::from(vec![100_u64, 102, 105]); + let mut bytes = vec![0_u8; 100]; + bytes.extend_from_slice(b"abcde"); + let decoder = BinaryPageDecoder { + decoded_indices, + validity: BooleanBuffer::from_iter([true, true]), + offsets_type: DataType::Int64, + bytes_decoder: Box::new(BytesDecoder { bytes }), + }; + + let data = decoder.decode(0, 2).unwrap(); + let variable = data.as_variable_width().unwrap(); + assert_eq!(variable.bits_per_offset, 64); + assert_eq!(variable.data.as_ref(), b"abcde"); + assert_eq!( + variable.offsets.borrow_to_typed_slice::().as_ref(), + &[0_i64, 2, 5] + ); + } + + #[derive(Debug)] + struct NeverDecodedStub; + + impl PrimitivePageDecoder for NeverDecodedStub { + fn decode(&self, _rows_to_skip: u64, _num_rows: u64) -> Result { + unreachable!("byte accounting must not decode any values") + } + } + + /// A real physical binary page over strings of the given byte lengths. + fn binary_page(value_lens: &[u64]) -> BinaryPageDecoder { + let mut indices = vec![0u64]; + for len in value_lens { + indices.push(indices.last().unwrap() + len); + } + BinaryPageDecoder { + decoded_indices: UInt64Array::from(indices), + offsets_type: DataType::Int32, + validity: BooleanBuffer::new_set(value_lens.len()), + bytes_decoder: Box::new(NeverDecodedStub), + } + } + + /// Variable-width value bytes of `n` rows starting at `skip` (what the + /// output array's i32 offsets index into). + fn expected_bytes(value_lens: &[u64], skip: usize, n: usize) -> u64 { + value_lens[skip..skip + n].iter().sum::() + } + + #[test] + fn test_unknown_size_pages_split_at_page_boundaries() { + use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; + use crate::array_encoding::logical::r#struct::SimpleStructDecoder; + use crate::decoder::{DecoderReady, I32_OFFSET_BYTE_BUDGET, LogicalPageDecoder}; + use arrow_schema::{Field as ArrowField, Fields}; + use std::collections::VecDeque; + + /// A variable-width page that cannot report sizes (default + /// `variable_width_bytes` returns `None`). + #[derive(Debug)] + struct UnknownSizeStub; + + impl PrimitivePageDecoder for UnknownSizeStub { + fn decode(&self, _rows_to_skip: u64, num_rows: u64) -> Result { + Ok(DataBlock::VariableWidth(VariableWidthBlock { + bits_per_offset: 32, + data: LanceBuffer::empty(), + offsets: LanceBuffer::reinterpret_vec(vec![0_i32; num_rows as usize + 1]), + num_values: num_rows, + block_info: BlockInfo::new(), + })) + } + } + + let fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let mut root = SimpleStructDecoder::new(fields, 6); + for _ in 0..2 { + root.accept_child(DecoderReady { + decoder: Box::new(PrimitiveFieldDecoder::new_from_data( + Arc::new(UnknownSizeStub), + DataType::Utf8, + 3, + false, + )), + path: VecDeque::from([0]), + }) + .unwrap(); + } + + // Each page's size is unknown, so pages must not stack in one batch: + // the batch takes the first page alone and stops at its boundary. + let limit = root.max_rows_to_drain(6, I32_OFFSET_BYTE_BUDGET).unwrap(); + assert_eq!(limit.rows, 3); + } + + #[test] + fn test_physical_binary_page_reports_variable_width_bytes() { + let page = binary_page(&[4, 4, 5]); + assert_eq!( + page.variable_width_bytes(0, 3).unwrap(), + Some(expected_bytes(&[4, 4, 5], 0, 3)) + ); + assert_eq!( + page.variable_width_bytes(1, 2).unwrap(), + Some(expected_bytes(&[4, 4, 5], 1, 2)) + ); + assert_eq!(page.variable_width_bytes(0, 0).unwrap(), Some(0)); + } + + #[test] + fn test_primitive_field_decoder_truncates_to_byte_budget() { + use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; + use crate::decoder::LogicalPageDecoder; + + let lens = [4u64, 4, 5]; + let decoder = PrimitiveFieldDecoder::new_from_data( + Arc::new(binary_page(&lens)), + DataType::Utf8, + 3, + false, + ); + let full = expected_bytes(&lens, 0, 3); + + let limit = decoder.max_rows_to_drain(3, full).unwrap(); + assert_eq!((limit.rows, limit.bytes), (3, full)); + + // One byte short of the full request: only two rows fit. + let limit = decoder.max_rows_to_drain(3, full - 1).unwrap(); + assert_eq!((limit.rows, limit.bytes), (2, expected_bytes(&lens, 0, 2))); + + // Nothing fits: zero rows, so the stream root can raise an error. + let limit = decoder.max_rows_to_drain(3, 0).unwrap(); + assert_eq!(limit.rows, 0); + } + + #[test] + fn test_physical_binary_pages_accumulate_budget_across_pages() { + use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; + use crate::array_encoding::logical::r#struct::SimpleStructDecoder; + use crate::decoder::{DecoderReady, LogicalPageDecoder}; + use arrow_schema::{Field as ArrowField, Fields}; + use std::collections::VecDeque; + + let lens = [4u64, 4, 5]; + let page_bytes = expected_bytes(&lens, 0, 3); + let fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let mut root = SimpleStructDecoder::new(fields, 6); + for _ in 0..2 { + root.accept_child(DecoderReady { + decoder: Box::new(PrimitiveFieldDecoder::new_from_data( + Arc::new(binary_page(&lens)), + DataType::Utf8, + 3, + false, + )), + path: VecDeque::from([0]), + }) + .unwrap(); + } + + // Both pages fit: the batch spans the page boundary. + let limit = root.max_rows_to_drain(6, page_bytes * 2).unwrap(); + assert_eq!((limit.rows, limit.bytes), (6, page_bytes * 2)); + + // Page 1 plus the first two rows of page 2 fit. + let second_page_prefix = expected_bytes(&lens, 0, 2); + let limit = root + .max_rows_to_drain(6, page_bytes + second_page_prefix) + .unwrap(); + assert_eq!( + (limit.rows, limit.bytes), + (5, page_bytes + second_page_prefix) + ); + + // Budget for page 1 only: the batch stops at the page boundary. + let limit = root.max_rows_to_drain(6, page_bytes).unwrap(); + assert_eq!((limit.rows, limit.bytes), (3, page_bytes)); + } } diff --git a/rust/lance-encoding/src/previous/encodings/physical/bitmap.rs b/rust/lance-encoding/src/array_encoding/physical/bitmap.rs similarity index 97% rename from rust/lance-encoding/src/previous/encodings/physical/bitmap.rs rename to rust/lance-encoding/src/array_encoding/physical/bitmap.rs index bab8970783f..2168aff24e2 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/bitmap.rs +++ b/rust/lance-encoding/src/array_encoding/physical/bitmap.rs @@ -6,15 +6,15 @@ use std::{ops::Range, sync::Arc}; use arrow_buffer::BooleanBufferBuilder; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_core::Result; use log::trace; use crate::{ + EncodingsIo, buffer::LanceBuffer, data::{BlockInfo, DataBlock, FixedWidthDataBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, - EncodingsIo, }; /// A physical scheduler for bitmap buffers encoded densely as 1 bit per value @@ -131,10 +131,10 @@ mod tests { use bytes::Bytes; use std::{collections::HashMap, sync::Arc}; + use crate::array_encoding::physical::bitmap::BitmapData; use crate::data::{DataBlock, FixedWidthDataBlock}; use crate::decoder::PrimitivePageDecoder; - use crate::previous::encodings::physical::bitmap::BitmapData; - use crate::testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}; + use crate::testing::{TestCases, check_basic_random, check_round_trip_encoding_of_data}; use super::BitmapDecoder; diff --git a/rust/lance-encoding/src/array_encoding/physical/bitpack.rs b/rust/lance-encoding/src/array_encoding/physical/bitpack.rs new file mode 100644 index 00000000000..a76add68a29 --- /dev/null +++ b/rust/lance-encoding/src/array_encoding/physical/bitpack.rs @@ -0,0 +1,728 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::sync::Arc; + +use arrow_buffer::bit_util::ceil; +use bytes::Bytes; +use futures::future::{BoxFuture, FutureExt}; +use log::trace; + +use lance_bitpacking::BitPacking; +use lance_core::{Error, Result}; + +use crate::buffer::LanceBuffer; +use crate::data::BlockInfo; +use crate::data::{DataBlock, FixedWidthDataBlock}; +use crate::decoder::{PageScheduler, PrimitivePageDecoder}; +use bytemuck::cast_slice; + +const LOG_ELEMS_PER_CHUNK: u8 = 10; +const ELEMS_PER_CHUNK: u64 = 1 << LOG_ELEMS_PER_CHUNK; + +#[derive(Debug)] +pub struct BitpackedForNonNegScheduler { + compressed_bit_width: u64, + uncompressed_bits_per_value: u64, + buffer_offset: u64, +} + +impl BitpackedForNonNegScheduler { + pub fn new( + compressed_bit_width: u64, + uncompressed_bits_per_value: u64, + buffer_offset: u64, + ) -> Self { + Self { + compressed_bit_width, + uncompressed_bits_per_value, + buffer_offset, + } + } + + fn locate_chunk_start(&self, relative_row_num: u64) -> u64 { + let chunk_size = ELEMS_PER_CHUNK * self.compressed_bit_width / 8; + self.buffer_offset + (relative_row_num / ELEMS_PER_CHUNK * chunk_size) + } + + fn locate_chunk_end(&self, relative_row_num: u64) -> u64 { + let chunk_size = ELEMS_PER_CHUNK * self.compressed_bit_width / 8; + self.buffer_offset + (relative_row_num / ELEMS_PER_CHUNK * chunk_size) + chunk_size + } +} + +impl PageScheduler for BitpackedForNonNegScheduler { + fn schedule_ranges( + &self, + ranges: &[std::ops::Range], + scheduler: &Arc, + top_level_row: u64, + ) -> BoxFuture<'static, Result>> { + assert!(!ranges.is_empty()); + + let mut byte_ranges = vec![]; + + // map one bytes to multiple ranges, one bytes has at least one range corresponding to it + let mut bytes_idx_to_range_indices = vec![]; + let first_byte_range = std::ops::Range { + start: self.locate_chunk_start(ranges[0].start), + end: self.locate_chunk_end(ranges[0].end - 1), + }; // the ranges are half-open + byte_ranges.push(first_byte_range); + bytes_idx_to_range_indices.push(vec![ranges[0].clone()]); + + for (i, range) in ranges.iter().enumerate().skip(1) { + let this_start = self.locate_chunk_start(range.start); + let this_end = self.locate_chunk_end(range.end - 1); + + // when the current range start is in the same chunk as the previous range's end, we colaesce this two bytes ranges + // when the current range start is not in the same chunk as the previous range's end, we create a new bytes range + if this_start == self.locate_chunk_start(ranges[i - 1].end - 1) { + byte_ranges.last_mut().unwrap().end = this_end; + bytes_idx_to_range_indices + .last_mut() + .unwrap() + .push(range.clone()); + } else { + byte_ranges.push(this_start..this_end); + bytes_idx_to_range_indices.push(vec![range.clone()]); + } + } + + trace!( + "Scheduling I/O for {} ranges spread across byte range {}..{}", + byte_ranges.len(), + byte_ranges[0].start, + byte_ranges.last().unwrap().end + ); + + let bytes = scheduler.submit_request(byte_ranges.clone(), top_level_row); + + // copy the necessary data from `self` to move into the async block + let compressed_bit_width = self.compressed_bit_width; + let uncompressed_bits_per_value = self.uncompressed_bits_per_value; + let num_rows = ranges.iter().map(|range| range.end - range.start).sum(); + + async move { + let bytes = bytes.await?; + let decompressed_output = bitpacked_for_non_neg_decode( + compressed_bit_width, + uncompressed_bits_per_value, + &bytes, + &bytes_idx_to_range_indices, + num_rows, + ); + Ok(Box::new(BitpackedForNonNegPageDecoder { + uncompressed_bits_per_value, + decompressed_buf: decompressed_output, + }) as Box) + } + .boxed() + } +} + +#[derive(Debug)] +struct BitpackedForNonNegPageDecoder { + // number of bits in the uncompressed value. E.g. this will be 32 for DataType::UInt32 + uncompressed_bits_per_value: u64, + + decompressed_buf: LanceBuffer, +} + +impl PrimitivePageDecoder for BitpackedForNonNegPageDecoder { + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { + if ![8, 16, 32, 64].contains(&self.uncompressed_bits_per_value) { + return Err(Error::invalid_input_source("BitpackedForNonNegPageDecoder should only has uncompressed_bits_per_value of 8, 16, 32, or 64".into())); + } + + let elem_size_in_bytes = self.uncompressed_bits_per_value / 8; + + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + data: self.decompressed_buf.slice_with_length( + (rows_to_skip * elem_size_in_bytes) as usize, + (num_rows * elem_size_in_bytes) as usize, + ), + bits_per_value: self.uncompressed_bits_per_value, + num_values: num_rows, + block_info: BlockInfo::new(), + })) + } +} + +macro_rules! bitpacked_decode { + ($uncompressed_type:ty, $compressed_bit_width:expr, $data:expr, $bytes_idx_to_range_indices:expr, $num_rows:expr) => {{ + let mut decompressed: Vec<$uncompressed_type> = Vec::with_capacity($num_rows as usize); + let packed_chunk_size_in_byte: usize = (ELEMS_PER_CHUNK * $compressed_bit_width) as usize / 8; + let mut decompress_chunk_buf = vec![0 as $uncompressed_type; ELEMS_PER_CHUNK as usize]; + + for (i, bytes) in $data.iter().enumerate() { + let mut ranges_idx = 0; + let mut curr_range_start = $bytes_idx_to_range_indices[i][0].start; + let mut chunk_num = 0; + + while chunk_num * packed_chunk_size_in_byte < bytes.len() { + // Copy for memory alignment + // TODO: This copy should not be needed + let chunk_in_u8: Vec = bytes[chunk_num * packed_chunk_size_in_byte..] + [..packed_chunk_size_in_byte] + .to_vec(); + chunk_num += 1; + let chunk = cast_slice(&chunk_in_u8); + unsafe { + BitPacking::unchecked_unpack( + $compressed_bit_width as usize, + chunk, + &mut decompress_chunk_buf, + ); + } + + loop { + // Case 1: All the elements after (curr_range_start % ELEMS_PER_CHUNK) inside this chunk are needed. + let elems_after_curr_range_start_in_this_chunk = + ELEMS_PER_CHUNK - curr_range_start % ELEMS_PER_CHUNK; + if curr_range_start + elems_after_curr_range_start_in_this_chunk + <= $bytes_idx_to_range_indices[i][ranges_idx].end + { + decompressed.extend_from_slice( + &decompress_chunk_buf[(curr_range_start % ELEMS_PER_CHUNK) as usize..], + ); + curr_range_start += elems_after_curr_range_start_in_this_chunk; + break; + } else { + // Case 2: Only part of the elements after (curr_range_start % ELEMS_PER_CHUNK) inside this chunk are needed. + let elems_this_range_needed_in_this_chunk = + ($bytes_idx_to_range_indices[i][ranges_idx].end - curr_range_start) + .min(ELEMS_PER_CHUNK - curr_range_start % ELEMS_PER_CHUNK); + decompressed.extend_from_slice( + &decompress_chunk_buf[(curr_range_start % ELEMS_PER_CHUNK) as usize..] + [..elems_this_range_needed_in_this_chunk as usize], + ); + if curr_range_start + elems_this_range_needed_in_this_chunk + == $bytes_idx_to_range_indices[i][ranges_idx].end + { + ranges_idx += 1; + if ranges_idx == $bytes_idx_to_range_indices[i].len() { + break; + } + curr_range_start = $bytes_idx_to_range_indices[i][ranges_idx].start; + } else { + curr_range_start += elems_this_range_needed_in_this_chunk; + } + } + } + } + } + + LanceBuffer::reinterpret_vec(decompressed) + }}; +} + +fn bitpacked_for_non_neg_decode( + compressed_bit_width: u64, + uncompressed_bits_per_value: u64, + data: &[Bytes], + bytes_idx_to_range_indices: &[Vec>], + num_rows: u64, +) -> LanceBuffer { + match uncompressed_bits_per_value { + 8 => bitpacked_decode!( + u8, + compressed_bit_width, + data, + bytes_idx_to_range_indices, + num_rows + ), + 16 => bitpacked_decode!( + u16, + compressed_bit_width, + data, + bytes_idx_to_range_indices, + num_rows + ), + 32 => bitpacked_decode!( + u32, + compressed_bit_width, + data, + bytes_idx_to_range_indices, + num_rows + ), + 64 => bitpacked_decode!( + u64, + compressed_bit_width, + data, + bytes_idx_to_range_indices, + num_rows + ), + _ => unreachable!( + "bitpacked_for_non_neg_decode only supports 8, 16, 32, 64 uncompressed_bits_per_value" + ), + } +} + +// A physical scheduler for bitpacked buffers +#[derive(Debug, Clone, Copy)] +pub struct BitpackedScheduler { + bits_per_value: u64, + uncompressed_bits_per_value: u64, + buffer_offset: u64, + signed: bool, +} + +impl BitpackedScheduler { + pub fn new( + bits_per_value: u64, + uncompressed_bits_per_value: u64, + buffer_offset: u64, + signed: bool, + ) -> Self { + Self { + bits_per_value, + uncompressed_bits_per_value, + buffer_offset, + signed, + } + } +} + +impl PageScheduler for BitpackedScheduler { + fn schedule_ranges( + &self, + ranges: &[std::ops::Range], + scheduler: &Arc, + top_level_row: u64, + ) -> BoxFuture<'static, Result>> { + let mut min = u64::MAX; + let mut max = 0; + + let mut buffer_bit_start_offsets: Vec = vec![]; + let mut buffer_bit_end_offsets: Vec> = vec![]; + let byte_ranges = ranges + .iter() + .map(|range| { + let start_byte_offset = range.start * self.bits_per_value / 8; + let mut end_byte_offset = range.end * self.bits_per_value / 8; + if !(range.end * self.bits_per_value).is_multiple_of(8) { + // If the end of the range is not byte-aligned, we need to read one more byte + end_byte_offset += 1; + + let end_bit_offset = range.end * self.bits_per_value % 8; + buffer_bit_end_offsets.push(Some(end_bit_offset as u8)); + } else { + buffer_bit_end_offsets.push(None); + } + + let start_bit_offset = range.start * self.bits_per_value % 8; + buffer_bit_start_offsets.push(start_bit_offset as u8); + + let start = self.buffer_offset + start_byte_offset; + let end = self.buffer_offset + end_byte_offset; + min = min.min(start); + max = max.max(end); + + start..end + }) + .collect::>(); + + trace!( + "Scheduling I/O for {} ranges spread across byte range {}..{}", + byte_ranges.len(), + min, + max + ); + + let bytes = scheduler.submit_request(byte_ranges, top_level_row); + + let bits_per_value = self.bits_per_value; + let uncompressed_bits_per_value = self.uncompressed_bits_per_value; + let signed = self.signed; + async move { + let bytes = bytes.await?; + Ok(Box::new(BitpackedPageDecoder { + buffer_bit_start_offsets, + buffer_bit_end_offsets, + bits_per_value, + uncompressed_bits_per_value, + signed, + data: bytes, + }) as Box) + } + .boxed() + } +} + +#[derive(Debug)] +struct BitpackedPageDecoder { + // bit offsets of the first value within each buffer + buffer_bit_start_offsets: Vec, + + // bit offsets of the last value within each buffer. e.g. if there was a buffer + // with 2 values, packed into 5 bits, this would be [Some(3)], indicating that + // the bits from the 3rd->8th bit in the last byte shouldn't be decoded. + buffer_bit_end_offsets: Vec>, + + // the number of bits used to represent a compressed value. E.g. if the max value + // in the page was 7 (0b111), then this will be 3 + bits_per_value: u64, + + // number of bits in the uncompressed value. E.g. this will be 32 for u32 + uncompressed_bits_per_value: u64, + + // whether or not to use the msb as a sign bit during decoding + signed: bool, + + data: Vec, +} + +impl PrimitivePageDecoder for BitpackedPageDecoder { + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { + let num_bytes = self.uncompressed_bits_per_value / 8 * num_rows; + let mut dest = vec![0; num_bytes as usize]; + + // current maximum supported bits per value = 64 + debug_assert!(self.bits_per_value <= 64); + + let mut rows_to_skip = rows_to_skip; + let mut rows_taken = 0; + let byte_len = self.uncompressed_bits_per_value / 8; + let mut dst_idx = 0; // index for current byte being written to destination buffer + + // create bit mask for source bits + let mask = u64::MAX >> (64 - self.bits_per_value); + + for i in 0..self.data.len() { + let src = &self.data[i]; + let (mut src_idx, mut src_offset) = match compute_start_offset( + rows_to_skip, + src.len(), + self.bits_per_value, + self.buffer_bit_start_offsets[i], + self.buffer_bit_end_offsets[i], + ) { + StartOffset::SkipFull(rows_to_skip_here) => { + rows_to_skip -= rows_to_skip_here; + continue; + } + StartOffset::SkipSome(buffer_start_offset) => ( + buffer_start_offset.index, + buffer_start_offset.bit_offset as u64, + ), + }; + + while src_idx < src.len() && rows_taken < num_rows { + rows_taken += 1; + let mut curr_mask = mask; // copy mask + + // current source byte being written to destination + let mut curr_src = src[src_idx] & (curr_mask << src_offset) as u8; + + // how many bits from the current source value have been written to destination + let mut src_bits_written = 0; + + // the offset within the current destination byte to write to + let mut dst_offset = 0; + + let is_negative = is_encoded_item_negative( + src, + src_idx, + src_offset, + self.bits_per_value as usize, + ); + + while src_bits_written < self.bits_per_value { + // write bits from current source byte into destination + dest[dst_idx] += (curr_src >> src_offset) << dst_offset; + let bits_written = (self.bits_per_value - src_bits_written) + .min(8 - src_offset) + .min(8 - dst_offset); + src_bits_written += bits_written; + dst_offset += bits_written; + src_offset += bits_written; + curr_mask >>= bits_written; + + if dst_offset == 8 { + dst_idx += 1; + dst_offset = 0; + } + + if src_offset == 8 { + src_idx += 1; + src_offset = 0; + if src_idx == src.len() { + break; + } + curr_src = src[src_idx] & curr_mask as u8; + } + } + + // if the type is signed, need to pad out the rest of the byte with 1s + let mut negative_padded_current_byte = false; + if self.signed && is_negative && dst_offset > 0 { + negative_padded_current_byte = true; + while dst_offset < 8 { + dest[dst_idx] |= 1 << dst_offset; + dst_offset += 1; + } + } + + // advance destination offset to the next location + // note that we don't need to do this if we wrote the full number of bits + // because source index would have been advanced by the inner loop above + if self.uncompressed_bits_per_value != self.bits_per_value { + let partial_bytes_written = ceil(self.bits_per_value as usize, 8); + + // we also want to move one location to the next location in destination, + // unless we wrote something byte-aligned in which case the logic above + // would have already advanced dst_idx + let mut to_next_byte = 1; + if self.bits_per_value.is_multiple_of(8) { + to_next_byte = 0; + } + let next_dst_idx = + dst_idx + byte_len as usize - partial_bytes_written + to_next_byte; + + // pad remaining bytes with 1 for negative signed numbers + if self.signed && is_negative { + if !negative_padded_current_byte { + dest[dst_idx] = 0xFF; + } + for i in dest.iter_mut().take(next_dst_idx).skip(dst_idx + 1) { + *i = 0xFF; + } + } + + dst_idx = next_dst_idx; + } + + // If we've reached the last byte, there may be some extra bits from the + // next value outside the range. We don't want to be taking those. + if let Some(buffer_bit_end_offset) = self.buffer_bit_end_offsets[i] + && src_idx == src.len() - 1 + && src_offset >= buffer_bit_end_offset as u64 + { + break; + } + } + } + + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::from(dest), + bits_per_value: self.uncompressed_bits_per_value, + num_values: num_rows, + block_info: BlockInfo::new(), + })) + } +} + +fn is_encoded_item_negative(src: &Bytes, src_idx: usize, src_offset: u64, num_bits: usize) -> bool { + let mut last_byte_idx = src_idx + ((src_offset as usize + num_bits) / 8); + let shift_amount = (src_offset as usize + num_bits) % 8; + let shift_amount = if shift_amount == 0 { + last_byte_idx -= 1; + 7 + } else { + shift_amount - 1 + }; + let last_byte = src[last_byte_idx]; + let sign_bit_mask = 1 << shift_amount; + let sign_bit = last_byte & sign_bit_mask; + + sign_bit > 0 +} + +#[derive(Debug, PartialEq)] +struct BufferStartOffset { + index: usize, + bit_offset: u8, +} + +#[derive(Debug, PartialEq)] +enum StartOffset { + // skip the full buffer. The value is how many rows are skipped + // by skipping the full buffer (e.g., # rows in buffer) + SkipFull(u64), + + // skip to some start offset in the buffer + SkipSome(BufferStartOffset), +} + +/// compute how far ahead in this buffer should we skip ahead and start reading +/// +/// * `rows_to_skip` - how many rows to skip +/// * `buffer_len` - length buf buffer (in bytes) +/// * `bits_per_value` - number of bits used to represent a single bitpacked value +/// * `buffer_start_bit_offset` - offset of the start of the first value within the +/// buffer's first byte +/// * `buffer_end_bit_offset` - end bit of the last value within the buffer. Can be +/// `None` if the end of the last value is byte aligned with end of buffer. +fn compute_start_offset( + rows_to_skip: u64, + buffer_len: usize, + bits_per_value: u64, + buffer_start_bit_offset: u8, + buffer_end_bit_offset: Option, +) -> StartOffset { + let rows_in_buffer = rows_in_buffer( + buffer_len, + bits_per_value, + buffer_start_bit_offset, + buffer_end_bit_offset, + ); + if rows_to_skip >= rows_in_buffer { + return StartOffset::SkipFull(rows_in_buffer); + } + + let start_bit = rows_to_skip * bits_per_value + buffer_start_bit_offset as u64; + let start_byte = start_bit / 8; + + StartOffset::SkipSome(BufferStartOffset { + index: start_byte as usize, + bit_offset: (start_bit % 8) as u8, + }) +} + +/// calculates the number of rows in a buffer +fn rows_in_buffer( + buffer_len: usize, + bits_per_value: u64, + buffer_start_bit_offset: u8, + buffer_end_bit_offset: Option, +) -> u64 { + let mut bits_in_buffer = (buffer_len * 8) as u64 - buffer_start_bit_offset as u64; + + // if the end of the last value of the buffer isn't byte aligned, subtract the + // end offset from the total number of bits in buffer + if let Some(buffer_end_bit_offset) = buffer_end_bit_offset { + bits_in_buffer -= (8 - buffer_end_bit_offset) as u64; + } + + bits_in_buffer / bits_per_value +} + +#[cfg(test)] +pub mod test { + use super::*; + use crate::BufferScheduler; + + use arrow_buffer::ArrowNativeType; + + fn fixed_width_values(block: DataBlock) -> Vec { + let DataBlock::FixedWidth(FixedWidthDataBlock { data, .. }) = block else { + panic!("expected fixed-width data"); + }; + data.borrow_to_typed_slice::().to_vec() + } + + #[test] + fn test_rows_in_buffer() { + let test_cases = vec![ + (5usize, 5u64, 0u8, None, 8u64), + (2, 3, 0, Some(5), 4), + (2, 3, 7, Some(6), 2), + ]; + + for ( + buffer_len, + bits_per_value, + buffer_start_bit_offset, + buffer_end_bit_offset, + expected, + ) in test_cases + { + let result = rows_in_buffer( + buffer_len, + bits_per_value, + buffer_start_bit_offset, + buffer_end_bit_offset, + ); + assert_eq!(expected, result); + } + } + + #[test] + fn test_compute_start_offset() { + let result = compute_start_offset(0, 5, 5, 0, None); + assert_eq!( + StartOffset::SkipSome(BufferStartOffset { + index: 0, + bit_offset: 0 + }), + result + ); + + let result = compute_start_offset(10, 5, 5, 0, None); + assert_eq!(StartOffset::SkipFull(8), result); + } + + #[test_log::test(tokio::test)] + async fn test_bitpacked_scheduler_non_byte_aligned_ranges() { + // Legacy 5-bit LSB-first encoding of values 1..=10, with a two-byte prefix. + let data = Bytes::from_static(&[0xFA, 0xCE, 0x41, 0x0C, 0x52, 0xCC, 0x41, 0x49, 0x01]); + let io: Arc = Arc::new(BufferScheduler::new(data)); + let scheduler = BitpackedScheduler::new(5, 16, 2, false); + + let decoder = scheduler + .schedule_ranges(&[1..3, 5..9], &io, 0) + .await + .unwrap(); + let decoded = decoder.decode(3, 3).unwrap(); + + // The scheduled rows are [2, 3, 6, 7, 8, 9]. Skipping across the first + // Bytes response must begin in the middle of the second response. + assert_eq!(fixed_width_values::(decoded), vec![7, 8, 9]); + } + + #[test_log::test(tokio::test)] + async fn test_bitpacked_scheduler_signed_byte_aligned() { + // Legacy 16-bit little-endian encoding of [513, -1, 4660, -32768]. + let data = Bytes::from_static(&[0x01, 0x02, 0xFF, 0xFF, 0x34, 0x12, 0x00, 0x80]); + let io: Arc = Arc::new(BufferScheduler::new(data)); + let scheduler = BitpackedScheduler::new(16, 32, 0, true); + + let decoder = scheduler.schedule_ranges(&[1..4], &io, 0).await.unwrap(); + let decoded = decoder.decode(0, 3).unwrap(); + + assert_eq!(fixed_width_values::(decoded), vec![-1, 4660, -32768]); + } + + #[test_log::test(tokio::test)] + async fn test_bitpacked_for_non_negative_scheduler_chunk_boundary() { + const BIT_WIDTH: usize = 7; + const NUM_CHUNKS: usize = 2; + const WORDS_PER_CHUNK: usize = ELEMS_PER_CHUNK as usize * BIT_WIDTH / u32::BITS as usize; + + let values = (0..ELEMS_PER_CHUNK as usize * NUM_CHUNKS) + .map(|index| ((index * 13 + 7) % 127) as u32) + .collect::>(); + let mut packed = vec![0_u32; WORDS_PER_CHUNK * NUM_CHUNKS]; + for chunk_index in 0..NUM_CHUNKS { + let value_start = chunk_index * ELEMS_PER_CHUNK as usize; + let word_start = chunk_index * WORDS_PER_CHUNK; + // SAFETY: Both slices have the exact input and output lengths required + // for one 1,024-value chunk at this bit width. + unsafe { + BitPacking::unchecked_pack( + BIT_WIDTH, + &values[value_start..value_start + ELEMS_PER_CHUNK as usize], + &mut packed[word_start..word_start + WORDS_PER_CHUNK], + ); + } + } + + let mut data = vec![0xFA, 0xCE, 0x01]; + data.extend_from_slice(cast_slice(&packed)); + let io: Arc = Arc::new(BufferScheduler::new(Bytes::from(data))); + let scheduler = BitpackedForNonNegScheduler::new(BIT_WIDTH as u64, 32, 3); + + let decoder = scheduler + .schedule_ranges(&[1020..1028, 1530..1533], &io, 0) + .await + .unwrap(); + let decoded = decoder.decode(2, 7).unwrap(); + let expected = (1022..1028) + .chain(1530..1531) + .map(|index| values[index]) + .collect::>(); + + assert_eq!(fixed_width_values::(decoded), expected); + } +} diff --git a/rust/lance-encoding/src/previous/encodings/physical/block.rs b/rust/lance-encoding/src/array_encoding/physical/block.rs similarity index 97% rename from rust/lance-encoding/src/previous/encodings/physical/block.rs rename to rust/lance-encoding/src/array_encoding/physical/block.rs index 517bce4e3a7..3bbd966e24a 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/block.rs +++ b/rust/lance-encoding/src/array_encoding/physical/block.rs @@ -5,9 +5,9 @@ use arrow_schema::DataType; use crate::{ data::{BlockInfo, DataBlock, OpaqueBlock}, + encoder::{ArrayEncoder, EncodedArray}, encodings::physical::block::{CompressedBufferEncoder, CompressionConfig, CompressionScheme}, format::ProtobufUtils, - previous::encoder::{ArrayEncoder, EncodedArray}, }; use lance_core::Result; diff --git a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs b/rust/lance-encoding/src/array_encoding/physical/dictionary.rs similarity index 82% rename from rust/lance-encoding/src/previous/encodings/physical/dictionary.rs rename to rust/lance-encoding/src/array_encoding/physical/dictionary.rs index 7ba906de556..9d02ed782a7 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/dictionary.rs +++ b/rust/lance-encoding/src/array_encoding/physical/dictionary.rs @@ -8,27 +8,26 @@ use arrow_array::builder::{ArrayBuilder, StringBuilder}; use arrow_array::cast::AsArray; use arrow_array::types::UInt8Type; use arrow_array::{ - make_array, new_null_array, Array, ArrayRef, DictionaryArray, StringArray, UInt8Array, + Array, ArrayRef, DictionaryArray, StringArray, UInt8Array, make_array, new_null_array, }; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_arrow::DataTypeExt; use lance_core::{Error, Result}; -use snafu::location; use std::collections::HashMap; +use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; use crate::buffer::LanceBuffer; use crate::data::{ BlockInfo, DataBlock, DictionaryDataBlock, FixedWidthDataBlock, NullableDataBlock, VariableWidthBlock, }; +use crate::decoder::LogicalPageDecoder; use crate::format::ProtobufUtils; -use crate::previous::decoder::LogicalPageDecoder; -use crate::previous::encodings::logical::primitive::PrimitiveFieldDecoder; use crate::{ - decoder::{PageScheduler, PrimitivePageDecoder}, - previous::encoder::{ArrayEncoder, EncodedArray}, EncodingsIo, + decoder::{PageScheduler, PrimitivePageDecoder}, + encoder::{ArrayEncoder, EncodedArray}, }; #[derive(Debug)] @@ -102,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?; @@ -142,6 +141,13 @@ struct DirectDictionaryPageDecoder { } impl PrimitivePageDecoder for DirectDictionaryPageDecoder { + fn variable_width_bytes(&self, _rows_to_skip: u64, _num_rows: u64) -> Result> { + // The decoded batch shares this page's dictionary values; Arrow + // concatenation may merge each page's values into one array, so charge + // the whole dictionary once per page. + Ok(Some(self.decoded_dict.data_size())) + } + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { let indices = self .indices_decoder @@ -162,6 +168,37 @@ struct DictionaryPageDecoder { } impl PrimitivePageDecoder for DictionaryPageDecoder { + fn variable_width_bytes(&self, rows_to_skip: u64, num_rows: u64) -> Result> { + // Decoding materializes each row's dictionary value into a plain string + // array. The u8 indices are cheap to decode, so compute the exact value + // bytes by summing each requested row's dictionary entry length (index 0 + // is the in-band null and contributes nothing). + let Some(strings) = self.decoded_dict.as_any().downcast_ref::() else { + return Ok(None); + }; + let indices = self.indices_decoder.decode(rows_to_skip, num_rows)?; + let indices = match &indices { + DataBlock::FixedWidth(fixed) => fixed, + DataBlock::Nullable(nullable) => { + let Some(fixed) = nullable.data.as_fixed_width_ref() else { + return Ok(None); + }; + fixed + } + _ => return Ok(None), + }; + if indices.bits_per_value != 8 { + return Ok(None); + } + let mut bytes = 0u64; + for &code in indices.data.as_ref() { + if code != 0 { + bytes += strings.value_length((code - 1) as usize) as u64; + } + } + Ok(Some(bytes)) + } + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { // Decode the indices let indices_data = self.indices_decoder.decode(rows_to_skip, num_rows)?; @@ -260,6 +297,16 @@ impl ArrayEncoder for AlreadyDictionaryEncoder { } _ => panic!("Expected dictionary data"), }; + let declared_key_bits = key_type.byte_width() as u64 * 8; + if dict_data.indices.bits_per_value != declared_key_bits { + return Err(Error::invalid_input(format!( + "dictionary indices use {} bits but the declared {} key type uses {} bits; the normalized dictionary has {} values", + dict_data.indices.bits_per_value, + key_type, + declared_key_bits, + dict_data.dictionary.num_values() + ))); + } let num_dictionary_items = dict_data.dictionary.num_values() as u32; let encoded_indices = self.indices_encoder.encode( @@ -361,14 +408,13 @@ impl ArrayEncoder for DictionaryEncoder { buffer_index: &mut u32, ) -> Result { if !matches!(data_type, DataType::Utf8) { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "DictionaryEncoder only supports string arrays but got {}", data_type ) .into(), - location: location!(), - }); + )); } // We only support string arrays for now let str_data = make_array(data.into_arrow(DataType::Utf8, false)?); @@ -405,16 +451,19 @@ impl ArrayEncoder for DictionaryEncoder { } #[cfg(test)] -pub mod tests { +mod tests { use arrow_array::{ - builder::{LargeStringBuilder, StringBuilder}, ArrayRef, DictionaryArray, StringArray, UInt8Array, + builder::{LargeStringBuilder, StringBuilder}, }; use arrow_schema::{DataType, Field}; use std::{collections::HashMap, sync::Arc, vec}; - use crate::testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}; + use crate::testing::{ + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, + }; + use rstest::rstest; use super::encode_dict_indices_and_items; @@ -442,28 +491,29 @@ pub mod tests { assert_eq!(&dict_items, &expected_items); } + #[rstest] #[test_log::test(tokio::test)] - async fn test_utf8() { - let field = Field::new("", DataType::Utf8, false); - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_binary() { - let field = Field::new("", DataType::Binary, false); - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_large_binary() { - let field = Field::new("", DataType::LargeBinary, true); - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_large_utf8() { - let field = Field::new("", DataType::LargeUtf8, true); - check_basic_random(field).await; + async fn test_random_dictionary( + #[values( + DataType::Utf8, + DataType::Binary, + DataType::LargeBinary, + DataType::LargeUtf8 + )] + data_type: DataType, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { + let nullable = matches!(data_type, DataType::LargeBinary | DataType::LargeUtf8); + let field = Field::new("", data_type, nullable); + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] @@ -562,14 +612,25 @@ pub mod tests { // These tests cover the case where the input is already dictionary encoded + #[rstest] #[test_log::test(tokio::test)] - async fn test_random_dictionary_input() { + async fn test_random_dictionary_input( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let dict_field = Field::new( "", DataType::Dictionary(Box::new(DataType::UInt16), Box::new(DataType::Utf8)), false, ); - check_basic_random(dict_field).await; + check_basic_random_case(dict_field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] diff --git a/rust/lance-encoding/src/previous/encodings/physical/fixed_size_binary.rs b/rust/lance-encoding/src/array_encoding/physical/fixed_size_binary.rs similarity index 72% rename from rust/lance-encoding/src/previous/encodings/physical/fixed_size_binary.rs rename to rust/lance-encoding/src/array_encoding/physical/fixed_size_binary.rs index 80d2c043f05..a5ed222e116 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/fixed_size_binary.rs +++ b/rust/lance-encoding/src/array_encoding/physical/fixed_size_binary.rs @@ -4,17 +4,14 @@ use std::sync::Arc; use arrow_buffer::ScalarBuffer; -use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_core::Result; use crate::{ + EncodingsIo, buffer::LanceBuffer, - data::{BlockInfo, DataBlock, FixedWidthDataBlock, VariableWidthBlock}, + data::{BlockInfo, DataBlock, VariableWidthBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, - format::ProtobufUtils, - previous::encoder::{ArrayEncoder, EncodedArray}, - EncodingsIo, }; /// A scheduler for fixed size binary data @@ -79,6 +76,10 @@ pub struct FixedSizeBinaryDecoder { } impl PrimitivePageDecoder for FixedSizeBinaryDecoder { + fn variable_width_bytes(&self, _rows_to_skip: u64, num_rows: u64) -> Result> { + Ok(Some(num_rows * self.byte_width)) + } + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { let rows_to_skip = rows_to_skip * self.byte_width; let num_bytes = num_rows * self.byte_width; @@ -116,91 +117,50 @@ impl PrimitivePageDecoder for FixedSizeBinaryDecoder { } } -#[derive(Debug)] -pub struct FixedSizeBinaryEncoder { - bytes_encoder: Box, - byte_width: usize, -} - -impl FixedSizeBinaryEncoder { - pub fn new(bytes_encoder: Box, byte_width: usize) -> Self { - Self { - bytes_encoder, - byte_width, - } - } -} - -impl ArrayEncoder for FixedSizeBinaryEncoder { - fn encode( - &self, - data: DataBlock, - _data_type: &DataType, - buffer_index: &mut u32, - ) -> Result { - let bytes_data = data.as_variable_width().unwrap(); - let fixed_data = DataBlock::FixedWidth(FixedWidthDataBlock { - bits_per_value: 8 * self.byte_width as u64, - data: bytes_data.data, - num_values: bytes_data.num_values, - block_info: BlockInfo::new(), - }); - - let encoded_data = self.bytes_encoder.encode( - fixed_data, - &DataType::FixedSizeBinary(self.byte_width as i32), - buffer_index, - )?; - let encoding = - ProtobufUtils::fixed_size_binary(encoded_data.encoding, self.byte_width as u32); - - Ok(EncodedArray { - data: encoded_data.data, - encoding, - }) - } -} - #[cfg(test)] mod tests { use std::{collections::HashMap, sync::Arc}; use arrow_array::{ - builder::LargeStringBuilder, Array, ArrayRef, FixedSizeBinaryArray, LargeStringArray, - StringArray, + Array, ArrayRef, FixedSizeBinaryArray, LargeStringArray, StringArray, + builder::LargeStringBuilder, }; use arrow_buffer::Buffer; use arrow_data::ArrayData; use arrow_schema::{DataType, Field}; + use crate::array_encoding::physical::fixed_size_binary::FixedSizeBinaryDecoder; use crate::data::{DataBlock, FixedWidthDataBlock}; use crate::decoder::PrimitivePageDecoder; - use crate::previous::encodings::physical::fixed_size_binary::FixedSizeBinaryDecoder; - use crate::testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}; - - #[test_log::test(tokio::test)] - async fn test_fixed_size_utf8_binary() { - let field = Field::new("", DataType::Utf8, false); - // This test only generates fixed size binary arrays anyway - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_fixed_size_binary() { - let field = Field::new("", DataType::Binary, false); - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_fixed_size_large_binary() { - let field = Field::new("", DataType::LargeBinary, true); - check_basic_random(field).await; - } + use crate::testing::{ + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, + }; + use rstest::rstest; + #[rstest] #[test_log::test(tokio::test)] - async fn test_fixed_size_large_utf8() { - let field = Field::new("", DataType::LargeUtf8, true); - check_basic_random(field).await; + async fn test_fixed_size_random( + #[values( + DataType::Utf8, + DataType::Binary, + DataType::LargeBinary, + DataType::LargeUtf8 + )] + data_type: DataType, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { + let nullable = matches!(data_type, DataType::LargeBinary | DataType::LargeUtf8); + let field = Field::new("", data_type, nullable); + // This test only generates fixed-size binary arrays for Utf8 and Binary. + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] @@ -351,4 +311,56 @@ mod tests { assert_eq!(string_array.value(0), "aaa"); assert_eq!(string_array.value(1), "bbb"); } + + #[test] + fn test_fixed_size_binary_pages_split_by_byte_budget() { + use crate::array_encoding::logical::primitive::PrimitiveFieldDecoder; + use crate::array_encoding::logical::r#struct::SimpleStructDecoder; + use crate::decoder::{DecoderReady, DrainLimit, LogicalPageDecoder}; + use arrow_schema::{Field as ArrowField, Fields}; + use lance_core::Result; + use std::collections::VecDeque; + + #[derive(Debug)] + struct NeverDecodedStub; + + impl PrimitivePageDecoder for NeverDecodedStub { + fn decode(&self, _rows_to_skip: u64, _num_rows: u64) -> Result { + unreachable!("byte accounting must not decode any values") + } + } + + // Two pages of 3 rows x 10 bytes each; each page fits the budget alone + // but the pair does not. + let fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let mut root = SimpleStructDecoder::new(fields, 6); + for _ in 0..2 { + root.accept_child(DecoderReady { + decoder: Box::new(PrimitiveFieldDecoder::new_from_data( + Arc::new(FixedSizeBinaryDecoder { + bytes_decoder: Box::new(NeverDecodedStub), + byte_width: 10, + bytes_per_offset: 4, + }), + DataType::Utf8, + 3, + false, + )), + path: VecDeque::from([0]), + }) + .unwrap(); + } + + // Budget for page 1 plus two rows of page 2. + let limit = root.max_rows_to_drain(6, 50).unwrap(); + assert_eq!(limit, DrainLimit { rows: 5, bytes: 50 }); + + // Budget for exactly one page: the batch stops at the page boundary. + let limit = root.max_rows_to_drain(6, 30).unwrap(); + assert_eq!(limit, DrainLimit { rows: 3, bytes: 30 }); + + // Both pages fit a large enough budget. + let limit = root.max_rows_to_drain(6, 60).unwrap(); + assert_eq!(limit, DrainLimit { rows: 6, bytes: 60 }); + } } diff --git a/rust/lance-encoding/src/previous/encodings/physical/fixed_size_list.rs b/rust/lance-encoding/src/array_encoding/physical/fixed_size_list.rs similarity index 85% rename from rust/lance-encoding/src/previous/encodings/physical/fixed_size_list.rs rename to rust/lance-encoding/src/array_encoding/physical/fixed_size_list.rs index a3740b852f8..49d2dbff19b 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/fixed_size_list.rs +++ b/rust/lance-encoding/src/array_encoding/physical/fixed_size_list.rs @@ -4,16 +4,16 @@ use std::sync::Arc; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_core::Result; use log::trace; use crate::{ + EncodingsIo, data::{DataBlock, FixedSizeListBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, + encoder::{ArrayEncoder, EncodedArray}, format::ProtobufUtils, - previous::encoder::{ArrayEncoder, EncodedArray}, - EncodingsIo, }; /// A scheduler for fixed size lists of primitive values @@ -133,26 +133,34 @@ impl ArrayEncoder for FslEncoder { mod tests { use std::{collections::HashMap, sync::Arc}; - use arrow_array::{types::Int32Type, FixedSizeListArray, Int32Array}; + use arrow_array::{FixedSizeListArray, Int32Array, types::Int32Type}; use arrow_buffer::{BooleanBuffer, NullBuffer}; use arrow_schema::{DataType, Field}; - use lance_datagen::{array, gen_array, ArrayGeneratorExt, RowCount}; + use lance_datagen::{ArrayGeneratorExt, RowCount, array, gen_array}; - use crate::{ - testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + use crate::testing::{ + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, }; + use rstest::rstest; - const PRIMITIVE_TYPES: &[DataType] = &[DataType::Int8, DataType::Float32, DataType::Float64]; - + #[rstest] #[test_log::test(tokio::test)] - async fn test_value_fsl_primitive() { - for data_type in PRIMITIVE_TYPES { - let inner_field = Field::new("item", data_type.clone(), true); - let data_type = DataType::FixedSizeList(Arc::new(inner_field), 16); - let field = Field::new("", data_type, false); - check_basic_random(field).await; - } + async fn test_value_fsl_primitive( + #[values(DataType::Int8, DataType::Float32, DataType::Float64)] data_type: DataType, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { + let inner_field = Field::new("item", data_type, true); + let data_type = DataType::FixedSizeList(Arc::new(inner_field), 16); + let field = Field::new("", data_type, false); + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] @@ -182,7 +190,7 @@ mod tests { .with_indices(vec![0, 1, 2]) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![list], &test_cases, HashMap::default()).await; } @@ -209,7 +217,7 @@ mod tests { .with_indices(vec![0, 1, 2]) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![list], &test_cases, HashMap::default()).await; } @@ -260,7 +268,7 @@ mod tests { .with_range(1..3) .with_indices(vec![0, 1, 2]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![outer_list], &test_cases, HashMap::default()).await; } diff --git a/rust/lance-encoding/src/previous/encodings/physical/fsst.rs b/rust/lance-encoding/src/array_encoding/physical/fsst.rs similarity index 91% rename from rust/lance-encoding/src/previous/encodings/physical/fsst.rs rename to rust/lance-encoding/src/array_encoding/physical/fsst.rs index 55024ded1cd..d6e8868eff2 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/fsst.rs +++ b/rust/lance-encoding/src/array_encoding/physical/fsst.rs @@ -5,17 +5,17 @@ use std::{ops::Range, sync::Arc}; use arrow_buffer::ScalarBuffer; use arrow_schema::DataType; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_core::Result; use crate::{ + EncodingsIo, buffer::LanceBuffer, data::{BlockInfo, DataBlock, NullableDataBlock, VariableWidthBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, + encoder::{ArrayEncoder, EncodedArray}, format::ProtobufUtils, - previous::encoder::{ArrayEncoder, EncodedArray}, - EncodingsIo, }; #[derive(Debug)] @@ -62,6 +62,15 @@ struct FsstPageDecoder { } impl PrimitivePageDecoder for FsstPageDecoder { + fn variable_width_bytes(&self, rows_to_skip: u64, num_rows: u64) -> Result> { + // FSST decompression expands by at most 8x, so the inner (compressed) + // byte count times 8 is a safe upper bound on the decoded size. + Ok(self + .inner_decoder + .variable_width_bytes(rows_to_skip, num_rows)? + .map(|compressed_bytes| compressed_bytes.saturating_mul(8))) + } + fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { let compressed_data = self.inner_decoder.decode(rows_to_skip, num_rows)?; let (string_data, nulls) = match compressed_data { @@ -88,7 +97,8 @@ impl PrimitivePageDecoder for FsstPageDecoder { &offsets, &mut decompressed_bytes, &mut decompressed_offsets, - )?; + ) + .map_err(crate::encodings::physical::fsst::map_fsst_error)?; // TODO: Change PrimitivePageDecoder to use Vec instead of BytesMut // since there is no way to get BytesMut from Vec but these copies should be avoidable diff --git a/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs b/rust/lance-encoding/src/array_encoding/physical/packed_struct.rs similarity index 92% rename from rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs rename to rust/lance-encoding/src/array_encoding/physical/packed_struct.rs index 8b47824e1b8..aadd440c937 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/packed_struct.rs +++ b/rust/lance-encoding/src/array_encoding/physical/packed_struct.rs @@ -6,20 +6,19 @@ use std::sync::Arc; use arrow_schema::{DataType, Fields}; use bytes::Bytes; use bytes::BytesMut; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_arrow::DataTypeExt; use lance_core::{Error, Result}; -use snafu::location; use crate::data::BlockInfo; use crate::data::FixedSizeListBlock; use crate::format::ProtobufUtils; use crate::{ + EncodingsIo, buffer::LanceBuffer, data::{DataBlock, FixedWidthDataBlock, StructDataBlock}, decoder::{PageScheduler, PrimitivePageDecoder}, - previous::encoder::{ArrayEncoder, EncodedArray}, - EncodingsIo, + encoder::{ArrayEncoder, EncodedArray}, }; #[derive(Debug)] @@ -212,23 +211,23 @@ impl ArrayEncoder for PackedStructEncoder { let flattened = fixed_size_list.try_into_flat().ok_or_else(|| { Error::invalid_input( "Packed struct encoder cannot pack nullable fixed-width data blocks", - location!(), ) })?; Ok(flattened) } _ => Err(Error::invalid_input( "Packed struct encoder currently only implemented for fixed-width data blocks", - location!(), )), }) .collect::>>()?; let total_bits_per_value = fixed_fields.iter().map(|f| f.bits_per_value).sum::(); let num_values = fixed_fields[0].num_values; - debug_assert!(fixed_fields - .iter() - .all(|field| field.num_values == num_values)); + debug_assert!( + fixed_fields + .iter() + .all(|field| field.num_values == num_values) + ); let zipped_input = fixed_fields .into_iter() @@ -257,16 +256,29 @@ impl ArrayEncoder for PackedStructEncoder { } #[cfg(test)] -pub mod tests { +mod tests { - use arrow_array::{ArrayRef, Int32Array, StructArray, UInt64Array, UInt8Array}; + use arrow_array::{ArrayRef, Int32Array, StructArray, UInt8Array, UInt64Array}; use arrow_schema::{DataType, Field, Fields}; use std::{collections::HashMap, sync::Arc, vec}; - use crate::testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}; + use crate::testing::{ + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, + }; + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_random_packed_struct() { + async fn test_random_packed_struct( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let data_type = DataType::Struct(Fields::from(vec![ Field::new("a", DataType::UInt64, false), Field::new("b", DataType::UInt32, false), @@ -276,7 +288,7 @@ pub mod tests { let field = Field::new("", data_type, false).with_metadata(metadata); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] diff --git a/rust/lance-encoding/src/previous/encodings/physical/value.rs b/rust/lance-encoding/src/array_encoding/physical/value.rs similarity index 97% rename from rust/lance-encoding/src/previous/encodings/physical/value.rs rename to rust/lance-encoding/src/array_encoding/physical/value.rs index f729ea6c42e..9f594a00252 100644 --- a/rust/lance-encoding/src/previous/encodings/physical/value.rs +++ b/rust/lance-encoding/src/array_encoding/physical/value.rs @@ -3,9 +3,8 @@ use arrow_schema::DataType; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use log::trace; -use snafu::location; use std::ops::Range; use std::sync::{Arc, Mutex}; @@ -17,9 +16,9 @@ use crate::encodings::physical::block::{ use crate::encodings::physical::value::ValueEncoder; use crate::format::ProtobufUtils; use crate::{ - decoder::{PageScheduler, PrimitivePageDecoder}, - previous::encoder::{ArrayEncoder, EncodedArray}, EncodingsIo, + decoder::{PageScheduler, PrimitivePageDecoder}, + encoder::{ArrayEncoder, EncodedArray}, }; use lance_core::{Error, Result}; @@ -231,14 +230,13 @@ impl ArrayEncoder for ValueEncoder { index, None, )), - _ => Err(Error::InvalidInput { - source: format!( + _ => Err(Error::invalid_input_source( + format!( "Cannot encode a data block of type {} with ValueEncoder", data.name() ) .into(), - location: location!(), - }), + )), }?; Ok(EncodedArray { data, encoding }) } diff --git a/rust/lance-encoding/src/array_encoding/strategy.rs b/rust/lance-encoding/src/array_encoding/strategy.rs new file mode 100644 index 00000000000..20439bad73f --- /dev/null +++ b/rust/lance-encoding/src/array_encoding/strategy.rs @@ -0,0 +1,1101 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::{collections::HashMap, env, hash::RandomState, sync::Arc}; + +use arrow_array::{ + Array, ArrayRef, GenericListArray, OffsetSizeTrait, UInt8Array, cast::AsArray, make_array, +}; +use arrow_buffer::{BooleanBuffer, NullBuffer}; +use arrow_data::ArrayData; +use arrow_schema::DataType; +use hyperloglogplus::{HyperLogLog, HyperLogLogPlus}; +use lance_arrow::BLOB_META_KEY; + +use crate::{ + array_encoding::{ + logical::{ + blob::BlobFieldEncoder, list::ListFieldEncoder, primitive::PrimitiveFieldEncoder, + }, + physical::{ + basic::BasicEncoder, + binary::BinaryEncoder, + dictionary::{AlreadyDictionaryEncoder, DictionaryEncoder}, + fixed_size_list::FslEncoder, + fsst::FsstArrayEncoder, + packed_struct::PackedStructEncoder, + }, + }, + constants::{ + COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY, PACKED_STRUCT_LEGACY_META_KEY, + PACKED_STRUCT_META_KEY, + }, + encoder::{ + ArrayEncoder, ArrayEncodingStrategy, ColumnIndexSequence, EncodeTask, EncodedColumn, + FieldEncoder, FieldEncodingContext, FieldEncodingStrategy, OutOfLineBuffers, + }, + encodings::{ + logical::r#struct::StructFieldEncoder, + physical::{ + block::{CompressionConfig, CompressionScheme}, + value::ValueEncoder, + }, + }, +}; + +use lance_core::datatypes::{BLOB_DESC_FIELD, Field}; +use lance_core::utils::parse::str_is_truthy; +use lance_core::{Error, Result}; + +/// Field-to-column composition for the `pb::ArrayEncoding` grammar. +#[derive(Debug)] +pub struct ArrayFieldEncodingStrategy { + array_encoding_strategy: Arc, +} + +struct ValidatingFieldEncoder { + inner: Box, + field: Field, + prepared_array: Option, +} + +impl ValidatingFieldEncoder { + fn new(inner: Box, field: Field) -> Self { + Self { + inner, + field, + prepared_array: None, + } + } +} + +impl FieldEncoder for ValidatingFieldEncoder { + fn prepare_array(&mut self, array: ArrayRef) -> Result { + ArrayFieldEncodingStrategy::validate_v2_0_array(array.as_ref(), &self.field, true)?; + let array = ArrayFieldEncodingStrategy::clear_unreachable_v2_0_fsl_struct_validity(array)?; + self.prepared_array = Some(array.clone()); + Ok(array) + } + + fn maybe_encode( + &mut self, + array: ArrayRef, + external_buffers: &mut OutOfLineBuffers, + repdef: crate::repdef::RepDefBuilder, + row_number: u64, + num_rows: u64, + ) -> Result> { + let array = match self.prepared_array.take() { + Some(prepared) if Arc::ptr_eq(&prepared, &array) => prepared, + _ => { + // Direct field encoders have historically accepted arrays whose top-level + // nullability differs from the declared field. Keep that API behavior while + // still making the v2.0 struct-validity invariant unavoidable. + ArrayFieldEncodingStrategy::validate_v2_0_array( + array.as_ref(), + &self.field, + false, + )?; + ArrayFieldEncodingStrategy::clear_unreachable_v2_0_fsl_struct_validity(array)? + } + }; + self.inner + .maybe_encode(array, external_buffers, repdef, row_number, num_rows) + } + + fn flush(&mut self, external_buffers: &mut OutOfLineBuffers) -> Result> { + self.inner.flush(external_buffers) + } + + fn finish( + &mut self, + external_buffers: &mut OutOfLineBuffers, + ) -> futures::future::BoxFuture<'_, Result>> { + self.inner.finish(external_buffers) + } + + fn num_columns(&self) -> u32 { + self.inner.num_columns() + } +} + +impl ArrayFieldEncodingStrategy { + /// Create the field strategy for the `pb::ArrayEncoding` grammar. + /// + /// ``` + /// use lance_encoding::encoder::ArrayFieldEncodingStrategy; + /// + /// let strategy = ArrayFieldEncodingStrategy::new(); + /// ``` + pub fn new() -> Self { + Self { + array_encoding_strategy: Arc::new(ArrayStrategy), + } + } + + fn is_primitive_type(data_type: &DataType) -> bool { + matches!( + data_type, + DataType::Boolean + | DataType::Date32 + | DataType::Date64 + | DataType::Decimal128(_, _) + | DataType::Decimal256(_, _) + | DataType::Duration(_) + | DataType::Float16 + | DataType::Float32 + | DataType::Float64 + | DataType::Int16 + | DataType::Int32 + | DataType::Int64 + | DataType::Int8 + | DataType::Interval(_) + | DataType::Null + | DataType::Time32(_) + | DataType::Time64(_) + | DataType::Timestamp(_, _) + | DataType::UInt16 + | DataType::UInt32 + | DataType::UInt64 + | DataType::UInt8 + | DataType::FixedSizeBinary(_) + | DataType::FixedSizeList(_, _) + | DataType::Binary + | DataType::LargeBinary + | DataType::Utf8 + | DataType::LargeUtf8, + ) + } + + fn validate_v2_0_array( + array: &dyn Array, + field: &Field, + enforce_field_nullability: bool, + ) -> Result<()> { + Self::validate_v2_0_array_reachability(array, field, enforce_field_nullability, None) + } + + fn field_requires_v2_0_validation(field: &Field, enforce_field_nullability: bool) -> bool { + field.logical_type.is_struct() + || (enforce_field_nullability && !field.nullable) + || field + .children + .iter() + .any(|child| Self::field_requires_v2_0_validation(child, enforce_field_nullability)) + } + + fn is_reachable(reachable: Option<&BooleanBuffer>, index: usize) -> bool { + reachable.map(|mask| mask.value(index)).unwrap_or(true) + } + + fn positional_child_reachability( + array: &dyn Array, + reachable: Option<&BooleanBuffer>, + ) -> Option { + if reachable.is_none() && array.null_count() == 0 { + return None; + } + Some(BooleanBuffer::from_iter((0..array.len()).map(|index| { + Self::is_reachable(reachable, index) && array.is_valid(index) + }))) + } + + fn list_child_reachability( + array: &GenericListArray, + reachable: Option<&BooleanBuffer>, + ) -> Option { + let values_len = array.values().len(); + let offsets = array.offsets(); + if reachable.is_none() + && array.null_count() == 0 + && offsets.first().map(|offset| offset.as_usize()) == Some(0) + && offsets.last().map(|offset| offset.as_usize()) == Some(values_len) + { + return None; + } + + let mut child_reachable = vec![false; values_len]; + for index in 0..array.len() { + if Self::is_reachable(reachable, index) && array.is_valid(index) { + let start = offsets[index].as_usize(); + let end = offsets[index + 1].as_usize(); + for is_reachable in child_reachable.iter_mut().take(end).skip(start) { + *is_reachable = true; + } + } + } + Some(BooleanBuffer::from_iter(child_reachable)) + } + + fn map_child_reachability( + array: &arrow_array::MapArray, + reachable: Option<&BooleanBuffer>, + ) -> Option { + let values_len = array.entries().len(); + let offsets = array.offsets(); + if reachable.is_none() + && array.null_count() == 0 + && offsets.first().copied() == Some(0) + && offsets.last().copied() == Some(values_len as i32) + { + return None; + } + + let mut child_reachable = vec![false; values_len]; + for index in 0..array.len() { + if Self::is_reachable(reachable, index) && array.is_valid(index) { + let start = offsets[index] as usize; + let end = offsets[index + 1] as usize; + for is_reachable in child_reachable.iter_mut().take(end).skip(start) { + *is_reachable = true; + } + } + } + Some(BooleanBuffer::from_iter(child_reachable)) + } + + fn fixed_size_list_child_reachability( + array: &arrow_array::FixedSizeListArray, + reachable: Option<&BooleanBuffer>, + ) -> Option { + let values_len = array.values().len(); + let dimension = array.value_length() as usize; + let (first_value, final_value) = if array.is_empty() { + (0, 0) + } else { + ( + array.value_offset(0) as usize, + array.value_offset(array.len() - 1) as usize + dimension, + ) + }; + if reachable.is_none() + && array.null_count() == 0 + && first_value == 0 + && final_value == values_len + { + return None; + } + + let mut child_reachable = vec![false; values_len]; + for index in 0..array.len() { + if Self::is_reachable(reachable, index) && array.is_valid(index) { + let start = array.value_offset(index) as usize; + for is_reachable in child_reachable.iter_mut().skip(start).take(dimension) { + *is_reachable = true; + } + } + } + Some(BooleanBuffer::from_iter(child_reachable)) + } + + fn validate_v2_0_array_reachability( + array: &dyn Array, + field: &Field, + enforce_field_nullability: bool, + reachable: Option<&BooleanBuffer>, + ) -> Result<()> { + if !Self::field_requires_v2_0_validation(field, enforce_field_nullability) { + return Ok(()); + } + + let reachable_null_count = if array.null_count() == 0 { + 0 + } else if let Some(reachable) = reachable { + (0..array.len()) + .filter(|index| reachable.value(*index) && array.is_null(*index)) + .count() + } else { + array.null_count() + }; + + if enforce_field_nullability && !field.nullable && reachable_null_count > 0 { + return Err(Error::invalid_input(format!( + "The field `{}` contained null values even though the field is marked non-null in the schema", + field.name + ))); + } + if field.logical_type.is_struct() && reachable_null_count > 0 { + return Err(Error::invalid_input(format!( + "The struct field `{}` contains {} null value(s), but Lance file version 2.0 does not encode struct validity; use file version 2.1 or later", + field.name, reachable_null_count + ))); + } + + match array.data_type() { + DataType::Struct(_) => { + let child_reachable = Self::positional_child_reachability(array, reachable); + for (child_field, child_array) in + field.children.iter().zip(array.as_struct().columns()) + { + Self::validate_v2_0_array_reachability( + child_array.as_ref(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + DataType::List(_) => { + let list_array = array.as_list::(); + if let Some(child_field) = field.children.first() { + let child_reachable = Self::list_child_reachability(list_array, reachable); + Self::validate_v2_0_array_reachability( + list_array.values().as_ref(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + DataType::LargeList(_) => { + let list_array = array.as_list::(); + if let Some(child_field) = field.children.first() { + let child_reachable = Self::list_child_reachability(list_array, reachable); + Self::validate_v2_0_array_reachability( + list_array.values().as_ref(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + DataType::Map(_, _) => { + let map_array = array.as_map(); + if let Some(child_field) = field.children.first() { + let child_reachable = Self::map_child_reachability(map_array, reachable); + Self::validate_v2_0_array_reachability( + map_array.entries(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + DataType::FixedSizeList(_, _) => { + let list_array = array.as_fixed_size_list(); + if let Some(child_field) = field.children.first() { + let child_reachable = + Self::fixed_size_list_child_reachability(list_array, reachable); + Self::validate_v2_0_array_reachability( + list_array.values().as_ref(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + _ => { + let array_data = array.to_data(); + for (child_field, child_data) in field.children.iter().zip(array_data.child_data()) + { + let child_array = make_array(child_data.clone()); + let child_reachable = (child_array.len() == array.len()) + .then(|| Self::positional_child_reachability(array, reachable)) + .flatten(); + Self::validate_v2_0_array_reachability( + child_array.as_ref(), + child_field, + enforce_field_nullability, + child_reachable.as_ref(), + )?; + } + } + } + Ok(()) + } + + fn clear_unreachable_v2_0_fsl_struct_validity(array: ArrayRef) -> Result { + fn force_valid_under_null_struct( + data: ArrayData, + struct_nulls: &NullBuffer, + ) -> Result { + let Some(child_nulls) = data.nulls() else { + return Ok(data); + }; + if !(0..data.len()) + .any(|index| struct_nulls.is_null(index) && child_nulls.is_null(index)) + { + return Ok(data); + } + + let nulls = + NullBuffer::new(BooleanBuffer::from_iter((0..data.len()).map(|index| { + struct_nulls.is_null(index) || child_nulls.is_valid(index) + }))); + let nulls = (nulls.null_count() > 0).then_some(nulls); + Ok(data.into_builder().nulls(nulls).build()?) + } + + fn strip_struct_validity(data: ArrayData) -> Result<(ArrayData, bool)> { + let struct_fields = match data.data_type() { + DataType::Struct(fields) => Some(fields.clone()), + _ => None, + }; + let struct_nulls = struct_fields + .as_ref() + .and_then(|_| data.nulls()) + .filter(|nulls| nulls.null_count() > 0) + .cloned(); + let mut children_changed = false; + let children = data + .child_data() + .iter() + .cloned() + .enumerate() + .map(|(index, child)| { + let (mut child, changed) = strip_struct_validity(child)?; + children_changed |= changed; + if let (Some(fields), Some(struct_nulls)) = (&struct_fields, &struct_nulls) + && !fields[index].is_nullable() + { + let original_null_count = child.null_count(); + child = force_valid_under_null_struct(child, struct_nulls)?; + children_changed |= child.null_count() != original_null_count; + } + Ok(child) + }) + .collect::>>()?; + if struct_nulls.is_none() && !children_changed { + return Ok((data, false)); + } + + let mut builder = data.into_builder().child_data(children); + if struct_nulls.is_some() { + builder = builder.nulls(None); + } + Ok((builder.build()?, true)) + } + + fn normalize(data: ArrayData) -> Result<(ArrayData, bool)> { + let is_fixed_size_list = matches!(data.data_type(), DataType::FixedSizeList(_, _)); + let mut children_changed = false; + let children = data + .child_data() + .iter() + .cloned() + .map(|child| { + let (child, changed) = if is_fixed_size_list { + strip_struct_validity(child)? + } else { + normalize(child)? + }; + children_changed |= changed; + Ok(child) + }) + .collect::>>()?; + if !children_changed { + return Ok((data, false)); + } + Ok((data.into_builder().child_data(children).build()?, true)) + } + + let (data, changed) = normalize(array.to_data())?; + if changed { + Ok(make_array(data)) + } else { + Ok(array) + } + } + + fn create_field_encoder_raw( + &self, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, + ) -> Result> { + let options = context.options; + let data_type = field.data_type(); + if Self::is_primitive_type(&data_type) { + let column_index = column_index.next_column_index(field.id as u32); + if field.metadata.contains_key(BLOB_META_KEY) { + let mut packed_meta = HashMap::new(); + packed_meta.insert(PACKED_STRUCT_META_KEY.to_string(), "true".to_string()); + let desc_field = + Field::try_from(BLOB_DESC_FIELD.clone().with_metadata(packed_meta)).unwrap(); + let desc_encoder = Box::new(PrimitiveFieldEncoder::try_new( + options, + self.array_encoding_strategy.clone(), + column_index, + desc_field, + )?); + Ok(Box::new(BlobFieldEncoder::new(desc_encoder))) + } else { + Ok(Box::new(PrimitiveFieldEncoder::try_new( + options, + self.array_encoding_strategy.clone(), + column_index, + field.clone(), + )?)) + } + } else { + match data_type { + DataType::List(_child) | DataType::LargeList(_child) => { + let list_idx = column_index.next_column_index(field.id as u32); + let inner_encoding = + self.create_field_encoder_raw(&field.children[0], column_index, context)?; + let offsets_encoder = + Arc::new(BasicEncoder::new(Box::new(ValueEncoder::default()))); + Ok(Box::new(ListFieldEncoder::new( + inner_encoding, + offsets_encoder, + options.cache_bytes_per_column, + options.keep_original_array, + list_idx, + ))) + } + DataType::Struct(_) => { + let field_metadata = &field.metadata; + if field_metadata + .get(PACKED_STRUCT_LEGACY_META_KEY) + .map(|v| str_is_truthy(v)) + .unwrap_or(field_metadata.contains_key(PACKED_STRUCT_META_KEY)) + { + Ok(Box::new(PrimitiveFieldEncoder::try_new( + options, + self.array_encoding_strategy.clone(), + column_index.next_column_index(field.id as u32), + field.clone(), + )?)) + } else { + let header_idx = column_index.next_column_index(field.id as u32); + let children_encoders = field + .children + .iter() + .map(|field| { + self.create_field_encoder_raw(field, column_index, context) + }) + .collect::>>()?; + Ok(Box::new(StructFieldEncoder::new( + children_encoders, + header_idx, + ))) + } + } + DataType::Dictionary(_, value_type) => { + if Self::is_primitive_type(&value_type) { + Ok(Box::new(PrimitiveFieldEncoder::try_new( + options, + self.array_encoding_strategy.clone(), + column_index.next_column_index(field.id as u32), + field.clone(), + )?)) + } else { + Err(Error::not_supported_source(format!( + "cannot encode a dictionary column whose value type is a logical type ({})", + value_type + ).into())) + } + } + _ => Err(Error::not_supported_source( + format!( + "Lance v2.0 has no field encoding for '{}' with data type {}", + field.name, + field.data_type() + ) + .into(), + )), + } + } + } +} + +impl Default for ArrayFieldEncodingStrategy { + fn default() -> Self { + Self::new() + } +} + +impl FieldEncodingStrategy for ArrayFieldEncodingStrategy { + fn validate_array(&self, array: &dyn Array, field: &Field) -> Result<()> { + Self::validate_v2_0_array(array, field, true) + } + + fn create_field_encoder( + &self, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, + ) -> Result> { + let encoder = self.create_field_encoder_raw(field, column_index, context)?; + Ok(Box::new(ValidatingFieldEncoder::new( + encoder, + field.clone(), + ))) + } +} + +/// Page-encoding selection for the `pb::ArrayEncoding` grammar. +#[derive(Debug)] +struct ArrayStrategy; + +impl ArrayStrategy { + fn get_field_compression(field_meta: &HashMap) -> Option { + let compression = field_meta.get(COMPRESSION_META_KEY)?; + let compression_scheme = compression.parse::(); + match compression_scheme { + Ok(compression_scheme) => Some(CompressionConfig::new( + compression_scheme, + field_meta + .get(COMPRESSION_LEVEL_META_KEY) + .and_then(|level| level.parse().ok()), + )), + Err(_) => None, + } + } + + fn default_binary_encoder( + arrays: &[ArrayRef], + field_meta: Option<&HashMap>, + data_size: u64, + ) -> Result> { + let bin_indices_encoder = + Self::choose_array_encoder(arrays, &DataType::UInt64, data_size, false, None)?; + + if let Some(compression) = field_meta.and_then(Self::get_field_compression) { + if compression.scheme() == CompressionScheme::Fsst { + // User requested FSST + let raw_encoder = Box::new(BinaryEncoder::try_new(bin_indices_encoder, None)?); + Ok(Box::new(FsstArrayEncoder::new(raw_encoder))) + } else { + // Generic compression + Ok(Box::new(BinaryEncoder::try_new( + bin_indices_encoder, + Some(compression), + )?)) + } + } else { + Ok(Box::new(BinaryEncoder::try_new(bin_indices_encoder, None)?)) + } + } + + fn choose_array_encoder( + arrays: &[ArrayRef], + data_type: &DataType, + data_size: u64, + use_dict_encoding: bool, + field_meta: Option<&HashMap>, + ) -> Result> { + match data_type { + DataType::FixedSizeList(inner, dimension) => { + Ok(Box::new(BasicEncoder::new(Box::new(FslEncoder::new( + Self::choose_array_encoder( + arrays, + inner.data_type(), + data_size, + use_dict_encoding, + None, + )?, + *dimension as u32, + ))))) + } + DataType::Dictionary(key_type, value_type) => { + let key_encoder = + Self::choose_array_encoder(arrays, key_type, data_size, false, None)?; + let value_encoder = + Self::choose_array_encoder(arrays, value_type, data_size, false, None)?; + + Ok(Box::new(AlreadyDictionaryEncoder::new( + key_encoder, + value_encoder, + ))) + } + DataType::Utf8 | DataType::LargeUtf8 | DataType::Binary | DataType::LargeBinary => { + if use_dict_encoding { + let dict_indices_encoder = Self::choose_array_encoder( + // We need to pass arrays to this method to figure out what kind of compression to + // use but we haven't actually calculated the indices yet. For now, we just assume + // worst case and use the full range. In the future maybe we can pass in statistics + // instead of the actual data + &[Arc::new(UInt8Array::from_iter_values(0_u8..255_u8))], + &DataType::UInt8, + data_size, + false, + None, + )?; + let dict_items_encoder = Self::choose_array_encoder( + arrays, + &DataType::Utf8, + data_size, + false, + None, + )?; + + Ok(Box::new(DictionaryEncoder::new( + dict_indices_encoder, + dict_items_encoder, + ))) + } else { + Self::default_binary_encoder(arrays, field_meta, data_size) + } + } + DataType::Struct(fields) => { + let num_fields = fields.len(); + let mut inner_encoders = Vec::new(); + + for i in 0..num_fields { + let inner_datatype = fields[i].data_type(); + let inner_encoder = Self::choose_array_encoder( + arrays, + inner_datatype, + data_size, + use_dict_encoding, + None, + )?; + inner_encoders.push(inner_encoder); + } + + Ok(Box::new(PackedStructEncoder::new(inner_encoders))) + } + DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => Ok( + Box::new(BasicEncoder::new(Box::new(ValueEncoder::default()))), + ), + + // TODO: for signed integers, I intend to make it a cascaded encoding, a sparse array for the negative values and very wide(bit-width) values, + // then a bitpacked array for the narrow(bit-width) values, I need `BitpackedForNeg` to be merged first, I am + // thinking about putting this sparse array in the metadata so bitpacking remain using one page buffer only. + DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => Ok(Box::new( + BasicEncoder::new(Box::new(ValueEncoder::default())), + )), + _ => Ok(Box::new(BasicEncoder::new(Box::new( + ValueEncoder::default(), + )))), + } + } +} + +fn get_dict_encoding_threshold() -> u64 { + env::var("LANCE_DICT_ENCODING_THRESHOLD") + .ok() + .and_then(|val| val.parse().ok()) + .unwrap_or(100) +} + +// check whether we want to use dictionary encoding or not +// by applying a threshold on cardinality +// returns true if cardinality < threshold but false if the total number of rows is less than the threshold +// The choice to use 100 is just a heuristic for now +// hyperloglog is used for cardinality estimation +// error rate = 1.04 / sqrt(2^p), where p is the precision +// and error rate is 1.04 / sqrt(2^12) = 1.56% +fn check_dict_encoding(arrays: &[ArrayRef], threshold: u64) -> bool { + let num_total_rows = arrays.iter().map(|arr| arr.len()).sum::(); + if num_total_rows < threshold as usize { + return false; + } + const PRECISION: u8 = 12; + + let mut hll: HyperLogLogPlus = + HyperLogLogPlus::new(PRECISION, RandomState::new()).unwrap(); + + for arr in arrays { + let string_array = arrow_array::cast::as_string_array(arr); + for value in string_array.iter().flatten() { + hll.insert(value); + let estimated_cardinality = hll.count() as u64; + if estimated_cardinality >= threshold { + return false; + } + } + } + + true +} + +#[cfg(test)] +fn check_fixed_size_encoding(arrays: &[ArrayRef]) -> Option { + if arrays.is_empty() { + return None; + } + + // make sure no array has an empty string + if !arrays.iter().all(|arr| { + if let Some(arr) = arr.as_string_opt::() { + arr.iter().flatten().all(|s| !s.is_empty()) + } else if let Some(arr) = arr.as_binary_opt::() { + arr.iter().flatten().all(|s| !s.is_empty()) + } else if let Some(arr) = arr.as_string_opt::() { + arr.iter().flatten().all(|s| !s.is_empty()) + } else if let Some(arr) = arr.as_binary_opt::() { + arr.iter().flatten().all(|s| !s.is_empty()) + } else { + panic!("wrong dtype"); + } + }) { + return None; + } + + let lengths = arrays + .iter() + .flat_map(|arr| { + if let Some(arr) = arr.as_string_opt::() { + let offsets = arr.offsets().inner(); + offsets + .windows(2) + .map(|w| (w[1] - w[0]) as u64) + .collect::>() + } else if let Some(arr) = arr.as_binary_opt::() { + let offsets = arr.offsets().inner(); + offsets + .windows(2) + .map(|w| (w[1] - w[0]) as u64) + .collect::>() + } else if let Some(arr) = arr.as_string_opt::() { + let offsets = arr.offsets().inner(); + offsets + .windows(2) + .map(|w| (w[1] - w[0]) as u64) + .collect::>() + } else if let Some(arr) = arr.as_binary_opt::() { + let offsets = arr.offsets().inner(); + offsets + .windows(2) + .map(|w| (w[1] - w[0]) as u64) + .collect::>() + } else { + panic!("wrong dtype"); + } + }) + .collect::>(); + + // find first non-zero value in lengths + let first_non_zero = lengths.iter().position(|&x| x != 0); + if let Some(first_non_zero) = first_non_zero { + // make sure all lengths are equal to first_non_zero length or zero + if !lengths + .iter() + .all(|&x| x == 0 || x == lengths[first_non_zero]) + { + return None; + } + + // set the byte width + Some(lengths[first_non_zero]) + } else { + None + } +} + +impl ArrayEncodingStrategy for ArrayStrategy { + fn create_array_encoder( + &self, + arrays: &[ArrayRef], + field: &Field, + ) -> Result> { + let data_size = arrays + .iter() + .map(|arr| arr.get_buffer_memory_size() as u64) + .sum::(); + let data_type = arrays[0].data_type(); + + let use_dict_encoding = data_type == &DataType::Utf8 + && check_dict_encoding(arrays, get_dict_encoding_threshold()); + + Self::choose_array_encoder( + arrays, + data_type, + data_size, + use_dict_encoding, + Some(&field.metadata), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{ + ArrayEncodingStrategy, ArrayFieldEncodingStrategy, ArrayStrategy, check_dict_encoding, + check_fixed_size_encoding, + }; + use crate::constants::{COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY}; + use crate::encoder::{BatchEncoder, EncodingOptions}; + use arrow_array::{ArrayRef, StringArray}; + use arrow_schema::{DataType, Field, Fields, Schema as ArrowSchema}; + use lance_core::{Error, datatypes::Schema}; + use std::collections::HashMap; + use std::sync::Arc; + + #[test] + fn test_unsupported_field_type_returns_error() { + let entries = Field::new( + "entries", + DataType::Struct(Fields::from(vec![ + Field::new("key", DataType::Utf8, false), + Field::new("value", DataType::Int32, true), + ])), + false, + ); + let arrow_schema = ArrowSchema::new(vec![Field::new( + "attributes", + DataType::Map(Arc::new(entries), false), + true, + )]); + let schema = Schema::try_from(&arrow_schema).unwrap(); + + let error = BatchEncoder::try_new( + &schema, + &ArrayFieldEncodingStrategy::new(), + &EncodingOptions::default(), + ) + .err() + .unwrap(); + + assert!(matches!(error, Error::NotSupported { .. })); + assert!(error.to_string().contains("attributes")); + assert!(error.to_string().contains("Map")); + } + + fn is_dict_encoding_applicable(arr: Vec>, threshold: u64) -> bool { + let arr = StringArray::from(arr); + let arr = Arc::new(arr) as ArrayRef; + check_dict_encoding(&[arr], threshold) + } + + #[test] + fn test_dict_encoding_should_be_applied_if_cardinality_less_than_threshold() { + assert!(is_dict_encoding_applicable( + vec![Some("a"), Some("b"), Some("a"), Some("b")], + 3, + )); + } + + #[test] + fn test_dict_encoding_should_not_be_applied_if_cardinality_larger_than_threshold() { + assert!(!is_dict_encoding_applicable( + vec![Some("a"), Some("b"), Some("c"), Some("d")], + 3, + )); + } + + #[test] + fn test_dict_encoding_should_not_be_applied_if_cardinality_equal_to_threshold() { + assert!(!is_dict_encoding_applicable( + vec![Some("a"), Some("b"), Some("c"), Some("a")], + 3, + )); + } + + #[test] + fn test_dict_encoding_should_not_be_applied_for_empty_arrays() { + assert!(!is_dict_encoding_applicable(vec![], 3)); + } + + #[test] + fn test_dict_encoding_should_not_be_applied_for_smaller_than_threshold_arrays() { + assert!(!is_dict_encoding_applicable(vec![Some("a"), Some("a")], 3)); + } + + fn is_fixed_size_encoding_applicable(arrays: Vec>>) -> bool { + let mut final_arrays = Vec::new(); + for arr in arrays { + let arr = StringArray::from(arr); + let arr = Arc::new(arr) as ArrayRef; + final_arrays.push(arr); + } + + check_fixed_size_encoding(&final_arrays).is_some() + } + + #[test] + fn test_fixed_size_binary_encoding_applicable() { + assert!(!is_fixed_size_encoding_applicable(vec![vec![]])); + + assert!(is_fixed_size_encoding_applicable(vec![vec![ + Some("a"), + Some("b") + ]])); + + assert!(!is_fixed_size_encoding_applicable(vec![vec![ + Some("abc"), + Some("de") + ]])); + + assert!(is_fixed_size_encoding_applicable(vec![vec![ + Some("pqr"), + None + ]])); + + assert!(!is_fixed_size_encoding_applicable(vec![vec![ + Some("pqr"), + Some("") + ]])); + + assert!(!is_fixed_size_encoding_applicable(vec![vec![ + Some(""), + Some("") + ]])); + } + + #[test] + fn test_fixed_size_binary_encoding_applicable_multiple_arrays() { + assert!(is_fixed_size_encoding_applicable(vec![ + vec![Some("a"), Some("b")], + vec![Some("c"), Some("d")] + ])); + + assert!(!is_fixed_size_encoding_applicable(vec![ + vec![Some("ab"), Some("bc")], + vec![Some("c"), Some("d")] + ])); + + assert!(!is_fixed_size_encoding_applicable(vec![ + vec![Some("ab"), None], + vec![None, Some("d")] + ])); + + assert!(is_fixed_size_encoding_applicable(vec![ + vec![Some("a"), None], + vec![None, Some("d")] + ])); + + assert!(!is_fixed_size_encoding_applicable(vec![ + vec![Some(""), None], + vec![None, Some("")] + ])); + + assert!(!is_fixed_size_encoding_applicable(vec![ + vec![None, None], + vec![None, None] + ])); + } + + fn verify_array_encoder( + array: ArrayRef, + field_meta: Option>, + expected_encoder: &str, + ) { + let encoding_strategy = ArrayStrategy; + let mut field = Field::new("test_field", array.data_type().clone(), true); + if let Some(field_meta) = field_meta { + field.set_metadata(field_meta); + } + let lance_field = lance_core::datatypes::Field::try_from(field).unwrap(); + let encoder_result = encoding_strategy.create_array_encoder(&[array], &lance_field); + assert!(encoder_result.is_ok()); + let encoder = encoder_result.unwrap(); + assert_eq!(format!("{:?}", encoder).as_str(), expected_encoder); + } + + #[test] + fn test_choose_encoder_for_zstd_compressed_string_field() { + verify_array_encoder( + Arc::new(StringArray::from(vec!["a", "bb", "ccc"])), + Some(HashMap::from([( + COMPRESSION_META_KEY.to_string(), + "zstd".to_string(), + )])), + "BinaryEncoder { indices_encoder: BasicEncoder { values_encoder: ValueEncoder }, compression_config: Some(CompressionConfig { scheme: Zstd, level: None }), buffer_compressor: Some(ZstdBufferCompressor { compression_level: 0 }) }", + ); + } + + #[test] + fn test_choose_encoder_for_zstd_compression_level() { + verify_array_encoder( + Arc::new(StringArray::from(vec!["a", "bb", "ccc"])), + Some(HashMap::from([ + (COMPRESSION_META_KEY.to_string(), "zstd".to_string()), + (COMPRESSION_LEVEL_META_KEY.to_string(), "22".to_string()), + ])), + "BinaryEncoder { indices_encoder: BasicEncoder { values_encoder: ValueEncoder }, compression_config: Some(CompressionConfig { scheme: Zstd, level: Some(22) }), buffer_compressor: Some(ZstdBufferCompressor { compression_level: 22 }) }", + ); + } +} diff --git a/rust/lance-encoding/src/buffer.rs b/rust/lance-encoding/src/buffer.rs index e8bcb8a3ea2..646f4f515a6 100644 --- a/rust/lance-encoding/src/buffer.rs +++ b/rust/lance-encoding/src/buffer.rs @@ -6,8 +6,7 @@ use std::{ops::Deref, panic::RefUnwindSafe, ptr::NonNull, sync::Arc}; use arrow_buffer::{ArrowNativeType, Buffer, MutableBuffer, ScalarBuffer}; -use lance_core::{utils::bit::is_pwr_two, Error, Result}; -use snafu::location; +use lance_core::{Error, Result, utils::bit::is_pwr_two}; use std::borrow::Cow; /// A copy-on-write byte buffer. @@ -154,7 +153,11 @@ impl LanceBuffer { let align = std::mem::align_of::(); let is_aligned = self.as_ptr().align_offset(align) == 0; if !self.len().is_multiple_of(std::mem::size_of::()) { - panic!("attempt to borrow_to_typed_slice to data type of size {} but we have {} bytes which isn't evenly divisible", std::mem::size_of::(), self.len()); + panic!( + "attempt to borrow_to_typed_slice to data type of size {} but we have {} bytes which isn't evenly divisible", + std::mem::size_of::(), + self.len() + ); } if is_aligned { @@ -185,7 +188,11 @@ impl LanceBuffer { pub fn borrow_to_typed_view(&self) -> Cow<'_, [T]> { let align = std::mem::align_of::(); if !self.len().is_multiple_of(std::mem::size_of::()) { - panic!("attempt to view data type of size {} but we have {} bytes which isn't evenly divisible", std::mem::size_of::(), self.len()); + panic!( + "attempt to view data type of size {} but we have {} bytes which isn't evenly divisible", + std::mem::size_of::(), + self.len() + ); } if self.as_ptr().align_offset(align) == 0 { @@ -224,7 +231,7 @@ impl LanceBuffer { if bits_per_value % 8 == 0 { Ok(bits_per_value / 8) } else { - Err(Error::InvalidInput { source: format!("LanceBuffer::zip_into_one only supports full-byte buffers currently and received a buffer with {} bits per value", bits_per_value).into(), location: location!() }) + Err(Error::invalid_input_source(format!("LanceBuffer::zip_into_one only supports full-byte buffers currently and received a buffer with {} bits per value", bits_per_value).into())) } }).collect::>>()?; let total_bytes_per_value = bytes_per_value.iter().sum::(); diff --git a/rust/lance-encoding/src/compression.rs b/rust/lance-encoding/src/compression.rs index 24c2c71d654..857ccd0320b 100644 --- a/rust/lance-encoding/src/compression.rs +++ b/rust/lance-encoding/src/compression.rs @@ -20,13 +20,16 @@ use crate::encodings::physical::bitpacking::{InlineBitpacking, OutOfLineBitpacking}; use crate::{ buffer::LanceBuffer, - compression_config::{BssMode, CompressionFieldParams, CompressionParams}, + compression_config::{BssMode, CompressionFieldParams}, constants::{ BSS_META_KEY, COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY, RLE_THRESHOLD_META_KEY, }, data::{DataBlock, FixedWidthDataBlock, VariableWidthBlock}, encodings::{ - logical::primitive::{fullzip::PerValueCompressor, miniblock::MiniBlockCompressor}, + logical::primitive::{ + fullzip::PerValueCompressor, + miniblock::{MAX_MINIBLOCK_VALUES, MiniBlockCompressor}, + }, physical::{ binary::{ BinaryBlockDecompressor, BinaryMiniBlockDecompressor, BinaryMiniBlockEncoder, @@ -37,7 +40,7 @@ use crate::{ GeneralBlockDecompressor, }, byte_stream_split::{ - should_use_bss, ByteStreamSplitDecompressor, ByteStreamSplitEncoder, + ByteStreamSplitDecompressor, ByteStreamSplitEncoder, should_use_bss, }, constant::ConstantDecompressor, fsst::{ @@ -46,27 +49,27 @@ use crate::{ }, general::{GeneralMiniBlockCompressor, GeneralMiniBlockDecompressor}, packed::{ + PackedStructFixedPerValueDecompressor, PackedStructFixedPerValueEncoder, PackedStructFixedWidthMiniBlockDecompressor, PackedStructFixedWidthMiniBlockEncoder, PackedStructVariablePerValueDecompressor, PackedStructVariablePerValueEncoder, VariablePackedStructFieldDecoder, VariablePackedStructFieldKind, }, - rle::{RleDecompressor, RleEncoder}, + rle::{ + RleChildDecompressor, RleDecompressor, RleEncoder, RunLengthWidth, + rle_encoded_size, select_run_length_width, + }, value::{ValueDecompressor, ValueEncoder}, }, }, - format::{ - pb21::{compressive_encoding::Compression, CompressiveEncoding}, - ProtobufUtils21, - }, + format::pb21::{CompressiveEncoding, compressive_encoding::Compression}, statistics::{GetStat, Stat}, - version::LanceFileVersion, }; use arrow_array::{cast::AsArray, types::UInt64Type}; +use arrow_schema::DataType; use fsst::fsst::{FSST_LEAST_INPUT_MAX_LENGTH, FSST_LEAST_INPUT_SIZE}; -use lance_core::{datatypes::Field, error::LanceOptionExt, Error, Result}; -use snafu::location; +use lance_core::{Error, Result, datatypes::Field, error::LanceOptionExt}; use std::{str::FromStr, sync::Arc}; /// Default threshold for RLE compression selection when the user explicitly provides a threshold. @@ -78,6 +81,7 @@ const DEFAULT_RLE_COMPRESSION_THRESHOLD: f64 = 0.5; // Minimum block size (32kb) to trigger general block compression const MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION: u64 = 32 * 1024; +const RLE_BLOCK_HEADER_BYTES: u128 = std::mem::size_of::() as u128; /// Trait for compression algorithms that compress an entire block of data into one opaque /// and self-described chunk. @@ -92,11 +96,11 @@ const MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION: u64 = 32 * 1024; /// required (e.g. when encoding metadata buffers like a dictionary or for encoding rep/def /// mini-block chunks) pub trait BlockCompressor: std::fmt::Debug + Send + Sync { - /// Compress the data into a single buffer + /// Compress the data into zero or one buffers and describe the codec used. /// - /// Also returns a description of the compression that can be used to decompress - /// when reading the data back - fn compress(&self, data: DataBlock) -> Result; + /// `None` represents a metadata-only codec. `Some` represents a physical + /// payload, including a zero-byte payload. + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)>; } /// A trait to pick which compression to use for given data @@ -112,12 +116,12 @@ pub trait BlockCompressor: std::fmt::Debug + Send + Sync { /// used for narrow data types (both fixed and variable length) where we can /// fit many values into an 16KiB block. pub trait CompressionStrategy: Send + Sync + std::fmt::Debug { - /// Create a block compressor for the given data + /// Create a block compressor for the given data. fn create_block_compressor( &self, field: &Field, data: &DataBlock, - ) -> Result<(Box, CompressiveEncoding)>; + ) -> Result>; /// Create a per-value compressor for the given data fn create_per_value( @@ -134,12 +138,17 @@ pub trait CompressionStrategy: Send + Sync + std::fmt::Debug { ) -> Result>; } -#[derive(Debug, Default, Clone)] -pub struct DefaultCompressionStrategy { - /// User-configured compression parameters - params: CompressionParams, - /// The lance file version for compatibilities. - version: LanceFileVersion, +pub(crate) fn compress_required_block( + strategy: &dyn CompressionStrategy, + field: &Field, + data: DataBlock, +) -> Result<(LanceBuffer, CompressiveEncoding)> { + let compressor = strategy.create_block_compressor(field, &data)?; + let (payload, encoding) = compressor.compress(data)?; + let payload = payload.ok_or_else(|| { + Error::internal("Required block compressor selected a metadata-only codec".to_string()) + })?; + Ok((payload, encoding)) } fn try_bss_for_mini_block( @@ -162,10 +171,7 @@ fn try_bss_for_mini_block( None } -fn try_rle_for_mini_block( - data: &FixedWidthDataBlock, - params: &CompressionFieldParams, -) -> Option> { +fn rle_is_applicable(data: &FixedWidthDataBlock, params: &CompressionFieldParams) -> Option { let bits = data.bits_per_value; if !matches!(bits, 8 | 16 | 32 | 64) { return None; @@ -188,43 +194,96 @@ fn try_rle_for_mini_block( return None; } - // Estimate the encoded size. - // - // RLE stores (value, run_length) pairs. Run lengths are u8 and long runs are split into - // multiple entries of up to 255 values. We don't know the run length distribution here, - // so we conservatively account for splitting with an upper bound. - let num_values = data.num_values; - let estimated_pairs = (run_count.saturating_add(num_values / 255)).min(num_values); + Some((data.num_values as u128) * (type_size as u128)) +} - let raw_bytes = (num_values as u128) * (type_size as u128); - let rle_bytes = (estimated_pairs as u128) * ((type_size + 1) as u128); +fn rle_beats_raw_and_bitpacking( + data: &FixedWidthDataBlock, + encoded_bytes: u128, + raw_bytes: u128, +) -> bool { + if encoded_bytes >= raw_bytes { + return false; + } - if rle_bytes < raw_bytes { - #[cfg(feature = "bitpacking")] + #[cfg(feature = "bitpacking")] + { + if let Some(bitpack_bytes) = estimate_inline_bitpacking_bytes(data).map(u128::from) + && bitpack_bytes < encoded_bytes { - if let Some(bitpack_bytes) = estimate_inline_bitpacking_bytes(data) { - if (bitpack_bytes as u128) < rle_bytes { - return None; - } - } + return false; } - return Some(Box::new(RleEncoder::new())); } - None + true } -fn try_rle_for_block( +fn try_fixed_u8_rle_for_mini_block( data: &FixedWidthDataBlock, - version: LanceFileVersion, params: &CompressionFieldParams, -) -> Option<(Box, CompressiveEncoding)> { - if version < LanceFileVersion::V2_2 { +) -> Option> { + let raw_bytes = rle_is_applicable(data, params)?; + let rle_bytes = estimate_rle_size_for_width_from_data( + data, + Some(*MAX_MINIBLOCK_VALUES), + RunLengthWidth::U8, + ) + .ok()?; + rle_beats_raw_and_bitpacking(data, rle_bytes, raw_bytes) + .then(|| Box::new(RleEncoder::with_run_length_width(RunLengthWidth::U8)) as _) +} + +fn try_child_rle_for_mini_block( + data: &FixedWidthDataBlock, + params: &CompressionFieldParams, +) -> Option> { + let raw_bytes = rle_is_applicable(data, params)?; + let (run_length_width, estimated_bytes) = + estimate_rle_width_and_size_from_data(data, Some(*MAX_MINIBLOCK_VALUES)).ok()?; + let child_compression = rle_child_compression_config(params); + let encoder = || { + RleEncoder::with_child_encoding( + run_length_width, + child_compression, + child_compression, + true, + ) + }; + + #[cfg(feature = "bitpacking")] + let bitpack_bytes = estimate_inline_bitpacking_bytes(data).map(u128::from); + #[cfg(not(feature = "bitpacking"))] + let bitpack_bytes = None::; + + let should_measure_children = (child_compression.is_some() || cfg!(feature = "bitpacking")) + && (estimated_bytes >= raw_bytes + || bitpack_bytes.is_some_and(|bytes| bytes < estimated_bytes)); + let selected_bytes = if should_measure_children { + encoder().selected_payload_size(data).ok()? + } else { + estimated_bytes + }; + + rle_beats_raw_and_bitpacking(data, selected_bytes, raw_bytes).then(|| Box::new(encoder()) as _) +} + +fn rle_child_compression_config(params: &CompressionFieldParams) -> Option { + let raw = params.compression.as_deref()?; + if matches!(raw, "none" | "fsst") { return None; } + let scheme = CompressionScheme::from_str(raw).ok()?; + Some(CompressionConfig::new(scheme, params.compression_level)) +} +fn try_rle_for_block_with_width( + data: &FixedWidthDataBlock, + params: &CompressionFieldParams, + run_length_width: RunLengthWidth, + rle_payload_bytes: u128, +) -> Result>> { let bits = data.bits_per_value; if !matches!(bits, 8 | 16 | 32 | 64) { - return None; + return Ok(None); } let run_count = data.expect_single_stat::(Stat::RunCount); @@ -232,15 +291,82 @@ fn try_rle_for_block( .rle_threshold .unwrap_or(DEFAULT_RLE_COMPRESSION_THRESHOLD); - if (run_count as f64) < (data.num_values as f64) * threshold { - let compressor = Box::new(RleEncoder::new()); - let encoding = ProtobufUtils21::rle( - ProtobufUtils21::flat(bits, None), - ProtobufUtils21::flat(/*bits_per_value=*/ 8, None), - ); - return Some((compressor, encoding)); + let passes_threshold = match params.rle_threshold { + Some(_) => (run_count as f64) < (data.num_values as f64) * threshold, + None => true, + }; + + if !passes_threshold { + return Ok(None); } - None + + let raw_bytes = (data.num_values as u128) * ((bits / 8) as u128); + let rle_bytes = rle_payload_bytes.saturating_add(RLE_BLOCK_HEADER_BYTES); + + if rle_bytes >= raw_bytes { + return Ok(None); + } + + #[cfg(feature = "bitpacking")] + { + if let Some(bitpack_bytes) = estimate_block_bitpacking_bytes(data) + && bitpack_bytes < rle_bytes + { + return Ok(None); + } + } + + Ok(Some(Box::new(RleEncoder::with_run_length_width( + run_length_width, + )))) +} + +fn try_fixed_u8_rle_for_block( + data: &FixedWidthDataBlock, + params: &CompressionFieldParams, +) -> Result>> { + if !matches!(data.bits_per_value, 8 | 16 | 32 | 64) { + return Ok(None); + } + let encoded_bytes = estimate_rle_size_for_width_from_data(data, None, RunLengthWidth::U8)?; + try_rle_for_block_with_width(data, params, RunLengthWidth::U8, encoded_bytes) +} + +fn try_variable_rle_for_block( + data: &FixedWidthDataBlock, + params: &CompressionFieldParams, +) -> Result>> { + if !matches!(data.bits_per_value, 8 | 16 | 32 | 64) { + return Ok(None); + } + let (width, encoded_bytes) = estimate_rle_width_and_size_from_data(data, None)?; + try_rle_for_block_with_width(data, params, width, encoded_bytes) +} + +fn estimate_rle_width_and_size_from_data( + data: &FixedWidthDataBlock, + max_segment_values: Option, +) -> Result<(RunLengthWidth, u128)> { + select_run_length_width( + &data.data, + data.num_values, + data.bits_per_value, + max_segment_values, + ) +} + +fn estimate_rle_size_for_width_from_data( + data: &FixedWidthDataBlock, + max_segment_values: Option, + run_length_width: RunLengthWidth, +) -> Result { + rle_encoded_size( + &data.data, + data.num_values, + data.bits_per_value, + max_segment_values, + run_length_width, + ) } fn try_bitpack_for_mini_block(_data: &FixedWidthDataBlock) -> Option> { @@ -292,9 +418,7 @@ fn estimate_inline_bitpacking_bytes(data: &FixedWidthDataBlock) -> Option { u64::try_from(estimated_bytes).ok() } -fn try_bitpack_for_block( - data: &FixedWidthDataBlock, -) -> Option<(Box, CompressiveEncoding)> { +fn try_bitpack_for_block(data: &FixedWidthDataBlock) -> Option> { let bits = data.bits_per_value; if !matches!(bits, 8 | 16 | 32 | 64) { return None; @@ -302,28 +426,86 @@ fn try_bitpack_for_block( let bit_widths = data.expect_stat(Stat::BitWidth); let widths = bit_widths.as_primitive::(); - let has_all_zeros = widths.values().contains(&0); let max_bit_width = *widths.values().iter().max().unwrap(); + // Full-width values save no space and leave no bit savings for a runt tail. + if max_bit_width >= bits { + return None; + } + let too_small = widths.len() == 1 && InlineBitpacking::min_size_bytes(widths.value(0)) >= data.data_size(); - if has_all_zeros || too_small { + if too_small { return None; } if data.num_values <= 1024 { - let compressor = Box::new(InlineBitpacking::new(bits)); - let encoding = ProtobufUtils21::inline_bitpacking(bits, None); - Some((compressor, encoding)) + Some(Box::new(InlineBitpacking::new(bits))) } else { - let compressor = Box::new(OutOfLineBitpacking::new(max_bit_width, bits)); - let encoding = ProtobufUtils21::out_of_line_bitpacking( - bits, - ProtobufUtils21::flat(max_bit_width, None), - ); - Some((compressor, encoding)) + Some(Box::new(OutOfLineBitpacking::new(max_bit_width, bits))) + } +} + +#[cfg(feature = "bitpacking")] +fn estimate_block_bitpacking_bytes(data: &FixedWidthDataBlock) -> Option { + let bits = data.bits_per_value; + if !matches!(bits, 8 | 16 | 32 | 64) || data.num_values == 0 { + return None; + } + + let bit_widths = data.expect_stat(Stat::BitWidth); + let widths = bit_widths.as_primitive::(); + let max_bit_width = *widths.values().iter().max()?; + let word_bytes = (bits / 8) as u128; + + let bitpacked_words = if data.num_values <= 1024 { + 1 + (1024u128 * (max_bit_width as u128)) / (bits as u128) + } else { + estimate_out_of_line_bitpacking_words(data.num_values, max_bit_width, bits)? + }; + let bitpacked_bytes = bitpacked_words.saturating_mul(word_bytes); + if bitpacked_bytes >= data.data_size() as u128 { + return None; + } + + Some(bitpacked_bytes) +} + +#[cfg(feature = "bitpacking")] +fn estimate_out_of_line_bitpacking_words( + num_values: u64, + compressed_bits_per_value: u64, + bits_per_value: u64, +) -> Option { + let num_values = usize::try_from(num_values).ok()?; + let compressed_bits_per_value = usize::try_from(compressed_bits_per_value).ok()?; + let bits_per_value = usize::try_from(bits_per_value).ok()?; + if compressed_bits_per_value >= bits_per_value { + return None; + } + + let elems_per_chunk = 1024usize; + let num_chunks = num_values.div_ceil(elems_per_chunk); + let words_per_chunk = (elems_per_chunk * compressed_bits_per_value).div_ceil(bits_per_value); + let last_chunk_is_runt = !num_values.is_multiple_of(elems_per_chunk); + + if !last_chunk_is_runt { + return Some((num_chunks * words_per_chunk) as u128); } + + let num_whole_chunks = num_chunks - 1; + let remaining_items = num_values - num_whole_chunks * elems_per_chunk; + let tail_bit_savings = bits_per_value - compressed_bits_per_value; + let padding_cost = compressed_bits_per_value * (elems_per_chunk - remaining_items); + let tail_pack_savings = tail_bit_savings * remaining_items; + let tail_words = if padding_cost < tail_pack_savings { + words_per_chunk + } else { + remaining_items + }; + + Some((num_whole_chunks * words_per_chunk + tail_words) as u128) } fn maybe_wrap_general_for_mini_block( @@ -333,9 +515,8 @@ fn maybe_wrap_general_for_mini_block( match params.compression.as_deref() { None | Some("none") | Some("fsst") => Ok(inner), Some(raw) => { - let scheme = CompressionScheme::from_str(raw).map_err(|_| { - Error::invalid_input(format!("Unknown compression scheme: {raw}"), location!()) - })?; + let scheme = CompressionScheme::from_str(raw) + .map_err(|_| Error::invalid_input(format!("Unknown compression scheme: {raw}")))?; let cfg = CompressionConfig::new(scheme, params.compression_level); Ok(Box::new(GeneralMiniBlockCompressor::new(inner, cfg))) } @@ -343,25 +524,25 @@ fn maybe_wrap_general_for_mini_block( } fn try_general_compression( - version: LanceFileVersion, field_params: &CompressionFieldParams, data: &DataBlock, ) -> Result, CompressionConfig)>> { + // Explicitly disable general compression. + if field_params.compression.as_deref() == Some("none") { + return Ok(None); + } + // User-requested compression (unused today but perhaps still used // in the future someday) if let Some(compression_scheme) = &field_params.compression { - if compression_scheme != "none" && version >= LanceFileVersion::V2_2 { - let scheme: CompressionScheme = compression_scheme.parse()?; - let config = CompressionConfig::new(scheme, field_params.compression_level); - let compressor = Box::new(CompressedBufferEncoder::try_new(config)?); - return Ok(Some((compressor, config))); - } + let scheme: CompressionScheme = compression_scheme.parse()?; + let config = CompressionConfig::new(scheme, field_params.compression_level); + let compressor = Box::new(CompressedBufferEncoder::try_new(config)?); + return Ok(Some((compressor, config))); } // Automatic compression for large blocks - if data.data_size() > MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION - && version >= LanceFileVersion::V2_2 - { + if data.data_size() > MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION { let compressor = Box::new(CompressedBufferEncoder::default()); let config = compressor.compressor.config(); return Ok(Some((compressor, config))); @@ -370,360 +551,360 @@ fn try_general_compression( Ok(None) } -impl DefaultCompressionStrategy { - /// Create a new compression strategy with default behavior - pub fn new() -> Self { - Self::default() - } +/// Parse field-level compression metadata without applying format-specific constraints. +pub fn field_metadata_params(field: &Field) -> CompressionFieldParams { + let mut params = CompressionFieldParams::default(); - /// Create a new compression strategy with user-configured parameters - pub fn with_params(params: CompressionParams) -> Self { - Self { - params, - version: LanceFileVersion::default(), - } + if let Some(compression) = field.metadata.get(COMPRESSION_META_KEY) { + params.compression = Some(compression.clone()); } - - /// Override the file version used to make compression decisions - pub fn with_version(mut self, version: LanceFileVersion) -> Self { - self.version = version; - self + if let Some(level) = field.metadata.get(COMPRESSION_LEVEL_META_KEY) { + params.compression_level = level.parse().ok(); } - - /// Parse compression parameters from field metadata - fn parse_field_metadata(field: &Field, version: &LanceFileVersion) -> CompressionFieldParams { - let mut params = CompressionFieldParams::default(); - - // Parse compression method - if let Some(compression) = field.metadata.get(COMPRESSION_META_KEY) { - params.compression = Some(compression.clone()); - } - - // Parse compression level - if let Some(level) = field.metadata.get(COMPRESSION_LEVEL_META_KEY) { - params.compression_level = level.parse().ok(); - } - - // Parse RLE threshold - if let Some(threshold) = field.metadata.get(RLE_THRESHOLD_META_KEY) { - params.rle_threshold = threshold.parse().ok(); - } - - // Parse BSS mode - if let Some(bss_str) = field.metadata.get(BSS_META_KEY) { - match BssMode::parse(bss_str) { - Some(mode) => params.bss = Some(mode), - None => { - log::warn!("Invalid BSS mode '{}', using default", bss_str); - } - } - } - - // Parse minichunk size - if let Some(minichunk_size_str) = field - .metadata - .get(super::constants::MINICHUNK_SIZE_META_KEY) - { - if let Ok(minichunk_size) = minichunk_size_str.parse::() { - // for lance v2.1, only 32kb or smaller is supported - if minichunk_size >= 32 * 1024 && *version <= LanceFileVersion::V2_1 { - log::warn!( - "minichunk_size '{}' too large for version '{}', using default", - minichunk_size, - version - ); - } else { - params.minichunk_size = Some(minichunk_size); - } - } else { - log::warn!("Invalid minichunk_size '{}', skipping", minichunk_size_str); - } + if let Some(threshold) = field.metadata.get(RLE_THRESHOLD_META_KEY) { + params.rle_threshold = threshold.parse().ok(); + } + if let Some(bss_str) = field.metadata.get(BSS_META_KEY) { + match BssMode::parse(bss_str) { + Some(mode) => params.bss = Some(mode), + None => log::warn!("Invalid BSS mode '{}', using default", bss_str), } - - params } - - fn build_fixed_width_compressor( - &self, - params: &CompressionFieldParams, - data: &FixedWidthDataBlock, - ) -> Result> { - if params.compression.as_deref() == Some("none") { - return Ok(Box::new(ValueEncoder::default())); + if let Some(minichunk_size_str) = field + .metadata + .get(super::constants::MINICHUNK_SIZE_META_KEY) + { + if let Ok(minichunk_size) = minichunk_size_str.parse::() { + params.minichunk_size = Some(minichunk_size); + } else { + log::warn!("Invalid minichunk_size '{}', skipping", minichunk_size_str); } + } - let base = try_bss_for_mini_block(data, params) - .or_else(|| try_rle_for_mini_block(data, params)) - .or_else(|| try_bitpack_for_mini_block(data)) - .unwrap_or_else(|| Box::new(ValueEncoder::default())); + params +} - maybe_wrap_general_for_mini_block(base, params) +/// Apply general-purpose compression requested for a fixed-width miniblock. +pub fn finalize_miniblock_compressor( + data: &DataBlock, + compressor: Box, + params: &CompressionFieldParams, +) -> Result> { + if matches!(data, DataBlock::FixedWidth(_)) { + maybe_wrap_general_for_mini_block(compressor, params) + } else { + Ok(compressor) } +} - /// Build compressor based on parameters for variable-width data - fn build_variable_width_compressor( - &self, - params: &CompressionFieldParams, - data: &VariableWidthBlock, - ) -> Result> { - if data.bits_per_offset != 32 && data.bits_per_offset != 64 { - return Err(Error::invalid_input( - format!( - "Variable width compression not supported for {} bit offsets", - data.bits_per_offset - ), - location!(), - )); - } +/// Honor an explicit `compression = none` request for fixed-width miniblocks. +pub fn try_uncompressed_fixed_width_miniblock( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Option> { + (matches!(data, DataBlock::FixedWidth(_)) && params.compression.as_deref() == Some("none")) + .then(|| Box::new(ValueEncoder::default()) as _) +} - // Get statistics - let data_size = data.expect_single_stat::(Stat::DataSize); - let max_len = data.expect_single_stat::(Stat::MaxLength); +/// Select byte-stream-split compression for an applicable fixed-width miniblock. +pub fn try_byte_stream_split_miniblock( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Option> { + let DataBlock::FixedWidth(data) = data else { + return None; + }; + try_bss_for_mini_block(data, params) +} - // 1. Check for explicit "none" compression - if params.compression.as_deref() == Some("none") { - return Ok(Box::new(BinaryMiniBlockEncoder::new(params.minichunk_size))); - } +/// Select the original fixed-u8 RLE miniblock grammar. +pub fn try_fixed_u8_rle_miniblock( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Option> { + let DataBlock::FixedWidth(data) = data else { + return None; + }; + try_fixed_u8_rle_for_mini_block(data, params) +} - // 2. Check for explicit "fsst" compression - if params.compression.as_deref() == Some("fsst") { - return Ok(Box::new(FsstMiniBlockEncoder::new(params.minichunk_size))); - } +/// Select variable-width RLE with independently encoded children. +pub fn try_child_rle_miniblock( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Option> { + let DataBlock::FixedWidth(data) = data else { + return None; + }; + try_child_rle_for_mini_block(data, params) +} - // 3. Choose base encoder (FSST or Binary) based on data characteristics - let mut base_encoder: Box = if max_len - >= FSST_LEAST_INPUT_MAX_LENGTH - && data_size >= FSST_LEAST_INPUT_SIZE as u64 - { - Box::new(FsstMiniBlockEncoder::new(params.minichunk_size)) - } else { - Box::new(BinaryMiniBlockEncoder::new(params.minichunk_size)) - }; +/// Select inline bitpacking for applicable fixed-width miniblocks. +pub fn try_bitpacking_miniblock(data: &DataBlock) -> Option> { + let DataBlock::FixedWidth(data) = data else { + return None; + }; + try_bitpack_for_mini_block(data) +} - // 4. Apply general compression if configured - if let Some(compression_scheme) = ¶ms.compression { - if compression_scheme != "none" && compression_scheme != "fsst" { - let scheme: CompressionScheme = compression_scheme.parse()?; - let config = CompressionConfig::new(scheme, params.compression_level); - base_encoder = Box::new(GeneralMiniBlockCompressor::new(base_encoder, config)); - } - } +/// Store fixed-width miniblock values without a value codec. +pub fn try_raw_fixed_width_miniblock(data: &DataBlock) -> Option> { + matches!(data, DataBlock::FixedWidth(_)).then(|| Box::new(ValueEncoder::default()) as _) +} - Ok(base_encoder) +/// Encode variable-width miniblocks with binary or FSST encoding. +pub fn try_variable_width_miniblock( + field: &Field, + data: &DataBlock, + params: &CompressionFieldParams, +) -> Result>> { + let DataBlock::VariableWidth(data) = data else { + return Ok(None); + }; + if data.bits_per_offset != 32 && data.bits_per_offset != 64 { + return Err(Error::invalid_input(format!( + "Variable width compression not supported for {} bit offsets", + data.bits_per_offset + ))); } - /// Merge user-configured parameters with field metadata - /// Field metadata has highest priority - fn get_merged_field_params(&self, field: &Field) -> CompressionFieldParams { - let mut field_params = self - .params - .get_field_params(&field.name, &field.data_type()); - - // Override with field metadata if present (highest priority) - let metadata_params = Self::parse_field_metadata(field, &self.version); - field_params.merge(&metadata_params); + let compression = params.compression.as_deref(); + let data_size = data.expect_single_stat::(Stat::DataSize); + let max_len = data.expect_single_stat::(Stat::MaxLength); + if compression == Some("none") { + return Ok(Some(Box::new(BinaryMiniBlockEncoder::new( + params.minichunk_size, + )))); + } - field_params + let use_fsst = compression == Some("fsst") + || (compression.is_none() + && !matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) + && max_len >= FSST_LEAST_INPUT_MAX_LENGTH + && data_size >= FSST_LEAST_INPUT_SIZE as u64); + let mut encoder: Box = if use_fsst { + Box::new(FsstMiniBlockEncoder::new(params.minichunk_size)) + } else { + Box::new(BinaryMiniBlockEncoder::new(params.minichunk_size)) + }; + if let Some(compression_scheme) = compression.filter(|scheme| *scheme != "fsst") { + let scheme: CompressionScheme = compression_scheme.parse()?; + let config = CompressionConfig::new(scheme, params.compression_level); + encoder = Box::new(GeneralMiniBlockCompressor::new(encoder, config)); } + Ok(Some(encoder)) } -impl CompressionStrategy for DefaultCompressionStrategy { - fn create_miniblock_compressor( - &self, - field: &Field, - data: &DataBlock, - ) -> Result> { - let field_params = self.get_merged_field_params(field); - - match data { - DataBlock::FixedWidth(fixed_width_data) => { - self.build_fixed_width_compressor(&field_params, fixed_width_data) - } - DataBlock::VariableWidth(variable_width_data) => { - self.build_variable_width_compressor(&field_params, variable_width_data) - } - DataBlock::Struct(struct_data_block) => { - // this condition is actually checked at `PrimitiveStructuralEncoder::do_flush`, - // just being cautious here. - if struct_data_block.has_variable_width_child() { - return Err(Error::invalid_input( - "Packed struct mini-block encoding supports only fixed-width children", - location!(), - )); - } - Ok(Box::new(PackedStructFixedWidthMiniBlockEncoder::default())) - } - DataBlock::FixedSizeList(_) => { - // Ideally we would compress the list items but this creates something of a challenge. - // We don't want to break lists across chunks and we need to worry about inner validity - // layers. If we try and use a compression scheme then it is unlikely to respect these - // constraints. - // - // For now, we just don't compress. In the future, we might want to consider a more - // sophisticated approach. - Ok(Box::new(ValueEncoder::default())) - } - _ => Err(Error::NotSupported { - source: format!( - "Mini-block compression not yet supported for block type {}", - data.name() - ) - .into(), - location: location!(), - }), - } +/// Encode fixed-width packed structs as miniblocks. +pub fn try_fixed_packed_struct_miniblock( + data: &DataBlock, +) -> Result>> { + let DataBlock::Struct(data) = data else { + return Ok(None); + }; + if data.has_variable_width_child() { + return Err(Error::invalid_input( + "Packed struct mini-block encoding supports only fixed-width children", + )); } + Ok(Some(Box::new( + PackedStructFixedWidthMiniBlockEncoder::default(), + ))) +} - fn create_per_value( - &self, - field: &Field, - data: &DataBlock, - ) -> Result> { - let field_params = self.get_merged_field_params(field); - - match data { - DataBlock::FixedWidth(_) => Ok(Box::new(ValueEncoder::default())), - DataBlock::FixedSizeList(_) => Ok(Box::new(ValueEncoder::default())), - DataBlock::Struct(struct_block) => { - if field.children.len() != struct_block.children.len() { - return Err(Error::invalid_input( - "Struct field metadata does not match data block children", - location!(), - )); - } - let has_variable_child = struct_block.has_variable_width_child(); - if has_variable_child { - if self.version < LanceFileVersion::V2_2 { - return Err(Error::NotSupported { - source: "Variable packed struct encoding requires Lance file version 2.2 or later".into(), - location: location!(), - }); - } - Ok(Box::new(PackedStructVariablePerValueEncoder::new( - self.clone(), - field.children.clone(), - ))) - } else { - Err(Error::invalid_input( - "Packed struct per-value compression should not be used for fixed-width-only structs", - location!(), - )) - } - } - DataBlock::VariableWidth(variable_width) => { - // Check for explicit "none" compression - if field_params.compression.as_deref() == Some("none") { - return Ok(Box::new(VariableEncoder::default())); - } +/// Store fixed-size-list miniblocks without a value codec. +pub fn try_raw_fixed_size_list_miniblock(data: &DataBlock) -> Option> { + matches!(data, DataBlock::FixedSizeList(_)).then(|| Box::new(ValueEncoder::default()) as _) +} - let max_len = variable_width.expect_single_stat::(Stat::MaxLength); - let data_size = variable_width.expect_single_stat::(Stat::DataSize); +/// Store fixed-width and fixed-size-list values directly in full-zip pages. +pub fn try_raw_per_value(data: &DataBlock) -> Option> { + matches!(data, DataBlock::FixedWidth(_) | DataBlock::FixedSizeList(_)) + .then(|| Box::new(ValueEncoder::default()) as _) +} - // If values are very large then use block compression on a per-value basis - // - // TODO: Could maybe use median here +fn validate_packed_struct(field: &Field, data: &DataBlock) -> Result> { + let DataBlock::Struct(data) = data else { + return Ok(None); + }; + if field.children.len() != data.children.len() { + return Err(Error::invalid_input( + "Struct field metadata does not match data block children", + )); + } + Ok(Some(data.has_variable_width_child())) +} - let per_value_requested = - if let Some(compression) = field_params.compression.as_deref() { - compression != "fsst" - } else { - false - }; +/// Reject variable-width packed structs while preserving the fixed-width error. +pub fn reject_packed_struct_per_value( + field: &Field, + data: &DataBlock, +) -> Result>> { + let Some(has_variable_child) = validate_packed_struct(field, data)? else { + return Ok(None); + }; + if has_variable_child { + return Err(Error::not_supported_source( + "Variable packed struct encoding is not enabled by the selected file format".into(), + )); + } + Err(Error::invalid_input( + "Packed struct per-value compression should not be used for fixed-width-only structs", + )) +} - if (max_len > 32 * 1024 || per_value_requested) - && data_size >= FSST_LEAST_INPUT_SIZE as u64 - { - return Ok(Box::new(CompressedBufferEncoder::default())); - } +/// Encode variable-width packed structs with the exact strategy recursively. +pub fn try_variable_packed_struct_per_value( + strategy: Arc, + field: &Field, + data: &DataBlock, +) -> Result>> { + let Some(has_variable_child) = validate_packed_struct(field, data)? else { + return Ok(None); + }; + if !has_variable_child { + return Err(Error::invalid_input( + "Packed struct per-value compression should not be used for fixed-width-only structs", + )); + } + Ok(Some(Box::new(PackedStructVariablePerValueEncoder::new( + strategy, + field.children.clone(), + )))) +} - if variable_width.bits_per_offset == 32 || variable_width.bits_per_offset == 64 { - let data_size = variable_width.expect_single_stat::(Stat::DataSize); - let max_len = variable_width.expect_single_stat::(Stat::MaxLength); +/// Encode all packed structs with the exact strategy recursively. +pub fn try_packed_struct_per_value( + strategy: Arc, + field: &Field, + data: &DataBlock, +) -> Result>> { + let Some(has_variable_child) = validate_packed_struct(field, data)? else { + return Ok(None); + }; + if has_variable_child { + return Ok(Some(Box::new(PackedStructVariablePerValueEncoder::new( + strategy, + field.children.clone(), + )))); + } - let variable_compression = Box::new(VariableEncoder::default()); + Ok(Some(Box::new(PackedStructFixedPerValueEncoder::new( + field.children.clone(), + )))) +} - // Use FSST if explicitly requested or if data characteristics warrant it - if field_params.compression.as_deref() == Some("fsst") - || (max_len >= FSST_LEAST_INPUT_MAX_LENGTH - && data_size >= FSST_LEAST_INPUT_SIZE as u64) - { - Ok(Box::new(FsstPerValueEncoder::new(variable_compression))) - } else { - Ok(variable_compression) - } - } else { - panic!("Does not support MiniBlockCompression for VariableWidth DataBlock with {} bits offsets.", variable_width.bits_per_offset); - } - } - _ => unreachable!( - "Per-value compression not yet supported for block type: {}", - data.name() - ), +/// Encode variable-width values directly, with FSST or per-value compression +/// when applicable. +pub fn try_variable_width_per_value( + field: &Field, + data: &DataBlock, + params: &CompressionFieldParams, +) -> Result>> { + let DataBlock::VariableWidth(data) = data else { + return Ok(None); + }; + let compression = params.compression.as_deref(); + if compression == Some("none") { + return Ok(Some(Box::new(VariableEncoder::default()))); + } + + let max_len = data.expect_single_stat::(Stat::MaxLength); + let data_size = data.expect_single_stat::(Stat::DataSize); + let per_value_requested = compression.is_some_and(|compression| compression != "fsst"); + if (max_len > 32 * 1024 || per_value_requested) && data_size >= FSST_LEAST_INPUT_SIZE as u64 { + if compression == Some("zstd") { + let config = CompressionConfig::new(CompressionScheme::Zstd, params.compression_level); + return Ok(Some(Box::new(CompressedBufferEncoder::try_new(config)?))); } + return Ok(Some(Box::new(CompressedBufferEncoder::default()))); } - fn create_block_compressor( - &self, - field: &Field, - data: &DataBlock, - ) -> Result<(Box, CompressiveEncoding)> { - let field_params = self.get_merged_field_params(field); + if data.bits_per_offset != 32 && data.bits_per_offset != 64 { + return Err(Error::invalid_input(format!( + "Per-value compression does not support variable-width data with {}-bit offsets", + data.bits_per_offset + ))); + } + let encoder = Box::new(VariableEncoder::default()); + let use_fsst = compression == Some("fsst") + || (compression.is_none() + && !matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) + && max_len >= FSST_LEAST_INPUT_MAX_LENGTH + && data_size >= FSST_LEAST_INPUT_SIZE as u64); + Ok(Some(if use_fsst { + Box::new(FsstPerValueEncoder::new(encoder)) + } else { + encoder + })) +} - match data { - DataBlock::FixedWidth(fixed_width) => { - if let Some((compressor, encoding)) = - try_rle_for_block(fixed_width, self.version, &field_params) - { - return Ok((compressor, encoding)); - } - if let Some((compressor, encoding)) = try_bitpack_for_block(fixed_width) { - return Ok((compressor, encoding)); - } +/// Select fixed-u8 RLE for block compression. +pub fn try_fixed_u8_rle_block( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Result>> { + let DataBlock::FixedWidth(data) = data else { + return Ok(None); + }; + try_fixed_u8_rle_for_block(data, params) +} - // Try general compression (user-requested or automatic over MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION) - if let Some((compressor, config)) = - try_general_compression(self.version, &field_params, data)? - { - let encoding = ProtobufUtils21::wrapped( - config, - ProtobufUtils21::flat(fixed_width.bits_per_value, None), - )?; - return Ok((compressor, encoding)); - } +/// Select variable-width RLE for block compression. +pub fn try_variable_rle_block( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Result>> { + let DataBlock::FixedWidth(data) = data else { + return Ok(None); + }; + try_variable_rle_for_block(data, params) +} - let encoder = Box::new(ValueEncoder::default()); - let encoding = ProtobufUtils21::flat(fixed_width.bits_per_value, None); - Ok((encoder, encoding)) - } - DataBlock::VariableWidth(variable_width) => { - // Try general compression - if let Some((compressor, config)) = - try_general_compression(self.version, &field_params, data)? - { - let encoding = ProtobufUtils21::wrapped( - config, - ProtobufUtils21::variable( - ProtobufUtils21::flat(variable_width.bits_per_offset as u64, None), - None, - ), - )?; - return Ok((compressor, encoding)); - } +/// Select block bitpacking for applicable fixed-width values. +pub fn try_bitpacking_block(data: &DataBlock) -> Option> { + let DataBlock::FixedWidth(data) = data else { + return None; + }; + try_bitpack_for_block(data) +} - let encoder = Box::new(VariableEncoder::default()); - let encoding = ProtobufUtils21::variable( - ProtobufUtils21::flat(variable_width.bits_per_offset as u64, None), - None, - ); - Ok((encoder, encoding)) - } - _ => unreachable!(), +/// Select explicitly requested or automatic general-purpose block compression. +pub fn try_general_block( + data: &DataBlock, + params: &CompressionFieldParams, +) -> Result>> { + let Some((compressor, _config)) = try_general_compression(params, data)? else { + return Ok(None); + }; + Ok(Some(compressor)) +} + +/// Store fixed- and variable-width block values without block compression. +pub fn try_raw_block(data: &DataBlock) -> Option> { + match data { + DataBlock::FixedWidth(_) => { + Some(Box::new(ValueEncoder::default()) as Box) } + DataBlock::VariableWidth(_) => { + Some(Box::new(VariableEncoder::default()) as Box) + } + _ => None, } } pub trait MiniBlockDecompressor: std::fmt::Debug + Send + Sync { fn decompress(&self, data: Vec, num_values: u64) -> Result; + + /// Returns the exact aggregate decoded size when it is determined solely by the value count. + /// + /// Implementations should only return `Some` when this aggregate estimate can be used by + /// [`DataBlockBuilder`](crate::data::DataBlockBuilder) to preallocate the decoded output + /// exactly. Outputs with multiple buffers or whose layout-dependent allocation cannot be + /// represented by one aggregate estimate should return `None`. + fn decoded_size_bytes(&self, _num_values: u64) -> Option { + None + } } pub trait FixedPerValueDecompressor: std::fmt::Debug + Send + Sync { @@ -733,6 +914,16 @@ pub trait FixedPerValueDecompressor: std::fmt::Debug + Send + Sync { /// /// Currently (and probably long term) this must be a multiple of 8 fn bits_per_value(&self) -> u64; + + /// Returns the exact aggregate decoded size when it is determined solely by the value count. + /// + /// Implementations should only return `Some` when this aggregate estimate can be used by + /// [`DataBlockBuilder`](crate::data::DataBlockBuilder) to preallocate the decoded output + /// exactly. Outputs with multiple buffers or whose layout-dependent allocation cannot be + /// represented by one aggregate estimate should return `None`. + fn decoded_size_bytes(&self, _num_values: u64) -> Option { + None + } } pub trait VariablePerValueDecompressor: std::fmt::Debug + Send + Sync { @@ -741,7 +932,31 @@ pub trait VariablePerValueDecompressor: std::fmt::Debug + Send + Sync { } pub trait BlockDecompressor: std::fmt::Debug + Send + Sync { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result; + fn decompress(&self, data: Option, num_values: u64) -> Result; + + /// Whether this codec consumes one payload buffer. + fn requires_payload(&self) -> bool { + true + } + + /// Inspect a block for an exact payload-derived value count when supported. + /// + /// This must not materialize the decoded values. `None` means the encoding requires an + /// external count or cannot safely prove one from this payload. + fn infer_num_values(&self, _data: &LanceBuffer) -> Result> { + Ok(None) + } +} + +pub(crate) fn require_block_payload(data: Option, codec: &str) -> Result { + data.ok_or_else(|| Error::invalid_input(format!("{codec} requires one payload"))) +} + +pub(crate) fn require_no_block_payload(data: Option, codec: &str) -> Result<()> { + if data.is_some() { + return Err(Error::invalid_input(format!("{codec} expects no payload"))); + } + Ok(()) } pub trait DecompressionStrategy: std::fmt::Debug + Send + Sync { @@ -783,10 +998,9 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { Ok(Box::new(InlineBitpacking::from_description(description))) } #[cfg(not(feature = "bitpacking"))] - Compression::InlineBitpacking(_) => Err(Error::NotSupported { - source: "this runtime was not built with bitpacking support".into(), - location: location!(), - }), + Compression::InlineBitpacking(_) => Err(Error::not_supported_source( + "this runtime was not built with bitpacking support".into(), + )), Compression::Variable(variable) => { let Compression::Flat(offsets) = variable .offsets @@ -815,19 +1029,18 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { Compression::PackedStruct(description) => Ok(Box::new( PackedStructFixedWidthMiniBlockDecompressor::new(description), )), - Compression::VariablePackedStruct(_) => Err(Error::NotSupported { - source: "variable packed struct decoding is not yet implemented".into(), - location: location!(), - }), + Compression::VariablePackedStruct(_) => Err(Error::not_supported_source( + "variable packed struct decoding is not yet implemented".into(), + )), Compression::FixedSizeList(fsl) => { // In the future, we might need to do something more complex here if FSL supports // compression. - Ok(Box::new(ValueDecompressor::from_fsl(fsl))) - } - Compression::Rle(rle) => { - let bits_per_value = validate_rle_compression(rle)?; - Ok(Box::new(RleDecompressor::new(bits_per_value))) + Ok(Box::new(ValueDecompressor::from_fsl(fsl)?)) } + Compression::Rle(rle) => Ok(Box::new(create_rle_decompressor( + rle, + decompression_strategy, + )?)), Compression::ByteStreamSplit(bss) => { let Compression::Flat(values) = bss.values.as_ref().unwrap().compression.as_ref().unwrap() @@ -842,14 +1055,14 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { // Create inner decompressor let inner_decompressor = self.create_miniblock_decompressor( general.values.as_ref().ok_or_else(|| { - Error::invalid_input("GeneralMiniBlock missing inner encoding", location!()) + Error::invalid_input("GeneralMiniBlock missing inner encoding") })?, decompression_strategy, )?; // Parse compression config let compression = general.compression.as_ref().ok_or_else(|| { - Error::invalid_input("GeneralMiniBlock missing compression config", location!()) + Error::invalid_input("GeneralMiniBlock missing compression config") })?; let scheme = compression.scheme().try_into()?; @@ -877,7 +1090,10 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { .map(|v| LanceBuffer::from_bytes(v.clone(), 1)), ))), Compression::Flat(flat) => Ok(Box::new(ValueDecompressor::from_flat(flat))), - Compression::FixedSizeList(fsl) => Ok(Box::new(ValueDecompressor::from_fsl(fsl))), + Compression::FixedSizeList(fsl) => Ok(Box::new(ValueDecompressor::from_fsl(fsl)?)), + Compression::PackedStruct(description) => Ok(Box::new( + PackedStructFixedPerValueDecompressor::new(description)?, + )), _ => todo!("fixed-per-value decompressor for {:?}", description), } } @@ -901,29 +1117,21 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { assert!(offsets.bits_per_value < u8::MAX as u64); Ok(Box::new(VariableDecoder::default())) } - Compression::Fsst(ref fsst) => Ok(Box::new(FsstPerValueDecompressor::new( + Compression::Fsst(fsst) => Ok(Box::new(FsstPerValueDecompressor::new( LanceBuffer::from_bytes(fsst.symbol_table.clone(), 1), Box::new(VariableDecoder::default()), ))), - Compression::General(ref general) => { - Ok(Box::new(CompressedBufferEncoder::from_scheme( - general.compression.as_ref().expect_ok()?.scheme(), - )?)) - } + Compression::General(general) => Ok(Box::new(CompressedBufferEncoder::from_scheme( + general.compression.as_ref().expect_ok()?.scheme(), + )?)), Compression::VariablePackedStruct(description) => { let mut fields = Vec::with_capacity(description.fields.len()); for field in &description.fields { let value_encoding = field.value.as_ref().ok_or_else(|| { - Error::invalid_input( - "VariablePackedStruct field is missing value encoding", - location!(), - ) + Error::invalid_input("VariablePackedStruct field is missing value encoding") })?; let decoder = match field.layout.as_ref().ok_or_else(|| { - Error::invalid_input( - "VariablePackedStruct field is missing layout details", - location!(), - ) + Error::invalid_input("VariablePackedStruct field is missing layout details") })? { crate::format::pb21::variable_packed_struct::field_encoding::Layout::BitsPerValue( bits_per_value, @@ -978,7 +1186,7 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { } Compression::Variable(_) => Ok(Box::new(BinaryBlockDecompressor::default())), Compression::FixedSizeList(fsl) => { - Ok(Box::new(ValueDecompressor::from_fsl(fsl.as_ref()))) + Ok(Box::new(ValueDecompressor::from_fsl(fsl.as_ref())?)) } Compression::OutOfLineBitpacking(out_of_line) => { // Extract the compressed bit width from the values encoding @@ -992,10 +1200,9 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { { Compression::Flat(flat) => flat.bits_per_value, _ => { - return Err(Error::InvalidInput { - location: location!(), - source: "OutOfLineBitpacking values must use Flat encoding".into(), - }) + return Err(Error::invalid_input_source( + "OutOfLineBitpacking values must use Flat encoding".into(), + )); } }; Ok(Box::new(OutOfLineBitpacking::new( @@ -1008,19 +1215,13 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { .values .as_ref() .ok_or_else(|| { - Error::invalid_input( - "General compression missing inner encoding", - location!(), - ) + Error::invalid_input("General compression missing inner encoding") })? .as_ref(); let inner_decompressor = self.create_block_decompressor(inner_desc)?; let compression = general.compression.as_ref().ok_or_else(|| { - Error::invalid_input( - "General compression missing compression config", - location!(), - ) + Error::invalid_input("General compression missing compression config") })?; let scheme = compression.scheme().try_into()?; let config = CompressionConfig::new(scheme, compression.level); @@ -1029,69 +1230,216 @@ impl DecompressionStrategy for DefaultDecompressionStrategy { Ok(Box::new(general_decompressor)) } - Compression::Rle(rle) => { - let bits_per_value = validate_rle_compression(rle)?; - Ok(Box::new(RleDecompressor::new(bits_per_value))) - } + Compression::Rle(rle) => Ok(Box::new(create_rle_decompressor(rle, self)?)), _ => todo!(), } } } -/// Validates RLE compression format and extracts bits_per_value -fn validate_rle_compression(rle: &crate::format::pb21::Rle) -> Result { - let values = rle.values.as_ref().ok_or_else(|| { - Error::invalid_input("RLE compression missing values encoding", location!()) - })?; - let run_lengths = rle.run_lengths.as_ref().ok_or_else(|| { - Error::invalid_input("RLE compression missing run lengths encoding", location!()) - })?; +pub(crate) fn create_rle_decompressor( + rle: &crate::format::pb21::Rle, + decompression_strategy: &dyn DecompressionStrategy, +) -> Result { + let values = rle + .values + .as_ref() + .ok_or_else(|| Error::invalid_input("RLE compression missing values encoding"))?; + let run_lengths = rle + .run_lengths + .as_ref() + .ok_or_else(|| Error::invalid_input("RLE compression missing run lengths encoding"))?; + + let values = create_rle_child_decompressor(values, "values", decompression_strategy)?; + let run_lengths = + create_rle_child_decompressor(run_lengths, "run lengths", decompression_strategy)?; + + if !matches!(values.bits_per_value(), 8 | 16 | 32 | 64) { + return Err(Error::invalid_input(format!( + "RLE compression only supports 8, 16, 32, or 64-bit values, got {}", + values.bits_per_value() + ))); + } - let values = values.compression.as_ref().ok_or_else(|| { - Error::invalid_input("RLE compression missing values compression", location!()) - })?; - let Compression::Flat(values) = values else { - return Err(Error::invalid_input( - "RLE compression only supports flat values", - location!(), - )); - }; + let run_length_width = + RunLengthWidth::from_bits(run_lengths.bits_per_value()).ok_or_else(|| { + Error::invalid_input(format!( + "RLE compression only supports 8, 16, or 32-bit run lengths, got {}", + run_lengths.bits_per_value() + )) + })?; - let run_lengths = run_lengths.compression.as_ref().ok_or_else(|| { - Error::invalid_input( - "RLE compression missing run lengths compression", - location!(), - ) - })?; - let Compression::Flat(run_lengths) = run_lengths else { + if values.requires_num_values() && run_lengths.requires_num_values() { return Err(Error::invalid_input( - "RLE compression only supports flat run lengths", - location!(), + "RLE values and run lengths child encodings cannot both require the run count", )); - }; + } - if run_lengths.bits_per_value != 8 { - return Err(Error::invalid_input( - format!( - "RLE compression only supports 8-bit run lengths, got {}", - run_lengths.bits_per_value - ), - location!(), + if values.is_identity() && run_lengths.is_identity() { + return Ok(RleDecompressor::with_run_length_width( + values.bits_per_value(), + run_length_width, )); } - Ok(values.bits_per_value) + Ok(RleDecompressor::with_child_decompressors( + values.bits_per_value(), + run_length_width, + values, + run_lengths, + )) +} + +fn create_rle_child_decompressor( + encoding: &CompressiveEncoding, + role: &str, + decompression_strategy: &dyn DecompressionStrategy, +) -> Result { + let compression = encoding + .compression + .as_ref() + .ok_or_else(|| Error::invalid_input(format!("RLE {role} missing child compression")))?; + let (bits_per_value, requires_num_values, needs_decompressor) = + validate_rle_child_compression(compression, role)?; + + if needs_decompressor { + Ok(RleChildDecompressor::block( + bits_per_value, + decompression_strategy.create_block_decompressor(encoding)?, + requires_num_values, + )) + } else { + Ok(RleChildDecompressor::flat(bits_per_value)) + } +} + +fn validate_rle_child_compression( + compression: &Compression, + role: &str, +) -> Result<(u64, bool, bool)> { + match compression { + Compression::Flat(flat) => Ok((flat.bits_per_value, false, false)), + Compression::General(general) => { + general.compression.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} general child missing compression config" + )) + })?; + let values = general.values.as_ref().ok_or_else(|| { + Error::invalid_input(format!("RLE {role} general child missing inner encoding")) + })?; + let inner = values.compression.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} general child missing inner compression" + )) + })?; + let (bits_per_value, requires_num_values) = + validate_rle_block_child_inner(inner, role)?; + Ok((bits_per_value, requires_num_values, true)) + } + Compression::OutOfLineBitpacking(out_of_line) => { + let values = out_of_line.values.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} bitpacking child missing values encoding" + )) + })?; + let Compression::Flat(_) = values.compression.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} bitpacking child missing values compression" + )) + })? + else { + return Err(Error::invalid_input(format!( + "RLE {role} bitpacking child only supports flat values" + ))); + }; + Ok((out_of_line.uncompressed_bits_per_value, true, true)) + } + other => Err(Error::invalid_input(format!( + "RLE {role} only supports flat, general, or out-of-line bitpacking child encodings, got {}", + compression_name(other) + ))), + } +} + +fn validate_rle_block_child_inner(compression: &Compression, role: &str) -> Result<(u64, bool)> { + match compression { + Compression::Flat(flat) => Ok((flat.bits_per_value, false)), + Compression::OutOfLineBitpacking(out_of_line) => { + let values = out_of_line.values.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} bitpacking child missing values encoding" + )) + })?; + let Compression::Flat(_) = values.compression.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "RLE {role} bitpacking child missing values compression" + )) + })? + else { + return Err(Error::invalid_input(format!( + "RLE {role} bitpacking child only supports flat values" + ))); + }; + Ok((out_of_line.uncompressed_bits_per_value, true)) + } + other => Err(Error::invalid_input(format!( + "RLE {role} general child only supports flat or out-of-line bitpacking inner encodings, got {}", + compression_name(other) + ))), + } +} + +fn compression_name(compression: &Compression) -> &'static str { + match compression { + Compression::Flat(_) => "flat", + Compression::Variable(_) => "variable", + Compression::Fsst(_) => "fsst", + Compression::OutOfLineBitpacking(_) => "out-of-line bitpacking", + Compression::InlineBitpacking(_) => "inline bitpacking", + Compression::General(_) => "general", + Compression::Constant(_) => "constant", + Compression::Dictionary(_) => "dictionary", + Compression::ByteStreamSplit(_) => "byte stream split", + Compression::PackedStruct(_) => "packed struct", + Compression::FixedSizeList(_) => "fixed-size list", + Compression::VariablePackedStruct(_) => "variable packed struct", + Compression::Rle(_) => "rle", + } } #[cfg(test)] mod tests { use super::*; use crate::buffer::LanceBuffer; + use crate::compression_config::CompressionParams; use crate::data::{BlockInfo, DataBlock, FixedWidthDataBlock}; + use crate::encodings::logical::primitive::miniblock::MiniBlockCompressionContext; use crate::statistics::ComputeStat; - use crate::testing::extract_array_encoding_chain; + use crate::testing::{TestEncoding, extract_array_encoding_chain, test_compression_strategy}; use arrow_schema::{DataType, Field as ArrowField}; use std::collections::HashMap; + fn strategy(encoding: TestEncoding, params: CompressionParams) -> Arc { + test_compression_strategy(encoding, params) + } + + fn baseline_strategy(params: CompressionParams) -> Arc { + strategy(TestEncoding::StructuralU16, params) + } + + fn selected_block_codec( + strategy: &Arc, + field: &Field, + data: &DataBlock, + ) -> (Box, CompressiveEncoding) { + let compressor = strategy.create_block_compressor(field, data).unwrap(); + let (_, encoding) = compressor.compress(data.clone()).unwrap(); + (compressor, encoding) + } + + fn miniblock_context() -> MiniBlockCompressionContext { + MiniBlockCompressionContext::new(0, true, true) + } + fn create_test_field(name: &str, data_type: DataType) -> Field { let arrow_field = ArrowField::new(name, data_type, true); let mut field = Field::try_from(&arrow_field).unwrap(); @@ -1168,6 +1516,37 @@ mod tests { DataBlock::FixedWidth(block) } + fn rle_run_length_bits(encoding: &CompressiveEncoding) -> u64 { + let Compression::Rle(rle) = encoding.compression.as_ref().unwrap() else { + panic!("expected RLE encoding"); + }; + let Compression::Flat(run_lengths) = rle + .run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap() + else { + panic!("expected flat run lengths"); + }; + run_lengths.bits_per_value + } + + fn expect_rle_encoding(encoding: &CompressiveEncoding) -> &crate::format::pb21::Rle { + match encoding.compression.as_ref().unwrap() { + Compression::Rle(rle) => rle, + Compression::General(general) => { + let inner = general.values.as_ref().unwrap(); + let Compression::Rle(rle) = inner.compression.as_ref().unwrap() else { + panic!("expected wrapped RLE encoding"); + }; + rle + } + other => panic!("expected RLE encoding, got {}", compression_name(other)), + } + } + fn create_variable_width_block( bits_per_offset: u8, num_values: u64, @@ -1229,6 +1608,10 @@ mod tests { DataBlock::VariableWidth(block) } + fn create_fsst_candidate_variable_width_block() -> DataBlock { + create_variable_width_block(32, 4096, FSST_LEAST_INPUT_MAX_LENGTH as usize + 16) + } + #[test] fn test_parameter_based_compression() { let mut params = CompressionParams::new(); @@ -1245,7 +1628,7 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); let field = create_test_field("user_id", DataType::Int32); // Create data with low run count for RLE @@ -1277,7 +1660,7 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); let field = create_test_field("some_column", DataType::Int32); // Create data with very low run count (50 runs for 1000 values = 0.05 ratio) let data = create_fixed_width_block_with_stats(32, 1000, 50); @@ -1287,10 +1670,108 @@ mod tests { assert!(format!("{:?}", compressor).contains("RleEncoder")); } + // Regression for #6626: an all-zero stat segment (e.g. rep/def for a long + // run of empty lists) used to disable block bitpacking entirely. + #[test] + #[cfg(feature = "bitpacking")] + fn test_block_bitpacks_with_zero_segment() { + let strategy = baseline_strategy(CompressionParams::default()); + let field = create_test_field("levels", DataType::UInt16); + + // First 1024 zeros, then 1024 ones; max bit width is 1. + let mut values: Vec = vec![0; 1024]; + values.extend(std::iter::repeat_n(1u16, 1024)); + let mut block = FixedWidthDataBlock { + bits_per_value: 16, + data: LanceBuffer::reinterpret_vec(values), + num_values: 2048, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + let data = DataBlock::FixedWidth(block); + + let compressor = strategy.create_block_compressor(&field, &data).unwrap(); + let debug_str = format!("{:?}", compressor); + assert!( + debug_str.contains("OutOfLineBitpacking"), + "expected OutOfLineBitpacking, got: {debug_str}" + ); + } + + #[rstest::rstest] + #[case::runt_tail(1600)] + #[case::whole_chunks(2048)] + #[cfg(feature = "bitpacking")] + fn test_block_skips_full_width_bitpacking(#[case] num_values: usize) { + let mut block = FixedWidthDataBlock { + bits_per_value: 64, + data: LanceBuffer::reinterpret_vec(vec![-1_i64; num_values]), + num_values: num_values as u64, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + + assert!(try_bitpacking_block(&DataBlock::FixedWidth(block)).is_none()); + } + + #[test] + fn test_rle_block_accounts_for_header_before_selecting() { + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let field = create_test_field("small_constant", DataType::Int32); + let values = vec![42i32; 2]; + let mut block = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 2, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + let data = DataBlock::FixedWidth(block); + + let (compressor, encoding) = selected_block_codec(&strategy, &field, &data); + + assert!(format!("{compressor:?}").contains("ValueEncoder")); + assert!(matches!( + encoding.compression.as_ref(), + Some(Compression::Flat(_)) + )); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_block_prefers_bitpacking_when_smaller() { + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let field = create_test_field("levels", DataType::UInt16); + + let mut values = Vec::with_capacity(2048); + for run_idx in 0..1024 { + values.extend(std::iter::repeat_n((run_idx % 2) as u16, 2)); + } + let mut block = FixedWidthDataBlock { + bits_per_value: 16, + data: LanceBuffer::reinterpret_vec(values), + num_values: 2048, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + let data = DataBlock::FixedWidth(block); + + let (compressor, encoding) = selected_block_codec(&strategy, &field, &data); + let debug_str = format!("{compressor:?}"); + assert!( + debug_str.contains("OutOfLineBitpacking"), + "expected OutOfLineBitpacking, got: {debug_str}" + ); + assert!(matches!( + encoding.compression.as_ref(), + Some(Compression::OutOfLineBitpacking(_)) + )); + } + #[test] #[cfg(feature = "bitpacking")] fn test_low_cardinality_prefers_bitpacking_over_rle() { - let strategy = DefaultCompressionStrategy::new(); + let strategy = baseline_strategy(CompressionParams::default()); let field = create_test_field("int_score", DataType::Int64); // Low cardinality values (3/4/5) but with moderate run count: @@ -1353,7 +1834,7 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); let field = create_test_field("embeddings", DataType::Float32); let fixed_data = create_fixed_width_block(32, 1000); let variable_data = create_variable_width_block(32, 10, 32 * 1024); @@ -1362,12 +1843,16 @@ mod tests { let compressor = strategy .create_miniblock_compressor(&field, &fixed_data) .unwrap(); - let (_block, encoding) = compressor.compress(fixed_data.clone()).unwrap(); + let (_block, encoding) = compressor + .compress(miniblock_context(), fixed_data.clone()) + .unwrap(); check_uncompressed_encoding(&encoding, false); let compressor = strategy .create_miniblock_compressor(&field, &variable_data) .unwrap(); - let (_block, encoding) = compressor.compress(variable_data.clone()).unwrap(); + let (_block, encoding) = compressor + .compress(miniblock_context(), variable_data.clone()) + .unwrap(); check_uncompressed_encoding(&encoding, true); // Test pervalue @@ -1380,40 +1865,154 @@ mod tests { } #[test] - fn test_field_metadata_none_compression() { - // Prepare field with metadata for none compression - let mut arrow_field = ArrowField::new("simple_col", DataType::Binary, true); - let mut metadata = HashMap::new(); - metadata.insert(COMPRESSION_META_KEY.to_string(), "none".to_string()); - arrow_field = arrow_field.with_metadata(metadata); - let field = Field::try_from(&arrow_field).unwrap(); - - let strategy = DefaultCompressionStrategy::with_params(CompressionParams::new()); - - // Test miniblock - let fixed_data = create_fixed_width_block(32, 1000); - let variable_data = create_variable_width_block(32, 10, 32 * 1024); + fn test_field_metadata_none_compression() { + // Prepare field with metadata for none compression + let mut arrow_field = ArrowField::new("simple_col", DataType::Binary, true); + let mut metadata = HashMap::new(); + metadata.insert(COMPRESSION_META_KEY.to_string(), "none".to_string()); + arrow_field = arrow_field.with_metadata(metadata); + let field = Field::try_from(&arrow_field).unwrap(); + + let strategy = baseline_strategy(CompressionParams::new()); + + // Test miniblock + let fixed_data = create_fixed_width_block(32, 1000); + let variable_data = create_variable_width_block(32, 10, 32 * 1024); + + let compressor = strategy + .create_miniblock_compressor(&field, &fixed_data) + .unwrap(); + let (_block, encoding) = compressor + .compress(miniblock_context(), fixed_data.clone()) + .unwrap(); + check_uncompressed_encoding(&encoding, false); + + let compressor = strategy + .create_miniblock_compressor(&field, &variable_data) + .unwrap(); + let (_block, encoding) = compressor + .compress(miniblock_context(), variable_data.clone()) + .unwrap(); + check_uncompressed_encoding(&encoding, true); + + // Test pervalue + let compressor = strategy.create_per_value(&field, &fixed_data).unwrap(); + let (_block, encoding) = compressor.compress(fixed_data).unwrap(); + check_uncompressed_encoding(&encoding, false); + + let compressor = strategy.create_per_value(&field, &variable_data).unwrap(); + let (_block, encoding) = compressor.compress(variable_data).unwrap(); + check_uncompressed_encoding(&encoding, true); + } + + #[test] + fn test_auto_fsst_disabled_for_binary_fields() { + let strategy = baseline_strategy(CompressionParams::default()); + let field = create_test_field("bytes", DataType::Binary); + let variable_data = create_fsst_candidate_variable_width_block(); + + let miniblock = strategy + .create_miniblock_compressor(&field, &variable_data) + .unwrap(); + let miniblock_debug = format!("{:?}", miniblock); + assert!( + miniblock_debug.contains("BinaryMiniBlockEncoder"), + "expected BinaryMiniBlockEncoder, got: {miniblock_debug}" + ); + assert!( + !miniblock_debug.contains("FsstMiniBlockEncoder"), + "did not expect FsstMiniBlockEncoder, got: {miniblock_debug}" + ); + + let per_value = strategy.create_per_value(&field, &variable_data).unwrap(); + let per_value_debug = format!("{:?}", per_value); + assert!( + per_value_debug.contains("VariableEncoder"), + "expected VariableEncoder, got: {per_value_debug}" + ); + assert!( + !per_value_debug.contains("FsstPerValueEncoder"), + "did not expect FsstPerValueEncoder, got: {per_value_debug}" + ); + } + + #[test] + fn test_auto_fsst_still_enabled_for_utf8_fields() { + let strategy = baseline_strategy(CompressionParams::default()); + let field = create_test_field("text", DataType::Utf8); + let variable_data = create_fsst_candidate_variable_width_block(); + + let miniblock = strategy + .create_miniblock_compressor(&field, &variable_data) + .unwrap(); + let miniblock_debug = format!("{:?}", miniblock); + assert!( + miniblock_debug.contains("FsstMiniBlockEncoder"), + "expected FsstMiniBlockEncoder, got: {miniblock_debug}" + ); + + let per_value = strategy.create_per_value(&field, &variable_data).unwrap(); + let per_value_debug = format!("{:?}", per_value); + assert!( + per_value_debug.contains("FsstPerValueEncoder"), + "expected FsstPerValueEncoder, got: {per_value_debug}" + ); + } + + #[test] + fn test_explicit_fsst_still_supported_for_binary_fields() { + let mut params = CompressionParams::new(); + params.columns.insert( + "bytes".to_string(), + CompressionFieldParams { + compression: Some("fsst".to_string()), + ..Default::default() + }, + ); - let compressor = strategy - .create_miniblock_compressor(&field, &fixed_data) - .unwrap(); - let (_block, encoding) = compressor.compress(fixed_data.clone()).unwrap(); - check_uncompressed_encoding(&encoding, false); + let strategy = baseline_strategy(params); + let field = create_test_field("bytes", DataType::Binary); + let variable_data = create_fsst_candidate_variable_width_block(); - let compressor = strategy + let miniblock = strategy .create_miniblock_compressor(&field, &variable_data) .unwrap(); - let (_block, encoding) = compressor.compress(variable_data.clone()).unwrap(); - check_uncompressed_encoding(&encoding, true); + let miniblock_debug = format!("{:?}", miniblock); + assert!( + miniblock_debug.contains("FsstMiniBlockEncoder"), + "expected FsstMiniBlockEncoder, got: {miniblock_debug}" + ); - // Test pervalue - let compressor = strategy.create_per_value(&field, &fixed_data).unwrap(); - let (_block, encoding) = compressor.compress(fixed_data).unwrap(); - check_uncompressed_encoding(&encoding, false); + let per_value = strategy.create_per_value(&field, &variable_data).unwrap(); + let per_value_debug = format!("{:?}", per_value); + assert!( + per_value_debug.contains("FsstPerValueEncoder"), + "expected FsstPerValueEncoder, got: {per_value_debug}" + ); + } - let compressor = strategy.create_per_value(&field, &variable_data).unwrap(); - let (_block, encoding) = compressor.compress(variable_data).unwrap(); - check_uncompressed_encoding(&encoding, true); + #[test] + #[cfg(feature = "zstd")] + fn test_compression_level_honored_for_large_per_value() { + let mut params = CompressionParams::new(); + params.columns.insert( + "html".to_string(), + CompressionFieldParams { + compression: Some("zstd".to_string()), + compression_level: Some(19), + ..Default::default() + }, + ); + let strategy = baseline_strategy(params); + let field = create_test_field("html", DataType::Utf8); + let large = create_variable_width_block(32, 64, 40 * 1024); + + let per_value = strategy.create_per_value(&field, &large).unwrap(); + let debug = format!("{per_value:?}"); + assert!( + debug.contains("ZstdBufferCompressor") && debug.contains("compression_level: 19"), + "expected zstd level 19 to reach the per-value compressor, got: {debug}" + ); } #[test] @@ -1442,12 +2041,8 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); - // Get merged params - let merged = strategy - .params - .get_field_params("user_id", &DataType::Int32); + let merged = params.get_field_params("user_id", &DataType::Int32); // Column params should override type params assert_eq!(merged.rle_threshold, Some(0.2)); @@ -1455,9 +2050,7 @@ mod tests { assert_eq!(merged.compression_level, Some(6)); // Test field with only type params - let merged = strategy - .params - .get_field_params("other_field", &DataType::Int32); + let merged = params.get_field_params("other_field", &DataType::Int32); assert_eq!(merged.rle_threshold, Some(0.5)); assert_eq!(merged.compression, Some("lz4".to_string())); assert_eq!(merged.compression_level, None); @@ -1477,26 +2070,20 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); - // Should match pattern - let merged = strategy - .params - .get_field_params("log_messages", &DataType::Utf8); + let merged = params.get_field_params("log_messages", &DataType::Utf8); assert_eq!(merged.compression, Some("zstd".to_string())); assert_eq!(merged.compression_level, Some(6)); // Should not match - let merged = strategy - .params - .get_field_params("messages_log", &DataType::Utf8); + let merged = params.get_field_params("messages_log", &DataType::Utf8); assert_eq!(merged.compression, None); } #[test] fn test_legacy_metadata_support() { let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Test field with "none" compression metadata let mut metadata = HashMap::new(); @@ -1515,7 +2102,7 @@ mod tests { fn test_default_behavior() { // Empty params should fall back to default behavior let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); let field = create_test_field("random_column", DataType::Int32); // Create data with high run count that won't trigger RLE (600 runs for 1000 values = 0.6 ratio) @@ -1530,7 +2117,7 @@ mod tests { #[test] fn test_field_metadata_compression() { let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Test field with compression metadata let mut metadata = HashMap::new(); @@ -1550,7 +2137,7 @@ mod tests { #[test] fn test_field_metadata_rle_threshold() { let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Test field with RLE threshold metadata let mut metadata = HashMap::new(); @@ -1570,6 +2157,277 @@ mod tests { assert!(debug_str.contains("RleEncoder")); } + #[test] + fn test_rle_v2_miniblock_selects_u16_run_lengths() { + let mut metadata = HashMap::new(); + metadata.insert(RLE_THRESHOLD_META_KEY.to_string(), "1.0".to_string()); + metadata.insert(BSS_META_KEY.to_string(), "off".to_string()); + let mut field = create_test_field("test_column", DataType::Int32); + field.metadata = metadata; + + let values = vec![7i32; 1000]; + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 1000, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 16); + } + + #[test] + fn test_rle_v2_miniblock_keeps_u8_run_lengths_before_v2_3() { + for version in [TestEncoding::StructuralU16, TestEncoding::StructuralU32] { + let mut metadata = HashMap::new(); + metadata.insert(RLE_THRESHOLD_META_KEY.to_string(), "1.0".to_string()); + metadata.insert(BSS_META_KEY.to_string(), "off".to_string()); + let mut field = create_test_field("test_column", DataType::Int32); + field.metadata = metadata; + + let values = vec![7i32; 1000]; + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 1000, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(version, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 8, "version={version}"); + } + } + + #[test] + fn test_rle_v2_uses_selected_width_cost_before_bitpacking() { + let mut metadata = HashMap::new(); + metadata.insert(RLE_THRESHOLD_META_KEY.to_string(), "1.0".to_string()); + metadata.insert(BSS_META_KEY.to_string(), "off".to_string()); + let mut field = create_test_field("test_column", DataType::Int32); + field.metadata = metadata; + + let values = vec![0i32; 4096]; + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 4096, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let debug_str = format!("{compressor:?}"); + assert!(debug_str.contains("RleEncoder")); + + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 16); + } + + #[test] + fn test_rle_v2_sorted_dictionary_indices_select_u16_run_lengths() { + let field = create_test_field("dict_indices", DataType::Int32); + + let mut values = Vec::with_capacity(1_200); + for value in 0..4 { + values.extend(std::iter::repeat_n(value, 300)); + } + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 1_200, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 16); + } + + #[test] + fn test_rle_v2_short_runs_keep_u8_run_lengths() { + let field = create_test_field("dict_indices", DataType::Int32); + + let mut values = Vec::with_capacity(1_280); + for value in 0..10 { + values.extend(std::iter::repeat_n(value, 128)); + } + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 1_280, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 8); + } + + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_rle_miniblock_released_versions_keep_flat_children_when_compression_requested() { + for version in [TestEncoding::StructuralU16, TestEncoding::StructuralU32] { + let mut params = CompressionParams::new(); + params.columns.insert( + "dict_indices".to_string(), + CompressionFieldParams { + compression: Some( + if cfg!(feature = "lz4") { "lz4" } else { "zstd" }.to_string(), + ), + rle_threshold: Some(1.0), + bss: Some(BssMode::Off), + ..Default::default() + }, + ); + let strategy = strategy(version, params); + let field = create_test_field("dict_indices", DataType::UInt32); + + let mut values = Vec::with_capacity(8192 * 4); + for value in 0..8192u32 { + values.extend(std::iter::repeat_n(value, 4)); + } + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 8192 * 4, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + let rle = expect_rle_encoding(&encoding); + + assert!( + matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + Compression::Flat(_) + ), + "version={version}" + ); + assert!( + matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + Compression::Flat(_) + ), + "version={version}" + ); + } + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_miniblock_strategy_bitpacks_child_values_when_smaller() { + let field = create_test_field("dict_indices", DataType::Int32); + + let mut values = Vec::with_capacity(8192 * 4); + for value in 0..8192 { + values.extend(std::iter::repeat_n(value, 4)); + } + let mut data = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 8192 * 4, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let debug_str = format!("{compressor:?}"); + assert!(debug_str.contains("RleEncoder")); + + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + let Compression::Rle(rle) = encoding.compression.as_ref().unwrap() else { + panic!("expected RLE encoding"); + }; + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + Compression::OutOfLineBitpacking(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + Compression::Flat(_) + )); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_miniblock_keeps_child_bitpacked_rle_when_smaller_than_inline_bitpacking() { + let field = create_test_field("int_score", DataType::UInt64); + + let mut values = Vec::with_capacity(8192 * 8); + for run_idx in 0..8192 { + let value = match run_idx % 3 { + 0 => 3u64, + 1 => 4u64, + _ => 5u64, + }; + values.extend(std::iter::repeat_n(value, 8)); + } + let mut data = FixedWidthDataBlock { + bits_per_value: 64, + data: LanceBuffer::reinterpret_vec(values), + num_values: 8192 * 8, + block_info: BlockInfo::default(), + }; + data.compute_stat(); + let data = DataBlock::FixedWidth(data); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::default()); + let compressor = strategy.create_miniblock_compressor(&field, &data).unwrap(); + let debug_str = format!("{compressor:?}"); + assert!( + debug_str.contains("RleEncoder"), + "expected RLE to beat inline bitpacking after child selection, got: {debug_str}" + ); + + let (_compressed, encoding) = compressor.compress(miniblock_context(), data).unwrap(); + let rle = expect_rle_encoding(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + Compression::OutOfLineBitpacking(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + Compression::Flat(_) + )); + } + #[test] fn test_field_metadata_override_params() { // Set up params with one configuration @@ -1585,7 +2443,7 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Field metadata should override params let mut metadata = HashMap::new(); @@ -1613,7 +2471,7 @@ mod tests { }, ); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Field metadata provides partial override let mut metadata = HashMap::new(); @@ -1632,7 +2490,7 @@ mod tests { #[test] fn test_bss_field_metadata() { let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Test BSS "on" mode with compression enabled (BSS requires compression to be effective) let mut metadata = HashMap::new(); @@ -1653,7 +2511,7 @@ mod tests { #[test] fn test_bss_with_compression() { let params = CompressionParams::new(); - let strategy = DefaultCompressionStrategy::with_params(params); + let strategy = baseline_strategy(params); // Test BSS with LZ4 compression let mut metadata = HashMap::new(); @@ -1686,8 +2544,7 @@ mod tests { }, ); - let mut strategy = DefaultCompressionStrategy::with_params(params); - strategy.version = LanceFileVersion::V2_2; + let strategy = strategy(TestEncoding::StructuralU32, params); let field = create_test_field("dict_values", DataType::FixedSizeBinary(3)); let data = create_fixed_width_block(24, 1024); @@ -1698,18 +2555,17 @@ mod tests { let expected_num_values = expected_block.num_values; let num_values = expected_num_values; - let (compressor, encoding) = strategy + let compressor = strategy .create_block_compressor(&field, &data) .expect("general compression should be selected"); + let (compressed_buffer, encoding) = compressor + .compress(data.clone()) + .expect("write path general compression should succeed"); match encoding.compression.as_ref() { Some(Compression::General(_)) => {} other => panic!("expected general compression, got {:?}", other), } - let compressed_buffer = compressor - .compress(data.clone()) - .expect("write path general compression should succeed"); - let decompressor = DefaultDecompressionStrategy::default() .create_block_decompressor(&encoding) .expect("general block decompressor should be created"); @@ -1728,6 +2584,170 @@ mod tests { } } + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn assert_general_block_preserves_compression_level( + compression: &str, + expected_scheme: crate::format::pb21::CompressionScheme, + compression_level: Option, + ) { + let mut params = CompressionParams::new(); + params.columns.insert( + "dict_values".to_string(), + CompressionFieldParams { + compression: Some(compression.to_string()), + compression_level, + ..Default::default() + }, + ); + let strategy = strategy(TestEncoding::StructuralU32, params); + let field = create_test_field("dict_values", DataType::FixedSizeBinary(3)); + let data = create_fixed_width_block(24, 1024); + + let compressor = strategy.create_block_compressor(&field, &data).unwrap(); + let (_, encoding) = compressor.compress(data).unwrap(); + let Some(Compression::General(general)) = encoding.compression.as_ref() else { + panic!("expected general compression"); + }; + + assert_eq!( + general.compression.as_ref(), + Some(&crate::format::pb21::BufferCompression { + scheme: expected_scheme as i32, + level: compression_level, + }) + ); + } + + #[test] + #[cfg(feature = "zstd")] + fn test_general_block_preserves_absent_zstd_level() { + assert_general_block_preserves_compression_level( + "zstd", + crate::format::pb21::CompressionScheme::CompressionAlgorithmZstd, + None, + ); + } + + #[test] + #[cfg(feature = "lz4")] + fn test_general_block_preserves_explicit_lz4_level() { + assert_general_block_preserves_compression_level( + "lz4", + crate::format::pb21::CompressionScheme::CompressionAlgorithmLz4, + Some(7), + ); + } + + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_general_compression_not_selected_for_v2_1_even_if_requested() { + let mut params = CompressionParams::new(); + params.columns.insert( + "dict_values".to_string(), + CompressionFieldParams { + compression: Some(if cfg!(feature = "lz4") { "lz4" } else { "zstd" }.to_string()), + ..Default::default() + }, + ); + + let strategy = strategy(TestEncoding::StructuralU16, params); + let field = create_test_field("dict_values", DataType::FixedSizeBinary(3)); + let data = create_fixed_width_block(24, 1024); + + let compressor = strategy + .create_block_compressor(&field, &data) + .expect("block compressor selection should succeed"); + let (_, encoding) = compressor.compress(data).unwrap(); + + assert!( + !matches!(encoding.compression.as_ref(), Some(Compression::General(_))), + "general compression should not be selected for V2.1" + ); + } + + #[test] + fn test_none_compression_disables_auto_general_block_compression() { + let mut params = CompressionParams::new(); + params.columns.insert( + "dict_values".to_string(), + CompressionFieldParams { + compression: Some("none".to_string()), + ..Default::default() + }, + ); + + let strategy = strategy(TestEncoding::StructuralU32, params); + let field = create_test_field("dict_values", DataType::FixedSizeBinary(3)); + let data = create_fixed_width_block(24, 20_000); + + assert!( + data.data_size() > MIN_BLOCK_SIZE_FOR_GENERAL_COMPRESSION, + "test requires block size above automatic general compression threshold" + ); + + let compressor = strategy + .create_block_compressor(&field, &data) + .expect("block compressor selection should succeed"); + let (_, encoding) = compressor.compress(data).unwrap(); + + assert!( + !matches!(encoding.compression.as_ref(), Some(Compression::General(_))), + "compression=none should disable automatic block general compression" + ); + } + + #[test] + fn test_rle_v2_block_selects_u32_run_lengths() { + let field = create_test_field("dict_indices", DataType::Int32); + let expected_values = vec![42i32; 70_000]; + let mut block = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(expected_values.clone()), + num_values: expected_values.len() as u64, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + let data = DataBlock::FixedWidth(block); + + let strategy = strategy(TestEncoding::StructuralSparse, CompressionParams::new()); + let compressor = strategy.create_block_compressor(&field, &data).unwrap(); + let (compressed, encoding) = compressor.compress(data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 32); + let decompressor = DefaultDecompressionStrategy::default() + .create_block_decompressor(&encoding) + .unwrap(); + let decoded = decompressor + .decompress(compressed, expected_values.len() as u64) + .unwrap(); + + match decoded { + DataBlock::FixedWidth(block) => { + let values = block.data.borrow_to_typed_slice::(); + assert_eq!(values.as_ref(), expected_values); + } + _ => panic!("expected fixed-width block"), + } + } + + #[test] + fn test_rle_v2_block_keeps_u8_run_lengths_for_v2_2() { + let field = create_test_field("dict_indices", DataType::Int32); + let values = vec![42i32; 70_000]; + let mut block = FixedWidthDataBlock { + bits_per_value: 32, + data: LanceBuffer::reinterpret_vec(values), + num_values: 70_000, + block_info: BlockInfo::default(), + }; + block.compute_stat(); + let data = DataBlock::FixedWidth(block); + + let strategy = strategy(TestEncoding::StructuralU32, CompressionParams::new()); + let compressor = strategy.create_block_compressor(&field, &data).unwrap(); + let (_, encoding) = compressor.compress(data).unwrap(); + assert_eq!(rle_run_length_bits(&encoding), 8); + } + #[test] fn test_rle_block_used_for_version_v2_2() { let field = create_test_field("test_repdef", DataType::UInt16); @@ -1752,10 +2772,9 @@ mod tests { let data_block = DataBlock::FixedWidth(block); - let strategy = DefaultCompressionStrategy::with_params(CompressionParams::new()) - .with_version(LanceFileVersion::V2_2); + let strategy = strategy(TestEncoding::StructuralU32, CompressionParams::new()); - let (compressor, _) = strategy + let compressor = strategy .create_block_compressor(&field, &data_block) .unwrap(); @@ -1787,10 +2806,9 @@ mod tests { let data_block = DataBlock::FixedWidth(block); - let strategy = DefaultCompressionStrategy::with_params(CompressionParams::new()) - .with_version(LanceFileVersion::V2_1); + let strategy = strategy(TestEncoding::StructuralU16, CompressionParams::new()); - let (compressor, _) = strategy + let compressor = strategy .create_block_compressor(&field, &data_block) .unwrap(); diff --git a/rust/lance-encoding/src/constants.rs b/rust/lance-encoding/src/constants.rs index 173b1a1c085..0bd31d676cc 100644 --- a/rust/lance-encoding/src/constants.rs +++ b/rust/lance-encoding/src/constants.rs @@ -26,6 +26,19 @@ pub const DICT_DIVISOR_META_KEY: &str = "lance-encoding:dict-divisor"; /// Example: 0.8 means use dict if encoded size < 80% of raw size /// Default: 0.8 pub const DICT_SIZE_RATIO_META_KEY: &str = "lance-encoding:dict-size-ratio"; +/// Metadata key for selecting general compression scheme for dictionary values +/// Valid values: "lz4", "zstd", "none" +pub const DICT_VALUES_COMPRESSION_META_KEY: &str = "lance-encoding:dict-values-compression"; +/// Metadata key for selecting compression level for dictionary values +/// Applies to schemes that support levels (e.g. zstd) +pub const DICT_VALUES_COMPRESSION_LEVEL_META_KEY: &str = + "lance-encoding:dict-values-compression-level"; + +/// Environment variable for selecting general compression scheme for dictionary values +pub const DICT_VALUES_COMPRESSION_ENV_VAR: &str = "LANCE_ENCODING_DICT_VALUES_COMPRESSION"; +/// Environment variable for selecting compression level for dictionary values +pub const DICT_VALUES_COMPRESSION_LEVEL_ENV_VAR: &str = + "LANCE_ENCODING_DICT_VALUES_COMPRESSION_LEVEL"; // NOTE: BLOB_META_KEY is defined in lance-core to avoid circular dependency @@ -42,6 +55,8 @@ pub const STRUCTURAL_ENCODING_META_KEY: &str = "lance-encoding:structural-encodi pub const STRUCTURAL_ENCODING_MINIBLOCK: &str = "miniblock"; /// Value for fullzip structural encoding pub const STRUCTURAL_ENCODING_FULLZIP: &str = "fullzip"; +/// Value for sparse structural encoding +pub const STRUCTURAL_ENCODING_SPARSE: &str = "sparse"; // Byte stream split metadata keys /// Metadata key for byte stream split encoding configuration diff --git a/rust/lance-encoding/src/data.rs b/rust/lance-encoding/src/data.rs index 8828673326f..84ea0300f17 100644 --- a/rust/lance-encoding/src/data.rs +++ b/rust/lance-encoding/src/data.rs @@ -20,16 +20,15 @@ use std::{ }; use arrow_array::{ + Array, ArrayRef, OffsetSizeTrait, UInt64Array, cast::AsArray, new_empty_array, new_null_array, - types::{ArrowDictionaryKeyType, UInt16Type, UInt32Type, UInt64Type, UInt8Type}, - Array, ArrayRef, OffsetSizeTrait, UInt64Array, + types::{ArrowDictionaryKeyType, UInt8Type, UInt16Type, UInt32Type, UInt64Type}, }; use arrow_buffer::{ArrowNativeType, BooleanBuffer, BooleanBufferBuilder, NullBuffer}; use arrow_data::{ArrayData, ArrayDataBuilder}; use arrow_schema::DataType; use lance_arrow::DataTypeExt; -use snafu::location; use lance_core::{Error, Result}; @@ -102,15 +101,11 @@ pub struct NullableDataBlock { } impl NullableDataBlock { - fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { let nulls = self.nulls.into_buffer(); - let data = self.data.into_arrow(data_type, validate)?.into_builder(); + let data = self.data.into_arrow_impl(data_type, true)?.into_builder(); let data = data.null_bit_buffer(Some(nulls)); - if validate { - Ok(data.build()?) - } else { - Ok(unsafe { data.build_unchecked() }) - } + Ok(data.build()?) } fn into_buffers(self) -> Vec { @@ -174,23 +169,33 @@ impl FixedWidthDataBlock { self, data_type: DataType, num_values: u64, - validate: bool, + _validate: bool, ) -> Result { - let data_buffer = self.data.into_buffer(); + // Booleans expanded for full-zip (bits_per_value==8, one byte each) need re-packing to + // Arrow's bit-packed format. + let data_buffer = if matches!(data_type, DataType::Boolean) && self.bits_per_value == 8 { + let mut builder = BooleanBufferBuilder::new(num_values as usize); + for &byte in self.data.as_ref().iter().take(num_values as usize) { + builder.append(byte != 0); + } + builder.finish().into_inner() + } else { + self.data.into_buffer() + }; let builder = ArrayDataBuilder::new(data_type) .add_buffer(data_buffer) .len(num_values as usize) .null_count(0); - if validate { - Ok(builder.build()?) - } else { - Ok(unsafe { builder.build_unchecked() }) - } + Ok(builder.build()?) } - pub fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + /// Convert this block into Arrow data with full layout validation. + /// + /// The `validate` argument is retained for API compatibility. Conversion is + /// always validated because callers can construct this public type directly. + pub fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { let root_num_values = self.num_values; - self.do_into_arrow(data_type, root_num_values, validate) + self.do_into_arrow(data_type, root_num_values, true) } pub fn into_buffers(self) -> Vec { @@ -212,7 +217,7 @@ impl FixedWidthDataBlock { } #[derive(Debug)] -pub struct VariableWidthDataBlockBuilder { +struct VariableWidthDataBlockBuilder { offsets: Vec, bytes: Vec, } @@ -227,28 +232,54 @@ impl VariableWidthDataBlockBuilder { } impl DataBlockBuilderImpl for VariableWidthDataBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, data_block: &DataBlock, selection: &Range) -> Result<()> { let block = data_block.as_variable_width_ref().unwrap(); - assert!(block.bits_per_offset == T::get_byte_width() as u8 * 8); + block.validate_offsets_for_append::(selection) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { + let block = data_block.as_variable_width_ref().unwrap(); + debug_assert_eq!(block.bits_per_offset, T::get_byte_width() as u8 * 8); let offsets = block.offsets.borrow_to_typed_view::(); let start_offset = offsets[selection.start as usize]; let end_offset = offsets[selection.end as usize]; - let mut previous_len = self.bytes.len(); + let selected_data_len = end_offset.as_usize() - start_offset.as_usize(); + let new_data_len = self + .bytes + .len() + .checked_add(selected_data_len) + .ok_or_else(|| { + Error::not_supported_source( + "appending variable-width data would overflow usize".into(), + ) + })?; + if T::from_usize(new_data_len).is_none() { + return Err(Error::not_supported_source( + format!( + "appending variable-width data would require {} bytes, which exceeds the \ + capacity of {}-bit offsets", + new_data_len, + T::get_byte_width() * 8 + ) + .into(), + )); + } + let previous_len = self.bytes.len(); self.bytes .extend_from_slice(&block.data[start_offset.as_usize()..end_offset.as_usize()]); self.offsets.extend( - offsets[selection.start as usize..selection.end as usize] + offsets[selection.start as usize + 1..=selection.end as usize] .iter() - .zip(&offsets[selection.start as usize + 1..=selection.end as usize]) - .map(|(¤t, &next)| { - let this_value_len = next - current; - previous_len += this_value_len.as_usize(); - T::from_usize(previous_len).unwrap() + .map(|&offset| { + let rebased_offset = + previous_len + (offset.as_usize() - start_offset.as_usize()); + T::from_usize(rebased_offset).unwrap() }), ); + Ok(()) } fn finish(self: Box) -> DataBlock { @@ -277,12 +308,17 @@ impl BitmapDataBlockBuilder { } impl DataBlockBuilderImpl for BitmapDataBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, _data_block: &DataBlock, _selection: &Range) -> Result<()> { + Ok(()) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { let bitmap_blk = data_block.as_fixed_width_ref().unwrap(); self.values.append_packed_range( selection.start as usize..selection.end as usize, &bitmap_blk.data, ); + Ok(()) } fn finish(mut self: Box) -> DataBlock { @@ -317,12 +353,17 @@ impl FixedWidthDataBlockBuilder { } impl DataBlockBuilderImpl for FixedWidthDataBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, _data_block: &DataBlock, _selection: &Range) -> Result<()> { + Ok(()) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { let block = data_block.as_fixed_width_ref().unwrap(); assert_eq!(self.bits_per_value, block.bits_per_value); let start = selection.start as usize * self.bytes_per_value as usize; let end = selection.end as usize * self.bytes_per_value as usize; self.values.extend_from_slice(&block.data[start..end]); + Ok(()) } fn finish(self: Box) -> DataBlock { @@ -348,11 +389,20 @@ impl StructDataBlockBuilder { } impl DataBlockBuilderImpl for StructDataBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, data_block: &DataBlock, selection: &Range) -> Result<()> { let data_block = data_block.as_struct_ref().unwrap(); for i in 0..self.children.len() { - self.children[i].append(&data_block.children[i], selection.clone()); + self.children[i].validate_append(&data_block.children[i], selection)?; } + Ok(()) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { + let data_block = data_block.as_struct_ref().unwrap(); + for i in 0..self.children.len() { + self.children[i].append_validated(&data_block.children[i], selection.clone())?; + } + Ok(()) } fn finish(self: Box) -> DataBlock { @@ -375,8 +425,13 @@ struct AllNullDataBlockBuilder { } impl DataBlockBuilderImpl for AllNullDataBlockBuilder { - fn append(&mut self, _data_block: &DataBlock, selection: Range) { + fn validate_append(&self, _data_block: &DataBlock, _selection: &Range) -> Result<()> { + Ok(()) + } + + fn append_validated(&mut self, _data_block: &DataBlock, selection: Range) -> Result<()> { self.num_values += selection.end - selection.start; + Ok(()) } fn finish(self: Box) -> DataBlock { @@ -451,13 +506,13 @@ impl FixedSizeListBlock { } } - fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { let num_values = self.num_values(); let builder = match &data_type { DataType::FixedSizeList(child_field, _) => { let child_data = self .child - .into_arrow(child_field.data_type().clone(), validate)?; + .into_arrow_impl(child_field.data_type().clone(), true)?; ArrayDataBuilder::new(data_type) .add_child_data(child_data) .len(num_values as usize) @@ -465,11 +520,7 @@ impl FixedSizeListBlock { } _ => panic!("Expected FixedSizeList data type and got {:?}", data_type), }; - if validate { - Ok(builder.build()?) - } else { - Ok(unsafe { builder.build_unchecked() }) - } + Ok(builder.build()?) } fn into_buffers(self) -> Vec { @@ -494,10 +545,16 @@ impl FixedSizeListBlockBuilder { } impl DataBlockBuilderImpl for FixedSizeListBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, data_block: &DataBlock, selection: &Range) -> Result<()> { + let selection = selection.start * self.dimension..selection.end * self.dimension; + let fsl = data_block.as_fixed_size_list_ref().unwrap(); + self.inner.validate_append(fsl.child.as_ref(), &selection) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { let selection = selection.start * self.dimension..selection.end * self.dimension; let fsl = data_block.as_fixed_size_list_ref().unwrap(); - self.inner.append(fsl.child.as_ref(), selection); + self.inner.append_validated(fsl.child.as_ref(), selection) } fn finish(self: Box) -> DataBlock { @@ -525,15 +582,23 @@ impl NullableDataBlockBuilder { } impl DataBlockBuilderImpl for NullableDataBlockBuilder { - fn append(&mut self, data_block: &DataBlock, selection: Range) { + fn validate_append(&self, data_block: &DataBlock, selection: &Range) -> Result<()> { let nullable = data_block.as_nullable_ref().unwrap(); + self.inner + .validate_append(nullable.data.as_ref(), selection) + } + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { + let nullable = data_block.as_nullable_ref().unwrap(); + self.inner + .append_validated(nullable.data.as_ref(), selection.clone())?; let bool_buf = BooleanBuffer::new( nullable.nulls.clone().into_buffer(), selection.start as usize, (selection.end - selection.start) as usize, ); self.validity.append_buffer(&bool_buf); - self.inner.append(nullable.data.as_ref(), selection); + Ok(()) } fn finish(mut self: Box) -> DataBlock { @@ -579,20 +644,315 @@ pub struct VariableWidthBlock { pub block_info: BlockInfo, } +/// Proof that a [`VariableWidthBlock`] satisfies the Arrow layout contract for +/// its target data type (offsets buffer long enough, offsets monotonic and +/// within the data buffer, values valid UTF-8 where required). +/// +/// Only [`VariableWidthBlock::validate_layout`] can construct it, which ties the +/// unchecked Arrow build below to an actual validation pass instead of a +/// caller-controlled flag. +struct ValidVariableWidthLayout; + impl VariableWidthBlock { - fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + fn append_error(&self, selection: &Range, detail: impl std::fmt::Display) -> Error { + Error::corrupt_file_named( + "variable width data block", + format!( + "cannot append offsets for selection {}..{}: {} (num_values: {}, \ + bits_per_offset: {}, offsets buffer size: {} bytes, data buffer size: {} bytes)", + selection.start, + selection.end, + detail, + self.num_values, + self.bits_per_offset, + self.offsets.len(), + self.data.len(), + ), + ) + } + + fn validate_offsets_for_append(&self, selection: &Range) -> Result<()> + where + T: OffsetSizeTrait + bytemuck::Pod, + { + let expected_bits_per_offset = T::get_byte_width() as u8 * 8; + if self.bits_per_offset != expected_bits_per_offset { + return Err(self.append_error( + selection, + format!( + "expected {}-bit offsets but found {}-bit offsets", + expected_bits_per_offset, self.bits_per_offset + ), + )); + } + let offset_size = std::mem::size_of::(); + if !self.offsets.len().is_multiple_of(offset_size) { + return Err(self.append_error( + selection, + format!( + "offsets buffer length {} is not a multiple of the {}-byte offset width", + self.offsets.len(), + offset_size + ), + )); + } + if selection.start > selection.end || selection.end > self.num_values { + return Err( + self.append_error(selection, "selection is outside the block's value range") + ); + } + let selection_start = usize::try_from(selection.start) + .map_err(|_| self.append_error(selection, "selection start does not fit in usize"))?; + let selection_end = usize::try_from(selection.end) + .map_err(|_| self.append_error(selection, "selection end does not fit in usize"))?; + let offsets = self.offsets.borrow_to_typed_view::(); + if selection_end >= offsets.len() { + return Err(self.append_error( + selection, + format!( + "selection requires offset {} but the buffer holds {} offsets", + selection_end, + offsets.len() + ), + )); + } + let selected_offsets = &offsets[selection_start..=selection_end]; + if let Some(detail) = + Self::offset_violation_detail(selected_offsets, self.data.len(), selection_start) + { + return Err(self.append_error(selection, detail)); + } + Ok(()) + } + + // The offsets buffer comes straight from file bytes, so an unchecked build would + // let a corrupt file smuggle out-of-bounds offsets into an Arrow array whose + // consumers then read (or crash on) memory outside the data buffer. This + // boundary therefore always validates the layout, ignoring the optional + // `validate` flag. Lance validates the common layouts itself (a branchless + // scan, measurably cheaper than Arrow's element-wise checked build) and only + // falls back to Arrow's checked build for the cold cases. + fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { + let Some(expected_bits_per_offset) = Self::expected_bits_per_offset(&data_type) else { + // Not an [offsets, bytes] layout we know how to prove; let Arrow + // check it. + return self.into_arrow_checked(data_type); + }; + if self.bits_per_offset != expected_bits_per_offset { + return Err(self.layout_error( + &data_type, + format!( + "expected {}-bit offsets but got {}-bit offsets", + expected_bits_per_offset, self.bits_per_offset + ), + )); + } + if self.num_values == 0 { + // Cold path; Arrow handles the empty-offsets special cases. + return self.into_arrow_checked(data_type); + } + let proof = self.validate_layout(&data_type)?; + Ok(self.into_arrow_unchecked(data_type, proof)) + } + + /// The offset width Arrow mandates for `data_type`, or `None` if the type + /// does not use the `[offsets, bytes]` layout this block represents. + fn expected_bits_per_offset(data_type: &DataType) -> Option { + match data_type { + DataType::Binary | DataType::Utf8 => Some(32), + DataType::LargeBinary | DataType::LargeUtf8 => Some(64), + _ => None, + } + } + + fn layout_error(&self, data_type: &DataType, detail: impl std::fmt::Display) -> Error { + Self::format_layout_error( + data_type, + detail, + self.num_values, + self.bits_per_offset, + self.offsets.len(), + self.data.len(), + ) + } + + fn format_layout_error( + data_type: &DataType, + detail: impl std::fmt::Display, + num_values: u64, + bits_per_offset: u8, + offsets_size: usize, + data_size: usize, + ) -> Error { + Error::corrupt_file_named( + "variable width data block", + format!( + "invalid variable-width layout for {}: {} (num_values: {}, bits_per_offset: {}, \ + offsets buffer size: {} bytes, data buffer size: {} bytes)", + data_type, detail, num_values, bits_per_offset, offsets_size, data_size, + ), + ) + } + + fn validate_layout(&self, data_type: &DataType) -> Result { + let bytes_per_offset = (self.bits_per_offset / 8) as u64; + let required_bytes = self + .num_values + .checked_add(1) + .and_then(|num_offsets| num_offsets.checked_mul(bytes_per_offset)) + .ok_or_else(|| self.layout_error(data_type, "offsets buffer size overflows"))?; + if (self.offsets.len() as u64) < required_bytes { + return Err(self.layout_error( + data_type, + format!( + "offsets buffer must hold at least {} offsets ({} bytes)", + self.num_values + 1, + required_bytes + ), + )); + } + let validate_utf8 = matches!(data_type, DataType::Utf8 | DataType::LargeUtf8); + match self.bits_per_offset { + 32 => self.validate_offsets_and_values::(data_type, validate_utf8), + 64 => self.validate_offsets_and_values::(data_type, validate_utf8), + other => Err(self.layout_error( + data_type, + format!("unsupported offset width: {} bits", other), + )), + } + } + + fn validate_offsets_and_values( + &self, + data_type: &DataType, + validate_utf8: bool, + ) -> Result { + let num_offsets = self.num_values as usize + 1; + // Slice before borrowing: the buffer may carry padding that is not a + // multiple of the offset width. + let offsets = self + .offsets + .slice_with_length(0, num_offsets * std::mem::size_of::()); + let offsets = offsets.borrow_to_typed_slice::(); + let offsets: &[T] = offsets.as_ref(); + let data = self.data.as_ref(); + + if let Some(detail) = Self::offset_violation_detail(offsets, data.len(), 0) { + return Err(self.layout_error(data_type, detail)); + } + + if validate_utf8 { + let (first, last) = (offsets[0].as_usize(), offsets[num_offsets - 1].as_usize()); + let values = std::str::from_utf8(&data[first..last]) + .map_err(|utf8_err| self.layout_error(data_type, utf8_err))?; + let mut on_char_boundaries = true; + for &offset in offsets { + on_char_boundaries &= values.is_char_boundary(offset.as_usize() - first); + } + if !on_char_boundaries { + // Cold path: rescan to pinpoint the offending offset. + let position = offsets + .iter() + .position(|offset| !values.is_char_boundary(offset.as_usize() - first)) + .expect("the fast scan found a non-boundary offset"); + return Err(self.layout_error( + data_type, + format!("offset at position {position} splits a UTF-8 character"), + )); + } + } + + Ok(ValidVariableWidthLayout) + } + + fn offset_violation_detail( + offsets: &[T], + data_size: usize, + position_base: usize, + ) -> Option { + // A monotonic sequence with a non-negative first offset and an + // in-bounds last offset is entirely within [0, data_size]. Keep this + // valid path branchless so it vectorizes, and only rescan on failure. + let mut is_monotonic = true; + for window in offsets.windows(2) { + is_monotonic &= window[0] <= window[1]; + } + let first = offsets[0]; + let last = offsets[offsets.len() - 1]; + let bounds_ok = + first >= T::usize_as(0) && last.to_usize().is_some_and(|last| last <= data_size); + if is_monotonic && bounds_ok { + return None; + } + + for (relative_position, window) in offsets.windows(2).enumerate() { + if window[0] > window[1] { + let position = position_base + relative_position + 1; + return Some(format!( + "non-monotonic offset at position {}: {:?} decreases from {:?}", + position, window[1], window[0] + )); + } + } + for (relative_position, offset) in offsets.iter().enumerate() { + let position = position_base + relative_position; + match offset.to_usize() { + None => { + return Some(format!( + "offset at position {} is negative: {:?}", + position, offset + )); + } + Some(offset) if offset > data_size => { + return Some(format!( + "offset at position {} is out of bounds: {} > {}", + position, offset, data_size + )); + } + Some(_) => {} + } + } + Some("offsets failed validation".to_string()) + } + + fn into_arrow_checked(self, data_type: DataType) -> Result { + let num_values = self.num_values; + let bits_per_offset = self.bits_per_offset; + let offsets_size = self.offsets.len(); + let data_size = self.data.len(); + let builder = self.into_arrow_builder(data_type.clone()); + builder.build().map_err(|arrow_err| { + Self::format_layout_error( + &data_type, + arrow_err, + num_values, + bits_per_offset, + offsets_size, + data_size, + ) + }) + } + + fn into_arrow_unchecked( + self, + data_type: DataType, + _proof: ValidVariableWidthLayout, + ) -> ArrayData { + let builder = self.into_arrow_builder(data_type); + // SAFETY: `_proof` witnesses that `validate_layout` proved this block + // satisfies the Arrow layout contract for `data_type`. + unsafe { builder.build_unchecked() } + } + + fn into_arrow_builder(self, data_type: DataType) -> ArrayDataBuilder { + let num_values = self.num_values; let data_buffer = self.data.into_buffer(); let offsets_buffer = self.offsets.into_buffer(); - let builder = ArrayDataBuilder::new(data_type) + ArrayDataBuilder::new(data_type) .add_buffer(offsets_buffer) .add_buffer(data_buffer) - .len(self.num_values as usize) - .null_count(0); - if validate { - Ok(builder.build()?) - } else { - Ok(unsafe { builder.build_unchecked() }) - } + .len(num_values as usize) + .null_count(0) } fn into_buffers(self) -> Vec { @@ -625,12 +985,12 @@ pub struct StructDataBlock { } impl StructDataBlock { - fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { if let DataType::Struct(fields) = &data_type { let mut builder = ArrayDataBuilder::new(DataType::Struct(fields.clone())); let mut num_rows = 0; for (field, child) in fields.iter().zip(self.children) { - let child_data = child.into_arrow(field.data_type().clone(), validate)?; + let child_data = child.into_arrow_impl(field.data_type().clone(), true)?; num_rows = child_data.len(); builder = builder.add_child_data(child_data); } @@ -646,16 +1006,12 @@ impl StructDataBlock { }; let builder = builder.len(num_rows); - if validate { - Ok(builder.build()?) - } else { - Ok(unsafe { builder.build_unchecked() }) - } + Ok(builder.build()?) } else { - Err(Error::Internal { - message: format!("Expected Struct, got {:?}", data_type), - location: location!(), - }) + Err(Error::internal(format!( + "Expected Struct, got {:?}", + data_type + ))) } } @@ -718,12 +1074,11 @@ impl DictionaryDataBlock { let indices = self.indices.data.borrow_to_typed_slice::(); let indices = indices.as_ref(); - indices - .iter() - .map(|idx| idx.to_usize().unwrap() as u64) - .for_each(|idx| { - data_builder.append(&self.dictionary, idx..idx + 1); - }); + let selections = indices.iter().map(|idx| { + let idx = idx.to_usize().unwrap() as u64; + idx..idx + 1 + }); + data_builder.append_ranges(&self.dictionary, selections)?; Ok(data_builder.finish()) } @@ -734,13 +1089,10 @@ impl DictionaryDataBlock { 16 => self.decode_helper::(), 32 => self.decode_helper::(), 64 => self.decode_helper::(), - _ => Err(lance_core::Error::Internal { - message: format!( - "Unsupported dictionary index bit width: {} bits", - self.indices.bits_per_value - ), - location: location!(), - }), + _ => Err(lance_core::Error::internal(format!( + "Unsupported dictionary index bit width: {} bits", + self.indices.bits_per_value + ))), } } @@ -748,30 +1100,39 @@ impl DictionaryDataBlock { self, key_type: Box, value_type: Box, - validate: bool, + _validate: bool, ) -> Result { - let indices = self.indices.into_arrow((*key_type).clone(), validate)?; + let declared_key_bits = key_type.byte_width() as u64 * 8; + if self.indices.bits_per_value != declared_key_bits { + return Err(lance_core::Error::corrupt_file_named( + "dictionary", + format!( + "dictionary indices use {} bits but the declared {} key type uses {} bits", + self.indices.bits_per_value, key_type, declared_key_bits + ), + )); + } + let indices_num_values = self.indices.num_values; + let indices = self + .indices + .do_into_arrow((*key_type).clone(), indices_num_values, true)?; let dictionary = self .dictionary - .into_arrow((*value_type).clone(), validate)?; + .into_arrow_impl((*value_type).clone(), true)?; let builder = indices .into_builder() .add_child_data(dictionary) .data_type(DataType::Dictionary(key_type, value_type)); - if validate { - Ok(builder.build()?) - } else { - Ok(unsafe { builder.build_unchecked() }) - } + Ok(builder.build()?) } fn into_arrow(self, data_type: DataType, validate: bool) -> Result { if let DataType::Dictionary(key_type, value_type) = data_type { self.into_arrow_dict(key_type, value_type, validate) } else { - self.decode()?.into_arrow(data_type, validate) + self.decode()?.into_arrow_impl(data_type, validate) } } @@ -822,8 +1183,15 @@ pub enum DataBlock { } impl DataBlock { - /// Convert self into an Arrow ArrayData - pub fn into_arrow(self, data_type: DataType, validate: bool) -> Result { + /// Convert self into an Arrow ArrayData with full layout validation. + /// + /// The `validate` argument is retained for API compatibility. Conversion is + /// always validated because callers can construct data blocks directly. + pub fn into_arrow(self, data_type: DataType, _validate: bool) -> Result { + self.into_arrow_impl(data_type, true) + } + + fn into_arrow_impl(self, data_type: DataType, validate: bool) -> Result { match self { Self::Empty() => Ok(new_empty_array(&data_type).to_data()), Self::Constant(inner) => inner.into_arrow(data_type, validate), @@ -834,10 +1202,9 @@ impl DataBlock { Self::VariableWidth(inner) => inner.into_arrow(data_type, validate), Self::Struct(inner) => inner.into_arrow(data_type, validate), Self::Dictionary(inner) => inner.into_arrow(data_type, validate), - Self::Opaque(_) => Err(Error::Internal { - message: "Cannot convert OpaqueBlock to Arrow".to_string(), - location: location!(), - }), + Self::Opaque(_) => Err(Error::internal( + "Cannot convert OpaqueBlock to Arrow".to_string(), + )), } } @@ -975,12 +1342,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(), } } @@ -1001,7 +1364,7 @@ impl DataBlock { } } - pub fn make_builder(&self, estimated_size_bytes: u64) -> Box { + fn make_builder(&self, estimated_size_bytes: u64) -> Box { match self { Self::FixedWidth(inner) => { if inner.bits_per_value == 1 { @@ -1190,13 +1553,22 @@ fn arrow_binary_to_data_block( bits_per_offset: u8, ) -> DataBlock { let data_vec = arrays.iter().map(|arr| arr.to_data()).collect::>(); + arrow_binary_array_data_to_data_block(&data_vec, num_values, bits_per_offset) +} + +fn arrow_binary_array_data_to_data_block( + data_vec: &[ArrayData], + num_values: u64, + bits_per_offset: u8, +) -> DataBlock { let bytes_per_offset = bits_per_offset as usize / 8; let offsets = data_vec .iter() .map(|d| { - LanceBuffer::from( - d.buffers()[0].slice_with_length(d.offset(), (d.len() + 1) * bytes_per_offset), - ) + LanceBuffer::from(d.buffers()[0].slice_with_length( + d.offset() * bytes_per_offset, + (d.len() + 1) * bytes_per_offset, + )) }) .collect::>(); let (offsets, data_ranges) = if bits_per_offset == 32 { @@ -1334,8 +1706,6 @@ fn arrow_dictionary_to_data_block(arrays: &[ArrayRef], validity: Option max_index_val { - // Widen the index type - if max_index_val >= u32::MAX as u64 { - unimplemented!("Dictionary arrays with 2^32 unique value (or more) and a null") - } - upcast = Some(arrow_cast::cast(indices, &DataType::UInt32).unwrap()); - indices = upcast.as_ref().unwrap(); - } - null_index + values.len() - 1 }); + let max_index_val = max_index_val(indices.data_type()); + let upcast = if first_invalid_index as u64 > max_index_val { + // Widen the index type when the null dictionary value cannot be addressed by the + // declared key type, whether the value already existed or was appended above. + if max_index_val >= u32::MAX as u64 { + unimplemented!("Dictionary arrays with 2^32 unique value (or more) and a null") + } + Some(arrow_cast::cast(indices, &DataType::UInt32).unwrap()) + } else { + None + }; + if let Some(upcast) = upcast.as_ref() { + indices = upcast; + } // This can't fail since we already checked for fit let null_index_arr = arrow_cast::cast( &UInt64Array::from(vec![first_invalid_index as u64]), @@ -1449,6 +1823,71 @@ fn extract_nulls(arrays: &[ArrayRef], num_values: u64) -> Nullability { } impl DataBlock { + fn validate_variable_width_offsets( + array_data: &ArrayData, + ) -> std::result::Result<(), String> { + if array_data.is_empty() && array_data.buffers()[0].is_empty() { + return Ok(()); + } + let offset_size = std::mem::size_of::(); + let offset_start = array_data.offset() * offset_size; + let offset_len = (array_data.len() + 1) * offset_size; + let offset_buffer = + LanceBuffer::from(array_data.buffers()[0].slice_with_length(offset_start, offset_len)); + let offsets = offset_buffer.borrow_to_typed_slice::(); + VariableWidthBlock::offset_violation_detail( + offsets.as_ref(), + array_data.buffers()[1].len(), + 0, + ) + .map_or(Ok(()), Err) + } + + // `validate_full` also rescans UTF-8 contents and character boundaries on every flush. + // Encoding only needs a complete monotonicity and bounds proof before slicing offsets. + fn validate_variable_width_layouts(array_data: &ArrayData) -> std::result::Result<(), String> { + match array_data.data_type() { + DataType::Binary | DataType::Utf8 => { + Self::validate_variable_width_offsets::(array_data)?; + } + DataType::LargeBinary | DataType::LargeUtf8 => { + Self::validate_variable_width_offsets::(array_data)?; + } + _ => {} + } + for child_data in array_data.child_data() { + Self::validate_variable_width_layouts(child_data)?; + } + Ok(()) + } + + fn validate_array_data( + array_data: &ArrayData, + field_name: &str, + array_index: usize, + ) -> Result<()> { + let validation = array_data + .validate() + .map_err(|error| error.to_string()) + .and_then(|_| Self::validate_variable_width_layouts(array_data)); + validation.map_err(|error| { + Error::invalid_input_source( + format!( + "Invalid Arrow array for field '{}' at buffered array {}: {}", + field_name, array_index, error + ) + .into(), + ) + }) + } + + pub(crate) fn validate_arrays(arrays: &[ArrayRef], field_name: &str) -> Result<()> { + for (array_index, array) in arrays.iter().enumerate() { + Self::validate_array_data(&array.to_data(), field_name, array_index)?; + } + Ok(()) + } + pub fn from_arrays(arrays: &[ArrayRef], num_values: u64) -> Self { if arrays.is_empty() || num_values == 0 { return Self::AllNull(AllNullDataBlock { num_values: 0 }); @@ -1463,8 +1902,26 @@ impl DataBlock { let mut encoded = match data_type { DataType::Binary | DataType::Utf8 => arrow_binary_to_data_block(arrays, num_values, 32), - DataType::BinaryView | DataType::Utf8View => { - todo!() + // View types have no Lance disk representation; cast to the classic offset layout. + DataType::Utf8View => { + let casted: Vec = arrays + .iter() + .map(|a| { + arrow_cast::cast(a.as_ref(), &DataType::Utf8) + .expect("Utf8View to Utf8 cast is always valid") + }) + .collect(); + arrow_binary_to_data_block(&casted, num_values, 32) + } + DataType::BinaryView => { + let casted: Vec = arrays + .iter() + .map(|a| { + arrow_cast::cast(a.as_ref(), &DataType::Binary) + .expect("BinaryView to Binary cast is always valid") + }) + .collect(); + arrow_binary_to_data_block(&casted, num_values, 32) } DataType::LargeBinary | DataType::LargeUtf8 => { arrow_binary_to_data_block(arrays, num_values, 64) @@ -1592,8 +2049,16 @@ impl From for DataBlock { } } -pub trait DataBlockBuilderImpl: std::fmt::Debug { - fn append(&mut self, data_block: &DataBlock, selection: Range); +trait DataBlockBuilderImpl: std::fmt::Debug { + fn validate_append(&self, data_block: &DataBlock, selection: &Range) -> Result<()>; + + fn append_validated(&mut self, data_block: &DataBlock, selection: Range) -> Result<()>; + + fn append(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { + self.validate_append(data_block, &selection)?; + self.append_validated(data_block, selection) + } + fn finish(self: Box) -> DataBlock; } @@ -1618,8 +2083,31 @@ impl DataBlockBuilder { self.builder.as_mut().unwrap().as_mut() } - pub fn append(&mut self, data_block: &DataBlock, selection: Range) { - self.get_builder(data_block).append(data_block, selection); + pub fn append(&mut self, data_block: &DataBlock, selection: Range) -> Result<()> { + self.get_builder(data_block).append(data_block, selection) + } + + fn append_ranges( + &mut self, + data_block: &DataBlock, + selections: impl IntoIterator>, + ) -> Result<()> { + let full_selection = 0..data_block.num_values(); + let builder = self.get_builder(data_block); + builder.validate_append(data_block, &full_selection)?; + for selection in selections { + if selection.start > selection.end || selection.end > full_selection.end { + return Err(Error::corrupt_file_named( + "data block", + format!( + "cannot append selection {}..{} from a block with {} values", + selection.start, selection.end, full_selection.end + ), + )); + } + builder.append_validated(data_block, selection)?; + } + Ok(()) } pub fn finish(self) -> DataBlock { @@ -1633,20 +2121,26 @@ mod tests { use std::sync::Arc; use arrow_array::{ - make_array, new_null_array, - types::{Int32Type, Int8Type}, - ArrayRef, DictionaryArray, Int8Array, LargeBinaryArray, StringArray, UInt16Array, - UInt8Array, + ArrayRef, BinaryArray, BinaryViewArray, DictionaryArray, Int8Array, LargeBinaryArray, + LargeStringArray, StringArray, StringViewArray, UInt8Array, UInt16Array, make_array, + new_null_array, + types::{Int8Type, Int32Type}, }; - use arrow_buffer::{BooleanBuffer, NullBuffer}; + use arrow_buffer::{BooleanBuffer, Buffer, NullBuffer}; + use arrow_data::ArrayData; use arrow_schema::{DataType, Field, Fields}; - use lance_datagen::{array, ArrayGeneratorExt, RowCount, DEFAULT_SEED}; + use lance_core::Error; + use lance_datagen::{ArrayGeneratorExt, DEFAULT_SEED, RowCount, array}; use rand::SeedableRng; + use rstest::rstest; use crate::buffer::LanceBuffer; - use super::{AllNullDataBlock, DataBlock}; + use super::{ + AllNullDataBlock, BlockInfo, DataBlock, DataBlockBuilder, DictionaryDataBlock, + FixedWidthDataBlock, VariableWidthBlock, + }; use arrow_array::Array; @@ -1714,6 +2208,58 @@ mod tests { assert_eq!(data.data, LanceBuffer::copy_slice(b"abcdef")); } + #[test] + fn test_string_view_to_data_block() { + let views1 = StringViewArray::from(vec![Some("hello"), None, Some("world")]); + let views2 = StringViewArray::from(vec![Some("a"), Some("b")]); + let views3 = StringViewArray::from(vec![Option::<&'static str>::None, None]); + + let arrays = &[views1, views2, views3] + .iter() + .map(|arr| Arc::new(arr.clone()) as ArrayRef) + .collect::>(); + + let block = DataBlock::from_arrays(arrays, 7); + + assert_eq!(block.num_values(), 7); + let block = block.as_nullable().unwrap(); + assert_eq!(block.nulls, LanceBuffer::from(vec![0b00011101])); + let data = block.data.as_variable_width().unwrap(); + assert_eq!( + data.offsets, + LanceBuffer::reinterpret_vec(vec![0, 5, 5, 10, 11, 12, 12, 12]) + ); + assert_eq!(data.data, LanceBuffer::copy_slice(b"helloworldab")); + + let views1 = StringViewArray::from(vec![Some("a"), Some("bc")]); + let views2 = StringViewArray::from(vec![Some("def")]); + + let arrays = &[views1, views2] + .iter() + .map(|arr| Arc::new(arr.clone()) as ArrayRef) + .collect::>(); + + let block = DataBlock::from_arrays(arrays, 3); + + assert_eq!(block.num_values(), 3); + let data = block.as_variable_width().unwrap(); + assert_eq!(data.offsets, LanceBuffer::reinterpret_vec(vec![0, 1, 3, 6])); + assert_eq!(data.data, LanceBuffer::copy_slice(b"abcdef")); + } + + #[test] + fn test_binary_view_to_data_block() { + let arr: ArrayRef = Arc::new(BinaryViewArray::from(vec![ + Some(b"foo".as_slice()), + None, + Some(b"bar".as_slice()), + ])); + let block = DataBlock::from_arrays(&[arr], 3); + let block = block.as_nullable().unwrap(); + let data = block.data.as_variable_width().unwrap(); + assert_eq!(data.data, LanceBuffer::copy_slice(b"foobar")); + } + #[test] fn test_string_sliced() { let check = |arr: Vec, expected_off: Vec, expected_data: &[u8]| { @@ -1748,6 +2294,70 @@ mod tests { ); } + #[rstest] + #[case::utf8( + DataType::Utf8, + Buffer::from_slice_ref([0_i32, 5, 10]), + 32, + LanceBuffer::reinterpret_vec(vec![0_i32, 5]) + )] + #[case::large_utf8( + DataType::LargeUtf8, + Buffer::from_slice_ref([0_i64, 5, 10]), + 64, + LanceBuffer::reinterpret_vec(vec![0_i64, 5]) + )] + fn test_variable_width_array_data_offset( + #[case] data_type: DataType, + #[case] offsets: Buffer, + #[case] bits_per_offset: u8, + #[case] expected_offsets: LanceBuffer, + ) { + let array_data = ArrayData::builder(data_type) + .len(1) + .offset(1) + .add_buffer(offsets) + .add_buffer(Buffer::from(b"helloworld")) + .build() + .unwrap(); + + DataBlock::validate_array_data(&array_data, "text", 0).unwrap(); + let data = super::arrow_binary_array_data_to_data_block(&[array_data], 1, bits_per_offset); + + let data = data.as_variable_width().unwrap(); + assert_eq!(data.offsets, expected_offsets); + assert_eq!(data.data, LanceBuffer::copy_slice(b"world")); + } + + #[rstest] + #[case::utf8(DataType::Utf8, Buffer::from_slice_ref([0_i32, -1]))] + #[case::large_utf8(DataType::LargeUtf8, Buffer::from_slice_ref([0_i64, -1]))] + fn test_invalid_string_offsets_rejected_before_encoding( + #[case] data_type: DataType, + #[case] offsets: Buffer, + ) { + let array_data = unsafe { + ArrayData::builder(data_type) + .len(1) + .add_buffer(offsets) + .add_buffer(Buffer::from(b"")) + .build_unchecked() + }; + + let error = DataBlock::validate_array_data(&array_data, "text", 0).unwrap_err(); + + assert!(matches!(error, Error::InvalidInput { .. })); + let message = error.to_string(); + assert!( + message.contains("field 'text'"), + "unexpected message: {message}" + ); + assert!( + message.contains("offset[1] (-1)"), + "unexpected message: {message}" + ); + } + #[test] fn test_large() { let arr = LargeBinaryArray::from_vec(vec![b"hello", b"world"]); @@ -2009,4 +2619,280 @@ mod tests { let total_nulls_size_in_bytes = concatenated_array.nulls().unwrap().len().div_ceil(8); assert!(block.data_size() == (total_buffer_size + total_nulls_size_in_bytes) as u64); } + + #[test] + fn variable_width_rejects_out_of_bounds_offsets_without_optional_validation() { + let block = VariableWidthBlock { + data: LanceBuffer::copy_slice(b"alphabetagamma"), + offsets: LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9, 100_000]), + bits_per_offset: 32, + num_values: 3, + block_info: BlockInfo::new(), + }; + + let error = block + .into_arrow(DataType::Binary, false) + .expect_err("out-of-bounds offsets must be rejected"); + assert!( + matches!(error, Error::CorruptFile { .. }), + "expected CorruptFile, got: {error}" + ); + let message = error.to_string(); + assert!( + message.contains("100000") && message.contains("data buffer size: 14 bytes"), + "error must report the offending offset and the data buffer size: {message}" + ); + } + + #[test] + fn public_fixed_width_conversion_always_validates_layout() { + let block = FixedWidthDataBlock { + data: LanceBuffer::from(vec![0_u8; 4]), + bits_per_value: 32, + num_values: 2, + block_info: BlockInfo::new(), + }; + + for validate in [false, true] { + block + .clone() + .into_arrow(DataType::Int32, validate) + .expect_err("a short values buffer must be rejected"); + DataBlock::FixedWidth(block.clone()) + .into_arrow(DataType::Int32, validate) + .expect_err("a short values buffer must be rejected"); + } + } + + #[rstest] + #[case::i32_decreasing( + LanceBuffer::reinterpret_vec(vec![0_i32, 5, 2]), + 32, + 2, + "decreases" + )] + #[case::i64_decreasing( + LanceBuffer::reinterpret_vec(vec![0_i64, 5, 2]), + 64, + 2, + "decreases" + )] + #[case::i32_out_of_bounds( + LanceBuffer::reinterpret_vec(vec![0_i32, 6]), + 32, + 1, + "out of bounds" + )] + fn variable_width_builder_rejects_malformed_offsets( + #[case] offsets: LanceBuffer, + #[case] bits_per_offset: u8, + #[case] num_values: u64, + #[case] expected_message: &str, + ) { + let block = DataBlock::VariableWidth(VariableWidthBlock { + data: LanceBuffer::copy_slice(b"abcde"), + offsets, + bits_per_offset, + num_values, + block_info: BlockInfo::new(), + }); + let mut builder = DataBlockBuilder::with_capacity_estimate(5); + + let error = builder + .append(&block, 0..num_values) + .expect_err("malformed offsets must fail concatenation"); + assert!( + matches!(error, Error::CorruptFile { .. }), + "expected CorruptFile, got: {error}" + ); + assert!( + error.to_string().contains(expected_message), + "unexpected message: {error}" + ); + } + + #[rstest] + #[case::binary_i32_tail_out_of_bounds( + DataType::Binary, + LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9, 100_000]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::utf8_i32_tail_out_of_bounds( + DataType::Utf8, + LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9, 100_000]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::large_binary_i64_tail_out_of_bounds( + DataType::LargeBinary, + LanceBuffer::reinterpret_vec(vec![0_i64, 5, 9, 100_000]), + 64, + 3, + b"alphabetagamma".as_slice() + )] + #[case::large_utf8_i64_tail_out_of_bounds( + DataType::LargeUtf8, + LanceBuffer::reinterpret_vec(vec![0_i64, 5, 9, 100_000]), + 64, + 3, + b"alphabetagamma".as_slice() + )] + #[case::binary_negative_offset( + DataType::Binary, + LanceBuffer::reinterpret_vec(vec![0_i32, -1, 9, 14]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::binary_non_monotonic_offsets( + DataType::Binary, + LanceBuffer::reinterpret_vec(vec![0_i32, 9, 5, 14]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::binary_interior_offset_out_of_bounds( + DataType::Binary, + LanceBuffer::reinterpret_vec(vec![0_i32, 100_000, 100_000, 14]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::binary_offsets_buffer_too_short( + DataType::Binary, + LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9]), + 32, + 3, + b"alphabetagamma".as_slice() + )] + #[case::utf8_invalid_byte_sequence( + DataType::Utf8, + LanceBuffer::reinterpret_vec(vec![0_i32, 1, 2, 3]), + 32, + 3, + &[b'a', 0xFF, b'b'] + )] + #[case::utf8_offset_splits_multibyte_char( + DataType::Utf8, + LanceBuffer::reinterpret_vec(vec![0_i32, 1, 2]), + 32, + 2, + "é".as_bytes() + )] + #[case::large_utf8_invalid_byte_sequence( + DataType::LargeUtf8, + LanceBuffer::reinterpret_vec(vec![0_i64, 1, 2, 3]), + 64, + 3, + &[b'a', 0xFF, b'b'] + )] + fn variable_width_rejects_malformed_layout( + #[case] data_type: DataType, + #[case] offsets: LanceBuffer, + #[case] bits_per_offset: u8, + #[case] num_values: u64, + #[case] data: &[u8], + ) { + let block = VariableWidthBlock { + data: LanceBuffer::copy_slice(data), + offsets, + bits_per_offset, + num_values, + block_info: BlockInfo::new(), + }; + + // The malformed layout must be rejected regardless of the optional + // `validate` flag: the flag selects extra validation, not the memory + // safety proof required to construct an Arrow array. + for validate in [false, true] { + let error = DataBlock::VariableWidth(block.clone()) + .into_arrow(data_type.clone(), validate) + .expect_err("malformed variable-width layout must be rejected"); + assert!( + matches!(error, Error::CorruptFile { .. }), + "expected CorruptFile with validate={validate}, got: {error}" + ); + } + } + + #[test] + fn dictionary_rejects_malformed_variable_width_values_without_optional_validation() { + let values = VariableWidthBlock { + data: LanceBuffer::copy_slice(b"alphabetagamma"), + offsets: LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9, 100_000]), + bits_per_offset: 32, + num_values: 3, + block_info: BlockInfo::new(), + }; + let dictionary = DataBlock::Dictionary(DictionaryDataBlock { + indices: FixedWidthDataBlock { + data: LanceBuffer::reinterpret_vec(vec![0_i32, 1, 2]), + bits_per_value: 32, + num_values: 3, + block_info: BlockInfo::new(), + }, + dictionary: Box::new(DataBlock::VariableWidth(values)), + }); + + let data_type = DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Binary)); + let error = dictionary + .into_arrow(data_type, false) + .expect_err("dictionary with out-of-bounds value offsets must be rejected"); + assert!( + matches!(error, Error::CorruptFile { .. }), + "expected CorruptFile, got: {error}" + ); + } + + #[test] + fn dictionary_rejects_indices_wider_than_declared_key_type() { + let dictionary = DataBlock::Dictionary(DictionaryDataBlock { + indices: FixedWidthDataBlock { + data: LanceBuffer::reinterpret_vec(vec![0_u32, 1, 128]), + bits_per_value: 32, + num_values: 3, + block_info: BlockInfo::new(), + }, + dictionary: Box::new(DataBlock::from_array(StringArray::from(vec![ + Some("zero"), + Some("one"), + None, + ]))), + }); + + let data_type = DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)); + let error = dictionary + .into_arrow(data_type, false) + .expect_err("mismatched dictionary index widths must be rejected"); + + assert!(matches!(error, Error::CorruptFile { .. })); + assert!( + error.to_string().contains( + "dictionary indices use 32 bits but the declared Int8 key type uses 8 bits" + ) + ); + } + + #[rstest] + #[case::binary(Arc::new(BinaryArray::from_vec(vec![b"alpha", b"", b"gamma"])) as ArrayRef)] + #[case::large_binary( + Arc::new(LargeBinaryArray::from_vec(vec![b"alpha", b"", b"gamma"])) as ArrayRef + )] + #[case::utf8(Arc::new(StringArray::from(vec!["héllo", "", "world"])) as ArrayRef)] + #[case::large_utf8(Arc::new(LargeStringArray::from(vec!["héllo", "", "world"])) as ArrayRef)] + fn variable_width_valid_data_survives_mandatory_validation(#[case] array: ArrayRef) { + let block = DataBlock::from_array(array.clone()); + for validate in [false, true] { + let round_tripped = make_array( + block + .clone() + .into_arrow(array.data_type().clone(), validate) + .unwrap(), + ); + assert_eq!(&round_tripped, &array); + } + } } diff --git a/rust/lance-encoding/src/decoder.rs b/rust/lance-encoding/src/decoder.rs index 67996c41f3c..8a4aebbd038 100644 --- a/rust/lance-encoding/src/decoder.rs +++ b/rust/lance-encoding/src/decoder.rs @@ -213,29 +213,40 @@ //! relation to the way the data is stored. use std::collections::VecDeque; -use std::sync::{LazyLock, Once}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{LazyLock, Mutex, Once, OnceLock}; use std::{ops::Range, sync::Arc}; use arrow_array::cast::AsArray; use arrow_array::{ArrayRef, RecordBatch, RecordBatchIterator, RecordBatchReader}; use arrow_schema::{ArrowError, DataType, Field as ArrowField, Fields, Schema as ArrowSchema}; use bytes::Bytes; -use futures::future::{maybe_done, BoxFuture, MaybeDone}; +use futures::channel::oneshot; +use futures::future::{BoxFuture, MaybeDone, Shared, maybe_done}; use futures::stream::{self, BoxStream}; use futures::{FutureExt, StreamExt}; use lance_arrow::DataTypeExt; -use lance_core::cache::LanceCache; -use lance_core::datatypes::{Field, Schema, BLOB_DESC_LANCE_FIELD}; -use lance_core::utils::futures::FinallyStreamExt; +use lance_core::cache::{Context, DeepSizeOf, LanceCache}; +use lance_core::datatypes::{ + BLOB_DESC_LANCE_FIELD, Field, Schema, validate_fixed_size_list_dimensions, +}; +use lance_core::utils::futures::{FinallyStreamExt, StreamOnDropExt}; +use lance_core::utils::parse::parse_env_as_bool; use log::{debug, trace, warn}; -use snafu::location; +use prost::Message; use tokio::sync::mpsc::error::SendError; use tokio::sync::mpsc::{self, unbounded_channel}; -use lance_core::error::LanceOptionExt; +use lance_core::error::{CloneableError, LanceOptionExt}; use lance_core::{ArrowResult, Error, Result}; use tracing::instrument; +use crate::array_encoding::logical::list::OffsetPageInfo; +use crate::array_encoding::logical::r#struct::{SimpleStructDecoder, SimpleStructScheduler}; +use crate::array_encoding::logical::{ + binary::BinaryFieldScheduler, blob::BlobFieldScheduler, list::ListFieldScheduler, + primitive::PrimitiveFieldScheduler, +}; use crate::compression::{DecompressionStrategy, DefaultDecompressionStrategy}; use crate::data::DataBlock; use crate::encoder::EncodedBatch; @@ -246,32 +257,184 @@ use crate::encodings::logical::primitive::StructuralPrimitiveFieldScheduler; use crate::encodings::logical::r#struct::{StructuralStructDecoder, StructuralStructScheduler}; use crate::format::pb::{self, column_encoding}; use crate::format::pb21; -use crate::previous::decoder::LogicalPageDecoder; -use crate::previous::encodings::logical::list::OffsetPageInfo; -use crate::previous::encodings::logical::r#struct::{SimpleStructDecoder, SimpleStructScheduler}; -use crate::previous::encodings::logical::{ - binary::BinaryFieldScheduler, blob::BlobFieldScheduler, list::ListFieldScheduler, - primitive::PrimitiveFieldScheduler, -}; use crate::repdef::{CompositeRepDefUnraveler, RepDefUnraveler}; -use crate::version::LanceFileVersion; use crate::{BufferScheduler, EncodingsIo}; +/// Candidate batch sizes evaluated during byte-budget planning. +/// Powers of 4, covering 1–16Ki rows in 8 probes. +pub const CANDIDATE_BATCH_SIZES: [u32; 8] = [1, 4, 16, 64, 256, 1024, 4096, 16384]; + +pub trait SchedulingJob: std::fmt::Debug { + fn schedule_next( + &mut self, + context: &mut SchedulerContext, + priority: &dyn PriorityRange, + ) -> Result; + + fn num_rows(&self) -> u64; +} + +/// Schedules the I/O needed to decode one field. +pub trait FieldScheduler: Send + Sync + std::fmt::Debug { + fn initialize<'a>( + &'a self, + filter: &'a FilterExpression, + context: &'a SchedulerContext, + ) -> BoxFuture<'a, Result<()>>; + + fn schedule_ranges<'a>( + &'a self, + ranges: &[Range], + filter: &FilterExpression, + ) -> Result>; + + fn num_rows(&self) -> u64; +} + +#[derive(Debug)] +pub struct DecoderReady { + pub decoder: Box, + pub path: VecDeque, +} + +/// Stateful decoder for one logical page. +pub trait LogicalPageDecoder: std::fmt::Debug + Send { + fn accept_child(&mut self, _child: DecoderReady) -> Result<()> { + Err(Error::internal(format!( + "The decoder {:?} does not expect children but received a child", + self + ))) + } + + fn wait_for_loaded(&'_ mut self, loaded_need: u64) -> BoxFuture<'_, Result<()>>; + + fn rows_loaded(&self) -> u64; + + fn rows_unloaded(&self) -> u64 { + self.num_rows() - self.rows_loaded() + } + + fn num_rows(&self) -> u64; + + fn rows_drained(&self) -> u64; + + fn rows_left(&self) -> u64 { + self.num_rows() - self.rows_drained() + } + + fn drain(&mut self, num_rows: u64) -> Result; + + /// Returns how many of the requested rows can be decoded into one Arrow array + /// while keeping every i32 offset buffer within `byte_budget` bytes. + /// + /// Most page decoders can always drain the full request and consume no budget. + /// Decoders that build arrays with i32 offsets (Utf8 / Binary / List) may return + /// fewer rows so that the concatenated array's offsets stay representable, and + /// report the budget those rows consume so callers can accumulate across pages. + fn max_rows_to_drain(&self, num_rows: u64, _byte_budget: u64) -> Result { + Ok(DrainLimit { + rows: num_rows, + bytes: 0, + }) + } + + fn data_type(&self) -> &DataType; +} + +/// Result of limiting a drain request to a variable-width byte budget. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DrainLimit { + /// How many of the requested rows can be decoded into one Arrow array + pub rows: u64, + /// The variable-width bytes (at the most constrained i32-offset nesting level) + /// those rows consume from the caller's budget + pub bytes: u64, +} + +/// The byte budget applied to each output batch of an i32-offset column. +/// +/// Concatenating decoded pages into one Arrow array fails when a variable-width +/// buffer's offsets exceed `i32::MAX`, so batches are split before that point. +pub(crate) const I32_OFFSET_BYTE_BUDGET: u64 = i32::MAX as u64; + +/// Clamp a batch's row count to the byte-budgeted limit. +/// +/// A batch always advances by at least one row: a first row whose (possibly +/// over-estimated) size exceeds the whole budget is emitted alone. A genuinely +/// oversized row then surfaces the same arrow-level offset error it always did, +/// while an over-estimated one decodes fine. +fn clamp_rows_to_i32_budget(limit: DrainLimit, to_take: u64) -> u64 { + if to_take == 0 { + return 0; + } + limit.rows.clamp(1, to_take) +} + +/// Returns whether concatenating arrays of this type can overflow an i32 offset buffer. +pub(crate) fn has_i32_offsets(data_type: &DataType) -> bool { + match data_type { + DataType::Binary | DataType::Utf8 | DataType::List(_) | DataType::ListView(_) => true, + DataType::Map(_, _) => true, + DataType::LargeList(field) + | DataType::LargeListView(field) + | DataType::FixedSizeList(field, _) => has_i32_offsets(field.data_type()), + DataType::Struct(fields) => fields + .iter() + .any(|field| has_i32_offsets(field.data_type())), + DataType::Dictionary(_, values) => has_i32_offsets(values), + DataType::RunEndEncoded(_, values) => has_i32_offsets(values.data_type()), + _ => false, + } +} + // If users are getting batches over 10MiB large then it's time to reduce the batch size const BATCH_SIZE_BYTES_WARNING: u64 = 10 * 1024 * 1024; +const ENV_LANCE_STRUCTURAL_BATCH_DECODE_SPAWN_MODE: &str = + "LANCE_STRUCTURAL_BATCH_DECODE_SPAWN_MODE"; +const ENV_LANCE_READ_CACHE_REPETITION_INDEX: &str = "LANCE_READ_CACHE_REPETITION_INDEX"; +const ENV_LANCE_INLINE_SCHEDULING_THRESHOLD: &str = "LANCE_INLINE_SCHEDULING_THRESHOLD"; + +// If a request is for at most this many rows we skip the scheduler-task spawn +// and run scheduling inline as part of the `schedule_and_decode` await. +const DEFAULT_INLINE_SCHEDULING_THRESHOLD: u64 = 16 * 1024; + +fn default_cache_repetition_index() -> bool { + static DEFAULT_CACHE_REPETITION_INDEX: OnceLock = OnceLock::new(); + *DEFAULT_CACHE_REPETITION_INDEX + .get_or_init(|| parse_env_as_bool(ENV_LANCE_READ_CACHE_REPETITION_INDEX, true)) +} -/// Top-level encoding message for a page. Wraps both the -/// legacy pb::ArrayEncoding and the newer pb::PageLayout +fn inline_scheduling_threshold() -> u64 { + static THRESHOLD: OnceLock = OnceLock::new(); + *THRESHOLD.get_or_init(|| { + std::env::var(ENV_LANCE_INLINE_SCHEDULING_THRESHOLD) + .ok() + .and_then(|v| v.trim().parse::().ok()) + .unwrap_or(DEFAULT_INLINE_SCHEDULING_THRESHOLD) + }) +} + +/// Top-level encoding message for a page. Wraps both the v2.0 +/// [`pb::ArrayEncoding`] grammar and the structural [`pb21::PageLayout`] grammar. /// /// A file should only use one or the other and never both. /// 2.0 decoders can always assume this is pb::ArrayEncoding /// and 2.1+ decoders can always assume this is pb::PageLayout -#[derive(Debug)] +#[derive(Debug, Clone)] pub enum PageEncoding { Legacy(pb::ArrayEncoding), Structural(pb21::PageLayout), } +impl DeepSizeOf for PageEncoding { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + match self { + Self::Legacy(encoding) => encoding.encoded_len() * 4, + Self::Structural(encoding) => encoding.encoded_len() * 4, + } + } +} + impl PageEncoding { pub fn as_legacy(&self) -> &pb::ArrayEncoding { match self { @@ -309,6 +472,13 @@ pub struct PageInfo { pub buffer_offsets_and_sizes: Arc<[(u64, u64)]>, } +impl DeepSizeOf for PageInfo { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.encoding.deep_size_of_children(context) + + self.buffer_offsets_and_sizes.deep_size_of_children(context) + } +} + /// Metadata describing a column in a file /// /// This is typically created by reading the metadata section of a Lance file @@ -323,6 +493,14 @@ pub struct ColumnInfo { pub encoding: pb::ColumnEncoding, } +impl DeepSizeOf for ColumnInfo { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.page_infos.deep_size_of_children(context) + + self.buffer_offsets_and_sizes.deep_size_of_children(context) + + self.encoding.encoded_len() * 4 + } +} + impl ColumnInfo { /// Create a new instance pub fn new( @@ -350,21 +528,21 @@ impl ColumnInfo { enum RootScheduler { Structural(Box), - Legacy(Arc), + Array(Arc), } impl RootScheduler { - fn as_legacy(&self) -> &Arc { + fn as_array(&self) -> &Arc { match self { - Self::Structural(_) => panic!("Expected a legacy scheduler"), - Self::Legacy(s) => s, + Self::Structural(_) => panic!("Expected an array scheduler"), + Self::Array(s) => s, } } fn as_structural(&self) -> &dyn StructuralFieldScheduler { match self { Self::Structural(s) => s.as_ref(), - Self::Legacy(_) => panic!("Expected a structural scheduler"), + Self::Array(_) => panic!("Expected a structural scheduler"), } } } @@ -428,7 +606,6 @@ impl<'a> ColumnInfoIter<'a> { self.next().ok_or_else(|| { Error::invalid_input( "there were more fields in the schema than provided column indices / infos", - location!(), ) }) } @@ -515,13 +692,10 @@ impl CoreFieldDecoderStrategy { .column_encoding .as_ref() .ok_or_else(|| { - Error::invalid_input( - format!( - "the column at index {} was missing a ColumnEncoding", - column_info.index - ), - location!(), - ) + Error::invalid_input(format!( + "the column at index {} was missing a ColumnEncoding", + column_info.index + )) })?; if matches!( column_encoding, @@ -529,7 +703,10 @@ impl CoreFieldDecoderStrategy { ) { Ok(()) } else { - Err(Error::invalid_input(format!("the column at index {} mapping to the input field {} has column encoding {:?} and no decoder is registered to handle it", column_info.index, field_name, column_encoding), location!())) + Err(Error::invalid_input(format!( + "the column at index {} mapping to the input field {} has column encoding {:?} and no decoder is registered to handle it", + column_info.index, field_name, column_encoding + ))) } } @@ -555,14 +732,14 @@ impl CoreFieldDecoderStrategy { } } - fn is_primitive_legacy(data_type: &DataType) -> bool { + fn is_array_primitive(data_type: &DataType) -> bool { if data_type.is_primitive() { true } else { match data_type { // DataType::is_primitive doesn't consider these primitive but we do DataType::Boolean | DataType::Null | DataType::FixedSizeBinary(_) => true, - DataType::FixedSizeList(inner, _) => Self::is_primitive_legacy(inner.data_type()), + DataType::FixedSizeList(inner, _) => Self::is_array_primitive(inner.data_type()), _ => false, } } @@ -573,7 +750,7 @@ impl CoreFieldDecoderStrategy { field: &Field, column: &ColumnInfo, buffers: FileBuffers, - ) -> Result> { + ) -> Result> { Self::ensure_values_encoded(column, &field.name)?; // Primitive fields map to a single column let column_buffers = ColumnBuffers { @@ -593,12 +770,12 @@ impl CoreFieldDecoderStrategy { fn check_simple_struct(column_info: &ColumnInfo, field_name: &str) -> Result<()> { Self::ensure_values_encoded(column_info, field_name)?; if column_info.page_infos.len() != 1 { - return Err(Error::InvalidInput { source: format!("Due to schema we expected a struct column but we received a column with {} pages and right now we only support struct columns with 1 page", column_info.page_infos.len()).into(), location: location!() }); + return Err(Error::invalid_input_source(format!("Due to schema we expected a struct column but we received a column with {} pages and right now we only support struct columns with 1 page", column_info.page_infos.len()).into())); } let encoding = &column_info.page_infos[0].encoding; match encoding.as_legacy().array_encoding.as_ref().unwrap() { pb::array_encoding::ArrayEncoding::Struct(_) => Ok(()), - _ => Err(Error::InvalidInput { source: format!("Expected a struct encoding because we have a struct field in the schema but got the encoding {:?}", encoding).into(), location: location!() }), + _ => Err(Error::invalid_input_source(format!("Expected a struct encoding because we have a struct field in the schema but got the encoding {:?}", encoding).into())), } } @@ -616,52 +793,57 @@ impl CoreFieldDecoderStrategy { column_infos: &mut ColumnInfoIter, buffers: FileBuffers, offsets_column: &ColumnInfo, - ) -> Result> { + ) -> Result> { Self::ensure_values_encoded(offsets_column, &list_field.name)?; let offsets_column_buffers = ColumnBuffers { file_buffers: buffers, positions_and_sizes: &offsets_column.buffer_offsets_and_sizes, }; let items_scheduler = - self.create_legacy_field_scheduler(&list_field.children[0], column_infos, buffers)?; + self.create_array_field_scheduler(&list_field.children[0], column_infos, buffers)?; - let (inner_infos, null_offset_adjustments): (Vec<_>, Vec<_>) = offsets_column + let mut inner_infos = Vec::with_capacity(offsets_column.page_infos.len()); + let mut null_offset_adjustments = Vec::with_capacity(offsets_column.page_infos.len()); + for (page_index, offsets_page) in offsets_column .page_infos .iter() - .filter(|offsets_page| offsets_page.num_rows > 0) - .map(|offsets_page| { - if let Some(pb::array_encoding::ArrayEncoding::List(list_encoding)) = - &offsets_page.encoding.as_legacy().array_encoding - { - let inner = PageInfo { - buffer_offsets_and_sizes: offsets_page.buffer_offsets_and_sizes.clone(), - encoding: PageEncoding::Legacy( - list_encoding.offsets.as_ref().unwrap().as_ref().clone(), - ), - num_rows: offsets_page.num_rows, - priority: 0, - }; - ( - inner, - OffsetPageInfo { - offsets_in_page: offsets_page.num_rows, - null_offset_adjustment: list_encoding.null_offset_adjustment, - num_items_referenced_by_page: list_encoding.num_items, - }, - ) - } else { - // TODO: Should probably return Err here - panic!("Expected a list column"); - } - }) - .unzip(); + .enumerate() + .filter(|(_, offsets_page)| offsets_page.num_rows > 0) + { + let PageEncoding::Legacy(pb::ArrayEncoding { + array_encoding: Some(pb::array_encoding::ArrayEncoding::List(list_encoding)), + }) = &offsets_page.encoding + else { + return Err(Error::invalid_input(format!( + "expected list encoding for field '{}' in column {}, page {} but got {:?}", + list_field.name, offsets_column.index, page_index, offsets_page.encoding + ))); + }; + let offsets_encoding = list_encoding.offsets.as_ref().ok_or_else(|| { + Error::invalid_input(format!( + "list encoding for field '{}' in column {}, page {} is missing its offsets encoding", + list_field.name, offsets_column.index, page_index + )) + })?; + inner_infos.push(PageInfo { + buffer_offsets_and_sizes: offsets_page.buffer_offsets_and_sizes.clone(), + encoding: PageEncoding::Legacy(offsets_encoding.as_ref().clone()), + num_rows: offsets_page.num_rows, + priority: 0, + }); + null_offset_adjustments.push(OffsetPageInfo { + offsets_in_page: offsets_page.num_rows, + null_offset_adjustment: list_encoding.null_offset_adjustment, + num_items_referenced_by_page: list_encoding.num_items, + }); + } let inner = Arc::new(PrimitiveFieldScheduler::new( offsets_column.index, DataType::UInt64, Arc::from(inner_infos.into_boxed_slice()), offsets_column_buffers, self.validate_data, - )) as Arc; + )) as Arc; let items_field = match list_field.data_type() { DataType::List(inner) => inner, DataType::LargeList(inner) => inner, @@ -699,11 +881,12 @@ impl CoreFieldDecoderStrategy { column_infos: &mut ColumnInfoIter, ) -> Result> { let data_type = field.data_type(); + validate_fixed_size_list_dimensions(&field.name, &data_type)?; if Self::is_structural_primitive(&data_type) { let column_info = column_infos.expect_next()?; - let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new( - column_info.as_ref(), - self.decompressor_strategy.as_ref(), + let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new_lazy( + column_info.clone(), + self.decompressor_strategy.clone(), self.cache_repetition_index, field, )?); @@ -718,9 +901,9 @@ impl CoreFieldDecoderStrategy { if field.is_packed_struct() { // Packed struct let column_info = column_infos.expect_next()?; - let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new( - column_info.as_ref(), - self.decompressor_strategy.as_ref(), + let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new_lazy( + column_info.clone(), + self.decompressor_strategy.clone(), self.cache_repetition_index, field, )?); @@ -742,9 +925,9 @@ impl CoreFieldDecoderStrategy { ) }) { let column_info = column_infos.expect_next()?; - let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new( - column_info.as_ref(), - self.decompressor_strategy.as_ref(), + let scheduler = Box::new(StructuralPrimitiveFieldScheduler::try_new_lazy( + column_info.clone(), + self.decompressor_strategy.clone(), self.cache_repetition_index, field, )?); @@ -789,10 +972,7 @@ impl CoreFieldDecoderStrategy { // because converting a rust arrow map field to the python arrow field will // lose the keys_sorted property. if *keys_sorted { - return Err(Error::NotSupported { - source: format!("Map data type is not supported with keys_sorted=true now, current value is {}", *keys_sorted).into(), - location: location!(), - }); + return Err(Error::not_supported_source(format!("Map data type is not supported with keys_sorted=true now, current value is {}", *keys_sorted).into())); } let entries_child = field.children.first().expect_ok()?; let child_scheduler = @@ -804,14 +984,15 @@ impl CoreFieldDecoderStrategy { } } - fn create_legacy_field_scheduler( + fn create_array_field_scheduler( &self, field: &Field, column_infos: &mut ColumnInfoIter, buffers: FileBuffers, - ) -> Result> { + ) -> Result> { let data_type = field.data_type(); - if Self::is_primitive_legacy(&data_type) { + validate_fixed_size_list_dimensions(&field.name, &data_type)?; + if Self::is_array_primitive(&data_type) { let column_info = column_infos.expect_next()?; let scheduler = self.create_primitive_scheduler(field, column_info, buffers)?; return Ok(scheduler); @@ -870,7 +1051,7 @@ impl CoreFieldDecoderStrategy { DataType::FixedSizeList(inner, _dimension) => { // A fixed size list column could either be a physical or a logical decoder // depending on the child data type. - if Self::is_primitive_legacy(inner.data_type()) { + if Self::is_array_primitive(inner.data_type()) { let primitive_col = column_infos.expect_next()?; let scheduler = self.create_primitive_scheduler(field, primitive_col, buffers)?; @@ -880,20 +1061,19 @@ impl CoreFieldDecoderStrategy { } } DataType::Dictionary(_key_type, value_type) => { - if Self::is_primitive_legacy(value_type) || value_type.is_binary_like() { + if Self::is_array_primitive(value_type) || value_type.is_binary_like() { let primitive_col = column_infos.expect_next()?; let scheduler = self.create_primitive_scheduler(field, primitive_col, buffers)?; Ok(scheduler) } else { - Err(Error::NotSupported { - source: format!( + Err(Error::not_supported_source( + format!( "No way to decode into a dictionary field of type {}", value_type ) .into(), - location: location!(), - }) + )) } } DataType::List(_) | DataType::LargeList(_) => { @@ -925,7 +1105,7 @@ impl CoreFieldDecoderStrategy { for field in &field.children { column_infos.next_top_level(); let field_scheduler = - self.create_legacy_field_scheduler(field, column_infos, buffers)?; + self.create_array_field_scheduler(field, column_infos, buffers)?; child_schedulers.push(Arc::from(field_scheduler)); } @@ -959,7 +1139,7 @@ fn root_column(num_rows: u64) -> ColumnInfo { pb::SimpleStruct {}, )), }), - priority: 0, // not used in legacy scheduler + priority: 0, // not used by the array scheduler buffer_offsets_and_sizes: Arc::new([]), }) .collect::>(); @@ -975,21 +1155,21 @@ fn root_column(num_rows: u64) -> ColumnInfo { pub enum RootDecoder { Structural(StructuralStructDecoder), - Legacy(SimpleStructDecoder), + Array(SimpleStructDecoder), } impl RootDecoder { pub fn into_structural(self) -> StructuralStructDecoder { match self { Self::Structural(decoder) => decoder, - Self::Legacy(_) => panic!("Expected a structural decoder"), + Self::Array(_) => panic!("Expected a structural decoder"), } } - pub fn into_legacy(self) -> SimpleStructDecoder { + pub fn into_array(self) -> SimpleStructDecoder { match self { - Self::Legacy(decoder) => decoder, - Self::Structural(_) => panic!("Expected a legacy decoder"), + Self::Array(decoder) => decoder, + Self::Structural(_) => panic!("Expected an array decoder"), } } } @@ -999,6 +1179,36 @@ impl DecodeBatchScheduler { /// metadata of the file. #[allow(clippy::too_many_arguments)] pub async fn try_new<'a>( + schema: &'a Schema, + column_indices: &[u32], + column_infos: &[Arc], + file_buffer_positions_and_sizes: &'a Vec<(u64, u64)>, + num_rows: u64, + decoder_plugins: Arc, + io: Arc, + cache: Arc, + filter: &FilterExpression, + decoder_config: &DecoderConfig, + ) -> Result { + Self::try_new_with_ranges( + schema, + column_indices, + column_infos, + file_buffer_positions_and_sizes, + num_rows, + decoder_plugins, + io, + cache, + None, + filter, + decoder_config, + ) + .await + } + + /// Creates a decode scheduler and initializes only pages overlapping known ranges. + #[allow(clippy::too_many_arguments)] + pub(crate) async fn try_new_with_ranges<'a>( schema: &'a Schema, column_indices: &[u32], column_infos: &[Arc], @@ -1007,6 +1217,10 @@ impl DecodeBatchScheduler { _decoder_plugins: Arc, io: Arc, cache: Arc, + // The top-level row ranges that will be scheduled, if known. This lets + // the structural path initialize only the pages those ranges touch. + // `None` preserves `try_new`'s eager initialization behavior. + requested_ranges: Option<&[Range]>, filter: &FilterExpression, decoder_config: &DecoderConfig, ) -> Result { @@ -1034,7 +1248,9 @@ impl DecodeBatchScheduler { strategy.create_structural_field_scheduler(&root_field, &mut column_iter)?; let context = SchedulerContext::new(io, cache.clone()); - root_scheduler.initialize(filter, &context).await?; + root_scheduler + .initialize(requested_ranges, filter, &context) + .await?; Ok(Self { root_scheduler: RootScheduler::Structural(root_scheduler), @@ -1055,27 +1271,27 @@ impl DecodeBatchScheduler { let mut column_iter = ColumnInfoIter::new(columns, &adjusted_column_indices); let strategy = CoreFieldDecoderStrategy::from_decoder_config(decoder_config); let root_scheduler = - strategy.create_legacy_field_scheduler(&root_field, &mut column_iter, buffers)?; + strategy.create_array_field_scheduler(&root_field, &mut column_iter, buffers)?; let context = SchedulerContext::new(io, cache.clone()); root_scheduler.initialize(filter, &context).await?; Ok(Self { - root_scheduler: RootScheduler::Legacy(root_scheduler.into()), + root_scheduler: RootScheduler::Array(root_scheduler.into()), root_fields, cache, }) } } - #[deprecated(since = "0.29.1", note = "This is for legacy 2.0 paths")] + #[deprecated(since = "0.29.1", note = "This is for v2.0 array-encoding paths")] pub fn from_scheduler( - root_scheduler: Arc, + root_scheduler: Arc, root_fields: Fields, cache: Arc, ) -> Self { Self { - root_scheduler: RootScheduler::Legacy(root_scheduler), + root_scheduler: RootScheduler::Array(root_scheduler), root_fields, cache, } @@ -1104,6 +1320,7 @@ impl DecodeBatchScheduler { return; } let next_scan_lines = maybe_next_scan_lines.unwrap(); + context.flush_io(); if next_scan_lines.is_empty() { return; } @@ -1125,7 +1342,7 @@ impl DecodeBatchScheduler { } } - fn do_schedule_ranges_legacy( + fn do_schedule_ranges_array( &mut self, ranges: &[Range], filter: &FilterExpression, @@ -1136,7 +1353,7 @@ impl DecodeBatchScheduler { // tasks are scheduled at the same top level row. priority: Option>, ) { - let root_scheduler = self.root_scheduler.as_legacy(); + let root_scheduler = self.root_scheduler.as_array(); let rows_requested = ranges.iter().map(|r| r.end - r.start).sum::(); trace!( "Scheduling {} ranges across {}..{} ({} rows){}", @@ -1168,6 +1385,7 @@ impl DecodeBatchScheduler { return; } let next_scan_line = maybe_next_scan_line.unwrap(); + context.flush_io(); priority.advance(next_scan_line.rows_scheduled); num_rows_scheduled += next_scan_line.rows_scheduled; rows_to_schedule -= next_scan_line.rows_scheduled; @@ -1200,8 +1418,8 @@ impl DecodeBatchScheduler { priority: Option>, ) { match &self.root_scheduler { - RootScheduler::Legacy(_) => { - self.do_schedule_ranges_legacy(ranges, filter, io, schedule_action, priority) + RootScheduler::Array(_) => { + self.do_schedule_ranges_array(ranges, filter, io, schedule_action, priority) } RootScheduler::Structural(_) => { self.do_schedule_ranges_structural(ranges, filter, io, schedule_action) @@ -1241,7 +1459,7 @@ impl DecodeBatchScheduler { /// * `ranges` - The ranges of rows to load /// * `sink` - A channel to send the decode tasks /// * `scheduler` An I/O scheduler to issue I/O requests - #[instrument(skip_all)] + #[instrument(level = "debug", skip_all)] pub fn schedule_ranges( &mut self, ranges: &[Range], @@ -1277,7 +1495,7 @@ impl DecodeBatchScheduler { /// * `range` - The range of rows to load /// * `sink` - A channel to send the decode tasks /// * `scheduler` An I/O scheduler to issue I/O requests - #[instrument(skip_all)] + #[instrument(level = "debug", skip_all)] pub fn schedule_range( &mut self, range: Range, @@ -1302,29 +1520,22 @@ impl DecodeBatchScheduler { sink: mpsc::UnboundedSender>, scheduler: Arc, ) { - debug_assert!(indices.windows(2).all(|w| w[0] < w[1])); if indices.is_empty() { return; } trace!("Scheduling take of {} rows", indices.len()); - let ranges = Self::indices_to_ranges(indices); - self.schedule_ranges(&ranges, filter, sink, scheduler) - } - - // coalesce continuous indices if possible (the input indices must be sorted and non-empty) - fn indices_to_ranges(indices: &[u64]) -> Vec> { - let mut ranges = Vec::new(); - let mut start = indices[0]; - - for window in indices.windows(2) { - if window[1] != window[0] + 1 { - ranges.push(start..window[0] + 1); - start = window[1]; + let ranges = match RequestedRows::indices_to_ranges(indices) { + Ok(ranges) => ranges, + Err(error) => { + if let Err(SendError { .. }) = sink.send(Err(error)) { + debug!( + "schedule_take could not report invalid indices because the decoder was dropped" + ); + } + return; } - } - - ranges.push(start..*indices.last().unwrap() + 1); - ranges + }; + self.schedule_ranges(&ranges, filter, sink, scheduler) } } @@ -1343,6 +1554,7 @@ pub struct BatchDecodeStream { rows_drained: u64, scheduler_exhausted: bool, emitted_batch_size_warning: Arc, + limit_i32_offset_batch_size: bool, } impl BatchDecodeStream { @@ -1360,6 +1572,16 @@ impl BatchDecodeStream { rows_per_batch: u32, num_rows: u64, root_decoder: SimpleStructDecoder, + ) -> Self { + Self::new_with_i32_offset_limit(scheduled, rows_per_batch, num_rows, root_decoder, true) + } + + fn new_with_i32_offset_limit( + scheduled: mpsc::UnboundedReceiver>, + rows_per_batch: u32, + num_rows: u64, + root_decoder: SimpleStructDecoder, + limit_i32_offset_batch_size: bool, ) -> Self { Self { context: DecoderContext::new(scheduled), @@ -1370,10 +1592,11 @@ impl BatchDecodeStream { rows_drained: 0, scheduler_exhausted: false, emitted_batch_size_warning: Arc::new(Once::new()), + limit_i32_offset_batch_size, } } - fn accept_decoder(&mut self, decoder: crate::previous::decoder::DecoderReady) -> Result<()> { + fn accept_decoder(&mut self, decoder: DecoderReady) -> Result<()> { if decoder.path.is_empty() { // The root decoder we can ignore Ok(()) @@ -1394,7 +1617,7 @@ impl BatchDecodeStream { let scan_line = scan_line?; self.rows_scheduled = scan_line.scheduled_so_far; for message in scan_line.decoders { - self.accept_decoder(message.into_legacy())?; + self.accept_decoder(message.into_array())?; } } None => { @@ -1413,9 +1636,7 @@ impl BatchDecodeStream { async fn next_batch_task(&mut self) -> Result> { trace!( "Draining batch task (rows_remaining={} rows_drained={} rows_scheduled={})", - self.rows_remaining, - self.rows_drained, - self.rows_scheduled, + self.rows_remaining, self.rows_drained, self.rows_scheduled, ); if self.rows_remaining == 0 { return Ok(None); @@ -1425,7 +1646,10 @@ impl BatchDecodeStream { self.rows_remaining -= to_take; let scheduled_need = (self.rows_drained + to_take).saturating_sub(self.rows_scheduled); - trace!("scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", scheduled_need, self.rows_drained, to_take, self.rows_scheduled); + trace!( + "scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", + scheduled_need, self.rows_drained, to_take, self.rows_scheduled + ); if scheduled_need > 0 { let desired_scheduled = scheduled_need + self.rows_scheduled; trace!( @@ -1451,6 +1675,17 @@ impl BatchDecodeStream { ); self.root_decoder.wait_for_loaded(loaded_need).await?; + if self.limit_i32_offset_batch_size && has_i32_offsets(self.root_decoder.data_type()) { + let limit = LogicalPageDecoder::max_rows_to_drain( + &self.root_decoder, + to_take, + I32_OFFSET_BYTE_BUDGET, + )?; + let clamped = clamp_rows_to_i32_budget(limit, to_take); + self.rows_remaining += to_take - clamped; + to_take = clamped; + } + let next_task = self.root_decoder.drain(to_take)?; self.rows_drained += to_take; Ok(Some(next_task)) @@ -1458,33 +1693,41 @@ impl BatchDecodeStream { pub fn into_stream(self) -> BoxStream<'static, ReadBatchTask> { let stream = futures::stream::unfold(self, |mut slf| async move { - let next_task = slf.next_batch_task().await; - 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 task = async move { - let next_task = next_task?; - // 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. + let next_task = match slf.next_batch_task().await { + Ok(Some(next_task)) => next_task, + Ok(None) => return None, + Err(err) => { + slf.rows_remaining = 0; + return Some(( + ReadBatchTask { + task: async move { Err(err) }.boxed(), + num_rows: 0, + }, + slf, + )); + } + }; + let num_rows = next_task.num_rows; + let emitted_batch_size_warning = slf.emitted_batch_size_warning.clone(); + let task = async move { + // 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. + let (batch, _data_size) = tokio::spawn(async move { next_task.into_batch(emitted_batch_size_warning) }) .await - .map_err(|err| Error::Wrapped { - error: err.into(), - location: location!(), - })? - }; - (task, num_rows) - }); - next_task.map(|(task, num_rows)| { - // This should be true since batch size is u32 - debug_assert!(num_rows <= u32::MAX as u64); - let next_task = ReadBatchTask { + .map_err(|err| Error::wrapped(err.into()))??; + Ok(batch) + }; + // This should be true since batch size is u32 + debug_assert!(num_rows <= u32::MAX as u64); + Some(( + ReadBatchTask { task: task.boxed(), num_rows: num_rows as u32, - }; - (next_task, slf) - }) + }, + slf, + )) }); stream.boxed() } @@ -1494,12 +1737,20 @@ impl BatchDecodeStream { // we can have a single implementation of the batch decode iterator enum RootDecoderMessage { LoadedPage(LoadedPageShard), - LegacyPage(crate::previous::decoder::DecoderReady), + ArrayPage(DecoderReady), } trait RootDecoderType { fn accept_message(&mut self, message: RootDecoderMessage) -> Result<()>; fn drain_batch(&mut self, num_rows: u64) -> Result; fn wait(&mut self, loaded_need: u64, runtime: &tokio::runtime::Runtime) -> Result<()>; + /// See [`LogicalPageDecoder::max_rows_to_drain`]. Decoders without i32-offset + /// accounting place no limit. + fn max_rows_to_drain(&self, num_rows: u64, _byte_budget: u64) -> Result { + Ok(DrainLimit { + rows: num_rows, + bytes: 0, + }) + } } impl RootDecoderType for StructuralStructDecoder { fn accept_message(&mut self, message: RootDecoderMessage) -> Result<()> { @@ -1518,10 +1769,10 @@ impl RootDecoderType for StructuralStructDecoder { } impl RootDecoderType for SimpleStructDecoder { fn accept_message(&mut self, message: RootDecoderMessage) -> Result<()> { - let RootDecoderMessage::LegacyPage(legacy_page) = message else { + let RootDecoderMessage::ArrayPage(array_page) = message else { unreachable!() }; - self.accept_child(legacy_page) + self.accept_child(array_page) } fn drain_batch(&mut self, num_rows: u64) -> Result { self.drain(num_rows) @@ -1529,6 +1780,15 @@ impl RootDecoderType for SimpleStructDecoder { fn wait(&mut self, loaded_need: u64, runtime: &tokio::runtime::Runtime) -> Result<()> { runtime.block_on(self.wait_for_loaded(loaded_need)) } + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + if !has_i32_offsets(LogicalPageDecoder::data_type(self)) { + return Ok(DrainLimit { + rows: num_rows, + bytes: 0, + }); + } + LogicalPageDecoder::max_rows_to_drain(self, num_rows, byte_budget) + } } /// A blocking batch decoder that performs synchronous decoding @@ -1589,7 +1849,7 @@ impl BatchDecodeIterator { /// /// Note that `scheduled_need` is cumulative. E.g. this method /// should be called with 5, 10, 15 and not 5, 5, 5 - #[instrument(skip_all)] + #[instrument(level = "debug", skip_all)] fn wait_for_io(&mut self, scheduled_need: u64, to_take: u64) -> Result { while self.rows_scheduled < scheduled_need && !self.messages.is_empty() { let message = self.messages.pop_front().unwrap()?; @@ -1605,7 +1865,7 @@ impl BatchDecodeIterator { // The root decoder we can ignore if !decoder_ready.path.is_empty() { self.root_decoder - .accept_message(RootDecoderMessage::LegacyPage(decoder_ready))?; + .accept_message(RootDecoderMessage::ArrayPage(decoder_ready))?; } } } @@ -1623,9 +1883,7 @@ impl BatchDecodeIterator { fn next_batch_task(&mut self) -> Result> { trace!( "Draining batch task (rows_remaining={} rows_drained={} rows_scheduled={})", - self.rows_remaining, - self.rows_drained, - self.rows_scheduled, + self.rows_remaining, self.rows_drained, self.rows_scheduled, ); if self.rows_remaining == 0 { return Ok(None); @@ -1635,7 +1893,10 @@ impl BatchDecodeIterator { self.rows_remaining -= to_take; let scheduled_need = (self.rows_drained + to_take).saturating_sub(self.rows_scheduled); - trace!("scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", scheduled_need, self.rows_drained, to_take, self.rows_scheduled); + trace!( + "scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", + scheduled_need, self.rows_drained, to_take, self.rows_scheduled + ); if scheduled_need > 0 { let desired_scheduled = scheduled_need + self.rows_scheduled; trace!( @@ -1653,11 +1914,18 @@ impl BatchDecodeIterator { return Ok(None); } + let limit = self + .root_decoder + .max_rows_to_drain(to_take, I32_OFFSET_BYTE_BUDGET)?; + let clamped = clamp_rows_to_i32_budget(limit, to_take); + self.rows_remaining += to_take - clamped; + to_take = clamped; + let next_task = self.root_decoder.drain_batch(to_take)?; 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)) } @@ -1667,9 +1935,12 @@ impl Iterator for BatchDecodeIterator { type Item = ArrowResult; fn next(&mut self) -> Option { - self.next_batch_task() - .transpose() - .map(|r| r.map_err(ArrowError::from)) + let next = self.next_batch_task().transpose(); + if let Some(Err(_)) = &next { + // Fuse after an error instead of re-attempting every remaining batch + self.rows_remaining = 0; + } + next.map(|r| r.map_err(ArrowError::from)) } } @@ -1679,6 +1950,44 @@ 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. +/// Returns a schema-based estimate of the decoded bytes per row for `data_type`. +/// +/// Fixed-width types are exact. Variable-width types (strings, lists, etc.) use +/// heuristic constants. This estimate is used both in batch-size planning and as +/// a fallback for V1 files that lack structural decoders. +pub 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, @@ -1689,6 +1998,23 @@ pub struct StructuralBatchDecodeStream { rows_drained: u64, scheduler_exhausted: bool, emitted_batch_size_warning: Arc, + // Decode scheduling policy selected at planning time. + // + // Performance tradeoff: + // - true: spawn `into_batch` onto Tokio, which improves scan throughput by allowing + // more decode parallelism. + // - 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 while retaining `rows_per_batch` + /// as an independent upper bound. + 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 { @@ -1706,7 +2032,14 @@ impl StructuralBatchDecodeStream { rows_per_batch: u32, 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, @@ -1716,6 +2049,10 @@ impl StructuralBatchDecodeStream { rows_drained: 0, 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)), } } @@ -1752,19 +2089,32 @@ impl StructuralBatchDecodeStream { async fn next_batch_task(&mut self) -> Result> { trace!( "Draining batch task (rows_remaining={} rows_drained={} rows_scheduled={})", - self.rows_remaining, - self.rows_drained, - self.rows_scheduled, + self.rows_remaining, self.rows_drained, self.rows_scheduled, ); if self.rows_remaining == 0 { return Ok(None); } - let mut to_take = self.rows_remaining.min(self.rows_per_batch as u64); + let row_limit = 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; + row_limit.min(rows.max(1)) + } else { + row_limit + }; self.rows_remaining -= to_take; let scheduled_need = (self.rows_drained + to_take).saturating_sub(self.rows_scheduled); - trace!("scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", scheduled_need, self.rows_drained, to_take, self.rows_scheduled); + trace!( + "scheduled_need = {} because rows_drained = {} and to_take = {} and rows_scheduled = {}", + scheduled_need, self.rows_drained, to_take, self.rows_scheduled + ); if scheduled_need > 0 { let desired_scheduled = scheduled_need + self.rows_scheduled; trace!( @@ -1789,25 +2139,60 @@ impl StructuralBatchDecodeStream { pub fn into_stream(self) -> BoxStream<'static, ReadBatchTask> { let stream = futures::stream::unfold(self, |mut slf| async move { - let next_task = slf.next_batch_task().await; - 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 task = async move { - let next_task = next_task?; - async move { next_task.into_batch(emitted_batch_size_warning) }.await + let next_task = match slf.next_batch_task().await { + Ok(Some(next_task)) => next_task, + Ok(None) => return None, + Err(err) => { + slf.rows_remaining = 0; + return Some(( + ReadBatchTask { + task: async move { Err(err) }.boxed(), + num_rows: 0, + }, + slf, + )); + } + }; + let num_rows = next_task.num_rows; + 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 (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()))?? + } else { + next_task.into_batch(emitted_batch_size_warning)? }; - (task, num_rows) - }); - next_task.map(|(task, num_rows)| { - // This should be true since batch size is u32 - debug_assert!(num_rows <= u32::MAX as u64); - let next_task = ReadBatchTask { + let num_rows = batch.num_rows() as u64; + if let Some(bpr) = data_size.checked_div(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) + }; + // This should be true since batch size is u32 + debug_assert!(num_rows <= u32::MAX as u64); + Some(( + ReadBatchTask { task: task.boxed(), num_rows: num_rows as u32, - }; - (next_task, slf) - }) + }, + slf, + )) }); stream.boxed() } @@ -1833,15 +2218,89 @@ impl RequestedRows { } self } + + fn into_ranges(self) -> Result>> { + match self { + Self::Ranges(ranges) => Ok(ranges), + Self::Indices(indices) => Self::indices_to_ranges(&indices), + } + } + + // coalesce continuous indices if possible (the input indices must be sorted) + fn indices_to_ranges(indices: &[u64]) -> Result>> { + if indices.is_empty() { + return Ok(Vec::new()); + } + + let mut ranges = Vec::with_capacity(indices.len()); + let mut start = indices[0]; + + for (index, pair) in indices.windows(2).enumerate() { + if pair[0] >= pair[1] { + return Err(Error::invalid_input(format!( + "Requested row indices are not strictly increasing at index {index}: {} then {}", + pair[0], pair[1] + ))); + } + let end = pair[0] + .checked_add(1) + .ok_or_else(|| Error::invalid_input("Requested row index cannot equal u64::MAX"))?; + if pair[1] != end { + ranges.push(start..end); + start = pair[1]; + } + } + + let end = indices + .last() + .copied() + .and_then(|index| index.checked_add(1)) + .ok_or_else(|| Error::invalid_input("Requested row index cannot equal u64::MAX"))?; + ranges.push(start..end); + Ok(ranges) + } } /// Configuration for decoder behavior -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct DecoderConfig { - /// Whether to cache repetition indices for better performance + /// Whether to cache repetition indices for better performance. + /// + /// This defaults to the `LANCE_READ_CACHE_REPETITION_INDEX` environment variable + /// when present and is enabled by default. Set the env var to a non-truthy + /// value (for example `0` or `false`) to disable it. The env var is read + /// once per process. pub cache_repetition_index: bool, /// Whether to validate decoded data pub validate_on_decode: bool, + /// Override the strategy used to dispatch the scheduling work in + /// [`schedule_and_decode`]. + /// + /// `schedule_and_decode` always awaits the scheduler's `initialize` (which + /// performs metadata I/O) before returning. This flag controls what + /// happens with the subsequent (synchronous) work of pushing decoder + /// messages into the channel that feeds the decode stream. + /// + /// * `None` - default behavior: the scheduling work runs inline (as part + /// of the `schedule_and_decode` await) when the request is small + /// (controlled by the `LANCE_INLINE_SCHEDULING_THRESHOLD` env var) and + /// is dispatched onto a spawned task otherwise. + /// * `Some(true)` - always run scheduling inline. The await of + /// `schedule_and_decode` does not return until every decoder message + /// has been queued. + /// * `Some(false)` - always spawn a task for scheduling so that it can + /// overlap with consumption of the decode stream. + pub inline_scheduling: Option, +} + +impl Default for DecoderConfig { + fn default() -> Self { + Self { + cache_repetition_index: default_cache_repetition_index(), + validate_on_decode: false, + inline_scheduling: None, + } + } } #[derive(Debug, Clone)] @@ -1852,6 +2311,12 @@ pub struct SchedulerDecoderConfig { pub cache: Arc, /// Decoder configuration pub decoder_config: DecoderConfig, + /// If set, target this many bytes per batch while retaining `batch_size` as + /// an independent row-count upper bound. + /// + /// 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( @@ -1861,6 +2326,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(); @@ -1871,16 +2337,56 @@ 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() } +#[allow(clippy::too_many_arguments)] pub fn create_decode_stream( schema: &Schema, num_rows: u64, batch_size: u32, is_structural: bool, should_validate: bool, + spawn_structural_batch_decode_tasks: bool, + rx: mpsc::UnboundedReceiver>, + batch_size_bytes: Option, +) -> Result> { + create_decode_stream_with_i32_offset_limit( + schema, + num_rows, + batch_size, + is_structural, + should_validate, + spawn_structural_batch_decode_tasks, + rx, + batch_size_bytes, + true, + ) +} + +#[allow(clippy::too_many_arguments)] +fn create_decode_stream_with_i32_offset_limit( + schema: &Schema, + num_rows: u64, + batch_size: u32, + is_structural: bool, + should_validate: bool, + spawn_structural_batch_decode_tasks: bool, rx: mpsc::UnboundedReceiver>, + batch_size_bytes: Option, + limit_i32_offset_batch_size: bool, ) -> Result> { if is_structural { let arrow_schema = ArrowSchema::from(schema); @@ -1888,17 +2394,33 @@ pub fn create_decode_stream( arrow_schema.fields, should_validate, /*is_root=*/ true, + /*nullable=*/ false, )?; - Ok( - StructuralBatchDecodeStream::new(rx, batch_size, num_rows, structural_decoder) - .into_stream(), + Ok(StructuralBatchDecodeStream::new( + rx, + batch_size, + 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; let simple_struct_decoder = SimpleStructDecoder::new(root_fields, num_rows); - Ok(BatchDecodeStream::new(rx, batch_size, num_rows, simple_struct_decoder).into_stream()) + Ok(BatchDecodeStream::new_with_i32_offset_limit( + rx, + batch_size, + num_rows, + simple_struct_decoder, + limit_i32_offset_batch_size, + ) + .into_stream()) } } @@ -1916,8 +2438,12 @@ pub fn create_decode_iterator( let arrow_schema = Arc::new(ArrowSchema::from(schema)); let root_fields = arrow_schema.fields.clone(); if is_structural { - let simple_struct_decoder = - StructuralStructDecoder::new(root_fields, should_validate, /*is_root=*/ true)?; + let simple_struct_decoder = StructuralStructDecoder::new( + root_fields, + should_validate, + /*is_root=*/ true, + /*nullable=*/ false, + )?; Ok(Box::new(BatchDecodeIterator::new( messages, batch_size, @@ -1937,7 +2463,7 @@ pub fn create_decode_iterator( } } -fn create_scheduler_decoder( +async fn create_scheduler_decoder( column_infos: Vec>, requested_rows: RequestedRows, filter: FilterExpression, @@ -1946,8 +2472,15 @@ fn create_scheduler_decoder( config: SchedulerDecoderConfig, ) -> Result> { let num_rows = requested_rows.num_rows(); + let is_range_request = matches!(&requested_rows, RequestedRows::Ranges(_)); let is_structural = column_infos[0].is_structural(); + let mode = std::env::var(ENV_LANCE_STRUCTURAL_BATCH_DECODE_SPAWN_MODE); + let spawn_structural_batch_decode_tasks = match mode.ok().as_deref() { + Some("always") => true, + Some("never") => false, + _ => is_range_request, + }; let (tx, rx) = mpsc::unbounded_channel(); @@ -1957,59 +2490,81 @@ fn create_scheduler_decoder( config.batch_size, is_structural, config.decoder_config.validate_on_decode, + spawn_structural_batch_decode_tasks, rx, + config.batch_size_bytes, )?; - let scheduler_handle = tokio::task::spawn(async move { - let mut decode_scheduler = match DecodeBatchScheduler::try_new( - target_schema.as_ref(), - &column_indices, - &column_infos, - &vec![], - num_rows, - config.decoder_plugins, - config.io.clone(), - config.cache, - &filter, - &config.decoder_config, - ) - .await - { - Ok(scheduler) => scheduler, - Err(e) => { - let _ = tx.send(Err(e)); - return; - } - }; - - match requested_rows { - RequestedRows::Ranges(ranges) => { - decode_scheduler.schedule_ranges(&ranges, &filter, tx, config.io) - } - RequestedRows::Indices(indices) => { - decode_scheduler.schedule_take(&indices, &filter, tx, config.io) - } - } - }); + // The scheduler's `initialize` may perform I/O to load column metadata + // unless that metadata is already in the cache. This metadata loading + // happens as part of this call and should be parallelized if reading + // multiple files. We pass the rows we are about to schedule so that the + // structural path only initializes the pages those rows touch. + let requested_ranges = requested_rows.into_ranges()?; + let mut decode_scheduler = DecodeBatchScheduler::try_new_with_ranges( + target_schema.as_ref(), + &column_indices, + &column_infos, + &vec![], + num_rows, + config.decoder_plugins, + config.io.clone(), + config.cache, + Some(&requested_ranges), + &filter, + &config.decoder_config, + ) + .await?; - Ok(check_scheduler_on_drop(decode_stream, scheduler_handle)) + // For small requests the scheduling cost is dwarfed by the overhead of + // spawning a task, so we run scheduling inline (still as part of this + // await) before returning. The threshold is configurable via + // `LANCE_INLINE_SCHEDULING_THRESHOLD`, and callers can force either + // strategy via `DecoderConfig::inline_scheduling`. + let inline_scheduling = config + .decoder_config + .inline_scheduling + .unwrap_or_else(|| num_rows <= inline_scheduling_threshold()); + + if inline_scheduling { + decode_scheduler.schedule_ranges(&requested_ranges, &filter, tx, config.io); + Ok(decode_stream) + } else { + // Spawn the (still synchronous) scheduling work so that decoder + // messages can stream into the channel while the consumer is + // already pulling from the decode stream. + let scheduling = async move { + decode_scheduler.schedule_ranges(&requested_ranges, &filter, tx, config.io) + }; + let scheduler_handle = tokio::task::spawn(scheduling); + Ok(check_scheduler_on_drop(decode_stream, scheduler_handle)) + } } -/// Launches a scheduler on a dedicated (spawned) task and creates a decoder to -/// decode the scheduled data and returns the decoder as a stream of record batches. +/// Initializes the scheduler, schedules the requested rows, and returns a +/// stream of decode tasks for the resulting batches. +/// +/// This is a convenience function that creates both the scheduler and the +/// decoder, which can be a little tricky to get right. +/// +/// # Why is this async? /// -/// This is a convenience function that creates both the scheduler and the decoder -/// which can be a little tricky to get right. -pub fn schedule_and_decode( +/// Constructing the scheduler runs `initialize` which will perform I/O +/// unless the data required is already in the file metadata cache. +/// +/// When `DecoderConfig::inline_scheduling` resolves to `true`, the +/// subsequent (synchronous) scheduling work also runs before this function +/// returns, leaving a fully primed decode stream. +pub async fn schedule_and_decode( column_infos: Vec>, requested_rows: RequestedRows, filter: FilterExpression, column_indices: Vec, target_schema: Arc, config: SchedulerDecoderConfig, -) -> BoxStream<'static, ReadBatchTask> { +) -> Result> { if requested_rows.num_rows() == 0 { - return stream::empty().boxed(); + return Ok(stream::empty().boxed()); } // If the user requested any ranges that are empty, ignore them. They are pointless and @@ -2018,27 +2573,19 @@ pub fn schedule_and_decode( let io = config.io.clone(); - // For convenience we really want this method to be a snchronous method where all - // errors happen on the stream. There is some async initialization that must happen - // when creating a scheduler. We wrap that all up in the very first task. - match create_scheduler_decoder( + let stream = create_scheduler_decoder( column_infos, requested_rows, filter, column_indices, target_schema, config, - ) { - // Keep the io alive until the stream is dropped or finishes. Otherwise the - // I/O drops as soon as the scheduling is finished and the I/O loop terminates. - Ok(stream) => stream.finally(move || drop(io)).boxed(), - // If the initialization failed make it look like a failed task - Err(e) => stream::once(std::future::ready(ReadBatchTask { - num_rows: 0, - task: std::future::ready(Err(e)).boxed(), - })) - .boxed(), - } + ) + .await?; + + // Keep the io alive until the stream is dropped or finishes. Otherwise the + // I/O drops as soon as the scheduling is finished and the I/O loop terminates. + Ok(stream.finally(move || drop(io)).boxed()) } pub static WAITER_RT: LazyLock = LazyLock::new(|| { @@ -2074,14 +2621,17 @@ pub fn schedule_and_decode_blocking( return Ok(Box::new(RecordBatchIterator::new(vec![], arrow_schema))); } + let requested_rows = requested_rows.trim_empty_ranges(); let num_rows = requested_rows.num_rows(); let is_structural = column_infos[0].is_structural(); let (tx, mut rx) = mpsc::unbounded_channel(); // Initialize the scheduler. This is still "asynchronous" but we run it with a current-thread - // runtime. - let mut decode_scheduler = WAITER_RT.block_on(DecodeBatchScheduler::try_new( + // runtime. Pass the rows we are about to schedule so the structural path + // only initializes the pages those rows touch. + let requested_ranges = requested_rows.into_ranges()?; + let mut decode_scheduler = WAITER_RT.block_on(DecodeBatchScheduler::try_new_with_ranges( target_schema.as_ref(), &column_indices, &column_infos, @@ -2090,19 +2640,13 @@ pub fn schedule_and_decode_blocking( config.decoder_plugins, config.io.clone(), config.cache, + Some(&requested_ranges), &filter, &config.decoder_config, ))?; // Schedule the requested rows - match requested_rows { - RequestedRows::Ranges(ranges) => { - decode_scheduler.schedule_ranges(&ranges, &filter, tx, config.io) - } - RequestedRows::Indices(indices) => { - decode_scheduler.schedule_take(&indices, &filter, tx, config.io) - } - } + decode_scheduler.schedule_ranges(&requested_ranges, &filter, tx, config.io); // Drain the scheduler queue into a vec of decode messages let mut messages = Vec::new(); @@ -2170,6 +2714,16 @@ pub trait PrimitivePageDecoder: Send + Sync { /// * `num_rows` - how many rows to decode /// * `all_null` - A mutable bool, set to true if a decoder determines all values are null fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result; + + /// Returns the variable-width **value** bytes (the bytes an offsets buffer + /// indexes into) that decoding `num_rows` rows starting at `rows_to_skip` + /// will produce, or `None` when the encoding cannot report sizes without + /// decoding. An upper bound is acceptable; callers use this to keep i32 + /// offset values within capacity, so over-estimating only splits batches + /// earlier while under-estimating would let them overflow. + fn variable_width_bytes(&self, _rows_to_skip: u64, _num_rows: u64) -> Result> { + Ok(None) + } } /// A scheduler for single-column encodings of primitive data @@ -2308,9 +2862,212 @@ impl PriorityRange for ListPriorityRange { } /// Contains the context for a scheduler +/// The writer accumulates about this many bytes of values per column before it +/// cuts pages (`EncodingOptions::cache_bytes_per_column`), and the rep/def +/// budget of a mini-block chunk then splits that block into many small pages +/// for nested columns. Batching reads back up to this size restores one +/// I/O request per accumulated block. +pub(crate) const IO_REQUEST_BATCH_BYTES: u64 = 8 * 1024 * 1024; + +type SharedRead = Shared>, CloneableError>>>; + +/// What a pending read receives once its batch is submitted. +enum SubmittedRead { + /// The read was alone in its batch and owns the request. + Alone(BoxFuture<'static, Result>>), + /// The batched request and the position of each of the read's ranges in it. + Shared(SharedRead, Vec), +} + +struct PendingRead { + ranges: Vec>, + priority: u64, + tx: oneshot::Sender, +} + +/// Collects the reads submitted while one scheduling step runs and sends them +/// to the I/O layer as a single request. +/// +/// The I/O scheduler only coalesces the ranges of one request, so the reads a +/// step issues for different pages, or for different columns, cost one request +/// each even when they sit next to each other in the file. A take of a few rows +/// reads a few bytes per column, and a file's small pages are packed together, +/// so batching them turns dozens of requests into a handful. +/// +/// A batch is submitted by [`Self::flush`] when the step ends, as soon as the +/// queued reads reach [`IO_REQUEST_BATCH_BYTES`] (a page that shards its own +/// reads to bound buffering keeps every shard in a bounded read), or when a +/// caller awaits one of its reads, so a read queued outside a step (indirect +/// I/O issued from a load future) never waits for a flush that does not come. +/// That last flush waits for one yield first: sibling futures polled in the +/// same pass (a struct's columns initializing their pages, a scan line's list +/// columns fetching their items) all queue their reads before the first of +/// them resumes and submits the lot. +pub(crate) struct RequestBatch { + inner: Arc, + pending: Arc>>, +} + +impl std::fmt::Debug for RequestBatch { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RequestBatch").finish_non_exhaustive() + } +} + +impl RequestBatch { + pub(crate) fn new(inner: Arc) -> Self { + Self { + inner, + pending: Arc::new(Mutex::new(Vec::new())), + } + } + + fn queued_bytes(pending: &[PendingRead]) -> u64 { + pending + .iter() + .flat_map(|read| &read.ranges) + .map(|range| range.end - range.start) + .sum() + } + + /// Bytes requested by the reads collected so far. + pub(crate) fn pending_bytes(&self) -> u64 { + Self::queued_bytes(&self.pending.lock().unwrap()) + } + + /// Submits the collected reads as one request. + pub(crate) fn flush(&self) { + Self::flush_pending(&self.inner, &self.pending); + } + + fn flush_pending(inner: &Arc, pending: &Mutex>) { + let pending = std::mem::take(&mut *pending.lock().unwrap()); + Self::submit_batch(inner, pending); + } + + /// Submits `pending` as one request, sorted by file offset, and hands each + /// read its own slice of the result. + fn submit_batch(inner: &Arc, pending: Vec) { + if pending.is_empty() { + return; + } + if pending.len() == 1 { + let read = pending.into_iter().next().unwrap(); + let _ = read.tx.send(SubmittedRead::Alone( + inner.submit_request(read.ranges, read.priority), + )); + return; + } + // The lowest row number any of the batched reads delivers data for. + let priority = pending.iter().map(|read| read.priority).min().unwrap(); + let mut ordered = pending + .iter() + .enumerate() + .flat_map(|(read_idx, read)| { + read.ranges + .iter() + .enumerate() + .map(move |(range_idx, range)| (range.clone(), read_idx, range_idx)) + }) + .collect::>(); + ordered.sort_by_key(|(range, _, _)| (range.start, range.end)); + let ranges = ordered + .iter() + .map(|(range, _, _)| range.clone()) + .collect::>(); + // For every read, where each of its ranges landed in the sorted request. + let mut positions = pending + .iter() + .map(|read| vec![0; read.ranges.len()]) + .collect::>(); + for (position, (_, read_idx, range_idx)) in ordered.iter().enumerate() { + positions[*read_idx][*range_idx] = position; + } + let batched: SharedRead = inner + .submit_request(ranges, priority) + .map(|result| result.map(Arc::new).map_err(CloneableError)) + .boxed() + .shared(); + for (read, read_positions) in pending.into_iter().zip(positions) { + // The receiver is gone only when the read's future was dropped. + let _ = read + .tx + .send(SubmittedRead::Shared(batched.clone(), read_positions)); + } + } +} + +impl EncodingsIo for RequestBatch { + fn submit_request( + &self, + ranges: Vec>, + priority: u64, + ) -> BoxFuture<'static, Result>> { + let mut pending = self.pending.lock().unwrap(); + // Keep every batch within the budget: a page that shards its reads + // must not have all its shards collapse into one unbounded read. + let bytes = ranges + .iter() + .map(|range| range.end - range.start) + .sum::(); + if !pending.is_empty() && Self::queued_bytes(&pending) + bytes > IO_REQUEST_BATCH_BYTES { + Self::submit_batch(&self.inner, std::mem::take(&mut *pending)); + } + let (tx, mut rx) = oneshot::channel(); + pending.push(PendingRead { + ranges, + priority, + tx, + }); + drop(pending); + let inner = self.inner.clone(); + let pending = self.pending.clone(); + async move { + let submitted = match rx.try_recv() { + // The step that queued this read already flushed it. + Ok(Some(submitted)) => submitted, + _ => { + // Awaiting a read is the end of whatever step queued it. + // Yield once so the siblings polled in the same pass get + // to queue too, then submit them all. + tokio::task::yield_now().await; + Self::flush_pending(&inner, &pending); + rx.await.map_err(|_| { + Error::internal("a batched read was dropped before the batch was submitted") + })? + } + }; + match submitted { + SubmittedRead::Alone(request) => request.await, + SubmittedRead::Shared(batched, positions) => { + let bytes = batched.await.map_err(|err| err.0)?; + Ok(positions + .into_iter() + .map(|position| bytes[position].clone()) + .collect()) + } + } + } + .boxed() + } + + fn with_bypass_backpressure(&self) -> Option> { + self.inner.with_bypass_backpressure() + } + + fn with_io_stats( + &self, + stats: Arc, + ) -> Option> { + self.inner.with_io_stats(stats) + } +} + pub struct SchedulerContext { recv: Option>, io: Arc, + /// `io`, as the batch that collects one scheduling step's reads. + batch: Arc, cache: Arc, name: String, path: Vec, @@ -2330,8 +3087,10 @@ impl<'a> ScopedSchedulerContext<'a> { impl SchedulerContext { pub fn new(io: Arc, cache: Arc) -> Self { + let batch = Arc::new(RequestBatch::new(io)); Self { - io, + io: batch.clone(), + batch, cache, recv: None, name: "".to_string(), @@ -2344,6 +3103,11 @@ impl SchedulerContext { &self.io } + /// Submits the reads the schedulers queued since the last step as one request. + pub fn flush_io(&self) { + self.batch.flush(); + } + pub fn cache(&self) -> &Arc { &self.cache } @@ -2372,17 +3136,14 @@ impl SchedulerContext { VecDeque::from_iter(self.path.iter().copied()) } - #[deprecated(since = "0.29.1", note = "This is for legacy 2.0 paths")] - pub fn locate_decoder( - &mut self, - decoder: Box, - ) -> crate::previous::decoder::DecoderReady { + #[deprecated(since = "0.29.1", note = "This is for v2.0 array-encoding paths")] + pub fn locate_decoder(&mut self, decoder: Box) -> DecoderReady { trace!( "Scheduling decoder of type {:?} for {:?}", decoder.data_type(), self.path, ); - crate::previous::decoder::DecoderReady { + DecoderReady { decoder, path: self.current_path(), } @@ -2438,8 +3199,14 @@ impl FilterExpression { } pub trait StructuralFieldScheduler: Send + std::fmt::Debug { + /// Loads the per-page metadata this column needs before scheduling. + /// + /// When `requested_ranges` is present, only pages overlapping those + /// top-level row ranges are initialized. `None` initializes the complete + /// field. fn initialize<'a>( &'a mut self, + requested_ranges: Option<&'a [Range]>, filter: &'a FilterExpression, context: &'a SchedulerContext, ) -> BoxFuture<'a, Result<()>>; @@ -2452,13 +3219,15 @@ 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)> { + let decoded_array = StructuralDecodeArrayTask::decode(*self)?; + decoded_array.repdef.ensure_exhausted()?; + Ok((decoded_array.array, decoded_array.data_size)) } } @@ -2479,28 +3248,16 @@ 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 { - message: format!("Error decoding batch: {}", e), - location: location!(), - }; - Err(e) - } + fn into_batch(self, emitted_batch_size_warning: Arc) -> Result<(RecordBatch, u64)> { + let (struct_arr, data_size) = self.task.decode()?; + 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)) } } @@ -2513,7 +3270,7 @@ pub enum MessageType { // decoder itself. The messages were not sent in priority order and the decoder // had to wait for I/O, figuring out the correct priority. This was a lot of // complexity. - DecoderReady(crate::previous::decoder::DecoderReady), + DecoderReady(DecoderReady), // Starting in 2.1 we use a simpler scheme where the scheduling happens in priority // order and the message is an unloaded decoder. These can be awaited, in order, and // the decoder does not have to worry about waiting for I/O. @@ -2521,7 +3278,7 @@ pub enum MessageType { } impl MessageType { - pub fn into_legacy(self) -> crate::previous::decoder::DecoderReady { + pub fn into_array(self) -> DecoderReady { match self { Self::DecoderReady(decoder) => decoder, Self::UnloadedPage(_) => { @@ -2568,6 +3325,13 @@ pub trait DecodePageTask: Send + std::fmt::Debug { pub trait StructuralPageDecoder: std::fmt::Debug + Send { fn drain(&mut self, num_rows: u64) -> Result>; fn num_rows(&self) -> u64; + /// Returns the exact decoded byte count for the next `num_rows` rows + /// from this decoder's current position, without consuming any rows. + fn decoded_bytes(&self, _num_rows: u64) -> Result { + Err(Error::not_supported( + "decoded_bytes is not implemented for this page decoder".to_string(), + )) + } } #[derive(Debug)] @@ -2598,6 +3362,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 { @@ -2614,18 +3380,41 @@ pub trait StructuralFieldDecoder: std::fmt::Debug + Send { fn drain(&mut self, num_rows: u64) -> Result>; /// The data type of the decoded data fn data_type(&self) -> &DataType; + /// Returns the exact decoded byte count for each of [`CANDIDATE_BATCH_SIZES`] + /// row counts, clamped to `rows_remaining`. + /// + /// Implementations should do their best to estimate the exact size required for + /// the uncompressed data. In cases where this is not possible they should return + /// a worst-case estimate. + /// + /// The default implementation simply returns a "not supported" error though this + /// will hopefully be removed once implementation is complete. + fn plan_decoded_bytes(&self, _rows_remaining: u64) -> Result<[u64; 8]> { + Err(Error::not_supported( + "decoded_bytes is not implemented for this field decoder".to_string(), + )) + } } #[derive(Debug, Default)] pub struct DecoderPlugins {} +/// The top-level column layout used by an in-memory encoded batch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncodedBatchLayout { + /// Array pages include structural columns. + Array, + /// Structural pages include only leaf columns. + Structural, +} + /// Decodes a batch of data from an in-memory structure created by [`crate::encoder::encode_batch`] pub async fn decode_batch( batch: &EncodedBatch, filter: &FilterExpression, decoder_plugins: Arc, should_validate: bool, - version: LanceFileVersion, + layout: EncodedBatchLayout, cache: Option>, ) -> Result { // The io is synchronous so it shouldn't be possible for any async stuff to still be in progress @@ -2655,14 +3444,21 @@ pub async fn decode_batch( .await?; let (tx, rx) = unbounded_channel(); decode_scheduler.schedule_range(0..batch.num_rows, filter, tx, io_scheduler); - let is_structural = version >= LanceFileVersion::V2_1; - let mut decode_stream = create_decode_stream( + let is_structural = layout == EncodedBatchLayout::Structural; + let mode = std::env::var(ENV_LANCE_STRUCTURAL_BATCH_DECODE_SPAWN_MODE); + let spawn_structural_batch_decode_tasks = !matches!(mode.ok().as_deref(), Some("never")); + // `EncodedBatch` is created from one RecordBatch, so every i32-offset array is + // already known to fit in one Arrow array. Preserve the single-batch contract here. + let mut decode_stream = create_decode_stream_with_i32_offset_limit( &batch.schema, batch.num_rows, batch.num_rows as u32, is_structural, should_validate, + spawn_structural_batch_decode_tasks, rx, + None, + false, )?; decode_stream.next().await.unwrap().task.await } @@ -2671,25 +3467,737 @@ pub async fn decode_batch( // test coalesce indices to ranges mod tests { use super::*; + use arrow_array::StringArray; + use std::collections::VecDeque; + + struct StaticArrayDecodeTask(ArrayRef); + + impl DecodeArrayTask for StaticArrayDecodeTask { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { + Ok((self.0, 0)) + } + } + + #[derive(Debug)] + struct StaticArrayPageDecoder { + array: ArrayRef, + rows_drained: u64, + /// Pretend each row decodes to this many bytes instead of its real size, + /// so budget exhaustion can be tested without gigabyte allocations. + fake_bytes_per_row: Option, + } + + impl StaticArrayPageDecoder { + fn new(values: &[&str]) -> Self { + Self { + array: Arc::new(StringArray::from(values.to_vec())), + rows_drained: 0, + fake_bytes_per_row: None, + } + } + + fn with_fake_bytes_per_row(values: &[&str], fake_bytes_per_row: u64) -> Self { + Self { + fake_bytes_per_row: Some(fake_bytes_per_row), + ..Self::new(values) + } + } + + fn row_bytes(&self, row: usize) -> u64 { + self.fake_bytes_per_row.unwrap_or_else(|| { + self.array + .as_any() + .downcast_ref::() + .unwrap() + .value(row) + .len() as u64 + }) + } + } + + impl LogicalPageDecoder for StaticArrayPageDecoder { + fn wait_for_loaded(&mut self, _loaded_need: u64) -> BoxFuture<'_, Result<()>> { + std::future::ready(Ok(())).boxed() + } + + fn rows_loaded(&self) -> u64 { + self.array.len() as u64 + } + + fn num_rows(&self) -> u64 { + self.array.len() as u64 + } + + fn rows_drained(&self) -> u64 { + self.rows_drained + } + + fn drain(&mut self, num_rows: u64) -> Result { + let array = self + .array + .slice(self.rows_drained as usize, num_rows as usize); + self.rows_drained += num_rows; + Ok(NextDecodeTask { + task: Box::new(StaticArrayDecodeTask(array)), + num_rows, + }) + } + + fn max_rows_to_drain(&self, num_rows: u64, byte_budget: u64) -> Result { + let start = self.rows_drained as usize; + let mut rows = 0u64; + let mut bytes = 0u64; + for row in start..start + num_rows as usize { + let row_bytes = self.row_bytes(row); + if bytes + row_bytes > byte_budget { + break; + } + bytes += row_bytes; + rows += 1; + } + Ok(DrainLimit { rows, bytes }) + } + + fn data_type(&self) -> &DataType { + self.array.data_type() + } + } + + async fn array_stream_batch_sizes( + pages: Vec, + batch_size: u32, + ) -> Vec { + let num_rows = pages.iter().map(|page| page.num_rows()).sum::(); + let fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let root_decoder = SimpleStructDecoder::new(fields, num_rows); + let (tx, rx) = unbounded_channel(); + let pages = pages + .into_iter() + .map(|page| { + MessageType::DecoderReady(DecoderReady { + decoder: Box::new(page), + path: VecDeque::from([0]), + }) + }) + .collect(); + tx.send(Ok(DecoderMessage { + scheduled_so_far: num_rows, + decoders: pages, + })) + .unwrap(); + drop(tx); + + let mut stream = + BatchDecodeStream::new(rx, batch_size, num_rows, root_decoder).into_stream(); + let mut batch_sizes = Vec::new(); + while let Some(task) = stream.next().await { + batch_sizes.push(task.task.await.unwrap().num_rows()); + } + batch_sizes + } + + #[tokio::test] + async fn test_array_stream_spans_i32_offset_pages_within_budget() { + // Small variable-width pages must NOT split the batch at page boundaries. + let batch_sizes = array_stream_batch_sizes( + vec![ + StaticArrayPageDecoder::new(&["a", "b"]), + StaticArrayPageDecoder::new(&["c", "d"]), + ], + 4, + ) + .await; + assert_eq!(batch_sizes, vec![4]); + } + + #[tokio::test] + async fn test_array_stream_splits_when_i32_offset_budget_exhausted() { + // Three single-row pages pretending to hold 800MiB each: two fit the + // i32 offset budget (~2GiB), the third starts a new batch. + const FAKE_ROW_BYTES: u64 = 800 * 1024 * 1024; + let batch_sizes = array_stream_batch_sizes( + vec![ + StaticArrayPageDecoder::with_fake_bytes_per_row(&["a"], FAKE_ROW_BYTES), + StaticArrayPageDecoder::with_fake_bytes_per_row(&["b"], FAKE_ROW_BYTES), + StaticArrayPageDecoder::with_fake_bytes_per_row(&["c"], FAKE_ROW_BYTES), + ], + 4, + ) + .await; + assert_eq!(batch_sizes, vec![2, 1]); + } + + #[tokio::test] + async fn test_array_stream_emits_over_budget_rows_alone() { + // Rows whose (possibly over-estimated) size exceeds the whole budget are + // emitted as single-row batches rather than failing the scan. + const FAKE_ROW_BYTES: u64 = 3 * 1024 * 1024 * 1024; + let batch_sizes = array_stream_batch_sizes( + vec![ + StaticArrayPageDecoder::with_fake_bytes_per_row(&["a"], FAKE_ROW_BYTES), + StaticArrayPageDecoder::with_fake_bytes_per_row(&["b"], FAKE_ROW_BYTES), + ], + 4, + ) + .await; + assert_eq!(batch_sizes, vec![1, 1]); + } + + #[test] + fn requested_row_indices_convert_to_checked_ranges() { + let requested_rows = RequestedRows::Indices(vec![1, 2, 5, 8, 9]); + let ranges = requested_rows.into_ranges().unwrap(); + assert_eq!(ranges, [1..3, 5..6, 8..10]); + + let error = RequestedRows::Indices(vec![2, 2]) + .into_ranges() + .unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("not strictly increasing")); + + let error = RequestedRows::Indices(vec![u64::MAX]) + .into_ranges() + .unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("u64::MAX")); + } + + #[derive(Debug)] + struct FailingPageDecoder { + page_data_type: DataType, + total_rows: u64, + load_error_message: &'static str, + } + + impl FailingPageDecoder { + fn new( + page_data_type: DataType, + total_rows: u64, + load_error_message: &'static str, + ) -> Self { + Self { + page_data_type, + total_rows, + load_error_message, + } + } + } + + impl LogicalPageDecoder for FailingPageDecoder { + fn wait_for_loaded(&'_ mut self, _rows_needed: u64) -> BoxFuture<'_, Result<()>> { + let load_error_message = self.load_error_message; + async move { Err(Error::io(load_error_message)) }.boxed() + } + + fn rows_loaded(&self) -> u64 { + 0 + } + + fn num_rows(&self) -> u64 { + self.total_rows + } + + fn rows_drained(&self) -> u64 { + 0 + } + + fn drain(&mut self, requested_rows: u64) -> Result { + Err(Error::internal(format!( + "failing page decoder should not be drained after load error \ + (requested_rows={})", + requested_rows + ))) + } + + fn data_type(&self) -> &DataType { + &self.page_data_type + } + } + + struct InvalidInputDecodeTask; + + impl DecodeArrayTask for InvalidInputDecodeTask { + fn decode(self: Box) -> Result<(ArrayRef, u64)> { + Err(Error::invalid_input_source("malformed sparse page".into())) + } + } + + #[test] + fn next_decode_task_preserves_invalid_input_errors() { + let err = NextDecodeTask { + task: Box::new(InvalidInputDecodeTask), + num_rows: 0, + } + .into_batch(Arc::new(Once::new())) + .unwrap_err(); + assert!(matches!(err, Error::InvalidInput { .. })); + } + + #[test] + fn test_read_zero_dimension_fsl_errors_instead_of_panicking() { + // Simulates reading a column whose stored schema declares a + // zero-dimension FixedSizeList, as old writers (before #5102) could + // persist. The read plan is built by the field-scheduler factories, + // which run the dimension guard before touching any column data, so + // an empty column iterator is sufficient to reach the guard. The read + // must surface a clean error rather than a divide-by-zero panic. + use arrow_schema::Field as ArrowField; + + let zero_dim = DataType::FixedSizeList( + Arc::new(ArrowField::new("item", DataType::Float32, true)), + 0, + ); + let field = Field::try_from(&ArrowField::new("vec", zero_dim, true)).unwrap(); + let strategy = CoreFieldDecoderStrategy::default(); + + let mut structural_columns = ColumnInfoIter::new(vec![], &[]); + let err = strategy + .create_structural_field_scheduler(&field, &mut structural_columns) + .unwrap_err(); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + + let mut array_columns = ColumnInfoIter::new(vec![], &[]); + let err = strategy + .create_array_field_scheduler( + &field, + &mut array_columns, + FileBuffers { + positions_and_sizes: &[], + }, + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + } + + #[test] + fn test_list_page_with_non_list_encoding_returns_error() { + let item = Arc::new(ArrowField::new("item", DataType::Int32, true)); + let list = DataType::List(item); + let field = Field::try_from(&ArrowField::new("values", list, true)).unwrap(); + let values_encoding = pb::ColumnEncoding { + column_encoding: Some(pb::column_encoding::ColumnEncoding::Values(())), + }; + let offsets_column = Arc::new(ColumnInfo::new( + 0, + Arc::new([PageInfo { + num_rows: 1, + priority: 0, + encoding: PageEncoding::Legacy(pb::ArrayEncoding { + array_encoding: Some(pb::array_encoding::ArrayEncoding::Flat( + pb::Flat::default(), + )), + }), + buffer_offsets_and_sizes: Arc::new([]), + }]), + vec![], + values_encoding.clone(), + )); + let items_column = Arc::new(ColumnInfo::new(1, Arc::new([]), vec![], values_encoding)); + let column_indices = [0, 1]; + let mut columns = ColumnInfoIter::new(vec![offsets_column, items_column], &column_indices); + + let err = CoreFieldDecoderStrategy::default() + .create_array_field_scheduler( + &field, + &mut columns, + FileBuffers { + positions_and_sizes: &[], + }, + ) + .unwrap_err(); + + assert!(matches!(err, Error::InvalidInput { .. })); + assert!( + err.to_string() + .contains("expected list encoding for field 'values' in column 0, page 0 but got"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn test_array_stream_stops_on_load_error() { + use arrow_schema::Field as ArrowField; + + let rows_per_batch = 1; + let total_rows = 2; + let scheduled_rows = 1; + let page_rows = 1; + let batch_readahead = 2; + let load_error_message = "simulated page load failure"; + let fields = Fields::from(vec![ArrowField::new("vector", DataType::Float32, true)]); + let root_decoder = SimpleStructDecoder::new(fields, total_rows); + let (tx, rx) = unbounded_channel(); + + tx.send(Ok(DecoderMessage { + scheduled_so_far: scheduled_rows, + decoders: vec![MessageType::DecoderReady(DecoderReady { + decoder: Box::new(FailingPageDecoder::new( + DataType::Float32, + page_rows, + load_error_message, + )), + path: VecDeque::from([0]), + })], + })) + .unwrap(); + drop(tx); + + let stream = + BatchDecodeStream::new(rx, rows_per_batch, total_rows, root_decoder).into_stream(); + let mut batches = stream.map(|task| task.task).buffered(batch_readahead); + + let err = batches + .next() + .await + .expect("stream should emit the array page-load error") + .unwrap_err(); + assert!( + err.to_string().contains(load_error_message), + "unexpected error: {}", + err + ); + assert!( + batches.next().await.is_none(), + "stream should stop after the array page-load error" + ); + } + + #[tokio::test] + async fn test_structural_stream_stops_on_load_error() { + let rows_per_batch = 1; + let total_rows = 2; + let scheduled_rows = 1; + let batch_readahead = 2; + let load_error_message = "simulated page load failure"; + let fields = Fields::from(vec![ArrowField::new("vector", DataType::Float32, true)]); + let root_decoder = StructuralStructDecoder::new( + fields, false, /*is_root=*/ true, /*nullable=*/ false, + ) + .unwrap(); + let (tx, rx) = unbounded_channel(); + let failed_page = async move { Err(Error::io(load_error_message)) }.boxed(); + + tx.send(Ok(DecoderMessage { + scheduled_so_far: scheduled_rows, + decoders: vec![MessageType::UnloadedPage(UnloadedPageShard(failed_page))], + })) + .unwrap(); + drop(tx); + + let stream = StructuralBatchDecodeStream::new( + rx, + rows_per_batch, + total_rows, + root_decoder, + /*spawn_batch_decode_tasks=*/ true, + None, + ) + .into_stream(); + let mut batches = stream.map(|task| task.task).buffered(batch_readahead); + + let err = batches + .next() + .await + .expect("stream should emit the page-load error") + .unwrap_err(); + assert!( + err.to_string().contains(load_error_message), + "unexpected error: {}", + err + ); + assert!( + batches.next().await.is_none(), + "stream should stop after the page-load error" + ); + } #[test] fn test_coalesce_indices_to_ranges_with_single_index() { let indices = vec![1]; - let ranges = DecodeBatchScheduler::indices_to_ranges(&indices); + let ranges = RequestedRows::indices_to_ranges(&indices).unwrap(); assert_eq!(ranges, vec![1..2]); } #[test] fn test_coalesce_indices_to_ranges() { let indices = vec![1, 2, 3, 4, 5, 6, 7, 8, 9]; - let ranges = DecodeBatchScheduler::indices_to_ranges(&indices); + let ranges = RequestedRows::indices_to_ranges(&indices).unwrap(); assert_eq!(ranges, vec![1..10]); } #[test] fn test_coalesce_indices_to_ranges_with_gaps() { let indices = vec![1, 2, 3, 5, 6, 7, 9]; - let ranges = DecodeBatchScheduler::indices_to_ranges(&indices); + let ranges = RequestedRows::indices_to_ranges(&indices).unwrap(); 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, encode_batch}, + testing::{TestEncoding, test_encoding_strategy}, + }; + + let version = TestEncoding::StructuralU16; + let options = EncodingOptions::default(); + let strategy = test_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_respect_row_limit() { + use arrow_array::Int32Array; + + let num_rows: i32 = 1000; + let schema = Arc::new(ArrowSchema::new(vec![ArrowField::new( + "x", + DataType::Int32, + false, + )])); + let input_batch = RecordBatch::try_new( + schema, + vec![Arc::new(Int32Array::from_iter_values(0..num_rows))], + ) + .unwrap(); + + // The byte limit can hold every row, so the 100-row limit must win. + let batches = + decode_batches_with_byte_limit(&input_batch, /*batch_size=*/ 100, Some(10_000)).await; + assert_eq!(batches.len(), 10); + assert!(batches.iter().all(|batch| batch.num_rows() == 100)); + } + + #[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/encoder.rs b/rust/lance-encoding/src/encoder.rs index 203b3b99642..ee3c7d854de 100644 --- a/rust/lance-encoding/src/encoder.rs +++ b/rust/lance-encoding/src/encoder.rs @@ -6,45 +6,72 @@ //! Lance files are encoded using a [`FieldEncodingStrategy`] which choose //! what encoder to use for each field. //! -//! The current strategy is the [`StructuralEncodingStrategy`] which uses "structural" -//! encoding. A tree of encoders is built up for each field. The struct & list encoders -//! simply pull off the validity and offsets and collect them. Then, in the primitive leaf -//! encoder the validity, offsets, and values are accumulated in an accumulation buffer. Once -//! enough data has been collected the primitive encoder will either use a miniblock encoding -//! or a full zip encoding to create a page of data from the accumulation buffer. +//! Structural strategies build a tree of encoders for each field from the +//! version-free builders in [`structural`]. Struct and list encoders collect +//! validity and offsets; primitive leaf encoders accumulate values and emit +//! miniblock or full-zip pages. use std::{collections::HashMap, sync::Arc}; use arrow_array::{Array, ArrayRef, RecordBatch}; -use arrow_schema::DataType; use bytes::{Bytes, BytesMut}; use futures::future::BoxFuture; use lance_core::datatypes::{Field, Schema}; -use lance_core::error::LanceOptionExt; use lance_core::utils::bit::{is_pwr_two, pad_bytes_to}; use lance_core::{Error, Result}; -use snafu::location; use crate::buffer::LanceBuffer; -use crate::compression::{CompressionStrategy, DefaultCompressionStrategy}; -use crate::compression_config::CompressionParams; +use crate::data::DataBlock; use crate::decoder::PageEncoding; -use crate::encodings::logical::blob::{BlobStructuralEncoder, BlobV2StructuralEncoder}; -use crate::encodings::logical::fixed_size_list::FixedSizeListStructuralEncoder; -use crate::encodings::logical::list::ListStructuralEncoder; -use crate::encodings::logical::map::MapStructuralEncoder; -use crate::encodings::logical::primitive::PrimitiveStructuralEncoder; -use crate::encodings::logical::r#struct::StructStructuralEncoder; use crate::repdef::RepDefBuilder; -use crate::version::LanceFileVersion; use crate::{ decoder::{ColumnInfo, PageInfo}, format::pb, }; +pub use crate::array_encoding::ArrayFieldEncodingStrategy; + +pub mod structural; + /// The minimum alignment for a page buffer. Writers must respect this. pub const MIN_PAGE_BUFFER_ALIGNMENT: u64 = 8; +/// An array encoded with the `pb::ArrayEncoding` grammar. +#[derive(Debug)] +pub struct EncodedArray { + pub data: DataBlock, + pub encoding: pb::ArrayEncoding, +} + +impl EncodedArray { + pub fn new(data: DataBlock, encoding: pb::ArrayEncoding) -> Self { + Self { data, encoding } + } + + pub fn into_buffers(self) -> (Vec, pb::ArrayEncoding) { + (self.data.into_buffers(), self.encoding) + } +} + +/// Encodes one data block and describes it with `pb::ArrayEncoding`. +pub trait ArrayEncoder: std::fmt::Debug + Send + Sync { + fn encode( + &self, + data: DataBlock, + data_type: &arrow_schema::DataType, + buffer_index: &mut u32, + ) -> Result; +} + +/// Selects an `ArrayEncoder` for one page. +pub trait ArrayEncodingStrategy: Send + Sync + std::fmt::Debug { + fn create_array_encoder( + &self, + arrays: &[ArrayRef], + field: &Field, + ) -> Result>; +} + /// An encoded page of data /// /// Maps to a top-level array @@ -146,6 +173,15 @@ pub type EncodeTask = BoxFuture<'static, Result>; /// tensor field) the tensor field is likely to emit encoded pages much more frequently /// than the boolean field. pub trait FieldEncoder: Send { + /// Validate and prepare an array before any encoder state is mutated. + /// + /// Batch-oriented callers invoke this for every root field before calling + /// [`Self::maybe_encode`], so a later validation failure cannot leave an + /// earlier field encoder partially advanced. + fn prepare_array(&mut self, array: ArrayRef) -> Result { + Ok(array) + } + /// Buffer the data and, if there is enough data in the buffer to form a page, return /// an encoding task to encode the data. /// @@ -236,9 +272,6 @@ pub struct EncodingOptions { /// The encoder needs to know this so it figures the position of out-of-line /// buffers correctly pub buffer_alignment: u64, - - /// The Lance file version being written - pub version: LanceFileVersion, } impl Default for EncodingOptions { @@ -248,369 +281,44 @@ impl Default for EncodingOptions { max_page_bytes: 32 * 1024 * 1024, keep_original_array: true, buffer_alignment: 64, - version: LanceFileVersion::default(), } } } -impl EncodingOptions { - /// If true (for Lance file version 2.2+), miniblock chunk sizes are u32, - /// to allow storing larger chunks and their sizes for better compression. - /// For Lance file version 2.1, miniblock chunk sizes are u16. - pub fn support_large_chunk(&self) -> bool { - self.version >= LanceFileVersion::V2_2 - } -} - /// A trait to pick which kind of field encoding to use for a field /// /// Unlike the ArrayEncodingStrategy, the field encoding strategy is /// chosen before any data is generated and the same field encoder is /// used for all data in the field. pub trait FieldEncodingStrategy: Send + Sync + std::fmt::Debug { + /// Validate one top-level array before any field encoder state is mutated. + fn validate_array(&self, _array: &dyn Array, _field: &Field) -> Result<()> { + Ok(()) + } + /// Choose and create an appropriate field encoder for the given /// field. /// /// The field encoder can be chosen on the data type as well as /// any metadata that is attached to the field. /// - /// The `encoding_strategy_root` is the encoder that should be - /// used to encode any inner data in struct / list / etc. fields. - /// - /// Initially it is the same as `self` and generally should be - /// forwarded to any inner encoding strategy. fn create_field_encoder( &self, - encoding_strategy_root: &dyn FieldEncodingStrategy, field: &Field, column_index: &mut ColumnIndexSequence, - options: &EncodingOptions, + context: &FieldEncodingContext<'_>, ) -> Result>; } -pub fn default_encoding_strategy(version: LanceFileVersion) -> Box { - match version.resolve() { - LanceFileVersion::Legacy => panic!(), - LanceFileVersion::V2_0 => Box::new( - crate::previous::encoder::CoreFieldEncodingStrategy::new(version), - ), - _ => Box::new(StructuralEncodingStrategy::with_version(version)), - } -} - -/// Create an encoding strategy with user-configured compression parameters -pub fn default_encoding_strategy_with_params( - version: LanceFileVersion, - params: CompressionParams, -) -> Result> { - match version.resolve() { - LanceFileVersion::Legacy | LanceFileVersion::V2_0 => Err(Error::invalid_input( - "Compression parameters are only supported in Lance file version 2.1 and later", - location!(), - )), - _ => { - let compression_strategy = - Arc::new(DefaultCompressionStrategy::with_params(params).with_version(version)); - Ok(Box::new(StructuralEncodingStrategy { - compression_strategy, - version, - })) - } - } -} - -/// An encoding strategy used for 2.1+ files -#[derive(Debug)] -pub struct StructuralEncodingStrategy { - pub compression_strategy: Arc, - pub version: LanceFileVersion, -} - -// For some reason, clippy thinks we can add Default to the above derive but -// rustc doesn't agree (no default for Arc) -#[allow(clippy::derivable_impls)] -impl Default for StructuralEncodingStrategy { - fn default() -> Self { - Self { - compression_strategy: Arc::new(DefaultCompressionStrategy::new()), - version: LanceFileVersion::default(), - } - } -} - -impl StructuralEncodingStrategy { - pub fn with_version(version: LanceFileVersion) -> Self { - Self { - compression_strategy: Arc::new(DefaultCompressionStrategy::new().with_version(version)), - version, - } - } - - fn is_primitive_type(data_type: &DataType) -> bool { - match data_type { - DataType::FixedSizeList(inner, _) => Self::is_primitive_type(inner.data_type()), - _ => matches!( - data_type, - DataType::Boolean - | DataType::Date32 - | DataType::Date64 - | DataType::Decimal128(_, _) - | DataType::Decimal256(_, _) - | DataType::Duration(_) - | DataType::Float16 - | DataType::Float32 - | DataType::Float64 - | DataType::Int16 - | DataType::Int32 - | DataType::Int64 - | DataType::Int8 - | DataType::Interval(_) - | DataType::Null - | DataType::Time32(_) - | DataType::Time64(_) - | DataType::Timestamp(_, _) - | DataType::UInt16 - | DataType::UInt32 - | DataType::UInt64 - | DataType::UInt8 - | DataType::FixedSizeBinary(_) - | DataType::Binary - | DataType::LargeBinary - | DataType::Utf8 - | DataType::LargeUtf8, - ), - } - } - - fn do_create_field_encoder( - &self, - _encoding_strategy_root: &dyn FieldEncodingStrategy, - field: &Field, - column_index: &mut ColumnIndexSequence, - options: &EncodingOptions, - root_field_metadata: &HashMap, - ) -> Result> { - let data_type = field.data_type(); - - // Check if field is marked as blob - if field.is_blob() { - match data_type { - DataType::Binary | DataType::LargeBinary => { - return Ok(Box::new(BlobStructuralEncoder::new( - field, - column_index.next_column_index(field.id as u32), - options, - self.compression_strategy.clone(), - )?)); - } - DataType::Struct(_) if self.version >= LanceFileVersion::V2_2 => { - return Ok(Box::new(BlobV2StructuralEncoder::new( - field, - column_index.next_column_index(field.id as u32), - options, - self.compression_strategy.clone(), - )?)); - } - DataType::Struct(_) => { - return Err(Error::InvalidInput { - source: "Blob v2 struct input requires file version >= 2.2".into(), - location: location!(), - }); - } - _ => { - return Err(Error::InvalidInput { - source: format!( - "Blob encoding only supports Binary/LargeBinary or v2 Struct, got {}", - data_type - ) - .into(), - location: location!(), - }); - } - } - } - - if Self::is_primitive_type(&data_type) { - Ok(Box::new(PrimitiveStructuralEncoder::try_new( - options, - self.compression_strategy.clone(), - column_index.next_column_index(field.id as u32), - field.clone(), - Arc::new(root_field_metadata.clone()), - )?)) - } else { - match data_type { - DataType::List(_) | DataType::LargeList(_) => { - let child = field.children.first().expect_ok()?; - let child_encoder = self.do_create_field_encoder( - _encoding_strategy_root, - child, - column_index, - options, - root_field_metadata, - )?; - Ok(Box::new(ListStructuralEncoder::new( - options.keep_original_array, - child_encoder, - ))) - } - DataType::FixedSizeList(inner, _) - if matches!(inner.data_type(), DataType::Struct(_)) => - { - if self.version < LanceFileVersion::V2_2 { - return Err(Error::NotSupported { - source: format!( - "FixedSizeList is only supported in Lance file format 2.2+, current version: {}", - self.version - ) - .into(), - location: location!(), - }); - } - // Complex FixedSizeList needs structural encoding - let child = field.children.first().expect_ok()?; - let child_encoder = self.do_create_field_encoder( - _encoding_strategy_root, - child, - column_index, - options, - root_field_metadata, - )?; - Ok(Box::new(FixedSizeListStructuralEncoder::new( - options.keep_original_array, - child_encoder, - ))) - } - DataType::Map(_, keys_sorted) => { - // TODO: We only support keys_sorted=false for now, - // because converting a rust arrow map field to the python arrow field will - // lose the keys_sorted property. - if keys_sorted { - return Err(Error::NotSupported { - source: format!("Map data type is not supported with keys_sorted=true now, current value is {}", keys_sorted).into(), - location: location!(), - }); - } - if self.version < LanceFileVersion::V2_2 { - return Err(Error::NotSupported { - source: format!( - "Map data type is only supported in Lance file format 2.2+, current version: {}", - self.version - ) - .into(), - location: location!(), - }); - } - let entries_child = field.children.first().ok_or_else(|| Error::Schema { - message: "Map should have an entries child".to_string(), - location: location!(), - })?; - let DataType::Struct(struct_fields) = entries_child.data_type() else { - return Err(Error::Schema { - message: "Map entries field must be a Struct".to_string(), - location: location!(), - }); - }; - if struct_fields.len() < 2 { - return Err(Error::Schema { - message: "Map entries struct must contain both key and value fields" - .to_string(), - location: location!(), - }); - } - let key_field = &struct_fields[0]; - if key_field.is_nullable() { - return Err(Error::Schema { - message: format!( - "Map key field '{}' must be non-nullable according to Arrow Map specification", - key_field.name() - ), - location: location!(), - }); - } - let child_encoder = self.do_create_field_encoder( - _encoding_strategy_root, - entries_child, - column_index, - options, - root_field_metadata, - )?; - Ok(Box::new(MapStructuralEncoder::new( - options.keep_original_array, - child_encoder, - ))) - } - DataType::Struct(fields) => { - if field.is_packed_struct() || fields.is_empty() { - // Both packed structs and empty structs are encoded as primitive - Ok(Box::new(PrimitiveStructuralEncoder::try_new( - options, - self.compression_strategy.clone(), - column_index.next_column_index(field.id as u32), - field.clone(), - Arc::new(root_field_metadata.clone()), - )?)) - } else { - let children_encoders = field - .children - .iter() - .map(|field| { - self.do_create_field_encoder( - _encoding_strategy_root, - field, - column_index, - options, - root_field_metadata, - ) - }) - .collect::>>()?; - Ok(Box::new(StructStructuralEncoder::new( - options.keep_original_array, - children_encoders, - ))) - } - } - DataType::Dictionary(_, value_type) => { - // A dictionary of primitive is, itself, primitive - if Self::is_primitive_type(&value_type) { - Ok(Box::new(PrimitiveStructuralEncoder::try_new( - options, - self.compression_strategy.clone(), - column_index.next_column_index(field.id as u32), - field.clone(), - Arc::new(root_field_metadata.clone()), - )?)) - } else { - // A dictionary of logical is, itself, logical and we don't support that today - // It could be possible (e.g. store indices in one column and values in remaining columns) - // but would be a significant amount of work - // - // An easier fallback implementation would be to decode-on-write and encode-on-read - Err(Error::NotSupported { source: format!("cannot encode a dictionary column whose value type is a logical type ({})", value_type).into(), location: location!() }) - } - } - _ => todo!("Implement encoding for field {}", field), - } - } - } -} - -impl FieldEncodingStrategy for StructuralEncodingStrategy { - fn create_field_encoder( - &self, - encoding_strategy_root: &dyn FieldEncodingStrategy, - field: &Field, - column_index: &mut ColumnIndexSequence, - options: &EncodingOptions, - ) -> Result> { - self.do_create_field_encoder( - encoding_strategy_root, - field, - column_index, - options, - &field.metadata, - ) - } +/// Context shared while one top-level field and all of its children are mapped +/// to concrete field encoders. +pub struct FieldEncodingContext<'a> { + /// The complete strategy composition used for recursive child fields. + pub strategy: &'a dyn FieldEncodingStrategy, + /// Runtime-only writer options. + pub options: &'a EncodingOptions, + /// Metadata inherited from the top-level field. + pub root_field_metadata: &'a HashMap, } /// A batch encoder that encodes RecordBatch objects by delegating @@ -632,12 +340,13 @@ impl BatchEncoder { .fields .iter() .map(|field| { - let encoder = strategy.create_field_encoder( + let context = FieldEncodingContext { strategy, - field, - &mut col_idx_sequence, options, - )?; + root_field_metadata: &field.metadata, + }; + let encoder = + strategy.create_field_encoder(field, &mut col_idx_sequence, &context)?; col_idx += encoder.as_ref().num_columns(); Ok(encoder) }) @@ -698,14 +407,13 @@ pub async fn encode_batch( ) -> Result { if !is_pwr_two(options.buffer_alignment) || options.buffer_alignment < MIN_PAGE_BUFFER_ALIGNMENT { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "buffer_alignment must be a power of two and at least {}", MIN_PAGE_BUFFER_ALIGNMENT ) .into(), - location: location!(), - }); + )); } let mut data_buffer = BytesMut::new(); @@ -714,17 +422,23 @@ pub async fn encode_batch( keep_original_array: true, ..*options }; - let batch_encoder = BatchEncoder::try_new(&lance_schema, encoding_strategy, &options)?; + let mut batch_encoder = BatchEncoder::try_new(&lance_schema, encoding_strategy, &options)?; + let arrays = batch + .columns() + .iter() + .cloned() + .zip(batch_encoder.field_encoders.iter_mut()) + .map(|(array, encoder)| encoder.prepare_array(array)) + .collect::>>()?; let mut page_table = Vec::new(); let mut col_idx_offset = 0; - for (arr, mut encoder) in batch.columns().iter().zip(batch_encoder.field_encoders) { + for (arr, mut encoder) in arrays.into_iter().zip(batch_encoder.field_encoders) { let mut external_buffers = OutOfLineBuffers::new(data_buffer.len() as u64, options.buffer_alignment); let repdef = RepDefBuilder::default(); let encoder = encoder.as_mut(); let num_rows = arr.len() as u64; - let mut tasks = - encoder.maybe_encode(arr.clone(), &mut external_buffers, repdef, 0, num_rows)?; + let mut tasks = encoder.maybe_encode(arr, &mut external_buffers, repdef, 0, num_rows)?; tasks.extend(encoder.flush(&mut external_buffers)?); for buffer in external_buffers.take_buffers() { data_buffer.extend_from_slice(&buffer); @@ -789,44 +503,101 @@ pub async fn encode_batch( #[cfg(test)] mod tests { use super::*; - use crate::compression_config::{CompressionFieldParams, CompressionParams}; + use crate::testing::{TestEncoding, create_test_field_encoder, test_encoding_strategy}; + use arrow_array::make_array; + use arrow_buffer::Buffer; + use arrow_data::ArrayData; + use arrow_schema::{DataType as ArrowDataType, Field as ArrowField, Fields as ArrowFields}; + use rstest::rstest; + + #[rstest] + fn test_nested_variable_width_offsets_are_validated_before_dispatch( + #[values(TestEncoding::Array, TestEncoding::StructuralU32)] encoding: TestEncoding, + #[values(ArrowDataType::Utf8, ArrowDataType::LargeUtf8)] item_type: ArrowDataType, + ) { + let offsets = match &item_type { + ArrowDataType::Utf8 => Buffer::from_slice_ref([0_i32, 2, 1, 3]), + ArrowDataType::LargeUtf8 => Buffer::from_slice_ref([0_i64, 2, 1, 3]), + _ => unreachable!(), + }; + let child_data = unsafe { + ArrayData::builder(item_type.clone()) + .len(3) + .add_buffer(offsets) + .add_buffer(Buffer::from(b"abc")) + .build_unchecked() + }; + let item_field = Arc::new(ArrowField::new("item", item_type, false)); + let data_type = ArrowDataType::FixedSizeList(item_field, 1); + let array_data = unsafe { + ArrayData::builder(data_type.clone()) + .len(3) + .add_child_data(child_data) + .build_unchecked() + }; + let array = make_array(array_data); + let field = Field::try_from(&ArrowField::new("payload", data_type, false)).unwrap(); + let strategy = test_encoding_strategy(encoding); + let mut column_index = ColumnIndexSequence::default(); + let options = EncodingOptions { + cache_bytes_per_column: 0, + ..Default::default() + }; + let mut encoder = + create_test_field_encoder(strategy.as_ref(), &field, &mut column_index, &options) + .unwrap(); + let mut external_buffers = OutOfLineBuffers::new(0, MIN_PAGE_BUFFER_ALIGNMENT); + + let error = encoder + .maybe_encode(array, &mut external_buffers, RepDefBuilder::default(), 0, 3) + .err() + .expect("malformed nested offsets should fail before task dispatch"); + + assert!(matches!(error, Error::InvalidInput { .. })); + let message = error.to_string(); + assert!( + message.contains("field 'payload'"), + "unexpected message: {message}" + ); + assert!( + message.contains("non-monotonic offset at position 2"), + "unexpected message: {message}" + ); + } #[test] - fn test_configured_encoding_strategy() { - // Create test parameters - let mut params = CompressionParams::new(); - params.columns.insert( - "*_id".to_string(), - CompressionFieldParams { - rle_threshold: Some(0.5), - compression: Some("lz4".to_string()), - compression_level: None, - bss: None, - minichunk_size: None, - }, + fn test_fixed_size_list_struct_requires_v2_2() { + let list_item = ArrowField::new( + "item", + ArrowDataType::Struct(ArrowFields::from(vec![ArrowField::new( + "x", + ArrowDataType::Int32, + true, + )])), + true, ); + let arrow_field = ArrowField::new( + "list_struct", + ArrowDataType::FixedSizeList(Arc::new(list_item), 2), + true, + ); + let field = Field::try_from(&arrow_field).unwrap(); + + let strategy = test_encoding_strategy(TestEncoding::StructuralU16); + let mut column_index = ColumnIndexSequence::default(); + let options = EncodingOptions::default(); - // Test with V2.1 - should succeed - let strategy = - default_encoding_strategy_with_params(LanceFileVersion::V2_1, params.clone()) - .expect("Should succeed for V2.1"); - - // Verify it's a StructuralEncodingStrategy - assert!(format!("{:?}", strategy).contains("StructuralEncodingStrategy")); - assert!(format!("{:?}", strategy).contains("DefaultCompressionStrategy")); - - // Test with V2.0 - should fail - let err = default_encoding_strategy_with_params(LanceFileVersion::V2_0, params.clone()) - .expect_err("Should fail for V2.0"); - assert!(err - .to_string() - .contains("only supported in Lance file version 2.1")); - - // Test with Legacy - should fail - let err = default_encoding_strategy_with_params(LanceFileVersion::Legacy, params) - .expect_err("Should fail for Legacy"); - assert!(err - .to_string() - .contains("only supported in Lance file version 2.1")); + let result = + create_test_field_encoder(strategy.as_ref(), &field, &mut column_index, &options); + assert!( + result.is_err(), + "FixedSizeList should be rejected for file version 2.1" + ); + let err = result.err().unwrap(); + + assert!( + err.to_string() + .contains("FixedSizeList is not enabled by the selected file format") + ); } } diff --git a/rust/lance-encoding/src/encoder/structural.rs b/rust/lance-encoding/src/encoder/structural.rs new file mode 100644 index 00000000000..06c9161a851 --- /dev/null +++ b/rust/lance-encoding/src/encoder/structural.rs @@ -0,0 +1,288 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Version-free structural field encoder builders. + +use std::sync::Arc; + +use arrow_schema::DataType; +use lance_core::{Error, Result, datatypes::Field, error::LanceOptionExt}; + +pub use crate::encodings::logical::primitive::PrimitivePageEncoding; + +use crate::encodings::logical::{ + blob::{BlobStructuralEncoder, BlobV2StructuralEncoder}, + fixed_size_list::FixedSizeListStructuralEncoder, + list::ListStructuralEncoder, + map::MapStructuralEncoder, + primitive::PrimitiveStructuralEncoder, + r#struct::StructStructuralEncoder, +}; + +use super::{ColumnIndexSequence, FieldEncoder, FieldEncodingContext}; + +/// Encode primitive leaves, primitive fixed-size lists, dictionaries, and +/// packed or empty structs using one concrete primitive page grammar. +#[derive(Debug, Clone)] +pub struct PrimitiveFieldEncoding { + page_encodings: Arc<[PrimitivePageEncoding]>, +} + +impl PrimitiveFieldEncoding { + /// Create a primitive field mechanism from ordered executable page behaviors. + pub fn new(page_encodings: impl IntoIterator) -> Self { + Self { + page_encodings: page_encodings.into_iter().collect(), + } + } + + fn is_primitive_type(data_type: &DataType) -> bool { + match data_type { + DataType::FixedSizeList(inner, _) => Self::is_primitive_type(inner.data_type()), + _ => matches!( + data_type, + DataType::Boolean + | DataType::Date32 + | DataType::Date64 + | DataType::Decimal128(_, _) + | DataType::Decimal256(_, _) + | DataType::Duration(_) + | DataType::Float16 + | DataType::Float32 + | DataType::Float64 + | DataType::Int16 + | DataType::Int32 + | DataType::Int64 + | DataType::Int8 + | DataType::Interval(_) + | DataType::Null + | DataType::Time32(_) + | DataType::Time64(_) + | DataType::Timestamp(_, _) + | DataType::UInt16 + | DataType::UInt32 + | DataType::UInt64 + | DataType::UInt8 + | DataType::FixedSizeBinary(_) + | DataType::Binary + | DataType::LargeBinary + | DataType::Utf8 + | DataType::LargeUtf8, + ), + } + } + + /// Compose a primitive encoder at an already allocated physical column. + /// File grammars use this when another logical type, such as a Blob, + /// supplies the physical descriptor field instead of the logical field. + fn create_at( + &self, + field: Field, + column_index: u32, + context: &FieldEncodingContext<'_>, + ) -> Result> { + Ok(Box::new(PrimitiveStructuralEncoder::try_new( + context.options, + self.page_encodings.clone(), + column_index, + field, + Arc::new(context.root_field_metadata.clone()), + )?)) + } + + /// Create a primitive field encoder when this mechanism recognizes `field`. + pub fn try_create( + &self, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, + ) -> Result>> { + if field.is_blob() { + return Ok(None); + } + + let data_type = field.data_type(); + let is_primitive = Self::is_primitive_type(&data_type); + let is_packed_or_empty_struct = matches!( + &data_type, + DataType::Struct(fields) if field.is_packed_struct() || fields.is_empty() + ); + let is_primitive_dictionary = matches!( + &data_type, + DataType::Dictionary(_, value_type) if Self::is_primitive_type(value_type) + ); + + if !is_primitive && !is_packed_or_empty_struct && !is_primitive_dictionary { + if let DataType::Dictionary(_, value_type) = data_type { + return Err(Error::not_supported_source( + format!( + "cannot encode a dictionary column whose value type is a logical type ({})", + value_type + ) + .into(), + )); + } + return Ok(None); + } + + Ok(Some(self.create_at( + field.clone(), + column_index.next_column_index(field.id as u32), + context, + )?)) + } +} + +/// Create the original binary blob descriptor when `field` matches. +pub fn try_create_binary_blob( + primitive: &PrimitiveFieldEncoding, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + if !field.is_blob() || !matches!(field.data_type(), DataType::Binary | DataType::LargeBinary) { + return Ok(None); + } + let descriptor_column_index = column_index.next_column_index(field.id as u32); + Ok(Some(Box::new(BlobStructuralEncoder::new( + field, + |descriptor_field| primitive.create_at(descriptor_field, descriptor_column_index, context), + )?))) +} + +/// Create the structural blob descriptor when `field` matches. +pub fn try_create_structural_blob( + primitive: &PrimitiveFieldEncoding, + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + if !field.is_blob() || !matches!(field.data_type(), DataType::Struct(_)) { + return Ok(None); + } + let descriptor_column_index = column_index.next_column_index(field.id as u32); + Ok(Some(Box::new(BlobV2StructuralEncoder::new( + field, + |descriptor_field| primitive.create_at(descriptor_field, descriptor_column_index, context), + )?))) +} + +/// Create a variable-size list encoder when `field` matches. +pub fn try_create_list( + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + if !matches!( + field.data_type(), + DataType::List(_) | DataType::LargeList(_) + ) { + return Ok(None); + } + let child = field.children.first().expect_ok()?; + let child_encoder = context + .strategy + .create_field_encoder(child, column_index, context)?; + Ok(Some(Box::new(ListStructuralEncoder::new( + context.options.keep_original_array, + child_encoder, + )))) +} + +/// Create a fixed-size-list encoder whose child is a struct when applicable. +pub fn try_create_structural_fixed_size_list( + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + if !matches!( + field.data_type(), + DataType::FixedSizeList(inner, _) if matches!(inner.data_type(), DataType::Struct(_)) + ) { + return Ok(None); + } + let child = field.children.first().expect_ok()?; + let child_encoder = context + .strategy + .create_field_encoder(child, column_index, context)?; + Ok(Some(Box::new(FixedSizeListStructuralEncoder::new( + context.options.keep_original_array, + child_encoder, + )))) +} + +/// Create an Arrow map encoder when `field` matches. +pub fn try_create_map( + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + let DataType::Map(_, keys_sorted) = field.data_type() else { + return Ok(None); + }; + if keys_sorted { + return Err(Error::not_supported_source( + format!( + "Map data type is not supported with keys_sorted=true now, current value is {}", + keys_sorted + ) + .into(), + )); + } + let entries_child = field + .children + .first() + .ok_or_else(|| Error::schema("Map should have an entries child".to_string()))?; + let DataType::Struct(struct_fields) = entries_child.data_type() else { + return Err(Error::schema( + "Map entries field must be a Struct".to_string(), + )); + }; + if struct_fields.len() < 2 { + return Err(Error::schema( + "Map entries struct must contain both key and value fields".to_string(), + )); + } + let key_field = &struct_fields[0]; + if key_field.is_nullable() { + return Err(Error::schema(format!( + "Map key field '{}' must be non-nullable according to Arrow Map specification", + key_field.name() + ))); + } + let child_encoder = + context + .strategy + .create_field_encoder(entries_child, column_index, context)?; + Ok(Some(Box::new(MapStructuralEncoder::new( + context.options.keep_original_array, + child_encoder, + )))) +} + +/// Create a non-packed, non-empty struct encoder when `field` matches. +pub fn try_create_struct( + field: &Field, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, +) -> Result>> { + let DataType::Struct(fields) = field.data_type() else { + return Ok(None); + }; + if field.is_blob() || field.is_packed_struct() || fields.is_empty() { + return Ok(None); + } + let children_encoders = field + .children + .iter() + .map(|child| { + context + .strategy + .create_field_encoder(child, column_index, context) + }) + .collect::>>()?; + Ok(Some(Box::new(StructStructuralEncoder::new( + context.options.keep_original_array, + children_encoders, + )))) +} diff --git a/rust/lance-encoding/src/encodings/fuzz_tests.rs b/rust/lance-encoding/src/encodings/fuzz_tests.rs index d609d3af7d3..7fb2cb123b8 100644 --- a/rust/lance-encoding/src/encodings/fuzz_tests.rs +++ b/rust/lance-encoding/src/encodings/fuzz_tests.rs @@ -14,11 +14,11 @@ use arrow_array::builder::{Int32Builder, ListBuilder}; use arrow_array::*; use arrow_schema::{DataType, Field}; use proptest::prelude::*; +use proptest::test_runner::{Config, TestRunner}; -use crate::testing::{check_round_trip_encoding_of_data, TestCases}; -use crate::version::LanceFileVersion; +use crate::testing::{TestCases, TestEncoding, check_round_trip_encoding_of_data}; use lance_core::Result; -use lance_datagen::{array, gen_batch, ArrayGenerator, ByteCount, Dimension, RowCount, Seed}; +use lance_datagen::{ArrayGenerator, ByteCount, Dimension, RowCount, Seed, array, gen_batch}; /// Test configuration representing one of the 16 permutations #[derive(Debug, Clone)] @@ -253,46 +253,54 @@ fn generate_test_data_for_config( Ok(batch.column(0).clone()) } -// Main property test for encoding round-trip -proptest! { - #![proptest_config(ProptestConfig::with_cases(50))] - - #[test] - fn test_encoding_round_trip( - config in encoding_config_strategy(), - num_rows in 100..=5000usize, - seed in any::() - ) { - let rt = tokio::runtime::Runtime::new().unwrap(); - - rt.block_on(async { - // Generate test data - let test_data = generate_test_data_for_config(&config, num_rows, seed) - .expect("Failed to generate test data"); - - // Set up test cases - let _field = config.to_field("test"); - - let mut metadata = HashMap::new(); - // Force specific encoding through metadata hints if needed - if config.encoding_type == EncodingType::Miniblock { - metadata.insert("encoding_hint".to_string(), "miniblock".to_string()); - } - - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) - .with_batch_size(100) - .with_range(0..num_rows.min(500) as u64) - .with_indices(vec![0, num_rows as u64 / 2, (num_rows - 1) as u64]); +#[rstest::rstest] +fn test_encoding_round_trip( + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values( + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, + 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, + 48, 49 + )] + _shard: usize, +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let strategy = (encoding_config_strategy(), 100..=1000usize, any::()); + let mut runner = TestRunner::new(Config { + cases: 1, + ..Config::default() + }); + runner + .run(&strategy, |(config, num_rows, seed)| { + rt.block_on(async { + let test_data = generate_test_data_for_config(&config, num_rows, seed) + .expect("Failed to generate test data"); + + let _field = config.to_field("test"); + + let mut metadata = HashMap::new(); + if config.encoding_type == EncodingType::Miniblock { + metadata.insert("encoding_hint".to_string(), "miniblock".to_string()); + } - // Execute round-trip test - check_round_trip_encoding_of_data( - vec![test_data], - &test_cases, - metadata - ).await; - }); - } + let test_cases = TestCases::default() + .with_encoding(encoding) + .with_batch_size(100) + .with_range(0..num_rows.min(500) as u64) + .with_indices(vec![0, num_rows as u64 / 2, (num_rows - 1) as u64]); + + check_round_trip_encoding_of_data(vec![test_data], &test_cases, metadata).await; + }); + Ok(()) + }) + .unwrap(); } #[tokio::test] @@ -301,7 +309,7 @@ async fn test_edge_cases_single_value() { let single_int32 = Arc::new(Int32Array::from(vec![42])) as Arc; let single_string = Arc::new(StringArray::from(vec!["test"])) as Arc; - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![single_int32], &test_cases, HashMap::new()).await; @@ -317,7 +325,7 @@ async fn test_edge_cases_all_nulls() { vec![None, None, None] as Vec> )) as Arc; - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![all_nulls_int32], &test_cases, HashMap::new()).await; @@ -347,7 +355,7 @@ proptest! { let list_array = Arc::new(list_builder.finish()) as Arc; let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) + .with_structural_encodings() .with_range(0..list_sizes.len().min(50) as u64); check_round_trip_encoding_of_data( @@ -359,37 +367,42 @@ proptest! { } } -// Test fixed size list encoding -proptest! { - #[test] - fn test_fixed_size_list_encoding( - list_size in 1..=100i32, - num_rows in 10..=1000usize, - seed in any::() - ) { - let rt = tokio::runtime::Runtime::new().unwrap(); - - rt.block_on(async { - let config = EncodingTestConfig { - encoding_type: EncodingType::Miniblock, - data_structure: DataStructure::FixedSizeList(list_size), - data_width: DataWidth::Fixed(FixedWidthType::Int32), - nullable: false, - }; - - let test_data = generate_test_data_for_config(&config, num_rows, seed) - .expect("Failed to generate test data"); - - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1); - - check_round_trip_encoding_of_data( - vec![test_data], - &test_cases, - HashMap::new() - ).await; - }); - } +#[rstest::rstest] +fn test_fixed_size_list_encoding( + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, +) { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let strategy = (1..=100i32, 10..=1000usize, any::()); + let mut runner = TestRunner::new(Config::default()); + runner + .run(&strategy, |(list_size, num_rows, seed)| { + rt.block_on(async { + let config = EncodingTestConfig { + encoding_type: EncodingType::Miniblock, + data_structure: DataStructure::FixedSizeList(list_size), + data_width: DataWidth::Fixed(FixedWidthType::Int32), + nullable: false, + }; + + let test_data = generate_test_data_for_config(&config, num_rows, seed) + .expect("Failed to generate test data"); + + let test_cases = TestCases::default().with_encoding(encoding); + + check_round_trip_encoding_of_data(vec![test_data], &test_cases, HashMap::new()) + .await; + }); + Ok(()) + }) + .unwrap(); } #[tokio::test] @@ -424,7 +437,7 @@ async fn test_list_dict_empty_batch() { let list_array = Arc::new(list_builder.finish()); let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) + .with_structural_encodings() // Read only the empty/null lists (rows 50-99) // This batch will have 0 underlying values .with_range(50..100); @@ -539,7 +552,7 @@ async fn test_all_valid_combinations() { let test_data = generate_test_data_for_config(&config, 100, 42).expect("Failed to generate test data"); - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![test_data], &test_cases, HashMap::new()).await; } diff --git a/rust/lance-encoding/src/encodings/logical/blob.rs b/rust/lance-encoding/src/encodings/logical/blob.rs index bc3bc80db27..aa1257eb458 100644 --- a/rust/lance-encoding/src/encodings/logical/blob.rs +++ b/rust/lance-encoding/src/encodings/logical/blob.rs @@ -4,25 +4,25 @@ use std::{collections::HashMap, sync::Arc}; use arrow_array::{ + Array, ArrayRef, StructArray, UInt64Array, builder::{PrimitiveBuilder, StringBuilder}, cast::AsArray, - types::{UInt32Type, UInt64Type, UInt8Type}, - Array, ArrayRef, StructArray, UInt64Array, + types::{UInt8Type, UInt32Type, UInt64Type}, }; use arrow_buffer::Buffer; use arrow_schema::{DataType, Field as ArrowField, Fields}; -use futures::{future::BoxFuture, FutureExt}; +use futures::{FutureExt, future::BoxFuture}; use lance_core::{ - datatypes::Field, datatypes::BLOB_V2_DESC_FIELDS, error::LanceOptionExt, Error, Result, + Error, Result, + datatypes::{BLOB_V2_DESC_FIELDS, BlobV2Layout, Field}, + error::LanceOptionExt, }; -use snafu::location; use crate::{ buffer::LanceBuffer, constants::PACKED_STRUCT_META_KEY, decoder::PageEncoding, encoder::{EncodeTask, EncodedColumn, EncodedPage, FieldEncoder, OutOfLineBuffers}, - encodings::logical::primitive::PrimitiveStructuralEncoder, format::ProtobufUtils21, repdef::{DefinitionInterpretation, RepDefBuilder}, }; @@ -43,9 +43,7 @@ pub struct BlobStructuralEncoder { impl BlobStructuralEncoder { pub fn new( field: &Field, - column_index: u32, - options: &crate::encoder::EncodingOptions, - compression_strategy: Arc, + make_descriptor_encoder: impl FnOnce(Field) -> Result>, ) -> Result { // Create descriptor field: struct // Preserve the original field's metadata for packed struct @@ -64,13 +62,7 @@ impl BlobStructuralEncoder { )?; // Use PrimitiveStructuralEncoder to handle the descriptor - let descriptor_encoder = Box::new(PrimitiveStructuralEncoder::try_new( - options, - compression_strategy, - column_index, - descriptor_field, - Arc::new(HashMap::new()), - )?); + let descriptor_encoder = make_descriptor_encoder(descriptor_field)?; Ok(Self { descriptor_encoder, @@ -90,11 +82,9 @@ impl BlobStructuralEncoder { let encoded_page = encoded_page?; let PageEncoding::Structural(inner_layout) = encoded_page.description else { - return Err(Error::Internal { - message: "Expected inner encoding to return structural layout" - .to_string(), - location: location!(), - }); + return Err(Error::internal( + "Expected inner encoding to return structural layout".to_string(), + )); }; let wrapped = ProtobufUtils21::blob_layout(inner_layout, &def_meaning); @@ -128,12 +118,11 @@ impl FieldEncoder for BlobStructuralEncoder { } // Convert input array to LargeBinary - let binary_array = array - .as_binary_opt::() - .ok_or_else(|| Error::InvalidInput { - source: format!("Expected LargeBinary array, got {}", array.data_type()).into(), - location: location!(), - })?; + let binary_array = array.as_binary_opt::().ok_or_else(|| { + Error::invalid_input_source( + format!("Expected LargeBinary array, got {}", array.data_type()).into(), + ) + })?; let repdef = RepDefBuilder::serialize(vec![repdef]); @@ -141,11 +130,18 @@ impl FieldEncoder for BlobStructuralEncoder { let def = repdef.definition_levels.as_ref(); let def_meaning: Arc<[DefinitionInterpretation]> = repdef.def_meaning.into(); - if self.def_meaning.is_none() { - self.def_meaning = Some(def_meaning.clone()); - } else { - debug_assert_eq!(self.def_meaning.as_ref().unwrap(), &def_meaning); - } + // A blob page stores one definition interpretation for all of its rows. + // The descriptor encoder can buffer multiple input arrays, so finish the + // pending page before a later array changes from all-valid to nullable (or + // vice versa). + let mut encode_tasks = match self.def_meaning.as_ref() { + Some(existing) if existing != &def_meaning => { + let existing = existing.clone(); + Self::wrap_tasks(self.descriptor_encoder.flush(external_buffers)?, existing) + } + _ => Vec::new(), + }; + self.def_meaning = Some(def_meaning.clone()); // Collect positions and sizes let mut positions = Vec::with_capacity(binary_array.len()); @@ -193,15 +189,16 @@ impl FieldEncoder for BlobStructuralEncoder { )); // Delegate to descriptor encoder - let encode_tasks = self.descriptor_encoder.maybe_encode( + let descriptor_tasks = self.descriptor_encoder.maybe_encode( descriptor_array, external_buffers, RepDefBuilder::default(), row_number, num_rows, )?; + encode_tasks.extend(Self::wrap_tasks(descriptor_tasks, def_meaning)); - Ok(Self::wrap_tasks(encode_tasks, def_meaning)) + Ok(encode_tasks) } fn flush(&mut self, external_buffers: &mut OutOfLineBuffers) -> Result> { @@ -237,9 +234,7 @@ pub struct BlobV2StructuralEncoder { impl BlobV2StructuralEncoder { pub fn new( field: &Field, - column_index: u32, - options: &crate::encoder::EncodingOptions, - compression_strategy: Arc, + make_descriptor_encoder: impl FnOnce(Field) -> Result>, ) -> Result { let mut descriptor_metadata = HashMap::with_capacity(1); descriptor_metadata.insert(PACKED_STRUCT_META_KEY.to_string(), "true".to_string()); @@ -251,13 +246,7 @@ impl BlobV2StructuralEncoder { .with_metadata(descriptor_metadata), )?; - let descriptor_encoder = Box::new(PrimitiveStructuralEncoder::try_new( - options, - compression_strategy, - column_index, - descriptor_field, - Arc::new(HashMap::new()), - )?); + let descriptor_encoder = make_descriptor_encoder(descriptor_field)?; Ok(Self { descriptor_encoder }) } @@ -268,57 +257,66 @@ impl FieldEncoder for BlobV2StructuralEncoder { &mut self, array: ArrayRef, external_buffers: &mut OutOfLineBuffers, - mut repdef: RepDefBuilder, + repdef: RepDefBuilder, row_number: u64, num_rows: u64, ) -> Result> { - let struct_arr = array.as_struct(); - if let Some(validity) = struct_arr.nulls() { - repdef.add_validity_bitmap(validity.clone()); - } else { - repdef.add_no_null(struct_arr.len()); + let struct_arr = array + .as_any() + .downcast_ref::() + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Blob v2 encoder expected StructArray, got {}", + array.data_type() + ) + .into(), + ) + })?; + if BlobV2Layout::classify(struct_arr.fields()) != Some(BlobV2Layout::Prepared) { + let actual = BlobV2Layout::classify(struct_arr.fields()) + .map(|layout| layout.to_string()) + .unwrap_or_else(|| format!("unrecognized ({:?})", struct_arr.fields())); + return Err(Error::invalid_input_source( + format!("Blob v2 encoder expected prepared array layout, got {actual} layout") + .into(), + )); } let kind_col = struct_arr .column_by_name("kind") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `kind` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `kind` field".into()) })? .as_primitive::(); let data_col = struct_arr .column_by_name("data") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `data` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `data` field".into()) })? .as_binary::(); let uri_col = struct_arr .column_by_name("uri") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `uri` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `uri` field".into()) })? .as_string::(); let blob_id_col = struct_arr .column_by_name("blob_id") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `blob_id` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `blob_id` field".into()) })? .as_primitive::(); let blob_size_col = struct_arr .column_by_name("blob_size") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `blob_size` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `blob_size` field".into()) })? .as_primitive::(); let packed_position_col = struct_arr .column_by_name("position") - .ok_or_else(|| Error::InvalidInput { - source: "Blob v2 struct missing `position` field".into(), - location: location!(), + .ok_or_else(|| { + Error::invalid_input_source("Blob v2 struct missing `position` field".into()) })? .as_primitive::(); @@ -331,49 +329,95 @@ impl FieldEncoder for BlobV2StructuralEncoder { let mut uri_builder = StringBuilder::with_capacity(row_count, row_count * 16); for i in 0..row_count { - let (kind_value, position_value, size_value, blob_id_value, uri_value) = - if struct_arr.is_null(i) || kind_col.is_null(i) { - (BlobKind::Inline as u8, 0, 0, 0, "".to_string()) - } else { - let kind_val = BlobKind::try_from(kind_col.value(i))?; - match kind_val { - BlobKind::Dedicated => ( - BlobKind::Dedicated as u8, - 0, - blob_size_col.value(i), - blob_id_col.value(i), - "".to_string(), - ), - BlobKind::External => ( + let (kind_value, position_value, size_value, blob_id_value, uri_value) = if struct_arr + .is_null(i) + || kind_col.is_null(i) + { + (BlobKind::Inline as u8, 0, 0, 0, "".to_string()) + } else { + let kind_val = BlobKind::try_from(kind_col.value(i))?; + match kind_val { + BlobKind::Managed | BlobKind::ManagedWithBase => { + if uri_col.is_null(i) + || ((kind_val == BlobKind::ManagedWithBase) != blob_id_col.is_valid(i)) + || packed_position_col.is_null(i) + || blob_size_col.is_null(i) + { + return Err(Error::invalid_input(format!( + "Managed blob row {i} requires URI, position, and size, plus a base ID for an explicit base" + ))); + } + let uri = uri_col.value(i); + let position = packed_position_col.value(i); + let size = blob_size_col.value(i); + lance_core::utils::blob::validate_managed_reference(uri, position, size)?; + ( + kind_val as u8, + position, + size, + if kind_val == BlobKind::ManagedWithBase { + blob_id_col.value(i) + } else { + 0 + }, + uri.to_string(), + ) + } + BlobKind::Dedicated => ( + BlobKind::Dedicated as u8, + 0, + blob_size_col.value(i), + blob_id_col.value(i), + "".to_string(), + ), + BlobKind::External => { + let uri = uri_col.value(i).to_string(); + let position = if packed_position_col.is_null(i) { + 0 + } else { + packed_position_col.value(i) + }; + let size = if blob_size_col.is_null(i) { + 0 + } else { + blob_size_col.value(i) + }; + let external_base_id = if blob_id_col.is_null(i) { + 0 + } else { + blob_id_col.value(i) + }; + ( BlobKind::External as u8, + position, + size, + external_base_id, + uri, + ) + } + BlobKind::Packed => ( + BlobKind::Packed as u8, + packed_position_col.value(i), + blob_size_col.value(i), + blob_id_col.value(i), + "".to_string(), + ), + BlobKind::Inline => { + let data_val = data_col.value(i); + let blob_len = data_val.len() as u64; + let position = + external_buffers.add_buffer(LanceBuffer::from(Buffer::from(data_val))); + + ( + BlobKind::Inline as u8, + position, + blob_len, 0, - 0, - 0, - uri_col.value(i).to_string(), - ), - BlobKind::Packed => ( - BlobKind::Packed as u8, - packed_position_col.value(i), - blob_size_col.value(i), - blob_id_col.value(i), "".to_string(), - ), - BlobKind::Inline => { - let data_val = data_col.value(i); - let blob_len = data_val.len() as u64; - let position = external_buffers - .add_buffer(LanceBuffer::from(Buffer::from(data_val))); - - ( - BlobKind::Inline as u8, - position, - blob_len, - 0, - "".to_string(), - ) - } + ) } - }; + } + }; kind_builder.append_value(kind_value); position_builder.append_value(position_value); @@ -392,7 +436,7 @@ impl FieldEncoder for BlobV2StructuralEncoder { let descriptor_array = Arc::new(StructArray::try_new( BLOB_V2_DESC_FIELDS.clone(), children, - None, + struct_arr.nulls().cloned(), )?) as ArrayRef; self.descriptor_encoder.maybe_encode( @@ -424,33 +468,82 @@ impl FieldEncoder for BlobV2StructuralEncoder { mod tests { use super::*; use crate::{ - compression::DefaultCompressionStrategy, encoder::{ColumnIndexSequence, EncodingOptions}, testing::{ - check_round_trip_encoding_of_data, check_round_trip_encoding_of_data_with_expected, - TestCases, + TestCases, TestEncoding, check_round_trip_encoding_of_data, + check_round_trip_encoding_of_data_with_expected, create_test_field_encoder, + test_encoding_strategy, }, - version::LanceFileVersion, }; use arrow_array::{ - ArrayRef, LargeBinaryArray, StringArray, StructArray, UInt32Array, UInt64Array, UInt8Array, + ArrayRef, LargeBinaryArray, StringArray, StructArray, UInt8Array, UInt32Array, UInt64Array, }; use arrow_schema::{DataType, Field as ArrowField}; + use lance_core::datatypes::BLOB_V2_LOGICAL_MINIMAL_FIELDS; #[test] fn test_blob_encoder_creation() { - let field = - Field::try_from(ArrowField::new("blob_field", DataType::LargeBinary, true)).unwrap(); + let field = Field::try_from( + ArrowField::new("blob_field", DataType::LargeBinary, true).with_metadata( + HashMap::from([(lance_arrow::BLOB_META_KEY.to_string(), "true".to_string())]), + ), + ) + .unwrap(); let mut column_index = ColumnIndexSequence::default(); - let column_idx = column_index.next_column_index(0); let options = EncodingOptions::default(); - let compression = Arc::new(DefaultCompressionStrategy::new()); + let strategy = test_encoding_strategy(TestEncoding::StructuralU16); - let encoder = BlobStructuralEncoder::new(&field, column_idx, &options, compression); + let encoder = + create_test_field_encoder(strategy.as_ref(), &field, &mut column_index, &options); assert!(encoder.is_ok()); } + #[test] + fn test_blob_v2_encoder_rejects_logical_array_layout() { + let field = Field::try_from( + ArrowField::new( + "blob_field", + DataType::Struct(BLOB_V2_LOGICAL_MINIMAL_FIELDS.clone()), + true, + ) + .with_metadata(HashMap::from([( + lance_arrow::ARROW_EXT_NAME_KEY.to_string(), + lance_arrow::BLOB_V2_EXT_NAME.to_string(), + )])), + ) + .unwrap(); + let mut column_index = ColumnIndexSequence::default(); + let options = EncodingOptions::default(); + let strategy = test_encoding_strategy(TestEncoding::StructuralU32); + let mut encoder = + create_test_field_encoder(strategy.as_ref(), &field, &mut column_index, &options) + .unwrap(); + let array = Arc::new( + StructArray::try_new( + BLOB_V2_LOGICAL_MINIMAL_FIELDS.clone(), + vec![ + Arc::new(LargeBinaryArray::from(vec![Some(b"payload".as_ref())])) as ArrayRef, + Arc::new(StringArray::from(vec![None::<&str>])) as ArrayRef, + ], + None, + ) + .unwrap(), + ) as ArrayRef; + let mut external_buffers = OutOfLineBuffers::new(0, 8); + let Err(error) = + encoder.maybe_encode(array, &mut external_buffers, RepDefBuilder::default(), 0, 1) + else { + panic!("logical array layout unexpectedly reached the descriptor encoder"); + }; + assert!(matches!(error, Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("expected prepared array layout, got logical layout") + ); + } + #[tokio::test] async fn test_blob_encoding_simple() { let field = Field::try_from( @@ -460,12 +553,12 @@ mod tests { ) .unwrap(); let mut column_index = ColumnIndexSequence::default(); - let column_idx = column_index.next_column_index(0); let options = EncodingOptions::default(); - let compression = Arc::new(DefaultCompressionStrategy::new()); + let strategy = test_encoding_strategy(TestEncoding::StructuralU16); let mut encoder = - BlobStructuralEncoder::new(&field, column_idx, &options, compression).unwrap(); + create_test_field_encoder(strategy.as_ref(), &field, &mut column_index, &options) + .unwrap(); // Create test data with larger blobs let large_data = vec![0u8; 1024 * 100]; // 100KB blob @@ -518,7 +611,53 @@ mod tests { // Use the standard test harness check_round_trip_encoding_of_data( vec![array], - &TestCases::default().with_max_file_version(LanceFileVersion::V2_1), + &TestCases::default().with_array_and_u16_encodings(), + blob_metadata, + ) + .await; + } + + #[tokio::test] + async fn test_blob_round_trip_empty_values() { + // Empty values share size == 0 with nulls in the descriptor layout + // and schedule no read; each must decode to zero-length bytes without + // consuming the read result of a following non-empty blob. Empties + // are placed before payloads so a misassignment corrupts the output + // instead of only exhausting the read iterator. + let blob_metadata = + HashMap::from([(lance_arrow::BLOB_META_KEY.to_string(), "true".to_string())]); + + let val1: &[u8] = &vec![1u8; 1024]; + let val2: &[u8] = &vec![2u8; 10240]; + let empty: &[u8] = &[]; + let array = Arc::new(LargeBinaryArray::from(vec![ + Some(empty), + Some(val1), + None, + Some(empty), + Some(val2), + None, + Some(empty), + ])); + + check_round_trip_encoding_of_data(vec![array], &TestCases::default(), blob_metadata).await; + } + + #[tokio::test] + async fn test_blob_round_trip_varying_chunk_nullability() { + let blob_metadata = + HashMap::from([(lance_arrow::BLOB_META_KEY.to_string(), "true".to_string())]); + let all_valid = Arc::new(LargeBinaryArray::from(vec![Some(b"first".as_ref())])); + let with_null = Arc::new(LargeBinaryArray::from(vec![ + Some(b"second".as_ref()), + None, + Some(b"".as_ref()), + ])); + let all_valid_again = Arc::new(LargeBinaryArray::from(vec![Some(b"last".as_ref())])); + + check_round_trip_encoding_of_data( + vec![all_valid, with_null, all_valid_again], + &TestCases::default().with_encoding(TestEncoding::StructuralU16), blob_metadata, ) .await; @@ -596,7 +735,7 @@ mod tests { check_round_trip_encoding_of_data_with_expected( vec![Arc::new(struct_array)], Some(Arc::new(expected_descriptor)), - &TestCases::default().with_min_file_version(LanceFileVersion::V2_2), + &TestCases::default().with_u32_structural_encodings(), blob_metadata, ) .await; @@ -661,7 +800,69 @@ mod tests { check_round_trip_encoding_of_data_with_expected( vec![Arc::new(struct_array)], Some(Arc::new(expected_descriptor)), - &TestCases::default().with_min_file_version(LanceFileVersion::V2_2), + &TestCases::default().with_u32_structural_encodings(), + blob_metadata, + ) + .await; + } + + #[tokio::test] + async fn test_blob_v2_external_with_range_round_trip() { + let blob_metadata = HashMap::from([( + lance_arrow::ARROW_EXT_NAME_KEY.to_string(), + lance_arrow::BLOB_V2_EXT_NAME.to_string(), + )]); + + let kind_field = Arc::new(ArrowField::new("kind", DataType::UInt8, true)); + let data_field = Arc::new(ArrowField::new("data", DataType::LargeBinary, true)); + let uri_field = Arc::new(ArrowField::new("uri", DataType::Utf8, true)); + let blob_id_field = Arc::new(ArrowField::new("blob_id", DataType::UInt32, true)); + let blob_size_field = Arc::new(ArrowField::new("blob_size", DataType::UInt64, true)); + let position_field = Arc::new(ArrowField::new("position", DataType::UInt64, true)); + + let kind_array = UInt8Array::from(vec![BlobKind::External as u8]); + let data_array = LargeBinaryArray::from(vec![None::<&[u8]>]); + let uri_array = StringArray::from(vec![Some("memory://container.pack")]); + let blob_id_array = UInt32Array::from(vec![0]); + let blob_size_array = UInt64Array::from(vec![42]); + let position_array = UInt64Array::from(vec![7]); + + let struct_array = StructArray::from(vec![ + (kind_field, Arc::new(kind_array) as ArrayRef), + (data_field, Arc::new(data_array) as ArrayRef), + (uri_field, Arc::new(uri_array) as ArrayRef), + (blob_id_field, Arc::new(blob_id_array) as ArrayRef), + (blob_size_field, Arc::new(blob_size_array) as ArrayRef), + (position_field, Arc::new(position_array) as ArrayRef), + ]); + + let expected_descriptor = StructArray::from(vec![ + ( + Arc::new(ArrowField::new("kind", DataType::UInt8, false)), + Arc::new(UInt8Array::from(vec![BlobKind::External as u8])) as ArrayRef, + ), + ( + Arc::new(ArrowField::new("position", DataType::UInt64, false)), + Arc::new(UInt64Array::from(vec![7])) as ArrayRef, + ), + ( + Arc::new(ArrowField::new("size", DataType::UInt64, false)), + Arc::new(UInt64Array::from(vec![42])) as ArrayRef, + ), + ( + Arc::new(ArrowField::new("blob_id", DataType::UInt32, false)), + Arc::new(UInt32Array::from(vec![0])) as ArrayRef, + ), + ( + Arc::new(ArrowField::new("blob_uri", DataType::Utf8, false)), + Arc::new(StringArray::from(vec!["memory://container.pack"])) as ArrayRef, + ), + ]); + + check_round_trip_encoding_of_data_with_expected( + vec![Arc::new(struct_array)], + Some(Arc::new(expected_descriptor)), + &TestCases::default().with_u32_structural_encodings(), blob_metadata, ) .await; @@ -723,7 +924,7 @@ mod tests { check_round_trip_encoding_of_data_with_expected( vec![Arc::new(struct_array)], Some(Arc::new(expected_descriptor)), - &TestCases::default().with_min_file_version(LanceFileVersion::V2_2), + &TestCases::default().with_u32_structural_encodings(), blob_metadata, ) .await; 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 805ab2c96bb..745139bf796 100644 --- a/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs +++ b/rust/lance-encoding/src/encodings/logical/fixed_size_list.rs @@ -9,13 +9,12 @@ use std::{ops::Range, sync::Arc}; -use arrow_array::{cast::AsArray, Array, ArrayRef, GenericListArray, OffsetSizeTrait, StructArray}; +use arrow_array::{Array, ArrayRef, GenericListArray, OffsetSizeTrait, StructArray, cast::AsArray}; use arrow_buffer::{BooleanBufferBuilder, NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow_schema::DataType; -use futures::future::BoxFuture; +use futures::{FutureExt, future::BoxFuture}; use lance_arrow::deepcopy::deep_copy_nulls; use lance_core::{Error, Result}; -use snafu::location; use crate::{ decoder::{ @@ -54,12 +53,9 @@ impl FieldEncoder for FixedSizeListStructuralEncoder { row_number: u64, num_rows: u64, ) -> Result> { - let fsl_arr = array - .as_fixed_size_list_opt() - .ok_or_else(|| Error::Internal { - message: "FixedSizeList encoder used for non-fixed-size-list data".to_string(), - location: location!(), - })?; + let fsl_arr = array.as_fixed_size_list_opt().ok_or_else(|| { + Error::internal("FixedSizeList encoder used for non-fixed-size-list data".to_string()) + })?; let dimension = fsl_arr.value_length() as usize; let values = fsl_arr.values().clone(); @@ -69,7 +65,7 @@ impl FieldEncoder for FixedSizeListStructuralEncoder { } else { deep_copy_nulls(array.nulls()) }; - repdef.add_fsl(validity.clone(), dimension, num_rows as usize); + repdef.add_fsl(validity.clone(), dimension, fsl_arr.len()); // FSL forces child elements to exist even under null rows. Normalize any // nested lists under null FSL rows to null empty lists. @@ -127,6 +123,27 @@ impl StructuralFixedSizeListScheduler { dimension: dimension as u64, } } + + fn child_ranges(&self, ranges: &[Range]) -> Result>> { + ranges + .iter() + .map(|range| { + let start = range.start.checked_mul(self.dimension).ok_or_else(|| { + Error::invalid_input(format!( + "fixed-size-list child range start overflowed: {} * {}", + range.start, self.dimension + )) + })?; + let end = range.end.checked_mul(self.dimension).ok_or_else(|| { + Error::invalid_input(format!( + "fixed-size-list child range end overflowed: {} * {}", + range.end, self.dimension + )) + })?; + Ok(start..end) + }) + .collect() + } } impl StructuralFieldScheduler for StructuralFixedSizeListScheduler { @@ -136,10 +153,7 @@ impl StructuralFieldScheduler for StructuralFixedSizeListScheduler { filter: &FilterExpression, ) -> Result> { // Scale ranges by dimension for the child - each FSL row becomes `dimension` child rows - let child_ranges: Vec> = ranges - .iter() - .map(|r| (r.start * self.dimension)..(r.end * self.dimension)) - .collect(); + let child_ranges = self.child_ranges(ranges)?; let child = self.child.schedule_ranges(&child_ranges, filter)?; Ok(Box::new(StructuralFixedSizeListSchedulingJob::new( child, @@ -149,10 +163,21 @@ impl StructuralFieldScheduler for StructuralFixedSizeListScheduler { fn initialize<'a>( &'a mut self, + requested_ranges: Option<&'a [Range]>, filter: &'a FilterExpression, context: &'a SchedulerContext, ) -> BoxFuture<'a, Result<()>> { - self.child.initialize(filter, context) + let child_ranges = match requested_ranges.map(|ranges| self.child_ranges(ranges)) { + Some(Ok(child_ranges)) => Some(child_ranges), + Some(Err(error)) => return std::future::ready(Err(error)).boxed(), + None => None, + }; + async move { + self.child + .initialize(child_ranges.as_deref(), filter, context) + .await + } + .boxed() } } @@ -210,10 +235,9 @@ impl StructuralFieldDecoder for StructuralFixedSizeListDecoder { let dimension = match &self.data_type { DataType::FixedSizeList(_, d) => *d as u64, _ => { - return Err(Error::Internal { - message: "FixedSizeListDecoder has non-FSL data type".to_string(), - location: location!(), - }); + return Err(Error::internal( + "FixedSizeListDecoder has non-FSL data type".to_string(), + )); } }; let child_task = self.child.drain(num_rows * dimension)?; @@ -252,11 +276,15 @@ 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; - let validity = repdef.unravel_fsl_validity(num_rows, *dimension as usize); + let validity = repdef.unravel_fsl_validity(num_rows, *dimension as usize)?; let fsl_array = arrow_array::FixedSizeListArray::try_new( child_field.clone(), *dimension, @@ -266,12 +294,12 @@ impl StructuralDecodeArrayTask for StructuralFixedSizeListDecodeTask { Ok(DecodedArray { array: Arc::new(fsl_array), repdef, + data_size, }) } - _ => Err(Error::Internal { - message: "FixedSizeList decoder did not have a fixed-size list field".to_string(), - location: location!(), - }), + _ => Err(Error::internal( + "FixedSizeList decoder did not have a fixed-size list field".to_string(), + )), } } } @@ -521,9 +549,9 @@ mod tests { use std::{collections::HashMap, sync::Arc}; use arrow_array::{ + Array, FixedSizeListArray, builder::{Int32Builder, ListBuilder}, cast::AsArray, - Array, FixedSizeListArray, }; use arrow_schema::{DataType, Field, Fields}; use rstest::rstest; @@ -534,8 +562,7 @@ mod tests { STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, STRUCTURAL_ENCODING_MINIBLOCK, }, - testing::{check_specific_random, TestCases}, - version::LanceFileVersion, + testing::{TestCases, TestEncoding, check_specific_random}, }; fn make_fsl_struct_type(struct_fields: Fields, dimension: i32) -> DataType { @@ -623,21 +650,90 @@ 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)] - #[case::struct_with_list(struct_with_list_fields(), 2, LanceFileVersion::V2_2)] - #[case::struct_with_large_list(struct_with_large_list_fields(), 2, LanceFileVersion::V2_2)] - #[case::nested_struct_with_list(nested_struct_with_list_fields(), 2, LanceFileVersion::V2_2)] - #[case::struct_with_nested_fsl(struct_with_nested_fsl_fields(), 2, LanceFileVersion::V2_2)] - #[case::struct_with_map(struct_with_map_fields(), 2, LanceFileVersion::V2_2)] + #[case::simple(simple_struct_fields(), 2)] + #[case::nested_struct(nested_struct_fields(), 2)] + #[case::struct_with_list(struct_with_list_fields(), 2)] + #[case::struct_with_large_list(struct_with_large_list_fields(), 2)] + #[case::nested_struct_with_list(nested_struct_with_list_fields(), 2)] + #[case::struct_with_nested_fsl(struct_with_nested_fsl_fields(), 2)] + #[case::struct_with_map(struct_with_map_fields(), 2)] #[test_log::test(tokio::test)] async fn test_fsl_struct_random( #[case] struct_fields: Fields, #[case] dimension: i32, - #[case] min_version: LanceFileVersion, #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, + #[values(TestEncoding::StructuralU32, TestEncoding::StructuralSparse)] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + #[values(1, 5, 10)] ingest_batch_count: u32, ) { let data_type = make_fsl_struct_type(struct_fields, dimension); let mut field_metadata = HashMap::new(); @@ -646,47 +742,23 @@ mod tests { structural_encoding.into(), ); let field = Field::new("", data_type, true).with_metadata(field_metadata); - let test_cases = TestCases::basic().with_min_file_version(min_version); + let test_cases = TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]) + .with_ingest_batch_counts([ingest_batch_count]); 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] diff --git a/rust/lance-encoding/src/encodings/logical/list.rs b/rust/lance-encoding/src/encodings/logical/list.rs index fc99fddae2a..a18608acef9 100644 --- a/rust/lance-encoding/src/encodings/logical/list.rs +++ b/rust/lance-encoding/src/encodings/logical/list.rs @@ -3,7 +3,7 @@ use std::{ops::Range, sync::Arc}; -use arrow_array::{cast::AsArray, make_array, Array, ArrayRef, LargeListArray, ListArray}; +use arrow_array::{Array, ArrayRef, LargeListArray, ListArray, cast::AsArray, make_array}; use arrow_schema::DataType; use futures::future::BoxFuture; use lance_arrow::deepcopy::deep_copy_nulls; @@ -120,10 +120,14 @@ impl StructuralFieldScheduler for StructuralListScheduler { fn initialize<'a>( &'a mut self, + requested_ranges: Option<&'a [Range]>, filter: &'a FilterExpression, context: &'a SchedulerContext, ) -> BoxFuture<'a, Result<()>> { - self.child.initialize(filter, context) + // The child (items) column is scheduled with the same top-level row + // ranges (see `schedule_ranges`); the page repetition index maps those + // rows to items, so forward the ranges unchanged. + self.child.initialize(requested_ranges, filter, context) } } @@ -176,6 +180,13 @@ impl StructuralFieldDecoder for StructuralListDecoder { fn data_type(&self) -> &DataType { &self.data_type } + + fn plan_decoded_bytes(&self, _rows_remaining: u64) -> lance_core::Result<[u64; 8]> { + Err(lance_core::Error::not_supported( + "plan_decoded_bytes is not yet supported for fields with repetition (list/repeated types)" + .to_string(), + )) + } } #[derive(Debug)] @@ -195,7 +206,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 +224,7 @@ impl StructuralDecodeArrayTask for StructuralListDecodeTask { Ok(DecodedArray { array: Arc::new(list_array), repdef, + data_size, }) } DataType::LargeList(child_field) => { @@ -218,6 +234,7 @@ impl StructuralDecodeArrayTask for StructuralListDecodeTask { Ok(DecodedArray { array: Arc::new(list_array), repdef, + data_size, }) } _ => panic!("List decoder did not have a list field"), @@ -234,18 +251,21 @@ mod tests { STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, STRUCTURAL_ENCODING_MINIBLOCK, }; use arrow_array::{ - builder::{Int32Builder, Int64Builder, LargeListBuilder, ListBuilder, StringBuilder}, - Array, ArrayRef, BooleanArray, DictionaryArray, LargeStringArray, ListArray, StructArray, - UInt64Array, UInt8Array, + Array, ArrayRef, BooleanArray, DictionaryArray, LargeStringArray, ListArray, StringArray, + StructArray, UInt8Array, UInt64Array, + builder::{ + Int32Builder, Int64Builder, LargeListBuilder, ListBuilder, StringBuilder, UInt32Builder, + }, }; use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, Fields}; + use lance_datagen::{RowCount, Seed, array, gen_batch}; use rstest::rstest; - use crate::{ - testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + use crate::testing::{ + TestCases, TestEncoding, check_round_trip_encoding_of_data, create_test_field_encoder, + test_encoding_strategy, }; fn make_list_type(inner_type: DataType) -> DataType { @@ -256,20 +276,197 @@ mod tests { DataType::LargeList(Arc::new(Field::new("item", inner_type, true))) } + #[derive(Clone, Copy)] + enum NullPattern { + None, + Mixed, + All, + } + + async fn check_nested_type( + data_type: DataType, + null_pattern: NullPattern, + encoding: TestEncoding, + ) { + check_nested_type_with_metadata(data_type, null_pattern, encoding, HashMap::new()).await; + } + + async fn check_nested_type_with_metadata( + data_type: DataType, + null_pattern: NullPattern, + encoding: TestEncoding, + field_metadata: HashMap, + ) { + let null_rate = match null_pattern { + NullPattern::None => None, + NullPattern::Mixed => Some(0.5), + NullPattern::All => Some(1.0), + }; + let make_batch = |seed, rows| { + let mut generator = gen_batch() + .with_seed(Seed::from(seed)) + .anon_col(array::rand_type(&data_type)); + if let Some(null_rate) = null_rate { + generator.with_random_nulls(null_rate); + } + generator + .into_batch_rows(RowCount::from(rows)) + .unwrap() + .column(0) + .clone() + }; + + // Combine a non-zero-offset slice with an independently generated batch. + // This covers both offset rebasing and rep/def accumulation at the ingest + // boundary without repeating the full generic random-test matrix. + let first = make_batch(0, 513).slice(1, 512); + let second = make_batch(1, 513); + let test_cases = TestCases::default() + .with_page_sizes(vec![4096]) + .with_encoding(encoding) + .with_batch_size(257) + .with_range(510..515) + .with_indices(vec![0, 511, 512, 1024]); + + check_round_trip_encoding_of_data(vec![first, second], &test_cases, field_metadata).await; + } + + async fn try_encode_v22_pages( + array: ArrayRef, + ) -> lance_core::Result> { + try_encode_v22_pages_with_metadata(array, HashMap::new()).await + } + + async fn try_encode_v22_pages_with_metadata( + array: ArrayRef, + field_metadata: HashMap, + ) -> lance_core::Result> { + let arrow_field = + Field::new("", array.data_type().clone(), true).with_metadata(field_metadata); + let lance_field = lance_core::datatypes::Field::try_from(&arrow_field).unwrap(); + let encoding_strategy = test_encoding_strategy(TestEncoding::StructuralU32); + let mut column_index_seq = crate::encoder::ColumnIndexSequence::default(); + let encoding_options = crate::encoder::EncodingOptions::default(); + let mut encoder = create_test_field_encoder( + encoding_strategy.as_ref(), + &lance_field, + &mut column_index_seq, + &encoding_options, + ) + .unwrap(); + let mut external_buffers = + crate::encoder::OutOfLineBuffers::new(0, crate::encoder::MIN_PAGE_BUFFER_ALIGNMENT); + let num_rows = array.len() as u64; + let mut pages = Vec::new(); + for task in encoder + .maybe_encode( + array, + &mut external_buffers, + crate::repdef::RepDefBuilder::default(), + 0, + num_rows, + ) + .unwrap() + { + pages.push(task.await?); + } + for task in encoder.flush(&mut external_buffers).unwrap() { + pages.push(task.await?); + } + Ok(pages) + } + + async fn encode_v22_pages(array: ArrayRef) -> Vec { + try_encode_v22_pages(array).await.unwrap() + } + + fn assert_split_miniblock_layout( + pages: &[crate::encoder::EncodedPage], + min_miniblock_pages: usize, + expect_structural_only_page: bool, + ) { + let mut miniblock_pages = 0; + let mut fullzip_pages = 0; + let mut structural_only_pages = 0; + + for page in pages { + let crate::decoder::PageEncoding::Structural(layout) = &page.description else { + continue; + }; + match layout.layout.as_ref().unwrap() { + crate::format::pb21::page_layout::Layout::MiniBlockLayout(_) => { + miniblock_pages += 1; + } + crate::format::pb21::page_layout::Layout::FullZipLayout(_) => { + fullzip_pages += 1; + } + crate::format::pb21::page_layout::Layout::ConstantLayout(layout) => { + if layout.inline_value.is_none() + && (layout.num_rep_values > 0 || layout.num_def_values > 0) + { + structural_only_pages += 1; + } + } + crate::format::pb21::page_layout::Layout::BlobLayout(_) => {} + crate::format::pb21::page_layout::Layout::SparseLayout(_) => {} + } + } + + assert!( + miniblock_pages >= min_miniblock_pages, + "expected at least {min_miniblock_pages} mini-block pages, got {miniblock_pages}" + ); + assert_eq!( + fullzip_pages, 0, + "split list pages should not fall back to full-zip" + ); + assert_eq!( + structural_only_pages > 0, + expect_structural_only_page, + "structural-only page presence did not match expectation; got {structural_only_pages}" + ); + } + + fn assert_has_fullzip_layout(pages: &[crate::encoder::EncodedPage]) { + let has_fullzip = pages.iter().any(|page| { + let crate::decoder::PageEncoding::Structural(layout) = &page.description else { + return false; + }; + matches!( + layout.layout.as_ref().unwrap(), + crate::format::pb21::page_layout::Layout::FullZipLayout(_) + ) + }); + assert!(has_fullzip, "expected at least one full-zip page"); + } + #[rstest] #[test_log::test(tokio::test)] async fn test_list( #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, + #[values(NullPattern::None, NullPattern::Mixed, NullPattern::All)] + null_pattern: NullPattern, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, ) { let mut field_metadata = HashMap::new(); field_metadata.insert( STRUCTURAL_ENCODING_META_KEY.to_string(), structural_encoding.into(), ); - let field = - Field::new("", make_list_type(DataType::Int32), true).with_metadata(field_metadata); - check_basic_random(field).await; + check_nested_type_with_metadata( + make_list_type(DataType::Int32), + null_pattern, + encoding, + field_metadata, + ) + .await; } #[rstest] @@ -277,55 +474,246 @@ mod tests { async fn test_deeply_nested_lists( #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, + #[values(1, 2, 3, 4, 5)] depth: usize, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + test_encoding: TestEncoding, ) { - let mut field_metadata = HashMap::new(); - field_metadata.insert( + let mut data_type = DataType::Int32; + for _ in 0..depth { + data_type = make_list_type(data_type); + } + + let mut generator = gen_batch() + .with_seed(Seed::from(depth as u64)) + .anon_col(array::rand_type(&data_type)); + generator.with_random_nulls(0.2); + let source = generator + .into_batch_rows(RowCount::from(1026)) + .unwrap() + .column(0) + .clone(); + + // Two non-zero-offset slices cover nested offset rebasing across ingest + // batches. The selected range and indices straddle that batch boundary. + let data = vec![source.slice(1, 512), source.slice(513, 513)]; + let test_cases = TestCases::default() + .with_page_sizes(vec![4096]) + .with_encoding(test_encoding) + .with_batch_size(257) + .with_range(510..515) + .with_indices(vec![0, 511, 512, 1024]); + let field_metadata = HashMap::from([( STRUCTURAL_ENCODING_META_KEY.to_string(), structural_encoding.into(), - ); - let field = Field::new("item", DataType::Int32, true).with_metadata(field_metadata); - for _ in 0..5 { - let field = Field::new("", make_list_type(field.data_type().clone()), true); - check_basic_random(field).await; - } + )]); + + check_round_trip_encoding_of_data(data, &test_cases, field_metadata).await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_large_list() { - let field = Field::new("", make_large_list_type(DataType::Int32), true); - check_basic_random(field).await; + async fn test_large_list( + #[values(NullPattern::None, NullPattern::Mixed, NullPattern::All)] + null_pattern: NullPattern, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + check_nested_type( + make_large_list_type(DataType::Int32), + null_pattern, + encoding, + ) + .await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_nested_strings() { - let field = Field::new("", make_list_type(DataType::Utf8), true); - check_basic_random(field).await; + async fn test_nested_strings( + #[values(NullPattern::None, NullPattern::Mixed, NullPattern::All)] + null_pattern: NullPattern, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + check_nested_type(make_list_type(DataType::Utf8), null_pattern, encoding).await; + } + + #[rstest] + #[test_log::test(tokio::test)] + async fn test_nested_list( + #[values(NullPattern::None, NullPattern::Mixed, NullPattern::All)] + null_pattern: NullPattern, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + check_nested_type( + make_list_type(make_list_type(DataType::Int32)), + null_pattern, + encoding, + ) + .await; } + /// Regression test: a `List>` column written as MULTIPLE + /// batches (chunks) whose flattened leaf values cross a value-page boundary + /// fails to decode with "Max offset N exceeds length of values M" (Arrow + /// error raised by `ListArray::try_new` in `StructuralListDecodeTask::decode`). + /// + /// The trigger (verified against the production file and pylance 7.0.0b12 / + /// 7.0.0 / 9.0.0-beta.10) requires ALL of: + /// 1. >= 2 list layers (`List>`), + /// 2. a leaf large enough to be chunked into multiple value pages, + /// 3. the column written as more than one batch. + /// A single batch of the identical data round-trips fine — which is why the + /// earlier single-chunk version of this test (and the small `test_nested_list` + /// cases) did not catch it. Found in production on the gaming TransNet + /// `dino_embedding_per_frame` column (rectangular 3 x 768 float per row). + /// + /// Each element of the `vec![..]` passed to `check_round_trip_encoding_of_data` + /// is encoded as a separate batch (its own `RepDefBuilder`), so we split the + /// rows into two chunks to exercise the multi-batch repdef accumulation path. + #[rstest] #[test_log::test(tokio::test)] - async fn test_nested_list() { - let field = Field::new("", make_list_type(make_list_type(DataType::Int32)), true); - check_basic_random(field).await; + async fn test_multipage_nested_float_list( + #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] + structural_encoding: &str, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + test_encoding: TestEncoding, + ) { + use arrow_array::Float32Array; + + // Production shape: 3 inner lists per row, 768 floats each. + let inner_per_row: usize = 3; + let inner_len: usize = 768; + // Each chunk contains ~1.38 MiB of leaf values, so both cross the 1 MiB + // value-page limit. A read batch that spans the ingest boundary is where + // the multi-page outer-offset bug triggered. + let chunk_rows: &[usize] = &[150, 149]; + + let make_chunk = |start_row: usize, num_rows: usize| -> Arc { + let total_inner = num_rows * inner_per_row; + let total_values = total_inner * inner_len; + let values = Float32Array::from( + (0..total_values) + .map(|i| (start_row + i) as f32) + .collect::>(), + ); + let inner_offsets = ScalarBuffer::::from( + (0..=total_inner) + .map(|i| (i * inner_len) as i32) + .collect::>(), + ); + let inner_list = ListArray::new( + Arc::new(Field::new("item", DataType::Float32, true)), + OffsetBuffer::new(inner_offsets), + Arc::new(values), + None, + ); + let outer_offsets = ScalarBuffer::::from( + (0..=num_rows) + .map(|i| (i * inner_per_row) as i32) + .collect::>(), + ); + Arc::new(ListArray::new( + Arc::new(Field::new( + "item", + DataType::List(Arc::new(Field::new("item", DataType::Float32, true))), + true, + )), + OffsetBuffer::new(outer_offsets), + Arc::new(inner_list), + None, + )) + }; + + let mut start = 0; + let chunks: Vec> = chunk_rows + .iter() + .map(|&n| { + let c = make_chunk(start, n); + start += n; + c + }) + .collect(); + + let mut field_metadata = HashMap::new(); + field_metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + structural_encoding.into(), + ); + + let test_cases = TestCases::default() + .with_page_sizes(vec![1024 * 1024]) + .with_encoding(test_encoding) + .with_batch_size(151) + .with_range(148..152) + .with_indices(vec![0, 149, 150, 298]); + check_round_trip_encoding_of_data(chunks, &test_cases, field_metadata).await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_list_struct_list() { + async fn test_list_struct_list( + #[values(NullPattern::None, NullPattern::Mixed, NullPattern::All)] + null_pattern: NullPattern, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { let struct_type = DataType::Struct(Fields::from(vec![Field::new( "inner_str", DataType::Utf8, false, )])); - let field = Field::new("", make_list_type(struct_type), true); - check_basic_random(field).await; + check_nested_type(make_list_type(struct_type), null_pattern, encoding).await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_list_struct_empty() { + async fn test_list_struct_empty( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { let fields = Fields::from(vec![Field::new("inner", DataType::UInt64, true)]); let items = UInt64Array::from(Vec::::new()); let structs = StructArray::new(fields, vec![Arc::new(items)], None); - let offsets = OffsetBuffer::new(ScalarBuffer::::from(vec![0; 2 * 1024 * 1024 + 1])); + // Exceed two 1 MiB offset pages so flushing the empty struct child is + // still exercised multiple times (the original #2762 regression). + let num_rows = 2 * 1024 * 1024 / size_of::() + 1; + let offsets = OffsetBuffer::new(ScalarBuffer::::from(vec![0; num_rows + 1])); let lists = ListArray::new( Arc::new(Field::new("item", structs.data_type().clone(), true)), offsets, @@ -335,7 +723,9 @@ mod tests { check_round_trip_encoding_of_data( vec![Arc::new(lists)], - &TestCases::default(), + &TestCases::default() + .with_page_sizes(vec![1024 * 1024]) + .with_encoding(encoding), HashMap::new(), ) .await; @@ -414,7 +804,7 @@ mod tests { .with_range(5..7) .with_indices(vec![1, 6]) .with_indices(vec![6]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(outer_list)], &test_cases, field_metadata) .await; } @@ -445,7 +835,7 @@ mod tests { .with_range(1..3) .with_indices(vec![1, 3]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, field_metadata) .await; } @@ -476,7 +866,7 @@ mod tests { .with_range(1..3) .with_indices(vec![1, 3]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, field_metadata) .await; } @@ -508,7 +898,7 @@ mod tests { .with_range(1..2) .with_indices(vec![0]) .with_indices(vec![1]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, field_metadata) .await; } @@ -560,7 +950,7 @@ mod tests { .with_range(1..2) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data( vec![Arc::new(list_arr)], &test_cases, @@ -599,7 +989,7 @@ mod tests { .with_range(1..2) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_arr)], &test_cases, field_metadata) .await; } @@ -635,7 +1025,7 @@ mod tests { ); let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) + .with_structural_encodings() .with_page_sizes(vec![100]) .with_range(800..900); check_round_trip_encoding_of_data( @@ -795,7 +1185,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..2) .with_indices(vec![1]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data( vec![struct_array.clone()], &test_cases, @@ -817,7 +1207,7 @@ mod tests { outer_list_builder.append_null(); let list_array = Arc::new(outer_list_builder.finish()); - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; } @@ -882,11 +1272,391 @@ mod tests { // This should trigger the assertion failure at primitive.rs:1362 // debug_assert!(rows_avail > 0) - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); // The bug manifests when encoding this specific pattern // Expected: successful round-trip encoding // 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, + ) { + // Three chunks' worth of rep/def levels (1 rep bit + 1 def bit each), so the + // planner must split the page. See #6184. + let levels_per_chunk = + crate::encodings::logical::primitive::miniblock::max_repdef_levels_per_chunk(2); + let num_rows = (levels_per_chunk * 3) as u32; + 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 list_array = Arc::new(list_array) as ArrayRef; + let pages = try_encode_v22_pages_with_metadata(list_array.clone(), field_metadata.clone()) + .await + .unwrap(); + if structural_encoding == STRUCTURAL_ENCODING_MINIBLOCK { + assert_split_miniblock_layout(&pages, 2, true); + } + + let chunk_boundary = levels_per_chunk; + let test_cases = TestCases::default() + .with_range(chunk_boundary - 2..chunk_boundary + 2) + .with_indices(vec![ + 0, + (step / 2) as u64, + chunk_boundary - 1, + chunk_boundary, + num_rows as u64 - 1, + ]) + .with_batch_size(64 * 1024) + .with_page_sizes(vec![1024 * 1024]) + .with_encoding(TestEncoding::StructuralU32); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, field_metadata).await; + } + + #[test_log::test(tokio::test)] + async fn test_sparse_boolean_list_uses_miniblock() { + // Redacted reproduction from a production schema shape containing ARRAY(BOOLEAN). + // The field names are not relevant; the failure requires sparse list structure + // with a 1-bit Boolean leaf value. + let levels_per_chunk = + crate::encodings::logical::primitive::miniblock::max_repdef_levels_per_chunk(2); + // One row past the chunk limit forces a split. Keeping values at both ends ensures + // both sides of that split remain mini-block pages instead of structural-only pages. + let num_rows = (levels_per_chunk + 1) as usize; + let booleans_per_list = 8usize; + + let mut offsets = Vec::with_capacity(num_rows + 1); + let mut values = Vec::with_capacity(2 * booleans_per_list); + offsets.push(0i32); + + for row in 0..num_rows { + if row == 0 || row == num_rows - 1 { + values.extend((0..booleans_per_list).map(|idx| idx % 2 == 0)); + } + offsets.push(values.len() as i32); + } + + let items = BooleanArray::from(values); + let list_array = ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(items), + None, + ); + + let test_cases = TestCases::default() + .with_range(0..1000) + .with_indices(vec![0, levels_per_chunk / 2, num_rows as u64 - 1]) + .with_batch_size(64 * 1024) + .with_page_sizes(vec![1024 * 1024]) + .with_encoding(TestEncoding::StructuralU32); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = encode_v22_pages(list_array.clone()).await; + assert_split_miniblock_layout(&pages, 2, false); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } + + #[test_log::test(tokio::test)] + async fn test_sparse_boolean_list_with_long_empty_prefix() { + let empty_prefix_rows = 70_000usize; + let trailing_empty_rows = 9usize; + let booleans_per_list = 8usize; + let num_rows = empty_prefix_rows + 1 + trailing_empty_rows; + + let mut offsets = Vec::with_capacity(num_rows + 1); + offsets.extend(std::iter::repeat_n(0i32, empty_prefix_rows + 1)); + let values = (0..booleans_per_list) + .map(|idx| idx % 2 == 0) + .collect::>(); + offsets.push(values.len() as i32); + offsets.extend(std::iter::repeat_n( + values.len() as i32, + trailing_empty_rows, + )); + + let items = BooleanArray::from(values); + let list_array = ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(items), + None, + ); + + let test_cases = TestCases::default() + .with_range(0..num_rows as u64) + .with_indices(vec![0, empty_prefix_rows as u64, num_rows as u64 - 1]) + .with_dense_encodings(); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = encode_v22_pages(list_array.clone()).await; + assert_split_miniblock_layout(&pages, 1, true); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } + + #[rstest] + #[test_log::test(tokio::test)] + async fn test_sparse_boolean_list_with_long_null_prefix( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32 + )] + encoding: TestEncoding, + ) { + let null_prefix_rows = 70_000usize; + let trailing_empty_rows = 9usize; + let booleans_per_list = 8usize; + let num_rows = null_prefix_rows + 1 + trailing_empty_rows; + + let mut offsets = Vec::with_capacity(num_rows + 1); + offsets.extend(std::iter::repeat_n(0i32, null_prefix_rows + 1)); + let values = (0..booleans_per_list) + .map(|idx| idx % 2 == 0) + .collect::>(); + offsets.push(values.len() as i32); + offsets.extend(std::iter::repeat_n( + values.len() as i32, + trailing_empty_rows, + )); + let validity = BooleanBuffer::from_iter((0..num_rows).map(|row| row >= null_prefix_rows)); + + let items = BooleanArray::from(values); + let list_array = ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(items), + Some(NullBuffer::new(validity)), + ); + + let test_cases = TestCases::default() + .with_range(0..num_rows as u64) + .with_indices(vec![0, null_prefix_rows as u64, num_rows as u64 - 1]) + .with_encoding(encoding); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = encode_v22_pages(list_array.clone()).await; + assert_split_miniblock_layout(&pages, 1, true); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } + + #[test_log::test(tokio::test)] + async fn test_sparse_boolean_list_with_amortized_long_empty_prefix() { + let empty_prefix_rows = 62_000usize; + let booleans_per_list = 8_192usize; + let num_rows = empty_prefix_rows + 1; + + let mut offsets = Vec::with_capacity(num_rows + 1); + offsets.extend(std::iter::repeat_n(0i32, empty_prefix_rows + 1)); + let values = (0..booleans_per_list) + .map(|idx| idx % 2 == 0) + .collect::>(); + offsets.push(values.len() as i32); + + let items = BooleanArray::from(values); + let list_array = ListArray::new( + Arc::new(Field::new("item", DataType::Boolean, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + Arc::new(items), + None, + ); + + let test_cases = TestCases::default() + .with_range(0..num_rows as u64) + .with_indices(vec![0, empty_prefix_rows as u64]) + .with_dense_encodings(); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = encode_v22_pages(list_array.clone()).await; + assert_split_miniblock_layout(&pages, 1, true); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } + + fn unsplittable_nested_list(items: ArrayRef, empty_inner_lists: usize) -> ArrayRef { + let mut inner_offsets = vec![0i32; empty_inner_lists + 1]; + inner_offsets.push(items.len() as i32); + let inner_list = ListArray::new( + Arc::new(Field::new("item", items.data_type().clone(), true)), + OffsetBuffer::new(ScalarBuffer::from(inner_offsets)), + items, + None, + ); + Arc::new(ListArray::new( + Arc::new(Field::new("item", inner_list.data_type().clone(), true)), + OffsetBuffer::new(ScalarBuffer::from(vec![0i32, empty_inner_lists as i32 + 1])), + Arc::new(inner_list), + None, + )) + } + + #[rstest] + #[case::boolean(Arc::new(BooleanArray::from(vec![true, false, true, false, true, false, true, false])))] + #[case::string(Arc::new(StringArray::from(vec!["value", "other"])))] + #[test_log::test(tokio::test)] + async fn test_nested_sparse_single_row_falls_back_to_fullzip(#[case] items: ArrayRef) { + let list = unsplittable_nested_list(items, 70_000); + let pages = encode_v22_pages(list.clone()).await; + assert_has_fullzip_layout(&pages); + + let test_cases = TestCases::default() + .with_range(0..1) + .with_indices(vec![0]) + .with_dense_encodings(); + check_round_trip_encoding_of_data(vec![list], &test_cases, HashMap::new()).await; + } + + /// Builds the HNSW-flush repro shape: a dense prefix where every row has + /// `NEIGHBORS_PER_ROW` distinct values, followed by a long tail of empty + /// lists. Mirrors `HNSW::schema()` `__neighbors` / `__dists` columns: + /// dense level-0 lists, then ~6x as many mostly-empty higher-level rows. + fn make_hnsw_shaped_list_u32() -> ListArray { + const DENSE_ROWS: u32 = 5_000; + const NEIGHBORS_PER_ROW: u32 = 32; + const EMPTY_TAIL_ROWS: u32 = 70_000; + + let mut list_builder = ListBuilder::new(UInt32Builder::new()); + let mut next_val: u32 = 0; + for _ in 0..DENSE_ROWS { + for _ in 0..NEIGHBORS_PER_ROW { + list_builder.values().append_value(next_val); + next_val = next_val.wrapping_add(1); + } + list_builder.append(true); + } + for _ in 0..EMPTY_TAIL_ROWS { + list_builder.append(true); + } + list_builder.finish() + } + + /// Reproduces the HNSW-flush shape at v2.2 on the auto path (no + /// `STRUCTURAL_ENCODING` metadata): a dense level-0 prefix followed by a + /// long tail of empty lists. The global levels/values ratio looks dense, + /// so this used to encode as a single mini-block page whose final chunk + /// absorbed every trailing empty list and overflowed the per-chunk `u16` + /// `num_levels`, corrupting the read. The structural page planner now + /// splits on top-level row boundaries: the dense prefix stays on + /// mini-block pages and the empty tail becomes structural-only pages, so + /// the round-trip is lossless without falling back to full-zip. + #[test_log::test(tokio::test)] + async fn test_list_hnsw_shape_splits_to_miniblock_v2_2() { + let list_array = make_hnsw_shaped_list_u32(); + let dense_rows: u64 = 5_000; + let total_rows = list_array.len() as u64; + + let test_cases = TestCases::default() + .with_range(0..1000) + .with_range(dense_rows.saturating_sub(8)..(dense_rows + 8)) + .with_range(0..total_rows) + .with_indices(vec![0, dense_rows - 1, dense_rows, total_rows - 1]) + .with_encoding(TestEncoding::StructuralU32); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = encode_v22_pages(list_array.clone()).await; + assert_split_miniblock_layout(&pages, 1, true); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } + + /// Companion to the auto-path test: even when the user explicitly requests + /// `STRUCTURAL_ENCODING_MINIBLOCK`, the structural page planner splits the + /// HNSW shape so every emitted page fits the mini-block per-chunk budget. + /// The request is honored (the dense prefix stays on mini-block pages + /// rather than being forced to full-zip) and the round-trip is lossless. + #[test_log::test(tokio::test)] + async fn test_forced_miniblock_hnsw_shape_splits_to_miniblock_v2_2() { + let list_array = make_hnsw_shaped_list_u32(); + let total_rows = list_array.len() as u64; + + let mut field_metadata = HashMap::new(); + field_metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_MINIBLOCK.into(), + ); + + let test_cases = TestCases::default() + .with_range(0..total_rows) + .with_encoding(TestEncoding::StructuralU32); + let list_array = Arc::new(list_array) as ArrayRef; + let pages = try_encode_v22_pages_with_metadata(list_array.clone(), field_metadata.clone()) + .await + .unwrap(); + assert_split_miniblock_layout(&pages, 1, true); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, field_metadata).await; + } + + #[test] + fn test_plan_decoded_bytes_list_i32_not_supported() { + // Lists encode items using repetition levels so the number of decoded + // bytes depends on the per-row list lengths, which are not known at + // planning time without inspecting page data. For this PR we + // explicitly reject the call rather than returning a wrong estimate. + use crate::decoder::StructuralFieldDecoder; + use crate::encodings::logical::primitive::StructuralPrimitiveFieldDecoder; + use arrow_array::builder::{Int32Builder, ListBuilder}; + use arrow_schema::Field; + + // Build a List array where every row has a random (variable) length + // to illustrate the case we cannot plan for. + let mut builder = ListBuilder::new(Int32Builder::new()); + let lengths = [0usize, 3, 1, 7, 2, 0, 5]; + let mut value = 0i32; + for &len in &lengths { + for _ in 0..len { + builder.values().append_value(value); + value += 1; + } + builder.append(true); + } + let _list_array = builder.finish(); // variable-length rows confirmed + + // Construct the decoder tree directly (no pages needed for this check). + let item_field = Arc::new(Field::new("item", DataType::Int32, true)); + let list_type = DataType::List(item_field.clone()); + + let child = Box::new(StructuralPrimitiveFieldDecoder::new(&item_field, false)); + let decoder = super::StructuralListDecoder::new(child, list_type); + + let err = decoder + .plan_decoded_bytes(lengths.len() as u64) + .unwrap_err(); + assert!( + matches!(err, lance_core::Error::NotSupported { .. }), + "expected NotSupported, got: {err:?}" + ); + assert!( + err.to_string().contains("repetition"), + "error should mention repetition, got: {err}" + ); + } } diff --git a/rust/lance-encoding/src/encodings/logical/map.rs b/rust/lance-encoding/src/encodings/logical/map.rs index 8d70b0fa532..15a00471d84 100644 --- a/rust/lance-encoding/src/encodings/logical/map.rs +++ b/rust/lance-encoding/src/encodings/logical/map.rs @@ -9,7 +9,6 @@ use futures::future::BoxFuture; use lance_arrow::deepcopy::deep_copy_nulls; use lance_arrow::list::ListArrayExt; use lance_core::{Error, Result}; -use snafu::location; use crate::{ decoder::{ @@ -113,10 +112,13 @@ impl StructuralFieldScheduler for StructuralMapScheduler { fn initialize<'a>( &'a mut self, + requested_ranges: Option<&'a [Range]>, filter: &'a FilterExpression, context: &'a SchedulerContext, ) -> BoxFuture<'a, Result<()>> { - self.child.initialize(filter, context) + // Like lists, the child (entries) column is scheduled with the same + // top-level row ranges, so forward them unchanged. + self.child.initialize(requested_ranges, filter, context) } } @@ -188,7 +190,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::()?; @@ -197,20 +203,18 @@ impl StructuralDecodeArrayTask for StructuralMapDecodeTask { let (entries_field, keys_sorted) = match &self.data_type { DataType::Map(field, keys_sorted) => { if *keys_sorted { - return Err(Error::NotSupported { - source: "Map type decoder does not support keys_sorted=true now" + return Err(Error::not_supported_source( + "Map type decoder does not support keys_sorted=true now" .to_string() .into(), - location: location!(), - }); + )); } (field.clone(), *keys_sorted) } _ => { - return Err(Error::Schema { - message: "Map decoder did not have a map field".to_string(), - location: location!(), - }); + return Err(Error::schema( + "Map decoder did not have a map field".to_string(), + )); } }; @@ -218,18 +222,17 @@ impl StructuralDecodeArrayTask for StructuralMapDecodeTask { let entries = array .as_any() .downcast_ref::() - .ok_or_else(|| Error::Schema { - message: "Map entries should be a StructArray".to_string(), - location: location!(), - })? + .ok_or_else(|| Error::schema("Map entries should be a StructArray".to_string()))? .clone(); // Build the MapArray from offsets, entries, validity, and keys_sorted - let map_array = MapArray::new(entries_field, offsets, entries, validity, keys_sorted); + let map_array = MapArray::try_new(entries_field, offsets, entries, validity, keys_sorted) + .map_err(|error| Error::invalid_input_source(error.to_string().into()))?; Ok(DecodedArray { array: Arc::new(map_array), repdef, + data_size, }) } } @@ -239,20 +242,27 @@ mod tests { use std::{collections::HashMap, sync::Arc}; use arrow_array::{ - builder::{Int32Builder, MapBuilder, StringBuilder}, Array, Int32Array, MapArray, StringArray, StructArray, + builder::{Int32Builder, MapBuilder, StringBuilder}, }; use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, Fields}; - use crate::encoder::{default_encoding_strategy, ColumnIndexSequence, EncodingOptions}; - use crate::{ - testing::{check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + use crate::decoder::{DecodedArray, StructuralDecodeArrayTask}; + use crate::encoder::{ColumnIndexSequence, EncodingOptions}; + use crate::encodings::logical::primitive::sparse::{ + SparseCountSet, SparsePositionSet, SparseStructuralLayerPlan, SparseStructuralPlan, + SparseValidityMeaning, SparseValiditySet, + }; + use crate::repdef::{CompositeRepDefUnraveler, RepDefUnraveler}; + use crate::testing::{ + TestCases, TestEncoding, check_round_trip_encoding_of_data, test_encoding_strategy, }; use arrow_schema::Field as ArrowField; use lance_core::datatypes::Field as LanceField; + use super::StructuralMapDecodeTask; + fn make_map_type(key_type: DataType, value_type: DataType) -> DataType { // Note: Arrow MapBuilder uses "keys" and "values" as field names (plural) let entries = Field::new( @@ -266,6 +276,70 @@ mod tests { DataType::Map(Arc::new(entries), false) } + #[derive(Debug)] + struct StaticMapEntriesTask { + entries: StructArray, + repdef: CompositeRepDefUnraveler, + } + + impl StructuralDecodeArrayTask for StaticMapEntriesTask { + fn decode(self: Box) -> lance_core::Result { + let Self { entries, repdef } = *self; + Ok(DecodedArray { + array: Arc::new(entries), + repdef, + data_size: 0, + }) + } + } + + #[test] + fn malformed_sparse_map_entries_return_invalid_input() { + let entry_fields = Fields::from(vec![ + Field::new("keys", DataType::Int32, false), + Field::new("values", DataType::Int32, true), + ]); + let entries = StructArray::try_new( + entry_fields.clone(), + vec![ + Arc::new(Int32Array::from(vec![1])), + Arc::new(Int32Array::from(vec![2])), + ], + Some(NullBuffer::from(vec![false])), + ) + .unwrap(); + let validity = SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions: SparsePositionSet::Empty, + }; + let plan = SparseStructuralPlan { + layers: vec![SparseStructuralLayerPlan::List { + num_slots: 1, + num_child_slots: 1, + non_empty_positions: SparsePositionSet::All { len: 1 }, + counts: SparseCountSet::Constant { value: 1, len: 1 }, + validity, + }], + num_items: 1, + num_visible_items: 1, + }; + let child_task = StaticMapEntriesTask { + entries, + repdef: CompositeRepDefUnraveler::new(vec![RepDefUnraveler::new_sparse(plan)]), + }; + let map_type = DataType::Map( + Arc::new(Field::new("entries", DataType::Struct(entry_fields), false)), + false, + ); + + let Err(err) = + Box::new(StructuralMapDecodeTask::new(Box::new(child_task), map_type)).decode() + else { + panic!("expected malformed map entries to be rejected"); + }; + assert!(matches!(err, lance_core::Error::InvalidInput { .. })); + } + #[test_log::test(tokio::test)] async fn test_simple_map() { // Create a simple Map @@ -289,7 +363,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..2) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -322,7 +396,7 @@ mod tests { .with_range(0..4) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -353,7 +427,7 @@ mod tests { .with_range(0..2) .with_indices(vec![0]) .with_indices(vec![1]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -402,7 +476,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..3) .with_indices(vec![0, 2]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data( vec![Arc::new(struct_array)], @@ -456,7 +530,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..3) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data( vec![Arc::new(struct_array)], @@ -502,7 +576,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..3) .with_indices(vec![0, 2]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, HashMap::new()) .await; @@ -562,7 +636,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..1) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(outer_map)], &test_cases, HashMap::new()) .await; @@ -592,7 +666,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..2) .with_indices(vec![0, 1]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -619,7 +693,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..2) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -640,7 +714,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..2) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(map_array)], &test_cases, HashMap::new()) .await; @@ -675,7 +749,7 @@ mod tests { let test_cases = TestCases::default() .with_range(0..3) .with_indices(vec![0, 1, 2]) - .with_min_file_version(LanceFileVersion::V2_2); + .with_u32_structural_encodings(); // This test ensures that regardless of the internal keep_original_array setting, // the end-to-end behavior produces equivalent results @@ -694,11 +768,11 @@ mod tests { let map_field = LanceField::try_from(&map_arrow_field).unwrap(); // Test encoder: Try to create encoder with V2_1 version - should fail - let encoder_strategy = default_encoding_strategy(LanceFileVersion::V2_1); + let encoder_strategy = test_encoding_strategy(TestEncoding::StructuralU16); let mut column_index = ColumnIndexSequence::default(); let options = EncodingOptions::default(); - let encoder_result = encoder_strategy.create_field_encoder( + let encoder_result = crate::testing::create_test_field_encoder( encoder_strategy.as_ref(), &map_field, &mut column_index, @@ -715,9 +789,8 @@ mod tests { let encoder_err_msg = format!("{}", encoder_err); assert!( - encoder_err_msg.contains("2.2"), - "Encoder error message should mention version 2.2, got: {}", - encoder_err_msg + encoder_err_msg.contains("not enabled by the selected file format"), + "unexpected encoder error: {encoder_err_msg}" ); assert!( encoder_err_msg.contains("Map data type"), diff --git a/rust/lance-encoding/src/encodings/logical/primitive.rs b/rust/lance-encoding/src/encodings/logical/primitive.rs index 9e22c414d48..d22f78101d7 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive.rs @@ -15,35 +15,42 @@ use std::{ use crate::{ constants::{ STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, STRUCTURAL_ENCODING_MINIBLOCK, + STRUCTURAL_ENCODING_SPARSE, }, data::DictionaryDataBlock, encodings::logical::primitive::blob::{BlobDescriptionPageScheduler, BlobPageScheduler}, format::{ - pb21::{self, compressive_encoding::Compression, CompressiveEncoding, PageLayout}, ProtobufUtils21, + pb21::{self, CompressiveEncoding, PageLayout, compressive_encoding::Compression}, }, }; -use arrow_array::{cast::AsArray, make_array, types::UInt64Type, Array, ArrayRef, PrimitiveArray}; +use arrow_array::{ + Array, ArrayRef, PrimitiveArray, cast::AsArray, make_array, new_null_array, types::UInt64Type, +}; use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, NullBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field as ArrowField}; use bytes::Bytes; -use futures::{future::BoxFuture, stream::FuturesOrdered, FutureExt, TryStreamExt}; +use futures::{FutureExt, TryStreamExt, future::BoxFuture, stream::FuturesOrdered}; use itertools::Itertools; -use lance_arrow::deepcopy::deep_copy_nulls; use lance_arrow::DataTypeExt; +use lance_arrow::deepcopy::deep_copy_nulls; use lance_core::{ - cache::{CacheKey, Context, DeepSizeOf}, + cache::{CacheKey, CacheKeySchema, Context, DeepSizeOf, KeyBuilder, LanceCache}, error::{Error, LanceOptionExt}, utils::bit::pad_bytes, }; -use log::trace; -use snafu::location; +use log::{debug, trace}; +use crate::encodings::logical::primitive::miniblock::MiniBlockChunk; +use crate::encodings::physical::rle::{RleDecompressor, RleRuns}; +use crate::utils::bytepack::ByteUnpacker; use crate::{ compression::{ BlockDecompressor, CompressionStrategy, DecompressionStrategy, MiniBlockDecompressor, + compress_required_block, create_rle_decompressor, }, data::{AllNullDataBlock, DataBlock, VariableWidthBlock}, + decoder::{IO_REQUEST_BATCH_BYTES, RequestBatch}, utils::bytepack::BytepackedIntegerEncoder, }; use crate::{ @@ -51,25 +58,27 @@ use crate::{ encodings::logical::primitive::fullzip::PerValueDataBlock, }; use crate::{ - encodings::logical::primitive::miniblock::MiniBlockChunk, utils::bytepack::ByteUnpacker, -}; -use crate::{ - encodings::logical::primitive::miniblock::MiniBlockCompressed, + encodings::logical::primitive::miniblock::{MiniBlockCompressed, MiniBlockCompressionContext}, statistics::{ComputeStat, GetStat, Stat}, }; use crate::{ repdef::{ - build_control_word_iterator, CompositeRepDefUnraveler, ControlWordIterator, - ControlWordParser, DefinitionInterpretation, RepDefSlicer, + CompositeRepDefUnraveler, ControlWordIterator, ControlWordParser, DefinitionInterpretation, + MiniBlockRepDefBudget, NormalizedStructuralPlan, RepDefSlicer, SerializedRepDefs, + build_control_word_iterator, max_visible_level, }, utils::accumulation::AccumulationQueue, }; -use lance_core::{datatypes::Field, utils::tokio::spawn_cpu, Result}; +use lance_core::{Result, datatypes::Field, utils::tokio::spawn_cpu}; -use crate::constants::DICT_SIZE_RATIO_META_KEY; -use crate::encodings::logical::primitive::dict::DICT_INDICES_BITS_PER_VALUE; -use crate::version::LanceFileVersion; +use crate::constants::{ + COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY, DICT_DIVISOR_META_KEY, + DICT_SIZE_RATIO_META_KEY, DICT_VALUES_COMPRESSION_ENV_VAR, + DICT_VALUES_COMPRESSION_LEVEL_ENV_VAR, DICT_VALUES_COMPRESSION_LEVEL_META_KEY, + DICT_VALUES_COMPRESSION_META_KEY, +}; use crate::{ + EncodingsIo, buffer::LanceBuffer, data::{BlockInfo, DataBlockBuilder, FixedWidthDataBlock}, decoder::{ @@ -82,32 +91,160 @@ use crate::{ EncodeTask, EncodedColumn, EncodedPage, EncodingOptions, FieldEncoder, OutOfLineBuffers, }, repdef::{LevelBuffer, RepDefBuilder, RepDefUnraveler}, - EncodingsIo, }; pub mod blob; +mod chunk_index; pub mod constant; pub mod dict; pub mod fullzip; +mod layout; pub mod miniblock; +pub(crate) mod sparse; + +use chunk_index::{ItemCounts, MiniBlockChunkIndex, PrefixSums, RowMapping, parse_nested_rep}; const FILL_BYTE: u8 = 0xFE; +const DEFAULT_DICT_DIVISOR: u64 = 2; +const DEFAULT_DICT_MAX_CARDINALITY: u64 = 100_000; +const DEFAULT_DICT_SIZE_RATIO: f64 = 0.8; +const DEFAULT_DICT_VALUES_COMPRESSION: &str = "lz4"; + +/// Largest level count a direct legacy u16-value/U8-run RLE frame can prove. +/// +/// Mini-block level payload sizes are u16. After the eight-byte frame header, each run needs a +/// two-byte value and a one-byte length, and each U8 run can represent at most 255 levels. +const MAX_LEGACY_RLE_LEVELS: u64 = ((u16::MAX as u64 - std::mem::size_of::() as u64) + / (std::mem::size_of::() as u64 + std::mem::size_of::() as u64)) + * u8::MAX as u64; struct PageLoadTask { decoder_fut: BoxFuture<'static, Result>>, num_rows: u64, } +/// The ordered initialization-buffer slots for one page. +/// +/// Optional slots preserve the semantic position of absent buffers so range +/// declaration and buffer consumption share one representation. +#[derive(Debug)] +struct PageInitialization { + buffer_ranges: Vec>>, +} + +impl PageInitialization { + fn new(buffer_ranges: impl IntoIterator>>) -> Self { + Self { + buffer_ranges: buffer_ranges.into_iter().collect(), + } + } + + fn required_ranges(&self) -> impl Iterator> { + self.buffer_ranges.iter().flatten() + } + + fn num_required_ranges(&self) -> usize { + self.required_ranges().count() + } + + fn with_buffers(self, buffers: Vec) -> Result { + let expected = self.num_required_ranges(); + if buffers.len() != expected { + return Err(Error::invalid_input_source( + format!( + "Page initialization received {} buffers for {expected} declared ranges", + buffers.len() + ) + .into(), + )); + } + + let mut buffers = buffers.into_iter(); + let mut buffer_slots = Vec::with_capacity(self.buffer_ranges.len()); + for range in self.buffer_ranges { + if range.is_some() { + let buffer = buffers.next().ok_or_else(|| { + Error::internal("Page initialization buffer was not assigned") + })?; + buffer_slots.push(Some(buffer)); + } else { + buffer_slots.push(None); + } + } + debug_assert!( + buffers.next().is_none(), + "validated page initialization buffer count must be exhausted" + ); + Ok(PageInitializationBuffers { buffer_slots }) + } +} + +#[derive(Debug)] +struct PageInitializationBuffers { + buffer_slots: Vec>, +} + +impl PageInitializationBuffers { + fn try_into_array(self, layout: &str) -> Result<[Option; N]> { + let actual = self.buffer_slots.len(); + self.buffer_slots.try_into().map_err(|_| { + Error::internal(format!( + "{layout} page initialization declared {actual} buffer slots, expected {N}" + )) + }) + } + + fn into_required(self, layout: &str) -> Result> { + self.buffer_slots + .into_iter() + .enumerate() + .map(|(index, buffer)| { + buffer.ok_or_else(|| { + Error::internal(format!( + "{layout} page initialization buffer slot {index} is absent" + )) + }) + }) + .collect() + } +} + /// A trait for figuring out how to schedule the data within /// a single page. trait StructuralPageScheduler: std::fmt::Debug + Send { - /// Fetches any metadata required for the page - fn initialize<'a>( + /// Whether this page requires initialized metadata before scheduling. + fn needs_initialization(&self) -> bool { + true + } + + /// Describes the file byte ranges this page must read to initialize its + /// metadata (chunk metadata, dictionary, repetition index, ...). + /// + /// The field scheduler sorts the combined I/O request by file offset and + /// restores buffers to the declared slots afterward. + fn init_layout(&self) -> Result; + + /// Returns initialized state from the bytes declared by [`Self::init_layout`]. + /// + /// The caller attaches the returned state with [`Self::try_load`]. + fn init_from_buffers<'a>( &'a mut self, + buffers: PageInitializationBuffers, io: &Arc, ) -> BoxFuture<'a, Result>>; - /// Loads metadata from a previous initialize call - fn load(&mut self, data: &Arc); + + /// Tries to attach metadata from a previous initialization. + fn try_load(&mut self, data: &Arc) -> Result<()>; + + // Identifies the requested decode shape (e.g. blob descriptor struct vs + // raw bytes). Blob columns can produce multiple page scheduler variants + // for the same physical column depending on the target field's data type, + // and the cached page state types differ per variant. The stable view is + // mixed into the cache key so different variants do not collide. + fn cache_view(&self) -> PageCacheView { + PageCacheView::Native + } + /// Schedules the read of the given ranges in the page /// /// The read may be split into multiple "shards" if the page is extremely large. @@ -160,16 +297,129 @@ struct DecodeMiniBlockTask { } impl DecodeMiniBlockTask { + fn decoded_size_bytes(&self) -> Option { + if self.rep_decompressor.is_some() || self.def_decompressor.is_some() { + return None; + } + let num_values = self + .instructions + .iter() + .try_fold(0_u64, |total, (instruction, _)| { + total.checked_add(instruction.rows_to_take) + })?; + self.value_decompressor.decoded_size_bytes(num_values) + } + + fn resolve_num_levels( + rep_decompressor: Option<&dyn BlockDecompressor>, + rep_levels: Option<&LanceBuffer>, + def_decompressor: Option<&dyn BlockDecompressor>, + def_levels: Option<&LanceBuffer>, + declared_num_levels: u16, + ) -> Result { + let rep_num_levels = match (rep_decompressor, rep_levels) { + (Some(decompressor), Some(levels)) => decompressor.infer_num_values(levels)?, + (None, None) => None, + _ => { + return Err(Error::invalid_input_source( + "miniblock repetition codec and payload presence disagree".into(), + )); + } + }; + let def_num_levels = match (def_decompressor, def_levels) { + (Some(decompressor), Some(levels)) => decompressor.infer_num_values(levels)?, + (None, None) => None, + _ => { + return Err(Error::invalid_input_source( + "miniblock definition codec and payload presence disagree".into(), + )); + } + }; + + let inferred_num_levels = match (rep_num_levels, def_num_levels) { + (Some(rep_num_levels), Some(def_num_levels)) if rep_num_levels != def_num_levels => { + return Err(Error::invalid_input_source( + format!( + "miniblock structural streams disagree on the level count: repetition inferred {rep_num_levels}, definition inferred {def_num_levels}" + ) + .into(), + )); + } + (Some(num_levels), _) | (_, Some(num_levels)) => num_levels, + (None, None) => return Ok(u64::from(declared_num_levels)), + }; + + let declared_num_levels = u64::from(declared_num_levels); + if inferred_num_levels == declared_num_levels { + return Ok(inferred_num_levels); + } + let u16_modulus = u64::from(u16::MAX) + 1; + if inferred_num_levels <= declared_num_levels + || inferred_num_levels % u16_modulus != declared_num_levels + { + return Err(Error::invalid_input_source( + format!( + "miniblock payload proves {inferred_num_levels} levels but the header declared {declared_num_levels}; the counts are not congruent modulo 65536" + ) + .into(), + )); + } + if inferred_num_levels > MAX_LEGACY_RLE_LEVELS { + return Err(Error::invalid_input_source( + format!( + "miniblock payload proves {inferred_num_levels} levels, exceeding the legacy RLE payload bound of {MAX_LEGACY_RLE_LEVELS}" + ) + .into(), + )); + } + Ok(inferred_num_levels) + } + fn decode_levels( - rep_decompressor: &dyn BlockDecompressor, + decompressor: &dyn BlockDecompressor, levels: LanceBuffer, - num_levels: u16, + expected_num_levels: u64, ) -> Result> { - let rep = rep_decompressor.decompress(levels, num_levels as u64)?; - let rep = rep.as_fixed_width().unwrap(); - debug_assert_eq!(rep.num_values, num_levels as u64); - debug_assert_eq!(rep.bits_per_value, 16); - Ok(rep.data.borrow_to_typed_slice::()) + let levels = decompressor.decompress(Some(levels), expected_num_levels)?; + let levels = levels.as_fixed_width().ok_or_else(|| { + Error::invalid_input_source( + "miniblock levels did not decode to fixed-width data".into(), + ) + })?; + if levels.num_values != expected_num_levels { + return Err(Error::invalid_input_source( + format!( + "miniblock levels decoded {} values, expected {expected_num_levels}", + levels.num_values + ) + .into(), + )); + } + if levels.bits_per_value != 16 { + return Err(Error::invalid_input_source( + format!( + "miniblock levels decoded {} bits per value, expected 16", + levels.bits_per_value + ) + .into(), + )); + } + let expected_bytes = expected_num_levels.checked_mul(2).ok_or_else(|| { + Error::invalid_input_source( + format!("miniblock level byte count overflowed for {expected_num_levels} levels") + .into(), + ) + })?; + if levels.data.len() as u64 != expected_bytes { + return Err(Error::invalid_input_source( + format!( + "miniblock levels decoded {} bytes, expected {expected_bytes}", + levels.data.len() + ) + .into(), + )); + } + Ok(levels.data.borrow_to_typed_slice::()) } // We are building a LevelBuffer (levels) and want to copy into it `total_len` @@ -497,6 +747,14 @@ impl DecodeMiniBlockTask { def }); + let num_levels = Self::resolve_num_levels( + self.rep_decompressor.as_deref(), + rep.as_ref(), + self.def_decompressor.as_deref(), + def.as_ref(), + num_levels, + )?; + let buffers = buffer_sizes .into_iter() .map(|buf_size| { @@ -530,6 +788,19 @@ impl DecodeMiniBlockTask { }) .transpose()?; + if let (Some(rep), Some(def)) = (&rep, &def) + && rep.len() != def.len() + { + return Err(Error::invalid_input_source( + format!( + "miniblock structural streams decoded different level counts: repetition {}, definition {}", + rep.len(), + def.len() + ) + .into(), + )); + } + Ok(DecodedMiniBlockChunk { rep, def, values }) } } @@ -542,15 +813,15 @@ impl DecodePageTask for DecodeMiniBlockTask { let max_rep = self.def_meaning.iter().filter(|l| l.is_list()).count() as u16; - // This is probably an over-estimate but it's quick and easy to calculate - let estimated_size_bytes = self - .instructions - .iter() - .map(|(_, chunk)| chunk.data.len()) - .sum::() - * 2; - let mut data_builder = - DataBlockBuilder::with_capacity_estimate(estimated_size_bytes as u64); + let estimated_size_bytes = self.decoded_size_bytes().unwrap_or_else(|| { + // Variable-width and rep/def encoded output sizes are not known before decoding. + self.instructions + .iter() + .map(|(_, chunk)| chunk.data.len() as u64) + .sum::() + * 2 + }); + let mut data_builder = DataBlockBuilder::with_capacity_estimate(estimated_size_bytes); // We need to keep track of the offset into repbuf/defbuf that we are building up let mut level_offset = 0; @@ -571,9 +842,7 @@ impl DecodePageTask for DecodeMiniBlockTask { let should_cache_this_chunk = needs_caching[idx]; let decoded_chunk = match &chunk_cache { - Some((cached_chunk_idx, ref cached_chunk)) - if *cached_chunk_idx == chunk.chunk_idx => - { + Some((cached_chunk_idx, cached_chunk)) if *cached_chunk_idx == chunk.chunk_idx => { // Clone only when we have a cache hit (much cheaper than decoding) cached_chunk.clone() } @@ -607,20 +876,17 @@ impl DecodePageTask for DecodeMiniBlockTask { instructions.preamble_action, ); if item_range.end - item_range.start > chunk.items_in_chunk { - return Err(lance_core::Error::Internal { - message: format!( - "Item range {:?} is greater than chunk items in chunk {:?}", - item_range, chunk.items_in_chunk - ), - location: location!(), - }); + return Err(lance_core::Error::internal(format!( + "Item range {:?} is greater than chunk items in chunk {:?}", + item_range, chunk.items_in_chunk + ))); } // Now we append the data to the output buffers Self::extend_levels(level_range.clone(), &mut repbuf, &rep, level_offset); Self::extend_levels(level_range.clone(), &mut defbuf, &def, level_offset); level_offset += (level_range.end - level_range.start) as usize; - data_builder.append(&values, item_range); + data_builder.append(&values, item_range)?; } let mut data = data_builder.finish(); @@ -631,13 +897,10 @@ impl DecodePageTask for DecodeMiniBlockTask { if let Some(dictionary) = &self.dictionary_data { // Don't decode here, that happens later (if needed) let DataBlock::FixedWidth(indices) = data else { - return Err(lance_core::Error::Internal { - message: format!( - "Expected FixedWidth DataBlock for dictionary indices, got {:?}", - data - ), - location: location!(), - }); + return Err(lance_core::Error::internal(format!( + "Expected FixedWidth DataBlock for dictionary indices, got {:?}", + data + ))); }; data = DataBlock::Dictionary(DictionaryDataBlock::from_parts( indices, @@ -720,12 +983,7 @@ impl StructuralPageDecoder for MiniBlockDecoder { // we were still in the middle of loading rows. We do need to latch skip_in_chunk though. self.offset_in_current_chunk = skip_in_chunk; - let max_visible_level = self - .def_meaning - .iter() - .take_while(|l| !l.is_list()) - .map(|l| l.num_def_levels()) - .sum::(); + let max_visible_level = max_visible_level(&self.def_meaning); Ok(Box::new(DecodeMiniBlockTask { instructions: drain_instructions, @@ -745,1341 +1003,2268 @@ impl StructuralPageDecoder for MiniBlockDecoder { } } -#[derive(Debug)] -struct CachedComplexAllNullState { - rep: Option>, - def: Option>, -} - -impl DeepSizeOf for CachedComplexAllNullState { - fn deep_size_of_children(&self, _ctx: &mut Context) -> usize { - self.rep.as_ref().map(|buf| buf.len() * 2).unwrap_or(0) - + self.def.as_ref().map(|buf| buf.len() * 2).unwrap_or(0) - } +/// How a complex-all-null page's rep/def level buffer is compressed on disk. +/// Captured at scheduler construction so `initialize` can keep RLE levels in run +/// form instead of expanding them. +#[derive(Debug, Clone)] +pub(crate) enum LevelCodec { + /// Raw little-endian u16 levels (no block compression). + Uncompressed, + /// RLE-compressed levels; the validated physical runs select their cached representation. + Rle(Arc), + /// Any other block compression; decoded eagerly into [`LazyLevels::Dense`] + /// (these encodings don't expand, so laziness buys nothing). + Block(Arc), } -impl CachedPageData for CachedComplexAllNullState { - fn as_arc_any(self: Arc) -> Arc { - self +impl LevelCodec { + fn try_new( + encoding: Option<&CompressiveEncoding>, + decompression_strategy: &dyn DecompressionStrategy, + ) -> Result { + match encoding { + None => Ok(Self::Uncompressed), + Some(encoding) => match encoding.compression.as_ref() { + Some(Compression::Rle(rle)) => Ok(Self::Rle(Arc::new(create_rle_decompressor( + rle, + decompression_strategy, + )?))), + _ => Ok(Self::Block(Arc::from( + decompression_strategy.create_block_decompressor(encoding)?, + ))), + }, + } } } -/// A scheduler for all-null data that has repetition and definition levels -/// -/// We still need to do some I/O in this case because we need to figure out what kind of null we -/// are dealing with (null list, null struct, what level null struct, etc.) -/// -/// TODO: Right now we just load the entire rep/def at initialization time and cache it. This is a touch -/// RAM aggressive and maybe we want something more lazy in the future. On the other hand, it's simple -/// and fast so...maybe not :) #[derive(Debug)] -pub struct ComplexAllNullScheduler { - // Set from protobuf - buffer_offsets_and_sizes: Arc<[(u64, u64)]>, - def_meaning: Arc<[DefinitionInterpretation]>, - repdef: Option>, - max_visible_level: u16, +enum RunEnds { + U16(Box<[u16]>), + U32(Box<[u32]>), + U64(Box<[u64]>), } -impl ComplexAllNullScheduler { - pub fn new( - buffer_offsets_and_sizes: Arc<[(u64, u64)]>, - def_meaning: Arc<[DefinitionInterpretation]>, - ) -> Self { - let max_visible_level = def_meaning - .iter() - .take_while(|l| !l.is_list()) - .map(|l| l.num_def_levels()) - .sum::(); - Self { - buffer_offsets_and_sizes, - def_meaning, - repdef: None, - max_visible_level, +impl RunEnds { + fn width_for(num_values: usize) -> usize { + if u16::try_from(num_values).is_ok() { + std::mem::size_of::() + } else if u32::try_from(num_values).is_ok() { + std::mem::size_of::() + } else { + std::mem::size_of::() } } -} - -impl StructuralPageScheduler for ComplexAllNullScheduler { - fn initialize<'a>( - &'a mut self, - io: &Arc, - ) -> BoxFuture<'a, Result>> { - // Fully load the rep & def buffers, as needed - let (rep_pos, rep_size) = self.buffer_offsets_and_sizes[0]; - let (def_pos, def_size) = self.buffer_offsets_and_sizes[1]; - let has_rep = rep_size > 0; - let has_def = def_size > 0; - let mut reads = Vec::with_capacity(2); - if has_rep { - reads.push(rep_pos..rep_pos + rep_size); - } - if has_def { - reads.push(def_pos..def_pos + def_size); + fn len(&self) -> usize { + match self { + Self::U16(ends) => ends.len(), + Self::U32(ends) => ends.len(), + Self::U64(ends) => ends.len(), } + } - let data = io.submit_request(reads, 0); + fn get(&self, run: usize) -> usize { + match self { + Self::U16(ends) => ends[run] as usize, + Self::U32(ends) => ends[run] as usize, + Self::U64(ends) => ends[run] as usize, + } + } - async move { - let data = data.await?; - let mut data_iter = data.into_iter(); - - let rep = if has_rep { - let rep = data_iter.next().unwrap(); - let rep = LanceBuffer::from_bytes(rep, 2); - let rep = rep.borrow_to_typed_slice::(); - Some(rep) - } else { - None - }; + fn partition_point(&self, logical_index: usize) -> usize { + match self { + Self::U16(ends) => ends.partition_point(|&end| end as usize <= logical_index), + Self::U32(ends) => ends.partition_point(|&end| end as usize <= logical_index), + Self::U64(ends) => ends.partition_point(|&end| end as usize <= logical_index), + } + } - let def = if has_def { - let def = data_iter.next().unwrap(); - let def = LanceBuffer::from_bytes(def, 2); - let def = def.borrow_to_typed_slice::(); - Some(def) - } else { - None - }; + fn deep_size(&self) -> usize { + match self { + Self::U16(ends) => std::mem::size_of_val(ends.as_ref()), + Self::U32(ends) => std::mem::size_of_val(ends.as_ref()), + Self::U64(ends) => std::mem::size_of_val(ends.as_ref()), + } + } +} - let repdef = Arc::new(CachedComplexAllNullState { rep, def }); +enum RunEndsBuilder { + U16(Vec), + U32(Vec), + U64(Vec), +} - self.repdef = Some(repdef.clone()); +impl RunEndsBuilder { + fn with_capacity(num_values: usize, capacity: usize) -> Self { + if u16::try_from(num_values).is_ok() { + Self::U16(Vec::with_capacity(capacity)) + } else if u32::try_from(num_values).is_ok() { + Self::U32(Vec::with_capacity(capacity)) + } else { + Self::U64(Vec::with_capacity(capacity)) + } + } - Ok(repdef as Arc) + fn push(&mut self, end: usize) -> Result<()> { + match self { + Self::U16(ends) => ends.push( + u16::try_from(end) + .map_err(|_| Error::internal(format!("Run end {end} does not fit in u16")))?, + ), + Self::U32(ends) => ends.push( + u32::try_from(end) + .map_err(|_| Error::internal(format!("Run end {end} does not fit in u32")))?, + ), + Self::U64(ends) => ends.push(end as u64), } - .boxed() + Ok(()) } - fn load(&mut self, data: &Arc) { - self.repdef = Some( - data.clone() - .as_arc_any() - .downcast::() - .unwrap(), - ); + fn set_last(&mut self, end: usize) -> Result<()> { + match self { + Self::U16(ends) => { + let last = ends.last_mut().ok_or_else(|| { + Error::internal("Cannot extend an empty coalesced run buffer") + })?; + *last = u16::try_from(end) + .map_err(|_| Error::internal(format!("Run end {end} does not fit in u16")))?; + } + Self::U32(ends) => { + let last = ends.last_mut().ok_or_else(|| { + Error::internal("Cannot extend an empty coalesced run buffer") + })?; + *last = u32::try_from(end) + .map_err(|_| Error::internal(format!("Run end {end} does not fit in u32")))?; + } + Self::U64(ends) => { + let last = ends.last_mut().ok_or_else(|| { + Error::internal("Cannot extend an empty coalesced run buffer") + })?; + *last = end as u64; + } + } + Ok(()) } - fn schedule_ranges( - &self, - ranges: &[Range], - _io: &Arc, - ) -> Result> { - let ranges = VecDeque::from_iter(ranges.iter().cloned()); - let num_rows = ranges.iter().map(|r| r.end - r.start).sum::(); - let decoder = Box::new(ComplexAllNullPageDecoder { - ranges, - rep: self.repdef.as_ref().unwrap().rep.clone(), - def: self.repdef.as_ref().unwrap().def.clone(), - num_rows, - def_meaning: self.def_meaning.clone(), - max_visible_level: self.max_visible_level, - }) as Box; - let page_load_task = PageLoadTask { - decoder_fut: std::future::ready(Ok(decoder)).boxed(), - num_rows, - }; - Ok(vec![page_load_task]) + fn finish(self) -> RunEnds { + match self { + Self::U16(ends) => RunEnds::U16(ends.into_boxed_slice()), + Self::U32(ends) => RunEnds::U32(ends.into_boxed_slice()), + Self::U64(ends) => RunEnds::U64(ends.into_boxed_slice()), + } } } #[derive(Debug)] -pub struct ComplexAllNullPageDecoder { - ranges: VecDeque>, - rep: Option>, - def: Option>, - num_rows: u64, - def_meaning: Arc<[DefinitionInterpretation]>, - max_visible_level: u16, +enum RunStorage { + Physical(RleRuns), + Coalesced { values: Box<[u16]>, ends: RunEnds }, } -impl ComplexAllNullPageDecoder { - fn drain_ranges(&mut self, num_rows: u64) -> Vec> { - let mut rows_desired = num_rows; - let mut ranges = Vec::with_capacity(self.ranges.len()); - while rows_desired > 0 { - let front = self.ranges.front_mut().unwrap(); - let avail = front.end - front.start; - if avail > rows_desired { - ranges.push(front.start..front.start + rows_desired); - front.start += rows_desired; - rows_desired = 0; - } else { - ranges.push(self.ranges.pop_front().unwrap()); - rows_desired -= avail; - } +impl RunStorage { + fn len(&self) -> usize { + match self { + Self::Physical(runs) => runs.num_values(), + Self::Coalesced { ends, .. } => ends.get(ends.len() - 1), } - ranges } -} -impl StructuralPageDecoder for ComplexAllNullPageDecoder { - fn drain(&mut self, num_rows: u64) -> Result> { - let drained_ranges = self.drain_ranges(num_rows); - Ok(Box::new(DecodeComplexAllNullTask { - ranges: drained_ranges, - rep: self.rep.clone(), - def: self.def.clone(), - def_meaning: self.def_meaning.clone(), - max_visible_level: self.max_visible_level, - })) + fn num_runs(&self) -> usize { + match self { + Self::Physical(runs) => runs.num_runs(), + Self::Coalesced { values, .. } => values.len(), + } } - fn num_rows(&self) -> u64 { - self.num_rows + fn value(&self, run: usize) -> u16 { + match self { + Self::Physical(runs) => runs.value(run), + Self::Coalesced { values, .. } => values[run], + } } -} - -/// We use `ranges` to slice into `rep` and `def` and create rep/def buffers -/// for the null data. -#[derive(Debug)] -pub struct DecodeComplexAllNullTask { - ranges: Vec>, - rep: Option>, - def: Option>, - def_meaning: Arc<[DefinitionInterpretation]>, - max_visible_level: u16, -} -impl DecodeComplexAllNullTask { - fn decode_level( - &self, - levels: &Option>, - num_values: u64, - ) -> Option> { - levels.as_ref().map(|levels| { - let mut referenced_levels = Vec::with_capacity(num_values as usize); - for range in &self.ranges { - referenced_levels.extend( - levels[range.start as usize..range.end as usize] - .iter() - .copied(), - ); - } - referenced_levels + fn first_value_above(&self, max: u16) -> Option<(usize, u16)> { + (0..self.num_runs()).find_map(|run| { + let value = self.value(run); + (value > max).then_some((run, value)) }) } -} -impl DecodePageTask for DecodeComplexAllNullTask { - fn decode(self: Box) -> Result { - let num_values = self.ranges.iter().map(|r| r.end - r.start).sum::(); - let rep = self.decode_level(&self.rep, num_values); - let def = self.decode_level(&self.def, num_values); - - // If there are definition levels there may be empty / null lists which are not visible - // in the items array. We need to account for that here to figure out how many values - // should be in the items array. - let num_values = if let Some(def) = &def { - def.iter().filter(|&d| *d <= self.max_visible_level).count() as u64 - } else { - num_values - }; + fn seek(&self, position: &mut RunPosition, logical_index: usize) { + if logical_index >= self.len() { + *position = RunPosition { + run: self.num_runs(), + start: self.len(), + end: self.len(), + }; + return; + } - let data = DataBlock::AllNull(AllNullDataBlock { num_values }); - let unraveler = RepDefUnraveler::new(rep, def, self.def_meaning, num_values); - Ok(DecodedPage { - data, - repdef: unraveler, - }) + match self { + Self::Physical(runs) => { + if position.run >= runs.num_runs() + || position.end == 0 + || logical_index < position.start + { + *position = RunPosition { + run: 0, + start: 0, + end: runs.length(0), + }; + } + while position.end <= logical_index { + self.advance(position); + } + } + Self::Coalesced { ends, .. } => { + if logical_index < position.start || logical_index >= position.end { + let run = ends.partition_point(logical_index); + *position = RunPosition { + run, + start: if run == 0 { 0 } else { ends.get(run - 1) }, + end: ends.get(run), + }; + } + } + } } -} -/// A scheduler for simple all-null data -/// -/// "simple" all-null data is data that is all null and only has a single level of definition and -/// no repetition. We don't need to read any data at all in this case. -#[derive(Debug, Default)] -pub struct SimpleAllNullScheduler {} + fn advance(&self, position: &mut RunPosition) { + let next_run = position.run + 1; + if next_run >= self.num_runs() { + *position = RunPosition { + run: self.num_runs(), + start: self.len(), + end: self.len(), + }; + return; + } -impl StructuralPageScheduler for SimpleAllNullScheduler { - fn initialize<'a>( - &'a mut self, - _io: &Arc, - ) -> BoxFuture<'a, Result>> { - std::future::ready(Ok(Arc::new(NoCachedPageData) as Arc)).boxed() + let start = position.end; + position.run = next_run; + position.start = start; + position.end = match self { + Self::Physical(runs) => start + runs.length(next_run), + Self::Coalesced { ends, .. } => ends.get(next_run), + }; } - fn load(&mut self, _cache: &Arc) {} - - fn schedule_ranges( - &self, - ranges: &[Range], - _io: &Arc, - ) -> Result> { - let num_rows = ranges.iter().map(|r| r.end - r.start).sum::(); - let decoder = - Box::new(SimpleAllNullPageDecoder { num_rows }) as Box; - let page_load_task = PageLoadTask { - decoder_fut: std::future::ready(Ok(decoder)).boxed(), - num_rows, - }; - Ok(vec![page_load_task]) + fn deep_size(&self) -> usize { + match self { + Self::Physical(runs) => runs.deep_size(), + Self::Coalesced { values, ends } => { + std::mem::size_of_val(values.as_ref()) + ends.deep_size() + } + } } } -/// A page decode task for all-null data without any -/// repetition and only a single level of definition -#[derive(Debug)] -struct SimpleAllNullDecodePageTask { - num_values: u64, +/// Rep/def levels for a complex-all-null page. +/// +/// RLE pages retain the smallest of their validated physical runs, coalesced +/// runs, and dense values. The decoder materializes only the per-drain slices +/// it touches. +#[derive(Debug, Clone)] +enum LazyLevels { + Dense(ScalarBuffer), + Runs(Arc), } -impl DecodePageTask for SimpleAllNullDecodePageTask { - fn decode(self: Box) -> Result { - let unraveler = RepDefUnraveler::new( - None, - Some(vec![1; self.num_values as usize]), - Arc::new([DefinitionInterpretation::NullableItem]), - self.num_values, - ); - Ok(DecodedPage { - data: DataBlock::AllNull(AllNullDataBlock { - num_values: self.num_values, - }), - repdef: unraveler, - }) - } + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LevelPlan { + Physical, + Coalesced, + Dense, } -#[derive(Debug)] -pub struct SimpleAllNullPageDecoder { - num_rows: u64, +#[derive(Debug, Default, Clone, Copy)] +struct RunPosition { + run: usize, + start: usize, + end: usize, } -impl StructuralPageDecoder for SimpleAllNullPageDecoder { - fn drain(&mut self, num_rows: u64) -> Result> { - Ok(Box::new(SimpleAllNullDecodePageTask { - num_values: num_rows, - })) - } +/// Monotonic forward cursor into a [`LazyLevels`] sequence. +/// +/// Drains seek to strictly increasing rows, so each [`LazyLevels::seek_row_start`] +/// resumes from the last position instead of rescanning — every run is visited at +/// most once per page while locating and counting level ranges. +#[derive(Debug, Default, Clone, Copy)] +struct LevelCursor { + /// Logical level index where the current row begins. + level: usize, + /// Row index at `level` (the number of `max_rep` occurrences before it). + row: u64, + /// Run containing `level`. Unused for [`LazyLevels::Dense`]. + run: RunPosition, +} - fn num_rows(&self) -> u64 { - self.num_rows +impl LazyLevels { + fn from_rle_runs(runs: RleRuns) -> Result { + let plan = Self::select_plan(&runs); + match plan { + LevelPlan::Physical => Ok(Self::Runs(Arc::new(RunStorage::Physical( + runs.into_owned(), + )))), + LevelPlan::Coalesced => Self::build_coalesced(runs), + LevelPlan::Dense => Self::build_dense(runs), + } } -} -#[derive(Debug, Clone)] -struct MiniBlockSchedulerDictionary { - // These come from the protobuf - dictionary_decompressor: Arc, - dictionary_buf_position_and_size: (u64, u64), - dictionary_data_alignment: u64, - num_dictionary_items: u64, -} + /// Minimize retained payload bytes first, then expected traversal work. + /// If both are equal, keep the physical runs and avoid another allocation. + fn select_plan(runs: &RleRuns) -> LevelPlan { + if runs.num_values() == 0 { + return LevelPlan::Dense; + } -/// Individual block metadata within a MiniBlock repetition index. -#[derive(Debug)] -struct MiniBlockRepIndexBlock { - // The index of the first row that starts after the beginning of this block. If the block - // has a preamble this will be the row after the preamble. If the block is entirely preamble - // then this will be a row that starts in some future block. - first_row: u64, - // The number of rows in the block, including the trailer but not the preamble. - // Can be 0 if the block is entirely preamble - starts_including_trailer: u64, - // Whether the block has a preamble - has_preamble: bool, - // Whether the block has a trailer - has_trailer: bool, -} - -impl DeepSizeOf for MiniBlockRepIndexBlock { - fn deep_size_of_children(&self, _context: &mut Context) -> usize { - 0 + let run_storage_size = std::mem::size_of::() as u128; + let physical_size = run_storage_size + runs.owned_size() as u128; + let coalesced_size = run_storage_size + + (runs.coalesced_runs() as u128) + * (std::mem::size_of::() + RunEnds::width_for(runs.num_values())) as u128; + let dense_size = (runs.num_values() as u128) * std::mem::size_of::() as u128; + [ + (physical_size, runs.num_runs(), 0usize, LevelPlan::Physical), + ( + coalesced_size, + runs.coalesced_runs(), + 1usize, + LevelPlan::Coalesced, + ), + (dense_size, runs.num_values(), 2usize, LevelPlan::Dense), + ] + .into_iter() + .min_by_key(|(size, traversal, priority, _)| (*size, *traversal, *priority)) + .map(|(_, _, _, plan)| plan) + .unwrap_or(LevelPlan::Dense) + } + + fn build_coalesced(runs: RleRuns) -> Result { + let mut values = Vec::with_capacity(runs.coalesced_runs()); + let mut ends = RunEndsBuilder::with_capacity(runs.num_values(), runs.coalesced_runs()); + let mut logical_end = 0usize; + for (value, length) in runs.iter() { + logical_end = logical_end + .checked_add(length) + .ok_or_else(|| Error::internal("Validated RLE run length sum overflowed usize"))?; + if values.last().copied() == Some(value) { + ends.set_last(logical_end)?; + } else { + values.push(value); + ends.push(logical_end)?; + } + } + Ok(Self::Runs(Arc::new(RunStorage::Coalesced { + values: values.into_boxed_slice(), + ends: ends.finish(), + }))) + } + + fn build_dense(runs: RleRuns) -> Result { + let mut values = Vec::new(); + values.try_reserve_exact(runs.num_values()).map_err(|_| { + Error::internal(format!( + "Cannot allocate {} dense repetition/definition levels", + runs.num_values() + )) + })?; + for (value, length) in runs.iter() { + values.resize(values.len() + length, value); + } + Ok(Self::Dense(ScalarBuffer::from(values))) } -} -/// Repetition index for MiniBlock encoding. -/// -/// Stores block-level offset information to enable efficient random -/// access to nested data structures within mini-blocks. -#[derive(Debug)] -struct MiniBlockRepIndex { - blocks: Vec, -} + fn len(&self) -> usize { + match self { + Self::Dense(buf) => buf.len(), + Self::Runs(runs) => runs.len(), + } + } -impl DeepSizeOf for MiniBlockRepIndex { - fn deep_size_of_children(&self, context: &mut Context) -> usize { - self.blocks.deep_size_of_children(context) + fn validate_max(&self, level_type: &str, max: u16) -> Result<()> { + let invalid = match self { + Self::Dense(levels) => levels + .iter() + .enumerate() + .find_map(|(index, &value)| (value > max).then_some(("index", index, value))), + Self::Runs(runs) => runs + .first_value_above(max) + .map(|(run, value)| ("run", run, value)), + }; + if let Some((position_type, position, value)) = invalid { + return Err(Error::invalid_input_source( + format!( + "Invalid {level_type} level {value} at {position_type} {position}: maximum is {max}" + ) + .into(), + )); + } + Ok(()) } -} -impl MiniBlockRepIndex { - /// Decode repetition index from chunk metadata using default values. + /// Advance `cursor` to the start of row `target_row`, returning that row's + /// starting level index. /// - /// This creates a repetition index where each chunk has no partial values - /// and no trailers, suitable for simple sequential data layouts. - pub fn default_from_chunks(chunks: &[ChunkMeta]) -> Self { - let mut blocks = Vec::with_capacity(chunks.len()); - let mut offset: u64 = 0; - - for c in chunks { - blocks.push(MiniBlockRepIndexBlock { - first_row: offset, - starts_including_trailer: c.num_values, - has_preamble: false, - has_trailer: false, - }); + /// Rows begin at `max_rep` positions, so this finds the `target_row`-th one. + /// `target_row` must be `>= cursor.row`: the cursor only moves forward, which + /// is what keeps a full page decode O(runs) rather than O(rows). + fn seek_row_start( + &self, + cursor: &mut LevelCursor, + target_row: u64, + max_rep: u16, + ) -> Result { + let mut need = target_row.checked_sub(cursor.row).ok_or_else(|| { + Error::internal(format!( + "Complex all-null row ranges are not sorted: target row {target_row} follows {}", + cursor.row + )) + })?; + if need == 0 { + return Ok(cursor.level); + } + match self { + Self::Dense(buf) => { + let mut level = cursor.level; + while need > 0 { + if level >= buf.len() { + return Err(Error::internal( + "Invalid complex all-null layout: repetition buffer too short", + )); + } + if buf[level] != max_rep { + return Err(Error::internal( + "Invalid complex all-null layout: row did not start at max repetition level", + )); + } + level += 1; + while level < buf.len() && buf[level] != max_rep { + level += 1; + } + need -= 1; + } + cursor.level = level; + cursor.row = target_row; + Ok(level) + } + Self::Runs(runs) => { + let mut level = cursor.level; + let mut run = cursor.run; + runs.seek(&mut run, level); + while need > 0 { + if run.run >= runs.num_runs() { + return Err(Error::internal( + "Invalid complex all-null layout: repetition buffer too short", + )); + } + if runs.value(run.run) != max_rep { + return Err(Error::internal( + "Invalid complex all-null layout: row did not start at max repetition level", + )); + } + let avail = (run.end - level) as u64; + if need < avail { + // Target lands inside this max-rep run. + level += need as usize; + need = 0; + } else { + // Consume every row start in this run, then skip the + // trailing non-max-rep runs to reach the next row start. + need -= avail; + runs.advance(&mut run); + while run.run < runs.num_runs() && runs.value(run.run) != max_rep { + runs.advance(&mut run); + } + level = if run.run < runs.num_runs() { + run.start + } else { + self.len() + }; + } + } + cursor.level = level; + cursor.row = target_row; + cursor.run = run; + Ok(level) + } + } + } - offset += c.num_values; + /// Count of levels in `range` that are `<= max`, resuming from `*run_cursor` + /// and leaving it on the last run that overlaps `range`. + /// + /// Successive calls must pass ascending, non-overlapping ranges (`range.start + /// >=` the previous `range.end`) so runs are swept at most once per page. + fn count_le_cursor( + &self, + run_cursor: &mut RunPosition, + range: Range, + max: u16, + ) -> (u64, RunPosition) { + if range.is_empty() { + return (0, *run_cursor); + } + match self { + Self::Dense(buf) => ( + buf[range].iter().filter(|&&d| d <= max).count() as u64, + RunPosition::default(), + ), + Self::Runs(runs) => { + // Advance to the first run overlapping the range. + runs.seek(run_cursor, range.start); + let start = *run_cursor; + let mut count = 0u64; + let mut current = *run_cursor; + while current.run < runs.num_runs() && current.start < range.end { + if runs.value(current.run) <= max { + let lo = current.start.max(range.start); + let hi = current.end.min(range.end); + count += (hi - lo) as u64; + } + if current.end >= range.end { + break; + } + runs.advance(&mut current); + } + // Resume the next (ascending) range from the last overlapping run; + // `current` remains valid because `range` is non-empty. + *run_cursor = current; + (count, start) + } } + } - Self { blocks } + fn extend_into(&self, range: Range, run: RunPosition, out: &mut Vec) { + if range.is_empty() { + return; + } + match self { + Self::Dense(buf) => out.extend_from_slice(&buf[range]), + Self::Runs(runs) => { + let mut current = run; + runs.seek(&mut current, range.start); + while current.run < runs.num_runs() && current.start < range.end { + let lo = current.start.max(range.start); + let hi = current.end.min(range.end); + if hi > lo { + out.resize(out.len() + (hi - lo), runs.value(current.run)); + } + runs.advance(&mut current); + } + } + } } - /// Decode repetition index from raw bytes in little-endian format. - /// - /// The bytes should contain u64 values arranged in groups of `stride` elements, - /// where the first two values of each group represent ends_count and partial_count. - /// Returns an empty index if no bytes are provided. - pub fn decode_from_bytes(rep_bytes: &[u8], stride: usize) -> Self { - // Convert bytes to u64 slice, handling alignment automatically - let buffer = crate::buffer::LanceBuffer::from(rep_bytes.to_vec()); - let u64_slice = buffer.borrow_to_typed_slice::(); - let n = u64_slice.len() / stride; - - let mut blocks = Vec::with_capacity(n); - let mut chunk_has_preamble = false; - let mut offset: u64 = 0; - - // Extract first two values from each block: ends_count and partial_count - for i in 0..n { - let base_idx = i * stride; - let ends = u64_slice[base_idx]; - let partial = u64_slice[base_idx + 1]; - - let has_trailer = partial > 0; - // Convert branches to arithmetic for better compiler optimization - let starts_including_trailer = - ends + (has_trailer as u64) - (chunk_has_preamble as u64); - - blocks.push(MiniBlockRepIndexBlock { - first_row: offset, - starts_including_trailer, - has_preamble: chunk_has_preamble, - has_trailer, - }); + #[cfg(test)] + fn deep_size(&self) -> usize { + self.deep_size_of_children(&mut Context::new()) + } +} - chunk_has_preamble = has_trailer; - offset += starts_including_trailer; +impl DeepSizeOf for LazyLevels { + fn deep_size_of_children(&self, ctx: &mut Context) -> usize { + match self { + Self::Dense(buf) => buf.deep_size_of_children(ctx), + Self::Runs(runs) => { + let pointer = Arc::as_ptr(runs) as *const () as usize; + if ctx.mark_seen(pointer) { + std::mem::size_of_val(runs.as_ref()) + runs.deep_size() + } else { + 0 + } + } } + } +} - Self { blocks } +fn validate_complex_all_null_levels( + rep: &Option, + def: &Option, + max_rep: u16, + max_def: u16, +) -> Result<()> { + if let Some(rep) = rep { + rep.validate_max("repetition", max_rep)?; + } + if let Some(def) = def { + def.validate_max("definition", max_def)?; + } + if let (Some(rep), Some(def)) = (rep, def) + && rep.len() != def.len() + { + return Err(Error::invalid_input_source( + format!( + "Mismatched complex all-null level counts: repetition has {}, definition has {}", + rep.len(), + def.len() + ) + .into(), + )); } + Ok(()) +} + +fn expected_level_bytes(num_values: u64, level_type: &str) -> Result { + usize::try_from(num_values) + .ok() + .and_then(|num_values| num_values.checked_mul(std::mem::size_of::())) + .ok_or_else(|| { + Error::invalid_input_source( + format!("{level_type} level count {num_values} does not fit in memory").into(), + ) + }) +} + +fn dense_levels_from_block( + decompressed: DataBlock, + num_values: u64, + level_type: &str, +) -> Result { + let DataBlock::FixedWidth(block) = decompressed else { + return Err(Error::invalid_input_source( + format!("Expected fixed-width data block for {level_type} levels").into(), + )); + }; + if block.num_values != num_values { + return Err(Error::invalid_input_source( + format!( + "Unexpected {level_type} level count after decompression: expected {num_values}, got {}", + block.num_values + ) + .into(), + )); + } + if block.bits_per_value != 16 { + return Err(Error::invalid_input_source( + format!( + "Unexpected {level_type} level bit width after decompression: expected 16, got {}", + block.bits_per_value + ) + .into(), + )); + } + let expected_bytes = expected_level_bytes(num_values, level_type)?; + if block.data.len() != expected_bytes { + return Err(Error::invalid_input_source( + format!( + "Unexpected decompressed {level_type} level size: expected {expected_bytes} bytes for {num_values} values, got {}", + block.data.len() + ) + .into(), + )); + } + Ok(LazyLevels::Dense(block.data.borrow_to_typed_slice::())) } -/// State that is loaded once and cached for future lookups #[derive(Debug)] -struct MiniBlockCacheableState { - /// Metadata that describes each chunk in the page - chunk_meta: Vec, - /// The decoded repetition index - rep_index: MiniBlockRepIndex, - /// The dictionary for the page, if any - dictionary: Option>, +struct CachedComplexAllNullState { + rep: Option, + def: Option, } -impl DeepSizeOf for MiniBlockCacheableState { - fn deep_size_of_children(&self, context: &mut Context) -> usize { - self.rep_index.deep_size_of_children(context) - + self - .dictionary - .as_ref() - .map(|dict| dict.data_size() as usize) - .unwrap_or(0) +impl DeepSizeOf for CachedComplexAllNullState { + fn deep_size_of_children(&self, ctx: &mut Context) -> usize { + self.rep.deep_size_of_children(ctx) + self.def.deep_size_of_children(ctx) } } -impl CachedPageData for MiniBlockCacheableState { +impl CachedPageData for CachedComplexAllNullState { fn as_arc_any(self: Arc) -> Arc { self } } -/// A scheduler for a page that has been encoded with the mini-block layout +/// A scheduler for all-null data that has repetition and definition levels /// -/// Scheduling mini-block encoded data is simple in concept and somewhat complex -/// in practice. -/// -/// First, during initialization, we load the chunk metadata, the repetition index, -/// and the dictionary (these last two may not be present) -/// -/// Then, during scheduling, we use the user's requested row ranges and the repetition -/// index to determine which chunks we need and which rows we need from those chunks. -/// -/// For example, if the repetition index is: [50, 3], [50, 0], [10, 0] and the range -/// from the user is 40..60 then we need to: -/// -/// - Read the first chunk and skip the first 40 rows, then read 10 full rows, and -/// then read 3 items for the 11th row of our range. -/// - Read the second chunk and read the remaining items in our 11th row and then read -/// the remaining 9 full rows. -/// -/// Then, if we are going to decode that in batches of 5, we need to make decode tasks. -/// The first two decode tasks will just need the first chunk. The third decode task will -/// need the first chunk (for the trailer which has the 11th row in our range) and the second -/// chunk. The final decode task will just need the second chunk. +/// We still need to do some I/O in this case because we need to figure out what kind of null we +/// are dealing with (null list, null struct, what level null struct, etc.) /// -/// The above prose descriptions are what are represented by `ChunkInstructions` and -/// `ChunkDrainInstructions`. +/// TODO: Right now we just load the entire rep/def at initialization time and cache it. This is a touch +/// RAM aggressive and maybe we want something more lazy in the future. On the other hand, it's simple +/// and fast so...maybe not :) #[derive(Debug)] -pub struct MiniBlockScheduler { - // These come from the protobuf - buffer_offsets_and_sizes: Vec<(u64, u64)>, - priority: u64, - items_in_page: u64, - repetition_index_depth: u16, - num_buffers: u64, - rep_decompressor: Option>, - def_decompressor: Option>, - value_decompressor: Arc, +pub struct ComplexAllNullScheduler { + // Set from protobuf + buffer_offsets_and_sizes: Arc<[(u64, u64)]>, def_meaning: Arc<[DefinitionInterpretation]>, - dictionary: Option, - // This is set after initialization - page_meta: Option>, - has_large_chunk: bool, + repdef: Option>, + max_rep: u16, + max_def: u16, + max_visible_level: u16, + rep_codec: LevelCodec, + def_codec: LevelCodec, + num_rep_values: u64, + num_def_values: u64, } -impl MiniBlockScheduler { - fn try_new( - buffer_offsets_and_sizes: &[(u64, u64)], - priority: u64, - items_in_page: u64, - layout: &pb21::MiniBlockLayout, - decompressors: &dyn DecompressionStrategy, - ) -> Result { - let rep_decompressor = layout - .rep_compression - .as_ref() - .map(|rep_compression| { - decompressors - .create_block_decompressor(rep_compression) - .map(Arc::from) - }) - .transpose()?; - let def_decompressor = layout - .def_compression - .as_ref() - .map(|def_compression| { - decompressors - .create_block_decompressor(def_compression) - .map(Arc::from) - }) - .transpose()?; - let def_meaning = layout - .layers +impl ComplexAllNullScheduler { + pub(crate) fn new( + buffer_offsets_and_sizes: Arc<[(u64, u64)]>, + def_meaning: Arc<[DefinitionInterpretation]>, + rep_codec: LevelCodec, + def_codec: LevelCodec, + num_rep_values: u64, + num_def_values: u64, + ) -> Self { + let max_rep = def_meaning.iter().filter(|l| l.is_list()).count() as u16; + let max_def = def_meaning .iter() - .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) - .collect::>(); - let value_decompressor = decompressors.create_miniblock_decompressor( - layout.value_compression.as_ref().unwrap(), - decompressors, - )?; + .map(|meaning| meaning.num_def_levels()) + .sum::(); + let max_visible_level = max_visible_level(&def_meaning); + Self { + buffer_offsets_and_sizes, + def_meaning, + repdef: None, + max_rep, + max_def, + max_visible_level, + rep_codec, + def_codec, + num_rep_values, + num_def_values, + } + } +} - let dictionary = if let Some(dictionary_encoding) = layout.dictionary.as_ref() { - let num_dictionary_items = layout.num_dictionary_items; - let dictionary_decompressor = decompressors - .create_block_decompressor(dictionary_encoding)? - .into(); - let dictionary_data_alignment = match dictionary_encoding.compression.as_ref().unwrap() - { - Compression::Variable(_) => 4, - Compression::Flat(_) => 16, - Compression::General(_) => 1, - Compression::InlineBitpacking(_) | Compression::OutOfLineBitpacking(_) => { - crate::encoder::MIN_PAGE_BUFFER_ALIGNMENT - } - _ => { - return Err(Error::InvalidInput { - source: format!( - "Unsupported mini-block dictionary encoding: {:?}", - dictionary_encoding.compression.as_ref().unwrap() - ) - .into(), - location: location!(), - }) +impl StructuralPageScheduler for ComplexAllNullScheduler { + fn init_layout(&self) -> Result { + // Fully load the rep & def buffers, as needed + let (rep_pos, rep_size) = self.buffer_offsets_and_sizes[0]; + let (def_pos, def_size) = self.buffer_offsets_and_sizes[1]; + + Ok(PageInitialization::new([ + (rep_size > 0).then_some(rep_pos..rep_pos + rep_size), + (def_size > 0).then_some(def_pos..def_pos + def_size), + ])) + } + + fn init_from_buffers<'a>( + &'a mut self, + buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, Result>> { + let rep_codec = self.rep_codec.clone(); + let def_codec = self.def_codec.clone(); + let num_rep_values = self.num_rep_values; + let num_def_values = self.num_def_values; + let max_rep = self.max_rep; + let max_def = self.max_def; + + async move { + let [rep_bytes, def_bytes] = buffers.try_into_array("complex all-null")?; + + // RLE levels select the smallest validated cache representation; + // everything else expands eagerly to `LazyLevels::Dense`. + let build_levels = |compressed_bytes: Bytes, + codec: &LevelCodec, + num_values: u64, + level_type: &str| + -> Result { + match codec { + LevelCodec::Uncompressed => { + if num_values == 0 { + if !compressed_bytes + .len() + .is_multiple_of(std::mem::size_of::()) + { + return Err(Error::invalid_input_source( + format!( + "Unexpected uncompressed {level_type} level size: {} bytes is not divisible by {}", + compressed_bytes.len(), + std::mem::size_of::() + ) + .into(), + )); + } + } else { + let expected_bytes = expected_level_bytes(num_values, level_type)?; + if compressed_bytes.len() != expected_bytes { + return Err(Error::invalid_input_source( + format!( + "Unexpected uncompressed {level_type} level size: expected {expected_bytes} bytes for {num_values} values, got {}", + compressed_bytes.len() + ) + .into(), + )); + } + } + let buffer = LanceBuffer::from_bytes(compressed_bytes, 2); + Ok(LazyLevels::Dense(buffer.borrow_to_typed_slice::())) + } + LevelCodec::Rle(decompressor) => { + let frame = LanceBuffer::from_bytes(compressed_bytes, 1); + let runs = decompressor.decode_u16_runs(frame, num_values)?; + LazyLevels::from_rle_runs(runs) + } + LevelCodec::Block(decompressor) => { + let frame = LanceBuffer::from_bytes(compressed_bytes, 1); + let decompressed = decompressor.decompress(Some(frame), num_values)?; + dense_levels_from_block(decompressed, num_values, level_type) + } } }; - Some(MiniBlockSchedulerDictionary { - dictionary_decompressor, - dictionary_buf_position_and_size: buffer_offsets_and_sizes[2], - dictionary_data_alignment, - num_dictionary_items, - }) - } else { - None + + let rep = if let Some(rep) = rep_bytes { + Some(build_levels(rep, &rep_codec, num_rep_values, "repetition")?) + } else { + None + }; + + let def = if let Some(def) = def_bytes { + Some(build_levels(def, &def_codec, num_def_values, "definition")?) + } else { + None + }; + + validate_complex_all_null_levels(&rep, &def, max_rep, max_def)?; + let repdef = Arc::new(CachedComplexAllNullState { rep, def }); + + Ok(repdef as Arc) + } + .boxed() + } + + fn try_load(&mut self, data: &Arc) -> Result<()> { + self.repdef = Some(data.clone().as_arc_any().downcast().map_err(|_| { + Error::invalid_input_source( + "Cached complex all-null page data has an unexpected type".into(), + ) + })?); + Ok(()) + } + + fn schedule_ranges( + &self, + ranges: &[Range], + _io: &Arc, + ) -> Result> { + let ranges = VecDeque::from_iter(ranges.iter().cloned()); + let num_rows = ranges.iter().map(|r| r.end - r.start).sum::(); + let decoder = Box::new(ComplexAllNullPageDecoder { + ranges, + rep: self.repdef.as_ref().unwrap().rep.clone(), + def: self.repdef.as_ref().unwrap().def.clone(), + num_rows, + def_meaning: self.def_meaning.clone(), + max_rep: self.max_rep, + max_visible_level: self.max_visible_level, + rep_cursor: LevelCursor::default(), + def_run_cursor: RunPosition::default(), + }) as Box; + let page_load_task = PageLoadTask { + decoder_fut: std::future::ready(Ok(decoder)).boxed(), + num_rows, }; + Ok(vec![page_load_task]) + } +} - Ok(Self { - buffer_offsets_and_sizes: buffer_offsets_and_sizes.to_vec(), - rep_decompressor, - def_decompressor, - value_decompressor: value_decompressor.into(), - repetition_index_depth: layout.repetition_index_depth as u16, - num_buffers: layout.num_buffers, - priority, - items_in_page, - dictionary, - def_meaning: def_meaning.into(), - page_meta: None, - has_large_chunk: layout.has_large_chunk, - }) +#[derive(Debug)] +pub struct ComplexAllNullPageDecoder { + ranges: VecDeque>, + rep: Option, + def: Option, + num_rows: u64, + def_meaning: Arc<[DefinitionInterpretation]>, + max_rep: u16, + max_visible_level: u16, + /// Monotonic cursor into `rep` tracking the current row's level start. + rep_cursor: LevelCursor, + /// Monotonic run cursor into `def` for `count_le_cursor`. + def_run_cursor: RunPosition, +} + +impl ComplexAllNullPageDecoder { + fn drain_ranges(&mut self, num_rows: u64) -> Vec> { + let mut rows_desired = num_rows; + let mut ranges = Vec::with_capacity(self.ranges.len()); + while rows_desired > 0 { + let front = self.ranges.front_mut().unwrap(); + let avail = front.end - front.start; + if avail > rows_desired { + ranges.push(front.start..front.start + rows_desired); + front.start += rows_desired; + rows_desired = 0; + } else { + ranges.push(self.ranges.pop_front().unwrap()); + rows_desired -= avail; + } + } + ranges } - fn lookup_chunks(&self, chunk_indices: &[usize]) -> Vec { - let page_meta = self.page_meta.as_ref().unwrap(); - chunk_indices - .iter() - .map(|&chunk_idx| { - let chunk_meta = &page_meta.chunk_meta[chunk_idx]; - let bytes_start = chunk_meta.offset_bytes; - let bytes_end = bytes_start + chunk_meta.chunk_size_bytes; - LoadedChunk { - byte_range: bytes_start..bytes_end, - items_in_chunk: chunk_meta.num_values, - chunk_idx, - data: LanceBuffer::empty(), + /// Level index at which row `target_row` starts, advancing the monotonic + /// repetition cursor. Callers must request non-decreasing `target_row`. + fn seek_row_start(&mut self, target_row: u64) -> Result { + match &self.rep { + Some(rep) => rep.seek_row_start(&mut self.rep_cursor, target_row, self.max_rep), + None => { + // Without repetition every level is its own row. + self.rep_cursor.row = target_row; + self.rep_cursor.level = target_row as usize; + Ok(target_row as usize) + } + } + } + + /// Number of visible items in the level range `levels` (definition levels + /// `<= max_visible_level`), advancing the monotonic definition cursor. + fn count_visible(&mut self, levels: Range) -> Result<(u64, RunPosition)> { + match &self.def { + Some(def) => { + if levels.end > def.len() { + return Err(Error::internal( + "Invalid complex all-null layout: definition buffer too short", + )); } - }) - .collect() + Ok(def.count_le_cursor(&mut self.def_run_cursor, levels, self.max_visible_level)) + } + None => Ok(((levels.end - levels.start) as u64, RunPosition::default())), + } } } -#[derive(Debug, PartialEq, Eq, Clone, Copy)] -enum PreambleAction { - Take, - Skip, - Absent, -} +impl StructuralPageDecoder for ComplexAllNullPageDecoder { + fn drain(&mut self, num_rows: u64) -> Result> { + let drained_ranges = self.drain_ranges(num_rows); + let mut level_slices: Vec = Vec::with_capacity(drained_ranges.len()); + let mut visible_items_total = 0; + + // Each row range is one contiguous level slice `[start_row_level, + // end_row_level)`, so we seek both boundaries and count its visibility at + // once rather than per row. The cursors only move forward, so locating and + // counting all requested ranges visits each intervening run at most once. + for range in drained_ranges { + let level_start = self.seek_row_start(range.start)?; + let rep_run = self.rep_cursor.run; + let level_end = self.seek_row_start(range.end)?; + let (visible_items, def_run) = self.count_visible(level_start..level_end)?; + visible_items_total += visible_items; + if let Some(last) = level_slices.last_mut() + && last.range.end == level_start + { + last.range.end = level_end; + } else { + level_slices.push(LevelSlice { + range: level_start..level_end, + rep_run, + def_run, + }); + } + } -// When we schedule a chunk we use the repetition index (or, if none exists, just the # of items -// in each chunk) to map a user requested range into a set of ChunkInstruction objects which tell -// us how exactly to read from the chunk. -// -// Examples: -// -// | Chunk 0 | Chunk 1 | Chunk 2 | Chunk 3 | -// | xxxxyyyyzzz | zzzzzzzzz | zzzzzzzzz | aaabbcc | -// -// Full read (0..6) -// -// Chunk 0: (several rows, ends with trailer) -// preamble: absent -// rows_to_skip: 0 -// rows_to_take: 3 (x, y, z) -// take_trailer: true -// -// Chunk 1: (all preamble, ends with trailer) -// preamble: take -// rows_to_skip: 0 -// rows_to_take: 0 -// take_trailer: true -// -// Chunk 2: (all preamble, no trailer) -// preamble: take -// rows_to_skip: 0 -// rows_to_take: 0 -// take_trailer: false -// -// Chunk 3: (several rows, no trailer or preamble) -// preamble: absent -// rows_to_skip: 0 -// rows_to_take: 3 (a, b, c) -// take_trailer: false -#[derive(Clone, Debug, PartialEq, Eq)] -struct ChunkInstructions { - // The index of the chunk to read - chunk_idx: usize, - // A "preamble" is when a chunk begins with a continuation of the previous chunk's list. If there - // is no repetition index there is never a preamble. - // - // It's possible for a chunk to be entirely premable. For example, if there is a really large list - // that spans several chunks. - preamble: PreambleAction, - // How many complete rows (not including the preamble or trailer) to skip - // - // If this is non-zero then premable must not be Take - rows_to_skip: u64, - // How many rows to take. If a row splits across chunks then we will count the row in the first - // chunk that contains the row. - rows_to_take: u64, - // A "trailer" is when a chunk ends with a partial list. If there is no repetition index there is - // never a trailer. - // - // A chunk that is all preamble may or may not have a trailer. - // - // If this is true then we want to include the trailer - take_trailer: bool, -} + Ok(Box::new(DecodeComplexAllNullTask { + level_slices, + visible_items_total, + rep: self.rep.clone(), + def: self.def.clone(), + def_meaning: self.def_meaning.clone(), + max_visible_level: self.max_visible_level, + })) + } -// First, we schedule a bunch of [`ChunkInstructions`] based on the users ranges. Then we -// start decoding them, based on a batch size, which might not align with what we scheduled. -// -// This results in `ChunkDrainInstructions` which targets a contiguous slice of a `ChunkInstructions` -// -// So if `ChunkInstructions` is "skip preamble, skip 10, take 50, take trailer" and we are decoding in -// batches of size 10 we might have a `ChunkDrainInstructions` that targets that chunk and has its own -// skip of 17 and take of 10. This would mean we decode the chunk, skip the preamble and 27 rows, and -// then take 10 rows. -// -// One very confusing bit is that `rows_to_take` includes the trailer. So if we have two chunks: -// -no preamble, skip 5, take 10, take trailer -// -take preamble, skip 0, take 50, no trailer -// -// and we are draining 20 rows then the drain instructions for the first batch will be: -// - no preamble, skip 0 (from chunk 0), take 11 (from chunk 0) -// - take preamble (from chunk 1), skip 0 (from chunk 1), take 9 (from chunk 1) -#[derive(Debug, PartialEq, Eq)] -struct ChunkDrainInstructions { - chunk_instructions: ChunkInstructions, - rows_to_skip: u64, - rows_to_take: u64, - preamble_action: PreambleAction, + fn num_rows(&self) -> u64 { + self.num_rows + } } -impl ChunkInstructions { - // Given a repetition index and a set of user ranges we need to figure out how to read from the chunks - // - // We assume that `user_ranges` are in sorted order and non-overlapping - // - // The output will be a set of `ChunkInstructions` which tell us how to read from the chunks - fn schedule_instructions( - rep_index: &MiniBlockRepIndex, - user_ranges: &[Range], - ) -> Vec { - // This is an in-exact capacity guess but pretty good. The actual capacity can be - // smaller if instructions are merged. It can be larger if there are multiple instructions - // per row which can happen with lists. - let mut chunk_instructions = Vec::with_capacity(user_ranges.len()); - - for user_range in user_ranges { - let mut rows_needed = user_range.end - user_range.start; - let mut need_preamble = false; +/// We use `level_slices` to slice into `rep` and `def` and create rep/def buffers +/// for the null data. +#[derive(Debug, Clone)] +struct LevelSlice { + range: Range, + rep_run: RunPosition, + def_run: RunPosition, +} - // Need to find the first chunk with a first row >= user_range.start. If there are - // multiple chunks with the same first row we need to take the first one. - let mut block_index = match rep_index - .blocks - .binary_search_by_key(&user_range.start, |block| block.first_row) - { - Ok(idx) => { - // Slightly tricky case, we may need to walk backwards a bit to make sure we - // are grabbing first eligible chunk - let mut idx = idx; - while idx > 0 && rep_index.blocks[idx - 1].first_row == user_range.start { - idx -= 1; - } - idx - } - // Easy case. idx is greater, and idx - 1 is smaller, so idx - 1 contains the start - Err(idx) => idx - 1, - }; +#[derive(Clone, Copy)] +enum LevelKind { + Repetition, + Definition, +} - let mut to_skip = user_range.start - rep_index.blocks[block_index].first_row; +impl LevelSlice { + fn run(&self, kind: LevelKind) -> RunPosition { + match kind { + LevelKind::Repetition => self.rep_run, + LevelKind::Definition => self.def_run, + } + } +} - while rows_needed > 0 || need_preamble { - // Check if we've gone past the last block (should not happen) - if block_index >= rep_index.blocks.len() { - log::warn!("schedule_instructions inconsistency: block_index >= rep_index.blocks.len(), exiting early"); - break; - } +#[derive(Debug)] +pub struct DecodeComplexAllNullTask { + level_slices: Vec, + visible_items_total: u64, + rep: Option, + def: Option, + def_meaning: Arc<[DefinitionInterpretation]>, + max_visible_level: u16, +} - let chunk = &rep_index.blocks[block_index]; - let rows_avail = chunk.starts_including_trailer.saturating_sub(to_skip); +impl DecodeComplexAllNullTask { + fn decode_level(&self, levels: &Option, kind: LevelKind) -> Option> { + levels.as_ref().map(|levels| { + let num_levels = self + .level_slices + .iter() + .map(|slice| slice.range.end - slice.range.start) + .sum(); + let mut referenced_levels = Vec::with_capacity(num_levels); + for slice in &self.level_slices { + levels.extend_into(slice.range.clone(), slice.run(kind), &mut referenced_levels); + } + referenced_levels + }) + } +} - // Handle blocks that are entirely preamble (rows_avail = 0) - // These blocks have no rows to take but may have a preamble we need - // We only look for preamble if to_skip == 0 (we're not skipping rows) - if rows_avail == 0 && to_skip == 0 { - // Only process if this chunk has a preamble we need - if chunk.has_preamble && need_preamble { - chunk_instructions.push(Self { - chunk_idx: block_index, - preamble: PreambleAction::Take, - rows_to_skip: 0, - rows_to_take: 0, - // We still need to look at has_trailer to distinguish between "all preamble - // and row ends at end of chunk" and "all preamble and row bleeds into next - // chunk". Both cases will have 0 rows available. - take_trailer: chunk.has_trailer, - }); - // Only set need_preamble = false if the chunk has at least one row, - // Or we are reaching the last block, - // Otherwise, the chunk is entirely preamble and we need the next chunk's preamble too - if chunk.starts_including_trailer > 0 - || block_index == rep_index.blocks.len() - 1 - { - need_preamble = false; - } - } - // Move to next block - block_index += 1; - continue; - } +impl DecodePageTask for DecodeComplexAllNullTask { + fn decode(self: Box) -> Result { + let rep = self.decode_level(&self.rep, LevelKind::Repetition); + let def = self.decode_level(&self.def, LevelKind::Definition); - // Edge case: if rows_avail == 0 but to_skip > 0 - // This theoretically shouldn't happen (binary search should avoid it) - // but handle it for safety - if rows_avail == 0 && to_skip > 0 { - // This block doesn't have enough rows to skip, move to next block - // Adjust to_skip by the number of rows in this block - to_skip -= chunk.starts_including_trailer; - block_index += 1; - continue; - } + // If there are definition levels there may be empty / null lists which are not visible + // in the items array. We need to account for that here to figure out how many values + // should be in the items array. + let num_values = if let Some(def) = &def { + def.iter().filter(|&d| *d <= self.max_visible_level).count() as u64 + } else { + self.visible_items_total + }; - let rows_to_take = rows_avail.min(rows_needed); - rows_needed -= rows_to_take; + let data = DataBlock::AllNull(AllNullDataBlock { num_values }); + let unraveler = RepDefUnraveler::new(rep, def, self.def_meaning, num_values); + Ok(DecodedPage { + data, + repdef: unraveler, + }) + } +} - let mut take_trailer = false; - let preamble = if chunk.has_preamble { - if need_preamble { - PreambleAction::Take - } else { - PreambleAction::Skip - } - } else { - PreambleAction::Absent - }; +/// A scheduler for simple all-null data +/// +/// "simple" all-null data is data that is all null and only has a single level of definition and +/// no repetition. We don't need to read any data at all in this case. +#[derive(Debug, Default)] +pub struct SimpleAllNullScheduler {} - // Are we taking the trailer? If so, make sure we mark that we need the preamble - if rows_to_take == rows_avail && chunk.has_trailer { - take_trailer = true; - need_preamble = true; - } else { - need_preamble = false; - }; +impl StructuralPageScheduler for SimpleAllNullScheduler { + fn needs_initialization(&self) -> bool { + false + } - chunk_instructions.push(Self { - preamble, - chunk_idx: block_index, - rows_to_skip: to_skip, - rows_to_take, - take_trailer, - }); + fn init_layout(&self) -> Result { + Ok(PageInitialization::new([])) + } - to_skip = 0; - block_index += 1; - } - } + fn init_from_buffers<'a>( + &'a mut self, + _buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, Result>> { + std::future::ready(Ok(Arc::new(NoCachedPageData) as Arc)).boxed() + } - // If there were multiple ranges we may have multiple instructions for a single chunk. Merge them now if they - // are _adjacent_ (i.e. don't merge "take first row of chunk 0" and "take third row of chunk 0" into "take 2 - // rows of chunk 0 starting at 0") - if user_ranges.len() > 1 { - // TODO: Could probably optimize this allocation away - let mut merged_instructions = Vec::with_capacity(chunk_instructions.len()); - let mut instructions_iter = chunk_instructions.into_iter(); - merged_instructions.push(instructions_iter.next().unwrap()); - for instruction in instructions_iter { - let last = merged_instructions.last_mut().unwrap(); - if last.chunk_idx == instruction.chunk_idx - && last.rows_to_take + last.rows_to_skip == instruction.rows_to_skip - { - last.rows_to_take += instruction.rows_to_take; - last.take_trailer |= instruction.take_trailer; - } else { - merged_instructions.push(instruction); - } - } - merged_instructions - } else { - chunk_instructions - } + fn try_load(&mut self, _cache: &Arc) -> Result<()> { + Ok(()) } - fn drain_from_instruction( + fn schedule_ranges( &self, - rows_desired: &mut u64, - need_preamble: &mut bool, - skip_in_chunk: &mut u64, - ) -> (ChunkDrainInstructions, bool) { - // If we need the premable then we shouldn't be skipping anything - debug_assert!(!*need_preamble || *skip_in_chunk == 0); - let rows_avail = self.rows_to_take - *skip_in_chunk; - let has_preamble = self.preamble != PreambleAction::Absent; - let preamble_action = match (*need_preamble, has_preamble) { - (true, true) => PreambleAction::Take, - (true, false) => panic!("Need preamble but there isn't one"), - (false, true) => PreambleAction::Skip, - (false, false) => PreambleAction::Absent, - }; - - // How many rows are we actually taking in this take step (including the preamble - // and trailer both as individual rows) - let rows_taking = if *rows_desired >= rows_avail { - // We want all the rows. If there is a trailer we are grabbing it and will need - // the preamble of the next chunk - // If there is a trailer and we are taking all the rows then we need the preamble - // of the next chunk. - // - // Also, if this chunk is entirely preamble (rows_avail == 0 && !take_trailer) then we - // need the preamble of the next chunk. - *need_preamble = self.take_trailer; - rows_avail - } else { - // We aren't taking all the rows. Even if there is a trailer we aren't taking - // it so we will not need the preamble - *need_preamble = false; - *rows_desired - }; - let rows_skipped = *skip_in_chunk; - - // Update the state for the next iteration - let consumed_chunk = if *rows_desired >= rows_avail { - *rows_desired -= rows_avail; - *skip_in_chunk = 0; - true - } else { - *skip_in_chunk += *rows_desired; - *rows_desired = 0; - false + ranges: &[Range], + _io: &Arc, + ) -> Result> { + let num_rows = ranges.iter().map(|r| r.end - r.start).sum::(); + let decoder = + Box::new(SimpleAllNullPageDecoder { num_rows }) as Box; + let page_load_task = PageLoadTask { + decoder_fut: std::future::ready(Ok(decoder)).boxed(), + num_rows, }; - - ( - ChunkDrainInstructions { - chunk_instructions: self.clone(), - rows_to_skip: rows_skipped, - rows_to_take: rows_taking, - preamble_action, - }, - consumed_chunk, - ) + Ok(vec![page_load_task]) } } -enum Words { - U16(ScalarBuffer), - U32(ScalarBuffer), +/// A page decode task for all-null data without any +/// repetition and only a single level of definition +#[derive(Debug)] +struct SimpleAllNullDecodePageTask { + num_values: u64, } - -struct WordsIter<'a> { - iter: Box + 'a>, +impl DecodePageTask for SimpleAllNullDecodePageTask { + fn decode(self: Box) -> Result { + let unraveler = RepDefUnraveler::new( + None, + Some(vec![1; self.num_values as usize]), + Arc::new([DefinitionInterpretation::NullableItem]), + self.num_values, + ); + Ok(DecodedPage { + data: DataBlock::AllNull(AllNullDataBlock { + num_values: self.num_values, + }), + repdef: unraveler, + }) + } } -impl Words { - pub fn len(&self) -> usize { - match self { - Self::U16(b) => b.len(), - Self::U32(b) => b.len(), - } - } +#[derive(Debug)] +pub struct SimpleAllNullPageDecoder { + num_rows: u64, +} - pub fn iter(&self) -> WordsIter<'_> { - match self { - Self::U16(buf) => WordsIter { - iter: Box::new(buf.iter().map(|&x| x as u32)), - }, - Self::U32(buf) => WordsIter { - iter: Box::new(buf.iter().copied()), - }, - } +impl StructuralPageDecoder for SimpleAllNullPageDecoder { + fn drain(&mut self, num_rows: u64) -> Result> { + Ok(Box::new(SimpleAllNullDecodePageTask { + num_values: num_rows, + })) } - pub fn from_bytes(bytes: Bytes, has_large_chunk: bool) -> Result { - let bytes_per_value = if has_large_chunk { 4 } else { 2 }; - assert_eq!(bytes.len() % bytes_per_value, 0); - let buffer = LanceBuffer::from_bytes(bytes, bytes_per_value as u64); - if has_large_chunk { - Ok(Self::U32(buffer.borrow_to_typed_slice::())) - } else { - Ok(Self::U16(buffer.borrow_to_typed_slice::())) - } + fn num_rows(&self) -> u64 { + self.num_rows } } -impl<'a> Iterator for WordsIter<'a> { - type Item = u32; - - fn next(&mut self) -> Option { - self.iter.next() - } +#[derive(Debug, Clone)] +struct MiniBlockSchedulerDictionary { + // These come from the protobuf + dictionary_decompressor: Arc, + dictionary_buf_position_and_size: (u64, u64), + dictionary_data_alignment: u64, + num_dictionary_items: u64, } -impl StructuralPageScheduler for MiniBlockScheduler { - fn initialize<'a>( - &'a mut self, - io: &Arc, - ) -> BoxFuture<'a, Result>> { - // We always need to fetch chunk metadata. We may also need to fetch a dictionary and - // we may also need to fetch the repetition index. Here, we gather what buffers we - // need. - let (meta_buf_position, meta_buf_size) = self.buffer_offsets_and_sizes[0]; - let value_buf_position = self.buffer_offsets_and_sizes[1].0; - let mut bufs_needed = 1; - if self.dictionary.is_some() { - bufs_needed += 1; - } - if self.repetition_index_depth > 0 { - bufs_needed += 1; - } - let mut required_ranges = Vec::with_capacity(bufs_needed); - required_ranges.push(meta_buf_position..meta_buf_position + meta_buf_size); - if let Some(ref dictionary) = self.dictionary { - required_ranges.push( - dictionary.dictionary_buf_position_and_size.0 - ..dictionary.dictionary_buf_position_and_size.0 - + dictionary.dictionary_buf_position_and_size.1, - ); - } - if self.repetition_index_depth > 0 { - let (rep_index_pos, rep_index_size) = self.buffer_offsets_and_sizes.last().unwrap(); - required_ranges.push(*rep_index_pos..*rep_index_pos + *rep_index_size); - } - let io_req = io.submit_request(required_ranges, 0); - - async move { - let mut buffers = io_req.await?.into_iter().fuse(); - let meta_bytes = buffers.next().unwrap(); - let dictionary_bytes = self.dictionary.as_ref().and_then(|_| buffers.next()); - let rep_index_bytes = buffers.next(); - - // Parse the metadata and build the chunk meta - let words = Words::from_bytes(meta_bytes, self.has_large_chunk)?; - let mut chunk_meta = Vec::with_capacity(words.len()); - - let mut rows_counter = 0; - let mut offset_bytes = value_buf_position; - for (word_idx, word) in words.iter().enumerate() { - let log_num_values = word & 0x0F; - let divided_bytes = word >> 4; - let num_bytes = (divided_bytes as usize + 1) * MINIBLOCK_ALIGNMENT; - debug_assert!(num_bytes > 0); - let num_values = if word_idx < words.len() - 1 { - debug_assert!(log_num_values > 0); - 1 << log_num_values - } else { - debug_assert!( - log_num_values == 0 - || (1 << log_num_values) == (self.items_in_page - rows_counter) - ); - self.items_in_page - rows_counter - }; - rows_counter += num_values; - - chunk_meta.push(ChunkMeta { - num_values, - chunk_size_bytes: num_bytes as u64, - offset_bytes, - }); - offset_bytes += num_bytes as u64; - } - - // Build the repetition index - let rep_index = if let Some(rep_index_data) = rep_index_bytes { - assert!(rep_index_data.len() % 8 == 0); - let stride = self.repetition_index_depth as usize + 1; - MiniBlockRepIndex::decode_from_bytes(&rep_index_data, stride) - } else { - MiniBlockRepIndex::default_from_chunks(&chunk_meta) - }; - - let mut page_meta = MiniBlockCacheableState { - chunk_meta, - rep_index, - dictionary: None, - }; - - // decode dictionary - if let Some(ref mut dictionary) = self.dictionary { - let dictionary_data = dictionary_bytes.unwrap(); - page_meta.dictionary = - Some(Arc::new(dictionary.dictionary_decompressor.decompress( - LanceBuffer::from_bytes( - dictionary_data, - dictionary.dictionary_data_alignment, - ), - dictionary.num_dictionary_items, - )?)); - }; - let page_meta = Arc::new(page_meta); - self.page_meta = Some(page_meta.clone()); - Ok(page_meta as Arc) - } - .boxed() - } +/// State that is loaded once and cached for future lookups +#[derive(Debug)] +struct MiniBlockCacheableState { + /// Compact per-chunk index (byte ranges + row/item mapping) for the page + chunk_index: MiniBlockChunkIndex, + /// The dictionary for the page, if any + dictionary: Option>, +} - fn load(&mut self, data: &Arc) { - self.page_meta = Some( - data.clone() - .as_arc_any() - .downcast::() - .unwrap(), - ); +impl DeepSizeOf for MiniBlockCacheableState { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.chunk_index.deep_size_of_children(context) + + self + .dictionary + .as_ref() + .map(|dict| dict.data_size() as usize) + .unwrap_or(0) } +} - fn schedule_ranges( - &self, - ranges: &[Range], - io: &Arc, - ) -> Result> { - let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); - - let page_meta = self.page_meta.as_ref().unwrap(); - - let chunk_instructions = - ChunkInstructions::schedule_instructions(&page_meta.rep_index, ranges); - - debug_assert_eq!( - num_rows, - chunk_instructions - .iter() - .map(|ci| ci.rows_to_take) - .sum::() - ); - - let chunks_needed = chunk_instructions - .iter() - .map(|ci| ci.chunk_idx) - .unique() - .collect::>(); - - let mut loaded_chunks = self.lookup_chunks(&chunks_needed); - let chunk_ranges = loaded_chunks - .iter() - .map(|c| c.byte_range.clone()) - .collect::>(); - let loaded_chunk_data = io.submit_request(chunk_ranges, self.priority); - - let rep_decompressor = self.rep_decompressor.clone(); - let def_decompressor = self.def_decompressor.clone(); - let value_decompressor = self.value_decompressor.clone(); - let num_buffers = self.num_buffers; - let has_large_chunk = self.has_large_chunk; - let dictionary = page_meta - .dictionary - .as_ref() - .map(|dictionary| dictionary.clone()); - let def_meaning = self.def_meaning.clone(); - - let res = async move { - let loaded_chunk_data = loaded_chunk_data.await?; - for (loaded_chunk, chunk_data) in loaded_chunks.iter_mut().zip(loaded_chunk_data) { - loaded_chunk.data = LanceBuffer::from_bytes(chunk_data, 1); - } - - Ok(Box::new(MiniBlockDecoder { - rep_decompressor, - def_decompressor, - value_decompressor, - def_meaning, - loaded_chunks: VecDeque::from_iter(loaded_chunks), - instructions: VecDeque::from(chunk_instructions), - offset_in_current_chunk: 0, - dictionary, - num_rows, - num_buffers, - has_large_chunk, - }) as Box) - } - .boxed(); - let page_load_task = PageLoadTask { - decoder_fut: res, - num_rows, - }; - Ok(vec![page_load_task]) +impl CachedPageData for MiniBlockCacheableState { + fn as_arc_any(self: Arc) -> Arc { + self } } -#[derive(Debug, Clone, Copy)] -struct FullZipRepIndexDetails { - buf_position: u64, - bytes_per_value: u64, // Will be 1, 2, 4, or 8 -} - -#[derive(Debug)] -enum PerValueDecompressor { - Fixed(Arc), - Variable(Arc), -} - -#[derive(Debug)] -struct FullZipDecodeDetails { - value_decompressor: PerValueDecompressor, - def_meaning: Arc<[DefinitionInterpretation]>, - ctrl_word_parser: ControlWordParser, - max_rep: u16, - max_visible_def: u16, -} - -/// A scheduler for full-zip encoded data +/// A scheduler for a page that has been encoded with the mini-block layout /// -/// When the data type has a fixed-width then we simply need to map from -/// row ranges to byte ranges using the fixed-width of the data type. +/// Scheduling mini-block encoded data is simple in concept and somewhat complex +/// in practice. /// -/// When the data type is variable-width or has any repetition then a -/// repetition index is required. +/// First, during initialization, we load the chunk metadata, the repetition index, +/// and the dictionary (these last two may not be present) +/// +/// Then, during scheduling, we use the user's requested row ranges and the repetition +/// index to determine which chunks we need and which rows we need from those chunks. +/// +/// For example, if the repetition index is: [50, 3], [50, 0], [10, 0] and the range +/// from the user is 40..60 then we need to: +/// +/// - Read the first chunk and skip the first 40 rows, then read 10 full rows, and +/// then read 3 items for the 11th row of our range. +/// - Read the second chunk and read the remaining items in our 11th row and then read +/// the remaining 9 full rows. +/// +/// Then, if we are going to decode that in batches of 5, we need to make decode tasks. +/// The first two decode tasks will just need the first chunk. The third decode task will +/// need the first chunk (for the trailer which has the 11th row in our range) and the second +/// chunk. The final decode task will just need the second chunk. +/// +/// The above prose descriptions are what are represented by `ChunkInstructions` and +/// `ChunkDrainInstructions`. #[derive(Debug)] -pub struct FullZipScheduler { - data_buf_position: u64, - rep_index: Option, +pub struct MiniBlockScheduler { + // These come from the protobuf + buffer_offsets_and_sizes: Vec<(u64, u64)>, priority: u64, - rows_in_page: u64, - bits_per_offset: u8, - details: Arc, - /// Cached state containing the decoded repetition index - cached_state: Option>, - /// Whether to enable caching of repetition indices - enable_cache: bool, + items_in_page: u64, + repetition_index_depth: u16, + num_buffers: u64, + rep_decompressor: Option>, + def_decompressor: Option>, + value_decompressor: Arc, + def_meaning: Arc<[DefinitionInterpretation]>, + dictionary: Option, + // This is set after initialization + page_meta: Option>, + has_large_chunk: bool, } -impl FullZipScheduler { +impl MiniBlockScheduler { fn try_new( buffer_offsets_and_sizes: &[(u64, u64)], priority: u64, - rows_in_page: u64, - layout: &pb21::FullZipLayout, + items_in_page: u64, + layout: &pb21::MiniBlockLayout, decompressors: &dyn DecompressionStrategy, ) -> Result { - // We don't need the data_buf_size because either the data type is - // fixed-width (and we can tell size from rows_in_page) or it is not - // and we have a repetition index. - let (data_buf_position, _) = buffer_offsets_and_sizes[0]; - let rep_index = buffer_offsets_and_sizes.get(1).map(|(pos, len)| { - let num_reps = rows_in_page + 1; - let bytes_per_rep = len / num_reps; - debug_assert_eq!(len % num_reps, 0); - debug_assert!( - bytes_per_rep == 1 - || bytes_per_rep == 2 - || bytes_per_rep == 4 - || bytes_per_rep == 8 - ); - FullZipRepIndexDetails { - buf_position: *pos, - bytes_per_value: bytes_per_rep, - } - }); - - let value_decompressor = match layout.details { - Some(pb21::full_zip_layout::Details::BitsPerValue(_)) => { - let decompressor = decompressors.create_fixed_per_value_decompressor( - layout.value_compression.as_ref().unwrap(), - )?; - PerValueDecompressor::Fixed(decompressor.into()) - } - Some(pb21::full_zip_layout::Details::BitsPerOffset(_)) => { - let decompressor = decompressors.create_variable_per_value_decompressor( - layout.value_compression.as_ref().unwrap(), - )?; - PerValueDecompressor::Variable(decompressor.into()) - } - None => { - panic!("Full-zip layout must have a `details` field"); - } - }; - let ctrl_word_parser = ControlWordParser::new( - layout.bits_rep.try_into().unwrap(), - layout.bits_def.try_into().unwrap(), - ); + let rep_decompressor = layout + .rep_compression + .as_ref() + .map(|rep_compression| { + decompressors + .create_block_decompressor(rep_compression) + .map(Arc::from) + }) + .transpose()?; + let def_decompressor = layout + .def_compression + .as_ref() + .map(|def_compression| { + decompressors + .create_block_decompressor(def_compression) + .map(Arc::from) + }) + .transpose()?; let def_meaning = layout .layers .iter() .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) .collect::>(); + let value_decompressor = decompressors.create_miniblock_decompressor( + layout.value_compression.as_ref().unwrap(), + decompressors, + )?; - let max_rep = def_meaning.iter().filter(|d| d.is_list()).count() as u16; - let max_visible_def = def_meaning - .iter() - .filter(|d| !d.is_list()) - .map(|d| d.num_def_levels()) - .sum(); - - let bits_per_offset = match layout.details { - Some(pb21::full_zip_layout::Details::BitsPerValue(_)) => 32, - Some(pb21::full_zip_layout::Details::BitsPerOffset(bits_per_offset)) => { - bits_per_offset as u8 - } - None => panic!("Full-zip layout must have a `details` field"), + let dictionary = if let Some(dictionary_encoding) = layout.dictionary.as_ref() { + let num_dictionary_items = layout.num_dictionary_items; + let dictionary_decompressor = decompressors + .create_block_decompressor(dictionary_encoding)? + .into(); + let dictionary_data_alignment = match dictionary_encoding.compression.as_ref().unwrap() + { + Compression::Variable(_) => 4, + Compression::Flat(_) => 16, + Compression::General(_) => 1, + Compression::InlineBitpacking(_) | Compression::OutOfLineBitpacking(_) => { + crate::encoder::MIN_PAGE_BUFFER_ALIGNMENT + } + _ => { + return Err(Error::invalid_input_source( + format!( + "Unsupported mini-block dictionary encoding: {:?}", + dictionary_encoding.compression.as_ref().unwrap() + ) + .into(), + )); + } + }; + Some(MiniBlockSchedulerDictionary { + dictionary_decompressor, + dictionary_buf_position_and_size: buffer_offsets_and_sizes[2], + dictionary_data_alignment, + num_dictionary_items, + }) + } else { + None }; - let details = Arc::new(FullZipDecodeDetails { - value_decompressor, - def_meaning: def_meaning.into(), - ctrl_word_parser, - max_rep, - max_visible_def, - }); Ok(Self { - data_buf_position, - rep_index, - details, + buffer_offsets_and_sizes: buffer_offsets_and_sizes.to_vec(), + rep_decompressor, + def_decompressor, + value_decompressor: value_decompressor.into(), + repetition_index_depth: layout.repetition_index_depth as u16, + num_buffers: layout.num_buffers, priority, - rows_in_page, - bits_per_offset, - cached_state: None, - enable_cache: false, // Default to false, will be set later + items_in_page, + dictionary, + def_meaning: def_meaning.into(), + page_meta: None, + has_large_chunk: layout.has_large_chunk, }) } - /// Creates a decoder from the loaded data - fn create_decoder( - details: Arc, - data: VecDeque, - num_rows: u64, - bits_per_offset: u8, - ) -> Result> { - match &details.value_decompressor { - PerValueDecompressor::Fixed(decompressor) => { - let bits_per_value = decompressor.bits_per_value(); - if bits_per_value == 0 { - return Err(lance_core::Error::Internal { - message: "Invalid encoding: bits_per_value must be greater than 0".into(), - location: location!(), - }); - } - if bits_per_value % 8 != 0 { - return Err(lance_core::Error::NotSupported { - source: "Bit-packed full-zip encoding (non-byte-aligned values) is not yet implemented".into(), - location: location!(), - }); - } - let bytes_per_value = bits_per_value / 8; - let total_bytes_per_value = - bytes_per_value as usize + details.ctrl_word_parser.bytes_per_word(); - Ok(Box::new(FixedFullZipDecoder { - details, - data, - num_rows, - offset_in_current: 0, - bytes_per_value: bytes_per_value as usize, - total_bytes_per_value, - }) as Box) - } - PerValueDecompressor::Variable(_decompressor) => { - Ok(Box::new(VariableFullZipDecoder::new( - details, - data, - num_rows, - bits_per_offset, - bits_per_offset, - ))) - } - } - } - - /// Extracts byte ranges from a repetition index buffer - /// The buffer contains pairs of (start, end) values for each range - fn extract_byte_ranges_from_pairs( - buffer: LanceBuffer, - bytes_per_value: u64, - data_buf_position: u64, - ) -> Vec> { - ByteUnpacker::new(buffer, bytes_per_value as usize) - .chunks(2) - .into_iter() - .map(|mut c| { - let start = c.next().unwrap() + data_buf_position; - let end = c.next().unwrap() + data_buf_position; - start..end + fn lookup_chunks(&self, chunk_indices: &[usize]) -> Vec { + let chunk_index = &self.page_meta.as_ref().unwrap().chunk_index; + chunk_indices + .iter() + .map(|&chunk_idx| LoadedChunk { + byte_range: chunk_index.byte_range(chunk_idx), + items_in_chunk: chunk_index.items_in_chunk(chunk_idx), + chunk_idx, + data: LanceBuffer::empty(), }) - .collect::>() + .collect() } +} - /// Extracts byte ranges from a cached repetition index buffer - /// The buffer contains all values and we need to extract specific ranges - fn extract_byte_ranges_from_cached( +#[derive(Debug, PartialEq, Eq, Clone, Copy)] +enum PreambleAction { + Take, + Skip, + Absent, +} + +// When we schedule a chunk we use the repetition index (or, if none exists, just the # of items +// in each chunk) to map a user requested range into a set of ChunkInstruction objects which tell +// us how exactly to read from the chunk. +// +// Examples: +// +// | Chunk 0 | Chunk 1 | Chunk 2 | Chunk 3 | +// | xxxxyyyyzzz | zzzzzzzzz | zzzzzzzzz | aaabbcc | +// +// Full read (0..6) +// +// Chunk 0: (several rows, ends with trailer) +// preamble: absent +// rows_to_skip: 0 +// rows_to_take: 3 (x, y, z) +// take_trailer: true +// +// Chunk 1: (all preamble, ends with trailer) +// preamble: take +// rows_to_skip: 0 +// rows_to_take: 0 +// take_trailer: true +// +// Chunk 2: (all preamble, no trailer) +// preamble: take +// rows_to_skip: 0 +// rows_to_take: 0 +// take_trailer: false +// +// Chunk 3: (several rows, no trailer or preamble) +// preamble: absent +// rows_to_skip: 0 +// rows_to_take: 3 (a, b, c) +// take_trailer: false +#[derive(Clone, Debug, PartialEq, Eq)] +struct ChunkInstructions { + // The index of the chunk to read + chunk_idx: usize, + // A "preamble" is when a chunk begins with a continuation of the previous chunk's list. If there + // is no repetition index there is never a preamble. + // + // It's possible for a chunk to be entirely premable. For example, if there is a really large list + // that spans several chunks. + preamble: PreambleAction, + // How many complete rows (not including the preamble or trailer) to skip + // + // If this is non-zero then premable must not be Take + rows_to_skip: u64, + // How many rows to take. If a row splits across chunks then we will count the row in the first + // chunk that contains the row. + rows_to_take: u64, + // A "trailer" is when a chunk ends with a partial list. If there is no repetition index there is + // never a trailer. + // + // A chunk that is all preamble may or may not have a trailer. + // + // If this is true then we want to include the trailer + take_trailer: bool, +} + +// First, we schedule a bunch of [`ChunkInstructions`] based on the users ranges. Then we +// start decoding them, based on a batch size, which might not align with what we scheduled. +// +// This results in `ChunkDrainInstructions` which targets a contiguous slice of a `ChunkInstructions` +// +// So if `ChunkInstructions` is "skip preamble, skip 10, take 50, take trailer" and we are decoding in +// batches of size 10 we might have a `ChunkDrainInstructions` that targets that chunk and has its own +// skip of 17 and take of 10. This would mean we decode the chunk, skip the preamble and 27 rows, and +// then take 10 rows. +// +// One very confusing bit is that `rows_to_take` includes the trailer. So if we have two chunks: +// -no preamble, skip 5, take 10, take trailer +// -take preamble, skip 0, take 50, no trailer +// +// and we are draining 20 rows then the drain instructions for the first batch will be: +// - no preamble, skip 0 (from chunk 0), take 11 (from chunk 0) +// - take preamble (from chunk 1), skip 0 (from chunk 1), take 9 (from chunk 1) +#[derive(Debug, PartialEq, Eq)] +struct ChunkDrainInstructions { + chunk_instructions: ChunkInstructions, + rows_to_skip: u64, + rows_to_take: u64, + preamble_action: PreambleAction, +} + +impl ChunkInstructions { + // Given a repetition index and a set of user ranges we need to figure out how to read from the chunks + // + // We assume that `user_ranges` are in sorted order and non-overlapping + // + // The output will be a set of `ChunkInstructions` which tell us how to read from the chunks + fn schedule_instructions( + chunk_index: &MiniBlockChunkIndex, + user_ranges: &[Range], + ) -> Vec { + // Bind the per-page chunk count once; re-deriving it each iteration + // costs a width match plus a length read. + let num_chunks = chunk_index.num_chunks(); + // This is an in-exact capacity guess but pretty good. The actual capacity can be + // smaller if instructions are merged. It can be larger if there are multiple instructions + // per row which can happen with lists. + let mut chunk_instructions = Vec::with_capacity(user_ranges.len()); + + for user_range in user_ranges { + let mut rows_needed = user_range.end - user_range.start; + let mut need_preamble = false; + + // Need to find the first chunk with a first row >= user_range.start. If there are + // multiple chunks with the same first row we need to take the first one. + let mut block_index = chunk_index.find_chunk(user_range.start); + + let mut to_skip = user_range.start - chunk_index.first_row(block_index); + + while rows_needed > 0 || need_preamble { + // Check if we've gone past the last block (should not happen) + if block_index >= num_chunks { + log::warn!( + "schedule_instructions inconsistency: block_index >= num_chunks, exiting early" + ); + break; + } + + let starts_including_trailer = chunk_index.rows_in_chunk(block_index); + let has_preamble = chunk_index.has_preamble(block_index); + let has_trailer = chunk_index.has_trailer(block_index); + let rows_avail = starts_including_trailer.saturating_sub(to_skip); + + // Handle blocks that are entirely preamble (rows_avail = 0) + // These blocks have no rows to take but may have a preamble we need + // We only look for preamble if to_skip == 0 (we're not skipping rows) + if rows_avail == 0 && to_skip == 0 { + // Only process if this chunk has a preamble we need + if has_preamble && need_preamble { + chunk_instructions.push(Self { + chunk_idx: block_index, + preamble: PreambleAction::Take, + rows_to_skip: 0, + rows_to_take: 0, + // We still need to look at has_trailer to distinguish between "all preamble + // and row ends at end of chunk" and "all preamble and row bleeds into next + // chunk". Both cases will have 0 rows available. + take_trailer: has_trailer, + }); + // Only set need_preamble = false if the chunk has at least one row, + // Or we are reaching the last block, + // Otherwise, the chunk is entirely preamble and we need the next chunk's preamble too + if starts_including_trailer > 0 || block_index == num_chunks - 1 { + need_preamble = false; + } + } + // Move to next block + block_index += 1; + continue; + } + + // Edge case: if rows_avail == 0 but to_skip > 0 + // This theoretically shouldn't happen (binary search should avoid it) + // but handle it for safety + if rows_avail == 0 && to_skip > 0 { + // This block doesn't have enough rows to skip, move to next block + // Adjust to_skip by the number of rows in this block + to_skip -= starts_including_trailer; + block_index += 1; + continue; + } + + let rows_to_take = rows_avail.min(rows_needed); + rows_needed -= rows_to_take; + + let mut take_trailer = false; + let preamble = if has_preamble { + if need_preamble { + PreambleAction::Take + } else { + PreambleAction::Skip + } + } else { + PreambleAction::Absent + }; + + // Are we taking the trailer? If so, make sure we mark that we need the preamble + if rows_to_take == rows_avail && has_trailer { + take_trailer = true; + need_preamble = true; + } else { + need_preamble = false; + }; + + chunk_instructions.push(Self { + preamble, + chunk_idx: block_index, + rows_to_skip: to_skip, + rows_to_take, + take_trailer, + }); + + to_skip = 0; + block_index += 1; + } + } + + // If there were multiple ranges we may have multiple instructions for a single chunk. Merge them now if they + // are _adjacent_ (i.e. don't merge "take first row of chunk 0" and "take third row of chunk 0" into "take 2 + // rows of chunk 0 starting at 0") + if user_ranges.len() > 1 { + // Merge adjacent instructions in place. `write` indexes the last + // retained instruction; each following instruction is either folded + // into it (contiguous within the same chunk) or compacted forward. + let mut write = 0; + for read in 1..chunk_instructions.len() { + let merges = { + let last = &chunk_instructions[write]; + let candidate = &chunk_instructions[read]; + last.chunk_idx == candidate.chunk_idx + && last.rows_to_take + last.rows_to_skip == candidate.rows_to_skip + }; + if merges { + let rows_to_take = chunk_instructions[read].rows_to_take; + let take_trailer = chunk_instructions[read].take_trailer; + let last = &mut chunk_instructions[write]; + last.rows_to_take += rows_to_take; + last.take_trailer |= take_trailer; + } else { + write += 1; + if write != read { + chunk_instructions.swap(write, read); + } + } + } + chunk_instructions.truncate(write + 1); + } + chunk_instructions + } + + fn drain_from_instruction( + &self, + rows_desired: &mut u64, + need_preamble: &mut bool, + skip_in_chunk: &mut u64, + ) -> (ChunkDrainInstructions, bool) { + // If we need the premable then we shouldn't be skipping anything + debug_assert!(!*need_preamble || *skip_in_chunk == 0); + let rows_avail = self.rows_to_take - *skip_in_chunk; + let has_preamble = self.preamble != PreambleAction::Absent; + let preamble_action = match (*need_preamble, has_preamble) { + (true, true) => PreambleAction::Take, + (true, false) => panic!("Need preamble but there isn't one"), + (false, true) => PreambleAction::Skip, + (false, false) => PreambleAction::Absent, + }; + + // How many rows are we actually taking in this take step (including the preamble + // and trailer both as individual rows) + let rows_taking = if *rows_desired >= rows_avail { + // We want all the rows. If there is a trailer we are grabbing it and will need + // the preamble of the next chunk + // If there is a trailer and we are taking all the rows then we need the preamble + // of the next chunk. + // + // Also, if this chunk is entirely preamble (rows_avail == 0 && !take_trailer) then we + // need the preamble of the next chunk. + *need_preamble = self.take_trailer; + rows_avail + } else { + // We aren't taking all the rows. Even if there is a trailer we aren't taking + // it so we will not need the preamble + *need_preamble = false; + *rows_desired + }; + let rows_skipped = *skip_in_chunk; + + // Update the state for the next iteration + let consumed_chunk = if *rows_desired >= rows_avail { + *rows_desired -= rows_avail; + *skip_in_chunk = 0; + true + } else { + *skip_in_chunk += *rows_desired; + *rows_desired = 0; + false + }; + + ( + ChunkDrainInstructions { + chunk_instructions: self.clone(), + rows_to_skip: rows_skipped, + rows_to_take: rows_taking, + preamble_action, + }, + consumed_chunk, + ) + } +} + +enum Words { + U16(ScalarBuffer), + U32(ScalarBuffer), +} + +struct WordsIter<'a> { + iter: Box + 'a>, +} + +impl Words { + pub fn len(&self) -> usize { + match self { + Self::U16(b) => b.len(), + Self::U32(b) => b.len(), + } + } + + pub fn iter(&self) -> WordsIter<'_> { + match self { + Self::U16(buf) => WordsIter { + iter: Box::new(buf.iter().map(|&x| x as u32)), + }, + Self::U32(buf) => WordsIter { + iter: Box::new(buf.iter().copied()), + }, + } + } + + pub fn from_bytes(bytes: Bytes, has_large_chunk: bool) -> Result { + let bytes_per_value = if has_large_chunk { 4 } else { 2 }; + assert_eq!(bytes.len() % bytes_per_value, 0); + let buffer = LanceBuffer::from_bytes(bytes, bytes_per_value as u64); + if has_large_chunk { + Ok(Self::U32(buffer.borrow_to_typed_slice::())) + } else { + Ok(Self::U16(buffer.borrow_to_typed_slice::())) + } + } +} + +impl<'a> Iterator for WordsIter<'a> { + type Item = u32; + + fn next(&mut self) -> Option { + self.iter.next() + } +} + +/// Per-chunk leaf value-count analysis derived from the metadata words. +/// +/// `values_per_chunk` is the count shared by every non-last chunk (meaningful +/// when `uniform`), and `last_chunk_values` is the final chunk's count. +struct FlatValueCounts { + logs: Vec, + uniform: bool, + values_per_chunk: u64, + last_chunk_values: u64, +} + +fn analyze_value_counts(words: &Words, items_in_page: u64) -> Result { + let num_chunks = words.len(); + let logs = words.iter().map(|w| (w & 0x0F) as u8).collect::>(); + let mut counted = 0u64; + for (chunk_index, &log) in logs.iter().take(num_chunks.saturating_sub(1)).enumerate() { + if log == 0 { + return Err(Error::corrupt_file_named( + "miniblock_metadata", + format!( + "non-final chunk {chunk_index} of {num_chunks} has invalid log_num_values=0" + ), + )); + } + counted = counted.checked_add(1u64 << log).ok_or_else(|| { + Error::corrupt_file_named( + "miniblock_metadata", + format!( + "value count overflow at chunk {chunk_index}: counted_values={counted}, \ + log_num_values={log}, items_in_page={items_in_page}" + ), + ) + })?; + } + let last_chunk_values = items_in_page.checked_sub(counted).ok_or_else(|| { + Error::corrupt_file_named( + "miniblock_metadata", + format!( + "non-final chunks account for counted_values={counted}, exceeding \ + items_in_page={items_in_page}" + ), + ) + })?; + if let Some(&last_log) = logs.last() + && last_log != 0 + && (1u64 << last_log) != last_chunk_values + { + return Err(Error::corrupt_file_named( + "miniblock_metadata", + format!( + "final chunk log_num_values={last_log} does not match \ + last_chunk_values={last_chunk_values}: counted_values={counted}, \ + items_in_page={items_in_page}" + ), + )); + } + let uniform = num_chunks <= 1 || logs[..num_chunks - 1].iter().all(|&log| log == logs[0]); + // A single-chunk page has no "non-last" chunk to derive a stride from; use the + // page item count (min 1 so it stays a valid divisor in `find_chunk`). + let values_per_chunk = if num_chunks <= 1 { + items_in_page.max(1) + } else { + 1u64 << logs[0] + }; + Ok(FlatValueCounts { + logs, + uniform, + values_per_chunk, + last_chunk_values, + }) +} + +/// Iterator over per-chunk value counts for a non-uniform flat page. Non-last +/// chunks yield `1 << log`; the last yields the validated remaining item count. +fn flat_value_counts_iter(logs: &[u8], last_chunk_values: u64) -> impl Iterator + '_ { + let num_chunks = logs.len(); + (0..num_chunks).map(move |i| { + if i + 1 < num_chunks { + 1u64 << logs[i] + } else { + last_chunk_values + } + }) +} + +/// Builds the compact per-chunk index from the metadata words and, for nested +/// pages, the raw repetition-index bytes. The row axis is picked by page shape: +/// `UniformFlat` when all non-last chunks share a value count (fixed-width / +/// bitpacking), `Flat` for non-uniform flat pages (RLE / FSST), else `Nested`. +fn build_chunk_index( + words: &Words, + items_in_page: u64, + base: u64, + data_buf_size: u64, + rep_index_bytes: Option<&[u8]>, + repetition_index_depth: u16, +) -> Result { + let num_chunks = words.len(); + // Validate item counts before byte sizes because both share a metadata word, + // and an invalid count must not reach the final-chunk subtraction. + let value_counts = analyze_value_counts(words, items_in_page)?; + + // Each chunk stores `(divided_bytes + 1) * MINIBLOCK_ALIGNMENT` bytes, so the + // deltas are the chunk sizes and their grand total is the data buffer size. + let byte_starts = PrefixSums::from_deltas( + words + .iter() + .map(|word| ((word >> 4) as u64 + 1) * MINIBLOCK_ALIGNMENT as u64), + num_chunks, + data_buf_size, + ); + + // Nested pages track rows via the repetition index and keep leaf item counts + // separately; flat pages have row == value index, so value counts are rows. + let rows = if let Some(rep_index_data) = rep_index_bytes { + assert!(rep_index_data.len() % 8 == 0); + let stride = repetition_index_depth as usize + 1; + let (row_starts, has_trailer) = parse_nested_rep(rep_index_data, stride); + let item_counts = if value_counts.uniform { + ItemCounts::Uniform { + values_per_chunk: value_counts.values_per_chunk, + last_chunk_values: value_counts.last_chunk_values, + } + } else { + ItemCounts::PerChunkLog { + logs: value_counts.logs, + last_chunk_values: value_counts.last_chunk_values, + } + }; + RowMapping::Nested { + row_starts, + has_trailer, + item_counts, + } + } else { + if value_counts.uniform { + RowMapping::UniformFlat { + values_per_chunk: value_counts.values_per_chunk, + last_chunk_values: value_counts.last_chunk_values, + num_chunks, + } + } else { + let value_starts = PrefixSums::from_deltas( + flat_value_counts_iter(&value_counts.logs, value_counts.last_chunk_values), + num_chunks, + items_in_page, + ); + RowMapping::Flat { value_starts } + } + }; + + Ok(MiniBlockChunkIndex::new(base, byte_starts, rows)) +} + +impl StructuralPageScheduler for MiniBlockScheduler { + fn init_layout(&self) -> Result { + // We always need to fetch chunk metadata. We may also need to fetch a dictionary and + // we may also need to fetch the repetition index. Here, we gather what buffers we + // need. + let (meta_buf_position, meta_buf_size) = self.buffer_offsets_and_sizes[0]; + let dictionary_range = self.dictionary.as_ref().map(|dictionary| { + dictionary.dictionary_buf_position_and_size.0 + ..dictionary.dictionary_buf_position_and_size.0 + + dictionary.dictionary_buf_position_and_size.1 + }); + let repetition_index_range = if self.repetition_index_depth > 0 { + let (rep_index_pos, rep_index_size) = + self.buffer_offsets_and_sizes.last().ok_or_else(|| { + Error::invalid_input_source( + "Mini-block page has a repetition index but no buffers".into(), + ) + })?; + Some(*rep_index_pos..*rep_index_pos + *rep_index_size) + } else { + None + }; + Ok(PageInitialization::new([ + Some(meta_buf_position..meta_buf_position + meta_buf_size), + dictionary_range, + repetition_index_range, + ])) + } + + fn init_from_buffers<'a>( + &'a mut self, + buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, Result>> { + let base = self.buffer_offsets_and_sizes[1].0; + let data_buf_size = self.buffer_offsets_and_sizes[1].1; + + async move { + let [meta_bytes, dictionary_bytes, rep_index_bytes] = + buffers.try_into_array("mini-block")?; + let meta_bytes = meta_bytes.ok_or_else(|| { + Error::internal("Mini-block page initialization is missing chunk metadata") + })?; + + let words = Words::from_bytes(meta_bytes, self.has_large_chunk)?; + let chunk_index = build_chunk_index( + &words, + self.items_in_page, + base, + data_buf_size, + rep_index_bytes.as_deref(), + self.repetition_index_depth, + )?; + + // decode dictionary + let dictionary = match (self.dictionary.as_ref(), dictionary_bytes) { + (Some(dictionary), Some(dictionary_data)) => { + Some(Arc::new(dictionary.dictionary_decompressor.decompress( + Some(LanceBuffer::from_bytes( + dictionary_data, + dictionary.dictionary_data_alignment, + )), + dictionary.num_dictionary_items, + )?)) + } + (None, None) => None, + _ => { + return Err(Error::internal( + "Mini-block page initialization dictionary slot does not match its layout", + )); + } + }; + + let page_meta = Arc::new(MiniBlockCacheableState { + chunk_index, + dictionary, + }); + Ok(page_meta as Arc) + } + .boxed() + } + + fn try_load(&mut self, data: &Arc) -> Result<()> { + self.page_meta = Some(data.clone().as_arc_any().downcast().map_err(|_| { + Error::invalid_input_source("Cached mini-block page data has an unexpected type".into()) + })?); + Ok(()) + } + + fn schedule_ranges( + &self, + ranges: &[Range], + io: &Arc, + ) -> Result> { + let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); + + let page_meta = self.page_meta.as_ref().unwrap(); + + let chunk_instructions = + ChunkInstructions::schedule_instructions(&page_meta.chunk_index, ranges); + + debug_assert_eq!( + num_rows, + chunk_instructions + .iter() + .map(|ci| ci.rows_to_take) + .sum::() + ); + + let chunks_needed = chunk_instructions + .iter() + .map(|ci| ci.chunk_idx) + .unique() + .collect::>(); + + let mut loaded_chunks = self.lookup_chunks(&chunks_needed); + let chunk_ranges = loaded_chunks + .iter() + .map(|c| c.byte_range.clone()) + .collect::>(); + let loaded_chunk_data = io.submit_request(chunk_ranges, self.priority); + + let rep_decompressor = self.rep_decompressor.clone(); + let def_decompressor = self.def_decompressor.clone(); + let value_decompressor = self.value_decompressor.clone(); + let num_buffers = self.num_buffers; + let has_large_chunk = self.has_large_chunk; + let dictionary = page_meta + .dictionary + .as_ref() + .map(|dictionary| dictionary.clone()); + let def_meaning = self.def_meaning.clone(); + + let res = async move { + let loaded_chunk_data = loaded_chunk_data.await?; + for (loaded_chunk, chunk_data) in loaded_chunks.iter_mut().zip(loaded_chunk_data) { + loaded_chunk.data = LanceBuffer::from_bytes(chunk_data, 1); + } + + Ok(Box::new(MiniBlockDecoder { + rep_decompressor, + def_decompressor, + value_decompressor, + def_meaning, + loaded_chunks: VecDeque::from_iter(loaded_chunks), + instructions: VecDeque::from(chunk_instructions), + offset_in_current_chunk: 0, + dictionary, + num_rows, + num_buffers, + has_large_chunk, + }) as Box) + } + .boxed(); + let page_load_task = PageLoadTask { + decoder_fut: res, + num_rows, + }; + Ok(vec![page_load_task]) + } +} + +#[derive(Debug, Clone, Copy)] +struct FullZipRepIndexDetails { + buf_position: u64, + bytes_per_value: u64, // Will be 1, 2, 4, or 8 +} + +#[derive(Debug)] +enum PerValueDecompressor { + Fixed(Arc), + Variable(Arc), +} + +#[derive(Debug)] +struct FullZipDecodeDetails { + value_decompressor: PerValueDecompressor, + def_meaning: Arc<[DefinitionInterpretation]>, + ctrl_word_parser: ControlWordParser, + max_rep: u16, + max_visible_def: u16, +} + +/// Describes where FullZip byte ranges should be read from. +/// +/// FullZip decoding always needs a list of byte ranges, but those bytes can come +/// from two different places: +/// - Remote I/O (normal path): ranges are fetched from the underlying `EncodingsIo`. +/// - A prefetched full page (full scan fast path): the entire page has already been +/// loaded once and ranges should be sliced from memory. +/// +/// This abstraction keeps scheduling code focused on "which ranges are needed" +/// instead of "how bytes are fetched", and it lets full-page scans avoid the +/// two-stage rep-index -> data I/O pipeline. +#[derive(Debug, Clone)] +enum FullZipReadSource { + /// Fetch ranges from the storage backend through the encoding I/O interface. + Remote(Arc), + /// Slice ranges from an already-loaded FullZip page buffer. + PrefetchedPage { base_offset: u64, data: LanceBuffer }, +} + +impl FullZipReadSource { + /// Materialize the requested ranges as decode-ready `LanceBuffer`s. + /// + /// The returned buffers preserve the input range order. + fn fetch( + &self, + ranges: &[Range], + priority: u64, + ) -> BoxFuture<'static, Result>> { + match self { + Self::Remote(io) => { + let io = io.clone(); + let ranges = ranges.to_vec(); + async move { + let data = io.submit_request(ranges, priority).await?; + Ok(data + .into_iter() + .map(|bytes| LanceBuffer::from_bytes(bytes, 1)) + .collect::>()) + } + .boxed() + } + Self::PrefetchedPage { base_offset, data } => { + let base_offset = *base_offset; + let data = data.clone(); + let page_end = base_offset + data.len() as u64; + std::future::ready( + ranges + .iter() + .map(|range| { + if range.start > range.end + || range.start < base_offset + || range.end > page_end + { + return Err(Error::internal(format!( + "Requested range {:?} is outside page range {}..{}", + range, base_offset, page_end + ))); + } + let start = (range.start - base_offset) as usize; + let len = (range.end - range.start) as usize; + Ok(data.slice_with_length(start, len)) + }) + .collect::>>(), + ) + .boxed() + } + } + } +} + +/// A scheduler for full-zip encoded data +/// +/// When the data type has a fixed-width then we simply need to map from +/// row ranges to byte ranges using the fixed-width of the data type. +/// +/// When the data type is variable-width or has any repetition then a +/// repetition index is required. +#[derive(Debug)] +pub struct FullZipScheduler { + data_buf_position: u64, + data_buf_size: u64, + rep_index: Option, + priority: u64, + rows_in_page: u64, + bits_per_offset: u8, + details: Arc, + /// Cached state containing the decoded repetition index + cached_state: Option>, + /// Whether repetition index metadata should be cached during initialize. + enable_cache: bool, +} + +impl FullZipScheduler { + fn try_new( + buffer_offsets_and_sizes: &[(u64, u64)], + priority: u64, + rows_in_page: u64, + layout: &pb21::FullZipLayout, + decompressors: &dyn DecompressionStrategy, + ) -> Result { + let (data_buf_position, data_buf_size) = buffer_offsets_and_sizes[0]; + let rep_index = buffer_offsets_and_sizes.get(1).map(|(pos, len)| { + let num_reps = rows_in_page + 1; + let bytes_per_rep = len / num_reps; + debug_assert_eq!(len % num_reps, 0); + debug_assert!( + bytes_per_rep == 1 + || bytes_per_rep == 2 + || bytes_per_rep == 4 + || bytes_per_rep == 8 + ); + FullZipRepIndexDetails { + buf_position: *pos, + bytes_per_value: bytes_per_rep, + } + }); + + let value_decompressor = match layout.details { + Some(pb21::full_zip_layout::Details::BitsPerValue(_)) => { + let decompressor = decompressors.create_fixed_per_value_decompressor( + layout.value_compression.as_ref().unwrap(), + )?; + PerValueDecompressor::Fixed(decompressor.into()) + } + Some(pb21::full_zip_layout::Details::BitsPerOffset(_)) => { + let decompressor = decompressors.create_variable_per_value_decompressor( + layout.value_compression.as_ref().unwrap(), + )?; + PerValueDecompressor::Variable(decompressor.into()) + } + None => { + panic!("Full-zip layout must have a `details` field"); + } + }; + let ctrl_word_parser = ControlWordParser::new( + layout.bits_rep.try_into().unwrap(), + layout.bits_def.try_into().unwrap(), + ); + let def_meaning = layout + .layers + .iter() + .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) + .collect::>(); + + let max_rep = def_meaning.iter().filter(|d| d.is_list()).count() as u16; + let max_visible_def = max_visible_level(&def_meaning); + + let bits_per_offset = match layout.details { + Some(pb21::full_zip_layout::Details::BitsPerValue(_)) => 32, + Some(pb21::full_zip_layout::Details::BitsPerOffset(bits_per_offset)) => { + bits_per_offset as u8 + } + None => panic!("Full-zip layout must have a `details` field"), + }; + + let details = Arc::new(FullZipDecodeDetails { + value_decompressor, + def_meaning: def_meaning.into(), + ctrl_word_parser, + max_rep, + max_visible_def, + }); + Ok(Self { + data_buf_position, + data_buf_size, + rep_index, + details, + priority, + rows_in_page, + bits_per_offset, + cached_state: None, + enable_cache: false, + }) + } + + fn covers_entire_page(ranges: &[Range], rows_in_page: u64) -> bool { + if ranges.is_empty() { + return false; + } + let mut expected_start = 0; + for range in ranges { + if range.start != expected_start || range.end > rows_in_page || range.end < range.start + { + return false; + } + expected_start = range.end; + } + expected_start == rows_in_page + } + + fn create_page_load_task( + io_future: BoxFuture<'static, Result>>, + num_rows: u64, + details: Arc, + bits_per_offset: u8, + ) -> PageLoadTask { + let load_task = async move { + 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(); + PageLoadTask { + decoder_fut: load_task, + num_rows, + } + } + + /// Creates a decoder from the loaded data + fn create_decoder( + details: Arc, + data: VecDeque, + num_rows: u64, + bits_per_offset: u8, + ) -> Result> { + match &details.value_decompressor { + PerValueDecompressor::Fixed(decompressor) => { + let bits_per_value = decompressor.bits_per_value(); + if bits_per_value % 8 != 0 { + return Err(lance_core::Error::not_supported_source("Bit-packed full-zip encoding (non-byte-aligned values) is not yet implemented".into())); + } + let bytes_per_value = bits_per_value / 8; + let total_bytes_per_value = + bytes_per_value as usize + details.ctrl_word_parser.bytes_per_word(); + // total_bytes_per_value == 0 is valid for constant-null FSL pages written by + // earlier encoders that produced bits_per_value=0 with no ctrl-word bytes. + // FixedFullZipDecoder::drain handles this case by producing AllNull output + // without touching the (empty) data buffer. + Ok(Box::new(FixedFullZipDecoder { + details, + data, + num_rows, + offset_in_current: 0, + bytes_per_value: bytes_per_value as usize, + total_bytes_per_value, + }) as Box) + } + PerValueDecompressor::Variable(_decompressor) => { + Ok(Box::new(VariableFullZipDecoder::new( + details, + data, + num_rows, + bits_per_offset, + bits_per_offset, + )?)) + } + } + } + + /// Extracts byte ranges from a repetition index buffer + /// The buffer contains pairs of (start, end) values for each range + fn extract_byte_ranges_from_pairs( + buffer: LanceBuffer, + bytes_per_value: u64, + data_buf_position: u64, + ) -> Vec> { + ByteUnpacker::new(buffer, bytes_per_value as usize) + .chunks(2) + .into_iter() + .map(|mut c| { + let start = c.next().unwrap() + data_buf_position; + let end = c.next().unwrap() + data_buf_position; + start..end + }) + .collect::>() + } + + /// Extracts byte ranges from a cached repetition index buffer + /// The buffer contains all values and we need to extract specific ranges + fn extract_byte_ranges_from_cached( buffer: &LanceBuffer, ranges: &[Range], bytes_per_value: u64, @@ -2087,4428 +3272,9300 @@ impl FullZipScheduler { ) -> Vec> { ranges .iter() - .map(|r| { - let start_offset = (r.start * bytes_per_value) as usize; - let end_offset = (r.end * bytes_per_value) as usize; + .map(|r| { + let start_offset = (r.start * bytes_per_value) as usize; + let end_offset = (r.end * bytes_per_value) as usize; + + let start_slice = &buffer[start_offset..start_offset + bytes_per_value as usize]; + let start_val = + ByteUnpacker::new(start_slice.iter().copied(), bytes_per_value as usize) + .next() + .unwrap(); + + let end_slice = &buffer[end_offset..end_offset + bytes_per_value as usize]; + let end_val = + ByteUnpacker::new(end_slice.iter().copied(), bytes_per_value as usize) + .next() + .unwrap(); + + (data_buf_position + start_val)..(data_buf_position + end_val) + }) + .collect() + } + + /// Computes the ranges in the repetition index that need to be loaded + fn compute_rep_index_ranges( + ranges: &[Range], + rep_index: &FullZipRepIndexDetails, + ) -> Vec> { + ranges + .iter() + .flat_map(|r| { + let first_val_start = + rep_index.buf_position + (r.start * rep_index.bytes_per_value); + let first_val_end = first_val_start + rep_index.bytes_per_value; + let last_val_start = rep_index.buf_position + (r.end * rep_index.bytes_per_value); + let last_val_end = last_val_start + rep_index.bytes_per_value; + [first_val_start..first_val_end, last_val_start..last_val_end] + }) + .collect() + } + + /// Schedules ranges in the presence of a repetition index + fn schedule_ranges_rep( + &self, + ranges: &[Range], + io: &Arc, + rep_index: FullZipRepIndexDetails, + ) -> Result> { + let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); + let data_buf_position = self.data_buf_position; + let priority = self.priority; + let details = self.details.clone(); + let bits_per_offset = self.bits_per_offset; + + if Self::covers_entire_page(ranges, self.rows_in_page) { + let full_range = self.data_buf_position..(self.data_buf_position + self.data_buf_size); + let page_data = io.submit_single(full_range.clone(), priority); + let load_task = async move { + let page_data = page_data.await?; + let source = FullZipReadSource::PrefetchedPage { + base_offset: full_range.start, + data: LanceBuffer::from_bytes(page_data, 1), + }; + let read_ranges = vec![full_range]; + let data = source.fetch(&read_ranges, priority).await?; + Self::create_decoder(details, data, num_rows, bits_per_offset) + } + .boxed(); + let page_load_task = PageLoadTask { + decoder_fut: load_task, + num_rows, + }; + return Ok(vec![page_load_task]); + } + + if let Some(cached_state) = &self.cached_state { + let byte_ranges = Self::extract_byte_ranges_from_cached( + &cached_state.rep_index_buffer, + ranges, + rep_index.bytes_per_value, + data_buf_position, + ); + 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]); + } + + let rep_ranges = Self::compute_rep_index_ranges(ranges, &rep_index); + let rep_data = io.submit_request(rep_ranges, priority); + let io_clone = io.clone(); + let load_task = async move { + let rep_data = rep_data.await?; + let rep_buffer = LanceBuffer::concat( + &rep_data + .into_iter() + .map(|d| LanceBuffer::from_bytes(d, 1)) + .collect::>(), + ); + let byte_ranges = Self::extract_byte_ranges_from_pairs( + rep_buffer, + rep_index.bytes_per_value, + data_buf_position, + ); + let source = FullZipReadSource::Remote(io_clone); + let data = source.fetch(&byte_ranges, priority).await?; + Self::create_decoder(details, data, num_rows, bits_per_offset) + } + .boxed(); + let page_load_task = PageLoadTask { + decoder_fut: load_task, + num_rows, + }; + Ok(vec![page_load_task]) + } + + // In the simple case there is no repetition and we just have large fixed-width + // rows of data. We can just map row ranges to byte ranges directly using the + // fixed-width of the data type. + fn schedule_ranges_simple( + &self, + ranges: &[Range], + io: &Arc, + ) -> Result> { + // Convert row ranges to item ranges (i.e. multiply by items per row) + let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); + + let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor else { + unreachable!() + }; + + // Convert item ranges to byte ranges (i.e. multiply by bytes per item) + let bits_per_value = decompressor.bits_per_value(); + if !bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input_source( + format!( + "Full-zip fixed-width values must be byte aligned, got {} bits per value", + bits_per_value + ) + .into(), + )); + } + let bytes_per_value = bits_per_value / 8; + let bytes_per_cw = self.details.ctrl_word_parser.bytes_per_word(); + let total_bytes_per_value = bytes_per_value + bytes_per_cw as u64; + let byte_ranges = ranges + .iter() + .map(|r| { + debug_assert!(r.end <= self.rows_in_page); + let start = self.data_buf_position + r.start * total_bytes_per_value; + let end = self.data_buf_position + r.end * total_bytes_per_value; + start..end + }) + .collect::>(); + + let io_future = io.submit_request(byte_ranges, self.priority); + let page_load_task = Self::create_page_load_task( + io_future, + num_rows, + self.details.clone(), + self.bits_per_offset, + ); + Ok(vec![page_load_task]) + } +} + +/// Cacheable state for FullZip encoding, storing the decoded repetition index +#[derive(Debug)] +struct FullZipCacheableState { + /// The raw repetition index buffer for future decoding + rep_index_buffer: LanceBuffer, +} + +impl DeepSizeOf for FullZipCacheableState { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + self.rep_index_buffer.len() + } +} + +impl CachedPageData for FullZipCacheableState { + fn as_arc_any(self: Arc) -> Arc { + self + } +} + +impl StructuralPageScheduler for FullZipScheduler { + fn needs_initialization(&self) -> bool { + self.enable_cache && self.rep_index.is_some() + } + + fn init_layout(&self) -> Result { + let repetition_index_range = if self.enable_cache + && let Some(rep_index) = self.rep_index + { + let total_size = (self.rows_in_page + 1) * rep_index.bytes_per_value; + Some(rep_index.buf_position..(rep_index.buf_position + total_size)) + } else { + None + }; + Ok(PageInitialization::new([repetition_index_range])) + } + + fn init_from_buffers<'a>( + &'a mut self, + buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, Result>> { + let [rep_index_buffer] = match buffers.try_into_array("full-zip") { + Ok(buffers) => buffers, + Err(error) => return std::future::ready(Err(error)).boxed(), + }; + if let Some(rep_index_buffer) = rep_index_buffer { + let state = Arc::new(FullZipCacheableState { + rep_index_buffer: LanceBuffer::from_bytes(rep_index_buffer, 1), + }); + std::future::ready(Ok(state as Arc)).boxed() + } else { + std::future::ready(Ok(Arc::new(NoCachedPageData) as Arc)).boxed() + } + } + + /// Loads previously cached repetition index data from the cache system. + /// This method is called when a scheduler instance needs to use cached data + /// that was initialized by another instance or in a previous operation. + fn try_load(&mut self, cache: &Arc) -> Result<()> { + self.cached_state = Some(cache.clone().as_arc_any().downcast().map_err(|_| { + Error::invalid_input_source("Cached full-zip page data has an unexpected type".into()) + })?); + Ok(()) + } + + fn schedule_ranges( + &self, + ranges: &[Range], + io: &Arc, + ) -> Result> { + if let Some(rep_index) = self.rep_index { + self.schedule_ranges_rep(ranges, io, rep_index) + } else { + self.schedule_ranges_simple(ranges, io) + } + } +} + +/// A decoder for full-zip encoded data when the data has a fixed-width +/// +/// Here we need to unzip the control words from the values themselves and +/// then decompress the requested values. +/// +/// We use a PerValueDecompressor because we will only be decompressing the +/// requested data. This decoder / scheduler does not do any read amplification. +#[derive(Debug)] +struct FixedFullZipDecoder { + details: Arc, + data: VecDeque, + offset_in_current: usize, + bytes_per_value: usize, + total_bytes_per_value: usize, + num_rows: u64, +} + +impl FixedFullZipDecoder { + fn slice_next_task(&mut self, num_rows: u64) -> Result { + debug_assert!(num_rows > 0); + let cur_buf = self.data.front_mut().ok_or_else(|| { + Error::corrupt_file_named( + "fixed_full_zip", + format!("page data ran out with {num_rows} row(s) left to decode"), + ) + })?; + let start = self.offset_in_current; + if self.details.ctrl_word_parser.has_rep() { + // This is a slightly slower path. In order to figure out where to split we need to + // examine the rep index so we can convert num_lists to num_rows + let mut rows_started = 0; + // We always need at least one value. Now loop through until we have passed num_rows + // values + let mut num_items = 0; + let bytes_per_word = self.details.ctrl_word_parser.bytes_per_word(); + while self.offset_in_current < cur_buf.len() { + // The item walk is driven by the control words themselves, so a page that + // disagrees with its own layout can step past the end of the buffer. Check + // before every read and every advance so that a malformed page is reported + // instead of panicking in `slice_with_length`. + let bytes_avail = cur_buf.len() - self.offset_in_current; + if bytes_avail < bytes_per_word { + return Err(Error::corrupt_file_named( + "fixed_full_zip", + format!( + "truncated control word: {bytes_avail} byte(s) remain in the page \ + buffer but a control word requires {bytes_per_word}" + ), + )); + } + let control = self.details.ctrl_word_parser.parse_desc( + &cur_buf[self.offset_in_current..], + self.details.max_rep, + self.details.max_visible_def, + ); + if control.is_new_row { + if rows_started == num_rows { + break; + } + rows_started += 1; + } + num_items += 1; + let bytes_in_item = if control.is_visible { + self.total_bytes_per_value + } else { + bytes_per_word + }; + if bytes_in_item > bytes_avail { + return Err(Error::corrupt_file_named( + "fixed_full_zip", + format!( + "truncated item: {bytes_avail} byte(s) remain in the page buffer at \ + offset {} but the item requires {bytes_in_item}", + self.offset_in_current + ), + )); + } + self.offset_in_current += bytes_in_item; + } + + let task_slice = cur_buf.slice_with_length(start, self.offset_in_current - start); + if self.offset_in_current == cur_buf.len() { + self.data.pop_front(); + self.offset_in_current = 0; + } + + Ok(FullZipDecodeTaskItem { + data: PerValueDataBlock::Fixed(FixedWidthDataBlock { + data: task_slice, + bits_per_value: self.bytes_per_value as u64 * 8, + num_values: num_items, + block_info: BlockInfo::new(), + }), + rows_in_buf: rows_started, + }) + } else { + // If there's no repetition we can calculate the slicing point by just multiplying + // the number of rows by the total bytes per value + let cur_buf = self.data.front_mut().unwrap(); + let bytes_avail = cur_buf.len() - self.offset_in_current; + let offset_in_cur = self.offset_in_current; + + let bytes_needed = num_rows as usize * self.total_bytes_per_value; + let mut rows_taken = num_rows; + let task_slice = if bytes_needed >= bytes_avail { + self.offset_in_current = 0; + rows_taken = bytes_avail as u64 / self.total_bytes_per_value as u64; + self.data + .pop_front() + .unwrap() + .slice_with_length(offset_in_cur, bytes_avail) + } else { + self.offset_in_current += bytes_needed; + cur_buf.slice_with_length(offset_in_cur, bytes_needed) + }; + Ok(FullZipDecodeTaskItem { + data: PerValueDataBlock::Fixed(FixedWidthDataBlock { + data: task_slice, + bits_per_value: self.bytes_per_value as u64 * 8, + num_values: rows_taken, + block_info: BlockInfo::new(), + }), + rows_in_buf: rows_taken, + }) + } + } +} + +impl StructuralPageDecoder for FixedFullZipDecoder { + fn drain(&mut self, num_rows: u64) -> Result> { + if self.total_bytes_per_value == 0 { + // No bytes per row: constant-null page with no ctrl-word bytes. + // The decompressor (ConstantDecompressor) ignores its input and returns AllNull. + return Ok(Box::new(FixedFullZipDecodeTask { + details: self.details.clone(), + data: vec![FullZipDecodeTaskItem { + data: PerValueDataBlock::Fixed(FixedWidthDataBlock { + data: LanceBuffer::empty(), + bits_per_value: 0, + num_values: num_rows, + block_info: BlockInfo::new(), + }), + rows_in_buf: num_rows, + }], + bytes_per_value: 0, + num_rows: num_rows as usize, + })); + } + let mut task_data = Vec::with_capacity(self.data.len()); + let mut remaining = num_rows; + while remaining > 0 { + let task_item = self.slice_next_task(remaining)?; + remaining -= task_item.rows_in_buf; + task_data.push(task_item); + } + Ok(Box::new(FixedFullZipDecodeTask { + details: self.details.clone(), + data: task_data, + bytes_per_value: self.bytes_per_value, + num_rows: num_rows as usize, + })) + } + + fn num_rows(&self) -> u64 { + self.num_rows + } +} + +/// A decoder for full-zip encoded data when the data has a variable-width +/// +/// Here we need to unzip the control words AND lengths from the values and +/// then decompress the requested values. +#[derive(Debug)] +struct VariableFullZipDecoder { + details: Arc, + decompressor: Arc, + data: LanceBuffer, + offsets: LanceBuffer, + rep: ScalarBuffer, + def: ScalarBuffer, + repdef_starts: Vec, + data_starts: Vec, + offset_starts: Vec, + visible_item_counts: Vec, + bits_per_offset: u8, + current_idx: usize, + num_rows: u64, +} + +impl VariableFullZipDecoder { + fn new( + details: Arc, + data: VecDeque, + num_rows: u64, + in_bits_per_length: u8, + out_bits_per_offset: u8, + ) -> Result { + let decompressor = match details.value_decompressor { + PerValueDecompressor::Variable(ref d) => d.clone(), + _ => unreachable!(), + }; + + assert_eq!(in_bits_per_length % 8, 0); + assert!(out_bits_per_offset == 32 || out_bits_per_offset == 64); + + let mut decoder = Self { + details, + decompressor, + data: LanceBuffer::empty(), + offsets: LanceBuffer::empty(), + rep: LanceBuffer::empty().borrow_to_typed_slice(), + def: LanceBuffer::empty().borrow_to_typed_slice(), + bits_per_offset: out_bits_per_offset, + repdef_starts: Vec::with_capacity(num_rows as usize + 1), + data_starts: Vec::with_capacity(num_rows as usize + 1), + offset_starts: Vec::with_capacity(num_rows as usize + 1), + visible_item_counts: Vec::with_capacity(num_rows as usize + 1), + current_idx: 0, + num_rows, + }; + + // There's no great time to do this and this is the least worst time. If we don't unzip then + // we can't slice the data during the decode phase. This is because we need the offsets to be + // unpacked to know where the values start and end. + // + // We don't want to unzip on the decode thread because that is a single-threaded path + // We don't want to unzip on the scheduling thread because that is a single-threaded path + // + // Fortunately, we know variable length data will always be read indirectly and so we can do it + // here, which should be on the indirect thread. The primary disadvantage to doing it here is that + // we load all the data into memory and then throw it away only to load it all into memory again during + // the decode. + // + // There are some alternatives to investigate: + // - Instead of just reading the beginning and end of the rep index we could read the entire + // range in between. This will give us the break points that we need for slicing and won't increase + // the number of IOPs but it will mean we are doing more total I/O and we need to load the rep index + // even when doing a full scan. + // - We could force each decode task to do a full unzip of all the data. Each decode task now + // has to do more work but the work is all fused. + // - We could just try doing this work on the decode thread and see if it is a problem. + decoder.unzip(data, in_bits_per_length, out_bits_per_offset, num_rows)?; + + Ok(decoder) + } + + fn slice_batch_data_and_rebase_offsets_typed( + data: &LanceBuffer, + offsets: &LanceBuffer, + ) -> Result<(LanceBuffer, LanceBuffer)> + where + T: arrow_buffer::ArrowNativeType + + Copy + + PartialOrd + + std::ops::Sub + + std::fmt::Display + + TryInto, + { + let offsets_slice = offsets.borrow_to_typed_slice::(); + let offsets_slice = offsets_slice.as_ref(); + if offsets_slice.is_empty() { + return Err(Error::internal( + "Variable offsets cannot be empty".to_string(), + )); + } + + let base = offsets_slice[0]; + let end = *offsets_slice.last().unwrap(); + if end < base { + return Err(Error::internal(format!( + "Invalid variable offsets: end ({end}) is less than base ({base})" + ))); + } + + let data_start = base.try_into().map_err(|_| { + Error::internal(format!("Variable offset ({base}) does not fit into usize")) + })?; + let data_end = end.try_into().map_err(|_| { + Error::internal(format!("Variable offset ({end}) does not fit into usize")) + })?; + if data_end > data.len() { + return Err(Error::internal(format!( + "Invalid variable offsets: end ({data_end}) exceeds data len ({})", + data.len() + ))); + } + + let mut rebased_offsets = Vec::with_capacity(offsets_slice.len()); + for &offset in offsets_slice { + if offset < base { + return Err(Error::internal(format!( + "Invalid variable offsets: offset ({offset}) is less than base ({base})" + ))); + } + rebased_offsets.push(offset - base); + } + + let sliced_data = data.slice_with_length(data_start, data_end - data_start); + // Copy into a compact buffer so each output batch owns only what it references. + let sliced_data = LanceBuffer::copy_slice(&sliced_data); + let rebased_offsets = LanceBuffer::reinterpret_vec(rebased_offsets); + Ok((sliced_data, rebased_offsets)) + } + + fn slice_batch_data_and_rebase_offsets( + data: &LanceBuffer, + offsets: &LanceBuffer, + bits_per_offset: u8, + ) -> Result<(LanceBuffer, LanceBuffer)> { + match bits_per_offset { + 32 => Self::slice_batch_data_and_rebase_offsets_typed::(data, offsets), + 64 => Self::slice_batch_data_and_rebase_offsets_typed::(data, offsets), + _ => Err(Error::internal(format!( + "Unsupported bits_per_offset={bits_per_offset}" + ))), + } + } + + /// Reads a single length prefix from the front of `data`. + /// + /// The bytes come from the file. A page whose item walk ends with a partial + /// trailing item leaves fewer than `bits_per_offset / 8` bytes here, so this + /// is bounds checked and reports a corrupt file rather than reading past the + /// end of the buffer. + fn parse_length(data: &[u8], bits_per_offset: u8) -> Result { + let width = bits_per_offset as usize / 8; + if data.len() < width { + return Err(Error::corrupt_file_named( + "variable_full_zip", + format!( + "truncated length prefix: {} byte(s) remain in the page buffer but a \ + {}-bit length prefix requires {}", + data.len(), + bits_per_offset, + width + ), + )); + } + Ok(match bits_per_offset { + 8 => data[0] as u64, + 16 => u16::from_le_bytes(data[..2].try_into().unwrap()) as u64, + 32 => u32::from_le_bytes(data[..4].try_into().unwrap()) as u64, + 64 => u64::from_le_bytes(data[..8].try_into().unwrap()), + _ => unreachable!(), + }) + } + + fn unzip( + &mut self, + data: VecDeque, + in_bits_per_length: u8, + out_bits_per_offset: u8, + num_rows: u64, + ) -> Result<()> { + // This undercounts if there are lists but, at this point, we don't really know how many items we have + let mut rep = Vec::with_capacity(num_rows as usize); + let mut def = Vec::with_capacity(num_rows as usize); + let bytes_cw = self.details.ctrl_word_parser.bytes_per_word() * num_rows as usize; + + // This undercounts if there are lists + // It can also overcount if there are invisible items + let bytes_per_offset = out_bits_per_offset as usize / 8; + let bytes_offsets = bytes_per_offset * (num_rows as usize + 1); + let mut offsets_data = Vec::with_capacity(bytes_offsets); + + let bytes_per_length = in_bits_per_length as usize / 8; + let bytes_lengths = bytes_per_length * num_rows as usize; + + let bytes_data = data.iter().map(|d| d.len()).sum::(); + // This overcounts since bytes_lengths and bytes_cw are undercounts + // It can also undercount if there are invisible items (hence the saturating_sub) + let mut unzipped_data = + Vec::with_capacity((bytes_data - bytes_cw).saturating_sub(bytes_lengths)); + + let mut current_offset = 0_u64; + let mut visible_item_count = 0_u64; + for databuf in data.into_iter() { + let mut databuf = databuf.as_ref(); + while !databuf.is_empty() { + let data_start = unzipped_data.len(); + let offset_start = offsets_data.len(); + // We might have only-rep or only-def, neither, or both. They move at the same + // speed though so we only need one index into it + let repdef_start = rep.len().max(def.len()); + // TODO: Kind of inefficient we parse the control word twice here + let ctrl_desc = self.details.ctrl_word_parser.parse_desc( + databuf, + self.details.max_rep, + self.details.max_visible_def, + ); + self.details + .ctrl_word_parser + .parse(databuf, &mut rep, &mut def); + databuf = &databuf[self.details.ctrl_word_parser.bytes_per_word()..]; + + if ctrl_desc.is_new_row { + self.repdef_starts.push(repdef_start); + self.data_starts.push(data_start); + self.offset_starts.push(offset_start); + self.visible_item_counts.push(visible_item_count); + } + if ctrl_desc.is_visible { + visible_item_count += 1; + if ctrl_desc.is_valid_item { + let length = Self::parse_length(databuf, in_bits_per_length)?; + match out_bits_per_offset { + 32 => offsets_data + .extend_from_slice(&(current_offset as u32).to_le_bytes()), + 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), + _ => unreachable!(), + }; + databuf = &databuf[bytes_per_offset..]; + unzipped_data.extend_from_slice(&databuf[..length as usize]); + databuf = &databuf[length as usize..]; + current_offset += length; + } else { + // Null items still get an offset + match out_bits_per_offset { + 32 => offsets_data + .extend_from_slice(&(current_offset as u32).to_le_bytes()), + 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), + _ => unreachable!(), + } + } + } + } + } + self.repdef_starts.push(rep.len().max(def.len())); + self.data_starts.push(unzipped_data.len()); + self.offset_starts.push(offsets_data.len()); + self.visible_item_counts.push(visible_item_count); + match out_bits_per_offset { + 32 => offsets_data.extend_from_slice(&(current_offset as u32).to_le_bytes()), + 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), + _ => unreachable!(), + }; + self.rep = ScalarBuffer::from(rep); + self.def = ScalarBuffer::from(def); + self.data = LanceBuffer::from(unzipped_data); + self.offsets = LanceBuffer::from(offsets_data); + Ok(()) + } +} + +impl StructuralPageDecoder for VariableFullZipDecoder { + fn drain(&mut self, num_rows: u64) -> Result> { + let start = self.current_idx; + let end = start + num_rows as usize; + + let offset_start = self.offset_starts[start]; + let offset_end = self.offset_starts[end] + (self.bits_per_offset as usize / 8); + let offsets = self + .offsets + .slice_with_length(offset_start, offset_end - offset_start); + // Keep each batch's variable data buffer bounded to the selected rows. + let (data, offsets) = + Self::slice_batch_data_and_rebase_offsets(&self.data, &offsets, self.bits_per_offset)?; + + let repdef_start = self.repdef_starts[start]; + let repdef_end = self.repdef_starts[end]; + let rep = if self.rep.is_empty() { + self.rep.clone() + } else { + self.rep.slice(repdef_start, repdef_end - repdef_start) + }; + let def = if self.def.is_empty() { + self.def.clone() + } else { + self.def.slice(repdef_start, repdef_end - repdef_start) + }; + + let visible_item_counts_start = self.visible_item_counts[start]; + let visible_item_counts_end = self.visible_item_counts[end]; + let num_visible_items = visible_item_counts_end - visible_item_counts_start; + + self.current_idx += num_rows as usize; + + Ok(Box::new(VariableFullZipDecodeTask { + details: self.details.clone(), + decompressor: self.decompressor.clone(), + data, + offsets, + bits_per_offset: self.bits_per_offset, + num_visible_items, + rep, + def, + })) + } + + fn num_rows(&self) -> u64 { + self.num_rows + } +} + +#[derive(Debug)] +struct VariableFullZipDecodeTask { + details: Arc, + decompressor: Arc, + data: LanceBuffer, + offsets: LanceBuffer, + bits_per_offset: u8, + num_visible_items: u64, + rep: ScalarBuffer, + def: ScalarBuffer, +} + +impl DecodePageTask for VariableFullZipDecodeTask { + fn decode(self: Box) -> Result { + let block = VariableWidthBlock { + data: self.data, + offsets: self.offsets, + bits_per_offset: self.bits_per_offset, + num_values: self.num_visible_items, + block_info: BlockInfo::new(), + }; + let decomopressed = self.decompressor.decompress(block)?; + let rep = if self.rep.is_empty() { + None + } else { + Some(self.rep.to_vec()) + }; + let def = if self.def.is_empty() { + None + } else { + Some(self.def.to_vec()) + }; + let unraveler = RepDefUnraveler::new( + rep, + def, + self.details.def_meaning.clone(), + self.num_visible_items, + ); + Ok(DecodedPage { + data: decomopressed, + repdef: unraveler, + }) + } +} + +#[derive(Debug)] +struct FullZipDecodeTaskItem { + data: PerValueDataBlock, + rows_in_buf: u64, +} + +/// A task to unzip and decompress full-zip encoded data when that data +/// has a fixed-width. +#[derive(Debug)] +struct FixedFullZipDecodeTask { + details: Arc, + data: Vec, + num_rows: usize, + bytes_per_value: usize, +} + +impl DecodePageTask for FixedFullZipDecodeTask { + fn decode(self: Box) -> Result { + let estimated_size_bytes = if self.details.ctrl_word_parser.bytes_per_word() == 0 { + let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor else { + return Err(Error::internal( + "FixedFullZipDecodeTask requires a fixed-width decompressor", + )); + }; + decompressor + .decoded_size_bytes(self.num_rows as u64) + .unwrap_or_else(|| { + self.data + .iter() + .map(|task_item| task_item.data.data_size()) + .sum::() + * 2 + }) + } else { + // Rep/def levels can suppress values, so the exact output size is not known + // until they are decoded. Keep the existing conservative estimate. + self.data + .iter() + .map(|task_item| task_item.data.data_size()) + .sum::() + * 2 + }; + let mut data_builder = DataBlockBuilder::with_capacity_estimate(estimated_size_bytes); + + if self.details.ctrl_word_parser.bytes_per_word() == 0 { + // Fast path, no need to unzip because there is no rep/def + // + // We decompress each buffer and add it to our output buffer + for task_item in self.data.into_iter() { + let PerValueDataBlock::Fixed(fixed_data) = task_item.data else { + unreachable!() + }; + let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor + else { + unreachable!() + }; + debug_assert_eq!(fixed_data.num_values, task_item.rows_in_buf); + let decompressed = decompressor.decompress(fixed_data, task_item.rows_in_buf)?; + data_builder.append(&decompressed, 0..task_item.rows_in_buf)?; + } + + let unraveler = RepDefUnraveler::new( + None, + None, + self.details.def_meaning.clone(), + self.num_rows as u64, + ); + + Ok(DecodedPage { + data: data_builder.finish(), + repdef: unraveler, + }) + } else { + // Slow path, unzipping needed + let mut rep = Vec::with_capacity(self.num_rows); + let mut def = Vec::with_capacity(self.num_rows); + + for task_item in self.data.into_iter() { + let PerValueDataBlock::Fixed(fixed_data) = task_item.data else { + unreachable!() + }; + let mut buf_slice = fixed_data.data.as_ref(); + let num_values = fixed_data.num_values as usize; + // We will be unzipping repdef in to `rep` and `def` and the + // values into `values` (which contains the compressed values) + let mut values = Vec::with_capacity( + fixed_data.data.len() + - (self.details.ctrl_word_parser.bytes_per_word() * num_values), + ); + let mut visible_items = 0; + for _ in 0..num_values { + // Extract rep/def + self.details + .ctrl_word_parser + .parse(buf_slice, &mut rep, &mut def); + buf_slice = &buf_slice[self.details.ctrl_word_parser.bytes_per_word()..]; + + let is_visible = def + .last() + .map(|d| *d <= self.details.max_visible_def) + .unwrap_or(true); + if is_visible { + // Extract value + values.extend_from_slice(buf_slice[..self.bytes_per_value].as_ref()); + buf_slice = &buf_slice[self.bytes_per_value..]; + visible_items += 1; + } + } + + // Finally, we decompress the values and add them to our output buffer + let values_buf = LanceBuffer::from(values); + let fixed_data = FixedWidthDataBlock { + bits_per_value: self.bytes_per_value as u64 * 8, + block_info: BlockInfo::new(), + data: values_buf, + num_values: visible_items, + }; + let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor + else { + unreachable!() + }; + let decompressed = decompressor.decompress(fixed_data, visible_items)?; + data_builder.append(&decompressed, 0..visible_items)?; + } + + let repetition = if rep.is_empty() { None } else { Some(rep) }; + let definition = if def.is_empty() { None } else { Some(def) }; + + let unraveler = RepDefUnraveler::new( + repetition, + definition, + self.details.def_meaning.clone(), + self.num_rows as u64, + ); + let data = data_builder.finish(); + + Ok(DecodedPage { + data, + repdef: unraveler, + }) + } + } +} + +#[derive(Debug)] +struct StructuralPrimitiveFieldSchedulingJob<'a> { + scheduler: &'a StructuralPrimitiveFieldScheduler, + mappings: std::vec::IntoIter, +} + +impl<'a> StructuralPrimitiveFieldSchedulingJob<'a> { + pub fn try_new( + scheduler: &'a StructuralPrimitiveFieldScheduler, + ranges: Vec>, + ) -> Result { + let mappings = map_ranges_to_pages(&scheduler.page_row_ends, &ranges)?.into_iter(); + Ok(Self { + scheduler, + mappings, + }) + } +} + +impl StructuralSchedulingJob for StructuralPrimitiveFieldSchedulingJob<'_> { + fn schedule_next(&mut self, context: &mut SchedulerContext) -> Result> { + let Some(first_mapping) = self.mappings.next() else { + return Ok(Vec::new()); + }; + // Pages are scheduled in row order until their reads add up to a + // batch, then submitted together so the I/O scheduler can coalesce + // neighbouring pages. + let batch = Arc::new(RequestBatch::new(context.io().clone())); + let batch_io: Arc = batch.clone(); + let cur_path = context.current_path(); + let mut scan_lines = Vec::new(); + let mut mapping = Some(first_mapping); + while let Some(cur_mapping) = mapping.take() { + let cur_page = self.scheduler.built_page(cur_mapping.page_idx)?; + + trace!( + "Scheduling {} rows across {} ranges from page with {} rows (column_index={}, page_index={})", + cur_mapping + .ranges_in_page + .iter() + .map(|r| r.end - r.start) + .sum::(), + cur_mapping.ranges_in_page.len(), + cur_page.row_range.end - cur_page.row_range.start, + self.scheduler.column_index, + cur_page.page_index, + ); + + let page_decoders = cur_page + .scheduler + .schedule_ranges(&cur_mapping.ranges_in_page, &batch_io)?; + + scan_lines.extend(page_decoders.into_iter().map(|page_load_task| { + let cur_path = cur_path.clone(); + let page_decoder = page_load_task.decoder_fut; + let unloaded_page = async move { + let page_decoder = page_decoder.await?; + Ok(LoadedPageShard { + decoder: page_decoder, + path: cur_path, + }) + } + .boxed(); + ScheduledScanLine { + decoders: vec![MessageType::UnloadedPage(UnloadedPageShard(unloaded_page))], + rows_scheduled: page_load_task.num_rows, + } + })); + + if batch.pending_bytes() < IO_REQUEST_BATCH_BYTES { + mapping = self.mappings.next(); + } + } + batch.flush(); + Ok(scan_lines) + } +} + +#[derive(Debug)] +struct PageInfoAndScheduler { + page_index: usize, + row_range: Range, + scheduler: Box, +} + +/// Everything needed to build a page scheduler from its `PageInfo` on demand. +/// +/// Building a page scheduler clones buffer tables and instantiates +/// decompressors, so a column with hundreds of thousands of pages must not pay +/// for every page on every read. The field scheduler keeps this source and +/// builds only the pages `initialize` is asked to cover. +#[derive(Debug)] +struct PageSchedulerSource { + column_info: Arc, + decompressors: Arc, + cache_repetition_index: bool, + target_field: Field, +} + +/// A scheduler for a leaf node +/// +/// Here we look at the layout of the various pages and delegate scheduling to a scheduler +/// appropriate for the layout of the page. +#[derive(Debug)] +pub struct StructuralPrimitiveFieldScheduler { + /// Exclusive end row of every page in the column, in page order, so page + /// `i` covers `page_row_ends[i - 1]..page_row_ends[i]` (page 0 starts at 0). + page_row_ends: Vec, + /// Page schedulers built so far, ordered by `page_index`. After + /// `initialize` this holds exactly the pages the requested ranges touch, + /// or every page when no ranges were given. + page_schedulers: Vec, + /// Metadata to build pages that are not in `page_schedulers` yet. `None` + /// once every page has been built. + page_source: Option, + column_index: u32, + target_data_type_tag: Arc, +} + +impl StructuralPrimitiveFieldScheduler { + /// Creates the scheduler and builds every page scheduler up front. + /// + /// This pays for all pages of the column on every read. Prefer + /// [`Self::try_new_lazy`], which builds only the pages a read touches. + #[deprecated( + since = "13.0.0", + note = "builds every page scheduler eagerly; use try_new_lazy" + )] + pub fn try_new( + column_info: &ColumnInfo, + decompressors: &dyn DecompressionStrategy, + cache_repetition_index: bool, + target_field: &Field, + ) -> Result { + let page_row_ends = Self::page_row_ends(column_info)?; + let mut page_schedulers = Vec::with_capacity(page_row_ends.len()); + let mut row_start = 0_u64; + for (page_index, (page_info, &row_end)) in column_info + .page_infos + .iter() + .zip(&page_row_ends) + .enumerate() + { + page_schedulers.push(Self::page_info_to_scheduler( + page_info, + page_index, + row_start..row_end, + decompressors, + cache_repetition_index, + target_field, + )?); + row_start = row_end; + } + Ok(Self { + page_row_ends, + page_schedulers, + page_source: None, + column_index: column_info.index, + target_data_type_tag: format!("{:?}", target_field.data_type()).into(), + }) + } + + /// Creates the scheduler without building any page schedulers. + /// + /// Only the per-page row boundaries are computed here. Page schedulers + /// are built in [`StructuralFieldScheduler::initialize`] for the pages the + /// requested ranges touch (or for every page when no ranges are given), so + /// a small read from a column with many pages stays cheap. + pub fn try_new_lazy( + column_info: Arc, + decompressors: Arc, + cache_repetition_index: bool, + target_field: &Field, + ) -> Result { + Ok(Self { + page_row_ends: Self::page_row_ends(&column_info)?, + page_schedulers: Vec::new(), + column_index: column_info.index, + target_data_type_tag: format!("{:?}", target_field.data_type()).into(), + page_source: Some(PageSchedulerSource { + column_info, + decompressors, + cache_repetition_index, + target_field: target_field.clone(), + }), + }) + } + + /// Exclusive end row of every page of `column_info`, in page order. + fn page_row_ends(column_info: &ColumnInfo) -> Result> { + let mut page_row_ends = Vec::with_capacity(column_info.page_infos.len()); + let mut row_start = 0_u64; + for (page_index, page_info) in column_info.page_infos.iter().enumerate() { + let row_end = row_start.checked_add(page_info.num_rows).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Page row range overflowed for column {} at page {page_index}: start={row_start}, rows={}", + column_info.index, page_info.num_rows + ) + .into(), + ) + })?; + page_row_ends.push(row_end); + row_start = row_end; + } + Ok(page_row_ends) + } + + /// Wraps already-built page schedulers; nothing is built lazily. + #[cfg(test)] + fn from_page_schedulers( + page_schedulers: Vec, + column_index: u32, + target_data_type_tag: Arc, + ) -> Self { + Self { + page_row_ends: page_schedulers + .iter() + .map(|page| page.row_range.end) + .collect(), + page_schedulers, + page_source: None, + column_index, + target_data_type_tag, + } + } + + fn page_row_range(&self, page_index: usize) -> Range { + let start = match page_index.checked_sub(1) { + Some(previous) => self.page_row_ends[previous], + None => 0, + }; + start..self.page_row_ends[page_index] + } + + /// Position of `page_index` in `page_schedulers` if it has been built. + fn built_page_position(&self, page_index: usize) -> Option { + self.page_schedulers + .binary_search_by_key(&page_index, |page| page.page_index) + .ok() + } + + fn built_page(&self, page_index: usize) -> Result<&PageInfoAndScheduler> { + let position = self.built_page_position(page_index).ok_or_else(|| { + Error::internal(format!( + "Page {page_index} of column {} was scheduled but not initialized; the scheduled ranges must be covered by the ranges passed to initialize", + self.column_index + )) + })?; + Ok(&self.page_schedulers[position]) + } + + /// Builds the schedulers for `page_indices` (ascending) that do not exist + /// yet and returns the positions of all of them in `page_schedulers`. + fn build_pages(&mut self, page_indices: &[usize]) -> Result> { + if let Some(source) = &self.page_source { + let mut missing = Vec::new(); + for &page_index in page_indices { + if self.built_page_position(page_index).is_some() { + continue; + } + let page_info = source + .column_info + .page_infos + .get(page_index) + .ok_or_else(|| { + Error::internal(format!( + "Page {page_index} is out of bounds for column {} with {} pages", + self.column_index, + source.column_info.page_infos.len() + )) + })?; + missing.push(Self::page_info_to_scheduler( + page_info, + page_index, + self.page_row_range(page_index), + source.decompressors.as_ref(), + source.cache_repetition_index, + &source.target_field, + )?); + } + if !missing.is_empty() { + self.page_schedulers = std::mem::take(&mut self.page_schedulers) + .into_iter() + .merge_by(missing, |built, new| built.page_index < new.page_index) + .collect(); + } + if self.page_schedulers.len() == self.page_row_ends.len() { + self.page_source = None; + } + } + page_indices + .iter() + .map(|&page_index| { + self.built_page_position(page_index).ok_or_else(|| { + Error::internal(format!( + "Page {page_index} of column {} is missing after construction", + self.column_index + )) + }) + }) + .collect() + } + + fn page_layout_to_scheduler( + page_info: &PageInfo, + page_layout: &PageLayout, + decompressors: &dyn DecompressionStrategy, + cache_repetition_index: bool, + target_field: &Field, + ) -> Result> { + use pb21::page_layout::Layout; + Ok(match page_layout.layout.as_ref().expect_ok()? { + Layout::MiniBlockLayout(mini_block) => Box::new(MiniBlockScheduler::try_new( + &page_info.buffer_offsets_and_sizes, + page_info.priority, + mini_block.num_items, + mini_block, + decompressors, + )?), + Layout::SparseLayout(sparse_layout) => { + Box::new(sparse::SparseStructuralScheduler::try_new( + &page_info.buffer_offsets_and_sizes, + page_info.priority, + page_info.num_rows, + target_field.data_type(), + sparse_layout, + decompressors, + )?) + } + Layout::FullZipLayout(full_zip) => { + let mut scheduler = FullZipScheduler::try_new( + &page_info.buffer_offsets_and_sizes, + page_info.priority, + page_info.num_rows, + full_zip, + decompressors, + )?; + scheduler.enable_cache = cache_repetition_index; + Box::new(scheduler) + } + Layout::ConstantLayout(constant_layout) => { + let def_meaning = constant_layout + .layers + .iter() + .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) + .collect::>(); + let has_scalar_value = constant_layout.inline_value.is_some() + || page_info.buffer_offsets_and_sizes.len() == 1 + || page_info.buffer_offsets_and_sizes.len() == 3; + if has_scalar_value { + Box::new(constant::ConstantPageScheduler::try_new( + page_info.buffer_offsets_and_sizes.clone(), + constant_layout.inline_value.clone(), + target_field.data_type(), + def_meaning.into(), + )?) as Box + } else if def_meaning.len() == 1 + && def_meaning[0] == DefinitionInterpretation::NullableItem + { + Box::new(SimpleAllNullScheduler::default()) as Box + } else { + // RLE levels select a validated cache representation; other + // block compressions keep flowing through the eager decompressor. + let rep_codec = LevelCodec::try_new( + constant_layout.rep_compression.as_ref(), + decompressors, + )?; + let def_codec = LevelCodec::try_new( + constant_layout.def_compression.as_ref(), + decompressors, + )?; + + Box::new(ComplexAllNullScheduler::new( + page_info.buffer_offsets_and_sizes.clone(), + def_meaning.into(), + rep_codec, + def_codec, + constant_layout.num_rep_values, + constant_layout.num_def_values, + )) as Box + } + } + Layout::BlobLayout(blob) => { + let inner_scheduler = Self::page_layout_to_scheduler( + page_info, + blob.inner_layout.as_ref().expect_ok()?.as_ref(), + decompressors, + cache_repetition_index, + target_field, + )?; + let def_meaning = blob + .layers + .iter() + .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) + .collect::>(); + if matches!(target_field.data_type(), DataType::Struct(_)) { + // User wants to decode blob into struct + Box::new(BlobDescriptionPageScheduler::new( + inner_scheduler, + def_meaning.into(), + )) + } else { + // User wants to decode blob into binary data + Box::new(BlobPageScheduler::new( + inner_scheduler, + page_info.priority, + page_info.num_rows, + def_meaning.into(), + )) + } + } + }) + } + + fn page_info_to_scheduler( + page_info: &PageInfo, + page_index: usize, + row_range: Range, + decompressors: &dyn DecompressionStrategy, + cache_repetition_index: bool, + target_field: &Field, + ) -> Result { + let page_layout = page_info.encoding.as_structural(); + let scheduler = Self::page_layout_to_scheduler( + page_info, + page_layout, + decompressors, + cache_repetition_index, + target_field, + )?; + Ok(PageInfoAndScheduler { + page_index, + row_range, + scheduler, + }) + } +} + +pub trait CachedPageData: Any + Send + Sync + DeepSizeOf + 'static { + fn as_arc_any(self: Arc) -> Arc; +} + +pub struct NoCachedPageData; + +impl DeepSizeOf for NoCachedPageData { + fn deep_size_of_children(&self, _ctx: &mut Context) -> usize { + 0 + } +} +impl CachedPageData for NoCachedPageData { + fn as_arc_any(self: Arc) -> Arc { + self + } +} + +#[doc = "Deprecated compatibility wrapper; new code uses per-page PageDataCacheKey entries."] +pub struct CachedFieldData { + pages: Vec>, +} + +impl DeepSizeOf for CachedFieldData { + fn deep_size_of_children(&self, ctx: &mut Context) -> usize { + self.pages.deep_size_of_children(ctx) + } +} + +/// Compatibility key for the former whole-field page metadata cache. +#[derive(Debug, Clone)] +pub struct FieldDataCacheKey { + pub column_index: u32, + pub view_tag: String, +} + +impl CacheKey for FieldDataCacheKey { + type ValueType = CachedFieldData; + + fn key(&self) -> std::borrow::Cow<'_, str> { + format!("{}:{}", self.column_index, self.view_tag).into() + } + + fn type_name() -> &'static str { + "FieldData" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("lance.encoding.logical.primitive.field-data-key", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u32(self.column_index); + builder.write_str(&self.view_tag); + } +} + +/// Sized wrapper around a page's cached metadata so [`PageDataCacheKey`] can use +/// the sized [`CacheKey`] path; the concrete per-layout state still lives behind +/// `dyn CachedPageData`. +pub struct CachedPage { + data: Arc, +} + +impl DeepSizeOf for CachedPage { + fn deep_size_of_children(&self, ctx: &mut Context) -> usize { + self.data.deep_size_of_children(ctx) + } +} + +// Cache key for a single page's initialized metadata, keyed per page (not per +// column) so a read populates and reuses only the pages it touches. +// +// `view` distinguishes decode shapes of one physical column: a blob column +// materializes as a `Struct` descriptor in one variant and raw +// `LargeBinary` in another, each caching a different `CachedPageData` type. +// Without it, two readers at the same column/page but different shapes collide +// and crash on a downcast. +/// Decode shape used to isolate cached page metadata for one physical column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum PageCacheView { + /// The column's native non-blob representation. + Native, + /// Blob position and size descriptors materialized as a struct. + BlobDescriptor, + /// Blob contents materialized as binary values. + BlobPayload, +} + +impl PageCacheView { + fn cache_tag(self) -> &'static str { + match self { + Self::Native => "n", + Self::BlobDescriptor => "bd", + Self::BlobPayload => "bp", + } + } +} + +#[derive(Debug, Clone)] +pub struct PageDataCacheKey { + pub column_index: u32, + pub page_index: u32, + pub view: PageCacheView, + pub target_data_type_tag: Arc, +} + +impl CacheKey for PageDataCacheKey { + type ValueType = CachedPage; + + fn key(&self) -> std::borrow::Cow<'_, str> { + format!( + "v2/{}/{}/{}/{}", + self.column_index, + self.page_index, + self.view.cache_tag(), + self.target_data_type_tag + ) + .into() + } + + fn type_name() -> &'static str { + "PageData" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("lance.encoding.logical.primitive.page-data-key", 2) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u32(self.column_index); + builder.write_u32(self.page_index); + builder.write_variant(match self.view { + PageCacheView::Native => 0, + PageCacheView::BlobDescriptor => 1, + PageCacheView::BlobPayload => 2, + }); + builder.write_str(&self.target_data_type_tag); + } +} + +/// One page's slice of a scheduling request: the page's index and the +/// page-local row sub-ranges (relative to the page's first row) of the request +/// that fall in it. +#[derive(Debug)] +struct PageRangeMapping { + page_idx: usize, + ranges_in_page: Vec>, +} + +/// Visits every intersection between ordered row ranges and ordered pages. +/// +/// `page_row_ends` holds the exclusive end row of each page (see +/// [`StructuralPrimitiveFieldScheduler::page_row_ends`]). Each range +/// binary-searches its first overlapping page and then visits only the pages +/// it touches. For `R` ranges, `P` pages, and `T` intersections this takes +/// `O(R log P + T)` time without scanning sparse gaps. +fn for_each_page_range( + page_row_ends: &[u64], + ranges: &[Range], + mut visit: impl FnMut(usize, Range), +) -> Result<()> { + for (range_idx, range) in ranges.iter().enumerate() { + if range.start > range.end { + return Err(Error::invalid_input_source( + format!( + "Requested row range {range_idx} is reversed: {}..{}", + range.start, range.end + ) + .into(), + )); + } + if range_idx > 0 { + let previous = &ranges[range_idx - 1]; + if previous.start >= range.start || previous.end > range.start { + return Err(Error::invalid_input_source( + format!( + "Requested row ranges overlap or are not strictly ordered at index {range_idx}: {previous:?}, then {range:?}" + ) + .into(), + )); + } + } + } + + for range in ranges { + let mut page_idx = page_row_ends.partition_point(|page_end| *page_end <= range.start); + let mut page_start = match page_idx.checked_sub(1) { + Some(previous) => page_row_ends[previous], + None => 0, + }; + while let Some(&page_end) = page_row_ends.get(page_idx) { + if page_start >= range.end { + break; + } + let start_in_page = range.start.max(page_start) - page_start; + let end_in_page = range.end.min(page_end) - page_start; + if start_in_page < end_in_page { + visit(page_idx, start_in_page..end_in_page); + } + page_idx += 1; + page_start = page_end; + } + } + Ok(()) +} + +/// Maps global row `ranges` onto a column's pages, yielding the page-local +/// sub-ranges for every page any range touches. +/// +/// `ranges` must be ascending and non-overlapping (the precondition +/// `schedule_ranges` relies on). +fn map_ranges_to_pages( + page_row_ends: &[u64], + ranges: &[Range], +) -> Result> { + let mut result: Vec = + Vec::with_capacity(ranges.len().min(page_row_ends.len())); + for_each_page_range(page_row_ends, ranges, |page_idx, range_in_page| { + if let Some(mapping) = result.last_mut() + && mapping.page_idx == page_idx + { + mapping.ranges_in_page.push(range_in_page); + } else { + result.push(PageRangeMapping { + page_idx, + ranges_in_page: vec![range_in_page], + }); + } + })?; + Ok(result) +} + +fn pages_overlapping_ranges(page_row_ends: &[u64], ranges: &[Range]) -> Result> { + let mut result = Vec::with_capacity(ranges.len().min(page_row_ends.len())); + for_each_page_range(page_row_ends, ranges, |page_idx, _| { + if result.last().copied() != Some(page_idx) { + result.push(page_idx); + } + })?; + Ok(result) +} + +#[derive(Debug)] +struct PageCacheMiss { + /// Position of the page in `StructuralPrimitiveFieldScheduler::page_schedulers`, + /// which holds only the built pages and so may differ from `page_index`. + page_idx: usize, + cache_key: PageDataCacheKey, + initialization: PageInitialization, +} + +async fn read_page_initialization_buffers( + io: &Arc, + all_ranges: Vec>, +) -> Result> { + // `submit_request` coalesces (and later un-coalesces) in one forward + // pass, so submit ranges in file order. Page layouts are not required + // to declare their initialization ranges in that order, so retain each + // range's original position and restore it before initializing pages. + let mut sorted_ranges = all_ranges.into_iter().enumerate().collect::>(); + sorted_ranges.sort_unstable_by_key(|(_, range)| (range.start, range.end)); + let sorted_requests = sorted_ranges + .iter() + .map(|(_, range)| range.clone()) + .collect::>(); + let expected_buffer_count = sorted_requests.len(); + let sorted_buffers = if sorted_requests.is_empty() { + Vec::new() + } else { + io.submit_request(sorted_requests, 0).await? + }; + if sorted_buffers.len() != expected_buffer_count { + return Err(Error::invalid_input_source( + format!( + "Page initialization I/O returned {} buffers for {} ranges", + sorted_buffers.len(), + expected_buffer_count + ) + .into(), + )); + } + let mut buffers = vec![None; expected_buffer_count]; + for ((original_idx, _), buffer) in sorted_ranges.into_iter().zip(sorted_buffers) { + buffers[original_idx] = Some(buffer); + } + buffers + .into_iter() + .map(|buffer| { + buffer.ok_or_else(|| Error::internal("Page initialization buffer was not assigned")) + }) + .collect() +} + +async fn cache_initialized_page( + cache: Arc, + cache_key: PageDataCacheKey, + data: Arc, +) { + if data + .clone() + .as_arc_any() + .downcast::() + .is_err() + { + cache + .insert_with_key(&cache_key, Arc::new(CachedPage { data })) + .await; + } +} + +impl StructuralPrimitiveFieldScheduler { + /// Checks the built pages at `positions` (ascending indices into + /// `page_schedulers`) against the cache and returns those still needing + /// initialization I/O. + async fn find_cache_misses( + &mut self, + positions: impl ExactSizeIterator, + cache: &Arc, + ) -> Result> { + // Serve cache hits in place; the rest are misses we must read. + let mut misses = Vec::with_capacity(positions.len()); + for page_idx in positions { + let page = &self.page_schedulers[page_idx]; + if !page.scheduler.needs_initialization() { + continue; + } + let page_index = u32::try_from(page.page_index).map_err(|_| { + Error::invalid_input_source( + format!("Page index does not fit u32: {}", page.page_index).into(), + ) + })?; + let cache_key = PageDataCacheKey { + column_index: self.column_index, + page_index, + view: page.scheduler.cache_view(), + target_data_type_tag: self.target_data_type_tag.clone(), + }; + if let Some(cached_page) = cache.get_with_key(&cache_key).await { + match self.page_schedulers[page_idx] + .scheduler + .try_load(&cached_page.data) + { + Ok(()) => continue, + Err(error) => { + debug!("Replacing invalid cached page data for {cache_key:?}: {error}"); + } + } + } + let initialization = self.page_schedulers[page_idx].scheduler.init_layout()?; + misses.push(PageCacheMiss { + page_idx, + cache_key, + initialization, + }); + } + Ok(misses) + } + + fn initialization_ranges(misses: &[PageCacheMiss]) -> Vec> { + let num_ranges = misses + .iter() + .map(|miss| miss.initialization.num_required_ranges()) + .sum(); + let mut all_ranges = Vec::with_capacity(num_ranges); + for miss in misses { + all_ranges.extend(miss.initialization.required_ranges().cloned()); + } + all_ranges + } + + async fn initialize_cache_misses( + &mut self, + misses: Vec, + buffers: Vec, + cache: Arc, + io: &Arc, + ) -> Result<()> { + let mut buffers = buffers.into_iter(); + + // Initialize each missing page from its slice of the buffers, in file + // order. `init_from_buffers` can perform further I/O (e.g. loading blob + // descriptions), so run pages concurrently; peel disjoint `&mut` page + // borrows off the slice to satisfy the borrow checker without a lookup + // table. + let mut tail = self.page_schedulers.as_mut_slice(); + let mut next_page = 0; + let mut page_inits = FuturesOrdered::new(); + for miss in misses { + let PageCacheMiss { + page_idx, + cache_key, + initialization, + } = miss; + let relative_idx = page_idx + .checked_sub(next_page) + .ok_or_else(|| Error::internal("Page initialization indices are not ordered"))?; + if relative_idx >= tail.len() { + return Err(Error::internal(format!( + "Page initialization index {page_idx} is out of bounds" + ))); + } + let (_, rest) = tail.split_at_mut(relative_idx); + let (page, rest) = rest.split_first_mut().ok_or_else(|| { + Error::internal(format!( + "Page initialization index {page_idx} is out of bounds" + )) + })?; + tail = rest; + next_page = page_idx + 1; + + let count = initialization.num_required_ranges(); + let page_buffers = buffers.by_ref().take(count).collect::>(); + if page_buffers.len() != count { + return Err(Error::invalid_input_source( + format!( + "Page {page_idx} received {} initialization buffers, expected {count}", + page_buffers.len() + ) + .into(), + )); + } + let page_buffers = initialization.with_buffers(page_buffers)?; + let cache = cache.clone(); + let scheduler = &mut page.scheduler; + page_inits.push_back(async move { + let cached = scheduler.init_from_buffers(page_buffers, io).await?; + scheduler.try_load(&cached)?; + cache_initialized_page(cache, cache_key, cached).await; + Ok::<(), Error>(()) + }); + } + if buffers.next().is_some() { + return Err(Error::invalid_input_source( + "Page initialization returned unassigned buffers".into(), + )); + } + + page_inits.try_collect::>().await?; + Ok(()) + } + + async fn initialize_pages( + &mut self, + requested_ranges: Option<&[Range]>, + context: &SchedulerContext, + ) -> Result<()> { + let cache = context.cache().clone(); + let page_indices = match requested_ranges { + None => (0..self.page_row_ends.len()).collect::>(), + Some(ranges) => pages_overlapping_ranges(&self.page_row_ends, ranges)?, + }; + let positions = self.build_pages(&page_indices)?; + let misses = self + .find_cache_misses(positions.into_iter(), &cache) + .await?; + + if misses.is_empty() { + return Ok(()); + } + + // Concatenate the misses' metadata ranges into one request so adjacent + // ranges coalesce into shared GETs instead of one request per page. + let all_ranges = Self::initialization_ranges(&misses); + let buffers = read_page_initialization_buffers(context.io(), all_ranges).await?; + self.initialize_cache_misses(misses, buffers, cache, context.io()) + .await + } +} + +impl StructuralFieldScheduler for StructuralPrimitiveFieldScheduler { + fn initialize<'a>( + &'a mut self, + requested_ranges: Option<&'a [Range]>, + _filter: &'a FilterExpression, + context: &'a SchedulerContext, + ) -> BoxFuture<'a, Result<()>> { + self.initialize_pages(requested_ranges, context).boxed() + } + + fn schedule_ranges<'a>( + &'a self, + ranges: &[Range], + _filter: &FilterExpression, + ) -> Result> { + let ranges = ranges.to_vec(); + Ok(Box::new(StructuralPrimitiveFieldSchedulingJob::try_new( + self, ranges, + )?)) + } +} + +/// Takes the output from several pages decoders and +/// concatenates them. +#[derive(Debug)] +pub struct StructuralCompositeDecodeArrayTask { + tasks: Vec>, + should_validate: bool, + data_type: DataType, +} + +impl StructuralCompositeDecodeArrayTask { + fn restore_validity( + array: Arc, + unraveler: &mut CompositeRepDefUnraveler, + ) -> Result> { + let validity = unraveler.unravel_validity(array.len())?; + let Some(validity) = validity else { + return Ok(array); + }; + if array.data_type() == &DataType::Null { + // We unravel from a null array but we don't add the null buffer because arrow-rs doesn't like it + return Ok(array); + } + if validity.len() != array.len() { + return Err(Error::invalid_input_source( + format!( + "Structural validity has {} entries for an array with {} values", + validity.len(), + array.len() + ) + .into(), + )); + } + // SAFETY: The array buffers have already been validated and the null buffer length + // matches the array. We are only attaching the null buffer here. + Ok(make_array(unsafe { + array + .to_data() + .into_builder() + .nulls(Some(validity)) + .build_unchecked() + })) + } +} + +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( + decoded + .data + .into_arrow(self.data_type.clone(), self.should_validate)?, + ); + + arrays.push(array); + } + let array_refs = arrays.iter().map(|arr| arr.as_ref()).collect::>(); + let array = arrow_select::concat::concat(&array_refs)?; + let mut repdef = CompositeRepDefUnraveler::new(unravelers); + + let array = Self::restore_validity(array, &mut repdef)?; + + Ok(DecodedArray { + array, + repdef, + data_size, + }) + } +} + +/// Returns the total bytes consumed by validity bitmaps of nested child fields +/// for `rows` logical rows of `data_type`. +/// +/// Only `FixedSizeList` types have nested children that contribute additional +/// bitmaps; all other fixed-width leaf types return 0. +fn fsl_child_bitmap_bytes(data_type: &arrow_schema::DataType, rows: u64) -> u64 { + match data_type { + arrow_schema::DataType::FixedSizeList(child_field, dimension) => { + let child_rows = rows * *dimension as u64; + let own = if child_field.is_nullable() { + child_rows.div_ceil(8) + } else { + 0 + }; + own + fsl_child_bitmap_bytes(child_field.data_type(), child_rows) + } + _ => 0, + } +} + +#[derive(Debug)] +pub struct StructuralPrimitiveFieldDecoder { + field: Arc, + page_decoders: VecDeque>, + should_validate: bool, + rows_drained_in_current: u64, +} + +impl StructuralPrimitiveFieldDecoder { + pub fn new(field: &Arc, should_validate: bool) -> Self { + Self { + field: field.clone(), + page_decoders: VecDeque::new(), + should_validate, + rows_drained_in_current: 0, + } + } + + fn decoded_bytes_for_rows(&self, rows: u64) -> lance_core::Result { + let mut remaining = rows; + let mut total = 0u64; + for (page_num, page) in self.page_decoders.iter().enumerate() { + if remaining == 0 { + break; + } + let available = if page_num == 0 { + page.num_rows().saturating_sub(self.rows_drained_in_current) + } else { + page.num_rows() + }; + let take = available.min(remaining); + total += page.decoded_bytes(take)?; + remaining -= take; + } + if remaining > 0 { + return Err(lance_core::Error::not_supported(format!( + "plan_decoded_bytes: queued pages cover only {} of {} requested rows", + rows - remaining, + rows, + ))); + } + Ok(total) + } +} + +impl StructuralFieldDecoder for StructuralPrimitiveFieldDecoder { + fn accept_page(&mut self, child: LoadedPageShard) -> Result<()> { + assert!(child.path.is_empty()); + self.page_decoders.push_back(child.decoder); + Ok(()) + } + + fn drain(&mut self, num_rows: u64) -> Result> { + let mut remaining = num_rows; + let mut tasks = Vec::new(); + while remaining > 0 { + let queued_pages = self.page_decoders.len(); + let Some(cur_page) = self.page_decoders.front_mut() else { + return Err(Error::internal(format!( + "Primitive decoder missing page decoder while draining field '{}' (data_type={:?}, requested_rows={}, remaining_rows={}, rows_drained_in_current={}, queued_pages={})", + self.field.name(), + self.field.data_type(), + num_rows, + remaining, + self.rows_drained_in_current, + queued_pages + ))); + }; + let num_in_page = cur_page.num_rows() - self.rows_drained_in_current; + let to_take = num_in_page.min(remaining); + + let task = cur_page.drain(to_take)?; + tasks.push(task); + + if to_take == num_in_page { + self.page_decoders.pop_front(); + self.rows_drained_in_current = 0; + } else { + self.rows_drained_in_current += to_take; + } + + remaining -= to_take; + } + Ok(Box::new(StructuralCompositeDecodeArrayTask { + tasks, + should_validate: self.should_validate, + data_type: self.field.data_type().clone(), + })) + } + + fn data_type(&self) -> &DataType { + self.field.data_type() + } + + fn plan_decoded_bytes(&self, rows_remaining: u64) -> lance_core::Result<[u64; 8]> { + use crate::decoder::CANDIDATE_BATCH_SIZES; + let data_type = self.field.data_type(); + let is_nullable = self.field.is_nullable(); + + // Fixed-width: exact from type metadata, no page inspection needed. + if let Some(byte_width) = data_type.byte_width_opt() { + let mut out = [0u64; 8]; + for (i, &c) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (c as u64).min(rows_remaining); + out[i] = rows * byte_width as u64; + if is_nullable { + out[i] += rows.div_ceil(8); + } + out[i] += fsl_child_bitmap_bytes(data_type, rows); + } + return Ok(out); + } + + // Boolean: bit-packed, 1 bit per value. + if matches!(data_type, DataType::Boolean) { + let mut out = [0u64; 8]; + for (i, &c) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (c as u64).min(rows_remaining); + out[i] = rows.div_ceil(8); + if is_nullable { + out[i] += rows.div_ceil(8); + } + } + return Ok(out); + } + + // Null type: no data bytes. + if matches!(data_type, DataType::Null) { + return Ok([0u64; 8]); + } + + // Variable-width: walk page decoders for exact byte counts. + let mut out = [0u64; 8]; + for (i, &c) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (c as u64).min(rows_remaining); + out[i] = self.decoded_bytes_for_rows(rows)?; + if is_nullable { + out[i] += rows.div_ceil(8); + } + } + Ok(out) + } +} + +/// The serialized representation of full-zip data +struct SerializedFullZip { + /// The zipped values buffer + values: LanceBuffer, + /// The repetition index (only present if there is repetition) + repetition_index: Option, +} + +// We align and pad mini-blocks to 8 byte boundaries for two reasons. First, +// to allow us to store a chunk size in 12 bits. +// +// If we directly record the size in bytes with 12 bits we would be limited to +// 4KiB which is too small. Since we know each mini-block consists of 8 byte +// words we can store the # of words instead which gives us 32KiB. +// +// Second, each chunk in a mini-block is aligned to 8 bytes. This allows multi-byte +// values like offsets to be stored in a mini-block and safely read back out. It also +// helps ensure zero-copy reads in cases where zero-copy is possible (e.g. no decoding +// needed). +// +// Note: by "aligned to 8 bytes" we mean BOTH "aligned to 8 bytes from the start of +// the page" and "aligned to 8 bytes from the start of the file." +const MINIBLOCK_ALIGNMENT: usize = 8; + +/// An encoder for primitive (leaf) arrays +/// +/// This encoder is fairly complicated and follows a number of paths depending +/// on the data. +/// +/// First, we convert the validity & offsets information into repetition and +/// definition levels. Then we compress the data itself into a single buffer. +/// +/// If the data is narrow then we encode the data in small chunks (each chunk +/// should be a few disk sectors and contains a buffer of repetition, a buffer +/// of definition, and a buffer of value data). This approach is called +/// "mini-block". These mini-blocks are stored into a single data buffer. +/// +/// If the data is wide then we zip together the repetition and definition value +/// with the value data into a single buffer. This approach is called "zipped". +/// +/// If there is any repetition information then we create a repetition index +/// +/// In addition, the compression process may create zero or more metadata buffers. +/// For example, a dictionary compression will create dictionary metadata. Any +/// mini-block approach has a metadata buffer of block sizes. This metadata is +/// stored in a separate buffer on disk and read at initialization time. +/// +/// TODO: We should concatenate metadata buffers from all pages into a single buffer +/// at (roughly) the end of the file so there is, at most, one read per column of +/// metadata per file. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum MiniblockChunkSize { + U16, + U32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ComplexNullEncoding { + RawLevels, + CompressedLevels, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FixedWidthDictionaryEncoding { + Exclude64Bit, + Include64Bit, +} + +trait PrimitivePageEncodingBehavior: Send + Sync + Debug { + fn validate_field(&self, _field: &Field, _metadata: &HashMap) -> Result<()> { + Ok(()) + } + + fn try_plan_pages( + &self, + _ctx: &PrimitivePlanContext<'_>, + _arrays: &[ArrayRef], + _normalized: &NormalizedStructuralPlan, + _row_number: u64, + _num_rows: u64, + _num_values: u64, + ) -> Result>> { + Ok(None) + } + + fn try_encode_page( + &self, + _ctx: &PrimitiveEncodeContext, + page: PrimitivePageData, + ) -> Result { + Ok(PrimitiveEncodeAttempt::Unhandled(page)) + } +} + +/// One executable primitive-page behavior selected by an exact file +/// composition. +#[derive(Debug, Clone)] +pub struct PrimitivePageEncoding { + behavior: Arc, +} + +impl PrimitivePageEncoding { + /// Reject an explicit request for sparse structural encoding. + pub fn reject_sparse() -> Self { + Self { + behavior: Arc::new(RejectSparsePrimitiveEncoding), + } + } + + /// Encode constant non-null values as a constant page when applicable. + pub fn constant() -> Self { + Self { + behavior: Arc::new(ConstantPrimitiveEncoding), + } + } + + /// Plan and encode sparse structural pages when applicable. + pub fn sparse(compression: Arc) -> Self { + Self { + behavior: Arc::new(SparsePrimitiveEncoding { compression }), + } + } + + /// Encode dense pages with the original u16 miniblock grammar. + pub fn dense_u16(compression: Arc) -> Self { + Self { + behavior: Arc::new(DenseU16PrimitiveEncoding { compression }), + } + } + + /// Encode dense pages with the u32 miniblock grammar. + pub fn dense_u32(compression: Arc) -> Self { + Self { + behavior: Arc::new(DenseU32PrimitiveEncoding { compression }), + } + } +} + +#[derive(Debug)] +struct RejectSparsePrimitiveEncoding; + +#[derive(Debug)] +struct ConstantPrimitiveEncoding; + +#[derive(Debug)] +struct SparsePrimitiveEncoding { + compression: Arc, +} + +#[derive(Debug)] +struct DenseU16PrimitiveEncoding { + compression: Arc, +} + +#[derive(Debug)] +struct DenseU32PrimitiveEncoding { + compression: Arc, +} + +pub struct PrimitiveStructuralEncoder { + // Accumulates arrays until we have enough data to justify a disk page + accumulation_queue: AccumulationQueue, + + keep_original_array: bool, + accumulated_repdefs: Vec, + page_encodings: Arc<[PrimitivePageEncoding]>, + column_index: u32, + field: Field, + encoding_metadata: Arc>, +} + +struct CompressedLevelsChunk { + data: LanceBuffer, + num_levels: u16, +} + +struct CompressedLevels { + data: Vec, + compression: CompressiveEncoding, + rep_index: Option, +} + +struct SerializedMiniBlockPage { + num_buffers: u64, + data: LanceBuffer, + metadata: LanceBuffer, +} + +#[derive(Debug, Clone, Copy)] +struct DictEncodingBudget { + max_dict_entries: u32, + max_encoded_size: usize, +} + +enum PrimitivePageStructure { + Dense { + repdef: SerializedRepDefs, + single_row_miniblock_repdef_levels: Option, + }, + Sparse { + plan: sparse::SparseStructuralPlan, + prepared_values: Option, + }, +} + +// A primitive page after structural encoding selection and optional dense splitting. +struct PrimitivePageData { + // Arrow leaf arrays that contain this page's visible values. + arrays: Vec, + // Structural representation aligned to this page. + structure: PrimitivePageStructure, + // Top-level row number of the first row in this page. + row_number: u64, + // Number of top-level rows in this page. + num_rows: u64, +} + +struct PrimitivePlanContext<'a> { + column_idx: u32, + field: &'a Field, + encoding_metadata: &'a HashMap, +} + +enum PrimitiveEncodeAttempt { + Encoded(EncodedPage), + Unhandled(PrimitivePageData), +} + +// Immutable encoder state shared by per-page encode tasks. +// +// Cloning this only clones Arc-backed configuration and field metadata. Page data +// stays in PrimitivePageData and is moved into exactly one task. +#[derive(Clone)] +struct PrimitiveEncodeContext { + // Column being encoded. + column_idx: u32, + field: Field, + encoding_metadata: Arc>, + is_simple_validity: bool, + has_repdef_info: bool, +} + +impl PrimitiveStructuralEncoder { + pub fn try_new( + options: &EncodingOptions, + page_encodings: Arc<[PrimitivePageEncoding]>, + column_index: u32, + field: Field, + encoding_metadata: Arc>, + ) -> Result { + for page_encoding in page_encodings.iter() { + page_encoding + .behavior + .validate_field(&field, &encoding_metadata)?; + } + Ok(Self { + accumulation_queue: AccumulationQueue::new( + options.cache_bytes_per_column, + column_index, + options.keep_original_array, + ), + keep_original_array: options.keep_original_array, + accumulated_repdefs: Vec::new(), + column_index, + page_encodings, + field, + encoding_metadata, + }) + } + + fn encode_page( + page_encodings: &[PrimitivePageEncoding], + ctx: &PrimitiveEncodeContext, + mut page: PrimitivePageData, + ) -> Result { + for page_encoding in page_encodings { + match page_encoding.behavior.try_encode_page(ctx, page)? { + PrimitiveEncodeAttempt::Encoded(page) => return Ok(page), + PrimitiveEncodeAttempt::Unhandled(unhandled) => page = unhandled, + } + } + Err(Error::invalid_input_source( + format!( + "No primitive page encoding atom supports field '{}'", + ctx.field.name + ) + .into(), + )) + } + + // TODO: This is a heuristic we may need to tune at some point + // + // As data gets narrow then the "zipping" process gets too expensive + // and we prefer mini-block + // As data gets wide then the # of values per block shrinks (very wide) + // data doesn't even fit in a mini-block and the block overhead gets + // too large and we prefer zipped. + fn is_narrow(data_block: &DataBlock) -> bool { + const MINIBLOCK_MAX_BYTE_LENGTH_PER_VALUE: u64 = 256; + + if let Some(max_len_array) = data_block.get_stat(Stat::MaxLength) { + let max_len_array = max_len_array + .as_any() + .downcast_ref::>() + .unwrap(); + if max_len_array.value(0) < MINIBLOCK_MAX_BYTE_LENGTH_PER_VALUE { + return true; + } + } + false + } + + fn prefers_miniblock( + data_block: &DataBlock, + encoding_metadata: &HashMap, + ) -> bool { + // If the user specifically requested miniblock then use it + if let Some(user_requested) = encoding_metadata.get(STRUCTURAL_ENCODING_META_KEY) { + return user_requested.to_lowercase() == STRUCTURAL_ENCODING_MINIBLOCK; + } + // Otherwise only use miniblock if it is narrow + Self::is_narrow(data_block) + } + + 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 + // to emit an error) + if let Some(user_requested) = encoding_metadata.get(STRUCTURAL_ENCODING_META_KEY) { + return user_requested.to_lowercase() == STRUCTURAL_ENCODING_FULLZIP; + } + true + } + + // Converts value data, repetition levels, and definition levels into a single + // buffer of mini-blocks. In addition, creates a buffer of mini-block metadata + // which tells us the size of each block. Finally, if repetition is present then + // we also create a buffer for the repetition index. + // + // Each chunk is serialized as: + // | num_bufs (1 byte) | buf_lens (2 bytes per buffer) | P | buf0 | P | buf1 | ... | bufN | P | + // + // P - Padding inserted to ensure each buffer is 8-byte aligned and the buffer size is a multiple + // of 8 bytes (so that the next chunk is 8-byte aligned). + // + // Each block has a u16 word of metadata. The upper 12 bits contain the + // # of 8-byte words in the block (if the block does not fill the final word + // then up to 7 bytes of padding are added). The lower 4 bits describe the log_2 + // number of values (e.g. if there are 1024 then the lower 4 bits will be + // 0xA) All blocks except the last must have power-of-two number of values. + // This not only makes metadata smaller but it makes decoding easier since + // batch sizes are typically a power of 2. 4 bits would allow us to express + // up to 32Ki values. + // + // This means blocks can have 1 to 32Ki values and 8 - 32Ki bytes. + // + // All metadata words are serialized (as little endian) into a single buffer + // of metadata values. + // + // If there is repetition then we also create a repetition index. This is a + // single buffer of integer vectors (stored in row major order). There is one + // entry for each chunk. The size of the vector is based on the depth of random + // access we want to support. + // + // A vector of size 2 is the minimum and will support row-based random access (e.g. + // "take the 57th row"). A vector of size 3 will support 1 level of nested access + // (e.g. "take the 3rd item in the 57th row"). A vector of size 4 will support 2 + // levels of nested access and so on. + // + // The first number in the vector is the number of top-level rows that complete in + // the chunk. The second number is the number of second-level rows that complete + // after the final top-level row completed (or beginning of the chunk if no top-level + // row completes in the chunk). And so on. The final number in the vector is always + // the number of leftover items not covered by earlier entries in the vector. + // + // Currently we are limited to 0 levels of nested access but that will change in the + // future. + // + // The repetition index and the chunk metadata are read at initialization time and + // cached in memory. + fn serialize_miniblocks( + miniblocks: MiniBlockCompressed, + rep: Option>, + def: Option>, + miniblock_chunk_size: MiniblockChunkSize, + ) -> Result { + let bytes_rep = rep + .as_ref() + .map(|rep| rep.iter().map(|r| r.data.len()).sum::()) + .unwrap_or(0); + let bytes_def = def + .as_ref() + .map(|def| def.iter().map(|d| d.data.len()).sum::()) + .unwrap_or(0); + let bytes_data = miniblocks.data.iter().map(|d| d.len()).sum::(); + let mut num_buffers = miniblocks.data.len(); + if rep.is_some() { + num_buffers += 1; + } + if def.is_some() { + num_buffers += 1; + } + // 2 bytes for the length of each buffer and up to 7 bytes of padding per buffer + let max_extra = 9 * num_buffers; + let mut data_buffer = Vec::with_capacity(bytes_rep + bytes_def + bytes_data + max_extra); + let chunk_size_bytes = match miniblock_chunk_size { + MiniblockChunkSize::U16 => 2, + MiniblockChunkSize::U32 => 4, + }; + let mut meta_buffer = Vec::with_capacity(miniblocks.chunks.len() * chunk_size_bytes); + + let mut rep_iter = rep.map(|r| r.into_iter()); + let mut def_iter = def.map(|d| d.into_iter()); + + let mut buffer_offsets = vec![0; miniblocks.data.len()]; + for chunk in miniblocks.chunks { + let start_pos = data_buffer.len(); + // Start of chunk should be aligned + debug_assert_eq!(start_pos % MINIBLOCK_ALIGNMENT, 0); + + let rep = rep_iter.as_mut().map(|r| r.next().unwrap()); + let def = def_iter.as_mut().map(|d| d.next().unwrap()); + + // Write the number of levels, or 0 if there is no rep/def + let num_levels = rep + .as_ref() + .map(|r| r.num_levels) + .unwrap_or(def.as_ref().map(|d| d.num_levels).unwrap_or(0)); + data_buffer.extend_from_slice(&num_levels.to_le_bytes()); + + // Write the buffer lengths + if let Some(rep) = rep.as_ref() { + 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()).map_err(|_| { + Error::internal(format!( + "Definition buffer size ({} bytes) too large", + def.data.len() + )) + })?; + data_buffer.extend_from_slice(&bytes_def.to_le_bytes()); + } + + if miniblock_chunk_size == MiniblockChunkSize::U32 { + for &buffer_size in &chunk.buffer_sizes { + data_buffer.extend_from_slice(&buffer_size.to_le_bytes()); + } + } else { + for &buffer_size in &chunk.buffer_sizes { + let buffer_size = u16::try_from(buffer_size).map_err(|_| { + Error::internal(format!( + "Mini-block buffer size ({} bytes) too large for 16-bit metadata", + buffer_size + )) + })?; + data_buffer.extend_from_slice(&buffer_size.to_le_bytes()); + } + } + + // Pad + let add_padding = |data_buffer: &mut Vec| { + let pad = pad_bytes::(data_buffer.len()); + data_buffer.extend(iter::repeat_n(FILL_BYTE, pad)); + }; + add_padding(&mut data_buffer); + + // Write the buffers themselves + if let Some(rep) = rep.as_ref() { + data_buffer.extend_from_slice(&rep.data); + add_padding(&mut data_buffer); + } + if let Some(def) = def.as_ref() { + data_buffer.extend_from_slice(&def.data); + add_padding(&mut data_buffer); + } + for (buffer_size, (buffer, buffer_offset)) in chunk + .buffer_sizes + .iter() + .zip(miniblocks.data.iter().zip(buffer_offsets.iter_mut())) + { + let start = *buffer_offset; + let end = start + *buffer_size as usize; + *buffer_offset += *buffer_size as usize; + data_buffer.extend_from_slice(&buffer[start..end]); + add_padding(&mut data_buffer); + } + + let chunk_bytes = data_buffer.len() - start_pos; + let max_chunk_size = match miniblock_chunk_size { + MiniblockChunkSize::U16 => 32 * 1024, + MiniblockChunkSize::U32 => 1_u64 << 31, + }; + if chunk_bytes == 0 || chunk_bytes as u64 > max_chunk_size { + return Err(Error::internal(format!( + "Mini-block chunk size {} bytes exceeds the {} byte metadata limit", + chunk_bytes, max_chunk_size + ))); + } + if chunk_bytes % MINIBLOCK_ALIGNMENT != 0 { + return Err(Error::internal(format!( + "Mini-block chunk size {} bytes is not aligned to {} bytes", + chunk_bytes, MINIBLOCK_ALIGNMENT + ))); + } + if chunk.log_num_values > 15 { + return Err(Error::internal(format!( + "Mini-block log_num_values {} exceeds the 4-bit metadata limit", + chunk.log_num_values + ))); + } + // We subtract 1 here from chunk_bytes because we want to be able to express + // a size of 32KiB and not (32Ki - 8)B which is what we'd get otherwise with + // 0xFFF + let divided_bytes = chunk_bytes / MINIBLOCK_ALIGNMENT; + let divided_bytes_minus_one = (divided_bytes - 1) as u64; + + let metadata = (divided_bytes_minus_one << 4) | chunk.log_num_values as u64; + if miniblock_chunk_size == MiniblockChunkSize::U32 { + meta_buffer.extend_from_slice(&(metadata as u32).to_le_bytes()); + } else { + meta_buffer.extend_from_slice(&(metadata as u16).to_le_bytes()); + } + } + + let data_buffer = LanceBuffer::from(data_buffer); + let metadata_buffer = LanceBuffer::from(meta_buffer); + + Ok(SerializedMiniBlockPage { + num_buffers: miniblocks.data.len() as u64, + data: data_buffer, + metadata: metadata_buffer, + }) + } + + /// Compresses a buffer of levels into chunks + /// + /// If these are repetition levels then we also calculate the repetition index here (that + /// is the third return value) + fn compress_levels( + mut levels: RepDefSlicer<'_>, + num_elements: u64, + compression_strategy: &dyn CompressionStrategy, + chunks: &[MiniBlockChunk], + // This will be 0 if we are compressing def levels + max_rep: u16, + ) -> Result { + let mut rep_index = if max_rep > 0 { + Vec::with_capacity(chunks.len()) + } else { + vec![] + }; + // Make the levels into a FixedWidth data block + let num_levels = levels.num_levels() as u64; + let levels_buf = levels.all_levels().clone(); + + let mut fixed_width_block = FixedWidthDataBlock { + data: levels_buf, + bits_per_value: 16, + num_values: num_levels, + block_info: BlockInfo::new(), + }; + // Compute statistics to enable optimal compression for rep/def levels + fixed_width_block.compute_stat(); + + let levels_block = DataBlock::FixedWidth(fixed_width_block); + let levels_field = Field::new_arrow("", DataType::UInt16, false)?; + // Pick a block compressor + let compressor = + compression_strategy.create_block_compressor(&levels_field, &levels_block)?; + let mut compressor_desc = None; + // Compress blocks of levels (sized according to the chunks) + let mut level_chunks = Vec::with_capacity(chunks.len()); + let mut values_counter = 0; + for (chunk_idx, chunk) in chunks.iter().enumerate() { + let chunk_num_values = chunk.num_values(values_counter, num_elements); + debug_assert!(chunk_num_values > 0); + values_counter += chunk_num_values; + let chunk_levels = if chunk_idx < chunks.len() - 1 { + levels.slice_next(chunk_num_values as usize) + } else { + levels.slice_rest() + }; + let num_chunk_levels = (chunk_levels.len() / 2) as u64; + if max_rep > 0 { + // If max_rep > 0 then we are working with rep levels and we need + // to calculate the repetition index. The repetition index for a + // chunk is currently 2 values (in the future it may be more). + // + // The first value is the number of rows that _finish_ in the + // chunk. + // + // The second value is the number of "leftovers" after the last + // finished row in the chunk. + let rep_values = chunk_levels.borrow_to_typed_slice::(); + let rep_values = rep_values.as_ref(); + + // We skip 1 here because a max_rep at spot 0 doesn't count as a finished list (we + // will count it in the previous chunk) + let mut num_rows = rep_values.iter().skip(1).filter(|v| **v == max_rep).count(); + let num_leftovers = if chunk_idx < chunks.len() - 1 { + rep_values + .iter() + .rev() + .position(|v| *v == max_rep) + // # of leftovers includes the max_rep spot + .map(|pos| pos + 1) + .unwrap_or(rep_values.len()) + } else { + // Last chunk can't have leftovers + 0 + }; + + if chunk_idx != 0 && rep_values.first() == Some(&max_rep) { + // This chunk starts with a new row and so, if we thought we had leftovers + // in the previous chunk, we were mistaken + // TODO: Can use unchecked here + let rep_len = rep_index.len(); + if rep_index[rep_len - 1] != 0 { + // We thought we had leftovers but that was actually a full row + rep_index[rep_len - 2] += 1; + rep_index[rep_len - 1] = 0; + } + } + + if chunk_idx == chunks.len() - 1 { + // The final list + num_rows += 1; + } + rep_index.push(num_rows as u64); + rep_index.push(num_leftovers as u64); + } + let mut chunk_fixed_width = FixedWidthDataBlock { + data: chunk_levels, + bits_per_value: 16, + num_values: num_chunk_levels, + block_info: BlockInfo::new(), + }; + chunk_fixed_width.compute_stat(); + let chunk_levels_block = DataBlock::FixedWidth(chunk_fixed_width); + let (compressed_levels, chunk_compressor_desc) = + compressor.compress(chunk_levels_block)?; + if let Some(compressor_desc) = compressor_desc.as_ref() { + if compressor_desc != &chunk_compressor_desc { + return Err(Error::internal( + "Rep/def block compressor changed encoding between chunks".to_string(), + )); + } + } else { + compressor_desc = Some(chunk_compressor_desc); + } + let compressed_levels = compressed_levels.ok_or_else(|| { + Error::internal( + "Rep/def block compressor selected a metadata-only codec".to_string(), + ) + })?; + let num_levels = u16::try_from(num_chunk_levels).map_err(|_| { + Error::invalid_input_source( + format!( + "Mini-block cannot encode {} rep/def levels in one chunk. \ + This usually means a top-level row contains too much nested structure \ + for the current layout.", + num_chunk_levels + ) + .into(), + ) + })?; + level_chunks.push(CompressedLevelsChunk { + data: compressed_levels, + num_levels, + }); + } + debug_assert_eq!(levels.num_levels_remaining(), 0); + let rep_index = if rep_index.is_empty() { + None + } else { + Some(LanceBuffer::reinterpret_vec(rep_index)) + }; + Ok(CompressedLevels { + data: level_chunks, + compression: compressor_desc.ok_or_else(|| { + Error::internal("Rep/def compression produced no chunks".to_string()) + })?, + rep_index, + }) + } + + fn encode_simple_all_null( + column_idx: u32, + num_rows: u64, + row_number: u64, + ) -> Result { + let description = + ProtobufUtils21::constant_layout(&[DefinitionInterpretation::NullableItem], None); + Ok(EncodedPage { + column_idx, + data: vec![], + description: PageEncoding::Structural(description), + num_rows, + row_number, + }) + } + + fn encode_complex_all_null_vals( + data: &Arc<[u16]>, + compression_strategy: &dyn CompressionStrategy, + ) -> Result<(LanceBuffer, pb21::CompressiveEncoding)> { + let buffer = LanceBuffer::reinterpret_slice(data.clone()); + let mut fixed_width_block = FixedWidthDataBlock { + data: buffer, + bits_per_value: 16, + num_values: data.len() as u64, + block_info: BlockInfo::new(), + }; + fixed_width_block.compute_stat(); + + let levels_block = DataBlock::FixedWidth(fixed_width_block); + let levels_field = Field::new_arrow("", DataType::UInt16, false)?; + let (compressed_buffer, encoding) = + compress_required_block(compression_strategy, &levels_field, levels_block)?; + Ok((compressed_buffer, encoding)) + } + + // Encodes a page where all values are null but we have rep/def + // information that we need to store (e.g. to distinguish between + // different kinds of null) + fn encode_complex_all_null( + column_idx: u32, + repdef: crate::repdef::SerializedRepDefs, + row_number: u64, + num_rows: u64, + complex_null_encoding: ComplexNullEncoding, + compression_strategy: &dyn CompressionStrategy, + ) -> Result { + if complex_null_encoding == ComplexNullEncoding::RawLevels { + let rep_bytes = if let Some(rep) = repdef.repetition_levels.as_ref() { + LanceBuffer::reinterpret_slice(rep.clone()) + } else { + LanceBuffer::empty() + }; + + let def_bytes = if let Some(def) = repdef.definition_levels.as_ref() { + LanceBuffer::reinterpret_slice(def.clone()) + } else { + LanceBuffer::empty() + }; + + let description = ProtobufUtils21::constant_layout(&repdef.def_meaning, None); + return Ok(EncodedPage { + column_idx, + data: vec![rep_bytes, def_bytes], + description: PageEncoding::Structural(description), + num_rows, + row_number, + }); + } + + let (rep_bytes, rep_encoding, num_rep_values) = if let Some(rep) = + repdef.repetition_levels.as_ref() + { + let num_values = rep.len() as u64; + let (buffer, encoding) = Self::encode_complex_all_null_vals(rep, compression_strategy)?; + (buffer, Some(encoding), num_values) + } else { + (LanceBuffer::empty(), None, 0) + }; + + let (def_bytes, def_encoding, num_def_values) = if let Some(def) = + repdef.definition_levels.as_ref() + { + let num_values = def.len() as u64; + let (buffer, encoding) = Self::encode_complex_all_null_vals(def, compression_strategy)?; + (buffer, Some(encoding), num_values) + } else { + (LanceBuffer::empty(), None, 0) + }; + + let description = ProtobufUtils21::compressed_all_null_constant_layout( + &repdef.def_meaning, + rep_encoding, + def_encoding, + num_rep_values, + num_def_values, + ); + Ok(EncodedPage { + column_idx, + data: vec![rep_bytes, def_bytes], + description: PageEncoding::Structural(description), + num_rows, + row_number, + }) + } + + fn leaf_validity( + repdef: &crate::repdef::SerializedRepDefs, + num_values: usize, + ) -> Result> { + let rep = repdef + .repetition_levels + .as_ref() + .map(|rep| rep.as_ref().to_vec()); + let def = repdef + .definition_levels + .as_ref() + .map(|def| def.as_ref().to_vec()); + let mut unraveler = RepDefUnraveler::new( + rep, + def, + repdef.def_meaning.clone().into(), + num_values as u64, + ); + if unraveler.is_all_valid() { + return Ok(None); + } + let mut validity = BooleanBufferBuilder::new(num_values); + unraveler.unravel_validity(&mut validity)?; + Ok(Some(validity.finish())) + } + + fn is_constant_values( + arrays: &[ArrayRef], + scalar: &ArrayRef, + validity: Option<&BooleanBuffer>, + ) -> Result { + debug_assert_eq!(scalar.len(), 1); + debug_assert_eq!(scalar.null_count(), 0); + + match scalar.data_type() { + DataType::Boolean => { + let mut global_idx = 0usize; + let scalar_val = scalar.as_boolean().value(0); + for arr in arrays { + let bool_arr = arr.as_boolean(); + for i in 0..arr.len() { + let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); + global_idx += 1; + if !is_valid { + continue; + } + if bool_arr.value(i) != scalar_val { + return Ok(false); + } + } + } + Ok(true) + } + DataType::Utf8 => Self::is_constant_utf8::(arrays, scalar, validity), + DataType::LargeUtf8 => Self::is_constant_utf8::(arrays, scalar, validity), + DataType::Binary => Self::is_constant_binary::(arrays, scalar, validity), + DataType::LargeBinary => Self::is_constant_binary::(arrays, scalar, validity), + data_type => { + let mut global_idx = 0usize; + let Some(byte_width) = data_type.byte_width_opt() else { + return Ok(false); + }; + let scalar_data = scalar.to_data(); + if scalar_data.buffers().len() != 1 || !scalar_data.child_data().is_empty() { + return Ok(false); + } + let scalar_bytes = scalar_data.buffers()[0].as_slice(); + if scalar_bytes.len() != byte_width { + return Ok(false); + } + + for arr in arrays { + let data = arr.to_data(); + if data.buffers().is_empty() { + return Ok(false); + } + let buf = data.buffers()[0].as_slice(); + let base = data.offset(); + for i in 0..arr.len() { + let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); + global_idx += 1; + if !is_valid { + continue; + } + let start = (base + i) * byte_width; + if buf[start..start + byte_width] != scalar_bytes[..] { + return Ok(false); + } + } + } + Ok(true) + } + } + } + + fn is_constant_utf8( + arrays: &[ArrayRef], + scalar: &ArrayRef, + validity: Option<&BooleanBuffer>, + ) -> Result { + debug_assert_eq!(scalar.len(), 1); + let scalar_val = scalar.as_string::().value(0).as_bytes(); + let mut global_idx = 0usize; + for arr in arrays { + let str_arr = arr.as_string::(); + for i in 0..arr.len() { + let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); + global_idx += 1; + if !is_valid { + continue; + } + if str_arr.value(i).as_bytes() != scalar_val { + return Ok(false); + } + } + } + Ok(true) + } + + fn is_constant_binary( + arrays: &[ArrayRef], + scalar: &ArrayRef, + validity: Option<&BooleanBuffer>, + ) -> Result { + debug_assert_eq!(scalar.len(), 1); + let scalar_val = scalar.as_binary::().value(0); + let mut global_idx = 0usize; + for arr in arrays { + let bin_arr = arr.as_binary::(); + for i in 0..arr.len() { + let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); + global_idx += 1; + if !is_valid { + continue; + } + if bin_arr.value(i) != scalar_val { + return Ok(false); + } + } + } + Ok(true) + } + + fn find_constant_scalar( + arrays: &[ArrayRef], + validity: Option<&BooleanBuffer>, + ) -> Result> { + if arrays.is_empty() { + return Ok(None); + } + + let global_scalar_idx = if let Some(validity) = validity { + let Some(idx) = (0..validity.len()).find(|&i| validity.value(i)) else { + return Ok(None); + }; + idx + } else { + 0 + }; + + let mut idx_remaining = global_scalar_idx; + let mut scalar_arr_idx = 0usize; + while scalar_arr_idx < arrays.len() { + let len = arrays[scalar_arr_idx].len(); + if idx_remaining < len { + break; + } + idx_remaining -= len; + scalar_arr_idx += 1; + } + + if scalar_arr_idx >= arrays.len() { + return Ok(None); + } - let start_slice = &buffer[start_offset..start_offset + bytes_per_value as usize]; - let start_val = - ByteUnpacker::new(start_slice.iter().copied(), bytes_per_value as usize) - .next() - .unwrap(); + let scalar = + lance_arrow::scalar::extract_scalar_value(&arrays[scalar_arr_idx], idx_remaining)?; + if scalar.null_count() != 0 { + return Ok(None); + } + if !Self::is_constant_values(arrays, &scalar, validity)? { + return Ok(None); + } + Ok(Some(scalar)) + } - let end_slice = &buffer[end_offset..end_offset + bytes_per_value as usize]; - let end_val = - ByteUnpacker::new(end_slice.iter().copied(), bytes_per_value as usize) - .next() - .unwrap(); + fn resolve_dict_values_compression_metadata( + field_metadata: &HashMap, + env_compression: Option, + env_compression_level: Option, + ) -> HashMap { + let mut metadata = HashMap::new(); + + let compression = field_metadata + .get(DICT_VALUES_COMPRESSION_META_KEY) + .cloned() + .or(env_compression) + .unwrap_or_else(|| DEFAULT_DICT_VALUES_COMPRESSION.to_string()); + metadata.insert(COMPRESSION_META_KEY.to_string(), compression); + + if let Some(compression_level) = field_metadata + .get(DICT_VALUES_COMPRESSION_LEVEL_META_KEY) + .cloned() + .or(env_compression_level) + { + metadata.insert(COMPRESSION_LEVEL_META_KEY.to_string(), compression_level); + } + + metadata + } + + fn build_dict_values_compressor_field(field: &Field) -> Result { + // This is an internal synthetic field used only to feed metadata into + // `create_block_compressor` for dictionary values. The concrete type/name here + // are not semantically meaningful; we rely on explicit metadata below to control + // general compression selection for dictionary values. + let mut dict_values_field = Field::new_arrow("", DataType::UInt16, false)?; + dict_values_field.metadata = Self::resolve_dict_values_compression_metadata( + &field.metadata, + env::var(DICT_VALUES_COMPRESSION_ENV_VAR).ok(), + env::var(DICT_VALUES_COMPRESSION_LEVEL_ENV_VAR).ok(), + ); + Ok(dict_values_field) + } + + #[allow(clippy::too_many_arguments)] + fn encode_miniblock( + column_idx: u32, + field: &Field, + compression_strategy: &dyn CompressionStrategy, + data: DataBlock, + repdef: crate::repdef::SerializedRepDefs, + row_number: u64, + dictionary_data: Option, + num_rows: u64, + miniblock_chunk_size: MiniblockChunkSize, + ) -> Result { + if let DataBlock::AllNull(_null_block) = data { + // We should not be using mini-block for all-null. There are other structural + // encodings for that. + unreachable!() + } + + let num_items = data.num_values(); + + let compressor = compression_strategy.create_miniblock_compressor(field, &data)?; + let common_chunk_buffers = + u64::from(repdef.rep_slicer().is_some()) + u64::from(repdef.def_slicer().is_some()); + let support_large_chunk = miniblock_chunk_size == MiniblockChunkSize::U32; + let compression_context = + MiniBlockCompressionContext::new(common_chunk_buffers, support_large_chunk, true); + let (compressed_data, value_encoding) = compressor.compress(compression_context, data)?; + + let max_rep = repdef.def_meaning.iter().filter(|l| l.is_list()).count() as u16; + + let mut compressed_rep = repdef + .rep_slicer() + .map(|rep_slicer| { + Self::compress_levels( + rep_slicer, + num_items, + compression_strategy, + &compressed_data.chunks, + max_rep, + ) + }) + .transpose()?; + + let (rep_index, rep_index_depth) = + match compressed_rep.as_mut().and_then(|cr| cr.rep_index.as_mut()) { + Some(rep_index) => (Some(rep_index.clone()), 1), + None => (None, 0), + }; + + let mut compressed_def = repdef + .def_slicer() + .map(|def_slicer| { + Self::compress_levels( + def_slicer, + num_items, + compression_strategy, + &compressed_data.chunks, + /*max_rep=*/ 0, + ) + }) + .transpose()?; + + // TODO: Parquet sparsely encodes values here. We could do the same but + // then we won't have log2 values per chunk. This means more metadata + // and potentially more decoder asymmetry. However, it may be worth + // investigating at some point + + let rep_data = compressed_rep + .as_mut() + .map(|cr| std::mem::take(&mut cr.data)); + let def_data = compressed_def + .as_mut() + .map(|cd| std::mem::take(&mut cd.data)); + + let serialized = + Self::serialize_miniblocks(compressed_data, rep_data, def_data, miniblock_chunk_size)?; + let has_large_chunk = miniblock_chunk_size == MiniblockChunkSize::U32; + + // Metadata, Data, Dictionary, (maybe) Repetition Index + let mut data = Vec::with_capacity(4); + data.push(serialized.metadata); + data.push(serialized.data); + + if let Some(dictionary_data) = dictionary_data { + let num_dictionary_items = dictionary_data.num_values(); + let dict_values_field = Self::build_dict_values_compressor_field(field)?; + + let (dictionary_buffer, dictionary_encoding) = + compress_required_block(compression_strategy, &dict_values_field, dictionary_data)?; + + data.push(dictionary_buffer); + if let Some(rep_index) = rep_index { + data.push(rep_index); + } + + let description = ProtobufUtils21::miniblock_layout( + compressed_rep.map(|cr| cr.compression), + compressed_def.map(|cd| cd.compression), + value_encoding, + rep_index_depth, + serialized.num_buffers, + Some((dictionary_encoding, num_dictionary_items)), + &repdef.def_meaning, + num_items, + has_large_chunk, + ); + Ok(EncodedPage { + num_rows, + column_idx, + data, + description: PageEncoding::Structural(description), + row_number, + }) + } else { + let description = ProtobufUtils21::miniblock_layout( + compressed_rep.map(|cr| cr.compression), + compressed_def.map(|cd| cd.compression), + value_encoding, + rep_index_depth, + serialized.num_buffers, + None, + &repdef.def_meaning, + num_items, + has_large_chunk, + ); + + if let Some(rep_index) = rep_index { + let view = rep_index.borrow_to_typed_slice::(); + let total = view.as_chunks::<2>().0.iter().map(|c| c[0]).sum::(); + debug_assert_eq!(total, num_rows); + + data.push(rep_index); + } + + Ok(EncodedPage { + num_rows, + column_idx, + data, + description: PageEncoding::Structural(description), + row_number, + }) + } + } + + // For fixed-size data we encode < control word | data > for each value + fn serialize_full_zip_fixed( + fixed: FixedWidthDataBlock, + mut repdef: ControlWordIterator, + num_values: u64, + ) -> Result { + if !fixed.bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input_source( + format!( + "Full-zip fixed-width values must be byte aligned, got {} bits per value", + fixed.bits_per_value + ) + .into(), + )); + } + + let len = fixed.data.len() + repdef.bytes_per_word() * num_values as usize; + let mut zipped_data = Vec::with_capacity(len); + + let max_rep_index_val = if repdef.has_repetition() { + len as u64 + } else { + // Setting this to 0 means we won't write a repetition index + 0 + }; + let mut rep_index_builder = + BytepackedIntegerEncoder::with_capacity(num_values as usize + 1, max_rep_index_val); + + let bytes_per_value = fixed.bits_per_value as usize / 8; + let mut offset = 0; + + if bytes_per_value == 0 { + // No data, just dump the repdef into the buffer + while let Some(control) = repdef.append_next(&mut zipped_data) { + if control.is_new_row { + // We have finished a row + debug_assert!(offset <= len); + rep_index_builder.append_trusted(offset as u64); + } + offset = zipped_data.len(); + } + } else { + // We have data, zip it with the repdef + let mut data_iter = fixed.data.chunks_exact(bytes_per_value); + while let Some(control) = repdef.append_next(&mut zipped_data) { + if control.is_new_row { + // We have finished a row + debug_assert!(offset <= len); + rep_index_builder.append_trusted(offset as u64); + } + if control.is_visible { + let value = data_iter.next().unwrap(); + zipped_data.extend_from_slice(value); + } + offset = zipped_data.len(); + } + } + + debug_assert_eq!(zipped_data.len(), len); + // Put the final value in the rep index + rep_index_builder.append_trusted(zipped_data.len() as u64); + + let zipped_data = LanceBuffer::from(zipped_data); + let rep_index = rep_index_builder.into_data(); + let rep_index = if rep_index.is_empty() { + None + } else { + Some(LanceBuffer::from(rep_index)) + }; + Ok(SerializedFullZip { + values: zipped_data, + repetition_index: rep_index, + }) + } + + // For variable-size data we encode < control word | length | data > for each value + // + // In addition, we create a second buffer, the repetition index + fn serialize_full_zip_variable( + variable: VariableWidthBlock, + mut repdef: ControlWordIterator, + num_items: u64, + ) -> Result { + let bytes_per_offset = variable.bits_per_offset as usize / 8; + if !variable.bits_per_offset.is_multiple_of(8) { + return Err(Error::invalid_input_source( + format!( + "Full-zip variable-width offsets must be byte aligned, got {} bits per offset", + variable.bits_per_offset + ) + .into(), + )); + } + let len = variable.data.len() + + repdef.bytes_per_word() * num_items as usize + + bytes_per_offset * variable.num_values as usize; + let mut buf = Vec::with_capacity(len); + + let max_rep_index_val = len as u64; + let mut rep_index_builder = + BytepackedIntegerEncoder::with_capacity(num_items as usize + 1, max_rep_index_val); + + // TODO: byte pack the item lengths with varint encoding + match bytes_per_offset { + 4 => { + let offs = variable.offsets.borrow_to_typed_slice::(); + let mut rep_offset = 0; + let mut windows_iter = offs.as_ref().windows(2); + while let Some(control) = repdef.append_next(&mut buf) { + if control.is_new_row { + // We have finished a row + debug_assert!(rep_offset <= len); + rep_index_builder.append_trusted(rep_offset as u64); + } + if control.is_visible { + let window = windows_iter.next().unwrap(); + if control.is_valid_item { + buf.extend_from_slice(&(window[1] - window[0]).to_le_bytes()); + buf.extend_from_slice( + &variable.data[window[0] as usize..window[1] as usize], + ); + } + } + rep_offset = buf.len(); + } + } + 8 => { + let offs = variable.offsets.borrow_to_typed_slice::(); + let mut rep_offset = 0; + let mut windows_iter = offs.as_ref().windows(2); + while let Some(control) = repdef.append_next(&mut buf) { + if control.is_new_row { + // We have finished a row + debug_assert!(rep_offset <= len); + rep_index_builder.append_trusted(rep_offset as u64); + } + if control.is_visible { + let window = windows_iter.next().unwrap(); + if control.is_valid_item { + buf.extend_from_slice(&(window[1] - window[0]).to_le_bytes()); + buf.extend_from_slice( + &variable.data[window[0] as usize..window[1] as usize], + ); + } + } + rep_offset = buf.len(); + } + } + _ => { + return Err(Error::invalid_input_source( + format!( + "Full-zip variable-width offsets must be 32 or 64 bits, got {} bits", + variable.bits_per_offset + ) + .into(), + )); + } + } - (data_buf_position + start_val)..(data_buf_position + end_val) - }) - .collect() - } + // We might have saved a few bytes by not copying lengths when the length was zero. However, + // if we are over `len` then we have a bug. + debug_assert!(buf.len() <= len); + // Put the final value in the rep index + rep_index_builder.append_trusted(buf.len() as u64); - /// Computes the ranges in the repetition index that need to be loaded - fn compute_rep_index_ranges( - ranges: &[Range], - rep_index: &FullZipRepIndexDetails, - ) -> Vec> { - ranges - .iter() - .flat_map(|r| { - let first_val_start = - rep_index.buf_position + (r.start * rep_index.bytes_per_value); - let first_val_end = first_val_start + rep_index.bytes_per_value; - let last_val_start = rep_index.buf_position + (r.end * rep_index.bytes_per_value); - let last_val_end = last_val_start + rep_index.bytes_per_value; - [first_val_start..first_val_end, last_val_start..last_val_end] - }) - .collect() + let zipped_data = LanceBuffer::from(buf); + let rep_index = rep_index_builder.into_data(); + debug_assert!(!rep_index.is_empty()); + let rep_index = Some(LanceBuffer::from(rep_index)); + Ok(SerializedFullZip { + values: zipped_data, + repetition_index: rep_index, + }) } - /// Resolves byte ranges from repetition index (either from cache or disk) - async fn resolve_byte_ranges( - data_buf_position: u64, - ranges: &[Range], - io: &Arc, - rep_index: &FullZipRepIndexDetails, - cached_state: Option<&Arc>, - priority: u64, - ) -> Result>> { - if let Some(cached_state) = cached_state { - // Use cached repetition index - Ok(Self::extract_byte_ranges_from_cached( - &cached_state.rep_index_buffer, - ranges, - rep_index.bytes_per_value, - data_buf_position, - )) - } else { - // Load from disk - let rep_ranges = Self::compute_rep_index_ranges(ranges, rep_index); - let rep_data = io.submit_request(rep_ranges, priority).await?; - let rep_buffer = LanceBuffer::concat( - &rep_data - .into_iter() - .map(|d| LanceBuffer::from_bytes(d, 1)) - .collect::>(), - ); - Ok(Self::extract_byte_ranges_from_pairs( - rep_buffer, - rep_index.bytes_per_value, - data_buf_position, - )) + /// Serializes data into a single buffer according to the full-zip format which zips + /// together the repetition, definition, and value data into a single buffer. + fn serialize_full_zip( + compressed_data: PerValueDataBlock, + repdef: ControlWordIterator, + num_items: u64, + ) -> Result { + match compressed_data { + PerValueDataBlock::Fixed(fixed) => { + Self::serialize_full_zip_fixed(fixed, repdef, num_items) + } + PerValueDataBlock::Variable(var) => { + Self::serialize_full_zip_variable(var, repdef, num_items) + } } } - /// Schedules ranges in the presence of a repetition index - fn schedule_ranges_rep( - &self, - ranges: &[Range], - io: &Arc, - rep_index: FullZipRepIndexDetails, - ) -> Result> { - // Copy necessary fields to avoid lifetime issues - let data_buf_position = self.data_buf_position; - let cached_state = self.cached_state.clone(); - let priority = self.priority; - let details = self.details.clone(); - let bits_per_offset = self.bits_per_offset; - let ranges = ranges.to_vec(); - let io_clone = io.clone(); - let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); - - let load_task = async move { - // Step 1: Resolve byte ranges from repetition index - let byte_ranges = Self::resolve_byte_ranges( - data_buf_position, - &ranges, - &io_clone, - &rep_index, - cached_state.as_ref(), - priority, - ) - .await?; - - // Step 2: Load data - let data = io_clone.submit_request(byte_ranges, priority).await?; - let data = data - .into_iter() - .map(|d| LanceBuffer::from_bytes(d, 1)) - .collect::>(); - - // Step 3: Calculate total rows - let num_rows: u64 = ranges.iter().map(|r| r.end - r.start).sum(); - - // Step 4: Create decoder - Self::create_decoder(details, data, num_rows, bits_per_offset) + fn expand_boolean_to_bytes(fixed: FixedWidthDataBlock) -> FixedWidthDataBlock { + debug_assert_eq!(fixed.bits_per_value, 1); + let num_values = fixed.num_values as usize; + let bool_buf = BooleanBuffer::new(fixed.data.into_buffer(), 0, num_values); + let expanded: Vec = (0..num_values).map(|i| bool_buf.value(i) as u8).collect(); + FixedWidthDataBlock { + data: LanceBuffer::from(expanded), + bits_per_value: 8, + num_values: fixed.num_values, + block_info: BlockInfo::new(), } - .boxed(); - let page_load_task = PageLoadTask { - decoder_fut: load_task, - num_rows, - }; - Ok(vec![page_load_task]) } - // In the simple case there is no repetition and we just have large fixed-width - // rows of data. We can just map row ranges to byte ranges directly using the - // fixed-width of the data type. - fn schedule_ranges_simple( - &self, - ranges: &[Range], - io: &dyn EncodingsIo, - ) -> Result> { - // Convert row ranges to item ranges (i.e. multiply by items per row) - let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); + fn encode_full_zip( + column_idx: u32, + field: &Field, + compression_strategy: &dyn CompressionStrategy, + data: DataBlock, + repdef: crate::repdef::SerializedRepDefs, + row_number: u64, + num_lists: u64, + ) -> Result { + let max_rep = repdef + .repetition_levels + .as_ref() + .map_or(0, |r| r.iter().max().copied().unwrap_or(0)); + let max_def = repdef + .definition_levels + .as_ref() + .map_or(0, |d| d.iter().max().copied().unwrap_or(0)); - let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor else { - unreachable!() - }; + // To handle FSL we just flatten + // let data = data.flatten(); - // Convert item ranges to byte ranges (i.e. multiply by bytes per item) - let bits_per_value = decompressor.bits_per_value(); - assert_eq!(bits_per_value % 8, 0); - let bytes_per_value = bits_per_value / 8; - let bytes_per_cw = self.details.ctrl_word_parser.bytes_per_word(); - let total_bytes_per_value = bytes_per_value + bytes_per_cw as u64; - let byte_ranges = ranges.iter().map(|r| { - debug_assert!(r.end <= self.rows_in_page); - let start = self.data_buf_position + r.start * total_bytes_per_value; - let end = self.data_buf_position + r.end * total_bytes_per_value; - start..end - }); + let (num_items, num_visible_items) = + if let Some(rep_levels) = repdef.repetition_levels.as_ref() { + // If there are rep levels there may be "invisible" items and we need to encode + // rep_levels.len() things which might be larger than data.num_values() + (rep_levels.len() as u64, data.num_values()) + } else { + // If there are no rep levels then we encode data.num_values() things + (data.num_values(), data.num_values()) + }; - // Request byte ranges - let data = io.submit_request(byte_ranges.collect(), self.priority); + let max_visible_def = repdef.max_visible_level.unwrap_or(u16::MAX); - let details = self.details.clone(); + let repdef_iter = build_control_word_iterator( + repdef.repetition_levels.as_deref(), + max_rep, + repdef.definition_levels.as_deref(), + max_def, + max_visible_def, + num_items as usize, + ); + let bits_rep = repdef_iter.bits_rep(); + let bits_def = repdef_iter.bits_def(); - let load_task = async move { - let data = data.await?; - let data = data - .into_iter() - .map(|d| LanceBuffer::from_bytes(d, 1)) - .collect(); - Ok(Box::new(FixedFullZipDecoder { - details, - data, - num_rows, - offset_in_current: 0, - bytes_per_value: bytes_per_value as usize, - total_bytes_per_value: total_bytes_per_value as usize, - }) as Box) - } - .boxed(); - let page_load_task = PageLoadTask { - decoder_fut: load_task, - num_rows, + // Full-zip requires byte-aligned values; expand 1-bit booleans to 1 byte each. + let data = match data { + DataBlock::FixedWidth(fixed) if fixed.bits_per_value == 1 => { + DataBlock::FixedWidth(Self::expand_boolean_to_bytes(fixed)) + } + other => other, }; - Ok(vec![page_load_task]) - } -} - -/// Cacheable state for FullZip encoding, storing the decoded repetition index -#[derive(Debug)] -struct FullZipCacheableState { - /// The raw repetition index buffer for future decoding - rep_index_buffer: LanceBuffer, -} - -impl DeepSizeOf for FullZipCacheableState { - fn deep_size_of_children(&self, _context: &mut Context) -> usize { - self.rep_index_buffer.len() - } -} - -impl CachedPageData for FullZipCacheableState { - fn as_arc_any(self: Arc) -> Arc { - self - } -} -impl StructuralPageScheduler for FullZipScheduler { - /// Initializes the scheduler. If there's a repetition index, loads and caches it. - /// Otherwise returns NoCachedPageData. - fn initialize<'a>( - &'a mut self, - io: &Arc, - ) -> BoxFuture<'a, Result>> { - // Check if caching is enabled and we have a repetition index - if self.enable_cache && self.rep_index.is_some() { - let rep_index = self.rep_index.as_ref().unwrap(); - // Calculate the total size of the repetition index - let total_size = (self.rows_in_page + 1) * rep_index.bytes_per_value; - let rep_index_range = rep_index.buf_position..(rep_index.buf_position + total_size); + let compressor = compression_strategy.create_per_value(field, &data)?; + let (compressed_data, value_encoding) = compressor.compress(data)?; - // Load the repetition index buffer - let io_clone = io.clone(); - let future = async move { - let rep_index_data = io_clone.submit_request(vec![rep_index_range], 0).await?; - let rep_index_buffer = LanceBuffer::from_bytes(rep_index_data[0].clone(), 1); + let description = match &compressed_data { + PerValueDataBlock::Fixed(fixed) => ProtobufUtils21::fixed_full_zip_layout( + bits_rep, + bits_def, + fixed.bits_per_value as u32, + value_encoding, + &repdef.def_meaning, + num_items as u32, + num_visible_items as u32, + ), + PerValueDataBlock::Variable(variable) => ProtobufUtils21::variable_full_zip_layout( + bits_rep, + bits_def, + variable.bits_per_offset as u32, + value_encoding, + &repdef.def_meaning, + num_items as u32, + num_visible_items as u32, + ), + }; - // Create and return the cacheable state - Ok(Arc::new(FullZipCacheableState { rep_index_buffer }) as Arc) - }; + let zipped = Self::serialize_full_zip(compressed_data, repdef_iter, num_items)?; - future.boxed() + let data = if let Some(repindex) = zipped.repetition_index { + vec![zipped.values, repindex] } else { - // Caching disabled or no repetition index, skip caching - std::future::ready(Ok(Arc::new(NoCachedPageData) as Arc)).boxed() - } - } - - /// Loads previously cached repetition index data from the cache system. - /// This method is called when a scheduler instance needs to use cached data - /// that was initialized by another instance or in a previous operation. - fn load(&mut self, cache: &Arc) { - // Try to downcast to our specific cache type - if let Ok(cached_state) = cache - .clone() - .as_arc_any() - .downcast::() - { - // Store the cached state for use in schedule_ranges - self.cached_state = Some(cached_state); - } - } + vec![zipped.values] + }; - fn schedule_ranges( - &self, - ranges: &[Range], - io: &Arc, - ) -> Result> { - if let Some(rep_index) = self.rep_index { - self.schedule_ranges_rep(ranges, io, rep_index) - } else { - self.schedule_ranges_simple(ranges, io.as_ref()) - } + Ok(EncodedPage { + num_rows: num_lists, + column_idx, + data, + description: PageEncoding::Structural(description), + row_number, + }) } -} -/// A decoder for full-zip encoded data when the data has a fixed-width -/// -/// Here we need to unzip the control words from the values themselves and -/// then decompress the requested values. -/// -/// We use a PerValueDecompressor because we will only be decompressing the -/// requested data. This decoder / scheduler does not do any read amplification. -#[derive(Debug)] -struct FixedFullZipDecoder { - details: Arc, - data: VecDeque, - offset_in_current: usize, - bytes_per_value: usize, - total_bytes_per_value: usize, - num_rows: u64, -} + fn should_dictionary_encode( + data_block: &DataBlock, + field: &Field, + fixed_width_dictionary_encoding: FixedWidthDictionaryEncoding, + ) -> Option { + const DEFAULT_SAMPLE_SIZE: usize = 4096; + const DEFAULT_SAMPLE_UNIQUE_RATIO: f64 = 0.98; -impl FixedFullZipDecoder { - fn slice_next_task(&mut self, num_rows: u64) -> FullZipDecodeTaskItem { - debug_assert!(num_rows > 0); - let cur_buf = self.data.front_mut().unwrap(); - let start = self.offset_in_current; - if self.details.ctrl_word_parser.has_rep() { - // This is a slightly slower path. In order to figure out where to split we need to - // examine the rep index so we can convert num_lists to num_rows - let mut rows_started = 0; - // We always need at least one value. Now loop through until we have passed num_rows - // values - let mut num_items = 0; - while self.offset_in_current < cur_buf.len() { - let control = self.details.ctrl_word_parser.parse_desc( - &cur_buf[self.offset_in_current..], - self.details.max_rep, - self.details.max_visible_def, - ); - if control.is_new_row { - if rows_started == num_rows { - break; - } - rows_started += 1; + // Since we only dictionary encode FixedWidth and VariableWidth blocks for now, we skip + // estimating the size for other types. + match data_block { + DataBlock::FixedWidth(fixed) => { + if fixed.bits_per_value == 64 + && fixed_width_dictionary_encoding == FixedWidthDictionaryEncoding::Exclude64Bit + { + return None; } - num_items += 1; - if control.is_visible { - self.offset_in_current += self.total_bytes_per_value; - } else { - self.offset_in_current += self.details.ctrl_word_parser.bytes_per_word(); + if fixed.bits_per_value != 64 && fixed.bits_per_value != 128 { + return None; + } + if fixed.bits_per_value % 8 != 0 { + return None; } } - - let task_slice = cur_buf.slice_with_length(start, self.offset_in_current - start); - if self.offset_in_current == cur_buf.len() { - self.data.pop_front(); - self.offset_in_current = 0; - } - - FullZipDecodeTaskItem { - data: PerValueDataBlock::Fixed(FixedWidthDataBlock { - data: task_slice, - bits_per_value: self.bytes_per_value as u64 * 8, - num_values: num_items, - block_info: BlockInfo::new(), - }), - rows_in_buf: rows_started, - } - } else { - // If there's no repetition we can calculate the slicing point by just multiplying - // the number of rows by the total bytes per value - let cur_buf = self.data.front_mut().unwrap(); - let bytes_avail = cur_buf.len() - self.offset_in_current; - let offset_in_cur = self.offset_in_current; - - let bytes_needed = num_rows as usize * self.total_bytes_per_value; - let mut rows_taken = num_rows; - let task_slice = if bytes_needed >= bytes_avail { - self.offset_in_current = 0; - rows_taken = bytes_avail as u64 / self.total_bytes_per_value as u64; - self.data - .pop_front() - .unwrap() - .slice_with_length(offset_in_cur, bytes_avail) - } else { - self.offset_in_current += bytes_needed; - cur_buf.slice_with_length(offset_in_cur, bytes_needed) - }; - FullZipDecodeTaskItem { - data: PerValueDataBlock::Fixed(FixedWidthDataBlock { - data: task_slice, - bits_per_value: self.bytes_per_value as u64 * 8, - num_values: rows_taken, - block_info: BlockInfo::new(), - }), - rows_in_buf: rows_taken, + DataBlock::VariableWidth(var) => { + if var.bits_per_offset != 32 && var.bits_per_offset != 64 { + return None; + } } + _ => return None, } - } -} -impl StructuralPageDecoder for FixedFullZipDecoder { - fn drain(&mut self, num_rows: u64) -> Result> { - let mut task_data = Vec::with_capacity(self.data.len()); - let mut remaining = num_rows; - while remaining > 0 { - let task_item = self.slice_next_task(remaining); - remaining -= task_item.rows_in_buf; - task_data.push(task_item); + // Don't dictionary encode tiny arrays. + let too_small = env::var("LANCE_ENCODING_DICT_TOO_SMALL") + .ok() + .and_then(|val| val.parse().ok()) + .unwrap_or(100); + if data_block.num_values() < too_small { + return None; } - Ok(Box::new(FixedFullZipDecodeTask { - details: self.details.clone(), - data: task_data, - bytes_per_value: self.bytes_per_value, - num_rows: num_rows as usize, - })) - } - fn num_rows(&self) -> u64 { - self.num_rows - } -} + let num_values = data_block.num_values(); -/// A decoder for full-zip encoded data when the data has a variable-width -/// -/// Here we need to unzip the control words AND lengths from the values and -/// then decompress the requested values. -#[derive(Debug)] -struct VariableFullZipDecoder { - details: Arc, - decompressor: Arc, - data: LanceBuffer, - offsets: LanceBuffer, - rep: ScalarBuffer, - def: ScalarBuffer, - repdef_starts: Vec, - data_starts: Vec, - offset_starts: Vec, - visible_item_counts: Vec, - bits_per_offset: u8, - current_idx: usize, - num_rows: u64, -} + // Apply divisor threshold and cap. This is intentionally conservative: the goal is to + // avoid spending too much CPU trying to estimate very high cardinalities. + let divisor: u64 = field + .metadata + .get(DICT_DIVISOR_META_KEY) + .and_then(|val| val.parse().ok()) + .or_else(|| { + env::var("LANCE_ENCODING_DICT_DIVISOR") + .ok() + .and_then(|val| val.parse().ok()) + }) + .unwrap_or(DEFAULT_DICT_DIVISOR); -impl VariableFullZipDecoder { - fn new( - details: Arc, - data: VecDeque, - num_rows: u64, - in_bits_per_length: u8, - out_bits_per_offset: u8, - ) -> Self { - let decompressor = match details.value_decompressor { - PerValueDecompressor::Variable(ref d) => d.clone(), - _ => unreachable!(), - }; + let max_cardinality: u64 = env::var("LANCE_ENCODING_DICT_MAX_CARDINALITY") + .ok() + .and_then(|val| val.parse().ok()) + .unwrap_or(DEFAULT_DICT_MAX_CARDINALITY); - assert_eq!(in_bits_per_length % 8, 0); - assert!(out_bits_per_offset == 32 || out_bits_per_offset == 64); + let threshold_cardinality = num_values + .checked_div(divisor.max(1)) + .unwrap_or(0) + .min(max_cardinality); + if threshold_cardinality == 0 { + return None; + } - let mut decoder = Self { - details, - decompressor, - data: LanceBuffer::empty(), - offsets: LanceBuffer::empty(), - rep: LanceBuffer::empty().borrow_to_typed_slice(), - def: LanceBuffer::empty().borrow_to_typed_slice(), - bits_per_offset: out_bits_per_offset, - repdef_starts: Vec::with_capacity(num_rows as usize + 1), - data_starts: Vec::with_capacity(num_rows as usize + 1), - offset_starts: Vec::with_capacity(num_rows as usize + 1), - visible_item_counts: Vec::with_capacity(num_rows as usize + 1), - current_idx: 0, - num_rows, - }; + // Get size ratio from metadata or env var. + let threshold_ratio = field + .metadata + .get(DICT_SIZE_RATIO_META_KEY) + .and_then(|val| val.parse::().ok()) + .or_else(|| { + env::var("LANCE_ENCODING_DICT_SIZE_RATIO") + .ok() + .and_then(|val| val.parse().ok()) + }) + .unwrap_or(DEFAULT_DICT_SIZE_RATIO); - // There's no great time to do this and this is the least worst time. If we don't unzip then - // we can't slice the data during the decode phase. This is because we need the offsets to be - // unpacked to know where the values start and end. - // - // We don't want to unzip on the decode thread because that is a single-threaded path - // We don't want to unzip on the scheduling thread because that is a single-threaded path - // - // Fortunately, we know variable length data will always be read indirectly and so we can do it - // here, which should be on the indirect thread. The primary disadvantage to doing it here is that - // we load all the data into memory and then throw it away only to load it all into memory again during - // the decode. - // - // There are some alternatives to investigate: - // - Instead of just reading the beginning and end of the rep index we could read the entire - // range in between. This will give us the break points that we need for slicing and won't increase - // the number of IOPs but it will mean we are doing more total I/O and we need to load the rep index - // even when doing a full scan. - // - We could force each decode task to do a full unzip of all the data. Each decode task now - // has to do more work but the work is all fused. - // - We could just try doing this work on the decode thread and see if it is a problem. - decoder.unzip(data, in_bits_per_length, out_bits_per_offset, num_rows); + if threshold_ratio <= 0.0 || threshold_ratio > 1.0 { + panic!( + "Invalid parameter: dict-size-ratio is {} which is not in the range (0, 1].", + threshold_ratio + ); + } + + let data_size = data_block.data_size(); + if data_size == 0 { + return None; + } - decoder - } + let max_encoded_size = (data_size as f64 * threshold_ratio) as u64; + let max_encoded_size = usize::try_from(max_encoded_size).ok()?; - unsafe fn parse_length(data: &[u8], bits_per_offset: u8) -> u64 { - match bits_per_offset { - 8 => *data.get_unchecked(0) as u64, - 16 => u16::from_le_bytes([*data.get_unchecked(0), *data.get_unchecked(1)]) as u64, - 32 => u32::from_le_bytes([ - *data.get_unchecked(0), - *data.get_unchecked(1), - *data.get_unchecked(2), - *data.get_unchecked(3), - ]) as u64, - 64 => u64::from_le_bytes([ - *data.get_unchecked(0), - *data.get_unchecked(1), - *data.get_unchecked(2), - *data.get_unchecked(3), - *data.get_unchecked(4), - *data.get_unchecked(5), - *data.get_unchecked(6), - *data.get_unchecked(7), - ]), - _ => unreachable!(), + // Avoid probing dictionary encoding on data that appears to be near-unique + // or likely to exceed the dictionary budget. + if let Some(sample_unique_ratio) = + Self::sample_unique_ratio(data_block, DEFAULT_SAMPLE_SIZE)? + { + if sample_unique_ratio >= DEFAULT_SAMPLE_UNIQUE_RATIO { + return None; + } + + let projected_cardinality = (sample_unique_ratio * num_values as f64).ceil() as u64; + if projected_cardinality > threshold_cardinality { + return None; + } } - } - fn unzip( - &mut self, - data: VecDeque, - in_bits_per_length: u8, - out_bits_per_offset: u8, - num_rows: u64, - ) { - // This undercounts if there are lists but, at this point, we don't really know how many items we have - let mut rep = Vec::with_capacity(num_rows as usize); - let mut def = Vec::with_capacity(num_rows as usize); - let bytes_cw = self.details.ctrl_word_parser.bytes_per_word() * num_rows as usize; + let max_dict_entries = u32::try_from(threshold_cardinality.min(i32::MAX as u64)).ok()?; + Some(DictEncodingBudget { + max_dict_entries, + max_encoded_size, + }) + } - // This undercounts if there are lists - // It can also overcount if there are invisible items - let bytes_per_offset = out_bits_per_offset as usize / 8; - let bytes_offsets = bytes_per_offset * (num_rows as usize + 1); - let mut offsets_data = Vec::with_capacity(bytes_offsets); + /// Samples whether a page looks near-unique before attempting dictionary encoding. + /// + /// The probe uses deterministic block sampling (not RNG sampling), which keeps + /// the check cheap and reproducible across runs. The result is only a gate for + /// whether we try dictionary encoding, not a cardinality statistic. + /// Returns `Some(None)` when there are too few reliable samples or the block type does not + /// support dictionary encoding. Returns `None` for malformed data. + fn sample_unique_ratio(data_block: &DataBlock, max_samples: usize) -> Option> { + use std::collections::HashSet; + + const NUM_SAMPLE_BLOCKS: usize = 32; + const MIN_RELIABLE_SAMPLES: usize = 1024; + + let num_values = usize::try_from(data_block.num_values()).ok()?; + if num_values == 0 { + return Some(None); + } - let bytes_per_length = in_bits_per_length as usize / 8; - let bytes_lengths = bytes_per_length * num_rows as usize; + let sample_count = num_values.min(max_samples).max(1); + if sample_count < MIN_RELIABLE_SAMPLES { + return Some(None); + } - let bytes_data = data.iter().map(|d| d.len()).sum::(); - // This overcounts since bytes_lengths and bytes_cw are undercounts - // It can also undercount if there are invisible items (hence the saturating_sub) - let mut unzipped_data = - Vec::with_capacity((bytes_data - bytes_cw).saturating_sub(bytes_lengths)); + let block_count = NUM_SAMPLE_BLOCKS.min(sample_count).min(num_values).max(1); + let samples_per_block = (sample_count / block_count).max(1); + let mut indices = Vec::with_capacity(sample_count); + for block_idx in 0..block_count { + let block_start = block_idx * num_values / block_count; + let next_block_start = ((block_idx + 1) * num_values / block_count).min(num_values); + let block_len = next_block_start.saturating_sub(block_start); + let samples_in_block = samples_per_block.min(block_len); + indices.extend((0..samples_in_block).map(|offset| block_start + offset)); + } - let mut current_offset = 0_u64; - let mut visible_item_count = 0_u64; - for databuf in data.into_iter() { - let mut databuf = databuf.as_ref(); - while !databuf.is_empty() { - let data_start = unzipped_data.len(); - let offset_start = offsets_data.len(); - // We might have only-rep or only-def, neither, or both. They move at the same - // speed though so we only need one index into it - let repdef_start = rep.len().max(def.len()); - // TODO: Kind of inefficient we parse the control word twice here - let ctrl_desc = self.details.ctrl_word_parser.parse_desc( - databuf, - self.details.max_rep, - self.details.max_visible_def, - ); - self.details - .ctrl_word_parser - .parse(databuf, &mut rep, &mut def); - databuf = &databuf[self.details.ctrl_word_parser.bytes_per_word()..]; + if indices.len() < MIN_RELIABLE_SAMPLES { + return Some(None); + } - if ctrl_desc.is_new_row { - self.repdef_starts.push(repdef_start); - self.data_starts.push(data_start); - self.offset_starts.push(offset_start); - self.visible_item_counts.push(visible_item_count); + let ratio = match data_block { + DataBlock::FixedWidth(fixed) => match fixed.bits_per_value { + 64 => { + let values = fixed.data.borrow_to_typed_slice::(); + let values = values.as_ref(); + let mut unique: HashSet = + HashSet::with_capacity(indices.len().min(MIN_RELIABLE_SAMPLES)); + for idx in indices.iter().copied() { + unique.insert(values.get(idx).copied()?); + } + unique.len() as f64 / indices.len() as f64 } - if ctrl_desc.is_visible { - visible_item_count += 1; - if ctrl_desc.is_valid_item { - // Safety: Data should have at least bytes_per_length bytes remaining - debug_assert!(databuf.len() >= bytes_per_length); - let length = unsafe { Self::parse_length(databuf, in_bits_per_length) }; - match out_bits_per_offset { - 32 => offsets_data - .extend_from_slice(&(current_offset as u32).to_le_bytes()), - 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), - _ => unreachable!(), - }; - databuf = &databuf[bytes_per_offset..]; - unzipped_data.extend_from_slice(&databuf[..length as usize]); - databuf = &databuf[length as usize..]; - current_offset += length; - } else { - // Null items still get an offset - match out_bits_per_offset { - 32 => offsets_data - .extend_from_slice(&(current_offset as u32).to_le_bytes()), - 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), - _ => unreachable!(), + 128 => { + let values = fixed.data.borrow_to_typed_slice::(); + let values = values.as_ref(); + let mut unique: HashSet = + HashSet::with_capacity(indices.len().min(MIN_RELIABLE_SAMPLES)); + for idx in indices.iter().copied() { + unique.insert(values.get(idx).copied()?); + } + unique.len() as f64 / indices.len() as f64 + } + _ => return Some(None), + }, + DataBlock::VariableWidth(var) => { + use xxhash_rust::xxh3::xxh3_64; + + // Hash variable-width slices instead of storing borrowed slice keys. + let mut unique: HashSet = + HashSet::with_capacity(indices.len().min(MIN_RELIABLE_SAMPLES)); + match var.bits_per_offset { + 32 => { + let offsets_ref = var.offsets.borrow_to_typed_slice::(); + let offsets: &[u32] = offsets_ref.as_ref(); + for i in indices.iter().copied() { + let start = usize::try_from(*offsets.get(i)?).ok()?; + let end = usize::try_from(*offsets.get(i + 1)?).ok()?; + if start > end || end > var.data.len() { + return None; + } + unique.insert(xxh3_64(&var.data[start..end])); + } + } + 64 => { + let offsets_ref = var.offsets.borrow_to_typed_slice::(); + let offsets: &[u64] = offsets_ref.as_ref(); + for i in indices.iter().copied() { + let start = usize::try_from(*offsets.get(i)?).ok()?; + let end = usize::try_from(*offsets.get(i + 1)?).ok()?; + if start > end || end > var.data.len() { + return None; + } + unique.insert(xxh3_64(&var.data[start..end])); } } + _ => return Some(None), } + unique.len() as f64 / indices.len() as f64 } - } - self.repdef_starts.push(rep.len().max(def.len())); - self.data_starts.push(unzipped_data.len()); - self.offset_starts.push(offsets_data.len()); - self.visible_item_counts.push(visible_item_count); - match out_bits_per_offset { - 32 => offsets_data.extend_from_slice(&(current_offset as u32).to_le_bytes()), - 64 => offsets_data.extend_from_slice(¤t_offset.to_le_bytes()), - _ => unreachable!(), + _ => return Some(None), }; - self.rep = ScalarBuffer::from(rep); - self.def = ScalarBuffer::from(def); - self.data = LanceBuffer::from(unzipped_data); - self.offsets = LanceBuffer::from(offsets_data); + + Some(Some(ratio)) } -} -impl StructuralPageDecoder for VariableFullZipDecoder { - fn drain(&mut self, num_rows: u64) -> Result> { - let start = self.current_idx; - let end = start + num_rows as usize; + fn slice_repdef(repdef: &SerializedRepDefs, range: Range) -> SerializedRepDefs { + let repetition_levels = repdef + .repetition_levels + .as_ref() + .map(|levels| levels[range.clone()].to_vec()); + let definition_levels = repdef + .definition_levels + .as_ref() + .map(|levels| levels[range].to_vec()); + SerializedRepDefs::new_with_fixed_size_list_levels( + repetition_levels, + definition_levels, + repdef.def_meaning.clone(), + repdef.has_fixed_size_list_levels(), + ) + } - // This might seem a little peculiar. We are returning the entire data for every single - // batch. This is because the offsets are relative to the start of the data. In other words - // imagine we have a data buffer that is 100 bytes long and the offsets are [0, 10, 20, 30, 40] - // and we return in batches of two. The second set of offsets will be [20, 30, 40]. - // - // So either we pay for a copy to normalize the offsets or we just return the entire data buffer - // which is slightly cheaper. - let data = self.data.clone(); + fn slice_arrays( + arrays: &[ArrayRef], + value_start: u64, + num_values: u64, + ) -> Result> { + if num_values == 0 { + return Ok(Vec::new()); + } - let offset_start = self.offset_starts[start]; - let offset_end = self.offset_starts[end] + (self.bits_per_offset as usize / 8); - let offsets = self - .offsets - .slice_with_length(offset_start, offset_end - offset_start); + let mut values_to_skip = usize::try_from(value_start).map_err(|_| { + Error::invalid_input(format!("Value start {} is too large", value_start)) + })?; + let mut values_remaining = usize::try_from(num_values).map_err(|_| { + Error::invalid_input(format!("Value count {} is too large", num_values)) + })?; + let mut sliced = Vec::new(); + + for array in arrays { + if values_to_skip >= array.len() { + values_to_skip -= array.len(); + continue; + } - let repdef_start = self.repdef_starts[start]; - let repdef_end = self.repdef_starts[end]; - let rep = if self.rep.is_empty() { - self.rep.clone() - } else { - self.rep.slice(repdef_start, repdef_end - repdef_start) + let offset = values_to_skip; + let len = (array.len() - offset).min(values_remaining); + sliced.push(array.slice(offset, len)); + values_remaining -= len; + values_to_skip = 0; + + if values_remaining == 0 { + break; + } + } + + if values_remaining != 0 { + return Err(Error::internal(format!( + "Page split requested {} values starting at {}, but the page did not contain enough values", + num_values, value_start + ))); + } + + Ok(sliced) + } + + fn split_pages_for_miniblock_repdef_budget( + arrays: Vec, + repdef: SerializedRepDefs, + budget: MiniBlockRepDefBudget, + row_number: u64, + num_rows: u64, + ) -> Result> { + if budget == MiniBlockRepDefBudget::WithinBudget { + return Ok(vec![PrimitivePageData { + arrays, + structure: PrimitivePageStructure::Dense { + repdef, + single_row_miniblock_repdef_levels: None, + }, + row_number, + num_rows, + }]); + } + if let MiniBlockRepDefBudget::SingleRowOverBudget(num_levels) = budget { + return Ok(vec![PrimitivePageData { + arrays, + structure: PrimitivePageStructure::Dense { + repdef, + single_row_miniblock_repdef_levels: Some(num_levels), + }, + row_number, + num_rows, + }]); + } + + let MiniBlockRepDefBudget::RequiresPageSplit(splits) = budget else { + unreachable!(); }; - let def = if self.def.is_empty() { - self.def.clone() - } else { - self.def.slice(repdef_start, repdef_end - repdef_start) + + let mut pages = Vec::with_capacity(splits.len()); + for split in splits { + let arrays = Self::slice_arrays(&arrays, split.value_start, split.num_values)?; + let repdef = Self::slice_repdef(&repdef, split.level_range); + pages.push(PrimitivePageData { + arrays, + structure: PrimitivePageStructure::Dense { + repdef, + single_row_miniblock_repdef_levels: None, + }, + row_number: row_number + split.row_start, + num_rows: split.num_rows, + }); + } + Ok(pages) + } + + fn encode_dense_page( + ctx: PrimitiveEncodeContext, + page: PrimitivePageData, + compression_strategy: Arc, + miniblock_chunk_size: MiniblockChunkSize, + complex_null_encoding: ComplexNullEncoding, + fixed_width_dictionary_encoding: FixedWidthDictionaryEncoding, + ) -> Result { + let PrimitiveEncodeContext { + column_idx, + field, + encoding_metadata, + is_simple_validity, + has_repdef_info, + } = ctx; + let PrimitivePageData { + arrays, + structure, + row_number, + num_rows, + } = page; + let num_values = arrays.iter().map(|arr| arr.len() as u64).sum(); + + let (repdef, single_row_miniblock_repdef_levels) = match structure { + PrimitivePageStructure::Dense { + repdef, + single_row_miniblock_repdef_levels, + } => (repdef, single_row_miniblock_repdef_levels), + PrimitivePageStructure::Sparse { .. } => { + unreachable!("dense atom received sparse page") + } }; - let visible_item_counts_start = self.visible_item_counts[start]; - let visible_item_counts_end = self.visible_item_counts[end]; - let num_visible_items = visible_item_counts_end - visible_item_counts_start; + if num_values == 0 { + // This page contains only structural events, such as empty/null list rows. + // The existing complex-null layout stores the rep/def stream without value buffers. + log::debug!( + "Encoding column {} with {} items ({} rows) using complex-null layout", + column_idx, + num_values, + num_rows + ); + return Self::encode_complex_all_null( + column_idx, + repdef, + row_number, + num_rows, + complex_null_encoding, + compression_strategy.as_ref(), + ); + } + + let leaf_validity = Self::leaf_validity(&repdef, num_values as usize)?; + let all_null = leaf_validity + .as_ref() + .map(|validity| validity.count_set_bits() == 0) + .unwrap_or(false); + + if all_null { + return if is_simple_validity { + log::debug!( + "Encoding column {} with {} items ({} rows) using simple-null layout", + column_idx, + num_values, + num_rows + ); + Self::encode_simple_all_null(column_idx, num_values, row_number) + } else { + log::debug!( + "Encoding column {} with {} items ({} rows) using complex-null layout", + column_idx, + num_values, + num_rows + ); + Self::encode_complex_all_null( + column_idx, + repdef, + row_number, + num_rows, + complex_null_encoding, + compression_strategy.as_ref(), + ) + }; + } + + if let DataType::Struct(fields) = &field.data_type() + && fields.is_empty() + { + if has_repdef_info { + return Err(Error::invalid_input_source(format!("Empty structs with rep/def information are not yet supported. The field {} is an empty struct that either has nulls or is in a list.", field.name).into())); + } + // This is maybe a little confusing but the reader should never look at this anyways and it + // seems like overkill to invent a new layout just for "empty structs". + return Self::encode_simple_all_null(column_idx, num_values, row_number); + } + + let data_block = DataBlock::from_arrays(&arrays, num_values); + + if let Some(num_levels) = single_row_miniblock_repdef_levels { + let requested_encoding = encoding_metadata + .get(STRUCTURAL_ENCODING_META_KEY) + .map(|requested| requested.to_lowercase()); + let fullzip_error = match &data_block { + // 1-bit booleans are widened to bytes inside `encode_full_zip`. + DataBlock::FixedWidth(fixed) + if fixed.bits_per_value != 1 && !fixed.bits_per_value.is_multiple_of(8) => + { + Some(format!( + "Full-zip fixed-width values must be byte aligned, got {} bits per value", + fixed.bits_per_value + )) + } + DataBlock::VariableWidth(variable) + if !variable.bits_per_offset.is_multiple_of(8) => + { + Some(format!( + "Full-zip variable-width offsets must be byte aligned, got {} bits per offset", + variable.bits_per_offset + )) + } + DataBlock::VariableWidth(variable) + if variable.bits_per_offset != 32 && variable.bits_per_offset != 64 => + { + Some(format!( + "Full-zip variable-width offsets must be 32 or 64 bits, got {} bits", + variable.bits_per_offset + )) + } + DataBlock::Dictionary(_) => { + Some("Full-zip does not encode dictionary data blocks directly".to_string()) + } + DataBlock::FixedSizeList(fsl) => match fsl.clone().try_into_flat() { + Some(flat) if flat.bits_per_value.is_multiple_of(8) => None, + Some(flat) => Some(format!( + "Full-zip fixed-size-list values must be byte aligned after flattening, got {} bits per value", + flat.bits_per_value + )), + None => Some( + "Full-zip fixed-size-list capability requires a flat fixed-width child" + .to_string(), + ), + }, + DataBlock::FixedWidth(_) | DataBlock::VariableWidth(_) | DataBlock::Struct(_) => { + None + } + other => Some(format!( + "Full-zip does not support value block type {}", + other.name() + )), + }; + match requested_encoding.as_deref() { + Some(STRUCTURAL_ENCODING_FULLZIP) => { + if let Some(reason) = fullzip_error { + return Err(Error::invalid_input_source(reason.into())); + } + return Self::encode_full_zip( + column_idx, + &field, + compression_strategy.as_ref(), + data_block, + repdef, + row_number, + num_rows, + ); + } + Some(STRUCTURAL_ENCODING_MINIBLOCK) | None => { + if requested_encoding.is_none() && fullzip_error.is_none() { + log::debug!( + "Encoding column {} with {} items using full-zip layout because mini-block cannot split the structural page", + column_idx, + num_values + ); + return Self::encode_full_zip( + column_idx, + &field, + compression_strategy.as_ref(), + data_block, + repdef, + row_number, + num_rows, + ); + } + return Err(Error::invalid_input_source( + format!( + "Mini-block cannot encode {} rep/def levels in one top-level row. \ + This usually means the row contains too much nested structure \ + for the current layout.", + num_levels + ) + .into(), + )); + } + _ => {} + } + } + + let requires_full_zip_packed_struct = + if let DataBlock::Struct(ref struct_data_block) = data_block { + struct_data_block.has_variable_width_child() + } else { + false + }; + + if requires_full_zip_packed_struct { + log::debug!( + "Encoding column {} with {} items using full-zip packed struct layout", + column_idx, + num_values + ); + return Self::encode_full_zip( + column_idx, + &field, + compression_strategy.as_ref(), + data_block, + repdef, + row_number, + num_rows, + ); + } - self.current_idx += num_rows as usize; + 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, + miniblock_chunk_size, + ); + } - Ok(Box::new(VariableFullZipDecodeTask { - details: self.details.clone(), - decompressor: self.decompressor.clone(), - data, - offsets, - bits_per_offset: self.bits_per_offset, - num_visible_items, - rep, - def, - })) - } + // 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, + fixed_width_dictionary_encoding, + ) + .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, + ) + }); - fn num_rows(&self) -> u64 { - self.num_rows + if let Some((indices_data_block, dictionary_data_block)) = dict_result { + Self::encode_miniblock( + column_idx, + &field, + compression_strategy.as_ref(), + indices_data_block, + repdef, + row_number, + Some(dictionary_data_block), + num_rows, + miniblock_chunk_size, + ) + } else if Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) { + log::debug!( + "Encoding column {} with {} items using mini-block layout", + column_idx, + num_values + ); + Self::encode_miniblock( + column_idx, + &field, + compression_strategy.as_ref(), + data_block, + repdef, + row_number, + None, + num_rows, + miniblock_chunk_size, + ) + } else if Self::prefers_fullzip(encoding_metadata.as_ref()) { + log::debug!( + "Encoding column {} with {} items using full-zip layout", + column_idx, + num_values + ); + Self::encode_full_zip( + column_idx, + &field, + compression_strategy.as_ref(), + data_block, + repdef, + row_number, + num_rows, + ) + } else { + Err(Error::invalid_input_source(format!("Cannot determine structural encoding for field {}. This typically indicates an invalid value of the field metadata key {}", field.name, STRUCTURAL_ENCODING_META_KEY).into())) + } } -} -#[derive(Debug)] -struct VariableFullZipDecodeTask { - details: Arc, - decompressor: Arc, - data: LanceBuffer, - offsets: LanceBuffer, - bits_per_offset: u8, - num_visible_items: u64, - rep: ScalarBuffer, - def: ScalarBuffer, -} + /// Rebuilds dictionary chunks whose values array is empty. + /// + /// Such chunks are entirely null and retain their key validity until rep/def has been + /// recorded. At flush time their keys are zeroed and attached to a neighboring non-empty + /// dictionary. If every chunk is empty, one non-null placeholder value makes key zero valid. + /// Rep/def retains the logical nullness, so these replacement keys are never exposed. + fn rebuild_empty_dictionary_chunks(arrays: Vec) -> Result> { + if !arrays.iter().any(|array| { + array + .as_any_dictionary_opt() + .is_some_and(|dictionary| dictionary.values().is_empty()) + }) { + return Ok(arrays); + } -impl DecodePageTask for VariableFullZipDecodeTask { - fn decode(self: Box) -> Result { - let block = VariableWidthBlock { - data: self.data, - offsets: self.offsets, - bits_per_offset: self.bits_per_offset, - num_values: self.num_visible_items, - block_info: BlockInfo::new(), + let zeroed = |data_type: &DataType, len: usize| { + new_null_array(data_type, len) + .to_data() + .into_builder() + .nulls(None) + .build() + .map(make_array) }; - let decomopressed = self.decompressor.decompress(block)?; - let rep = if self.rep.is_empty() { - None + let rebuild = + |keys: Vec, data_type: &DataType, values: ArrayRef| -> Result { + let keys = keys.iter().map(|keys| keys.as_ref()).collect::>(); + let keys = arrow_select::concat::concat(&keys)?; + let data = keys + .to_data() + .into_builder() + .data_type(data_type.clone()) + .child_data(vec![values.to_data()]) + .build()?; + Ok(make_array(data)) + }; + + let mut rebuilt = Vec::with_capacity(arrays.len()); + let mut pending_keys = Vec::with_capacity(arrays.len()); + let mut empty_data_type = None; + for array in arrays { + let Some(dictionary) = array.as_any_dictionary_opt() else { + return Err(Error::invalid_input_source( + "Cannot mix dictionary and non-dictionary chunks".into(), + )); + }; + if dictionary.values().is_empty() { + pending_keys.push(zeroed(dictionary.keys().data_type(), array.len())?); + empty_data_type.get_or_insert_with(|| array.data_type().clone()); + } else if pending_keys.is_empty() { + rebuilt.push(array); + } else { + pending_keys.push(make_array(dictionary.keys().to_data())); + rebuilt.push(rebuild( + std::mem::take(&mut pending_keys), + array.data_type(), + dictionary.values().clone(), + )?); + } + } + + if pending_keys.is_empty() { + return Ok(rebuilt); + } + if let Some(array) = rebuilt.pop() { + let Some(dictionary) = array.as_any_dictionary_opt() else { + return Err(Error::invalid_input_source( + "Cannot mix dictionary and non-dictionary chunks".into(), + )); + }; + let mut keys = Vec::with_capacity(pending_keys.len() + 1); + keys.push(make_array(dictionary.keys().to_data())); + keys.append(&mut pending_keys); + rebuilt.push(rebuild( + keys, + array.data_type(), + dictionary.values().clone(), + )?); } else { - Some(self.rep.to_vec()) + let data_type = empty_data_type.ok_or_else(|| { + Error::internal("Missing data type for an empty dictionary chunk") + })?; + let DataType::Dictionary(_, value_type) = &data_type else { + return Err(Error::internal(format!( + "Expected dictionary data type, got {data_type}" + ))); + }; + rebuilt.push(rebuild(pending_keys, &data_type, zeroed(value_type, 1)?)?); + } + Ok(rebuilt) + } + + // Creates encode tasks, consuming all buffered data + fn do_flush( + &mut self, + arrays: Vec, + repdefs: Vec, + row_number: u64, + num_rows: u64, + ) -> Result> { + let arrays = Self::rebuild_empty_dictionary_chunks(arrays)?; + DataBlock::validate_arrays(&arrays, &self.field.name)?; + let num_values = arrays.iter().map(|arr| arr.len() as u64).sum(); + let is_simple_validity = repdefs.iter().all(|rd| rd.is_simple_validity()); + let has_repdef_info = repdefs.iter().any(|rd| !rd.is_empty()); + let normalized = RepDefBuilder::normalize(repdefs); + let plan_ctx = PrimitivePlanContext { + column_idx: self.column_index, + field: &self.field, + encoding_metadata: &self.encoding_metadata, }; - let def = if self.def.is_empty() { - None - } else { - Some(self.def.to_vec()) + let mut pages = None; + for page_encoding in self.page_encodings.iter() { + if let Some(planned) = page_encoding.behavior.try_plan_pages( + &plan_ctx, + &arrays, + &normalized, + row_number, + num_rows, + num_values, + )? { + pages = Some(planned); + break; + } + } + let pages = pages.ok_or_else(|| { + Error::invalid_input_source( + format!( + "No primitive page planner supports field '{}'", + self.field.name + ) + .into(), + ) + })?; + + let mut tasks = Vec::with_capacity(pages.len()); + let ctx = PrimitiveEncodeContext { + column_idx: self.column_index, + field: self.field.clone(), + encoding_metadata: self.encoding_metadata.clone(), + is_simple_validity, + has_repdef_info, }; - let unraveler = RepDefUnraveler::new( - rep, - def, - self.details.def_meaning.clone(), - self.num_visible_items, - ); - Ok(DecodedPage { - data: decomopressed, - repdef: unraveler, - }) + for page in pages { + let ctx = ctx.clone(); + let page_encodings = self.page_encodings.clone(); + let task = + spawn_cpu(move || Self::encode_page(page_encodings.as_ref(), &ctx, page)).boxed(); + tasks.push(task); + } + Ok(tasks) } -} - -#[derive(Debug)] -struct FullZipDecodeTaskItem { - data: PerValueDataBlock, - rows_in_buf: u64, -} - -/// A task to unzip and decompress full-zip encoded data when that data -/// has a fixed-width. -#[derive(Debug)] -struct FixedFullZipDecodeTask { - details: Arc, - data: Vec, - num_rows: usize, - bytes_per_value: usize, -} -impl DecodePageTask for FixedFullZipDecodeTask { - fn decode(self: Box) -> Result { - // Multiply by 2 to make a stab at the size of the output buffer (which will be decompressed and thus bigger) - let estimated_size_bytes = self - .data - .iter() - .map(|task_item| task_item.data.data_size() as usize) - .sum::() - * 2; - let mut data_builder = - DataBlockBuilder::with_capacity_estimate(estimated_size_bytes as u64); - - if self.details.ctrl_word_parser.bytes_per_word() == 0 { - // Fast path, no need to unzip because there is no rep/def - // - // We decompress each buffer and add it to our output buffer - for task_item in self.data.into_iter() { - let PerValueDataBlock::Fixed(fixed_data) = task_item.data else { - unreachable!() - }; - let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor - else { - unreachable!() - }; - debug_assert_eq!(fixed_data.num_values, task_item.rows_in_buf); - let decompressed = decompressor.decompress(fixed_data, task_item.rows_in_buf)?; - data_builder.append(&decompressed, 0..task_item.rows_in_buf); + fn extract_validity_buf( + array: Arc, + repdef: &mut RepDefBuilder, + keep_original_array: bool, + ) -> Result> { + if let Some(validity) = array.nulls() { + if keep_original_array { + repdef.add_validity_bitmap(validity.clone()); + } else { + repdef.add_validity_bitmap(deep_copy_nulls(Some(validity)).unwrap()); } - - let unraveler = RepDefUnraveler::new( - None, - None, - self.details.def_meaning.clone(), - self.num_rows as u64, - ); - - Ok(DecodedPage { - data: data_builder.finish(), - repdef: unraveler, - }) + // Empty dictionaries have no valid key payload. Keep the validity until rep/def is + // grouped with the buffered page; `do_flush` then rebuilds the keys against either a + // neighboring values array or an all-empty-page placeholder. + if let Some(dictionary) = array.as_any_dictionary_opt() + && dictionary.values().is_empty() + { + return Ok(array); + } + let data_no_nulls = array.to_data().into_builder().nulls(None).build()?; + Ok(make_array(data_no_nulls)) } else { - // Slow path, unzipping needed - let mut rep = Vec::with_capacity(self.num_rows); - let mut def = Vec::with_capacity(self.num_rows); - - for task_item in self.data.into_iter() { - let PerValueDataBlock::Fixed(fixed_data) = task_item.data else { - unreachable!() - }; - let mut buf_slice = fixed_data.data.as_ref(); - let num_values = fixed_data.num_values as usize; - // We will be unzipping repdef in to `rep` and `def` and the - // values into `values` (which contains the compressed values) - let mut values = Vec::with_capacity( - fixed_data.data.len() - - (self.details.ctrl_word_parser.bytes_per_word() * num_values), - ); - let mut visible_items = 0; - for _ in 0..num_values { - // Extract rep/def - self.details - .ctrl_word_parser - .parse(buf_slice, &mut rep, &mut def); - buf_slice = &buf_slice[self.details.ctrl_word_parser.bytes_per_word()..]; - - let is_visible = def - .last() - .map(|d| *d <= self.details.max_visible_def) - .unwrap_or(true); - if is_visible { - // Extract value - values.extend_from_slice(buf_slice[..self.bytes_per_value].as_ref()); - buf_slice = &buf_slice[self.bytes_per_value..]; - visible_items += 1; - } - } + repdef.add_no_null(array.len()); + Ok(array) + } + } - // Finally, we decompress the values and add them to our output buffer - let values_buf = LanceBuffer::from(values); - let fixed_data = FixedWidthDataBlock { - bits_per_value: self.bytes_per_value as u64 * 8, - block_info: BlockInfo::new(), - data: values_buf, - num_values: visible_items, - }; - let PerValueDecompressor::Fixed(decompressor) = &self.details.value_decompressor - else { - unreachable!() - }; - let decompressed = decompressor.decompress(fixed_data, visible_items)?; - data_builder.append(&decompressed, 0..visible_items); + fn extract_validity( + mut array: Arc, + repdef: &mut RepDefBuilder, + keep_original_array: bool, + ) -> Result> { + match array.data_type() { + DataType::Null => { + repdef.add_validity_bitmap(NullBuffer::new(BooleanBuffer::new_unset(array.len()))); + Ok(array) } + DataType::Dictionary(_, _) => { + array = dict::normalize_dict_nulls(array)?; + array = dict::clear_out_of_range_null_keys(array)?; + Self::extract_validity_buf(array, repdef, keep_original_array) + } + // Extract our validity buf but NOT any child validity bufs. (they will be encoded in + // as part of the values). Note: for FSL we do not use repdef.add_fsl because we do + // NOT want to increase the repdef depth. + // + // This would be quite catasrophic for something like vector embeddings. Imagine we + // had thousands of vectors and some were null but no vector contained null items. If + // we treated the vectors (primitive FSL) like we treat structural FSL we would end up + // with a rep/def value for every single item in the vector. + _ => Self::extract_validity_buf(array, repdef, keep_original_array), + } + } +} - let repetition = if rep.is_empty() { None } else { Some(rep) }; - let definition = if def.is_empty() { None } else { Some(def) }; - - let unraveler = RepDefUnraveler::new( - repetition, - definition, - self.details.def_meaning.clone(), - self.num_rows as u64, - ); - let data = data_builder.finish(); - - Ok(DecodedPage { - data, - repdef: unraveler, - }) +impl PrimitivePageEncodingBehavior for RejectSparsePrimitiveEncoding { + fn validate_field(&self, field: &Field, metadata: &HashMap) -> Result<()> { + if metadata + .get(STRUCTURAL_ENCODING_META_KEY) + .is_some_and(|requested| requested.eq_ignore_ascii_case(STRUCTURAL_ENCODING_SPARSE)) + { + return Err(Error::invalid_input_source( + format!( + "Field '{}' requests sparse structural encoding, which is not enabled by the selected file format", + field.name + ) + .into(), + )); } + Ok(()) } } -#[derive(Debug)] -struct StructuralPrimitiveFieldSchedulingJob<'a> { - scheduler: &'a StructuralPrimitiveFieldScheduler, - ranges: Vec>, - page_idx: usize, - range_idx: usize, - global_row_offset: u64, +fn plan_dense_primitive_pages( + arrays: &[ArrayRef], + normalized: &NormalizedStructuralPlan, + row_number: u64, + num_rows: u64, + num_values: u64, +) -> Result> { + let (repdef, miniblock_repdef_budget) = normalized.serialize_with_miniblock_repdef_budget( + miniblock::max_repdef_levels_per_chunk, + num_rows, + num_values, + )?; + PrimitiveStructuralEncoder::split_pages_for_miniblock_repdef_budget( + arrays.to_vec(), + repdef, + miniblock_repdef_budget, + row_number, + num_rows, + ) } -impl<'a> StructuralPrimitiveFieldSchedulingJob<'a> { - pub fn new(scheduler: &'a StructuralPrimitiveFieldScheduler, ranges: Vec>) -> Self { - Self { - scheduler, - ranges, - page_idx: 0, - range_idx: 0, - global_row_offset: 0, +impl PrimitivePageEncodingBehavior for DenseU16PrimitiveEncoding { + fn try_plan_pages( + &self, + _ctx: &PrimitivePlanContext<'_>, + arrays: &[ArrayRef], + normalized: &NormalizedStructuralPlan, + row_number: u64, + num_rows: u64, + num_values: u64, + ) -> Result>> { + Ok(Some(plan_dense_primitive_pages( + arrays, normalized, row_number, num_rows, num_values, + )?)) + } + + fn try_encode_page( + &self, + ctx: &PrimitiveEncodeContext, + page: PrimitivePageData, + ) -> Result { + if !matches!(&page.structure, PrimitivePageStructure::Dense { .. }) { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); } + Ok(PrimitiveEncodeAttempt::Encoded( + PrimitiveStructuralEncoder::encode_dense_page( + ctx.clone(), + page, + self.compression.clone(), + MiniblockChunkSize::U16, + ComplexNullEncoding::RawLevels, + FixedWidthDictionaryEncoding::Exclude64Bit, + )?, + )) } } -impl StructuralSchedulingJob for StructuralPrimitiveFieldSchedulingJob<'_> { - fn schedule_next(&mut self, context: &mut SchedulerContext) -> Result> { - if self.range_idx >= self.ranges.len() { - return Ok(Vec::new()); +impl PrimitivePageEncodingBehavior for DenseU32PrimitiveEncoding { + fn try_plan_pages( + &self, + _ctx: &PrimitivePlanContext<'_>, + arrays: &[ArrayRef], + normalized: &NormalizedStructuralPlan, + row_number: u64, + num_rows: u64, + num_values: u64, + ) -> Result>> { + Ok(Some(plan_dense_primitive_pages( + arrays, normalized, row_number, num_rows, num_values, + )?)) + } + + fn try_encode_page( + &self, + ctx: &PrimitiveEncodeContext, + page: PrimitivePageData, + ) -> Result { + if !matches!(&page.structure, PrimitivePageStructure::Dense { .. }) { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); } - // Get our current range - let mut range = self.ranges[self.range_idx].clone(); - let priority = range.start; + Ok(PrimitiveEncodeAttempt::Encoded( + PrimitiveStructuralEncoder::encode_dense_page( + ctx.clone(), + page, + self.compression.clone(), + MiniblockChunkSize::U32, + ComplexNullEncoding::CompressedLevels, + FixedWidthDictionaryEncoding::Include64Bit, + )?, + )) + } +} - let mut cur_page = &self.scheduler.page_schedulers[self.page_idx]; - trace!( - "Current range is {:?} and current page has {} rows", - range, - cur_page.num_rows - ); - // Skip entire pages until we have some overlap with our next range - while cur_page.num_rows + self.global_row_offset <= range.start { - self.global_row_offset += cur_page.num_rows; - self.page_idx += 1; - trace!("Skipping entire page of {} rows", cur_page.num_rows); - cur_page = &self.scheduler.page_schedulers[self.page_idx]; - } - - // Now the cur_page has overlap with range. Continue looping through ranges - // until we find a range that exceeds the current page - - let mut ranges_in_page = Vec::new(); - while cur_page.num_rows + self.global_row_offset > range.start { - range.start = range.start.max(self.global_row_offset); - let start_in_page = range.start - self.global_row_offset; - let end_in_page = start_in_page + (range.end - range.start); - let end_in_page = end_in_page.min(cur_page.num_rows); - let last_in_range = (end_in_page + self.global_row_offset) >= range.end; - - ranges_in_page.push(start_in_page..end_in_page); - if last_in_range { - self.range_idx += 1; - if self.range_idx == self.ranges.len() { - break; - } - range = self.ranges[self.range_idx].clone(); - } else { - break; +impl PrimitivePageEncodingBehavior for SparsePrimitiveEncoding { + fn try_plan_pages( + &self, + ctx: &PrimitivePlanContext<'_>, + arrays: &[ArrayRef], + normalized: &NormalizedStructuralPlan, + row_number: u64, + num_rows: u64, + num_values: u64, + ) -> Result>> { + let requested_encoding = ctx.encoding_metadata.get(STRUCTURAL_ENCODING_META_KEY); + let requests_sparse = requested_encoding + .is_some_and(|requested| requested.eq_ignore_ascii_case(STRUCTURAL_ENCODING_SPARSE)); + if requests_sparse { + let plan = sparse::writer::plan(normalized, num_values)?; + if sparse::writer::uses_constant_layout(&plan, ctx.field) { + return Ok(None); } + return Ok(Some(vec![PrimitivePageData { + arrays: arrays.to_vec(), + structure: PrimitivePageStructure::Sparse { + plan, + prepared_values: None, + }, + row_number, + num_rows, + }])); } - trace!( - "Scheduling {} rows across {} ranges from page with {} rows (priority={}, column_index={}, page_index={})", - ranges_in_page.iter().map(|r| r.end - r.start).sum::(), - ranges_in_page.len(), - cur_page.num_rows, - priority, - self.scheduler.column_index, - cur_page.page_index, - ); - - self.global_row_offset += cur_page.num_rows; - self.page_idx += 1; - - let page_decoders = cur_page - .scheduler - .schedule_ranges(&ranges_in_page, context.io())?; - - let cur_path = context.current_path(); - page_decoders - .into_iter() - .map(|page_load_task| { - let cur_path = cur_path.clone(); - let page_decoder = page_load_task.decoder_fut; - let unloaded_page = async move { - let page_decoder = page_decoder.await?; - Ok(LoadedPageShard { - decoder: page_decoder, - path: cur_path, - }) + let (_, miniblock_repdef_budget) = normalized.serialize_with_miniblock_repdef_budget( + miniblock::max_repdef_levels_per_chunk, + num_rows, + num_values, + )?; + let automatic_sparse = layout::select_automatic_sparse( + requested_encoding.map(String::as_str), + &miniblock_repdef_budget, + || { + let data = DataBlock::from_arrays(arrays, num_values); + if !sparse::writer::supports_value_block(&data) { + return Ok(None); } - .boxed(); - Ok(ScheduledScanLine { - decoders: vec![MessageType::UnloadedPage(UnloadedPageShard(unloaded_page))], - rows_scheduled: page_load_task.num_rows, - }) - }) - .collect::>>() + let prepared_values = match sparse::writer::prepare_values( + ctx.field, + self.compression.as_ref(), + data, + MiniblockChunkSize::U32, + ) { + Ok(prepared_values) => prepared_values, + Err(error) => { + debug!( + "Keeping column {} on its dense structural path because sparse value preparation is unavailable: {}", + ctx.column_idx, error + ); + return Ok(None); + } + }; + let plan = sparse::writer::plan(normalized, num_values)?; + if sparse::writer::uses_constant_layout(&plan, ctx.field) { + return Ok(None); + } + Ok(Some((plan, prepared_values))) + }, + )?; + Ok(automatic_sparse.map(|(plan, prepared_values)| { + vec![PrimitivePageData { + arrays: arrays.to_vec(), + structure: PrimitivePageStructure::Sparse { + plan, + prepared_values: Some(prepared_values), + }, + row_number, + num_rows, + }] + })) } -} - -#[derive(Debug)] -struct PageInfoAndScheduler { - page_index: usize, - num_rows: u64, - scheduler: Box, -} -/// A scheduler for a leaf node -/// -/// Here we look at the layout of the various pages and delegate scheduling to a scheduler -/// appropriate for the layout of the page. -#[derive(Debug)] -pub struct StructuralPrimitiveFieldScheduler { - page_schedulers: Vec, - column_index: u32, + fn try_encode_page( + &self, + ctx: &PrimitiveEncodeContext, + page: PrimitivePageData, + ) -> Result { + if !matches!(&page.structure, PrimitivePageStructure::Sparse { .. }) { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); + } + let PrimitivePageData { + arrays, + structure: + PrimitivePageStructure::Sparse { + plan, + prepared_values, + }, + row_number, + num_rows, + } = page + else { + unreachable!() + }; + let num_values = arrays.iter().map(|array| array.len() as u64).sum(); + log::debug!( + "Encoding column {} with {} visible items ({} rows) using sparse layout", + ctx.column_idx, + num_values, + num_rows + ); + Ok(PrimitiveEncodeAttempt::Encoded( + sparse::writer::encode_page( + ctx.column_idx, + &ctx.field, + self.compression.as_ref(), + prepared_values.map_or_else( + || { + sparse::writer::SparseValueInput::Unprepared(DataBlock::from_arrays( + &arrays, num_values, + )) + }, + sparse::writer::SparseValueInput::Prepared, + ), + plan, + row_number, + num_rows, + MiniblockChunkSize::U32, + )?, + )) + } } -impl StructuralPrimitiveFieldScheduler { - pub fn try_new( - column_info: &ColumnInfo, - decompressors: &dyn DecompressionStrategy, - cache_repetition_index: bool, - target_field: &Field, - ) -> Result { - let page_schedulers = column_info - .page_infos - .iter() - .enumerate() - .map(|(page_index, page_info)| { - Self::page_info_to_scheduler( - page_info, - page_index, - decompressors, - cache_repetition_index, - target_field, - ) - }) - .collect::>>()?; - Ok(Self { - page_schedulers, - column_index: column_info.index, - }) +impl PrimitivePageEncodingBehavior for ConstantPrimitiveEncoding { + fn try_encode_page( + &self, + ctx: &PrimitiveEncodeContext, + page: PrimitivePageData, + ) -> Result { + let PrimitivePageStructure::Dense { repdef, .. } = &page.structure else { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); + }; + let num_values: u64 = page.arrays.iter().map(|array| array.len() as u64).sum(); + if num_values == 0 { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); + } + let leaf_validity = PrimitiveStructuralEncoder::leaf_validity(repdef, num_values as usize)?; + if leaf_validity + .as_ref() + .is_some_and(|validity| validity.count_set_bits() == 0) + || matches!(ctx.field.data_type(), DataType::Struct(fields) if fields.is_empty()) + { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); + } + let Some(scalar) = + PrimitiveStructuralEncoder::find_constant_scalar(&page.arrays, leaf_validity.as_ref())? + else { + return Ok(PrimitiveEncodeAttempt::Unhandled(page)); + }; + let PrimitivePageData { + structure: PrimitivePageStructure::Dense { repdef, .. }, + row_number, + num_rows, + .. + } = page + else { + unreachable!() + }; + log::debug!( + "Encoding column {} with {} items ({} rows) using constant layout", + ctx.column_idx, + num_values, + num_rows + ); + Ok(PrimitiveEncodeAttempt::Encoded( + constant::encode_constant_page(ctx.column_idx, scalar, repdef, row_number, num_rows)?, + )) } +} - fn page_layout_to_scheduler( - page_info: &PageInfo, - page_layout: &PageLayout, - decompressors: &dyn DecompressionStrategy, - cache_repetition_index: bool, - target_field: &Field, - ) -> Result> { - use pb21::page_layout::Layout; - Ok(match page_layout.layout.as_ref().expect_ok()? { - Layout::MiniBlockLayout(mini_block) => Box::new(MiniBlockScheduler::try_new( - &page_info.buffer_offsets_and_sizes, - page_info.priority, - mini_block.num_items, - mini_block, - decompressors, - )?), - Layout::FullZipLayout(full_zip) => { - let mut scheduler = FullZipScheduler::try_new( - &page_info.buffer_offsets_and_sizes, - page_info.priority, - page_info.num_rows, - full_zip, - decompressors, - )?; - scheduler.enable_cache = cache_repetition_index; - Box::new(scheduler) - } - Layout::ConstantLayout(constant_layout) => { - let def_meaning = constant_layout - .layers - .iter() - .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) - .collect::>(); - let has_scalar_value = constant_layout.inline_value.is_some() - || page_info.buffer_offsets_and_sizes.len() == 1 - || page_info.buffer_offsets_and_sizes.len() == 3; - if has_scalar_value { - Box::new(constant::ConstantPageScheduler::try_new( - page_info.buffer_offsets_and_sizes.clone(), - constant_layout.inline_value.clone(), - target_field.data_type(), - def_meaning.into(), - )?) as Box - } else if def_meaning.len() == 1 - && def_meaning[0] == DefinitionInterpretation::NullableItem - { - Box::new(SimpleAllNullScheduler::default()) as Box - } else { - Box::new(ComplexAllNullScheduler::new( - page_info.buffer_offsets_and_sizes.clone(), - def_meaning.into(), - )) as Box - } - } - Layout::BlobLayout(blob) => { - let inner_scheduler = Self::page_layout_to_scheduler( - page_info, - blob.inner_layout.as_ref().expect_ok()?.as_ref(), - decompressors, - cache_repetition_index, - target_field, - )?; - let def_meaning = blob - .layers - .iter() - .map(|l| ProtobufUtils21::repdef_layer_to_def_interp(*l)) - .collect::>(); - if matches!(target_field.data_type(), DataType::Struct(_)) { - // User wants to decode blob into struct - Box::new(BlobDescriptionPageScheduler::new( - inner_scheduler, - def_meaning.into(), - )) - } else { - // User wants to decode blob into binary data - Box::new(BlobPageScheduler::new( - inner_scheduler, - page_info.priority, - page_info.num_rows, - def_meaning.into(), - )) - } - } - }) +impl FieldEncoder for PrimitiveStructuralEncoder { + // Buffers data, if there is enough to write a page then we create an encode task + fn maybe_encode( + &mut self, + array: ArrayRef, + _external_buffers: &mut OutOfLineBuffers, + mut repdef: RepDefBuilder, + row_number: u64, + num_rows: u64, + ) -> Result> { + let array = Self::extract_validity(array, &mut repdef, self.keep_original_array)?; + self.accumulated_repdefs.push(repdef); + + if let Some((arrays, row_number, num_rows)) = + self.accumulation_queue.insert(array, row_number, num_rows) + { + let accumulated_repdefs = std::mem::take(&mut self.accumulated_repdefs); + Ok(self.do_flush(arrays, accumulated_repdefs, row_number, num_rows)?) + } else { + Ok(vec![]) + } } - fn page_info_to_scheduler( - page_info: &PageInfo, - page_index: usize, - decompressors: &dyn DecompressionStrategy, - cache_repetition_index: bool, - target_field: &Field, - ) -> Result { - let page_layout = page_info.encoding.as_structural(); - let scheduler = Self::page_layout_to_scheduler( - page_info, - page_layout, - decompressors, - cache_repetition_index, - target_field, - )?; - Ok(PageInfoAndScheduler { - page_index, - num_rows: page_info.num_rows, - scheduler, - }) + // If there is any data left in the buffer then create an encode task from it + fn flush(&mut self, _external_buffers: &mut OutOfLineBuffers) -> Result> { + if let Some((arrays, row_number, num_rows)) = self.accumulation_queue.flush() { + let accumulated_repdefs = std::mem::take(&mut self.accumulated_repdefs); + Ok(self.do_flush(arrays, accumulated_repdefs, row_number, num_rows)?) + } else { + Ok(vec![]) + } } -} -pub trait CachedPageData: Any + Send + Sync + DeepSizeOf + 'static { - fn as_arc_any(self: Arc) -> Arc; + fn num_columns(&self) -> u32 { + 1 + } + + fn finish( + &mut self, + _external_buffers: &mut OutOfLineBuffers, + ) -> BoxFuture<'_, Result>> { + std::future::ready(Ok(vec![EncodedColumn::default()])).boxed() + } } -pub struct NoCachedPageData; +#[cfg(test)] +#[allow(clippy::single_range_in_vec_init)] +mod tests { + use super::{ + CachedPage, CachedPageData, ChunkInstructions, DataBlock, DecodeMiniBlockTask, + DecodePageTask, FixedFullZipDecodeTask, FixedPerValueDecompressor, FixedWidthDataBlock, + FixedWidthDictionaryEncoding, FullZipCacheableState, FullZipDecodeDetails, + FullZipDecodeTaskItem, FullZipReadSource, FullZipRepIndexDetails, FullZipScheduler, + LazyLevels, LevelCodec, LevelCursor, LevelPlan, MiniBlockChunk, MiniBlockChunkIndex, + MiniBlockCompressed, MiniblockChunkSize, PageCacheView, PageDataCacheKey, + PageInfoAndScheduler, PageInitialization, PerValueDataBlock, PerValueDecompressor, + PreambleAction, RunEndsBuilder, RunPosition, RunStorage, SimpleAllNullScheduler, + StructuralPageScheduler, StructuralPrimitiveFieldScheduler, VariableFullZipDecoder, + dense_levels_from_block, map_ranges_to_pages, pages_overlapping_ranges, + validate_complex_all_null_levels, + }; + use crate::EncodingsIo; + use crate::buffer::LanceBuffer; + use crate::compression::{ + BlockCompressor, DefaultDecompressionStrategy, MiniBlockDecompressor, + }; + use crate::constants::{ + COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY, DICT_VALUES_COMPRESSION_LEVEL_META_KEY, + DICT_VALUES_COMPRESSION_META_KEY, STRUCTURAL_ENCODING_META_KEY, + STRUCTURAL_ENCODING_MINIBLOCK, + }; + use crate::data::BlockInfo; + use crate::decoder::{ + FilterExpression, PageEncoding, RequestBatch, SchedulerContext, StructuralFieldDecoder, + StructuralFieldScheduler, + }; + use crate::encodings::logical::primitive::fullzip::PerValueCompressor; + use crate::encodings::logical::primitive::{ + ChunkDrainInstructions, LoadedChunk, PrimitiveStructuralEncoder, + StructuralPrimitiveFieldDecoder, + }; + use crate::encodings::physical::rle::{RleDecompressor, RleEncoder, RleRuns, RunLengthWidth}; + use crate::encodings::physical::value::{ValueDecompressor, ValueEncoder}; + use crate::format::ProtobufUtils21; + use crate::format::pb21; + use crate::format::pb21::compressive_encoding::Compression; + use crate::repdef::build_control_word_iterator; + use crate::testing::TestEncoding; + use crate::testing::{SimulatedScheduler, TestCases, check_round_trip_encoding_of_data}; + use arrow_array::{ + Array, ArrayRef, DictionaryArray, FixedSizeListArray, Float32Array, Int8Array, + PrimitiveArray, StringArray, UInt8Array, make_array, new_null_array, types::Int32Type, + }; + use arrow_buffer::{BooleanBuffer, NullBuffer, ScalarBuffer}; + use arrow_schema::{DataType, Field as ArrowField}; + use std::collections::HashMap; + use std::{collections::VecDeque, ops::Range, sync::Arc}; -impl DeepSizeOf for NoCachedPageData { - fn deep_size_of_children(&self, _ctx: &mut Context) -> usize { - 0 + #[derive(Debug)] + struct CacheMarker(u8); + + impl lance_core::cache::DeepSizeOf for CacheMarker { + fn deep_size_of_children(&self, _context: &mut lance_core::cache::Context) -> usize { + 0 + } } -} -impl CachedPageData for NoCachedPageData { - fn as_arc_any(self: Arc) -> Arc { - self + + impl CachedPageData for CacheMarker { + fn as_arc_any(self: Arc) -> Arc { + self + } } -} -pub struct CachedFieldData { - pages: Vec>, -} + #[test] + fn test_is_narrow() { + let int8_array = Int8Array::from(vec![1, 2, 3]); + let array_ref: ArrayRef = Arc::new(int8_array); + let block = DataBlock::from_array(array_ref); -impl DeepSizeOf for CachedFieldData { - fn deep_size_of_children(&self, ctx: &mut Context) -> usize { - self.pages.deep_size_of_children(ctx) + assert!(PrimitiveStructuralEncoder::is_narrow(&block)); + + let string_array = StringArray::from(vec![Some("hello"), Some("world")]); + let block = DataBlock::from_array(string_array); + assert!(PrimitiveStructuralEncoder::is_narrow(&block)); + + let string_array = StringArray::from(vec![ + Some("hello world".repeat(100)), + Some("world".to_string()), + ]); + let block = DataBlock::from_array(string_array); + assert!((!PrimitiveStructuralEncoder::is_narrow(&block))); } -} -// Cache key for field data -#[derive(Debug, Clone)] -pub struct FieldDataCacheKey { - pub column_index: u32, -} + fn valued_dictionary() -> ArrayRef { + Arc::new( + DictionaryArray::::try_new( + PrimitiveArray::::from(vec![0]), + Arc::new(StringArray::from(vec!["a"])), + ) + .unwrap(), + ) + } -impl CacheKey for FieldDataCacheKey { - type ValueType = CachedFieldData; + fn empty_dictionary() -> ArrayRef { + new_null_array( + &DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)), + 1, + ) + } - fn key(&self) -> std::borrow::Cow<'_, str> { - self.column_index.to_string().into() + fn null_valued_dictionary() -> ArrayRef { + Arc::new( + DictionaryArray::::try_new( + PrimitiveArray::::from(vec![0]), + Arc::new(StringArray::from(vec![None::<&str>])), + ) + .unwrap(), + ) } -} -impl StructuralFieldScheduler for StructuralPrimitiveFieldScheduler { - fn initialize<'a>( - &'a mut self, - _filter: &'a FilterExpression, - context: &'a SchedulerContext, - ) -> BoxFuture<'a, Result<()>> { - let cache_key = FieldDataCacheKey { - column_index: self.column_index, + fn sliced_empty_dictionary_with_hidden_key_payload() -> ArrayRef { + let keys = PrimitiveArray::::new( + ScalarBuffer::from(vec![5, 7, 9]), + Some(NullBuffer::new(BooleanBuffer::new_unset(3))), + ); + let values = Arc::new(StringArray::from(Vec::<&str>::new())) as ArrayRef; + let dictionary = DictionaryArray::::try_new(keys, values).unwrap(); + Arc::new(dictionary.slice(1, 1)) + } + + #[rstest::rstest] + #[case::empty_after_value(vec![valued_dictionary(), empty_dictionary()])] + #[case::empty_before_value(vec![empty_dictionary(), valued_dictionary()])] + #[case::null_value_after_value(vec![valued_dictionary(), null_valued_dictionary()])] + #[case::hidden_sliced_after_value(vec![ + valued_dictionary(), + sliced_empty_dictionary_with_hidden_key_payload(), + ])] + #[tokio::test] + async fn test_mixed_valued_and_all_null_dictionary_chunks(#[case] dictionaries: Vec) { + check_round_trip_encoding_of_data( + dictionaries, + &TestCases::default() + .with_structural_encodings() + .with_page_sizes(vec![1]) + .with_range(0..1) + .with_range(1..2) + .with_indices(vec![0]) + .with_indices(vec![1]), + HashMap::new(), + ) + .await; + } + + #[tokio::test] + async fn test_sliced_empty_dictionary_with_hidden_key_payload() { + check_round_trip_encoding_of_data( + vec![sliced_empty_dictionary_with_hidden_key_payload()], + &TestCases::default() + .with_structural_encodings() + .with_page_sizes(vec![4096]), + HashMap::new(), + ) + .await; + } + + #[test] + fn test_primitive_decoder_empty_page_queue_returns_error() { + let field = Arc::new(ArrowField::new("vector", DataType::Float32, true)); + let mut decoder = StructuralPrimitiveFieldDecoder::new(&field, false); + + let err = decoder.drain(1).unwrap_err(); + assert!( + matches!(&err, lance_core::Error::Internal { .. }), + "expected internal error, got: {err:?}" + ); + let message = err.to_string(); + for expected in [ + "Primitive decoder missing page decoder", + "field 'vector'", + "data_type=Float32", + "requested_rows=1", + "remaining_rows=1", + "rows_drained_in_current=0", + "queued_pages=0", + ] { + assert!( + message.contains(expected), + "expected error to contain {expected:?}, got: {message}" + ); + } + } + + #[test] + fn test_plan_decoded_bytes_i32() { + use crate::decoder::CANDIDATE_BATCH_SIZES; + + let field = Arc::new(ArrowField::new("x", DataType::Int32, false)); + let decoder = StructuralPrimitiveFieldDecoder::new(&field, false); + + let rows_remaining = 100u64; + let bytes = decoder.plan_decoded_bytes(rows_remaining).unwrap(); + + for (i, &candidate) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (candidate as u64).min(rows_remaining); + assert_eq!(bytes[i], rows * 4, "candidate batch size {candidate}"); + } + } + + #[test] + fn test_plan_decoded_bytes_i32_with_nulls() { + use arrow_array::Int32Array; + + // Use N == CANDIDATE_BATCH_SIZES[5] == 1024. At this size both the + // values buffer (1024*4 = 4096 bytes) and the validity bitmap + // (ceil(1024/8) = 128 bytes) are naturally aligned to Arrow's 64-byte + // allocation boundary, so plan_decoded_bytes matches + // get_buffer_memory_size exactly without needing alignment arithmetic. + const N: i32 = 1024; + let array = Int32Array::from_iter((0..N).map(|i| if i % 2 == 0 { Some(i) } else { None })); + let expected = array.get_buffer_memory_size() as u64; + + let field = Arc::new(ArrowField::new("x", DataType::Int32, true)); + let decoder = StructuralPrimitiveFieldDecoder::new(&field, false); + + let bytes = decoder.plan_decoded_bytes(N as u64).unwrap(); + // bytes[5] corresponds to CANDIDATE_BATCH_SIZES[5] == 1024 == N + assert_eq!( + bytes[5], expected, + "plan_decoded_bytes({N}) = {} but get_buffer_memory_size = {expected}", + bytes[5], + ); + } + + #[test] + fn test_plan_decoded_bytes_nullable_fsl_with_nullable_items() { + // FixedSizeList where the FSL itself is also nullable. + // Arrow allocates three buffers: + // 1. FSL null bitmap: ceil(rows / 8) + // 2. Child i32 null bitmap: ceil(rows * DIM / 8) + // 3. Child i32 values: rows * DIM * 4 + // + // N=1024, DIM=4 keeps all three naturally 64-byte aligned so the + // comparison against get_buffer_memory_size() is exact. + use arrow_array::{FixedSizeListArray, Int32Array}; + use arrow_buffer::NullBuffer; + + const N: usize = 1024; + const DIM: i32 = 4; + + let child_values = Int32Array::from_iter( + (0..(N as i32 * DIM)).map(|i| if i % 2 == 0 { Some(i) } else { None }), + ); + let item_field = Arc::new(ArrowField::new("item", DataType::Int32, true)); + let fsl_nulls = NullBuffer::from((0..N).map(|i| i % 3 != 0).collect::>()); + let fsl_array = FixedSizeListArray::new( + item_field.clone(), + DIM, + Arc::new(child_values), + Some(fsl_nulls), + ); + let expected = fsl_array.get_buffer_memory_size() as u64; + + let fsl_type = DataType::FixedSizeList(item_field, DIM); + let field = Arc::new(ArrowField::new("v", fsl_type, true)); + let decoder = StructuralPrimitiveFieldDecoder::new(&field, false); + + let bytes = decoder.plan_decoded_bytes(N as u64).unwrap(); + // bytes[5] corresponds to CANDIDATE_BATCH_SIZES[5] == 1024 == N + assert_eq!( + bytes[5], expected, + "plan_decoded_bytes({N}) = {} but get_buffer_memory_size = {expected}", + bytes[5], + ); + } + + #[test] + fn test_plan_decoded_bytes_fixed_size_list_of_f32() { + use crate::decoder::CANDIDATE_BATCH_SIZES; + + const DIMENSION: i32 = 8; + // Items are non-nullable, which is the typical case for vector embeddings. + let item_field = Arc::new(ArrowField::new("item", DataType::Float32, false)); + let fsl_type = DataType::FixedSizeList(item_field, DIMENSION); + let field = Arc::new(ArrowField::new("vector", fsl_type, true)); + let decoder = StructuralPrimitiveFieldDecoder::new(&field, false); + + let rows_remaining = 100u64; + let bytes = decoder.plan_decoded_bytes(rows_remaining).unwrap(); + + // Each FSL row is DIMENSION f32 values = DIMENSION * 4 bytes, plus + // 1 bit of validity bitmap (the field is nullable). + let bytes_per_row = DIMENSION as u64 * 4; + for (i, &candidate) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (candidate as u64).min(rows_remaining); + assert_eq!( + bytes[i], + rows * bytes_per_row + rows.div_ceil(8), + "candidate batch size {candidate}" + ); + } + } + + #[test] + fn test_fullzip_fixed_rejects_non_byte_aligned_values() { + let fixed = FixedWidthDataBlock { + data: LanceBuffer::from(vec![0_u8]), + bits_per_value: 1, + num_values: 8, + block_info: BlockInfo::new(), }; - let cache = context.cache().clone(); + let repdef = build_control_word_iterator(None, 0, None, 0, u16::MAX, 8); - async move { - if let Some(cached_data) = cache.get_with_key(&cache_key).await { - self.page_schedulers - .iter_mut() - .zip(cached_data.pages.iter()) - .for_each(|(page_scheduler, cached_data)| { - page_scheduler.scheduler.load(cached_data); - }); - return Ok(()); + let Err(err) = PrimitiveStructuralEncoder::serialize_full_zip_fixed(fixed, repdef, 8) + else { + panic!("expected full-zip to reject 1-bit fixed-width values"); + }; + assert!( + err.to_string().contains("byte aligned"), + "unexpected error: {err}" + ); + } + + fn decode_fixed_fullzip_no_levels( + decompressor: Arc, + data: Vec, + num_rows: usize, + bytes_per_value: usize, + ) -> DataBlock { + Box::new(FixedFullZipDecodeTask { + details: Arc::new(FullZipDecodeDetails { + value_decompressor: PerValueDecompressor::Fixed(decompressor), + def_meaning: Arc::from([]), + ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 0), + max_rep: 0, + max_visible_def: u16::MAX, + }), + data, + num_rows, + bytes_per_value, + }) + .decode() + .unwrap() + .data + } + + #[test] + fn test_fixed_fullzip_decode_preallocates_exact_output_size() { + #[derive(Debug)] + struct IdentityFixedDecompressor; + + impl FixedPerValueDecompressor for IdentityFixedDecompressor { + fn decompress( + &self, + data: FixedWidthDataBlock, + num_rows: u64, + ) -> crate::Result { + assert_eq!(data.num_values, num_rows); + Ok(DataBlock::FixedWidth(data)) } - let page_data = self - .page_schedulers - .iter_mut() - .map(|s| s.scheduler.initialize(context.io())) - .collect::>(); + fn bits_per_value(&self) -> u64 { + 32 + } - let page_data = page_data.try_collect::>().await?; - let cached_data = Arc::new(CachedFieldData { pages: page_data }); - cache.insert_with_key(&cache_key, cached_data).await; - Ok(()) + fn decoded_size_bytes(&self, num_values: u64) -> Option { + num_values.checked_mul(4) + } } - .boxed() - } - fn schedule_ranges<'a>( - &'a self, - ranges: &[Range], - _filter: &FilterExpression, - ) -> Result> { - let ranges = ranges.to_vec(); - Ok(Box::new(StructuralPrimitiveFieldSchedulingJob::new( - self, ranges, - ))) + let make_item = |num_rows: u64| FullZipDecodeTaskItem { + data: PerValueDataBlock::Fixed(FixedWidthDataBlock { + data: LanceBuffer::from(vec![7_u8; num_rows as usize * 4]), + bits_per_value: 32, + num_values: num_rows, + block_info: BlockInfo::new(), + }), + rows_in_buf: num_rows, + }; + + let num_rows = 512; + let decoded = decode_fixed_fullzip_no_levels( + Arc::new(IdentityFixedDecompressor), + vec![make_item(128), make_item(384)], + num_rows, + 4, + ); + let values = decoded.as_fixed_width_ref().unwrap(); + let expected_size = num_rows * 4; + assert_eq!(values.data.len(), expected_size); + assert_eq!(values.data.clone().into_buffer().capacity(), expected_size); } -} -/// Takes the output from several pages decoders and -/// concatenates them. -#[derive(Debug)] -pub struct StructuralCompositeDecodeArrayTask { - tasks: Vec>, - should_validate: bool, - data_type: DataType, -} + #[test] + fn test_fixed_fullzip_decode_falls_back_when_output_size_is_not_exact() { + #[derive(Debug)] + struct FallbackFixedDecompressor; -impl StructuralCompositeDecodeArrayTask { - fn restore_validity( - array: Arc, - unraveler: &mut CompositeRepDefUnraveler, - ) -> Arc { - let validity = unraveler.unravel_validity(array.len()); - let Some(validity) = validity else { - return array; + impl FixedPerValueDecompressor for FallbackFixedDecompressor { + fn decompress( + &self, + data: FixedWidthDataBlock, + num_rows: u64, + ) -> crate::Result { + assert_eq!(data.num_values, num_rows); + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::from(vec![7_u8; num_rows as usize * 4]), + bits_per_value: 32, + num_values: num_rows, + block_info: BlockInfo::new(), + })) + } + + fn bits_per_value(&self) -> u64 { + // This deliberately cannot be multiplied by num_rows. If FullZip treats + // bits_per_value as an exact decoded-size estimate, decoding will fail. + u64::MAX - 7 + } + } + + let num_rows = 2; + let decoded = decode_fixed_fullzip_no_levels( + Arc::new(FallbackFixedDecompressor), + vec![FullZipDecodeTaskItem { + data: PerValueDataBlock::Fixed(FixedWidthDataBlock { + data: LanceBuffer::from(vec![0_u8; num_rows * 4]), + bits_per_value: 32, + num_values: num_rows as u64, + block_info: BlockInfo::new(), + }), + rows_in_buf: num_rows as u64, + }], + num_rows, + 4, + ); + let values = decoded.as_fixed_width_ref().unwrap(); + assert_eq!(values.num_values, num_rows as u64); + assert_eq!(values.data.len(), num_rows * 4); + } + + #[test] + fn test_fixed_fullzip_real_fsl_preallocates_exact_output_size() { + let num_rows = 64; + let dimension = 32; + let items = Arc::new(Float32Array::from_iter_values( + (0..num_rows * dimension).map(|value| value as f32), + )); + let item_field = Arc::new(ArrowField::new("item", DataType::Float32, false)); + let sample = FixedSizeListArray::new(item_field, dimension as i32, items, None); + + let (data, compression) = PerValueCompressor::compress( + &ValueEncoder::default(), + DataBlock::from_array(sample.clone()), + ) + .unwrap(); + let Compression::FixedSizeList(fsl) = compression.compression.unwrap() else { + panic!("expected fixed-size-list compression"); }; - if array.data_type() == &DataType::Null { - // We unravel from a null array but we don't add the null buffer because arrow-rs doesn't like it - return array; - } - assert_eq!(validity.len(), array.len()); - // SAFETY: We've should have already asserted the buffers are all valid, we are just - // adding null buffers to the array here - make_array(unsafe { - array - .to_data() - .into_builder() - .nulls(Some(validity)) - .build_unchecked() - }) + let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(); + let expected_size = num_rows * dimension * size_of::(); + assert_eq!( + FixedPerValueDecompressor::decoded_size_bytes(&decompressor, num_rows as u64), + Some(expected_size as u64) + ); + + let decoded = decode_fixed_fullzip_no_levels( + Arc::new(decompressor), + vec![FullZipDecodeTaskItem { + data, + rows_in_buf: num_rows as u64, + }], + num_rows, + dimension * size_of::(), + ); + let fsl = decoded.as_fixed_size_list_ref().unwrap(); + let values = fsl.child.as_fixed_width_ref().unwrap(); + assert_eq!(values.data.len(), expected_size); + assert_eq!(values.data.clone().into_buffer().capacity(), expected_size); + + let decoded_array = make_array( + decoded + .into_arrow(sample.data_type().clone(), true) + .unwrap(), + ); + assert_eq!(decoded_array.as_ref(), &sample); } -} -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()); - for task in self.tasks { - let decoded = task.decode()?; - unravelers.push(decoded.repdef); + #[test] + fn test_fixed_fullzip_nullable_fsl_uses_fallback_end_to_end() { + #[derive(Debug)] + struct NullableFslDecompressor { + inner: ValueDecompressor, + } - let array = make_array( - decoded - .data - .into_arrow(self.data_type.clone(), self.should_validate)?, - ); + impl FixedPerValueDecompressor for NullableFslDecompressor { + fn decompress( + &self, + data: FixedWidthDataBlock, + num_rows: u64, + ) -> crate::Result { + FixedPerValueDecompressor::decompress(&self.inner, data, num_rows) + } - arrays.push(array); + fn bits_per_value(&self) -> u64 { + // FullZip must not use this physical row width as an exact decoded-size + // estimate for the nullable, multi-buffer Arrow output. + u64::MAX - 7 + } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + FixedPerValueDecompressor::decoded_size_bytes(&self.inner, num_values) + } } - let array_refs = arrays.iter().map(|arr| arr.as_ref()).collect::>(); - let array = arrow_select::concat::concat(&array_refs)?; - let mut repdef = CompositeRepDefUnraveler::new(unravelers); - let array = Self::restore_validity(array, &mut repdef); + let num_rows = 64; + let items = Arc::new(UInt8Array::from_iter( + (0..num_rows).map(|value| (value % 3 != 0).then_some(value as u8)), + )); + let item_field = Arc::new(ArrowField::new("item", DataType::UInt8, true)); + let sample = FixedSizeListArray::new(item_field, 1, items, None); + + let (data, compression) = PerValueCompressor::compress( + &ValueEncoder::default(), + DataBlock::from_array(sample.clone()), + ) + .unwrap(); + let Compression::FixedSizeList(fsl) = compression.compression.unwrap() else { + panic!("expected fixed-size-list compression"); + }; + let decompressor = NullableFslDecompressor { + inner: ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(), + }; + assert_eq!( + FixedPerValueDecompressor::decoded_size_bytes(&decompressor, num_rows as u64), + None + ); - Ok(DecodedArray { array, repdef }) + let decoded = decode_fixed_fullzip_no_levels( + Arc::new(decompressor), + vec![FullZipDecodeTaskItem { + data, + rows_in_buf: num_rows as u64, + }], + num_rows, + 2, + ); + let decoded_array = make_array( + decoded + .into_arrow(sample.data_type().clone(), true) + .unwrap(), + ); + assert_eq!(decoded_array.as_ref(), &sample); } -} -#[derive(Debug)] -pub struct StructuralPrimitiveFieldDecoder { - field: Arc, - page_decoders: VecDeque>, - should_validate: bool, - rows_drained_in_current: u64, -} + #[test] + fn test_miniblock_decode_uses_exact_fixed_width_output_size() { + #[derive(Debug)] + struct FixedWidthMiniBlockDecompressor; -impl StructuralPrimitiveFieldDecoder { - pub fn new(field: &Arc, should_validate: bool) -> Self { - Self { - field: field.clone(), - page_decoders: VecDeque::new(), - should_validate, - rows_drained_in_current: 0, + impl MiniBlockDecompressor for FixedWidthMiniBlockDecompressor { + fn decompress( + &self, + data: Vec, + num_values: u64, + ) -> crate::Result { + assert_eq!(data.len(), 1); + Ok(DataBlock::FixedWidth(FixedWidthDataBlock { + data: data.into_iter().next().unwrap(), + bits_per_value: 32, + num_values, + block_info: BlockInfo::new(), + })) + } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + num_values.checked_mul(4) + } } - } -} -impl StructuralFieldDecoder for StructuralPrimitiveFieldDecoder { - fn accept_page(&mut self, child: LoadedPageShard) -> Result<()> { - assert!(child.path.is_empty()); - self.page_decoders.push_back(child.decoder); - Ok(()) + let num_rows = 512; + let expected_size = num_rows * 4; + let mut chunk_data = Vec::new(); + chunk_data.extend_from_slice(&0_u16.to_le_bytes()); + chunk_data.extend_from_slice(&(expected_size as u16).to_le_bytes()); + let header_padding = + lance_core::utils::bit::pad_bytes::<{ super::MINIBLOCK_ALIGNMENT }>(chunk_data.len()); + chunk_data.resize(chunk_data.len() + header_padding, 0); + chunk_data.resize(chunk_data.len() + expected_size as usize, 7); + + let task = DecodeMiniBlockTask { + rep_decompressor: None, + def_decompressor: None, + value_decompressor: Arc::new(FixedWidthMiniBlockDecompressor), + dictionary_data: None, + def_meaning: Arc::from([]), + num_buffers: 1, + max_visible_level: 0, + instructions: vec![( + ChunkDrainInstructions { + chunk_instructions: ChunkInstructions { + chunk_idx: 0, + preamble: PreambleAction::Absent, + rows_to_skip: 0, + rows_to_take: num_rows, + take_trailer: false, + }, + rows_to_skip: 0, + rows_to_take: num_rows, + preamble_action: PreambleAction::Absent, + }, + LoadedChunk { + byte_range: 0..chunk_data.len() as u64, + data: LanceBuffer::from(chunk_data), + items_in_chunk: num_rows, + chunk_idx: 0, + }, + )], + has_large_chunk: false, + }; + + let decoded = Box::new(task).decode().unwrap(); + let values = decoded.data.as_fixed_width_ref().unwrap(); + assert_eq!(values.data.len(), expected_size as usize); + assert_eq!( + values.data.clone().into_buffer().capacity(), + expected_size as usize + ); } - fn drain(&mut self, num_rows: u64) -> Result> { - let mut remaining = num_rows; - let mut tasks = Vec::new(); - while remaining > 0 { - let cur_page = self.page_decoders.front_mut().unwrap(); - let num_in_page = cur_page.num_rows() - self.rows_drained_in_current; - let to_take = num_in_page.min(remaining); + #[test] + fn test_map_range() { + // Null in the middle + // [[A, B, C], [D, E], NULL, [F, G, H]] + let rep = Some(vec![1, 0, 0, 1, 0, 1, 1, 0, 0]); + let def = Some(vec![0, 0, 0, 0, 0, 1, 0, 0, 0]); + let max_visible_def = 0; + let total_items = 8; + let max_rep = 1; - let task = cur_page.drain(to_take)?; - tasks.push(task); + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - if to_take == num_in_page { - self.page_decoders.pop_front(); - self.rows_drained_in_current = 0; - } else { - self.rows_drained_in_current += to_take; - } + check(0..1, 0..3, 0..3); + check(1..2, 3..5, 3..5); + check(2..3, 5..5, 5..6); + check(3..4, 5..8, 6..9); + check(0..2, 0..5, 0..5); + check(1..3, 3..5, 3..6); + check(2..4, 5..8, 5..9); + check(0..3, 0..5, 0..6); + check(1..4, 3..8, 3..9); + check(0..4, 0..8, 0..9); - remaining -= to_take; - } - Ok(Box::new(StructuralCompositeDecodeArrayTask { - tasks, - should_validate: self.should_validate, - data_type: self.field.data_type().clone(), - })) - } + // Null at start + // [NULL, [A, B], [C]] + let rep = Some(vec![1, 1, 0, 1]); + let def = Some(vec![1, 0, 0, 0]); + let max_visible_def = 0; + let total_items = 3; - fn data_type(&self) -> &DataType { - self.field.data_type() - } -} + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; -/// The serialized representation of full-zip data -struct SerializedFullZip { - /// The zipped values buffer - values: LanceBuffer, - /// The repetition index (only present if there is repetition) - repetition_index: Option, -} + check(0..1, 0..0, 0..1); + check(1..2, 0..2, 1..3); + check(2..3, 2..3, 3..4); + check(0..2, 0..2, 0..3); + check(1..3, 0..3, 1..4); + check(0..3, 0..3, 0..4); -// We align and pad mini-blocks to 8 byte boundaries for two reasons. First, -// to allow us to store a chunk size in 12 bits. -// -// If we directly record the size in bytes with 12 bits we would be limited to -// 4KiB which is too small. Since we know each mini-block consists of 8 byte -// words we can store the # of words instead which gives us 32KiB. We want -// at least 24KiB so we can handle even the worst case of -// - 4Ki values compressed into an 8186 byte buffer -// - 4 bytes to describe rep & def lengths -// - 16KiB of rep & def buffer (this will almost never happen but life is easier if we -// plan for it) -// -// Second, each chunk in a mini-block is aligned to 8 bytes. This allows multi-byte -// values like offsets to be stored in a mini-block and safely read back out. It also -// helps ensure zero-copy reads in cases where zero-copy is possible (e.g. no decoding -// needed). -// -// Note: by "aligned to 8 bytes" we mean BOTH "aligned to 8 bytes from the start of -// the page" and "aligned to 8 bytes from the start of the file." -const MINIBLOCK_ALIGNMENT: usize = 8; + // Null at end + // [[A], [B, C], NULL] + let rep = Some(vec![1, 1, 0, 1]); + let def = Some(vec![0, 0, 0, 1]); + let max_visible_def = 0; + let total_items = 3; -/// An encoder for primitive (leaf) arrays -/// -/// This encoder is fairly complicated and follows a number of paths depending -/// on the data. -/// -/// First, we convert the validity & offsets information into repetition and -/// definition levels. Then we compress the data itself into a single buffer. -/// -/// If the data is narrow then we encode the data in small chunks (each chunk -/// should be a few disk sectors and contains a buffer of repetition, a buffer -/// of definition, and a buffer of value data). This approach is called -/// "mini-block". These mini-blocks are stored into a single data buffer. -/// -/// If the data is wide then we zip together the repetition and definition value -/// with the value data into a single buffer. This approach is called "zipped". -/// -/// If there is any repetition information then we create a repetition index -/// -/// In addition, the compression process may create zero or more metadata buffers. -/// For example, a dictionary compression will create dictionary metadata. Any -/// mini-block approach has a metadata buffer of block sizes. This metadata is -/// stored in a separate buffer on disk and read at initialization time. -/// -/// TODO: We should concatenate metadata buffers from all pages into a single buffer -/// at (roughly) the end of the file so there is, at most, one read per column of -/// metadata per file. -pub struct PrimitiveStructuralEncoder { - // Accumulates arrays until we have enough data to justify a disk page - accumulation_queue: AccumulationQueue, + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - keep_original_array: bool, - support_large_chunk: bool, - accumulated_repdefs: Vec, - // The compression strategy we will use to compress the data - compression_strategy: Arc, - column_index: u32, - field: Field, - encoding_metadata: Arc>, - version: LanceFileVersion, -} + check(0..1, 0..1, 0..1); + check(1..2, 1..3, 1..3); + check(2..3, 3..3, 3..4); + check(0..2, 0..3, 0..3); + check(1..3, 1..3, 1..4); + check(0..3, 0..3, 0..4); -struct CompressedLevelsChunk { - data: LanceBuffer, - num_levels: u16, -} + // No nulls, with repetition + // [[A, B], [C, D], [E, F]] + let rep = Some(vec![1, 0, 1, 0, 1, 0]); + let def: Option<&[u16]> = None; + let max_visible_def = 0; + let total_items = 6; -struct CompressedLevels { - data: Vec, - compression: CompressiveEncoding, - rep_index: Option, -} + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; -struct SerializedMiniBlockPage { - num_buffers: u64, - data: LanceBuffer, - metadata: LanceBuffer, -} + check(0..1, 0..2, 0..2); + check(1..2, 2..4, 2..4); + check(2..3, 4..6, 4..6); + check(0..2, 0..4, 0..4); + check(1..3, 2..6, 2..6); + check(0..3, 0..6, 0..6); -impl PrimitiveStructuralEncoder { - pub fn try_new( - options: &EncodingOptions, - compression_strategy: Arc, - column_index: u32, - field: Field, - encoding_metadata: Arc>, - ) -> Result { - Ok(Self { - accumulation_queue: AccumulationQueue::new( - options.cache_bytes_per_column, - column_index, - options.keep_original_array, - ), - support_large_chunk: options.support_large_chunk(), - keep_original_array: options.keep_original_array, - accumulated_repdefs: Vec::new(), - column_index, - compression_strategy, - field, - encoding_metadata, - version: options.version, - }) - } + // No repetition, with nulls (this case is trivial) + // [A, B, NULL, C] + let rep: Option<&[u16]> = None; + let def = Some(vec![0, 0, 1, 0]); + let max_visible_def = 1; + let total_items = 4; - // TODO: This is a heuristic we may need to tune at some point - // - // As data gets narrow then the "zipping" process gets too expensive - // and we prefer mini-block - // As data gets wide then the # of values per block shrinks (very wide) - // data doesn't even fit in a mini-block and the block overhead gets - // too large and we prefer zipped. - fn is_narrow(data_block: &DataBlock) -> bool { - const MINIBLOCK_MAX_BYTE_LENGTH_PER_VALUE: u64 = 256; + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - if let Some(max_len_array) = data_block.get_stat(Stat::MaxLength) { - let max_len_array = max_len_array - .as_any() - .downcast_ref::>() - .unwrap(); - if max_len_array.value(0) < MINIBLOCK_MAX_BYTE_LENGTH_PER_VALUE { - return true; - } - } - false - } + check(0..1, 0..1, 0..1); + check(1..2, 1..2, 1..2); + check(2..3, 2..3, 2..3); + check(0..2, 0..2, 0..2); + check(1..3, 1..3, 1..3); + check(0..3, 0..3, 0..3); - fn prefers_miniblock( - data_block: &DataBlock, - encoding_metadata: &HashMap, - ) -> bool { - // If the user specifically requested miniblock then use it - if let Some(user_requested) = encoding_metadata.get(STRUCTURAL_ENCODING_META_KEY) { - return user_requested.to_lowercase() == STRUCTURAL_ENCODING_MINIBLOCK; - } - // Otherwise only use miniblock if it is narrow - Self::is_narrow(data_block) - } + // Tricky case, this chunk is a continuation and starts with a rep-index = 0 + // [[..., A] [B, C], NULL] + // + // What we do will depend on the preamble action + let rep = Some(vec![0, 1, 0, 1]); + let def = Some(vec![0, 0, 0, 1]); + let max_visible_def = 0; + let total_items = 3; - 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 - // to emit an error) - if let Some(user_requested) = encoding_metadata.get(STRUCTURAL_ENCODING_META_KEY) { - return user_requested.to_lowercase() == STRUCTURAL_ENCODING_FULLZIP; - } - true - } + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Take, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - // Converts value data, repetition levels, and definition levels into a single - // buffer of mini-blocks. In addition, creates a buffer of mini-block metadata - // which tells us the size of each block. Finally, if repetition is present then - // we also create a buffer for the repetition index. - // - // Each chunk is serialized as: - // | num_bufs (1 byte) | buf_lens (2 bytes per buffer) | P | buf0 | P | buf1 | ... | bufN | P | - // - // P - Padding inserted to ensure each buffer is 8-byte aligned and the buffer size is a multiple - // of 8 bytes (so that the next chunk is 8-byte aligned). - // - // Each block has a u16 word of metadata. The upper 12 bits contain the - // # of 8-byte words in the block (if the block does not fill the final word - // then up to 7 bytes of padding are added). The lower 4 bits describe the log_2 - // number of values (e.g. if there are 1024 then the lower 4 bits will be - // 0xA) All blocks except the last must have power-of-two number of values. - // This not only makes metadata smaller but it makes decoding easier since - // batch sizes are typically a power of 2. 4 bits would allow us to express - // up to 16Ki values but we restrict this further to 4Ki values. - // - // This means blocks can have 1 to 4Ki values and 8 - 32Ki bytes. - // - // All metadata words are serialized (as little endian) into a single buffer - // of metadata values. - // - // If there is repetition then we also create a repetition index. This is a - // single buffer of integer vectors (stored in row major order). There is one - // entry for each chunk. The size of the vector is based on the depth of random - // access we want to support. - // - // A vector of size 2 is the minimum and will support row-based random access (e.g. - // "take the 57th row"). A vector of size 3 will support 1 level of nested access - // (e.g. "take the 3rd item in the 57th row"). A vector of size 4 will support 2 - // levels of nested access and so on. - // - // The first number in the vector is the number of top-level rows that complete in - // the chunk. The second number is the number of second-level rows that complete - // after the final top-level row completed (or beginning of the chunk if no top-level - // row completes in the chunk). And so on. The final number in the vector is always - // the number of leftover items not covered by earlier entries in the vector. - // - // Currently we are limited to 0 levels of nested access but that will change in the - // future. - // - // The repetition index and the chunk metadata are read at initialization time and - // cached in memory. - fn serialize_miniblocks( - miniblocks: MiniBlockCompressed, - rep: Option>, - def: Option>, - support_large_chunk: bool, - ) -> SerializedMiniBlockPage { - let bytes_rep = rep - .as_ref() - .map(|rep| rep.iter().map(|r| r.data.len()).sum::()) - .unwrap_or(0); - let bytes_def = def - .as_ref() - .map(|def| def.iter().map(|d| d.data.len()).sum::()) - .unwrap_or(0); - let bytes_data = miniblocks.data.iter().map(|d| d.len()).sum::(); - let mut num_buffers = miniblocks.data.len(); - if rep.is_some() { - num_buffers += 1; - } - if def.is_some() { - num_buffers += 1; - } - // 2 bytes for the length of each buffer and up to 7 bytes of padding per buffer - let max_extra = 9 * num_buffers; - let mut data_buffer = Vec::with_capacity(bytes_rep + bytes_def + bytes_data + max_extra); - let chunk_size_bytes = if support_large_chunk { 4 } else { 2 }; - let mut meta_buffer = Vec::with_capacity(miniblocks.chunks.len() * chunk_size_bytes); + // If we are taking the preamble then the range must start at 0 + check(0..1, 0..3, 0..3); + check(0..2, 0..3, 0..4); - let mut rep_iter = rep.map(|r| r.into_iter()); - let mut def_iter = def.map(|d| d.into_iter()); + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Skip, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - let mut buffer_offsets = vec![0; miniblocks.data.len()]; - for chunk in miniblocks.chunks { - let start_pos = data_buffer.len(); - // Start of chunk should be aligned - debug_assert_eq!(start_pos % MINIBLOCK_ALIGNMENT, 0); + check(0..1, 1..3, 1..3); + check(1..2, 3..3, 3..4); + check(0..2, 1..3, 1..4); + + // Another preamble case but now it doesn't end with a new list + // [[..., A], NULL, [D, E]] + // + // What we do will depend on the preamble action + let rep = Some(vec![0, 1, 1, 0]); + let def = Some(vec![0, 1, 0, 0]); + let max_visible_def = 0; + let total_items = 4; - let rep = rep_iter.as_mut().map(|r| r.next().unwrap()); - let def = def_iter.as_mut().map(|d| d.next().unwrap()); + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Take, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - // Write the number of levels, or 0 if there is no rep/def - let num_levels = rep - .as_ref() - .map(|r| r.num_levels) - .unwrap_or(def.as_ref().map(|d| d.num_levels).unwrap_or(0)); - data_buffer.extend_from_slice(&num_levels.to_le_bytes()); + // If we are taking the preamble then the range must start at 0 + check(0..1, 0..1, 0..2); + check(0..2, 0..3, 0..4); - // Write the buffer lengths - if let Some(rep) = rep.as_ref() { - let bytes_rep = u16::try_from(rep.data.len()).unwrap(); - 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(); - data_buffer.extend_from_slice(&bytes_def.to_le_bytes()); - } + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Skip, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - if support_large_chunk { - for &buffer_size in &chunk.buffer_sizes { - data_buffer.extend_from_slice(&buffer_size.to_le_bytes()); - } - } else { - for &buffer_size in &chunk.buffer_sizes { - data_buffer.extend_from_slice(&(buffer_size as u16).to_le_bytes()); - } - } + // If we are taking the preamble then the range must start at 0 + check(0..1, 1..1, 1..2); + check(1..2, 1..3, 2..4); + check(0..2, 1..3, 1..4); - // Pad - let add_padding = |data_buffer: &mut Vec| { - let pad = pad_bytes::(data_buffer.len()); - data_buffer.extend(iter::repeat_n(FILL_BYTE, pad)); - }; - add_padding(&mut data_buffer); + // Now a preamble case without any definition levels + // [[..., A] [B, C], [D]] + let rep = Some(vec![0, 1, 0, 1]); + let def: Option> = None; + let max_visible_def = 0; + let total_items = 4; - // Write the buffers themselves - if let Some(rep) = rep.as_ref() { - data_buffer.extend_from_slice(&rep.data); - add_padding(&mut data_buffer); - } - if let Some(def) = def.as_ref() { - data_buffer.extend_from_slice(&def.data); - add_padding(&mut data_buffer); - } - for (buffer_size, (buffer, buffer_offset)) in chunk - .buffer_sizes - .iter() - .zip(miniblocks.data.iter().zip(buffer_offsets.iter_mut())) - { - let start = *buffer_offset; - let end = start + *buffer_size as usize; - *buffer_offset += *buffer_size as usize; - data_buffer.extend_from_slice(&buffer[start..end]); - add_padding(&mut data_buffer); - } + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Take, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - let chunk_bytes = data_buffer.len() - start_pos; - let max_chunk_size = if support_large_chunk { - 4 * 1024 * 1024 * 1024 // 4GB limit with u32 metadata - } else { - 32 * 1024 // 32KiB limit with u16 metadata - }; - assert!(chunk_bytes <= max_chunk_size); - assert!(chunk_bytes > 0); - assert_eq!(chunk_bytes % 8, 0); - // 4Ki values max - assert!(chunk.log_num_values <= 12); - // We subtract 1 here from chunk_bytes because we want to be able to express - // a size of 32KiB and not (32Ki - 8)B which is what we'd get otherwise with - // 0xFFF - let divided_bytes = chunk_bytes / MINIBLOCK_ALIGNMENT; - let divided_bytes_minus_one = (divided_bytes - 1) as u64; + // If we are taking the preamble then the range must start at 0 + check(0..1, 0..3, 0..3); + check(0..2, 0..4, 0..4); - let metadata = (divided_bytes_minus_one << 4) | chunk.log_num_values as u64; - if support_large_chunk { - meta_buffer.extend_from_slice(&(metadata as u32).to_le_bytes()); - } else { - meta_buffer.extend_from_slice(&(metadata as u16).to_le_bytes()); - } - } + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Skip, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - let data_buffer = LanceBuffer::from(data_buffer); - let metadata_buffer = LanceBuffer::from(meta_buffer); + check(0..1, 1..3, 1..3); + check(1..2, 3..4, 3..4); + check(0..2, 1..4, 1..4); - SerializedMiniBlockPage { - num_buffers: miniblocks.data.len() as u64, - data: data_buffer, - metadata: metadata_buffer, - } - } + // If we have nested lists then non-top level lists may be empty/null + // and we need to make sure we still handle them as invisible items (we + // failed to do this previously) + let rep = Some(vec![2, 1, 2, 0, 1, 2]); + let def = Some(vec![0, 1, 2, 0, 0, 0]); + let max_rep = 2; + let max_visible_def = 0; + let total_items = 4; - /// Compresses a buffer of levels into chunks - /// - /// If these are repetition levels then we also calculate the repetition index here (that - /// is the third return value) - fn compress_levels( - mut levels: RepDefSlicer<'_>, - num_elements: u64, - compression_strategy: &dyn CompressionStrategy, - chunks: &[MiniBlockChunk], - // This will be 0 if we are compressing def levels - max_rep: u16, - ) -> Result { - let mut rep_index = if max_rep > 0 { - Vec::with_capacity(chunks.len()) - } else { - vec![] + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Absent, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); }; - // Make the levels into a FixedWidth data block - let num_levels = levels.num_levels() as u64; - let levels_buf = levels.all_levels().clone(); - let mut fixed_width_block = FixedWidthDataBlock { - data: levels_buf, - bits_per_value: 16, - num_values: num_levels, - block_info: BlockInfo::new(), - }; - // Compute statistics to enable optimal compression for rep/def levels - fixed_width_block.compute_stat(); + check(0..3, 0..4, 0..6); + check(0..1, 0..1, 0..2); + check(1..2, 1..3, 2..5); + check(2..3, 3..4, 5..6); - let levels_block = DataBlock::FixedWidth(fixed_width_block); - let levels_field = Field::new_arrow("", DataType::UInt16, false)?; - // Pick a block compressor - let (compressor, compressor_desc) = - compression_strategy.create_block_compressor(&levels_field, &levels_block)?; - // Compress blocks of levels (sized according to the chunks) - let mut level_chunks = Vec::with_capacity(chunks.len()); - let mut values_counter = 0; - for (chunk_idx, chunk) in chunks.iter().enumerate() { - let chunk_num_values = chunk.num_values(values_counter, num_elements); - debug_assert!(chunk_num_values > 0); - values_counter += chunk_num_values; - let chunk_levels = if chunk_idx < chunks.len() - 1 { - levels.slice_next(chunk_num_values as usize) - } else { - levels.slice_rest() - }; - let num_chunk_levels = (chunk_levels.len() / 2) as u64; - if max_rep > 0 { - // If max_rep > 0 then we are working with rep levels and we need - // to calculate the repetition index. The repetition index for a - // chunk is currently 2 values (in the future it may be more). - // - // The first value is the number of rows that _finish_ in the - // chunk. - // - // The second value is the number of "leftovers" after the last - // finished row in the chunk. - let rep_values = chunk_levels.borrow_to_typed_slice::(); - let rep_values = rep_values.as_ref(); + // Invisible items in a preamble that we are taking (regressing a previous failure) + let rep = Some(vec![0, 0, 1, 0, 1, 1]); + let def = Some(vec![0, 1, 0, 0, 0, 0]); + let max_rep = 1; + let max_visible_def = 0; + let total_items = 5; - // We skip 1 here because a max_rep at spot 0 doesn't count as a finished list (we - // will count it in the previous chunk) - let mut num_rows = rep_values.iter().skip(1).filter(|v| **v == max_rep).count(); - let num_leftovers = if chunk_idx < chunks.len() - 1 { - rep_values - .iter() - .rev() - .position(|v| *v == max_rep) - // # of leftovers includes the max_rep spot - .map(|pos| pos + 1) - .unwrap_or(rep_values.len()) - } else { - // Last chunk can't have leftovers - 0 - }; + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Take, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); + }; - if chunk_idx != 0 && rep_values.first() == Some(&max_rep) { - // This chunk starts with a new row and so, if we thought we had leftovers - // in the previous chunk, we were mistaken - // TODO: Can use unchecked here - let rep_len = rep_index.len(); - if rep_index[rep_len - 1] != 0 { - // We thought we had leftovers but that was actually a full row - rep_index[rep_len - 2] += 1; - rep_index[rep_len - 1] = 0; - } - } + check(0..0, 0..1, 0..2); + check(0..1, 0..3, 0..4); + check(0..2, 0..4, 0..5); - if chunk_idx == chunks.len() - 1 { - // The final list - num_rows += 1; - } - rep_index.push(num_rows as u64); - rep_index.push(num_leftovers as u64); - } - let mut chunk_fixed_width = FixedWidthDataBlock { - data: chunk_levels, - bits_per_value: 16, - num_values: num_chunk_levels, - block_info: BlockInfo::new(), - }; - chunk_fixed_width.compute_stat(); - let chunk_levels_block = DataBlock::FixedWidth(chunk_fixed_width); - let compressed_levels = compressor.compress(chunk_levels_block)?; - level_chunks.push(CompressedLevelsChunk { - data: compressed_levels, - num_levels: num_chunk_levels as u16, - }); - } - debug_assert_eq!(levels.num_levels_remaining(), 0); - let rep_index = if rep_index.is_empty() { - None - } else { - Some(LanceBuffer::reinterpret_vec(rep_index)) + // Skip preamble (with invis items) and skip a few rows (with invis items) + // and then take a few rows but not all the rows + let rep = Some(vec![0, 1, 0, 1, 0, 1, 0, 1]); + let def = Some(vec![1, 0, 1, 1, 0, 0, 0, 0]); + let max_rep = 1; + let max_visible_def = 0; + let total_items = 5; + + let check = |range, expected_item_range, expected_level_range| { + let (item_range, level_range) = DecodeMiniBlockTask::map_range( + range, + rep.as_ref(), + def.as_ref(), + max_rep, + max_visible_def, + total_items, + PreambleAction::Skip, + ); + assert_eq!(item_range, expected_item_range); + assert_eq!(level_range, expected_level_range); }; - Ok(CompressedLevels { - data: level_chunks, - compression: compressor_desc, - rep_index, - }) + + check(2..3, 2..4, 5..7); } - fn encode_simple_all_null( - column_idx: u32, - num_rows: u64, - row_number: u64, - ) -> Result { - let description = - ProtobufUtils21::constant_layout(&[DefinitionInterpretation::NullableItem], None); - Ok(EncodedPage { - column_idx, - data: vec![], - description: PageEncoding::Structural(description), - num_rows, - row_number, - }) + #[test] + fn test_slice_batch_data_and_rebase_offsets_u32() { + let data = LanceBuffer::copy_slice(b"0123456789abcdefghij"); + let offsets = LanceBuffer::reinterpret_vec(vec![6_u32, 8_u32, 8_u32, 12_u32]); + + let (sliced_data, normalized_offsets) = + VariableFullZipDecoder::slice_batch_data_and_rebase_offsets(&data, &offsets, 32) + .unwrap(); + + assert_eq!(sliced_data.as_ref(), b"6789ab"); + let normalized = normalized_offsets.borrow_to_typed_slice::(); + assert_eq!(normalized.as_ref(), &[0, 2, 2, 6]); } - // Encodes a page where all values are null but we have rep/def - // information that we need to store (e.g. to distinguish between - // different kinds of null) - fn encode_complex_all_null( - column_idx: u32, - repdef: crate::repdef::SerializedRepDefs, - row_number: u64, - num_rows: u64, - ) -> Result { - // TODO: Actually compress repdef - let rep_bytes = if let Some(rep) = repdef.repetition_levels.as_ref() { - LanceBuffer::reinterpret_slice(rep.clone()) - } else { - LanceBuffer::empty() - }; + #[test] + fn test_slice_batch_data_and_rebase_offsets_u64() { + let data = LanceBuffer::copy_slice(b"abcdefghijklmnopqrstuvwxyz"); + let offsets = LanceBuffer::reinterpret_vec(vec![10_u64, 12_u64, 16_u64, 20_u64]); - let def_bytes = if let Some(def) = repdef.definition_levels.as_ref() { - LanceBuffer::reinterpret_slice(def.clone()) - } else { - LanceBuffer::empty() - }; + let (sliced_data, normalized_offsets) = + VariableFullZipDecoder::slice_batch_data_and_rebase_offsets(&data, &offsets, 64) + .unwrap(); - let description = ProtobufUtils21::constant_layout(&repdef.def_meaning, None); - Ok(EncodedPage { - column_idx, - data: vec![rep_bytes, def_bytes], - description: PageEncoding::Structural(description), - num_rows, - row_number, - }) + assert_eq!(sliced_data.as_ref(), b"klmnopqrst"); + let normalized = normalized_offsets.borrow_to_typed_slice::(); + assert_eq!(normalized.as_ref(), &[0, 2, 6, 10]); } - fn leaf_validity( - repdef: &crate::repdef::SerializedRepDefs, - num_values: usize, - ) -> Result> { - let rep = repdef - .repetition_levels - .as_ref() - .map(|rep| rep.as_ref().to_vec()); - let def = repdef - .definition_levels - .as_ref() - .map(|def| def.as_ref().to_vec()); - let mut unraveler = RepDefUnraveler::new( - rep, - def, - repdef.def_meaning.clone().into(), - num_values as u64, - ); - if unraveler.is_all_valid() { - return Ok(None); - } - let mut validity = BooleanBufferBuilder::new(num_values); - unraveler.unravel_validity(&mut validity); - Ok(Some(validity.finish())) + #[test] + fn test_slice_batch_data_and_rebase_offsets_rejects_invalid_offsets() { + let data = LanceBuffer::copy_slice(b"abcd"); + let offsets = LanceBuffer::reinterpret_vec(vec![3_u32, 2_u32]); + + let err = VariableFullZipDecoder::slice_batch_data_and_rebase_offsets(&data, &offsets, 32) + .expect_err("offset end before start should error"); + assert!(err.to_string().contains("less than base")); } - fn is_constant_values( - arrays: &[ArrayRef], - scalar: &ArrayRef, - validity: Option<&BooleanBuffer>, - ) -> Result { - debug_assert_eq!(scalar.len(), 1); - debug_assert_eq!(scalar.null_count(), 0); + #[test] + fn test_schedule_instructions() { + // Convert repetition index to bytes for testing + let rep_data: Vec = vec![5, 2, 3, 0, 4, 7, 2, 0]; + let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); + let chunk_index = MiniBlockChunkIndex::new_nested_for_test(&rep_bytes, 2); - match scalar.data_type() { - DataType::Boolean => { - let mut global_idx = 0usize; - let scalar_val = scalar.as_boolean().value(0); - for arr in arrays { - let bool_arr = arr.as_boolean(); - for i in 0..arr.len() { - let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); - global_idx += 1; - if !is_valid { - continue; - } - if bool_arr.value(i) != scalar_val { - return Ok(false); - } - } - } - Ok(true) - } - DataType::Utf8 => Self::is_constant_utf8::(arrays, scalar, validity), - DataType::LargeUtf8 => Self::is_constant_utf8::(arrays, scalar, validity), - DataType::Binary => Self::is_constant_binary::(arrays, scalar, validity), - DataType::LargeBinary => Self::is_constant_binary::(arrays, scalar, validity), - data_type => { - let mut global_idx = 0usize; - let Some(byte_width) = data_type.byte_width_opt() else { - return Ok(false); - }; - let scalar_data = scalar.to_data(); - if scalar_data.buffers().len() != 1 || !scalar_data.child_data().is_empty() { - return Ok(false); - } - let scalar_bytes = scalar_data.buffers()[0].as_slice(); - if scalar_bytes.len() != byte_width { - return Ok(false); - } + let check = |user_ranges, expected_instructions| { + let instructions = ChunkInstructions::schedule_instructions(&chunk_index, user_ranges); + assert_eq!(instructions, expected_instructions); + }; - for arr in arrays { - let data = arr.to_data(); - if data.buffers().is_empty() { - return Ok(false); - } - let buf = data.buffers()[0].as_slice(); - let base = data.offset(); - for i in 0..arr.len() { - let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); - global_idx += 1; - if !is_valid { - continue; - } - let start = (base + i) * byte_width; - if buf[start..start + byte_width] != scalar_bytes[..] { - return Ok(false); - } - } - } - Ok(true) - } - } - } + // The instructions we expect if we're grabbing the whole range + let expected_take_all = vec![ + ChunkInstructions { + chunk_idx: 0, + preamble: PreambleAction::Absent, + rows_to_skip: 0, + rows_to_take: 6, + take_trailer: true, + }, + ChunkInstructions { + chunk_idx: 1, + preamble: PreambleAction::Take, + rows_to_skip: 0, + rows_to_take: 2, + take_trailer: false, + }, + ChunkInstructions { + chunk_idx: 2, + preamble: PreambleAction::Absent, + rows_to_skip: 0, + rows_to_take: 5, + take_trailer: true, + }, + ChunkInstructions { + chunk_idx: 3, + preamble: PreambleAction::Take, + rows_to_skip: 0, + rows_to_take: 1, + take_trailer: false, + }, + ]; + + // Take all as 1 range + check(&[0..14], expected_take_all.clone()); + + // Take all a individual rows + check( + &[ + 0..1, + 1..2, + 2..3, + 3..4, + 4..5, + 5..6, + 6..7, + 7..8, + 8..9, + 9..10, + 10..11, + 11..12, + 12..13, + 13..14, + ], + expected_take_all, + ); - fn is_constant_utf8( - arrays: &[ArrayRef], - scalar: &ArrayRef, - validity: Option<&BooleanBuffer>, - ) -> Result { - debug_assert_eq!(scalar.len(), 1); - let scalar_val = scalar.as_string::().value(0).as_bytes(); - let mut global_idx = 0usize; - for arr in arrays { - let str_arr = arr.as_string::(); - for i in 0..arr.len() { - let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); - global_idx += 1; - if !is_valid { - continue; - } - if str_arr.value(i).as_bytes() != scalar_val { - return Ok(false); - } - } - } - Ok(true) - } + // Test some partial takes - fn is_constant_binary( - arrays: &[ArrayRef], - scalar: &ArrayRef, - validity: Option<&BooleanBuffer>, - ) -> Result { - debug_assert_eq!(scalar.len(), 1); - let scalar_val = scalar.as_binary::().value(0); - let mut global_idx = 0usize; - for arr in arrays { - let bin_arr = arr.as_binary::(); - for i in 0..arr.len() { - let is_valid = validity.map(|v| v.value(global_idx)).unwrap_or(true); - global_idx += 1; - if !is_valid { - continue; - } - if bin_arr.value(i) != scalar_val { - return Ok(false); - } - } - } - Ok(true) - } + // 2 rows in the same chunk but not contiguous + check( + &[0..1, 3..4], + vec![ + ChunkInstructions { + chunk_idx: 0, + preamble: PreambleAction::Absent, + rows_to_skip: 0, + rows_to_take: 1, + take_trailer: false, + }, + ChunkInstructions { + chunk_idx: 0, + preamble: PreambleAction::Absent, + rows_to_skip: 3, + rows_to_take: 1, + take_trailer: false, + }, + ], + ); - fn find_constant_scalar( - arrays: &[ArrayRef], - validity: Option<&BooleanBuffer>, - ) -> Result> { - if arrays.is_empty() { - return Ok(None); - } + // Taking just a trailer/preamble + check( + &[5..6], + vec![ + ChunkInstructions { + chunk_idx: 0, + preamble: PreambleAction::Absent, + rows_to_skip: 5, + rows_to_take: 1, + take_trailer: true, + }, + ChunkInstructions { + chunk_idx: 1, + preamble: PreambleAction::Take, + rows_to_skip: 0, + rows_to_take: 0, + take_trailer: false, + }, + ], + ); - let global_scalar_idx = if let Some(validity) = validity { - let Some(idx) = (0..validity.len()).find(|&i| validity.value(i)) else { - return Ok(None); - }; - idx - } else { - 0 - }; + // Skipping an entire chunk + check( + &[7..10], + vec![ + ChunkInstructions { + chunk_idx: 1, + preamble: PreambleAction::Skip, + rows_to_skip: 1, + rows_to_take: 1, + take_trailer: false, + }, + ChunkInstructions { + chunk_idx: 2, + preamble: PreambleAction::Absent, + rows_to_skip: 0, + rows_to_take: 2, + take_trailer: false, + }, + ], + ); + } - let mut idx_remaining = global_scalar_idx; - let mut scalar_arr_idx = 0usize; - while scalar_arr_idx < arrays.len() { - let len = arrays[scalar_arr_idx].len(); - if idx_remaining < len { - break; + #[test] + fn test_drain_instructions() { + fn drain_from_instructions( + instructions: &mut VecDeque, + mut rows_desired: u64, + need_preamble: &mut bool, + skip_in_chunk: &mut u64, + ) -> Vec { + // Note: instructions.len() is an upper bound, we typically take much fewer + let mut drain_instructions = Vec::with_capacity(instructions.len()); + while rows_desired > 0 || *need_preamble { + let (next_instructions, consumed_chunk) = instructions + .front() + .unwrap() + .drain_from_instruction(&mut rows_desired, need_preamble, skip_in_chunk); + if consumed_chunk { + instructions.pop_front(); + } + drain_instructions.push(next_instructions); } - idx_remaining -= len; - scalar_arr_idx += 1; + drain_instructions } - if scalar_arr_idx >= arrays.len() { - return Ok(None); - } + // Convert repetition index to bytes for testing + let rep_data: Vec = vec![5, 2, 3, 0, 4, 7, 2, 0]; + let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); + let chunk_index = MiniBlockChunkIndex::new_nested_for_test(&rep_bytes, 2); + let user_ranges = vec![1..7, 10..14]; - let scalar = - lance_arrow::scalar::extract_scalar_value(&arrays[scalar_arr_idx], idx_remaining)?; - if scalar.null_count() != 0 { - return Ok(None); - } - if !Self::is_constant_values(arrays, &scalar, validity)? { - return Ok(None); - } - Ok(Some(scalar)) - } + // First, schedule the ranges + let scheduled = ChunkInstructions::schedule_instructions(&chunk_index, &user_ranges); - #[allow(clippy::too_many_arguments)] - fn encode_miniblock( - column_idx: u32, - field: &Field, - compression_strategy: &dyn CompressionStrategy, - data: DataBlock, - repdef: crate::repdef::SerializedRepDefs, - row_number: u64, - dictionary_data: Option, - num_rows: u64, - support_large_chunk: bool, - ) -> Result { - if let DataBlock::AllNull(_null_block) = data { - // We should not be using mini-block for all-null. There are other structural - // encodings for that. - unreachable!() - } + let mut to_drain = VecDeque::from(scheduled.clone()); - let num_items = data.num_values(); + // Now we drain in batches of 4 - let compressor = compression_strategy.create_miniblock_compressor(field, &data)?; - let (compressed_data, value_encoding) = compressor.compress(data)?; + let mut need_preamble = false; + let mut skip_in_chunk = 0; - let max_rep = repdef.def_meaning.iter().filter(|l| l.is_list()).count() as u16; + let next_batch = + drain_from_instructions(&mut to_drain, 4, &mut need_preamble, &mut skip_in_chunk); - let mut compressed_rep = repdef - .rep_slicer() - .map(|rep_slicer| { - Self::compress_levels( - rep_slicer, - num_items, - compression_strategy, - &compressed_data.chunks, - max_rep, - ) - }) - .transpose()?; + assert!(!need_preamble); + assert_eq!(skip_in_chunk, 4); + assert_eq!( + next_batch, + vec![ChunkDrainInstructions { + chunk_instructions: scheduled[0].clone(), + rows_to_take: 4, + rows_to_skip: 0, + preamble_action: PreambleAction::Absent, + }] + ); - let (rep_index, rep_index_depth) = - match compressed_rep.as_mut().and_then(|cr| cr.rep_index.as_mut()) { - Some(rep_index) => (Some(rep_index.clone()), 1), - None => (None, 0), - }; + let next_batch = + drain_from_instructions(&mut to_drain, 4, &mut need_preamble, &mut skip_in_chunk); - let mut compressed_def = repdef - .def_slicer() - .map(|def_slicer| { - Self::compress_levels( - def_slicer, - num_items, - compression_strategy, - &compressed_data.chunks, - /*max_rep=*/ 0, - ) - }) - .transpose()?; + assert!(!need_preamble); + assert_eq!(skip_in_chunk, 2); - // TODO: Parquet sparsely encodes values here. We could do the same but - // then we won't have log2 values per chunk. This means more metadata - // and potentially more decoder asymmetry. However, it may be worth - // investigating at some point + assert_eq!( + next_batch, + vec![ + ChunkDrainInstructions { + chunk_instructions: scheduled[0].clone(), + rows_to_take: 1, + rows_to_skip: 4, + preamble_action: PreambleAction::Absent, + }, + ChunkDrainInstructions { + chunk_instructions: scheduled[1].clone(), + rows_to_take: 1, + rows_to_skip: 0, + preamble_action: PreambleAction::Take, + }, + ChunkDrainInstructions { + chunk_instructions: scheduled[2].clone(), + rows_to_take: 2, + rows_to_skip: 0, + preamble_action: PreambleAction::Absent, + } + ] + ); - let rep_data = compressed_rep - .as_mut() - .map(|cr| std::mem::take(&mut cr.data)); - let def_data = compressed_def - .as_mut() - .map(|cd| std::mem::take(&mut cd.data)); + let next_batch = + drain_from_instructions(&mut to_drain, 2, &mut need_preamble, &mut skip_in_chunk); - let serialized = - Self::serialize_miniblocks(compressed_data, rep_data, def_data, support_large_chunk); + assert!(!need_preamble); + assert_eq!(skip_in_chunk, 0); - // Metadata, Data, Dictionary, (maybe) Repetition Index - let mut data = Vec::with_capacity(4); - data.push(serialized.metadata); - data.push(serialized.data); + assert_eq!( + next_batch, + vec![ + ChunkDrainInstructions { + chunk_instructions: scheduled[2].clone(), + rows_to_take: 1, + rows_to_skip: 2, + preamble_action: PreambleAction::Absent, + }, + ChunkDrainInstructions { + chunk_instructions: scheduled[3].clone(), + rows_to_take: 1, + rows_to_skip: 0, + preamble_action: PreambleAction::Take, + }, + ] + ); - if let Some(dictionary_data) = dictionary_data { - let num_dictionary_items = dictionary_data.num_values(); - // field in `create_block_compressor` is not used currently. - let dummy_dictionary_field = Field::new_arrow("", DataType::UInt16, false)?; + // Regression case. Need a chunk with preamble, rows, and trailer (the middle chunk here) + let rep_data: Vec = vec![5, 2, 3, 3, 20, 0]; + let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); + let chunk_index = MiniBlockChunkIndex::new_nested_for_test(&rep_bytes, 2); + let user_ranges = vec![0..28]; - let (compressor, dictionary_encoding) = compression_strategy - .create_block_compressor(&dummy_dictionary_field, &dictionary_data)?; - let dictionary_buffer = compressor.compress(dictionary_data)?; + // First, schedule the ranges + let scheduled = ChunkInstructions::schedule_instructions(&chunk_index, &user_ranges); - data.push(dictionary_buffer); - if let Some(rep_index) = rep_index { - data.push(rep_index); - } + let mut to_drain = VecDeque::from(scheduled.clone()); - let description = ProtobufUtils21::miniblock_layout( - compressed_rep.map(|cr| cr.compression), - compressed_def.map(|cd| cd.compression), - value_encoding, - rep_index_depth, - serialized.num_buffers, - Some((dictionary_encoding, num_dictionary_items)), - &repdef.def_meaning, - num_items, - support_large_chunk, - ); - Ok(EncodedPage { - num_rows, - column_idx, - data, - description: PageEncoding::Structural(description), - row_number, - }) - } else { - let description = ProtobufUtils21::miniblock_layout( - compressed_rep.map(|cr| cr.compression), - compressed_def.map(|cd| cd.compression), - value_encoding, - rep_index_depth, - serialized.num_buffers, - None, - &repdef.def_meaning, - num_items, - support_large_chunk, - ); + // Drain first chunk and some of second chunk - if let Some(rep_index) = rep_index { - let view = rep_index.borrow_to_typed_slice::(); - let total = view.chunks_exact(2).map(|c| c[0]).sum::(); - debug_assert_eq!(total, num_rows); + let mut need_preamble = false; + let mut skip_in_chunk = 0; - data.push(rep_index); - } + let next_batch = + drain_from_instructions(&mut to_drain, 7, &mut need_preamble, &mut skip_in_chunk); - Ok(EncodedPage { - num_rows, - column_idx, - data, - description: PageEncoding::Structural(description), - row_number, - }) - } - } + assert_eq!( + next_batch, + vec![ + ChunkDrainInstructions { + chunk_instructions: scheduled[0].clone(), + rows_to_take: 6, + rows_to_skip: 0, + preamble_action: PreambleAction::Absent, + }, + ChunkDrainInstructions { + chunk_instructions: scheduled[1].clone(), + rows_to_take: 1, + rows_to_skip: 0, + preamble_action: PreambleAction::Take, + }, + ] + ); - // For fixed-size data we encode < control word | data > for each value - fn serialize_full_zip_fixed( - fixed: FixedWidthDataBlock, - mut repdef: ControlWordIterator, - num_values: u64, - ) -> SerializedFullZip { - let len = fixed.data.len() + repdef.bytes_per_word() * num_values as usize; - let mut zipped_data = Vec::with_capacity(len); + assert!(!need_preamble); + assert_eq!(skip_in_chunk, 1); - let max_rep_index_val = if repdef.has_repetition() { - len as u64 - } else { - // Setting this to 0 means we won't write a repetition index - 0 - }; - let mut rep_index_builder = - BytepackedIntegerEncoder::with_capacity(num_values as usize + 1, max_rep_index_val); + // Now, the tricky part. We drain the second chunk, including the trailer, and need to make sure + // we get a drain task to take the preamble of the third chunk (and nothing else) + let next_batch = + drain_from_instructions(&mut to_drain, 2, &mut need_preamble, &mut skip_in_chunk); - // I suppose we can just pad to the nearest byte but I'm not sure we need to worry about this anytime soon - // because it is unlikely compression of large values is going to yield a result that is not byte aligned assert_eq!( - fixed.bits_per_value % 8, - 0, - "Non-byte aligned full-zip compression not yet supported" + next_batch, + vec![ + ChunkDrainInstructions { + chunk_instructions: scheduled[1].clone(), + rows_to_take: 2, + rows_to_skip: 1, + preamble_action: PreambleAction::Skip, + }, + ChunkDrainInstructions { + chunk_instructions: scheduled[2].clone(), + rows_to_take: 0, + rows_to_skip: 0, + preamble_action: PreambleAction::Take, + }, + ] ); - let bytes_per_value = fixed.bits_per_value as usize / 8; - let mut offset = 0; + assert!(!need_preamble); + assert_eq!(skip_in_chunk, 0); + } - if bytes_per_value == 0 { - // No data, just dump the repdef into the buffer - while let Some(control) = repdef.append_next(&mut zipped_data) { - if control.is_new_row { - // We have finished a row - debug_assert!(offset <= len); - // SAFETY: We know that `start <= len` - unsafe { rep_index_builder.append(offset as u64) }; - } - offset = zipped_data.len(); - } - } else { - // We have data, zip it with the repdef - let mut data_iter = fixed.data.chunks_exact(bytes_per_value); - while let Some(control) = repdef.append_next(&mut zipped_data) { - if control.is_new_row { - // We have finished a row - debug_assert!(offset <= len); - // SAFETY: We know that `start <= len` - unsafe { rep_index_builder.append(offset as u64) }; - } - if control.is_visible { - let value = data_iter.next().unwrap(); - zipped_data.extend_from_slice(value); - } - offset = zipped_data.len(); - } + use super::chunk_index::{PrefixSums, RowMapping}; + use super::{MINIBLOCK_ALIGNMENT, Words, build_chunk_index}; + use bytes::Bytes; + use lance_core::cache::{Context, DeepSizeOf}; + use rstest::rstest; + + /// Builds a `Words` metadata buffer (u16 words) from `(log_num_values, num_bytes)` + /// pairs, returning the words and the total data-buffer size. + fn words_from(entries: &[(u32, u32)]) -> (Words, u64) { + let mut raw = Vec::with_capacity(entries.len() * 2); + let mut total = 0u64; + for &(log, num_bytes) in entries { + assert!(num_bytes > 0 && num_bytes % MINIBLOCK_ALIGNMENT as u32 == 0); + let divided = num_bytes / MINIBLOCK_ALIGNMENT as u32 - 1; + let word = (divided << 4) | log; + assert!(word <= u16::MAX as u32, "test word {word} exceeds u16"); + raw.extend_from_slice(&(word as u16).to_le_bytes()); + total += num_bytes as u64; + } + (Words::from_bytes(Bytes::from(raw), false).unwrap(), total) + } + + fn rep_bytes_from(values: &[u64]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() + } + + #[rstest] + // Two full chunks of 8 values (log 3) plus a partial last chunk; byte sizes vary + // independently of value counts. + #[case::uniform_partial_last(&[(3, 16), (3, 24), (0, 8)], 19, "uniform_flat", 8, 3)] + // Single chunk covers the whole page. + #[case::single_chunk(&[(0, 24)], 5, "uniform_flat", 5, 5)] + // Last chunk is also full (exact multiple). + #[case::exact_multiple(&[(3, 16), (3, 16)], 16, "uniform_flat", 8, 8)] + // Non-last chunks differ in size, so this is a non-uniform flat page. + #[case::non_uniform(&[(4, 16), (2, 16), (0, 8)], 21, "flat", 16, 1)] + fn test_flat_detection( + #[case] entries: &[(u32, u32)], + #[case] items_in_page: u64, + #[case] expected_kind: &str, + #[case] expected_first_items: u64, + #[case] expected_last_items: u64, + ) { + let base = 100u64; + let (words, data_buf_size) = words_from(entries); + let index = build_chunk_index(&words, items_in_page, base, data_buf_size, None, 0).unwrap(); + + assert_eq!(index.row_mapping_debug(), expected_kind); + assert_eq!(index.num_chunks(), entries.len()); + assert_eq!(index.items_in_chunk(0), expected_first_items); + assert_eq!(index.items_in_chunk(entries.len() - 1), expected_last_items); + + // Byte ranges are absolute, contiguous, and exactly cover the data buffer. + let mut expected_start = base; + for (i, &(_, num_bytes)) in entries.iter().enumerate() { + let range = index.byte_range(i); + assert_eq!(range.start, expected_start); + assert_eq!(range.end - range.start, num_bytes as u64); + expected_start = range.end; } + assert_eq!(expected_start, base + data_buf_size); - debug_assert_eq!(zipped_data.len(), len); - // Put the final value in the rep index - // SAFETY: `zipped_data.len() == len` - unsafe { - rep_index_builder.append(zipped_data.len() as u64); + // For flat pages rows == items, so the per-chunk items sum to the page total. + let total_items: u64 = (0..index.num_chunks()) + .map(|i| index.items_in_chunk(i)) + .sum(); + assert_eq!(total_items, items_in_page); + } + + #[test] + fn test_nested_detection_and_axes() { + // Repetition index (stride 2): three chunks holding 5, 4, 3 rows, no trailers. + let rep = rep_bytes_from(&[5, 0, 4, 0, 3, 0]); + + // Uniform leaf chunking: value counts 4, 4, 2. + let (words, data_buf_size) = words_from(&[(2, 8), (2, 8), (0, 8)]); + let index = build_chunk_index(&words, 10, 0, data_buf_size, Some(&rep), 1).unwrap(); + assert_eq!(index.row_mapping_debug(), "nested"); + assert_eq!(index.num_chunks(), 3); + // Rows come from the repetition index, not the value counts. + assert_eq!(index.first_row(0), 0); + assert_eq!(index.rows_in_chunk(0), 5); + assert_eq!(index.first_row(1), 5); + assert_eq!(index.rows_in_chunk(1), 4); + assert_eq!(index.first_row(2), 9); + assert_eq!(index.rows_in_chunk(2), 3); + // Items come from the value words. + assert_eq!(index.items_in_chunk(0), 4); + assert_eq!(index.items_in_chunk(1), 4); + assert_eq!(index.items_in_chunk(2), 2); + + // Non-uniform leaf chunking: value counts 8, 2, 5. + let (words_nu, dbs_nu) = words_from(&[(3, 8), (1, 8), (0, 8)]); + let index_nu = build_chunk_index(&words_nu, 15, 0, dbs_nu, Some(&rep), 1).unwrap(); + assert_eq!(index_nu.row_mapping_debug(), "nested"); + assert_eq!(index_nu.items_in_chunk(0), 8); + assert_eq!(index_nu.items_in_chunk(1), 2); + assert_eq!(index_nu.items_in_chunk(2), 5); + // The row axis is unchanged by the leaf chunking. + assert_eq!(index_nu.rows_in_chunk(0), 5); + } + + #[test] + fn test_uniform_flat_matches_prefix_sum_flat() { + // Distribution: 4 chunks of 4 values, last chunk 3 (15 items total). + let (words, data_buf_size) = words_from(&[(2, 8), (2, 8), (2, 8), (0, 8)]); + let uniform = build_chunk_index(&words, 15, 0, data_buf_size, None, 0).unwrap(); + assert_eq!(uniform.row_mapping_debug(), "uniform_flat"); + + // The same distribution expressed as a non-uniform Flat prefix-sum index. + let byte_starts = PrefixSums::from_deltas([8u64, 8, 8, 8].into_iter(), 4, 32); + let value_starts = PrefixSums::from_deltas([4u64, 4, 4, 3].into_iter(), 4, 15); + let flat = MiniBlockChunkIndex::new(0, byte_starts, RowMapping::Flat { value_starts }); + assert_eq!(flat.row_mapping_debug(), "flat"); + + // Lookup parity: identical byte ranges and item counts. + for i in 0..4 { + assert_eq!(uniform.byte_range(i), flat.byte_range(i)); + assert_eq!(uniform.items_in_chunk(i), flat.items_in_chunk(i)); } - let zipped_data = LanceBuffer::from(zipped_data); - let rep_index = rep_index_builder.into_data(); - let rep_index = if rep_index.is_empty() { - None - } else { - Some(LanceBuffer::from(rep_index)) - }; - SerializedFullZip { - values: zipped_data, - repetition_index: rep_index, + // Scheduler parity across scan / single-row / partial / scattered multi-range. + let range_sets: Vec>> = vec![ + vec![0..15], + vec![0..1], + vec![7..8], + vec![14..15], + vec![3..10], + vec![0..2, 5..6, 12..15], + ]; + for ranges in &range_sets { + let from_uniform = ChunkInstructions::schedule_instructions(&uniform, ranges); + let from_flat = ChunkInstructions::schedule_instructions(&flat, ranges); + assert_eq!(from_uniform, from_flat, "mismatch for ranges {ranges:?}"); } - } - // For variable-size data we encode < control word | length | data > for each value - // - // In addition, we create a second buffer, the repetition index - fn serialize_full_zip_variable( - variable: VariableWidthBlock, - mut repdef: ControlWordIterator, - num_items: u64, - ) -> SerializedFullZip { - let bytes_per_offset = variable.bits_per_offset as usize / 8; - assert_eq!( - variable.bits_per_offset % 8, - 0, - "Only byte-aligned offsets supported" - ); - let len = variable.data.len() - + repdef.bytes_per_word() * num_items as usize - + bytes_per_offset * variable.num_values as usize; - let mut buf = Vec::with_capacity(len); + // A full scan yields one Absent, no-trailer instruction per chunk. + let full = ChunkInstructions::schedule_instructions(&uniform, &[0..15]); + assert_eq!(full.len(), 4); + for (i, inst) in full.iter().enumerate() { + assert_eq!(inst.chunk_idx, i); + assert_eq!(inst.preamble, PreambleAction::Absent); + assert_eq!(inst.rows_to_skip, 0); + assert!(!inst.take_trailer); + } + assert_eq!(full.iter().map(|i| i.rows_to_take).sum::(), 15); + } - let max_rep_index_val = len as u64; - let mut rep_index_builder = - BytepackedIntegerEncoder::with_capacity(num_items as usize + 1, max_rep_index_val); + #[test] + fn test_deep_size_per_variant_below_legacy() { + // The previous representation cached 48 bytes per chunk (24 for ChunkMeta plus + // 24 for a rep-index block); every variant's heap must be well below that. + const LEGACY_PER_CHUNK: usize = 48; + let num_chunks = 3; + let heap = |index: &MiniBlockChunkIndex| index.deep_size_of_children(&mut Context::new()); + + let (uniform_words, uniform_dbs) = words_from(&[(2, 8), (2, 8), (0, 8)]); + let uniform = build_chunk_index(&uniform_words, 10, 0, uniform_dbs, None, 0).unwrap(); + assert_eq!(uniform.row_mapping_debug(), "uniform_flat"); + assert!(heap(&uniform) < LEGACY_PER_CHUNK * num_chunks); + + let (flat_words, flat_dbs) = words_from(&[(3, 8), (1, 8), (0, 8)]); + let flat = build_chunk_index(&flat_words, 11, 0, flat_dbs, None, 0).unwrap(); + assert_eq!(flat.row_mapping_debug(), "flat"); + assert!(heap(&flat) < LEGACY_PER_CHUNK * num_chunks); + // Flat carries a value-starts array that UniformFlat derives arithmetically. + assert!(heap(&flat) > heap(&uniform)); + + let rep = rep_bytes_from(&[4, 0, 3, 0, 3, 0]); + let (nested_words, nested_dbs) = words_from(&[(2, 8), (2, 8), (0, 8)]); + let nested = build_chunk_index(&nested_words, 10, 0, nested_dbs, Some(&rep), 1).unwrap(); + assert_eq!(nested.row_mapping_debug(), "nested"); + assert!(heap(&nested) < LEGACY_PER_CHUNK * num_chunks); + } - // TODO: byte pack the item lengths with varint encoding - match bytes_per_offset { - 4 => { - let offs = variable.offsets.borrow_to_typed_slice::(); - let mut rep_offset = 0; - let mut windows_iter = offs.as_ref().windows(2); - while let Some(control) = repdef.append_next(&mut buf) { - if control.is_new_row { - // We have finished a row - debug_assert!(rep_offset <= len); - // SAFETY: We know that `buf.len() <= len` - unsafe { rep_index_builder.append(rep_offset as u64) }; - } - if control.is_visible { - let window = windows_iter.next().unwrap(); - if control.is_valid_item { - buf.extend_from_slice(&(window[1] - window[0]).to_le_bytes()); - buf.extend_from_slice( - &variable.data[window[0] as usize..window[1] as usize], - ); - } - } - rep_offset = buf.len(); + #[tokio::test] + async fn test_fullzip_initialize_is_lazy() { + use futures::{FutureExt, future::BoxFuture}; + use std::ops::Range; + use std::sync::Mutex; + + #[derive(Debug, Clone)] + struct RecordingScheduler { + data: bytes::Bytes, + requests: Arc>>>>, + } + + impl RecordingScheduler { + fn new(data: bytes::Bytes) -> Self { + Self { + data, + requests: Arc::new(Mutex::new(Vec::new())), } } - 8 => { - let offs = variable.offsets.borrow_to_typed_slice::(); - let mut rep_offset = 0; - let mut windows_iter = offs.as_ref().windows(2); - while let Some(control) = repdef.append_next(&mut buf) { - if control.is_new_row { - // We have finished a row - debug_assert!(rep_offset <= len); - // SAFETY: We know that `buf.len() <= len` - unsafe { rep_index_builder.append(rep_offset as u64) }; - } - if control.is_visible { - let window = windows_iter.next().unwrap(); - if control.is_valid_item { - buf.extend_from_slice(&(window[1] - window[0]).to_le_bytes()); - buf.extend_from_slice( - &variable.data[window[0] as usize..window[1] as usize], - ); - } - } - rep_offset = buf.len(); - } + + fn requests(&self) -> Vec>> { + self.requests.lock().unwrap().clone() } - _ => panic!("Unsupported offset size"), } - // We might have saved a few bytes by not copying lengths when the length was zero. However, - // if we are over `len` then we have a bug. - debug_assert!(buf.len() <= len); - // Put the final value in the rep index - // SAFETY: `zipped_data.len() == len` - unsafe { - rep_index_builder.append(buf.len() as u64); + impl crate::EncodingsIo for RecordingScheduler { + fn submit_request( + &self, + ranges: Vec>, + _priority: u64, + ) -> BoxFuture<'static, crate::Result>> { + self.requests.lock().unwrap().push(ranges.clone()); + let data = ranges + .into_iter() + .map(|range| self.data.slice(range.start as usize..range.end as usize)) + .collect::>(); + std::future::ready(Ok(data)).boxed() + } } - let zipped_data = LanceBuffer::from(buf); - let rep_index = rep_index_builder.into_data(); - debug_assert!(!rep_index.is_empty()); - let rep_index = Some(LanceBuffer::from(rep_index)); - SerializedFullZip { - values: zipped_data, - repetition_index: rep_index, - } - } + #[derive(Debug)] + struct TestFixedDecompressor; - /// Serializes data into a single buffer according to the full-zip format which zips - /// together the repetition, definition, and value data into a single buffer. - fn serialize_full_zip( - compressed_data: PerValueDataBlock, - repdef: ControlWordIterator, - num_items: u64, - ) -> SerializedFullZip { - match compressed_data { - PerValueDataBlock::Fixed(fixed) => { - Self::serialize_full_zip_fixed(fixed, repdef, num_items) + impl FixedPerValueDecompressor for TestFixedDecompressor { + fn decompress( + &self, + _data: FixedWidthDataBlock, + _num_rows: u64, + ) -> crate::Result { + unimplemented!("Test decompressor") } - PerValueDataBlock::Variable(var) => { - Self::serialize_full_zip_variable(var, repdef, num_items) + + fn bits_per_value(&self) -> u64 { + 32 } } + + let io = Arc::new(RecordingScheduler::new(bytes::Bytes::from(vec![ + 0; + 16 * 1024 + ]))); + let mut scheduler = FullZipScheduler { + data_buf_position: 0, + data_buf_size: 4096, + rep_index: Some(FullZipRepIndexDetails { + buf_position: 1000, + bytes_per_value: 4, + }), + priority: 0, + rows_in_page: 100, + bits_per_offset: 32, + details: Arc::new(FullZipDecodeDetails { + value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), + def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), + ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), + max_rep: 0, + max_visible_def: 0, + }), + cached_state: None, + enable_cache: false, + }; + + let io_dyn: Arc = io.clone(); + let initialization = scheduler.init_layout().unwrap(); + assert!( + initialization.required_ranges().next().is_none(), + "FullZip should not request any ranges when caching is disabled" + ); + let buffers = initialization.with_buffers(vec![]).unwrap(); + let cached_data = scheduler.init_from_buffers(buffers, &io_dyn).await.unwrap(); + + assert!( + cached_data + .as_arc_any() + .downcast_ref::() + .is_some(), + "FullZip initialize should not eagerly load repetition index data" + ); + assert!(scheduler.cached_state.is_none()); + assert!( + io.requests().is_empty(), + "FullZip initialize should not issue any I/O" + ); } - fn encode_full_zip( - column_idx: u32, - field: &Field, - compression_strategy: &dyn CompressionStrategy, - data: DataBlock, - repdef: crate::repdef::SerializedRepDefs, - row_number: u64, - num_lists: u64, - ) -> Result { - let max_rep = repdef - .repetition_levels - .as_ref() - .map_or(0, |r| r.iter().max().copied().unwrap_or(0)); - let max_def = repdef - .definition_levels - .as_ref() - .map_or(0, |d| d.iter().max().copied().unwrap_or(0)); + /// Prove that an N-page cache miss issues one `submit_request` carrying every + /// page's metadata range, rather than one submission per page. The production + /// I/O scheduler can then coalesce adjacent ranges within that batch. + #[tokio::test] + async fn test_initialize_coalesces_missed_page_metadata() { + use std::ops::Range; - // To handle FSL we just flatten - // let data = data.flatten(); + use futures::FutureExt; + use futures::future::BoxFuture; - let (num_items, num_visible_items) = - if let Some(rep_levels) = repdef.repetition_levels.as_ref() { - // If there are rep levels there may be "invisible" items and we need to encode - // rep_levels.len() things which might be larger than data.num_values() - (rep_levels.len() as u64, data.num_values()) - } else { - // If there are no rep levels then we encode data.num_values() things - (data.num_values(), data.num_values()) - }; + use super::{ + CachedPage, CachedPageData, Error, PageCacheView, PageDataCacheKey, + PageInfoAndScheduler, PageInitialization, PageInitializationBuffers, PageLoadTask, + StructuralPrimitiveFieldScheduler, + }; + use crate::decoder::{FilterExpression, SchedulerContext, StructuralFieldScheduler}; - let max_visible_def = repdef.max_visible_level.unwrap_or(u16::MAX); + // Give each fake page one fixed-size metadata buffer. The ranges are far + // apart because this test observes batching, not the I/O layer's distance- + // based range coalescing. + #[derive(Debug)] + struct FakePageScheduler { + meta_range: Range, + } + impl StructuralPageScheduler for FakePageScheduler { + fn init_layout(&self) -> crate::Result { + Ok(PageInitialization::new([Some(self.meta_range.clone())])) + } + fn init_from_buffers<'a>( + &'a mut self, + buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, crate::Result>> { + let [buffer] = buffers.try_into_array("fake").unwrap(); + let buffer = buffer.unwrap(); + assert_eq!( + &buffer[..8], + &self.meta_range.start.to_le_bytes(), + "each page gets the buffer for its own range" + ); + std::future::ready(Ok(Arc::new(CacheMarker(42)) as Arc)).boxed() + } + fn try_load(&mut self, data: &Arc) -> crate::Result<()> { + data.clone() + .as_arc_any() + .downcast::() + .map(|_| ()) + .map_err(|_| { + Error::invalid_input_source( + "Fake page cache data has an unexpected type".into(), + ) + }) + } + fn schedule_ranges( + &self, + _ranges: &[Range], + _io: &Arc, + ) -> crate::Result> { + unreachable!("initialize never schedules data reads"); + } + } - let repdef_iter = build_control_word_iterator( - repdef.repetition_levels.as_deref(), - max_rep, - repdef.definition_levels.as_deref(), - max_def, - max_visible_def, - num_items as usize, + const NUM_PAGES: u64 = 64; + const META_LEN: u64 = 64; + const STRIDE: u64 = 1024 * 1024; + let page_schedulers = (0..NUM_PAGES) + .map(|i| PageInfoAndScheduler { + page_index: i as usize, + row_range: (i * 100)..((i + 1) * 100), + scheduler: Box::new(FakePageScheduler { + meta_range: (i * STRIDE)..(i * STRIDE + META_LEN), + }), + }) + .collect(); + let mut scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + page_schedulers, + 0, + Arc::from("test"), ); - let bits_rep = repdef_iter.bits_rep(); - let bits_def = repdef_iter.bits_def(); - let compressor = compression_strategy.create_per_value(field, &data)?; - let (compressed_data, value_encoding) = compressor.compress(data)?; + let io = Arc::new(RecordingScheduler::default()); + // A no-op cache always misses, so every page is a miss. + let cache = Arc::new(lance_core::cache::LanceCache::no_cache()); + let context = SchedulerContext::new(io.clone(), cache.clone()); - let description = match &compressed_data { - PerValueDataBlock::Fixed(fixed) => ProtobufUtils21::fixed_full_zip_layout( - bits_rep, - bits_def, - fixed.bits_per_value as u32, - value_encoding, - &repdef.def_meaning, - num_items as u32, - num_visible_items as u32, - ), - PerValueDataBlock::Variable(variable) => ProtobufUtils21::variable_full_zip_layout( - bits_rep, - bits_def, - variable.bits_per_offset as u32, - value_encoding, - &repdef.def_meaning, - num_items as u32, - num_visible_items as u32, - ), - }; + // `None` requests every page; against an always-miss cache that's an + // N-page miss. + let filter = FilterExpression::no_filter(); + scheduler.initialize(None, &filter, &context).await.unwrap(); + + { + let requests = io.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 1, + "an N-page miss must issue exactly one request, not one per page" + ); + assert_eq!( + requests[0].len() as u64, + NUM_PAGES, + "the single request must carry every missed page's metadata range" + ); + } - let zipped = Self::serialize_full_zip(compressed_data, repdef_iter, num_items); + io.requests.lock().unwrap().clear(); + let cache_misses_before = cache.stats().await.misses; + let requested_ranges = Arc::<[Range]>::from([150..151, 6_350..6_351]); + scheduler + .initialize(Some(&requested_ranges), &filter, &context) + .await + .unwrap(); + assert_eq!( + cache.stats().await.misses - cache_misses_before, + 2, + "range-scoped initialization must only look up touched pages" + ); - let data = if let Some(repindex) = zipped.repetition_index { - vec![zipped.values, repindex] - } else { - vec![zipped.values] + { + let requests = io.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0], + vec![ + STRIDE..(STRIDE + META_LEN), + 63 * STRIDE..(63 * STRIDE + META_LEN), + ], + "range-scoped initialization must omit untouched pages" + ); + } + + io.requests.lock().unwrap().clear(); + let cache = Arc::new(lance_core::cache::LanceCache::with_capacity(4 * 1024)); + let cache_key = PageDataCacheKey { + column_index: 9, + page_index: 0, + view: PageCacheView::Native, + target_data_type_tag: Arc::from("test"), }; + cache + .insert_with_key( + &cache_key, + Arc::new(CachedPage { + data: Arc::new(super::NoCachedPageData), + }), + ) + .await; + let mut recovering_scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + vec![PageInfoAndScheduler { + page_index: 0, + row_range: 0..100, + scheduler: Box::new(FakePageScheduler { + meta_range: 0..META_LEN, + }), + }], + 9, + Arc::from("test"), + ); + let recovering_context = SchedulerContext::new(io.clone(), cache.clone()); + recovering_scheduler + .initialize(None, &filter, &recovering_context) + .await + .unwrap(); + assert_eq!(io.requests.lock().unwrap().as_slice(), &[vec![0..META_LEN]]); + + io.requests.lock().unwrap().clear(); + let mut warm_scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + vec![PageInfoAndScheduler { + page_index: 0, + row_range: 0..100, + scheduler: Box::new(FakePageScheduler { + meta_range: 0..META_LEN, + }), + }], + 9, + Arc::from("test"), + ); + warm_scheduler + .initialize(None, &filter, &recovering_context) + .await + .unwrap(); + assert!( + io.requests.lock().unwrap().is_empty(), + "the rebuilt cache entry must be reusable" + ); - Ok(EncodedPage { - num_rows: num_lists, - column_idx, - data, - description: PageEncoding::Structural(description), - row_number, - }) + let mut out_of_order_scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + vec![ + PageInfoAndScheduler { + page_index: 0, + row_range: 0..100, + scheduler: Box::new(FakePageScheduler { + meta_range: STRIDE..(STRIDE + META_LEN), + }), + }, + PageInfoAndScheduler { + page_index: 1, + row_range: 100..200, + scheduler: Box::new(FakePageScheduler { + meta_range: 0..META_LEN, + }), + }, + ], + 0, + Arc::from("test"), + ); + out_of_order_scheduler + .initialize(None, &filter, &context) + .await + .unwrap(); + assert_eq!( + io.requests.lock().unwrap().as_slice(), + &[vec![0..META_LEN, STRIDE..(STRIDE + META_LEN)]], + "metadata reads must be sorted without changing page buffer assignment" + ); } - /// Estimates the total size of dictionary-encoded data - /// - /// Dictionary encoding splits data into two parts: - /// 1. Dictionary: stores unique values - /// 2. Indices: maps each value to a dictionary entry - /// - /// For FixedWidth: - /// - Dictionary values: cardinality × (bits_per_value / 8) - /// - Indices: num_values × 4 bytes (32-bit i32) - /// - /// For VariableWidth (strings/binary): - /// - Dictionary values: cardinality × avg_value_size (actual data) - /// - Indices: num_values × 4 bytes (32-bit i32) - fn estimate_dict_size(data_block: &DataBlock, version: LanceFileVersion) -> Option { - let cardinality = if let Some(cardinality_array) = data_block.get_stat(Stat::Cardinality) { - cardinality_array.as_primitive::().value(0) - } else { - return None; - }; + #[tokio::test] + async fn page_cache_key_distinguishes_page_view_and_target_type() { + let cache = lance_core::cache::LanceCache::with_capacity(4 * 1024); + let cases = [ + ( + PageDataCacheKey { + column_index: 7, + page_index: 0, + view: PageCacheView::Native, + target_data_type_tag: Arc::from("Boolean"), + }, + 10, + ), + ( + PageDataCacheKey { + column_index: 7, + page_index: 1, + view: PageCacheView::Native, + target_data_type_tag: Arc::from("Boolean"), + }, + 11, + ), + ( + PageDataCacheKey { + column_index: 7, + page_index: 0, + view: PageCacheView::Native, + target_data_type_tag: Arc::from("UInt8"), + }, + 12, + ), + ( + PageDataCacheKey { + column_index: 7, + page_index: 0, + view: PageCacheView::BlobDescriptor, + target_data_type_tag: Arc::from("Struct"), + }, + 20, + ), + ( + PageDataCacheKey { + column_index: 7, + page_index: 0, + view: PageCacheView::BlobPayload, + target_data_type_tag: Arc::from("LargeBinary"), + }, + 21, + ), + ]; - let num_values = data_block.num_values(); - if num_values == 0 { - return None; + for (key, marker) in &cases { + cache + .insert_with_key( + key, + Arc::new(CachedPage { + data: Arc::new(CacheMarker(*marker)), + }), + ) + .await; } - match data_block { - DataBlock::FixedWidth(fixed) => { - if fixed.bits_per_value == 64 && version < LanceFileVersion::V2_2 { - return None; - } - // The current fixed-width dictionary encoding uses i32 indices. - if cardinality > i32::MAX as u64 { - return None; - } - // We currently only support dictionary encoding for 64-bit and 128-bit fixed-width values. - if fixed.bits_per_value != 64 && fixed.bits_per_value != 128 { - return None; - } - if fixed.bits_per_value % 8 != 0 { - return None; - } - // Dictionary: cardinality unique values at value bit width - let dict_size = cardinality * (fixed.bits_per_value / 8); - // Indices: num_values indices at 32 bits each - let indices_size = num_values * (DICT_INDICES_BITS_PER_VALUE / 8); - Some(dict_size + indices_size) - } - DataBlock::VariableWidth(var) => { - // Only 32-bit and 64-bit offsets are supported - if var.bits_per_offset != 32 && var.bits_per_offset != 64 { - return None; - } - if cardinality > i32::MAX as u64 { - return None; - } - let bytes_per_offset = var.bits_per_offset as u64 / 8; - let avg_value_size = (var.data.len() as u64) / num_values; + assert_eq!(cache.size().await, cases.len()); + for (key, expected) in &cases { + let cached = cache.get_with_key(key).await.unwrap(); + let marker = cached + .data + .clone() + .as_arc_any() + .downcast::() + .unwrap(); + assert_eq!(marker.0, *expected); + } + } - let dict_values_size = cardinality.checked_mul(avg_value_size)?; - let dict_offsets_size = cardinality.checked_mul(bytes_per_offset)?; - let indices_size = num_values.checked_mul(DICT_INDICES_BITS_PER_VALUE / 8)?; + #[tokio::test] + async fn no_op_pages_do_not_create_cache_entries() { + let mut scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + vec![PageInfoAndScheduler { + page_index: 0, + row_range: 0..10, + scheduler: Box::new(SimpleAllNullScheduler::default()), + }], + 3, + Arc::from("test"), + ); + let io: Arc = + Arc::new(SimulatedScheduler::new(bytes::Bytes::new())); + let cache = Arc::new(lance_core::cache::LanceCache::with_capacity(4 * 1024)); + let context = SchedulerContext::new(io, cache.clone()); + + scheduler + .initialize(None, &FilterExpression::no_filter(), &context) + .await + .unwrap(); - dict_values_size - .checked_add(dict_offsets_size)? - .checked_add(indices_size) - } - _ => None, - } + assert_eq!(cache.size().await, 0); } - fn should_dictionary_encode( - data_block: &DataBlock, - field: &Field, - version: LanceFileVersion, - ) -> bool { - // Since we only dictionary encode FixedWidth and VariableWidth blocks for now, we skip - // estimating the size - match data_block { - DataBlock::FixedWidth(fixed) => { - if fixed.bits_per_value == 64 && version < LanceFileVersion::V2_2 { - return false; - } - if fixed.bits_per_value != 64 && fixed.bits_per_value != 128 { - return false; - } - } - DataBlock::VariableWidth(_) => {} - _ => return false, - } + #[test] + fn page_initialization_preserves_optional_buffer_slots() { + let initialization = PageInitialization::new([None, Some(20..22), None, Some(10..11)]); + assert_eq!( + initialization + .required_ranges() + .cloned() + .collect::>(), + [20..22, 10..11] + ); - // Currently VariableWidth only supports 32 and 64 bits - if let DataBlock::VariableWidth(var) = data_block { - if var.bits_per_offset != 32 && var.bits_per_offset != 64 { - return false; - } - } + let buffers = initialization + .with_buffers(vec![ + bytes::Bytes::from_static(b"ab"), + bytes::Bytes::from_static(b"c"), + ]) + .unwrap(); + let [first, second, third, fourth] = buffers.try_into_array("test").unwrap(); + assert!(first.is_none()); + assert_eq!(second.unwrap(), bytes::Bytes::from_static(b"ab")); + assert!(third.is_none()); + assert_eq!(fourth.unwrap(), bytes::Bytes::from_static(b"c")); - // Don't dictionary encode tiny arrays - let too_small = env::var("LANCE_ENCODING_DICT_TOO_SMALL") - .ok() - .and_then(|val| val.parse().ok()) - .unwrap_or(100); - if data_block.num_values() < too_small { - return false; - } + let error = PageInitialization::new([Some(0..1)]) + .with_buffers(Vec::new()) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!(error.to_string().contains("1 declared ranges")); + } - // Get size ratio from metadata or env var, default to 0.8 - let threshold_ratio = field - .metadata - .get(DICT_SIZE_RATIO_META_KEY) - .and_then(|val| val.parse::().ok()) - .or_else(|| { - env::var("LANCE_ENCODING_DICT_SIZE_RATIO") - .ok() - .and_then(|val| val.parse().ok()) + #[tokio::test] + async fn initialize_builds_only_requested_page_schedulers() { + use crate::compression::DefaultDecompressionStrategy; + use crate::decoder::{ + ColumnInfo, FilterExpression, PageEncoding, PageInfo, SchedulerContext, + StructuralFieldScheduler, + }; + use crate::format::pb; + use lance_core::datatypes::Field; + + const NUM_PAGES: u64 = 64; + const ROWS_PER_PAGE: u64 = 100; + // All-null constant pages need no initialization I/O, so this test + // observes construction alone. + let page_infos = (0..NUM_PAGES) + .map(|page_index| PageInfo { + num_rows: ROWS_PER_PAGE, + priority: page_index * ROWS_PER_PAGE, + encoding: PageEncoding::Structural(pb21::PageLayout { + layout: Some(pb21::page_layout::Layout::ConstantLayout( + pb21::ConstantLayout { + layers: vec![pb21::RepDefLayer::RepdefNullableItem as i32], + ..Default::default() + }, + )), + }), + buffer_offsets_and_sizes: Arc::new([]), }) - .unwrap_or(0.8); + .collect::>(); + let column_info = Arc::new(ColumnInfo::new( + 3, + Arc::from(page_infos), + Vec::new(), + pb::ColumnEncoding::default(), + )); + let field = Field::try_from(&ArrowField::new("x", DataType::Int32, true)).unwrap(); + #[allow(deprecated)] + let eager = StructuralPrimitiveFieldScheduler::try_new( + &column_info, + &DefaultDecompressionStrategy {}, + false, + &field, + ) + .unwrap(); + assert_eq!(eager.page_schedulers.len(), NUM_PAGES as usize); + assert!(eager.page_source.is_none()); - // Validate size ratio is in valid range - if threshold_ratio <= 0.0 || threshold_ratio > 1.0 { - panic!( - "Invalid parameter: dict-size-ratio is {} which is not in the range (0, 1].", - threshold_ratio - ); - } + let mut scheduler = StructuralPrimitiveFieldScheduler::try_new_lazy( + column_info, + Arc::new(DefaultDecompressionStrategy {}), + false, + &field, + ) + .unwrap(); + assert!( + scheduler.page_schedulers.is_empty(), + "construction must not build page schedulers" + ); + assert_eq!(scheduler.page_row_ends.len(), NUM_PAGES as usize); - // Get raw data size - let data_size = data_block.data_size(); + let io: Arc = Arc::new(SimulatedScheduler::new(bytes::Bytes::new())); + let cache = Arc::new(lance_core::cache::LanceCache::no_cache()); + let mut context = SchedulerContext::new(io, cache); + let filter = FilterExpression::no_filter(); - // Estimate dictionary-encoded size - let Some(encoded_size) = Self::estimate_dict_size(data_block, version) else { - return false; + let requested_ranges = [150..151, 6_350..6_351]; + scheduler + .initialize(Some(&requested_ranges), &filter, &context) + .await + .unwrap(); + let built_pages = |scheduler: &StructuralPrimitiveFieldScheduler| { + scheduler + .page_schedulers + .iter() + .map(|page| page.page_index) + .collect::>() }; + assert_eq!(built_pages(&scheduler), [1, 63]); + assert_eq!(scheduler.page_schedulers[1].row_range, 6_300..6_400); + assert!(scheduler.page_source.is_some()); - let size_ratio_actual = if data_size > 0 { - encoded_size as f64 / data_size as f64 - } else { - return false; - }; - size_ratio_actual < threshold_ratio - } + { + let mut job = scheduler + .schedule_ranges(&requested_ranges, &filter) + .unwrap(); + // Both pages fit one read batch, so one call schedules them together. + assert_eq!(job.schedule_next(&mut context).unwrap().len(), 2); + assert!(job.schedule_next(&mut context).unwrap().is_empty()); + + // Rows outside the initialized ranges must fail cleanly, not panic + // on an uninitialized page. + let mut job = scheduler.schedule_ranges(&[0..1], &filter).unwrap(); + let error = job.schedule_next(&mut context).unwrap_err(); + assert!(matches!(error, lance_core::Error::Internal { .. })); + assert!(error.to_string().contains("not initialized")); + } - // Creates an encode task, consuming all buffered data - fn do_flush( - &mut self, - arrays: Vec, - repdefs: Vec, - row_number: u64, - num_rows: u64, - ) -> Result> { - let column_idx = self.column_index; - let compression_strategy = self.compression_strategy.clone(); - let field = self.field.clone(); - let encoding_metadata = self.encoding_metadata.clone(); - let support_large_chunk = self.support_large_chunk; - let version = self.version; - let task = spawn_cpu(move || { - let num_values = arrays.iter().map(|arr| arr.len() as u64).sum(); - let is_simple_validity = repdefs.iter().all(|rd| rd.is_simple_validity()); - let has_repdef_info = repdefs.iter().any(|rd| !rd.is_empty()); - let repdef = RepDefBuilder::serialize(repdefs); - - if num_values == 0 { - // We should not encode empty arrays. So if we get here that should mean that we - // either have all empty lists or all null lists (or a mix). We still need to encode - // the rep/def information but we can skip the data encoding. - log::debug!("Encoding column {} with {} items ({} rows) using complex-null layout", column_idx, num_values, num_rows); - return Self::encode_complex_all_null(column_idx, repdef, row_number, num_rows); - } - - let leaf_validity = Self::leaf_validity(&repdef, num_values as usize)?; - let all_null = leaf_validity - .as_ref() - .map(|validity| validity.count_set_bits() == 0) - .unwrap_or(false); + // Widening the request builds only the new pages and keeps page order. + scheduler + .initialize(Some(&[0..250]), &filter, &context) + .await + .unwrap(); + assert_eq!(built_pages(&scheduler), [0, 1, 2, 63]); + + // The eager path builds every page and releases the page source. + scheduler.initialize(None, &filter, &context).await.unwrap(); + assert_eq!(scheduler.page_schedulers.len(), NUM_PAGES as usize); + assert!(scheduler.page_source.is_none()); + let mut job = scheduler.schedule_ranges(&[0..1], &filter).unwrap(); + assert_eq!(job.schedule_next(&mut context).unwrap().len(), 1); + } + + /// Records every `submit_request` (ranges and priority) and answers each + /// range with a zero buffer that starts with the range's offset, so a test + /// can count requests and check that every reader got its own ranges back. + #[derive(Debug, Default)] + struct RecordingScheduler { + requests: std::sync::Mutex>>>, + priorities: std::sync::Mutex>, + } + + impl crate::EncodingsIo for RecordingScheduler { + fn submit_request( + &self, + ranges: Vec>, + priority: u64, + ) -> futures::future::BoxFuture<'static, crate::Result>> { + use futures::FutureExt; + + let buffers = ranges + .iter() + .map(|range| { + let mut buffer = vec![0u8; (range.end - range.start) as usize]; + let marker = range.start.to_le_bytes(); + let marker_len = marker.len().min(buffer.len()); + buffer[..marker_len].copy_from_slice(&marker[..marker_len]); + Bytes::from(buffer) + }) + .collect(); + self.requests.lock().unwrap().push(ranges); + self.priorities.lock().unwrap().push(priority); + std::future::ready(Ok(buffers)).boxed() + } + } - if all_null { - return if is_simple_validity { - log::debug!( - "Encoding column {} with {} items ({} rows) using simple-null layout", - column_idx, - num_values, - num_rows - ); - Self::encode_simple_all_null(column_idx, num_values, row_number) - } else { - log::debug!( - "Encoding column {} with {} items ({} rows) using complex-null layout", - column_idx, - num_values, - num_rows - ); - Self::encode_complex_all_null(column_idx, repdef, row_number, num_rows) - }; - } + #[tokio::test] + async fn page_read_batch_submits_pending_reads_as_one_request() { + let io = Arc::new(RecordingScheduler::default()); + let batch = RequestBatch::new(io.clone()); + let first = batch.submit_request(vec![100..108, 300..308], 7); + let second = batch.submit_request(vec![200..208], 3); + assert_eq!(batch.pending_bytes(), 24); + assert!( + io.requests.lock().unwrap().is_empty(), + "nothing is read before the flush" + ); - if let DataType::Struct(fields) = &field.data_type() { - if fields.is_empty() { - if has_repdef_info { - return Err(Error::InvalidInput { source: format!("Empty structs with rep/def information are not yet supported. The field {} is an empty struct that either has nulls or is in a list.", field.name).into(), location: location!() }); - } - // This is maybe a little confusing but the reader should never look at this anyways and it - // seems like overkill to invent a new layout just for "empty structs". - return Self::encode_simple_all_null(column_idx, num_values, row_number); - } - } + batch.flush(); + { + let requests = io.requests.lock().unwrap(); + assert_eq!(requests.len(), 1, "the batch is one request"); + assert_eq!( + requests[0], + vec![100..108, 200..208, 300..308], + "the batch is sorted by file offset" + ); + } + assert_eq!( + io.priorities.lock().unwrap()[0], + 3, + "the batch takes the lowest priority" + ); + let first = first.await.unwrap(); + assert_eq!(first.len(), 2); + assert_eq!(first[0].as_ref(), &100_u64.to_le_bytes()); + assert_eq!(first[1].as_ref(), &300_u64.to_le_bytes()); + let second = second.await.unwrap(); + assert_eq!(second.len(), 1); + assert_eq!(second[0].as_ref(), &200_u64.to_le_bytes()); + + // A read submitted after the flush is batched until it is awaited. + let late = batch.submit_request(vec![400..408], 9).await.unwrap(); + assert_eq!(late[0].as_ref(), &400_u64.to_le_bytes()); + assert_eq!(io.requests.lock().unwrap().len(), 2); + + // Queued reads that would exceed the budget are submitted first, so + // no batch grows past it. + let io = Arc::new(RecordingScheduler::default()); + let batch = RequestBatch::new(io.clone()); + let budget = crate::decoder::IO_REQUEST_BATCH_BYTES; + let first = batch.submit_request(vec![0..budget], 1); + assert!(io.requests.lock().unwrap().is_empty()); + let second = batch.submit_request(vec![budget..(budget + 8)], 2); + assert_eq!( + io.requests.lock().unwrap().as_slice(), + &[vec![0..budget]], + "the full batch is submitted before the read that would overflow it" + ); + batch.flush(); + assert_eq!(io.requests.lock().unwrap().len(), 2); + assert_eq!(first.await.unwrap()[0].len(), budget as usize); + assert_eq!(second.await.unwrap()[0].as_ref(), &budget.to_le_bytes()); + } - let data_block = DataBlock::from_arrays(&arrays, num_values); + #[tokio::test] + async fn request_batch_flush_keeps_collecting_and_awaiting_flushes() { + let io = Arc::new(RecordingScheduler::default()); + let batch = RequestBatch::new(io.clone()); + let first = batch.submit_request(vec![100..108], 1); + let second = batch.submit_request(vec![300..308], 2); + batch.flush(); + assert_eq!( + io.requests.lock().unwrap().as_slice(), + &[vec![100..108, 300..308]], + "a flush submits the collected reads as one request" + ); + // The batch keeps collecting after a flush. + let third = batch.submit_request(vec![500..508], 3); + let fourth = batch.submit_request(vec![700..708], 4); + assert_eq!(batch.pending_bytes(), 16); + assert_eq!(io.requests.lock().unwrap().len(), 1); + // Awaiting a read submits whatever is queued, so a read queued outside + // a scheduling step (indirect I/O) is never stuck behind a missing flush. + assert_eq!(third.await.unwrap()[0].as_ref(), &500_u64.to_le_bytes()); + assert_eq!( + io.requests.lock().unwrap().as_slice(), + &[vec![100..108, 300..308], vec![500..508, 700..708]] + ); + assert_eq!(fourth.await.unwrap()[0].as_ref(), &700_u64.to_le_bytes()); + assert_eq!(first.await.unwrap()[0].as_ref(), &100_u64.to_le_bytes()); + assert_eq!(second.await.unwrap()[0].as_ref(), &300_u64.to_le_bytes()); + assert_eq!(io.requests.lock().unwrap().len(), 2); + + // Futures that submit when first polled and are polled in the same + // pass (struct children initializing, list columns fetching items) + // end up in one request: the flush waits for one yield. + let (fifth, sixth) = futures::join!( + async { batch.submit_request(vec![900..908], 5).await.unwrap() }, + async { batch.submit_request(vec![1100..1108], 6).await.unwrap() }, + ); + assert_eq!(fifth[0].as_ref(), &900_u64.to_le_bytes()); + assert_eq!(sixth[0].as_ref(), &1100_u64.to_le_bytes()); + assert_eq!( + io.requests.lock().unwrap().last().unwrap(), + &vec![900..908, 1100..1108], + "reads submitted by sibling futures share one request" + ); + assert_eq!(io.requests.lock().unwrap().len(), 3); + } - if version.resolve() >= LanceFileVersion::V2_2 { - if let Some(scalar) = Self::find_constant_scalar(&arrays, leaf_validity.as_ref())? - { - log::debug!( - "Encoding column {} with {} items ({} rows) using constant layout", - column_idx, - num_values, - num_rows - ); - return constant::encode_constant_page( - column_idx, - scalar, - repdef, - row_number, - num_rows, - ); - } - } + #[tokio::test] + async fn schedule_next_batches_page_reads_up_to_the_budget() { + use super::{ + CachedPageData, IO_REQUEST_BATCH_BYTES, PageInfoAndScheduler, PageInitialization, + PageInitializationBuffers, PageLoadTask, StructuralPageScheduler, + StructuralPrimitiveFieldScheduler, + }; + use crate::decoder::{FilterExpression, SchedulerContext, StructuralFieldScheduler}; - let requires_full_zip_packed_struct = - if let DataBlock::Struct(ref struct_data_block) = data_block { - struct_data_block.has_variable_width_child() - } else { - false - }; + /// A page that reads `data_ranges`, one shard (and one load task) per + /// range. The bytes it gets back are covered by the batch test, so its + /// load futures just fail and the test never has to build a decoder. + #[derive(Debug)] + struct FakeDataPage { + data_ranges: Vec>, + } - if requires_full_zip_packed_struct { - log::debug!( - "Encoding column {} with {} items using full-zip packed struct layout", - column_idx, - num_values - ); - return Self::encode_full_zip( - column_idx, - &field, - compression_strategy.as_ref(), - data_block, - repdef, - row_number, - num_rows, - ); + impl StructuralPageScheduler for FakeDataPage { + fn needs_initialization(&self) -> bool { + false } - 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, - ) - } else { - // Try dictionary encoding first if applicable. If encoding aborts, fall back to the - // preferred structural encoding. - let dict_result = if Self::should_dictionary_encode(&data_block, &field, version) { - log::debug!( - "Encoding column {} with {} items using dictionary encoding (mini-block layout)", - column_idx, - num_values - ); - dict::dictionary_encode(data_block.clone()) - } else { - None - }; - - if let Some((indices_data_block, dictionary_data_block)) = dict_result { - 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 if Self::prefers_miniblock(&data_block, encoding_metadata.as_ref()) { - log::debug!( - "Encoding column {} with {} items using mini-block layout", - column_idx, - num_values - ); - Self::encode_miniblock( - column_idx, - &field, - compression_strategy.as_ref(), - data_block, - repdef, - row_number, - None, - num_rows, - support_large_chunk, - ) - } else if Self::prefers_fullzip(encoding_metadata.as_ref()) { - log::debug!( - "Encoding column {} with {} items using full-zip layout", - column_idx, - num_values - ); - Self::encode_full_zip( - column_idx, - &field, - compression_strategy.as_ref(), - data_block, - repdef, - row_number, - num_rows, - ) - } else { - Err(Error::InvalidInput { source: format!("Cannot determine structural encoding for field {}. This typically indicates an invalid value of the field metadata key {}", field.name, STRUCTURAL_ENCODING_META_KEY).into(), location: location!() }) - } + fn init_layout(&self) -> crate::Result { + unreachable!("fake data pages need no initialization") } - }) - .boxed(); - Ok(vec![task]) - } - fn extract_validity_buf( - array: Arc, - repdef: &mut RepDefBuilder, - keep_original_array: bool, - ) -> Result> { - if let Some(validity) = array.nulls() { - if keep_original_array { - repdef.add_validity_bitmap(validity.clone()); - } else { - repdef.add_validity_bitmap(deep_copy_nulls(Some(validity)).unwrap()); + fn init_from_buffers<'a>( + &'a mut self, + _buffers: PageInitializationBuffers, + _io: &Arc, + ) -> futures::future::BoxFuture<'a, crate::Result>> + { + unreachable!("fake data pages need no initialization") } - let data_no_nulls = array.to_data().into_builder().nulls(None).build()?; - Ok(make_array(data_no_nulls)) - } else { - repdef.add_no_null(array.len()); - Ok(array) - } - } - fn extract_validity( - mut array: Arc, - repdef: &mut RepDefBuilder, - keep_original_array: bool, - ) -> Result> { - match array.data_type() { - DataType::Null => { - repdef.add_validity_bitmap(NullBuffer::new(BooleanBuffer::new_unset(array.len()))); - Ok(array) - } - DataType::Dictionary(_, _) => { - array = dict::normalize_dict_nulls(array)?; - Self::extract_validity_buf(array, repdef, keep_original_array) + fn try_load(&mut self, _data: &Arc) -> crate::Result<()> { + Ok(()) } - // Extract our validity buf but NOT any child validity bufs. (they will be encoded in - // as part of the values). Note: for FSL we do not use repdef.add_fsl because we do - // NOT want to increase the repdef depth. - // - // This would be quite catasrophic for something like vector embeddings. Imagine we - // had thousands of vectors and some were null but no vector contained null items. If - // we treated the vectors (primitive FSL) like we treat structural FSL we would end up - // with a rep/def value for every single item in the vector. - _ => Self::extract_validity_buf(array, repdef, keep_original_array), - } - } -} - -impl FieldEncoder for PrimitiveStructuralEncoder { - // Buffers data, if there is enough to write a page then we create an encode task - fn maybe_encode( - &mut self, - array: ArrayRef, - _external_buffers: &mut OutOfLineBuffers, - mut repdef: RepDefBuilder, - row_number: u64, - num_rows: u64, - ) -> Result> { - let array = Self::extract_validity(array, &mut repdef, self.keep_original_array)?; - self.accumulated_repdefs.push(repdef); - if let Some((arrays, row_number, num_rows)) = - self.accumulation_queue.insert(array, row_number, num_rows) - { - let accumulated_repdefs = std::mem::take(&mut self.accumulated_repdefs); - Ok(self.do_flush(arrays, accumulated_repdefs, row_number, num_rows)?) - } else { - Ok(vec![]) + fn schedule_ranges( + &self, + ranges: &[Range], + io: &Arc, + ) -> crate::Result> { + use futures::FutureExt; + + let num_rows = ranges.iter().map(|r| r.end - r.start).sum(); + Ok(self + .data_ranges + .iter() + .enumerate() + .map(|(shard, range)| { + drop(io.submit_request(vec![range.clone()], range.start)); + PageLoadTask { + decoder_fut: std::future::ready(Err(lance_core::Error::internal( + "fake page", + ))) + .boxed(), + // The page's rows are reported once, on the first shard. + num_rows: if shard == 0 { num_rows } else { 0 }, + } + }) + .collect()) + } } - } - // If there is any data left in the buffer then create an encode task from it - fn flush(&mut self, _external_buffers: &mut OutOfLineBuffers) -> Result> { - if let Some((arrays, row_number, num_rows)) = self.accumulation_queue.flush() { - let accumulated_repdefs = std::mem::take(&mut self.accumulated_repdefs); - Ok(self.do_flush(arrays, accumulated_repdefs, row_number, num_rows)?) - } else { - Ok(vec![]) + fn scheduler_with_pages( + page_bytes: u64, + num_pages: u64, + ) -> StructuralPrimitiveFieldScheduler { + // Leave a gap between pages so the test can tell batching from + // the I/O layer's coalescing of adjacent ranges. + let stride = page_bytes * 2; + let pages = (0..num_pages) + .map(|page| PageInfoAndScheduler { + page_index: page as usize, + row_range: (page * 10)..((page + 1) * 10), + scheduler: Box::new(FakeDataPage { + data_ranges: vec![(page * stride)..(page * stride + page_bytes)], + }), + }) + .collect(); + StructuralPrimitiveFieldScheduler::from_page_schedulers(pages, 0, Arc::from("test")) } - } - fn num_columns(&self) -> u32 { - 1 - } + let filter = FilterExpression::no_filter(); + let cache = Arc::new(lance_core::cache::LanceCache::no_cache()); + + // Small pages: every page of the request is read in one batch. + let io = Arc::new(RecordingScheduler::default()); + let scheduler = scheduler_with_pages(64, 6); + let mut context = SchedulerContext::new(io.clone(), cache.clone()); + let mut job = scheduler.schedule_ranges(&[5..45], &filter).unwrap(); + let scan_lines = job.schedule_next(&mut context).unwrap(); + context.flush_io(); + assert_eq!(scan_lines.len(), 5, "one scan line per touched page"); + assert_eq!( + scan_lines + .iter() + .map(|line| line.rows_scheduled) + .sum::(), + 40 + ); + assert!(job.schedule_next(&mut context).unwrap().is_empty()); + { + let requests = io.requests.lock().unwrap(); + assert_eq!(requests.len(), 1, "five pages cost one request"); + assert_eq!( + requests[0], + vec![0..64, 128..192, 256..320, 384..448, 512..576], + "the untouched last page is not read" + ); + } + assert_eq!( + io.priorities.lock().unwrap()[0], + 0, + "the batch carries the first page's priority" + ); - fn finish( - &mut self, - _external_buffers: &mut OutOfLineBuffers, - ) -> BoxFuture<'_, Result>> { - std::future::ready(Ok(vec![EncodedColumn::default()])).boxed() + // Pages as large as the batch budget are read one request at a time. + let io = Arc::new(RecordingScheduler::default()); + let scheduler = scheduler_with_pages(IO_REQUEST_BATCH_BYTES, 2); + let mut context = SchedulerContext::new(io.clone(), cache); + let mut job = scheduler.schedule_ranges(&[0..20], &filter).unwrap(); + let first = job.schedule_next(&mut context).unwrap(); + context.flush_io(); + assert_eq!(first.len(), 1); + assert_eq!(io.requests.lock().unwrap().len(), 1); + let second = job.schedule_next(&mut context).unwrap(); + context.flush_io(); + assert_eq!(second.len(), 1); + assert_eq!(io.requests.lock().unwrap().len(), 2); + assert!(job.schedule_next(&mut context).unwrap().is_empty()); + + // A page that shards its own reads (like a blob page) keeps every + // shard in a bounded read instead of one request for the whole page. + let io = Arc::new(RecordingScheduler::default()); + let shard = IO_REQUEST_BATCH_BYTES; + let scheduler = StructuralPrimitiveFieldScheduler::from_page_schedulers( + vec![PageInfoAndScheduler { + page_index: 0, + row_range: 0..10, + scheduler: Box::new(FakeDataPage { + data_ranges: vec![0..shard, (2 * shard)..(3 * shard)], + }), + }], + 0, + Arc::from("test"), + ); + let mut context = SchedulerContext::new( + io.clone(), + Arc::new(lance_core::cache::LanceCache::no_cache()), + ); + let mut job = scheduler.schedule_ranges(&[0..10], &filter).unwrap(); + let scan_lines = job.schedule_next(&mut context).unwrap(); + context.flush_io(); + assert_eq!(scan_lines.len(), 2, "one scan line per shard"); + assert_eq!( + io.requests.lock().unwrap().as_slice(), + &[vec![0..shard], vec![(2 * shard)..(3 * shard)]], + "each shard stays its own bounded request" + ); + assert!(job.schedule_next(&mut context).unwrap().is_empty()); } -} - -#[cfg(test)] -#[allow(clippy::single_range_in_vec_init)] -mod tests { - use super::{ - ChunkInstructions, DataBlock, DecodeMiniBlockTask, FixedPerValueDecompressor, - FixedWidthDataBlock, FullZipCacheableState, FullZipDecodeDetails, FullZipRepIndexDetails, - FullZipScheduler, MiniBlockRepIndex, PerValueDecompressor, PreambleAction, - StructuralPageScheduler, - }; - use crate::compression::DefaultDecompressionStrategy; - use crate::constants::{STRUCTURAL_ENCODING_META_KEY, STRUCTURAL_ENCODING_MINIBLOCK}; - use crate::data::BlockInfo; - use crate::decoder::PageEncoding; - use crate::encodings::logical::primitive::{ - ChunkDrainInstructions, PrimitiveStructuralEncoder, - }; - use crate::format::pb21; - use crate::format::pb21::compressive_encoding::Compression; - use crate::format::ProtobufUtils21; - use crate::testing::{check_round_trip_encoding_of_data, TestCases}; - use crate::version::LanceFileVersion; - use arrow_array::{ArrayRef, Int8Array, StringArray, UInt64Array}; - use arrow_schema::DataType; - use std::collections::HashMap; - use std::{collections::VecDeque, sync::Arc}; #[test] - fn test_is_narrow() { - let int8_array = Int8Array::from(vec![1, 2, 3]); - let array_ref: ArrayRef = Arc::new(int8_array); - let block = DataBlock::from_array(array_ref); - - assert!(PrimitiveStructuralEncoder::is_narrow(&block)); - - let string_array = StringArray::from(vec![Some("hello"), Some("world")]); - let block = DataBlock::from_array(string_array); - assert!(PrimitiveStructuralEncoder::is_narrow(&block)); + fn page_range_mapping_validates_and_splits_ranges() { + // Exclusive end rows of three ten-row pages. + let pages = [10_u64, 20, 30]; + + let mappings = map_ranges_to_pages(&pages, &[8..22]).unwrap(); + assert_eq!(mappings.len(), 3); + assert_eq!(mappings[0].page_idx, 0); + assert_eq!(mappings[0].ranges_in_page, [8..10]); + assert_eq!(mappings[1].page_idx, 1); + assert_eq!(mappings[1].ranges_in_page, [0..10]); + assert_eq!(mappings[2].page_idx, 2); + assert_eq!(mappings[2].ranges_in_page, [0..2]); + + let sparse_mappings = map_ranges_to_pages(&pages, &[1..2, 3..4, 28..29]).unwrap(); + assert_eq!(sparse_mappings.len(), 2); + assert_eq!(sparse_mappings[0].page_idx, 0); + assert_eq!(sparse_mappings[0].ranges_in_page, [1..2, 3..4]); + assert_eq!(sparse_mappings[1].page_idx, 2); + assert_eq!(sparse_mappings[1].ranges_in_page, [8..9]); + assert_eq!( + pages_overlapping_ranges(&pages, &[1..2, 3..4, 28..29]).unwrap(), + [0, 2] + ); - let string_array = StringArray::from(vec![ - Some("hello world".repeat(100)), - Some("world".to_string()), - ]); - let block = DataBlock::from_array(string_array); - assert!((!PrimitiveStructuralEncoder::is_narrow(&block))); + for (ranges, expected_message) in [ + (vec![Range { start: 2, end: 1 }], "is reversed"), + (vec![15..16, 5..6], "not strictly ordered"), + (vec![5..15, 10..20], "overlap"), + (vec![5..5, 5..6], "not strictly ordered"), + ] { + let error = map_ranges_to_pages(&pages, &ranges).unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!(error.to_string().contains(expected_message)); + } } - #[test] - fn test_map_range() { - // Null in the middle - // [[A, B, C], [D, E], NULL, [F, G, H]] - let rep = Some(vec![1, 0, 0, 1, 0, 1, 1, 0, 0]); - let def = Some(vec![0, 0, 0, 0, 0, 1, 0, 0, 0]); - let max_visible_def = 0; - let total_items = 8; - let max_rep = 1; - - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); + #[tokio::test] + async fn test_fullzip_read_source_slices_prefetched_page() { + let page_start = 200_u64; + let page_data = LanceBuffer::copy_slice(&[0, 1, 2, 3, 4, 5, 6, 7]); + let source = FullZipReadSource::PrefetchedPage { + base_offset: page_start, + data: page_data, }; + let ranges = vec![ + page_start..(page_start + 3), + (page_start + 4)..(page_start + 8), + ]; + let mut data = source.fetch(&ranges, 0).await.unwrap(); + assert_eq!(data.pop_front().unwrap().as_ref(), &[0, 1, 2]); + assert_eq!(data.pop_front().unwrap().as_ref(), &[4, 5, 6, 7]); + } - check(0..1, 0..3, 0..3); - check(1..2, 3..5, 3..5); - check(2..3, 5..5, 5..6); - check(3..4, 5..8, 6..9); - check(0..2, 0..5, 0..5); - check(1..3, 3..5, 3..6); - check(2..4, 5..8, 5..9); - check(0..3, 0..5, 0..6); - check(1..4, 3..8, 3..9); - check(0..4, 0..8, 0..9); + #[tokio::test] + async fn test_fullzip_initialize_caches_rep_index_when_enabled() { + use futures::{FutureExt, future::BoxFuture}; + use std::ops::Range; + use std::sync::Mutex; + + #[derive(Debug, Clone)] + struct RecordingScheduler { + data: bytes::Bytes, + requests: Arc>>>>, + } - // Null at start - // [NULL, [A, B], [C]] - let rep = Some(vec![1, 1, 0, 1]); - let def = Some(vec![1, 0, 0, 0]); - let max_visible_def = 0; - let total_items = 3; + impl RecordingScheduler { + fn new(data: bytes::Bytes) -> Self { + Self { + data, + requests: Arc::new(Mutex::new(Vec::new())), + } + } - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + fn requests(&self) -> Vec>> { + self.requests.lock().unwrap().clone() + } + } - check(0..1, 0..0, 0..1); - check(1..2, 0..2, 1..3); - check(2..3, 2..3, 3..4); - check(0..2, 0..2, 0..3); - check(1..3, 0..3, 1..4); - check(0..3, 0..3, 0..4); + impl crate::EncodingsIo for RecordingScheduler { + fn submit_request( + &self, + ranges: Vec>, + _priority: u64, + ) -> BoxFuture<'static, crate::Result>> { + self.requests.lock().unwrap().push(ranges.clone()); + let data = ranges + .into_iter() + .map(|range| self.data.slice(range.start as usize..range.end as usize)) + .collect::>(); + std::future::ready(Ok(data)).boxed() + } + } - // Null at end - // [[A], [B, C], NULL] - let rep = Some(vec![1, 1, 0, 1]); - let def = Some(vec![0, 0, 0, 1]); - let max_visible_def = 0; - let total_items = 3; + #[derive(Debug)] + struct TestFixedDecompressor; - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + impl FixedPerValueDecompressor for TestFixedDecompressor { + fn decompress( + &self, + _data: FixedWidthDataBlock, + _num_rows: u64, + ) -> crate::Result { + unimplemented!("Test decompressor") + } - check(0..1, 0..1, 0..1); - check(1..2, 1..3, 1..3); - check(2..3, 3..3, 3..4); - check(0..2, 0..3, 0..3); - check(1..3, 1..3, 1..4); - check(0..3, 0..3, 0..4); + fn bits_per_value(&self) -> u64 { + 32 + } + } - // No nulls, with repetition - // [[A, B], [C, D], [E, F]] - let rep = Some(vec![1, 0, 1, 0, 1, 0]); - let def: Option<&[u16]> = None; - let max_visible_def = 0; - let total_items = 6; + let rows_in_page = 100_u64; + let bytes_per_value = 4_u64; + let rep_start = 1000_u64; + let rep_size = ((rows_in_page + 1) * bytes_per_value) as usize; + let mut data = vec![0_u8; 16 * 1024]; + data[rep_start as usize..rep_start as usize + rep_size].fill(7); + let io = Arc::new(RecordingScheduler::new(bytes::Bytes::from(data))); - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); + let mut scheduler = FullZipScheduler { + data_buf_position: 0, + data_buf_size: 4096, + rep_index: Some(FullZipRepIndexDetails { + buf_position: rep_start, + bytes_per_value, + }), + priority: 0, + rows_in_page, + bits_per_offset: 32, + details: Arc::new(FullZipDecodeDetails { + value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), + def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), + ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), + max_rep: 0, + max_visible_def: 0, + }), + cached_state: None, + enable_cache: true, }; - check(0..1, 0..2, 0..2); - check(1..2, 2..4, 2..4); - check(2..3, 4..6, 4..6); - check(0..2, 0..4, 0..4); - check(1..3, 2..6, 2..6); - check(0..3, 0..6, 0..6); + let io_dyn: Arc = io.clone(); + let initialization = scheduler.init_layout().unwrap(); + let ranges = initialization + .required_ranges() + .cloned() + .collect::>(); + assert_eq!( + ranges, + vec![rep_start..(rep_start + (rows_in_page + 1) * bytes_per_value)] + ); + // The field scheduler fetches the declared ranges (coalesced across all + // pages) and hands the bytes back to the page to finish initialization. + let buffers = io_dyn.submit_request(ranges, 0).await.unwrap(); + let buffers = initialization.with_buffers(buffers).unwrap(); + let cached_data = scheduler.init_from_buffers(buffers, &io_dyn).await.unwrap(); + assert!( + cached_data + .clone() + .as_arc_any() + .downcast_ref::() + .is_some() + ); + assert!(scheduler.cached_state.is_none()); + scheduler.try_load(&cached_data).unwrap(); + assert!(scheduler.cached_state.is_some()); + assert_eq!( + io.requests(), + vec![vec![ + rep_start..(rep_start + (rows_in_page + 1) * bytes_per_value) + ]] + ); + } - // No repetition, with nulls (this case is trivial) - // [A, B, NULL, C] - let rep: Option<&[u16]> = None; - let def = Some(vec![0, 0, 1, 0]); - let max_visible_def = 1; - let total_items = 4; + #[tokio::test] + async fn test_fullzip_full_page_bypasses_rep_index_io() { + use futures::{FutureExt, future::BoxFuture}; + use std::ops::Range; + use std::sync::Mutex; + + #[derive(Debug, Clone)] + struct RecordingScheduler { + data: bytes::Bytes, + requests: Arc>>>>, + } - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + impl RecordingScheduler { + fn new(data: bytes::Bytes) -> Self { + Self { + data, + requests: Arc::new(Mutex::new(Vec::new())), + } + } - check(0..1, 0..1, 0..1); - check(1..2, 1..2, 1..2); - check(2..3, 2..3, 2..3); - check(0..2, 0..2, 0..2); - check(1..3, 1..3, 1..3); - check(0..3, 0..3, 0..3); + fn requests(&self) -> Vec>> { + self.requests.lock().unwrap().clone() + } + } - // Tricky case, this chunk is a continuation and starts with a rep-index = 0 - // [[..., A] [B, C], NULL] - // - // What we do will depend on the preamble action - let rep = Some(vec![0, 1, 0, 1]); - let def = Some(vec![0, 0, 0, 1]); - let max_visible_def = 0; - let total_items = 3; + impl crate::EncodingsIo for RecordingScheduler { + fn submit_request( + &self, + ranges: Vec>, + _priority: u64, + ) -> BoxFuture<'static, crate::Result>> { + self.requests.lock().unwrap().push(ranges.clone()); + let data = ranges + .into_iter() + .map(|range| self.data.slice(range.start as usize..range.end as usize)) + .collect::>(); + std::future::ready(Ok(data)).boxed() + } + } - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Take, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + #[derive(Debug)] + struct TestFixedDecompressor; - // If we are taking the preamble then the range must start at 0 - check(0..1, 0..3, 0..3); - check(0..2, 0..3, 0..4); + impl FixedPerValueDecompressor for TestFixedDecompressor { + fn decompress( + &self, + _data: FixedWidthDataBlock, + _num_rows: u64, + ) -> crate::Result { + unimplemented!("Test decompressor") + } - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Skip, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + fn bits_per_value(&self) -> u64 { + 32 + } + } - check(0..1, 1..3, 1..3); - check(1..2, 3..3, 3..4); - check(0..2, 1..3, 1..4); + let rows_in_page = 100_u64; + let data_start = 256_u64; + let data_size = 500_u64; + let rep_start = 4096_u64; + let bytes_per_value = 4_u64; - // Another preamble case but now it doesn't end with a new list - // [[..., A], NULL, [D, E]] - // - // What we do will depend on the preamble action - let rep = Some(vec![0, 1, 1, 0]); - let def = Some(vec![0, 1, 0, 0]); - let max_visible_def = 0; - let total_items = 4; + let mut bytes = vec![0_u8; 16 * 1024]; + for i in 0..=rows_in_page { + let offset = (i * 5) as u32; + let pos = rep_start as usize + (i * bytes_per_value) as usize; + bytes[pos..pos + 4].copy_from_slice(&offset.to_le_bytes()); + } + let io = Arc::new(RecordingScheduler::new(bytes::Bytes::from(bytes))); - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Take, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); + let scheduler = FullZipScheduler { + data_buf_position: data_start, + data_buf_size: data_size, + rep_index: Some(FullZipRepIndexDetails { + buf_position: rep_start, + bytes_per_value, + }), + priority: 0, + rows_in_page, + bits_per_offset: 32, + details: Arc::new(FullZipDecodeDetails { + value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), + def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), + ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), + max_rep: 0, + max_visible_def: 0, + }), + cached_state: None, + enable_cache: false, }; - // If we are taking the preamble then the range must start at 0 - check(0..1, 0..1, 0..2); - check(0..2, 0..3, 0..4); + let io_dyn: Arc = io.clone(); + let tasks = scheduler + .schedule_ranges_rep( + &[0..rows_in_page], + &io_dyn, + FullZipRepIndexDetails { + buf_position: rep_start, + bytes_per_value, + }, + ) + .unwrap(); - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Skip, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + let requests = io.requests(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0], vec![data_start..(data_start + data_size)]); - // If we are taking the preamble then the range must start at 0 - check(0..1, 1..1, 1..2); - check(1..2, 1..3, 2..4); - check(0..2, 1..3, 1..4); + let _ = tasks.into_iter().next().unwrap().decoder_fut.await.unwrap(); + let requests_after_await = io.requests(); + assert_eq!( + requests_after_await.len(), + 1, + "full page path should not issue rep-index I/O" + ); + } - // Now a preamble case without any definition levels - // [[..., A] [B, C], [D]] - let rep = Some(vec![0, 1, 0, 1]); - let def: Option> = None; - let max_visible_def = 0; - let total_items = 4; + /// This test is used to reproduce fuzz test https://github.com/lancedb/lance/issues/4492 + #[tokio::test] + async fn test_fuzz_issue_4492_empty_rep_values() { + use lance_datagen::{RowCount, Seed, array, gen_batch}; - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Take, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + let seed = 1823859942947654717u64; + let num_rows = 2741usize; - // If we are taking the preamble then the range must start at 0 - check(0..1, 0..3, 0..3); - check(0..2, 0..4, 0..4); + // Generate the exact same data that caused the failure + let batch_gen = gen_batch().with_seed(Seed::from(seed)); + let base_generator = array::rand_type(&DataType::FixedSizeBinary(32)); + let list_generator = array::rand_list_any(base_generator, false); - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Skip, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + let batch = batch_gen + .anon_col(list_generator) + .into_batch_rows(RowCount::from(num_rows as u64)) + .unwrap(); - check(0..1, 1..3, 1..3); - check(1..2, 3..4, 3..4); - check(0..2, 1..4, 1..4); + let list_array = batch.column(0).clone(); - // If we have nested lists then non-top level lists may be empty/null - // and we need to make sure we still handle them as invisible items (we - // failed to do this previously) - let rep = Some(vec![2, 1, 2, 0, 1, 2]); - let def = Some(vec![0, 1, 2, 0, 0, 0]); - let max_rep = 2; - let max_visible_def = 0; - let total_items = 4; + // Force miniblock encoding + let mut metadata = HashMap::new(); + metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_MINIBLOCK.to_string(), + ); - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Absent, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + let test_cases = TestCases::default() + .with_structural_encodings() + .with_batch_size(100) + .with_range(0..num_rows.min(500) as u64) + .with_indices(vec![0, num_rows as u64 / 2, (num_rows - 1) as u64]); - check(0..3, 0..4, 0..6); - check(0..1, 0..1, 0..2); - check(1..2, 1..3, 2..5); - check(2..3, 3..4, 5..6); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, metadata).await + } - // Invisible items in a preamble that we are taking (regressing a previous failure) - let rep = Some(vec![0, 0, 1, 0, 1, 1]); - let def = Some(vec![0, 1, 0, 0, 0, 0]); - let max_rep = 1; - let max_visible_def = 0; - let total_items = 5; + async fn test_minichunk_size_helper( + string_data: Vec>, + minichunk_size: u64, + encodings: &[TestEncoding], + ) { + use crate::constants::MINICHUNK_SIZE_META_KEY; + use crate::testing::{TestCases, check_round_trip_encoding_of_data}; + use arrow_array::{ArrayRef, StringArray}; + use std::sync::Arc; - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Take, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + let string_array: ArrayRef = Arc::new(StringArray::from(string_data)); + + let mut metadata = HashMap::new(); + metadata.insert( + MINICHUNK_SIZE_META_KEY.to_string(), + minichunk_size.to_string(), + ); + metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_MINIBLOCK.to_string(), + ); - check(0..0, 0..1, 0..2); - check(0..1, 0..3, 0..4); - check(0..2, 0..4, 0..5); + let test_cases = TestCases::default() + .with_encodings(encodings.iter().copied()) + .with_batch_size(1000); - // Skip preamble (with invis items) and skip a few rows (with invis items) - // and then take a few rows but not all the rows - let rep = Some(vec![0, 1, 0, 1, 0, 1, 0, 1]); - let def = Some(vec![1, 0, 1, 1, 0, 0, 0, 0]); - let max_rep = 1; - let max_visible_def = 0; - let total_items = 5; + check_round_trip_encoding_of_data(vec![string_array], &test_cases, metadata).await; + } - let check = |range, expected_item_range, expected_level_range| { - let (item_range, level_range) = DecodeMiniBlockTask::map_range( - range, - rep.as_ref(), - def.as_ref(), - max_rep, - max_visible_def, - total_items, - PreambleAction::Skip, - ); - assert_eq!(item_range, expected_item_range); - assert_eq!(level_range, expected_level_range); - }; + #[tokio::test] + async fn test_minichunk_size_roundtrip() { + // Test that minichunk size can be configured and works correctly in round-trip encoding + let mut string_data = Vec::new(); + for i in 0..100 { + string_data.push(Some(format!("test_string_{}", i).repeat(50))); + } + // configure minichunk size to 64 bytes (smaller than the default 4kb) for Lance 2.1 + test_minichunk_size_helper( + string_data, + 64, + &[ + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse, + ], + ) + .await; + } - check(2..3, 2..4, 5..7); + #[tokio::test] + async fn test_minichunk_size_128kb_v2_2() { + // Test that minichunk size can be configured to 128KB and works correctly with Lance 2.2 + let mut string_data = Vec::new(); + // create a 500kb string array + for i in 0..10000 { + string_data.push(Some(format!("test_string_{}", i).repeat(50))); + } + test_minichunk_size_helper( + string_data, + 128 * 1024, + &[TestEncoding::StructuralU32, TestEncoding::StructuralSparse], + ) + .await; } - #[test] - fn test_schedule_instructions() { - // Convert repetition index to bytes for testing - let rep_data: Vec = vec![5, 2, 3, 0, 4, 7, 2, 0]; - let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); - let repetition_index = MiniBlockRepIndex::decode_from_bytes(&rep_bytes, 2); + #[tokio::test] + async fn test_binary_large_minichunk_size_over_max_miniblock_values() { + let mut string_data = Vec::new(); + // 128kb/chunk / 6 bytes (t_9999) = 21845 items per chunk + for i in 0..10000 { + string_data.push(Some(format!("t_{}", i))); + } + test_minichunk_size_helper( + string_data, + 128 * 1024, + &[TestEncoding::StructuralU32, TestEncoding::StructuralSparse], + ) + .await; + } - let check = |user_ranges, expected_instructions| { - let instructions = - ChunkInstructions::schedule_instructions(&repetition_index, user_ranges); - assert_eq!(instructions, expected_instructions); - }; + #[tokio::test] + async fn test_large_dictionary_general_compression() { + use arrow_array::{ArrayRef, StringArray}; + use std::collections::HashMap; + use std::sync::Arc; - // The instructions we expect if we're grabbing the whole range - let expected_take_all = vec![ - ChunkInstructions { - chunk_idx: 0, - preamble: PreambleAction::Absent, - rows_to_skip: 0, - rows_to_take: 6, - take_trailer: true, - }, - ChunkInstructions { - chunk_idx: 1, - preamble: PreambleAction::Take, - rows_to_skip: 0, - rows_to_take: 2, - take_trailer: false, - }, - ChunkInstructions { - chunk_idx: 2, - preamble: PreambleAction::Absent, - rows_to_skip: 0, - rows_to_take: 5, - take_trailer: true, - }, - ChunkInstructions { - chunk_idx: 3, - preamble: PreambleAction::Take, - rows_to_skip: 0, - rows_to_take: 1, - take_trailer: false, - }, - ]; + // Create large string dictionary data (>32KiB) with low cardinality + // Use 100 unique strings, each 500 bytes long = 50KB dictionary + let unique_values: Vec = (0..100) + .map(|i| format!("value_{:04}_{}", i, "x".repeat(500))) + .collect(); - // Take all as 1 range - check(&[0..14], expected_take_all.clone()); + // Repeat these strings many times to create a large array + let repeated_strings: Vec<_> = unique_values + .iter() + .cycle() + .take(10_000) + .map(|s| Some(s.as_str())) + .collect(); - // Take all a individual rows - check( - &[ - 0..1, - 1..2, - 2..3, - 3..4, - 4..5, - 5..6, - 6..7, - 7..8, - 8..9, - 9..10, - 10..11, - 11..12, - 12..13, - 13..14, - ], - expected_take_all, - ); + let string_array = Arc::new(StringArray::from(repeated_strings)) as ArrayRef; - // Test some partial takes + // Configure test to use V2_2 and verify encoding + let test_cases = TestCases::default() + .with_u32_structural_encodings() + .with_verify_encoding(Arc::new(|cols: &[crate::encoder::EncodedColumn], _| { + assert_eq!(cols.len(), 1); + let col = &cols[0]; - // 2 rows in the same chunk but not contiguous - check( - &[0..1, 3..4], - vec![ - ChunkInstructions { - chunk_idx: 0, - preamble: PreambleAction::Absent, - rows_to_skip: 0, - rows_to_take: 1, - take_trailer: false, - }, - ChunkInstructions { - chunk_idx: 0, - preamble: PreambleAction::Absent, - rows_to_skip: 3, - rows_to_take: 1, - take_trailer: false, - }, - ], - ); + // Navigate to the dictionary encoding in the page layout + if let Some(PageEncoding::Structural(page_layout)) = + &col.final_pages.first().map(|p| &p.description) + && let Some(pb21::page_layout::Layout::MiniBlockLayout(mini_block)) = + &page_layout.layout + && let Some(dictionary_encoding) = &mini_block.dictionary + { + match dictionary_encoding.compression.as_ref() { + Some(Compression::General(general)) => { + // Verify it's using LZ4 or Zstd + let compression = general.compression.as_ref().unwrap(); + assert!( + compression.scheme() + == pb21::CompressionScheme::CompressionAlgorithmLz4 + || compression.scheme() + == pb21::CompressionScheme::CompressionAlgorithmZstd, + "Expected LZ4 or Zstd compression for large dictionary" + ); + } + _ => panic!("Expected General compression for large dictionary"), + } + } + })); - // Taking just a trailer/preamble - check( - &[5..6], - vec![ - ChunkInstructions { - chunk_idx: 0, - preamble: PreambleAction::Absent, - rows_to_skip: 5, - rows_to_take: 1, - take_trailer: true, - }, - ChunkInstructions { - chunk_idx: 1, - preamble: PreambleAction::Take, - rows_to_skip: 0, - rows_to_take: 0, - take_trailer: false, - }, - ], - ); + check_round_trip_encoding_of_data(vec![string_array], &test_cases, HashMap::new()).await; + } - // Skipping an entire chunk - check( - &[7..10], - vec![ - ChunkInstructions { - chunk_idx: 1, - preamble: PreambleAction::Skip, - rows_to_skip: 1, - rows_to_take: 1, - take_trailer: false, - }, - ChunkInstructions { - chunk_idx: 2, - preamble: PreambleAction::Absent, - rows_to_skip: 0, - rows_to_take: 2, - take_trailer: false, - }, - ], - ); + fn dictionary_encoding_from_page( + page: &crate::encoder::EncodedPage, + ) -> &crate::format::pb21::CompressiveEncoding { + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural page encoding"); + }; + let pb21::page_layout::Layout::MiniBlockLayout(layout) = layout.layout.as_ref().unwrap() + else { + panic!("Expected mini-block layout"); + }; + layout + .dictionary + .as_ref() + .unwrap_or_else(|| panic!("Expected dictionary encoding")) } - #[test] - fn test_drain_instructions() { - fn drain_from_instructions( - instructions: &mut VecDeque, - mut rows_desired: u64, - need_preamble: &mut bool, - skip_in_chunk: &mut u64, - ) -> Vec { - // Note: instructions.len() is an upper bound, we typically take much fewer - let mut drain_instructions = Vec::with_capacity(instructions.len()); - while rows_desired > 0 || *need_preamble { - let (next_instructions, consumed_chunk) = instructions - .front() - .unwrap() - .drain_from_instruction(&mut rows_desired, need_preamble, skip_in_chunk); - if consumed_chunk { - instructions.pop_front(); - } - drain_instructions.push(next_instructions); - } - drain_instructions - } + async fn encode_variable_dict_page( + metadata: HashMap, + ) -> crate::encoder::EncodedPage { + use arrow_array::types::Int32Type; + use arrow_array::{ArrayRef, DictionaryArray, Int32Array, StringArray}; + + let values = Arc::new(StringArray::from( + (0..128) + .map(|i| format!("value_{i:04}_{}", "x".repeat(256))) + .collect::>(), + )) as ArrayRef; + let keys = Int32Array::from_iter_values((0..20_000).map(|i| i % 128)); + let dict_array = + Arc::new(DictionaryArray::::try_new(keys, values).unwrap()) as ArrayRef; - // Convert repetition index to bytes for testing - let rep_data: Vec = vec![5, 2, 3, 0, 4, 7, 2, 0]; - let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); - let repetition_index = MiniBlockRepIndex::decode_from_bytes(&rep_bytes, 2); - let user_ranges = vec![1..7, 10..14]; + let field = arrow_schema::Field::new( + "dict_col", + DataType::Dictionary(Box::new(DataType::Int32), Box::new(DataType::Utf8)), + false, + ) + .with_metadata(metadata); - // First, schedule the ranges - let scheduled = ChunkInstructions::schedule_instructions(&repetition_index, &user_ranges); + encode_first_page(field, dict_array, TestEncoding::StructuralU32).await + } - let mut to_drain = VecDeque::from(scheduled.clone()); + async fn encode_auto_fixed_dict_page( + metadata: HashMap, + ) -> crate::encoder::EncodedPage { + use arrow_array::{ArrayRef, Decimal128Array}; + + // 128-bit fixed-width values with low cardinality to trigger dictionary encoding. + let values = (0..20_000) + .map(|i| match i % 3 { + 0 => 10_i128, + 1 => 20_i128, + _ => 30_i128, + }) + .collect::>(); + let decimal = Decimal128Array::from_iter_values(values) + .with_precision_and_scale(38, 0) + .unwrap(); + let decimal = Arc::new(decimal) as ArrayRef; + + let mut field_metadata = metadata; + // Strongly encourage dictionary encoding for this synthetic test data. + field_metadata.insert( + "lance-encoding:dict-size-ratio".to_string(), + "0.99".to_string(), + ); + let field = arrow_schema::Field::new("fixed_col", DataType::Decimal128(38, 0), false) + .with_metadata(field_metadata); - // Now we drain in batches of 4 + encode_first_page(field, decimal, TestEncoding::StructuralU32).await + } - let mut need_preamble = false; - let mut skip_in_chunk = 0; + #[tokio::test] + async fn test_dict_values_general_compression_default_lz4_for_variable_dict_values() { + let page = encode_variable_dict_page(HashMap::new()).await; + let dictionary_encoding = dictionary_encoding_from_page(&page); + let Some(Compression::General(general)) = dictionary_encoding.compression.as_ref() else { + panic!("Expected General compression for dictionary values"); + }; + let compression = general.compression.as_ref().unwrap(); + assert_eq!( + compression.scheme(), + pb21::CompressionScheme::CompressionAlgorithmLz4 + ); + } - let next_batch = - drain_from_instructions(&mut to_drain, 4, &mut need_preamble, &mut skip_in_chunk); + #[tokio::test] + async fn test_dict_values_general_compression_default_lz4_for_fixed_dict_values() { + let page = encode_auto_fixed_dict_page(HashMap::new()).await; + let dictionary_encoding = dictionary_encoding_from_page(&page); + let Some(Compression::General(general)) = dictionary_encoding.compression.as_ref() else { + panic!("Expected General compression for dictionary values"); + }; + let compression = general.compression.as_ref().unwrap(); + assert_eq!( + compression.scheme(), + pb21::CompressionScheme::CompressionAlgorithmLz4 + ); + } - assert!(!need_preamble); - assert_eq!(skip_in_chunk, 4); + #[tokio::test] + async fn test_dict_values_general_compression_zstd() { + let mut metadata = HashMap::new(); + metadata.insert( + DICT_VALUES_COMPRESSION_META_KEY.to_string(), + "zstd".to_string(), + ); + let page = encode_variable_dict_page(metadata).await; + let dictionary_encoding = dictionary_encoding_from_page(&page); + let Some(Compression::General(general)) = dictionary_encoding.compression.as_ref() else { + panic!("Expected General compression for dictionary values"); + }; + let compression = general.compression.as_ref().unwrap(); assert_eq!( - next_batch, - vec![ChunkDrainInstructions { - chunk_instructions: scheduled[0].clone(), - rows_to_take: 4, - rows_to_skip: 0, - preamble_action: PreambleAction::Absent, - }] + compression.scheme(), + pb21::CompressionScheme::CompressionAlgorithmZstd ); + } - let next_batch = - drain_from_instructions(&mut to_drain, 4, &mut need_preamble, &mut skip_in_chunk); + #[tokio::test] + async fn test_dict_values_general_compression_none() { + let mut metadata = HashMap::new(); + metadata.insert( + DICT_VALUES_COMPRESSION_META_KEY.to_string(), + "none".to_string(), + ); + let page = encode_variable_dict_page(metadata).await; + let dictionary_encoding = dictionary_encoding_from_page(&page); + assert!( + !matches!( + dictionary_encoding.compression.as_ref(), + Some(Compression::General(_)) + ), + "Expected dictionary values to avoid General compression" + ); + } - assert!(!need_preamble); - assert_eq!(skip_in_chunk, 2); + #[test] + fn test_resolve_dict_values_compression_metadata_defaults_to_lz4() { + let metadata = PrimitiveStructuralEncoder::resolve_dict_values_compression_metadata( + &HashMap::new(), + None, + None, + ); + assert_eq!(metadata.get(COMPRESSION_META_KEY), Some(&"lz4".to_string()),); + assert!(!metadata.contains_key(COMPRESSION_LEVEL_META_KEY)); + } + #[test] + fn test_resolve_dict_values_compression_metadata_metadata_overrides_env() { + let field_metadata = HashMap::from([ + ( + DICT_VALUES_COMPRESSION_META_KEY.to_string(), + "none".to_string(), + ), + ( + DICT_VALUES_COMPRESSION_LEVEL_META_KEY.to_string(), + "7".to_string(), + ), + ]); + let metadata = PrimitiveStructuralEncoder::resolve_dict_values_compression_metadata( + &field_metadata, + Some("zstd".to_string()), + Some("3".to_string()), + ); assert_eq!( - next_batch, - vec![ - ChunkDrainInstructions { - chunk_instructions: scheduled[0].clone(), - rows_to_take: 1, - rows_to_skip: 4, - preamble_action: PreambleAction::Absent, - }, - ChunkDrainInstructions { - chunk_instructions: scheduled[1].clone(), - rows_to_take: 1, - rows_to_skip: 0, - preamble_action: PreambleAction::Take, - }, - ChunkDrainInstructions { - chunk_instructions: scheduled[2].clone(), - rows_to_take: 2, - rows_to_skip: 0, - preamble_action: PreambleAction::Absent, - } - ] + metadata.get(COMPRESSION_META_KEY), + Some(&"none".to_string()), + ); + assert_eq!( + metadata.get(COMPRESSION_LEVEL_META_KEY), + Some(&"7".to_string()), ); + } - let next_batch = - drain_from_instructions(&mut to_drain, 2, &mut need_preamble, &mut skip_in_chunk); + #[test] + fn test_resolve_dict_values_compression_metadata_env_fallback() { + let metadata = PrimitiveStructuralEncoder::resolve_dict_values_compression_metadata( + &HashMap::new(), + Some("zstd".to_string()), + Some("9".to_string()), + ); + assert_eq!( + metadata.get(COMPRESSION_META_KEY), + Some(&"zstd".to_string()), + ); + assert_eq!( + metadata.get(COMPRESSION_LEVEL_META_KEY), + Some(&"9".to_string()), + ); + } - assert!(!need_preamble); - assert_eq!(skip_in_chunk, 0); + #[tokio::test] + async fn test_dictionary_encode_int64() { + use crate::constants::{DICT_SIZE_RATIO_META_KEY, STRUCTURAL_ENCODING_META_KEY}; + use crate::testing::{TestCases, check_round_trip_encoding_of_data}; + use arrow_array::{ArrayRef, Int64Array}; + use std::collections::HashMap; + use std::sync::Arc; - assert_eq!( - next_batch, - vec![ - ChunkDrainInstructions { - chunk_instructions: scheduled[2].clone(), - rows_to_take: 1, - rows_to_skip: 2, - preamble_action: PreambleAction::Absent, - }, - ChunkDrainInstructions { - chunk_instructions: scheduled[3].clone(), - rows_to_take: 1, - rows_to_skip: 0, - preamble_action: PreambleAction::Take, - }, - ] + // Low cardinality with poor RLE opportunity. + let values = (0..1000) + .map(|i| match i % 3 { + 0 => 10i64, + 1 => 20i64, + _ => 30i64, + }) + .collect::>(); + let array = Arc::new(Int64Array::from(values)) as ArrayRef; + + let mut metadata = HashMap::new(); + metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_MINIBLOCK.to_string(), ); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.99".to_string()); - // Regression case. Need a chunk with preamble, rows, and trailer (the middle chunk here) - let rep_data: Vec = vec![5, 2, 3, 3, 20, 0]; - let rep_bytes: Vec = rep_data.iter().flat_map(|v| v.to_le_bytes()).collect(); - let repetition_index = MiniBlockRepIndex::decode_from_bytes(&rep_bytes, 2); - let user_ranges = vec![0..28]; + let test_cases = TestCases::default() + .with_u32_structural_encodings() + .with_batch_size(1000) + .with_range(0..1000) + .with_indices(vec![0, 1, 10, 999]) + .with_expected_encoding("dictionary"); - // First, schedule the ranges - let scheduled = ChunkInstructions::schedule_instructions(&repetition_index, &user_ranges); + check_round_trip_encoding_of_data(vec![array], &test_cases, metadata).await; + } - let mut to_drain = VecDeque::from(scheduled.clone()); + #[tokio::test] + async fn test_dictionary_encode_float64() { + use crate::constants::{DICT_SIZE_RATIO_META_KEY, STRUCTURAL_ENCODING_META_KEY}; + use crate::testing::{TestCases, check_round_trip_encoding_of_data}; + use arrow_array::{ArrayRef, Float64Array}; + use std::collections::HashMap; + use std::sync::Arc; - // Drain first chunk and some of second chunk + // Low cardinality with poor RLE opportunity. + let values = (0..1000) + .map(|i| match i % 3 { + 0 => 0.1f64, + 1 => 0.2f64, + _ => 0.3f64, + }) + .collect::>(); + let array = Arc::new(Float64Array::from(values)) as ArrayRef; - let mut need_preamble = false; - let mut skip_in_chunk = 0; + let mut metadata = HashMap::new(); + metadata.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_MINIBLOCK.to_string(), + ); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.99".to_string()); - let next_batch = - drain_from_instructions(&mut to_drain, 7, &mut need_preamble, &mut skip_in_chunk); + let test_cases = TestCases::default() + .with_u32_structural_encodings() + .with_batch_size(1000) + .with_range(0..1000) + .with_indices(vec![0, 1, 10, 999]) + .with_expected_encoding("dictionary"); + + check_round_trip_encoding_of_data(vec![array], &test_cases, metadata).await; + } + + #[test] + fn test_miniblock_dictionary_out_of_line_bitpacking_decode() { + let rows = 10_000; + let unique_values = 2_000; + + let dictionary_encoding = + ProtobufUtils21::out_of_line_bitpacking(64, ProtobufUtils21::flat(11, None)); + let layout = pb21::MiniBlockLayout { + rep_compression: None, + def_compression: None, + value_compression: Some(ProtobufUtils21::flat(64, None)), + dictionary: Some(dictionary_encoding), + num_dictionary_items: unique_values, + layers: vec![pb21::RepDefLayer::RepdefAllValidItem as i32], + num_buffers: 1, + repetition_index_depth: 0, + num_items: rows, + has_large_chunk: false, + }; + let buffer_offsets_and_sizes = vec![(0, 0), (0, 0), (0, 0)]; + let scheduler = super::MiniBlockScheduler::try_new( + &buffer_offsets_and_sizes, + /*priority=*/ 0, + /*items_in_page=*/ rows, + &layout, + &DefaultDecompressionStrategy::default(), + ) + .unwrap(); + + let dictionary = scheduler.dictionary.unwrap(); + assert_eq!(dictionary.num_dictionary_items, unique_values); assert_eq!( - next_batch, - vec![ - ChunkDrainInstructions { - chunk_instructions: scheduled[0].clone(), - rows_to_take: 6, - rows_to_skip: 0, - preamble_action: PreambleAction::Absent, - }, - ChunkDrainInstructions { - chunk_instructions: scheduled[1].clone(), - rows_to_take: 1, - rows_to_skip: 0, - preamble_action: PreambleAction::Take, - }, - ] + dictionary.dictionary_data_alignment, + crate::encoder::MIN_PAGE_BUFFER_ALIGNMENT ); + } - assert!(!need_preamble); - assert_eq!(skip_in_chunk, 1); + // Dictionary encoding decision tests + fn create_test_fixed_data_block( + num_values: u64, + cardinality: u64, + bits_per_value: u64, + ) -> DataBlock { + assert!(cardinality > 0); + assert!(cardinality <= num_values); + let block_info = BlockInfo::default(); - // Now, the tricky part. We drain the second chunk, including the trailer, and need to make sure - // we get a drain task to take the preamble of the third chunk (and nothing else) - let next_batch = - drain_from_instructions(&mut to_drain, 2, &mut need_preamble, &mut skip_in_chunk); + assert_eq!(bits_per_value % 8, 0); + let data = match bits_per_value { + 32 => { + let values = (0..num_values) + .map(|i| (i % cardinality) as u32) + .collect::>(); + crate::buffer::LanceBuffer::reinterpret_vec(values) + } + 64 => { + let values = (0..num_values).map(|i| i % cardinality).collect::>(); + crate::buffer::LanceBuffer::reinterpret_vec(values) + } + 128 => { + let values = (0..num_values) + .map(|i| (i % cardinality) as u128) + .collect::>(); + crate::buffer::LanceBuffer::reinterpret_vec(values) + } + _ => unreachable!(), + }; + DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value, + data, + num_values, + block_info, + }) + } + + /// Helper to create VariableWidth (string) test data block with exact cardinality + fn create_test_variable_width_block(num_values: u64, cardinality: u64) -> DataBlock { + use arrow_array::StringArray; + + assert!(cardinality <= num_values && cardinality > 0); - assert_eq!( - next_batch, - vec![ - ChunkDrainInstructions { - chunk_instructions: scheduled[1].clone(), - rows_to_take: 2, - rows_to_skip: 1, - preamble_action: PreambleAction::Skip, - }, - ChunkDrainInstructions { - chunk_instructions: scheduled[2].clone(), - rows_to_take: 0, - rows_to_skip: 0, - preamble_action: PreambleAction::Take, - }, - ] - ); + let mut values = Vec::with_capacity(num_values as usize); + for i in 0..num_values { + values.push(format!("value_{:016}", i % cardinality)); + } - assert!(!need_preamble); - assert_eq!(skip_in_chunk, 0); + let array = StringArray::from(values); + DataBlock::from_array(Arc::new(array) as ArrayRef) } - #[tokio::test] - async fn test_fullzip_repetition_index_caching() { - use crate::testing::SimulatedScheduler; - - // Simplified FixedPerValueDecompressor for testing - #[derive(Debug)] - struct TestFixedDecompressor; + fn create_sorted_string_array(num_values: u64, cardinality: u64) -> ArrayRef { + use arrow_array::StringArray; - impl FixedPerValueDecompressor for TestFixedDecompressor { - fn decompress( - &self, - _data: FixedWidthDataBlock, - _num_rows: u64, - ) -> crate::Result { - unimplemented!("Test decompressor") - } + assert!(cardinality <= num_values && cardinality > 0); - fn bits_per_value(&self) -> u64 { - 32 - } + let mut values = Vec::with_capacity(num_values as usize); + for i in 0..num_values { + let value_idx = i * cardinality / num_values; + values.push(format!("value_{:016}", value_idx)); } - // Create test repetition index data - let rows_in_page = 100u64; - let bytes_per_value = 4u64; - let _rep_index_size = (rows_in_page + 1) * bytes_per_value; + Arc::new(StringArray::from(values)) as ArrayRef + } - // Create mock repetition index data - let mut rep_index_data = Vec::new(); - for i in 0..=rows_in_page { - let offset = (i * 100) as u32; // Each row starts at i * 100 bytes - rep_index_data.extend_from_slice(&offset.to_le_bytes()); - } + fn create_sorted_variable_width_block(num_values: u64, cardinality: u64) -> DataBlock { + DataBlock::from_array(create_sorted_string_array(num_values, cardinality)) + } - // Simulate storage with the repetition index at position 1000 - let mut full_data = vec![0u8; 1000]; - full_data.extend_from_slice(&rep_index_data); - full_data.extend_from_slice(&vec![0u8; 10000]); // Add some data after + #[test] + fn test_should_dictionary_encode() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; + use lance_core::datatypes::Field as LanceField; - let data = bytes::Bytes::from(full_data); - let io = Arc::new(SimulatedScheduler::new(data)); - let _cache = Arc::new(lance_core::cache::LanceCache::with_capacity(1024 * 1024)); + // Create data where dict encoding saves space + let block = create_test_variable_width_block(1000, 10); - // Create FullZipScheduler with repetition index - let mut scheduler = FullZipScheduler { - data_buf_position: 0, - rep_index: Some(FullZipRepIndexDetails { - buf_position: 1000, - bytes_per_value, - }), - priority: 0, - rows_in_page, - bits_per_offset: 32, - details: Arc::new(FullZipDecodeDetails { - value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), - def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), - ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), - max_rep: 0, - max_visible_def: 0, - }), - cached_state: None, - enable_cache: true, // Enable caching for test - }; + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); + let arrow_field = + arrow_schema::Field::new("test", DataType::Utf8, false).with_metadata(metadata); + let field = LanceField::try_from(&arrow_field).unwrap(); - // First initialization should load and cache the repetition index - let io_dyn: Arc = io.clone(); - let cached_data1 = scheduler.initialize(&io_dyn).await.unwrap(); - - // Verify that we got a FullZipCacheableState (not NoCachedPageData) - let is_cached = cached_data1 - .clone() - .as_arc_any() - .downcast::() - .is_ok(); + let result = PrimitiveStructuralEncoder::should_dictionary_encode( + &block, + &field, + FixedWidthDictionaryEncoding::Exclude64Bit, + ); + + assert!( + result.is_some(), + "Should use dictionary encode based on size" + ); + } + + #[test] + fn test_block_sampling_detects_low_cardinality_in_short_sorted_runs() { + let sample_count: usize = 4096; + let num_values: u64 = 200_000; + let cardinality: u64 = 8_000; + let run_length = num_values / cardinality; + let stride = num_values as usize / sample_count; assert!( - is_cached, - "Expected FullZipCacheableState, got NoCachedPageData" + stride > run_length as usize, + "test must construct the stride > run_length case" ); - // Load the cached data into the scheduler - scheduler.load(&cached_data1); + let block = create_sorted_variable_width_block(num_values, cardinality); + let sample_unique_ratio = + PrimitiveStructuralEncoder::sample_unique_ratio(&block, sample_count).unwrap(); - // Verify that cached_state is now populated assert!( - scheduler.cached_state.is_some(), - "cached_state should be populated after load" + sample_unique_ratio.is_some_and(|ratio| ratio < 0.98), + "sorted low-cardinality data must not be classified as near-unique" ); + } + + #[test] + fn test_should_dictionary_encode_sorted_low_cardinality() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; + use lance_core::datatypes::Field as LanceField; - // Verify the cached data contains the repetition index - let cached_state = scheduler.cached_state.as_ref().unwrap(); + let block = create_sorted_variable_width_block(200_000, 8_000); - // Test that schedule_ranges_rep uses the cached data - let ranges = vec![0..10, 20..30]; - let result = scheduler.schedule_ranges_rep( - &ranges, - &io_dyn, - FullZipRepIndexDetails { - buf_position: 1000, - bytes_per_value, - }, + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); + let arrow_field = + arrow_schema::Field::new("test", DataType::Utf8, false).with_metadata(metadata); + let field = LanceField::try_from(&arrow_field).unwrap(); + + let result = PrimitiveStructuralEncoder::should_dictionary_encode( + &block, + &field, + FixedWidthDictionaryEncoding::Include64Bit, ); - // The result should be OK (not an error) assert!( - result.is_ok(), - "schedule_ranges_rep should succeed with cached data" + result.is_some(), + "sorted low-cardinality data should reach dictionary encoding" ); + } - // Second scheduler instance should be able to use the cached data - let mut scheduler2 = FullZipScheduler { - data_buf_position: 0, - rep_index: Some(FullZipRepIndexDetails { - buf_position: 1000, - bytes_per_value, - }), - priority: 0, - rows_in_page, - bits_per_offset: 32, - details: scheduler.details.clone(), - cached_state: None, - enable_cache: true, // Enable caching for test - }; + #[test] + fn test_should_not_dictionary_encode_sorted_high_cardinality_short_runs() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; + use lance_core::datatypes::Field as LanceField; - // Load cached data from the first scheduler - scheduler2.load(&cached_data1); - assert!( - scheduler2.cached_state.is_some(), - "Second scheduler should have cached_state after load" + let num_values = 200_002; + let cardinality = 100_001; + let block = create_sorted_variable_width_block(num_values, cardinality); + + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); + let arrow_field = + arrow_schema::Field::new("test", DataType::Utf8, false).with_metadata(metadata); + let field = LanceField::try_from(&arrow_field).unwrap(); + + let result = PrimitiveStructuralEncoder::should_dictionary_encode( + &block, + &field, + FixedWidthDictionaryEncoding::Include64Bit, ); - // Verify that both schedulers have the same cached data - let cached_state2 = scheduler2.cached_state.as_ref().unwrap(); assert!( - Arc::ptr_eq(cached_state, cached_state2), - "Both schedulers should share the same cached data" + result.is_none(), + "sorted high-cardinality short runs should not trigger a full dictionary probe" ); } #[tokio::test] - async fn test_fullzip_cache_config_controls_caching() { - use crate::testing::SimulatedScheduler; + async fn test_encode_sorted_low_cardinality_uses_dictionary_layout() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; - // Simplified FixedPerValueDecompressor for testing - #[derive(Debug)] - struct TestFixedDecompressor; + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); + let field = arrow_schema::Field::new("test", DataType::Utf8, false).with_metadata(metadata); + let array = create_sorted_string_array(200_000, 8_000); - impl FixedPerValueDecompressor for TestFixedDecompressor { - fn decompress( - &self, - _data: FixedWidthDataBlock, - _num_rows: u64, - ) -> crate::Result { - unimplemented!("Test decompressor") - } + let page = encode_first_page(field, array, TestEncoding::StructuralU32).await; + let _ = dictionary_encoding_from_page(&page); + } - fn bits_per_value(&self) -> u64 { - 32 - } - } + #[test] + fn test_should_not_dictionary_encode_unsupported_bits() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; + use lance_core::datatypes::Field as LanceField; - // Test that enable_cache flag actually controls caching behavior - let rows_in_page = 1000_u64; - let bytes_per_value = 4_u64; + let block = create_test_fixed_data_block(1000, 1000, 32); - // Create simulated data - let rep_index_data = vec![0u8; ((rows_in_page + 1) * bytes_per_value) as usize]; - let value_data = vec![0u8; 4000]; // Dummy value data - let mut full_data = vec![0u8; 1000]; // Padding before rep index - full_data.extend_from_slice(&rep_index_data); - full_data.extend_from_slice(&value_data); + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); + let arrow_field = + arrow_schema::Field::new("test", DataType::Int32, false).with_metadata(metadata); + let field = LanceField::try_from(&arrow_field).unwrap(); - let data = bytes::Bytes::from(full_data); - let io = Arc::new(SimulatedScheduler::new(data)); + let result = PrimitiveStructuralEncoder::should_dictionary_encode( + &block, + &field, + FixedWidthDictionaryEncoding::Exclude64Bit, + ); - // Test 1: With caching disabled - let mut scheduler_no_cache = FullZipScheduler { - data_buf_position: 0, - rep_index: Some(FullZipRepIndexDetails { - buf_position: 1000, - bytes_per_value, - }), - priority: 0, - rows_in_page, - bits_per_offset: 32, - details: Arc::new(FullZipDecodeDetails { - value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), - def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), - ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), - max_rep: 0, - max_visible_def: 0, - }), - cached_state: None, - enable_cache: false, // Caching disabled - }; + assert!( + result.is_none(), + "Should not use dictionary encode for unsupported bit width" + ); + } - let io_dyn: Arc = io.clone(); - let cached_data = scheduler_no_cache.initialize(&io_dyn).await.unwrap(); + #[test] + fn test_should_not_dictionary_encode_near_unique_sample() { + use crate::constants::DICT_SIZE_RATIO_META_KEY; + use lance_core::datatypes::Field as LanceField; + + let num_values = 5000; + let block = create_test_variable_width_block(num_values, num_values); + + let mut metadata = HashMap::new(); + metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "1.0".to_string()); + let arrow_field = + arrow_schema::Field::new("test", DataType::Utf8, false).with_metadata(metadata); + let field = LanceField::try_from(&arrow_field).unwrap(); + + let result = PrimitiveStructuralEncoder::should_dictionary_encode( + &block, + &field, + FixedWidthDictionaryEncoding::Exclude64Bit, + ); - // Should return NoCachedPageData when caching is disabled assert!( - cached_data - .as_arc_any() - .downcast_ref::() - .is_some(), - "With enable_cache=false, should return NoCachedPageData" + result.is_none(), + "Should not probe dictionary encoding for near-unique data" ); + } - // Test 2: With caching enabled - let mut scheduler_with_cache = FullZipScheduler { - data_buf_position: 0, - rep_index: Some(FullZipRepIndexDetails { - buf_position: 1000, - bytes_per_value, - }), - priority: 0, - rows_in_page, - bits_per_offset: 32, - details: Arc::new(FullZipDecodeDetails { - value_decompressor: PerValueDecompressor::Fixed(Arc::new(TestFixedDecompressor)), - def_meaning: Arc::new([crate::repdef::DefinitionInterpretation::NullableItem]), - ctrl_word_parser: crate::repdef::ControlWordParser::new(0, 1), - max_rep: 0, - max_visible_def: 0, - }), - cached_state: None, - enable_cache: true, // Caching enabled + #[test] + fn test_v2_1_miniblock_serializes_log_num_values_15() { + let miniblocks = MiniBlockCompressed { + data: vec![LanceBuffer::from(vec![1_u8; 16])], + chunks: vec![ + MiniBlockChunk { + buffer_sizes: vec![8], + log_num_values: 15, + }, + MiniBlockChunk { + buffer_sizes: vec![8], + log_num_values: 0, + }, + ], + num_values: 32_769, }; - let cached_data2 = scheduler_with_cache.initialize(&io_dyn).await.unwrap(); + let serialized = PrimitiveStructuralEncoder::serialize_miniblocks( + miniblocks, + None, + None, + MiniblockChunkSize::U16, + ) + .unwrap(); - // Should return FullZipCacheableState when caching is enabled - assert!( - cached_data2 - .as_arc_any() - .downcast_ref::() - .is_some(), - "With enable_cache=true, should return FullZipCacheableState" + let chunk_metadata = serialized.metadata.borrow_to_typed_slice::(); + assert_eq!(chunk_metadata.len(), 2); + assert_eq!( + chunk_metadata[0] & 0x0F, + 15, + "V2.1 metadata should use all 4 bits for log_num_values" ); } - /// This test is used to reproduce fuzz test https://github.com/lancedb/lance/issues/4492 + async fn encode_first_page( + field: arrow_schema::Field, + array: ArrayRef, + version: TestEncoding, + ) -> crate::encoder::EncodedPage { + use crate::repdef::RepDefBuilder; + use crate::{ + encoder::{ + ColumnIndexSequence, EncodingOptions, MIN_PAGE_BUFFER_ALIGNMENT, OutOfLineBuffers, + }, + testing::{create_test_field_encoder, test_encoding_strategy}, + }; + + let lance_field = lance_core::datatypes::Field::try_from(&field).unwrap(); + let encoding_strategy = test_encoding_strategy(version); + let mut column_index_seq = ColumnIndexSequence::default(); + let encoding_options = EncodingOptions { + cache_bytes_per_column: 1, + max_page_bytes: 32 * 1024 * 1024, + keep_original_array: true, + buffer_alignment: MIN_PAGE_BUFFER_ALIGNMENT, + }; + + let mut encoder = create_test_field_encoder( + encoding_strategy.as_ref(), + &lance_field, + &mut column_index_seq, + &encoding_options, + ) + .unwrap(); + + let mut external_buffers = OutOfLineBuffers::new(0, MIN_PAGE_BUFFER_ALIGNMENT); + let repdef = RepDefBuilder::default(); + let num_rows = array.len() as u64; + let mut pages = Vec::new(); + for task in encoder + .maybe_encode(array, &mut external_buffers, repdef, 0, num_rows) + .unwrap() + { + pages.push(task.await.unwrap()); + } + for task in encoder.flush(&mut external_buffers).unwrap() { + pages.push(task.await.unwrap()); + } + pages.into_iter().next().unwrap() + } + #[tokio::test] - async fn test_fuzz_issue_4492_empty_rep_values() { - use lance_datagen::{array, gen_batch, RowCount, Seed}; + async fn test_constant_layout_out_of_line_fixed_size_binary_v2_2() { + use crate::format::pb21::page_layout::Layout; - let seed = 1823859942947654717u64; - let num_rows = 2741usize; + let val = vec![0xABu8; 33]; + let arr: ArrayRef = Arc::new( + arrow_array::FixedSizeBinaryArray::try_from_sparse_iter_with_size( + std::iter::repeat_n(Some(val.as_slice()), 256), + 33, + ) + .unwrap(), + ); + let field = arrow_schema::Field::new("c", DataType::FixedSizeBinary(33), true); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; - // Generate the exact same data that caused the failure - let batch_gen = gen_batch().with_seed(Seed::from(seed)); - let base_generator = array::rand_type(&DataType::FixedSizeBinary(32)); - let list_generator = array::rand_list_any(base_generator, false); + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { + panic!("Expected constant layout in slot 2"); + }; + assert!(layout.inline_value.is_none()); + assert_eq!(page.data.len(), 1); - let batch = batch_gen - .anon_col(list_generator) - .into_batch_rows(RowCount::from(num_rows as u64)) - .unwrap(); + let test_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + } - let list_array = batch.column(0).clone(); + #[tokio::test] + async fn test_constant_layout_out_of_line_utf8_v2_2() { + use crate::format::pb21::page_layout::Layout; - // Force miniblock encoding - let mut metadata = HashMap::new(); - metadata.insert( - STRUCTURAL_ENCODING_META_KEY.to_string(), - STRUCTURAL_ENCODING_MINIBLOCK.to_string(), - ); + let arr: ArrayRef = Arc::new(arrow_array::StringArray::from_iter_values( + std::iter::repeat_n("hello", 512), + )); + let field = arrow_schema::Field::new("c", DataType::Utf8, true); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) - .with_batch_size(100) - .with_range(0..num_rows.min(500) as u64) - .with_indices(vec![0, num_rows as u64 / 2, (num_rows - 1) as u64]); + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { + panic!("Expected constant layout in slot 2"); + }; + assert!(layout.inline_value.is_none()); + assert_eq!(page.data.len(), 1); - check_round_trip_encoding_of_data(vec![list_array], &test_cases, metadata).await + let test_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; } - async fn test_minichunk_size_helper( - string_data: Vec>, - minichunk_size: u64, - file_version: LanceFileVersion, - ) { - use crate::constants::MINICHUNK_SIZE_META_KEY; - use crate::testing::{check_round_trip_encoding_of_data, TestCases}; - use arrow_array::{ArrayRef, StringArray}; - use std::sync::Arc; + #[tokio::test] + async fn test_constant_layout_nullable_item_v2_2() { + use crate::format::pb21::page_layout::Layout; - let string_array: ArrayRef = Arc::new(StringArray::from(string_data)); + let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![ + Some(7), + None, + Some(7), + None, + Some(7), + ])); + let field = arrow_schema::Field::new("c", DataType::Int32, true); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; - let mut metadata = HashMap::new(); - metadata.insert( - MINICHUNK_SIZE_META_KEY.to_string(), - minichunk_size.to_string(), - ); - metadata.insert( - STRUCTURAL_ENCODING_META_KEY.to_string(), - STRUCTURAL_ENCODING_MINIBLOCK.to_string(), - ); + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { + panic!("Expected constant layout in slot 2"); + }; + assert!(layout.inline_value.is_some()); + assert_eq!(page.data.len(), 2); let test_cases = TestCases::default() - .with_min_file_version(file_version) - .with_batch_size(1000); - - check_round_trip_encoding_of_data(vec![string_array], &test_cases, metadata).await; + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; } #[tokio::test] - async fn test_minichunk_size_roundtrip() { - // Test that minichunk size can be configured and works correctly in round-trip encoding - let mut string_data = Vec::new(); - for i in 0..100 { - string_data.push(Some(format!("test_string_{}", i).repeat(50))); - } - // configure minichunk size to 64 bytes (smaller than the default 4kb) for Lance 2.1 - test_minichunk_size_helper(string_data, 64, LanceFileVersion::V2_1).await; - } + async fn test_constant_layout_list_repdef_v2_2() { + use crate::format::pb21::page_layout::Layout; + use arrow_array::builder::{Int32Builder, ListBuilder}; - #[tokio::test] - async fn test_minichunk_size_128kb_v2_2() { - // Test that minichunk size can be configured to 128KB and works correctly with Lance 2.2 - let mut string_data = Vec::new(); - // create a 500kb string array - for i in 0..10000 { - string_data.push(Some(format!("test_string_{}", i).repeat(50))); - } - test_minichunk_size_helper(string_data, 128 * 1024, LanceFileVersion::V2_2).await; - } + let mut builder = ListBuilder::new(Int32Builder::new()); + builder.values().append_value(7); + builder.values().append_null(); + builder.values().append_value(7); + builder.append(true); - #[tokio::test] - async fn test_binary_large_minichunk_size_over_max_miniblock_values() { - let mut string_data = Vec::new(); - // 128kb/chunk / 6 bytes (t_9999) = 21845 > max 4096 items per chunk - for i in 0..10000 { - string_data.push(Some(format!("t_{}", i))); - } - test_minichunk_size_helper(string_data, 128 * 1024, LanceFileVersion::V2_2).await; - } + builder.append(true); - #[tokio::test] - async fn test_large_dictionary_general_compression() { - use arrow_array::{ArrayRef, StringArray}; - use std::collections::HashMap; - use std::sync::Arc; + builder.values().append_value(7); + builder.append(true); - // Create large string dictionary data (>32KiB) with low cardinality - // Use 100 unique strings, each 500 bytes long = 50KB dictionary - let unique_values: Vec = (0..100) - .map(|i| format!("value_{:04}_{}", i, "x".repeat(500))) - .collect(); + builder.append_null(); - // Repeat these strings many times to create a large array - let repeated_strings: Vec<_> = unique_values - .iter() - .cycle() - .take(100_000) - .map(|s| Some(s.as_str())) - .collect(); + let arr: ArrayRef = Arc::new(builder.finish()); + let field = arrow_schema::Field::new( + "c", + DataType::List(Arc::new(arrow_schema::Field::new( + "item", + DataType::Int32, + true, + ))), + true, + ); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; - let string_array = Arc::new(StringArray::from(repeated_strings)) as ArrayRef; + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { + panic!("Expected constant layout in slot 2"); + }; + assert!(layout.inline_value.is_some()); + assert_eq!(page.data.len(), 2); - // Configure test to use V2_2 and verify encoding let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_verify_encoding(Arc::new(|cols: &[crate::encoder::EncodedColumn], _| { - assert_eq!(cols.len(), 1); - let col = &cols[0]; + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + } - // Navigate to the dictionary encoding in the page layout - if let Some(PageEncoding::Structural(page_layout)) = &col.final_pages.first().map(|p| &p.description) { - // Check that dictionary is wrapped with general compression - if let Some(pb21::page_layout::Layout::MiniBlockLayout(mini_block)) = &page_layout.layout { - if let Some(dictionary_encoding) = &mini_block.dictionary { - match dictionary_encoding.compression.as_ref() { - Some(Compression::General(general)) => { - // Verify it's using LZ4 or Zstd - let compression = general.compression.as_ref().unwrap(); - assert!( - compression.scheme() == pb21::CompressionScheme::CompressionAlgorithmLz4 - || compression.scheme() == pb21::CompressionScheme::CompressionAlgorithmZstd, - "Expected LZ4 or Zstd compression for large dictionary" - ); - } - _ => panic!("Expected General compression for large dictionary"), - } - } - } - } - })); + #[tokio::test] + async fn test_constant_layout_fixed_size_list_not_used_v2_2() { + use crate::format::pb21::page_layout::Layout; + use arrow_array::builder::{FixedSizeListBuilder, Int32Builder}; - check_round_trip_encoding_of_data(vec![string_array], &test_cases, HashMap::new()).await; + let mut builder = FixedSizeListBuilder::new(Int32Builder::new(), 3); + for _ in 0..64 { + builder.values().append_value(1); + builder.values().append_null(); + builder.values().append_value(3); + builder.append(true); + } + let arr: ArrayRef = Arc::new(builder.finish()); + let field = arrow_schema::Field::new( + "c", + DataType::FixedSizeList( + Arc::new(arrow_schema::Field::new("item", DataType::Int32, true)), + 3, + ), + true, + ); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; + + if let PageEncoding::Structural(layout) = &page.description { + assert!( + !matches!(layout.layout.as_ref().unwrap(), Layout::ConstantLayout(_)), + "FixedSizeList should not use constant layout yet" + ); + } + + let test_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; } #[tokio::test] - async fn test_dictionary_encode_int64() { - use crate::constants::{DICT_SIZE_RATIO_META_KEY, STRUCTURAL_ENCODING_META_KEY}; - use crate::testing::{check_round_trip_encoding_of_data, TestCases}; - use crate::version::LanceFileVersion; - use arrow_array::{ArrayRef, Int64Array}; - use std::collections::HashMap; - use std::sync::Arc; + async fn test_constant_layout_not_written_before_v2_2() { + use crate::format::pb21::page_layout::Layout; - // Low cardinality with poor RLE opportunity. - let values = (0..1000) - .map(|i| match i % 3 { - 0 => 10i64, - 1 => 20i64, - _ => 30i64, - }) - .collect::>(); - let array = Arc::new(Int64Array::from(values)) as ArrayRef; + let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![7; 1024])); + let field = arrow_schema::Field::new("c", DataType::Int32, true); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU16).await; - let mut metadata = HashMap::new(); - metadata.insert( - STRUCTURAL_ENCODING_META_KEY.to_string(), - STRUCTURAL_ENCODING_MINIBLOCK.to_string(), + let PageEncoding::Structural(layout) = &page.description else { + return; + }; + assert!( + !matches!(layout.layout.as_ref().unwrap(), Layout::ConstantLayout(_)), + "Should not emit constant layout before v2.2" ); - metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.99".to_string()); let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_batch_size(1000) - .with_range(0..1000) - .with_indices(vec![0, 1, 10, 999]) - .with_expected_encoding("dictionary"); - - check_round_trip_encoding_of_data(vec![array], &test_cases, metadata).await; + .with_encoding(TestEncoding::StructuralU16) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; } #[tokio::test] - async fn test_dictionary_encode_float64() { - use crate::constants::{DICT_SIZE_RATIO_META_KEY, STRUCTURAL_ENCODING_META_KEY}; - use crate::testing::{check_round_trip_encoding_of_data, TestCases}; - use crate::version::LanceFileVersion; - use arrow_array::{ArrayRef, Float64Array}; - use std::collections::HashMap; - use std::sync::Arc; + async fn test_all_null_constant_layout_still_works_v2_2() { + use crate::format::pb21::page_layout::Layout; - // Low cardinality with poor RLE opportunity. - let values = (0..1000) - .map(|i| match i % 3 { - 0 => 0.1f64, - 1 => 0.2f64, - _ => 0.3f64, - }) - .collect::>(); - let array = Arc::new(Float64Array::from(values)) as ArrayRef; + let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![None, None, None])); + let field = arrow_schema::Field::new("c", DataType::Int32, true); + let page = encode_first_page(field, arr.clone(), TestEncoding::StructuralU32).await; - let mut metadata = HashMap::new(); - metadata.insert( - STRUCTURAL_ENCODING_META_KEY.to_string(), - STRUCTURAL_ENCODING_MINIBLOCK.to_string(), + let PageEncoding::Structural(layout) = &page.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { + panic!("Expected layout in slot 2"); + }; + assert!(layout.inline_value.is_none()); + assert_eq!(page.data.len(), 0); + + let test_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![4096]); + check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + } + + fn hand_built_dictionary_with_out_of_range_null_keys() -> ArrayRef { + use arrow_array::{DictionaryArray, Int32Array, types::Int32Type}; + use arrow_buffer::NullBuffer; + + let keys = Int32Array::new( + vec![0, 7, 7].into(), + Some(NullBuffer::from(vec![true, false, false])), ); - metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.99".to_string()); + let values = Arc::new(StringArray::from(vec!["a"])); + Arc::new(DictionaryArray::::try_new(keys, values).unwrap()) as ArrayRef + } + + fn concatenated_dictionary_with_out_of_range_null_keys() -> ArrayRef { + use arrow_array::{builder::StringDictionaryBuilder, new_null_array, types::Int32Type}; + let mut builder = StringDictionaryBuilder::::new(); + builder.append_value("a"); + for _ in 0..7 { + builder.append_null(); + } + let valued = Arc::new(builder.finish()) as ArrayRef; + let all_null = new_null_array(valued.data_type(), 8); + arrow_select::concat::concat(&[valued.as_ref(), all_null.as_ref()]).unwrap() + } + + #[rstest::rstest] + #[case::hand_built(hand_built_dictionary_with_out_of_range_null_keys(), 1)] + #[case::concatenated(concatenated_dictionary_with_out_of_range_null_keys(), 8)] + #[tokio::test] + async fn test_dictionary_out_of_range_null_keys_round_trip( + #[case] dictionary: ArrayRef, + #[case] split_at: usize, + ) { + let num_rows = dictionary.len(); + let dictionaries = vec![ + dictionary.slice(0, split_at), + dictionary.slice(split_at, num_rows - split_at), + ]; let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_batch_size(1000) - .with_range(0..1000) - .with_indices(vec![0, 1, 10, 999]) - .with_expected_encoding("dictionary"); + .with_encoding(TestEncoding::StructuralU32) + .with_page_sizes(vec![1]) + .with_range(split_at as u64..num_rows as u64) + .with_indices(vec![split_at as u64]); - check_round_trip_encoding_of_data(vec![array], &test_cases, metadata).await; + check_round_trip_encoding_of_data(dictionaries, &test_cases, HashMap::new()).await; } #[test] - fn test_miniblock_dictionary_out_of_line_bitpacking_decode() { - let rows = 10_000; - let unique_values = 2_000; + fn test_encode_decode_complex_all_null_vals_roundtrip() { + use crate::compression::{DecompressionStrategy, DefaultDecompressionStrategy}; - let dictionary_encoding = - ProtobufUtils21::out_of_line_bitpacking(64, ProtobufUtils21::flat(11, None)); - let layout = pb21::MiniBlockLayout { - rep_compression: None, - def_compression: None, - value_compression: Some(ProtobufUtils21::flat(64, None)), - dictionary: Some(dictionary_encoding), - num_dictionary_items: unique_values, - layers: vec![pb21::RepDefLayer::RepdefAllValidItem as i32], - num_buffers: 1, - repetition_index_depth: 0, - num_items: rows, - has_large_chunk: false, - }; + let values: Arc<[u16]> = Arc::from((0..2048).map(|i| (i % 5) as u16).collect::>()); - let buffer_offsets_and_sizes = vec![(0, 0), (0, 0), (0, 0)]; - let scheduler = super::MiniBlockScheduler::try_new( - &buffer_offsets_and_sizes, - /*priority=*/ 0, - /*items_in_page=*/ rows, - &layout, - &DefaultDecompressionStrategy::default(), + let compression_strategy = crate::testing::test_compression_strategy( + TestEncoding::StructuralU16, + crate::compression_config::CompressionParams::default(), + ); + let decompression_strategy = DefaultDecompressionStrategy::default(); + + let (compressed_buf, encoding) = PrimitiveStructuralEncoder::encode_complex_all_null_vals( + &values, + compression_strategy.as_ref(), ) .unwrap(); - let dictionary = scheduler.dictionary.unwrap(); - assert_eq!(dictionary.num_dictionary_items, unique_values); - assert_eq!( - dictionary.dictionary_data_alignment, - crate::encoder::MIN_PAGE_BUFFER_ALIGNMENT - ); + let decompressor = decompression_strategy + .create_block_decompressor(&encoding) + .unwrap(); + let decompressed = decompressor + .decompress(Some(compressed_buf), values.len() as u64) + .unwrap(); + let decompressed_fixed_width = decompressed.as_fixed_width().unwrap(); + assert_eq!(decompressed_fixed_width.num_values, values.len() as u64); + assert_eq!(decompressed_fixed_width.bits_per_value, 16); + let rep_result = decompressed_fixed_width.data.borrow_to_typed_slice::(); + assert_eq!(rep_result.as_ref(), values.as_ref()); } - // Dictionary encoding decision tests - /// Helper to create FixedWidth test data block with exact cardinality stat injected - /// to ensure consistent test behavior (avoids HLL estimation error) - fn create_test_fixed_data_block( - num_values: u64, - cardinality: u64, - bits_per_value: u64, - ) -> DataBlock { - use crate::statistics::Stat; + #[tokio::test] + async fn test_complex_all_null_compression_gated_by_version() { + use crate::format::pb21::page_layout::Layout; + use arrow_array::ListArray; - let block_info = BlockInfo::default(); + let list_array = ListArray::from_iter_primitive::( + (0..1000).map(|i| if i % 2 == 0 { None } else { Some(vec![]) }), + ); + let arr: ArrayRef = Arc::new(list_array); + let field = arrow_schema::Field::new( + "c", + DataType::List(Arc::new(arrow_schema::Field::new( + "item", + DataType::Int32, + true, + ))), + true, + ); - // Manually inject exact cardinality stat for consistent test behavior - let cardinality_array = Arc::new(UInt64Array::from(vec![cardinality])); - block_info - .0 - .write() - .unwrap() - .insert(Stat::Cardinality, cardinality_array); + let page_v21 = + encode_first_page(field.clone(), arr.clone(), TestEncoding::StructuralU16).await; + let PageEncoding::Structural(layout_v21) = &page_v21.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout_v21) = layout_v21.layout.as_ref().unwrap() else { + panic!("Expected constant layout"); + }; + assert!(layout_v21.rep_compression.is_none()); + assert!(layout_v21.def_compression.is_none()); + assert_eq!(layout_v21.num_rep_values, 0); + assert_eq!(layout_v21.num_def_values, 0); - assert_eq!(bits_per_value % 8, 0); - let bytes_per_value = bits_per_value / 8; - DataBlock::FixedWidth(FixedWidthDataBlock { - bits_per_value, - data: crate::buffer::LanceBuffer::from(vec![ - 0u8; - (num_values * bytes_per_value) as usize - ]), - num_values, - block_info, - }) + let page_v22 = encode_first_page(field, arr, TestEncoding::StructuralU32).await; + let PageEncoding::Structural(layout_v22) = &page_v22.description else { + panic!("Expected structural encoding"); + }; + let Layout::ConstantLayout(layout_v22) = layout_v22.layout.as_ref().unwrap() else { + panic!("Expected constant layout"); + }; + assert!(layout_v22.def_compression.is_some()); + assert!(layout_v22.num_def_values > 0); } - /// Helper to create VariableWidth (string) test data block with exact cardinality - fn create_test_variable_width_block(num_values: u64, cardinality: u64) -> DataBlock { - use crate::statistics::Stat; - use arrow_array::StringArray; - - assert!(cardinality <= num_values && cardinality > 0); - - let mut values = Vec::with_capacity(num_values as usize); - for i in 0..num_values { - values.push(format!("value_{:016}", i % cardinality)); - } + #[tokio::test] + async fn test_complex_all_null_round_trip() { + use arrow_array::ListArray; - let array = StringArray::from(values); - let block = DataBlock::from_array(Arc::new(array) as ArrayRef); - - // Manually inject stats for consistent test behavior - if let DataBlock::VariableWidth(ref var_block) = block { - let mut info = var_block.block_info.0.write().unwrap(); - // Cardinality: exact value to avoid HLL estimation error - info.insert( - Stat::Cardinality, - Arc::new(UInt64Array::from(vec![cardinality])), - ); - } + let list_array = ListArray::from_iter_primitive::( + (0..1000).map(|i| if i % 2 == 0 { None } else { Some(vec![]) }), + ); - block + let test_cases = TestCases::default().with_u32_structural_encodings(); + check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, HashMap::new()) + .await; } - #[test] - fn test_estimate_dict_size_fixed_width() { - use crate::encodings::logical::primitive::dict::DICT_INDICES_BITS_PER_VALUE; - - let bits_per_value = 128; - let block = create_test_fixed_data_block(1000, 400, bits_per_value); - let estimated_size = - PrimitiveStructuralEncoder::estimate_dict_size(&block, LanceFileVersion::V2_1).unwrap(); + #[tokio::test] + async fn test_complex_all_null_constant_def_round_trip() { + use arrow_array::ListArray; - // Dictionary: 400 * 16 bytes (128-bit values) - // Indices: 1000 * 4 bytes (32-bit i32) - let expected_dict_size = 400 * (bits_per_value / 8); - let expected_indices_size = 1000 * (DICT_INDICES_BITS_PER_VALUE / 8); - let expected_total = expected_dict_size + expected_indices_size; + // Every row is a null list => constant def levels => a single RLE run, + // exercising the lazy run-form decode end to end. + let list_array = ListArray::from_iter_primitive::( + (0..5000).map(|_| None::>>), + ); - assert_eq!(estimated_size, expected_total); + let test_cases = TestCases::default().with_u32_structural_encodings(); + check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, HashMap::new()) + .await; } - #[test] - fn test_estimate_dict_size_variable_width() { - let block = create_test_variable_width_block(1000, 400); - let estimated_size = - PrimitiveStructuralEncoder::estimate_dict_size(&block, LanceFileVersion::V2_1).unwrap(); - - // Get actual data size - let data_size = block.data_size(); - let avg_value_size = data_size / 1000; - - let expected = 400 * avg_value_size + 1000 * 4; + fn encoded_u16_frame(levels: &[u16], run_length_width: RunLengthWidth) -> LanceBuffer { + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_slice(Arc::from(levels)), + bits_per_value: 16, + num_values: levels.len() as u64, + block_info: BlockInfo::new(), + }); + BlockCompressor::compress(&RleEncoder::with_run_length_width(run_length_width), block) + .unwrap() + .0 + .unwrap() + } - assert_eq!(estimated_size, expected); + fn encoded_u16_runs(levels: &[u16], run_length_width: RunLengthWidth) -> RleRuns { + let frame = encoded_u16_frame(levels, run_length_width); + RleDecompressor::with_run_length_width(16, run_length_width) + .decode_u16_runs(frame, levels.len() as u64) + .unwrap() } #[test] - fn test_should_dictionary_encode() { - use crate::constants::DICT_SIZE_RATIO_META_KEY; - use lance_core::datatypes::Field as LanceField; + fn miniblock_levels_use_one_count_for_mixed_codecs() { + let actual_num_levels = usize::from(u16::MAX) + 8; + let levels = vec![1_u16; actual_num_levels]; + let rep_frame = encoded_u16_frame(&levels, RunLengthWidth::U8); + let rep_decompressor = RleDecompressor::new(16); + let def_frame = LanceBuffer::reinterpret_slice(Arc::from(levels.clone())); + let def_decompressor = ValueDecompressor::from_flat(&pb21::Flat { + bits_per_value: 16, + data: None, + }); - // Create data where dict encoding saves space - let block = create_test_variable_width_block(1000, 10); + let num_levels = DecodeMiniBlockTask::resolve_num_levels( + Some(&rep_decompressor), + Some(&rep_frame), + Some(&def_decompressor), + Some(&def_frame), + 7, + ) + .unwrap(); + assert_eq!(num_levels, actual_num_levels as u64); - let mut metadata = HashMap::new(); - metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); - let arrow_field = - arrow_schema::Field::new("test", DataType::Int32, false).with_metadata(metadata); - let field = LanceField::try_from(&arrow_field).unwrap(); + let rep = + DecodeMiniBlockTask::decode_levels(&rep_decompressor, rep_frame, num_levels).unwrap(); + let def = + DecodeMiniBlockTask::decode_levels(&def_decompressor, def_frame, num_levels).unwrap(); + assert_eq!(rep.as_ref(), levels); + assert_eq!(def, rep); + } - let result = PrimitiveStructuralEncoder::should_dictionary_encode( - &block, - &field, - LanceFileVersion::V2_1, - ); + #[test] + fn miniblock_levels_reject_non_wrapped_count_mismatch() { + let frame = encoded_u16_frame(&[1_u16; 8], RunLengthWidth::U8); + let decompressor = RleDecompressor::new(16); - assert!(result, "Should use dictionary encode based on size"); + let error = DecodeMiniBlockTask::resolve_num_levels( + Some(&decompressor), + Some(&frame), + None, + None, + 7, + ) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!(error.to_string().contains("not congruent modulo 65536")); } #[test] - fn test_should_not_dictionary_encode() { - use crate::constants::DICT_SIZE_RATIO_META_KEY; - use lance_core::datatypes::Field as LanceField; + fn miniblock_levels_reject_cross_stream_count_disagreement() { + let rep_levels = vec![1_u16; usize::from(u16::MAX) + 8]; + let def_levels = vec![1_u16; rep_levels.len() + usize::from(u16::MAX) + 1]; + let rep_frame = encoded_u16_frame(&rep_levels, RunLengthWidth::U8); + let def_frame = encoded_u16_frame(&def_levels, RunLengthWidth::U8); + let decompressor = RleDecompressor::new(16); + + let error = DecodeMiniBlockTask::resolve_num_levels( + Some(&decompressor), + Some(&rep_frame), + Some(&decompressor), + Some(&def_frame), + 7, + ) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("structural streams disagree on the level count") + ); + } - let block = create_test_fixed_data_block(1000, 1000, 128); + fn physical_levels(levels: &[u16]) -> LazyLevels { + LazyLevels::Runs(Arc::new(RunStorage::Physical( + encoded_u16_runs(levels, RunLengthWidth::U8).into_owned(), + ))) + } - let mut metadata = HashMap::new(); - metadata.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.8".to_string()); - let arrow_field = - arrow_schema::Field::new("test", DataType::Int32, false).with_metadata(metadata); - let field = LanceField::try_from(&arrow_field).unwrap(); + fn coalesced_levels(levels: &[u16]) -> LazyLevels { + let mut values = Vec::new(); + let mut ends = RunEndsBuilder::with_capacity(levels.len(), levels.len()); + for (index, &value) in levels.iter().enumerate() { + if values.last() == Some(&value) { + ends.set_last(index + 1).unwrap(); + } else { + values.push(value); + ends.push(index + 1).unwrap(); + } + } + LazyLevels::Runs(Arc::new(RunStorage::Coalesced { + values: values.into_boxed_slice(), + ends: ends.finish(), + })) + } - let result = PrimitiveStructuralEncoder::should_dictionary_encode( - &block, - &field, - LanceFileVersion::V2_1, - ); + #[test] + fn lazy_levels_runs_match_dense() { + // Runs: 3x2, 1x1, 3x3, 0x2 => [3,3,1,3,3,3,0,0] + let expanded: Vec = vec![3, 3, 1, 3, 3, 3, 0, 0]; + let coalesced = coalesced_levels(&expanded); + let physical = physical_levels(&expanded); + let dense = LazyLevels::Dense(ScalarBuffer::::from(expanded.clone())); + let n = expanded.len(); + + assert_eq!(coalesced.len(), n); + assert_eq!(physical.len(), n); + assert_eq!(dense.len(), n); + + // Rows begin at each `max_rep` (3) position; row `num_rows` maps to `len`. + let max_rep = 3u16; + let row_starts: Vec = (0..n).filter(|&i| expanded[i] == max_rep).collect(); + for target in 0..=row_starts.len() as u64 { + let want = row_starts.get(target as usize).copied().unwrap_or(n); + for runs in [&coalesced, &physical] { + let mut cursor = LevelCursor::default(); + assert_eq!( + runs.seek_row_start(&mut cursor, target, max_rep).unwrap(), + want, + "seek_row_start({target})" + ); + } + let mut c_dense = LevelCursor::default(); + assert_eq!( + dense.seek_row_start(&mut c_dense, target, max_rep).unwrap(), + want + ); + } - assert!(!result, "Should not use dictionary encode based on size"); + // `count_le_cursor` (fresh cursor per range) and `extend_into` agree with + // the dense reference on every sub-range. + for start in 0..=n { + for end in start..=n { + for max in [0u16, 1, 2, 3] { + let want = expanded[start..end].iter().filter(|&&d| d <= max).count() as u64; + for runs in [&coalesced, &physical] { + let mut cursor = RunPosition::default(); + assert_eq!( + runs.count_le_cursor(&mut cursor, start..end, max).0, + want, + "count_le_cursor({start}..{end}, {max})" + ); + } + let mut d_cur = RunPosition::default(); + assert_eq!(dense.count_le_cursor(&mut d_cur, start..end, max).0, want); + } + for runs in [&coalesced, &physical] { + let mut got = Vec::new(); + runs.extend_into(start..end, RunPosition::default(), &mut got); + assert_eq!( + got, + expanded[start..end].to_vec(), + "extend_into({start}..{end})" + ); + } + let mut got_dense = Vec::new(); + dense.extend_into(start..end, RunPosition::default(), &mut got_dense); + assert_eq!(got_dense, expanded[start..end].to_vec()); + } + } } - async fn encode_first_page( - field: arrow_schema::Field, - array: ArrayRef, - version: LanceFileVersion, - ) -> crate::encoder::EncodedPage { - use crate::encoder::{ - default_encoding_strategy, ColumnIndexSequence, EncodingOptions, OutOfLineBuffers, - MIN_PAGE_BUFFER_ALIGNMENT, + #[test] + fn physical_run_hints_support_deferred_materialization() { + let expanded: Vec = vec![3, 3, 1, 1, 2, 2, 0, 0]; + let physical = physical_levels(&expanded); + let LazyLevels::Runs(runs) = &physical else { + panic!("expected physical runs"); }; - use crate::repdef::RepDefBuilder; + let mut first_hint = RunPosition::default(); + runs.seek(&mut first_hint, 2); + let mut second_hint = RunPosition::default(); + runs.seek(&mut second_hint, 6); + + let mut second = Vec::new(); + physical.extend_into(6..8, second_hint, &mut second); + let mut first = Vec::new(); + physical.extend_into(2..4, first_hint, &mut first); + assert_eq!(second, expanded[6..8]); + assert_eq!(first, expanded[2..4]); + } + + /// Fuzz parity for the run-oriented complex-all-null drain: the cursor walk + /// over `LazyLevels` must yield the exact level slices and visible + /// count that a brute-force reference over the fully expanded levels does, for + /// dense, physical-run, and coalesced-run forms and arbitrarily shaped range requests. + mod complex_all_null_drain_parity { + use std::ops::Range; + + use arrow_buffer::ScalarBuffer; + use proptest::prelude::*; + + use super::super::{LazyLevels, LevelCursor, RunPosition}; + use super::{coalesced_levels, physical_levels}; + use crate::Result; + + #[derive(Debug, Clone)] + struct DrainInput { + max_rep: u16, + max_visible: u16, + rep: Option>, + def: Option>, + ranges: Vec>, + } - let lance_field = lance_core::datatypes::Field::try_from(&field).unwrap(); - let encoding_strategy = default_encoding_strategy(version); - let mut column_index_seq = ColumnIndexSequence::default(); - let encoding_options = EncodingOptions { - cache_bytes_per_column: 1, - max_page_bytes: 32 * 1024 * 1024, - keep_original_array: true, - buffer_alignment: MIN_PAGE_BUFFER_ALIGNMENT, - version, - }; + fn dense_levels(levels: &[u16]) -> LazyLevels { + LazyLevels::Dense(ScalarBuffer::from(levels.to_vec())) + } - let mut encoder = encoding_strategy - .create_field_encoder( - encoding_strategy.as_ref(), - &lance_field, - &mut column_index_seq, - &encoding_options, - ) - .unwrap(); + fn rle_levels(levels: &[u16]) -> LazyLevels { + coalesced_levels(levels) + } - let mut external_buffers = OutOfLineBuffers::new(0, MIN_PAGE_BUFFER_ALIGNMENT); - let repdef = RepDefBuilder::default(); - let num_rows = array.len() as u64; - let mut pages = Vec::new(); - for task in encoder - .maybe_encode(array, &mut external_buffers, repdef, 0, num_rows) - .unwrap() - { - pages.push(task.await.unwrap()); + fn seek( + rep: Option<&LazyLevels>, + cursor: &mut LevelCursor, + row: u64, + max_rep: u16, + ) -> Result { + match rep { + Some(rep) => rep.seek_row_start(cursor, row, max_rep), + None => { + cursor.row = row; + cursor.level = row as usize; + Ok(row as usize) + } + } } - for task in encoder.flush(&mut external_buffers).unwrap() { - pages.push(task.await.unwrap()); + + /// Mirror of `ComplexAllNullPageDecoder::drain`, driving the real + /// `seek_row_start` / `count_le_cursor` with monotonic cursors. + fn simulate_drain( + rep: Option<&LazyLevels>, + def: Option<&LazyLevels>, + max_rep: u16, + max_visible: u16, + ranges: &[Range], + ) -> Result<(Vec>, u64)> { + let mut rep_cursor = LevelCursor::default(); + let mut def_run_cursor = RunPosition::default(); + let mut slices: Vec> = Vec::new(); + let mut visible = 0u64; + for range in ranges { + let level_start = seek(rep, &mut rep_cursor, range.start, max_rep)?; + let level_end = seek(rep, &mut rep_cursor, range.end, max_rep)?; + visible += match def { + Some(def) => { + def.count_le_cursor( + &mut def_run_cursor, + level_start..level_end, + max_visible, + ) + .0 + } + None => (level_end - level_start) as u64, + }; + match slices.last_mut() { + Some(last) if last.end == level_start => last.end = level_end, + _ => slices.push(level_start..level_end), + } + } + Ok((slices, visible)) + } + + /// Independent brute-force reference over fully expanded levels. + fn reference_drain( + rep: Option<&[u16]>, + def: Option<&[u16]>, + max_rep: u16, + max_visible: u16, + ranges: &[Range], + ) -> (Vec>, u64) { + let total_levels = rep + .map(|r| r.len()) + .or_else(|| def.map(|d| d.len())) + .unwrap_or(0); + // Level index where each row starts (or `total_levels` for the end row). + let row_starts: Vec = match rep { + Some(rep) => (0..rep.len()).filter(|&i| rep[i] == max_rep).collect(), + None => (0..total_levels).collect(), + }; + let level_of_row = |row: u64| { + row_starts + .get(row as usize) + .copied() + .unwrap_or(total_levels) + }; + + let mut slices: Vec> = Vec::new(); + let mut visible = 0u64; + for range in ranges { + let ls = level_of_row(range.start); + let le = level_of_row(range.end); + visible += match def { + Some(def) => def[ls..le].iter().filter(|&&d| d <= max_visible).count() as u64, + None => (le - ls) as u64, + }; + match slices.last_mut() { + Some(last) if last.end == ls => last.end = le, + _ => slices.push(ls..le), + } + } + (slices, visible) } - pages.into_iter().next().unwrap() - } - #[tokio::test] - async fn test_constant_layout_out_of_line_fixed_size_binary_v2_2() { - use crate::format::pb21::page_layout::Layout; + fn ranges_strategy(num_rows: u64) -> BoxedStrategy>> { + if num_rows == 0 { + return Just(Vec::new()).boxed(); + } + // (gap, len) pairs; a zero gap yields ranges adjacent in row space, + // which exercises the level-slice coalescing path. + proptest::collection::vec((0u64..=3, 1u64..=4), 0..=8) + .prop_map(move |pairs| { + let mut ranges = Vec::new(); + let mut pos = 0u64; + for (gap, len) in pairs { + pos = pos.saturating_add(gap); + if pos >= num_rows { + break; + } + let end = (pos + len).min(num_rows); + ranges.push(pos..end); + pos = end; + } + ranges + }) + .boxed() + } - let val = vec![0xABu8; 33]; - let arr: ArrayRef = Arc::new( - arrow_array::FixedSizeBinaryArray::try_from_sparse_iter_with_size( - std::iter::repeat_n(Some(val.as_slice()), 256), - 33, + fn drain_input() -> impl Strategy { + ( + 1u16..=3, + 0u16..=3, + any::(), + any::(), + 1usize..=48, ) - .unwrap(), - ); - let field = arrow_schema::Field::new("c", DataType::FixedSizeBinary(33), true); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + .prop_flat_map(|(max_rep, max_visible, has_rep, has_def, len)| { + // Complex-all-null always has definition levels when there is + // no repetition, so force `def` present in that case. + let has_def = has_def || !has_rep; + let rep = if has_rep { + proptest::collection::vec(0u16..=max_rep, len) + .prop_map(move |mut v| { + // Row 0 must start at a max-rep boundary. + v[0] = max_rep; + Some(v) + }) + .boxed() + } else { + Just(None).boxed() + }; + let def = if has_def { + proptest::collection::vec(0u16..=(max_visible + 2), len) + .prop_map(Some) + .boxed() + } else { + Just(None).boxed() + }; + (Just(max_rep), Just(max_visible), rep, def) + }) + .prop_flat_map(|(max_rep, max_visible, rep, def)| { + let num_rows = match &rep { + Some(rep) => rep.iter().filter(|&&v| v == max_rep).count() as u64, + None => def.as_ref().map(|d| d.len() as u64).unwrap_or(0), + }; + ranges_strategy(num_rows).prop_map(move |ranges| DrainInput { + max_rep, + max_visible, + rep: rep.clone(), + def: def.clone(), + ranges, + }) + }) + } - let PageEncoding::Structural(layout) = &page.description else { - panic!("Expected structural encoding"); - }; - let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { - panic!("Expected constant layout in slot 2"); - }; - assert!(layout.inline_value.is_none()); - assert_eq!(page.data.len(), 1); + proptest! { + #![proptest_config(ProptestConfig::with_cases(512))] - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; - } + #[test] + fn drain_matches_reference(input in drain_input()) { + let DrainInput { max_rep, max_visible, rep, def, ranges } = input; - #[tokio::test] - async fn test_constant_layout_out_of_line_utf8_v2_2() { - use crate::format::pb21::page_layout::Layout; + let reference = + reference_drain(rep.as_deref(), def.as_deref(), max_rep, max_visible, &ranges); - let arr: ArrayRef = Arc::new(arrow_array::StringArray::from_iter_values( - std::iter::repeat_n("hello", 512), - )); - let field = arrow_schema::Field::new("c", DataType::Utf8, true); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + let rep_dense = rep.as_deref().map(dense_levels); + let def_dense = def.as_deref().map(dense_levels); + let got_dense = + simulate_drain(rep_dense.as_ref(), def_dense.as_ref(), max_rep, max_visible, &ranges) + .unwrap(); + prop_assert_eq!(&got_dense, &reference, "dense form diverged from reference"); - let PageEncoding::Structural(layout) = &page.description else { - panic!("Expected structural encoding"); - }; - let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { - panic!("Expected constant layout in slot 2"); - }; - assert!(layout.inline_value.is_none()); - assert_eq!(page.data.len(), 1); + let rep_rle = rep.as_deref().map(rle_levels); + let def_rle = def.as_deref().map(rle_levels); + let got_rle = + simulate_drain(rep_rle.as_ref(), def_rle.as_ref(), max_rep, max_visible, &ranges) + .unwrap(); + prop_assert_eq!(&got_rle, &reference, "rle form diverged from reference"); - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + let rep_physical = rep.as_deref().map(physical_levels); + let def_physical = def.as_deref().map(physical_levels); + let got_physical = + simulate_drain(rep_physical.as_ref(), def_physical.as_ref(), max_rep, max_visible, &ranges) + .unwrap(); + prop_assert_eq!(&got_physical, &reference, "physical form diverged from reference"); + } + } } - #[tokio::test] - async fn test_constant_layout_nullable_item_v2_2() { - use crate::format::pb21::page_layout::Layout; + #[test] + fn lazy_levels_runs_are_compact() { + let single_run = |n: usize| { + let mut ends = RunEndsBuilder::with_capacity(n, 1); + ends.push(n).unwrap(); + LazyLevels::Runs(Arc::new(RunStorage::Coalesced { + values: vec![1u16].into_boxed_slice(), + ends: ends.finish(), + })) + }; + // Run-form footprint is independent of the logical length within an end width... + assert_eq!(single_run(100).deep_size(), single_run(10_000).deep_size()); + assert!(single_run(10_000_000).deep_size() < 100); + assert_eq!(single_run(10_000_000).len(), 10_000_000); + // ...while Dense pays 2 bytes per value. + assert_eq!( + LazyLevels::Dense(ScalarBuffer::::from(vec![1u16; 1000])).deep_size(), + 2000 + ); + } - let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![ - Some(7), - None, - Some(7), - None, - Some(7), - ])); - let field = arrow_schema::Field::new("c", DataType::Int32, true); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + #[test] + fn lazy_levels_selects_smallest_representation() { + let runs = encoded_u16_runs(&[7u16; 10], RunLengthWidth::U8); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Dense); - let PageEncoding::Structural(layout) = &page.description else { - panic!("Expected structural encoding"); - }; - let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { - panic!("Expected constant layout in slot 2"); - }; - assert!(layout.inline_value.is_some()); - assert_eq!(page.data.len(), 2); + let equal_size: Vec = std::iter::repeat_n(0, 256) + .chain(std::iter::repeat_n(1, 100)) + .chain(std::iter::repeat_n(2, 100)) + .collect(); + let runs = encoded_u16_runs(&equal_size, RunLengthWidth::U8); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Coalesced); - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; - } + let moderate_runs: Vec = (0..250) + .flat_map(|run| std::iter::repeat_n((run % 2) as u16, 4)) + .collect(); + let runs = encoded_u16_runs(&moderate_runs, RunLengthWidth::U8); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Physical); - #[tokio::test] - async fn test_constant_layout_list_repdef_v2_2() { - use crate::format::pb21::page_layout::Layout; - use arrow_array::builder::{Int32Builder, ListBuilder}; + let split_constant = vec![7u16; 5000]; + let runs = encoded_u16_runs(&split_constant, RunLengthWidth::U8); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Coalesced); - let mut builder = ListBuilder::new(Int32Builder::new()); - builder.values().append_value(7); - builder.values().append_null(); - builder.values().append_value(7); - builder.append(true); + let high_density: Vec = (0..70_000).map(|index| (index % 2) as u16).collect(); + let runs = encoded_u16_runs(&high_density, RunLengthWidth::U32); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Dense); + } - builder.append(true); + #[test] + fn physical_runs_detach_from_large_encoded_frame() { + let levels: Vec = (0..250) + .flat_map(|run| std::iter::repeat_n((run % 2) as u16, 4)) + .collect(); + let frame = encoded_u16_frame(&levels, RunLengthWidth::U8); + let frame_offset = 4096; + let mut allocation = vec![0; frame_offset + frame.len() + 1_000_000]; + allocation[frame_offset..frame_offset + frame.len()].copy_from_slice(frame.as_ref()); + let frame = LanceBuffer::from(allocation).slice_with_length(frame_offset, frame.len()); + let runs = RleDecompressor::with_run_length_width(16, RunLengthWidth::U8) + .decode_u16_runs(frame, levels.len() as u64) + .unwrap(); - builder.values().append_value(7); - builder.append(true); + assert_eq!(LazyLevels::select_plan(&runs), LevelPlan::Physical); + let cached = LazyLevels::from_rle_runs(runs).unwrap(); + assert!( + matches!(cached, LazyLevels::Runs(ref runs) if matches!(runs.as_ref(), RunStorage::Physical(_))) + ); + assert_eq!(cached.len(), levels.len()); + assert!(cached.deep_size() < 4096); + } - builder.append_null(); + #[test] + fn complex_all_null_levels_reject_invalid_values_and_lengths() { + let invalid_levels = vec![0u16, 3]; + for levels in [ + LazyLevels::Dense(ScalarBuffer::from(invalid_levels.clone())), + physical_levels(&invalid_levels), + coalesced_levels(&invalid_levels), + ] { + let error = validate_complex_all_null_levels(&None, &Some(levels), 0, 2).unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!(error.to_string().contains("Invalid definition level 3")); + } - let arr: ArrayRef = Arc::new(builder.finish()); - let field = arrow_schema::Field::new( - "c", - DataType::List(Arc::new(arrow_schema::Field::new( - "item", - DataType::Int32, - true, - ))), - true, + let rep = Some(LazyLevels::Dense(ScalarBuffer::from(vec![0u16; 2]))); + let def = Some(LazyLevels::Dense(ScalarBuffer::from(vec![0u16]))); + let error = validate_complex_all_null_levels(&rep, &def, 0, 0).unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("repetition has 2, definition has 1") ); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + } - let PageEncoding::Structural(layout) = &page.description else { - panic!("Expected structural encoding"); - }; - let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { - panic!("Expected constant layout in slot 2"); + #[test] + fn block_levels_reject_malformed_payload_size() { + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::from(vec![0]), + bits_per_value: 16, + num_values: 1, + block_info: BlockInfo::new(), + }); + let error = dense_levels_from_block(block, 1, "definition").unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("expected 2 bytes for 1 values, got 1") + ); + } + + #[test] + fn complex_all_null_level_codec_validates_rle_metadata() { + let encoding = pb21::CompressiveEncoding { + compression: Some(Compression::Rle(Box::new(pb21::Rle { + values: None, + run_lengths: Some(Box::new(ProtobufUtils21::flat(8, None))), + }))), }; - assert!(layout.inline_value.is_some()); - assert_eq!(page.data.len(), 2); - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + let error = LevelCodec::try_new(Some(&encoding), &DefaultDecompressionStrategy::default()) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("RLE compression missing values encoding") + ); } + // https://github.com/lance-format/lance/issues/6681 #[tokio::test] - async fn test_constant_layout_fixed_size_list_not_used_v2_2() { - use crate::format::pb21::page_layout::Layout; - use arrow_array::builder::{FixedSizeListBuilder, Int32Builder}; - - let mut builder = FixedSizeListBuilder::new(Int32Builder::new(), 3); - for _ in 0..64 { - builder.values().append_value(1); - builder.values().append_null(); - builder.values().append_value(3); - builder.append(true); + async fn test_sparse_boolean_list_roundtrip() { + use arrow_array::builder::{BooleanBuilder, ListBuilder}; + + let mut list_builder = ListBuilder::new(BooleanBuilder::new()); + for i in 0..1000i32 { + if i % 64 == 0 { + // Alternate true/false so the array is not constant (constant path avoids the bug). + list_builder.values().append_value(i % 128 == 0); + list_builder.append(true); + } else { + list_builder.append(false); + } } - let arr: ArrayRef = Arc::new(builder.finish()); - let field = arrow_schema::Field::new( - "c", - DataType::FixedSizeList( - Arc::new(arrow_schema::Field::new("item", DataType::Int32, true)), - 3, - ), - true, - ); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + let list_array = Arc::new(list_builder.finish()); - if let PageEncoding::Structural(layout) = &page.description { - assert!( - !matches!(layout.layout.as_ref().unwrap(), Layout::ConstantLayout(_)), - "FixedSizeList should not use constant layout yet" - ); - } + let test_cases = TestCases::default().with_structural_encodings(); + check_round_trip_encoding_of_data(vec![list_array], &test_cases, HashMap::new()).await; + } - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + fn truncated_tail_details() -> std::sync::Arc { + use crate::compression::VariablePerValueDecompressor; + use crate::encodings::physical::binary::VariableDecoder; + use crate::repdef::{ControlWordParser, DefinitionInterpretation}; + use std::sync::Arc; + Arc::new(super::FullZipDecodeDetails { + value_decompressor: super::PerValueDecompressor::Variable(Arc::new( + VariableDecoder::default(), + ) + as Arc), + def_meaning: vec![DefinitionInterpretation::NullableItem].into(), + ctrl_word_parser: ControlWordParser::new(0, 0), + max_rep: 0, + max_visible_def: 0, + }) } - #[tokio::test] - async fn test_constant_layout_not_written_before_v2_2() { - use crate::format::pb21::page_layout::Layout; + fn decode_variable_full_zip( + buf: Vec, + bits_per_offset: u8, + ) -> lance_core::Result { + use std::collections::VecDeque; + let mut data = VecDeque::new(); + data.push_back(crate::buffer::LanceBuffer::from(buf)); + super::VariableFullZipDecoder::new( + truncated_tail_details(), + data, + 1, + bits_per_offset, + bits_per_offset, + ) + } - let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![7; 1024])); - let field = arrow_schema::Field::new("c", DataType::Int32, true); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_1).await; + /// A well-formed length prefix decodes without incident, for both widths. + #[test] + fn variable_full_zip_wellformed_length_prefix() { + assert!(decode_variable_full_zip(0u32.to_le_bytes().to_vec(), 32).is_ok()); + assert!(decode_variable_full_zip(0u64.to_le_bytes().to_vec(), 64).is_ok()); + } - let PageEncoding::Structural(layout) = &page.description else { - return; - }; - assert!( - !matches!(layout.layout.as_ref().unwrap(), Layout::ConstantLayout(_)), - "Should not emit constant layout before v2.2" - ); + /// A page whose item walk ends with a partial length prefix must surface a + /// corrupt-file error rather than read past the end of the buffer. + /// + /// This asserts the error variant and message rather than merely expecting a + /// panic: before the length prefix was bounds checked, the read was + /// `get_unchecked` behind a `debug_assert!`, so a debug build panicked here + /// (which a `#[should_panic]` test would have accepted as a pass) while a + /// release build read up to 8 bytes out of a 4 byte allocation. + #[test] + fn variable_full_zip_truncated_length_prefix_is_corrupt_file() { + use lance_core::Error; - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) - .with_max_file_version(LanceFileVersion::V2_1) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + for (bits, buf_len) in [(32u8, 3usize), (64u8, 4usize)] { + let err = decode_variable_full_zip(vec![0xAA; buf_len], bits) + .expect_err("a truncated length prefix must not decode"); + assert!( + matches!(err, Error::CorruptFile { .. }), + "expected CorruptFile for a {}-bit prefix with {} byte(s), got: {:?}", + bits, + buf_len, + err + ); + let msg = err.to_string(); + assert!( + msg.contains("truncated length prefix"), + "error should say what is wrong, got: {msg}" + ); + } } - #[tokio::test] - async fn test_all_null_constant_layout_still_works_v2_2() { - use crate::format::pb21::page_layout::Layout; + /// Drains `num_rows` from a single-buffer fixed full-zip page built from `buf`. + /// + /// `bits_rep` picks the control word width (1 -> one byte, 9 -> two bytes) and each + /// visible item is a one-word control plus a 4 byte value. + fn drain_fixed_full_zip(buf: Vec, bits_rep: u8, num_rows: u64) -> lance_core::Result<()> { + use crate::compression::FixedPerValueDecompressor; + use crate::decoder::StructuralPageDecoder; + use crate::repdef::{ControlWordParser, DefinitionInterpretation}; + use std::collections::VecDeque; + use std::sync::Arc; - let arr: ArrayRef = Arc::new(arrow_array::Int32Array::from(vec![None, None, None])); - let field = arrow_schema::Field::new("c", DataType::Int32, true); - let page = encode_first_page(field, arr.clone(), LanceFileVersion::V2_2).await; + #[derive(Debug)] + struct UnusedFixedDecompressor; - let PageEncoding::Structural(layout) = &page.description else { - panic!("Expected structural encoding"); - }; - let Layout::ConstantLayout(layout) = layout.layout.as_ref().unwrap() else { - panic!("Expected layout in slot 2"); + impl FixedPerValueDecompressor for UnusedFixedDecompressor { + fn decompress(&self, _: FixedWidthDataBlock, _: u64) -> crate::Result { + unreachable!("these cases fail while slicing, before any value is decoded") + } + + fn bits_per_value(&self) -> u64 { + 32 + } + } + + let details = Arc::new(super::FullZipDecodeDetails { + value_decompressor: super::PerValueDecompressor::Fixed(Arc::new( + UnusedFixedDecompressor, + )), + def_meaning: vec![DefinitionInterpretation::AllValidList].into(), + ctrl_word_parser: ControlWordParser::new(bits_rep, 0), + max_rep: 1, + max_visible_def: 0, + }); + let bytes_per_word = bits_rep.div_ceil(8) as usize; + + let mut data = VecDeque::new(); + data.push_back(crate::buffer::LanceBuffer::from(buf)); + let mut decoder = super::FixedFullZipDecoder { + details, + data, + offset_in_current: 0, + bytes_per_value: 4, + total_bytes_per_value: 4 + bytes_per_word, + num_rows, }; - assert!(layout.inline_value.is_none()); - assert_eq!(page.data.len(), 0); + decoder.drain(num_rows).map(|_| ()) + } - let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) - .with_max_file_version(LanceFileVersion::V2_2) - .with_page_sizes(vec![4096]); - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + /// Two one-item rows, each a control word plus a 4 byte value, slice cleanly. + #[test] + fn fixed_full_zip_wellformed_page_slices() { + assert!(drain_fixed_full_zip(vec![1, 0, 0, 0, 0, 1, 0, 0, 0, 0], 1, 2).is_ok()); + } + + /// The item walk is driven by the page's own control words, so a page that disagrees + /// with its layout can run off the end of the buffer. Each way of doing so must + /// surface a corrupt-file error. + /// + /// These assert the error variant and message rather than merely expecting a panic: + /// before the walk was bounds checked these aborted the process inside + /// `LanceBuffer::slice_with_length`, which a `#[should_panic]` test would have + /// accepted as a pass. + #[rstest::rstest] + // Second value is one byte short of a full 4 byte value + #[case::truncated_item(vec![1, 0, 0, 0, 0, 1, 0, 0, 0], 1, 2, "truncated item")] + // Two byte control words, with a lone trailing byte after the first item + #[case::truncated_control_word( + vec![1, 0, 0, 0, 0, 0, 1], + 9, + 2, + "truncated control word" + )] + // A well-formed two row page cannot satisfy a request for three rows + #[case::page_exhausted(vec![1, 0, 0, 0, 0, 1, 0, 0, 0, 0], 1, 3, "page data ran out")] + fn fixed_full_zip_malformed_page_is_corrupt_file( + #[case] buf: Vec, + #[case] bits_rep: u8, + #[case] num_rows: u64, + #[case] expected_msg: &str, + ) { + use lance_core::Error; + + let err = drain_fixed_full_zip(buf, bits_rep, num_rows) + .expect_err("a malformed page must not slice"); + assert!( + matches!(err, Error::CorruptFile { .. }), + "expected CorruptFile, got: {err:?}" + ); + let msg = err.to_string(); + assert!( + msg.contains(expected_msg), + "error should say what is wrong, got: {msg}" + ); } } diff --git a/rust/lance-encoding/src/encodings/logical/primitive/blob.rs b/rust/lance-encoding/src/encodings/logical/primitive/blob.rs index 2d504ff37ba..d9ed1b390dd 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive/blob.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive/blob.rs @@ -8,22 +8,24 @@ use std::{collections::VecDeque, ops::Range, sync::Arc}; -use arrow_array::{cast::AsArray, make_array, Array, UInt64Array}; +use arrow_array::{Array, UInt64Array, cast::AsArray, make_array}; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt}; -use snafu::location; +use futures::{FutureExt, future::BoxFuture}; use lance_core::{ - cache::DeepSizeOf, datatypes::BLOB_DESC_TYPE, error::LanceOptionExt, Error, Result, + Error, Result, cache::DeepSizeOf, datatypes::BLOB_DESC_TYPE, error::LanceOptionExt, }; use crate::{ + EncodingsIo, buffer::LanceBuffer, data::{BlockInfo, DataBlock, VariableWidthBlock}, decoder::{DecodePageTask, DecodedPage, StructuralPageDecoder}, - encodings::logical::primitive::{CachedPageData, PageLoadTask, StructuralPageScheduler}, + encodings::logical::primitive::{ + CachedPageData, PageCacheView, PageInitialization, PageInitializationBuffers, PageLoadTask, + StructuralPageScheduler, + }, repdef::{DefinitionInterpretation, RepDefUnraveler}, - EncodingsIo, }; /// How many bytes to target in each unloaded / loaded shard. A larger value means @@ -66,15 +68,28 @@ impl BlobDescriptionPageScheduler { } impl StructuralPageScheduler for BlobDescriptionPageScheduler { - fn initialize<'a>( + fn needs_initialization(&self) -> bool { + self.inner_scheduler.needs_initialization() + } + + fn init_layout(&self) -> Result { + self.inner_scheduler.init_layout() + } + + fn init_from_buffers<'a>( &'a mut self, + buffers: PageInitializationBuffers, io: &Arc, ) -> BoxFuture<'a, Result>> { - self.inner_scheduler.initialize(io) + self.inner_scheduler.init_from_buffers(buffers, io) } - fn load(&mut self, data: &Arc) { - self.inner_scheduler.load(data); + fn try_load(&mut self, data: &Arc) -> Result<()> { + self.inner_scheduler.try_load(data) + } + + fn cache_view(&self) -> PageCacheView { + PageCacheView::BlobDescriptor } fn schedule_ranges( @@ -140,23 +155,18 @@ impl DecodePageTask for BlobDescriptionDecodePageTask { // Need to extract out the repdef information let DataBlock::Struct(descriptions) = &decoded.data else { - return Err(Error::Internal { - message: "Expected struct data block for descriptions".into(), - location: location!(), - }); + return Err(Error::internal( + "Expected struct data block for descriptions", + )); }; let mut description_children = descriptions.children.iter(); let DataBlock::FixedWidth(positions) = description_children.next().expect_ok()? else { - return Err(Error::Internal { - message: "Expected fixed width data block for positions".into(), - location: location!(), - }); + return Err(Error::internal( + "Expected fixed width data block for positions", + )); }; let DataBlock::FixedWidth(sizes) = description_children.next().expect_ok()? else { - return Err(Error::Internal { - message: "Expected fixed width data block for sizes".into(), - location: location!(), - }); + return Err(Error::internal("Expected fixed width data block for sizes")); }; let positions = positions.data.borrow_to_typed_slice::(); let sizes = sizes.data.borrow_to_typed_slice::(); @@ -207,14 +217,12 @@ impl DecodePageTask for BlobDescriptionDecodePageTask { struct BlobCacheableState { positions: Arc, sizes: Arc, - inner_state: Arc, } impl DeepSizeOf for BlobCacheableState { - fn deep_size_of_children(&self, context: &mut lance_core::cache::Context) -> usize { - self.positions.get_array_memory_size() - + self.sizes.get_array_memory_size() - + self.inner_state.deep_size_of_children(context) + fn deep_size_of_children(&self, context: &mut lance_core::deepsize::Context) -> usize { + (self.positions.as_ref() as &dyn arrow_array::Array).deep_size_of_children(context) + + (self.sizes.as_ref() as &dyn arrow_array::Array).deep_size_of_children(context) } } @@ -264,7 +272,9 @@ impl BlobPageScheduler { let bytes = read_fut.await?; let mut bytes_iter = bytes.into_iter(); for blob in loaded_blobs.iter_mut() { - if blob.def == 0 { + // Empty values have def == 0 too but scheduled no read; their + // bytes were set at scheduling time. + if blob.def == 0 && blob.bytes.is_none() { blob.set_bytes(bytes_iter.next().expect_ok()?); } } @@ -281,23 +291,28 @@ impl BlobPageScheduler { } impl StructuralPageScheduler for BlobPageScheduler { - fn initialize<'a>( + fn init_layout(&self) -> Result { + self.inner_scheduler.init_layout() + } + + fn init_from_buffers<'a>( &'a mut self, + buffers: PageInitializationBuffers, io: &Arc, ) -> BoxFuture<'a, Result>> { let io = io.clone(); let num_rows = self.num_rows; async move { - let cached = self.inner_scheduler.initialize(&io).await?; + let cached = self.inner_scheduler.init_from_buffers(buffers, &io).await?; + self.inner_scheduler.try_load(&cached)?; let mut desc_decoders = self.inner_scheduler.schedule_ranges(&[0..num_rows], &io)?; if desc_decoders.len() != 1 { // This can't happen yet today so being a little lazy but if it did happen we just // need to concatenate the descriptions. I'm guessing by then we might be doing something // different than "load all descriptors in initialize" anyways. - return Err(Error::NotSupported { - source: "Expected exactly one descriptor decoder".into(), - location: location!(), - }); + return Err(Error::not_supported_source( + "Expected exactly one descriptor decoder".into(), + )); } let desc_decoder_task = desc_decoders.pop().unwrap(); let mut desc_decoder = desc_decoder_task.decoder_fut.await?; @@ -322,27 +337,27 @@ impl StructuralPageScheduler for BlobPageScheduler { .unwrap() .clone(), ); - self.positions = Some(positions.clone()); - self.sizes = Some(sizes.clone()); - let state = Arc::new(BlobCacheableState { - inner_state: cached, - positions, - sizes, - }); + let state = Arc::new(BlobCacheableState { positions, sizes }); Ok(state as Arc) } .boxed() } - fn load(&mut self, data: &Arc) { + fn try_load(&mut self, data: &Arc) -> Result<()> { let blob_state = data .clone() .as_arc_any() .downcast::() - .unwrap(); + .map_err(|_| { + Error::invalid_input_source("Cached blob page data has an unexpected type".into()) + })?; self.positions = Some(blob_state.positions.clone()); self.sizes = Some(blob_state.sizes.clone()); - self.inner_scheduler.load(&blob_state.inner_state); + Ok(()) + } + + fn cache_view(&self) -> PageCacheView { + PageCacheView::BlobPayload } fn schedule_ranges( @@ -371,7 +386,17 @@ impl StructuralPageScheduler for BlobPageScheduler { if size == 0 { let rep = (position & 0xFFFF) as u16; let def = ((position >> 16) & 0xFFFF) as u16; - loaded_blobs.push(LoadedBlob::new(rep, def)); + let mut blob = LoadedBlob::new(rep, def); + if def == 0 { + // A size-0 descriptor with definition level 0 is a + // valid, empty value (nulls carry their non-zero + // packed rep/def levels in `position`). No read is + // scheduled for it, so it gets its zero-length bytes + // here rather than consuming another blob's read + // result in the load task. + blob.set_bytes(Bytes::new()); + } + loaded_blobs.push(blob); } else { loaded_blobs.push(LoadedBlob::new(0, 0)); ranges_to_read.push(position..(position + size)); diff --git a/rust/lance-encoding/src/encodings/logical/primitive/chunk_index.rs b/rust/lance-encoding/src/encodings/logical/primitive/chunk_index.rs new file mode 100644 index 00000000000..19fb0fdb041 --- /dev/null +++ b/rust/lance-encoding/src/encodings/logical/primitive/chunk_index.rs @@ -0,0 +1,536 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Compact per-page chunk index for the mini-block structural encoding. +//! +//! The chunk index is stored on disk in an extremely compressed form that +//! requires a lot of CPU to work with. However, extracting it out to its full +//! width can be RAM-intensive. As a compromise we extract into a prefix-sum +//! array that we fit into `u32` if possible and we avoid storing per-block row +//! counts when those are redundant. The scheduler looks chunks up by index +//! (byte range, leaf value count) and by row (which chunk holds a row). +//! +//! ```text +//! MiniBlockChunkIndex +//! |- base: u64 absolute file position of the value buffer +//! |- byte_starts: PrefixSums cumulative chunk byte sizes (all pages) +//! `- rows: RowMapping +//! |- UniformFlat { .. } flat page, uniform leaf chunking (arithmetic) +//! |- Flat { value_starts } flat page, non-uniform leaf chunking +//! `- Nested { row_starts, .. } repetition present; rows tracked as prefix sums +//! ``` + +use std::ops::Range; + +use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder}; +use lance_core::cache::{Context, DeepSizeOf}; + +/// Cumulative (prefix-sum) array of length `num_chunks + 1` (entry `0` is `0`, +/// the last entry is the grand total). Stored as `u32` when the total fits, +/// else `u64`. +#[derive(Debug, DeepSizeOf)] +pub enum PrefixSums { + U32(Vec), + U64(Vec), +} + +impl PrefixSums { + /// Builds the cumulative array from per-chunk `deltas`. `total` selects the + /// storage width (callers must pass the true sum); `num_chunks` only pre-sizes. + pub fn from_deltas(deltas: impl Iterator, num_chunks: usize, total: u64) -> Self { + if total <= u32::MAX as u64 { + let mut values = Vec::with_capacity(num_chunks + 1); + let mut acc = 0u32; + values.push(0); + for delta in deltas { + acc += delta as u32; + values.push(acc); + } + debug_assert_eq!(values.len(), num_chunks + 1); + debug_assert_eq!(acc as u64, total); + Self::U32(values) + } else { + let mut values = Vec::with_capacity(num_chunks + 1); + let mut acc = 0u64; + values.push(0); + for delta in deltas { + acc += delta; + values.push(acc); + } + debug_assert_eq!(values.len(), num_chunks + 1); + debug_assert_eq!(acc, total); + Self::U64(values) + } + } + + /// Builds a `PrefixSums` from an already-cumulative array (`[0, .., total]`), + /// narrowing to `u32` when the total fits. Avoids the deltas buffer + /// [`Self::from_deltas`] would need. + fn from_prefix(prefix: Vec) -> Self { + debug_assert!(!prefix.is_empty()); + debug_assert_eq!(prefix[0], 0); + let total = prefix.last().copied().unwrap_or(0); + if total <= u32::MAX as u64 { + Self::U32(prefix.into_iter().map(|v| v as u32).collect()) + } else { + Self::U64(prefix) + } + } + + /// Cumulative value at position `i` (i.e. the start of chunk `i`). + pub fn get(&self, i: usize) -> u64 { + match self { + Self::U32(values) => values[i] as u64, + Self::U64(values) => values[i], + } + } + + /// Start and end of chunk `i` (positions `i`, `i + 1`) behind one width + /// match -- halves the branching of two `get` calls on the hot per-chunk path. + pub fn get_pair(&self, i: usize) -> (u64, u64) { + match self { + Self::U32(values) => (values[i] as u64, values[i + 1] as u64), + Self::U64(values) => (values[i], values[i + 1]), + } + } + + /// Number of chunks (array length minus the trailing total). + pub fn num_chunks(&self) -> usize { + match self { + Self::U32(values) => values.len() - 1, + Self::U64(values) => values.len() - 1, + } + } + + /// Size of chunk `i` (the delta between consecutive cumulative values). + pub fn delta(&self, i: usize) -> u64 { + let (start, end) = self.get_pair(i); + end - start + } + + /// Index of the chunk whose half-open span `[get(i), get(i+1))` contains + /// `value`. On an exact hit against a chunk start, returns the *first* chunk + /// with that start (chunks can share a start row). + pub fn find(&self, value: u64) -> usize { + // Match the width once, then binary-search only the starts (not the + // trailing total). `partition_point` already yields the first of any + // duplicated starts; the `idx - 1` fallback is safe since `get(0) == 0`. + match self { + Self::U32(values) => { + let starts = &values[..values.len() - 1]; + let idx = starts.partition_point(|&start| (start as u64) < value); + if idx < starts.len() && starts[idx] as u64 == value { + idx + } else { + idx - 1 + } + } + Self::U64(values) => { + let starts = &values[..values.len() - 1]; + let idx = starts.partition_point(|&start| start < value); + if idx < starts.len() && starts[idx] == value { + idx + } else { + idx - 1 + } + } + } + } +} + +/// Leaf value counts per chunk, needed to decode. Tracked only for nested +/// pages; flat pages read items off the row mapping (rows == items). +#[derive(Debug, DeepSizeOf)] +pub enum ItemCounts { + /// Every non-last chunk holds the same number of values. + Uniform { + values_per_chunk: u64, + last_chunk_values: u64, + }, + /// `log2` of each chunk's value count, stored as one byte per chunk rather + /// than the full count because this index stays cached in RAM; the last + /// chunk is handled via `last_chunk_values`. + PerChunkLog { + logs: Vec, + last_chunk_values: u64, + }, +} + +impl ItemCounts { + fn get(&self, i: usize, num_chunks: usize) -> u64 { + match self { + Self::Uniform { + values_per_chunk, + last_chunk_values, + } => { + if i == num_chunks - 1 { + *last_chunk_values + } else { + *values_per_chunk + } + } + Self::PerChunkLog { + logs, + last_chunk_values, + } => { + if i == num_chunks - 1 { + *last_chunk_values + } else { + 1u64 << logs[i] + } + } + } + } +} + +/// How row ranges map onto chunks. +/// +/// Flat pages have row == value index and no preamble/trailer. Nested pages +/// track rows separately from leaf items and store a trailer bit per chunk; a +/// chunk's preamble is the previous chunk's trailer. +#[derive(Debug)] +pub enum RowMapping { + /// Flat page whose non-last chunks all hold `values_per_chunk` values, so + /// row->chunk is pure arithmetic. + UniformFlat { + values_per_chunk: u64, + last_chunk_values: u64, + num_chunks: usize, + }, + /// Flat page with non-uniform chunk sizes; `value_starts` are the cumulative + /// value counts (final entry == number of items in the page). + Flat { value_starts: PrefixSums }, + /// Nested page. `row_starts` are cumulative row counts (final entry == + /// number of rows in the page); `has_trailer[i]` is set when chunk `i` ends + /// with a partial list. + Nested { + row_starts: PrefixSums, + has_trailer: BooleanBuffer, + item_counts: ItemCounts, + }, +} + +impl DeepSizeOf for RowMapping { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + match self { + Self::UniformFlat { .. } => 0, + Self::Flat { value_starts } => value_starts.deep_size_of_children(context), + Self::Nested { + row_starts, + has_trailer, + item_counts, + } => { + row_starts.deep_size_of_children(context) + + has_trailer.len().div_ceil(8) + + item_counts.deep_size_of_children(context) + } + } + } +} + +/// Compact per-page chunk index that avoids fully materializing the repetition +/// index into u64's to save RAM. See the module docs for the layout. +#[derive(Debug)] +pub struct MiniBlockChunkIndex { + base: u64, + byte_starts: PrefixSums, + rows: RowMapping, +} + +impl MiniBlockChunkIndex { + pub fn new(base: u64, byte_starts: PrefixSums, rows: RowMapping) -> Self { + Self { + base, + byte_starts, + rows, + } + } + + /// Number of chunks in the page. + pub fn num_chunks(&self) -> usize { + self.byte_starts.num_chunks() + } + + /// Absolute byte range of chunk `i` within the file. + pub fn byte_range(&self, i: usize) -> Range { + let (start, end) = self.byte_starts.get_pair(i); + (self.base + start)..(self.base + end) + } + + /// Number of leaf values in chunk `i` (passed to the value decompressor). + pub fn items_in_chunk(&self, i: usize) -> u64 { + let num_chunks = self.num_chunks(); + match &self.rows { + RowMapping::UniformFlat { + values_per_chunk, + last_chunk_values, + .. + } => { + if i == num_chunks - 1 { + *last_chunk_values + } else { + *values_per_chunk + } + } + RowMapping::Flat { value_starts } => value_starts.delta(i), + RowMapping::Nested { item_counts, .. } => item_counts.get(i, num_chunks), + } + } + + /// Index of the chunk that contains `row`. + pub fn find_chunk(&self, row: u64) -> usize { + match &self.rows { + RowMapping::UniformFlat { + values_per_chunk, + num_chunks, + .. + } => ((row / values_per_chunk) as usize).min(num_chunks - 1), + RowMapping::Flat { value_starts } => value_starts.find(row), + RowMapping::Nested { row_starts, .. } => row_starts.find(row), + } + } + + /// First row (relative to the page) that begins in chunk `i`. + pub fn first_row(&self, i: usize) -> u64 { + match &self.rows { + RowMapping::UniformFlat { + values_per_chunk, .. + } => i as u64 * values_per_chunk, + RowMapping::Flat { value_starts } => value_starts.get(i), + RowMapping::Nested { row_starts, .. } => row_starts.get(i), + } + } + + /// Number of rows that start in chunk `i`, including a trailer but not a + /// preamble (the previous `starts_including_trailer`). + pub fn rows_in_chunk(&self, i: usize) -> u64 { + let num_chunks = self.num_chunks(); + match &self.rows { + RowMapping::UniformFlat { + values_per_chunk, + last_chunk_values, + .. + } => { + if i == num_chunks - 1 { + *last_chunk_values + } else { + *values_per_chunk + } + } + RowMapping::Flat { value_starts } => value_starts.delta(i), + RowMapping::Nested { row_starts, .. } => row_starts.delta(i), + } + } + + /// Whether chunk `i` begins with a preamble (a continuation of the previous + /// chunk's list). Always false for flat pages; for nested pages this is the + /// previous chunk's trailer. + pub fn has_preamble(&self, i: usize) -> bool { + match &self.rows { + RowMapping::Nested { has_trailer, .. } => i > 0 && has_trailer.value(i - 1), + _ => false, + } + } + + /// Whether chunk `i` ends with a trailer (a partial list continued in the + /// next chunk). Always false for flat pages. + pub fn has_trailer(&self, i: usize) -> bool { + match &self.rows { + RowMapping::Nested { has_trailer, .. } => has_trailer.value(i), + _ => false, + } + } + + /// Name of the active row-mapping variant, used to assert detection in tests. + #[cfg(test)] + pub fn row_mapping_debug(&self) -> &'static str { + match &self.rows { + RowMapping::UniformFlat { .. } => "uniform_flat", + RowMapping::Flat { .. } => "flat", + RowMapping::Nested { .. } => "nested", + } + } + + /// Builds a nested index from raw repetition-index bytes, using placeholder + /// byte offsets and item counts. Only the row axis is populated, which is + /// all the scheduler exercises. + #[cfg(test)] + pub fn new_nested_for_test(rep_bytes: &[u8], stride: usize) -> Self { + let (row_starts, has_trailer) = parse_nested_rep(rep_bytes, stride); + let num_chunks = row_starts.num_chunks(); + let byte_starts = PrefixSums::from_deltas( + std::iter::repeat_n(8u64, num_chunks), + num_chunks, + 8 * num_chunks as u64, + ); + Self { + base: 0, + byte_starts, + rows: RowMapping::Nested { + row_starts, + has_trailer, + item_counts: ItemCounts::Uniform { + values_per_chunk: 1, + last_chunk_values: 1, + }, + }, + } + } +} + +impl DeepSizeOf for MiniBlockChunkIndex { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + self.byte_starts.deep_size_of_children(context) + self.rows.deep_size_of_children(context) + } +} + +/// Parses a mini-block repetition index into the compact nested row mapping. +/// +/// Bytes are `u64`s in groups of `stride`; the first two are `ends` (lists +/// finishing in the chunk) and `partial` (leftover items). Only cumulative row +/// starts and a trailer bit are kept: `has_preamble[i] = has_trailer[i-1]` and +/// `starts_including_trailer = ends + has_trailer - has_preamble`. +pub fn parse_nested_rep(rep_bytes: &[u8], stride: usize) -> (PrefixSums, BooleanBuffer) { + // Read the two `u64`s per group straight from the little-endian bytes rather + // than copying the buffer to reinterpret it. The caller guarantees + // `rep_bytes.len() % 8 == 0`, so the 8-byte windows stay in bounds. + const WORD: usize = std::mem::size_of::(); + let read_word = |word_idx: usize| -> u64 { + let byte = word_idx * WORD; + u64::from_le_bytes(rep_bytes[byte..byte + WORD].try_into().unwrap()) + }; + let num_chunks = (rep_bytes.len() / WORD) / stride; + + let mut has_trailer_builder = BooleanBufferBuilder::new(num_chunks); + // Accumulate the cumulative row starts in a single pass (entry 0 is 0, the + // trailing entry is the total) so there is no separate deltas buffer. + let mut row_starts = Vec::with_capacity(num_chunks + 1); + row_starts.push(0u64); + let mut acc = 0u64; + let mut chunk_has_preamble = false; + + for i in 0..num_chunks { + let base_idx = i * stride; + let ends = read_word(base_idx); + let partial = read_word(base_idx + 1); + + let has_trailer = partial > 0; + let starts_including_trailer = ends + (has_trailer as u64) - (chunk_has_preamble as u64); + + has_trailer_builder.append(has_trailer); + acc += starts_including_trailer; + row_starts.push(acc); + + chunk_has_preamble = has_trailer; + } + + ( + PrefixSums::from_prefix(row_starts), + has_trailer_builder.finish(), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::buffer::LanceBuffer; + + /// Reference decode: the previous per-block repetition index, used as an + /// oracle for the compact `parse_nested_rep`. + struct RefBlock { + first_row: u64, + starts_including_trailer: u64, + has_preamble: bool, + has_trailer: bool, + } + + fn reference_decode(rep_bytes: &[u8], stride: usize) -> Vec { + let buffer = LanceBuffer::from(rep_bytes.to_vec()); + let u64_slice = buffer.borrow_to_typed_slice::(); + let n = u64_slice.len() / stride; + let mut blocks = Vec::with_capacity(n); + let mut chunk_has_preamble = false; + let mut offset = 0u64; + for i in 0..n { + let base_idx = i * stride; + let ends = u64_slice[base_idx]; + let partial = u64_slice[base_idx + 1]; + let has_trailer = partial > 0; + let starts_including_trailer = + ends + (has_trailer as u64) - (chunk_has_preamble as u64); + blocks.push(RefBlock { + first_row: offset, + starts_including_trailer, + has_preamble: chunk_has_preamble, + has_trailer, + }); + chunk_has_preamble = has_trailer; + offset += starts_including_trailer; + } + blocks + } + + fn rep_bytes(values: &[u64]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() + } + + #[test] + fn test_prefix_sums_u32() { + let sums = PrefixSums::from_deltas([2u64, 3, 5].into_iter(), 3, 10); + assert!(matches!(sums, PrefixSums::U32(_))); + assert_eq!(sums.num_chunks(), 3); + assert_eq!(sums.get(0), 0); + assert_eq!(sums.get(1), 2); + assert_eq!(sums.get(3), 10); + assert_eq!(sums.delta(1), 3); + } + + #[test] + fn test_prefix_sums_u64_selected_by_total() { + let big = u32::MAX as u64 + 1; + let sums = PrefixSums::from_deltas([big].into_iter(), 1, big); + assert!(matches!(sums, PrefixSums::U64(_))); + assert_eq!(sums.get(1), big); + } + + #[test] + fn test_prefix_sums_find() { + // Chunk starts: 0, 5, 5, 12 (a zero-width chunk creates a duplicate start) + let sums = PrefixSums::from_deltas([5u64, 0, 7].into_iter(), 3, 12); + // Inside the first chunk + assert_eq!(sums.find(3), 0); + // Exact match on a duplicated start returns the first such chunk + assert_eq!(sums.find(5), 1); + // Inside the last chunk + assert_eq!(sums.find(11), 2); + // Start of the first chunk + assert_eq!(sums.find(0), 0); + } + + #[test] + fn test_parse_nested_rep_matches_reference() { + let cases: Vec> = vec![ + vec![5, 2, 3, 0, 4, 7, 2, 0], + vec![5, 2, 3, 3, 20, 0], + vec![0, 5, 0, 3, 10, 0], + vec![1, 0], + ]; + for values in cases { + let bytes = rep_bytes(&values); + let reference = reference_decode(&bytes, 2); + let (row_starts, has_trailer) = parse_nested_rep(&bytes, 2); + assert_eq!(row_starts.num_chunks(), reference.len()); + for (i, block) in reference.iter().enumerate() { + assert_eq!(row_starts.get(i), block.first_row, "first_row[{i}]"); + assert_eq!( + row_starts.delta(i), + block.starts_including_trailer, + "starts_including_trailer[{i}]" + ); + assert_eq!(has_trailer.value(i), block.has_trailer, "has_trailer[{i}]"); + let derived_preamble = i > 0 && has_trailer.value(i - 1); + assert_eq!(derived_preamble, block.has_preamble, "has_preamble[{i}]"); + } + } + } +} diff --git a/rust/lance-encoding/src/encodings/logical/primitive/constant.rs b/rust/lance-encoding/src/encodings/logical/primitive/constant.rs index 822c90fcb3f..3ac011ceb49 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive/constant.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive/constant.rs @@ -3,27 +3,28 @@ use std::{any::Any, collections::VecDeque, ops::Range, sync::Arc}; -use arrow_array::{new_empty_array, Array, ArrayRef}; +use arrow_array::{Array, ArrayRef, new_empty_array}; use arrow_buffer::ScalarBuffer; use arrow_schema::DataType; use bytes::Bytes; -use futures::future::BoxFuture; use futures::FutureExt; -use snafu::location; +use futures::future::BoxFuture; use lance_core::{ - cache::{Context, DeepSizeOf}, Error, Result, + cache::{Context, DeepSizeOf}, }; use crate::{ + EncodingsIo, buffer::LanceBuffer, decoder::PageEncoding, encoder::EncodedPage, - encodings::logical::primitive::{CachedPageData, PageLoadTask}, + encodings::logical::primitive::{ + CachedPageData, PageInitialization, PageInitializationBuffers, PageLoadTask, + }, format::ProtobufUtils21, - repdef::{DefinitionInterpretation, RepDefUnraveler}, - EncodingsIo, + repdef::{DefinitionInterpretation, RepDefUnraveler, max_visible_level}, }; pub(crate) fn encode_constant_page( @@ -102,16 +103,25 @@ enum ScalarSource { ValueBuffer(usize), } +/// The (scalar, rep, def) file ranges a constant page reads, in the order +/// `init_layout` declares and `init_from_buffers` consumes them. Any field is +/// `None` when that buffer is absent. Computed once at construction so the two +/// halves share one source of truth and their buffer order cannot drift. +#[derive(Debug)] +struct ConstantBufferLayout { + scalar: Option>, + rep: Option>, + def: Option>, +} + #[derive(Debug)] pub struct ConstantPageScheduler { - buffer_offsets_and_sizes: Arc<[(u64, u64)]>, scalar_source: ScalarSource, - rep_buf_idx: Option, - def_buf_idx: Option, data_type: DataType, def_meaning: Arc<[DefinitionInterpretation]>, max_rep: u16, max_visible_def: u16, + layout: ConstantBufferLayout, repdef: Option>, } @@ -123,11 +133,7 @@ impl ConstantPageScheduler { def_meaning: Arc<[DefinitionInterpretation]>, ) -> Result { let max_rep = def_meaning.iter().filter(|d| d.is_list()).count() as u16; - let max_visible_def = def_meaning - .iter() - .filter(|d| !d.is_list()) - .map(|d| d.num_def_levels()) - .sum(); + let max_visible_def = max_visible_level(&def_meaning); let (scalar_source, rep_buf_idx, def_buf_idx) = match (inline_value, buffer_offsets_and_sizes.len()) { @@ -136,167 +142,141 @@ impl ConstantPageScheduler { (None, 1) => (ScalarSource::ValueBuffer(0), None, None), (None, 3) => (ScalarSource::ValueBuffer(0), Some(1), Some(2)), (Some(_inline), 1) => { - return Err(Error::invalid_input( - format!( - "Invalid constant layout: inline_value present with {} buffers", - 1 - ), - location!(), - )); + return Err(Error::invalid_input(format!( + "Invalid constant layout: inline_value present with {} buffers", + 1 + ))); } (Some(_inline), 3) => { return Err(Error::invalid_input( "Invalid constant layout: inline_value present with 3 buffers", - location!(), )); } (None, 0) => { return Err(Error::invalid_input( "Invalid constant layout: missing scalar source", - location!(), - )) + )); } (None, 2) => { return Err(Error::invalid_input( "Invalid constant layout: ambiguous (2 buffers and no inline_value)", - location!(), - )) + )); } (Some(_), n) => { - return Err(Error::invalid_input( - format!( - "Invalid constant layout: inline_value present with {} buffers", - n - ), - location!(), - )) + return Err(Error::invalid_input(format!( + "Invalid constant layout: inline_value present with {} buffers", + n + ))); } (None, n) => { - return Err(Error::invalid_input( - format!("Invalid constant layout: unexpected buffer count {}", n), - location!(), - )) + return Err(Error::invalid_input(format!( + "Invalid constant layout: unexpected buffer count {}", + n + ))); } }; + let range_of = |idx: usize| { + let (pos, len) = buffer_offsets_and_sizes[idx]; + pos..pos + len + }; + let layout = ConstantBufferLayout { + scalar: match &scalar_source { + ScalarSource::ValueBuffer(idx) => Some(range_of(*idx)), + ScalarSource::Inline(_) => None, + }, + rep: rep_buf_idx + .filter(|&idx| buffer_offsets_and_sizes[idx].1 > 0) + .map(range_of), + def: def_buf_idx + .filter(|&idx| buffer_offsets_and_sizes[idx].1 > 0) + .map(range_of), + }; + Ok(Self { - buffer_offsets_and_sizes, scalar_source, - rep_buf_idx, - def_buf_idx, data_type, def_meaning, max_rep, max_visible_def, + layout, repdef: None, }) } } impl crate::encodings::logical::primitive::StructuralPageScheduler for ConstantPageScheduler { - fn initialize<'a>( + fn init_layout(&self) -> Result { + // Order must match `init_from_buffers`' consumption: scalar, rep, def. + Ok(PageInitialization::new([ + self.layout.scalar.clone(), + self.layout.rep.clone(), + self.layout.def.clone(), + ])) + } + + fn init_from_buffers<'a>( &'a mut self, - io: &Arc, + buffers: PageInitializationBuffers, + _io: &Arc, ) -> BoxFuture<'a, Result>> { - let rep_range = self - .rep_buf_idx - .and_then(|idx| self.buffer_offsets_and_sizes.get(idx).copied()) - .filter(|(_, len)| *len > 0) - .map(|(pos, len)| pos..pos + len); - - let def_range = self - .def_buf_idx - .and_then(|idx| self.buffer_offsets_and_sizes.get(idx).copied()) - .filter(|(_, len)| *len > 0) - .map(|(pos, len)| pos..pos + len); - - let scalar_range = match self.scalar_source { - ScalarSource::ValueBuffer(idx) => { - let (pos, len) = self.buffer_offsets_and_sizes[idx]; - Some(pos..pos + len) - } - ScalarSource::Inline(_) => None, - }; - - let mut reads = Vec::with_capacity(3); - if let Some(r) = scalar_range { - reads.push(r); - } - if let Some(r) = rep_range.clone() { - reads.push(r); - } - if let Some(r) = def_range.clone() { - reads.push(r); - } - - if reads.is_empty() { - let ScalarSource::Inline(inline) = &self.scalar_source else { - return std::future::ready(Err(Error::invalid_input( - "Invalid constant layout: missing scalar source", - location!(), - ))) - .boxed(); - }; - - let scalar = match lance_arrow::scalar::decode_scalar_from_inline_value( - &self.data_type, - inline.as_slice(), - ) { - Ok(s) => s, - Err(e) => return std::future::ready(Err(e.into())).boxed(), - }; - let cached = Arc::new(CachedConstantState { - scalar, - rep: None, - def: None, - }); - self.repdef = Some(cached.clone()); - return std::future::ready(Ok(cached as Arc)).boxed(); - } - - let data = io.submit_request(reads, 0); + // Consume `buffers` in the same scalar, rep, def order `init_layout` + // declared them, using the shared layout's presence flags. let scalar_source = self.scalar_source.clone(); let data_type = self.data_type.clone(); async move { - let mut data_iter = data.await?.into_iter(); + let [scalar_buffer, rep_buffer, def_buffer] = buffers.try_into_array("constant")?; - let scalar = match scalar_source { - ScalarSource::Inline(inline) => { + let scalar = match (scalar_source, scalar_buffer) { + (ScalarSource::Inline(inline), None) => { lance_arrow::scalar::decode_scalar_from_inline_value(&data_type, &inline)? } - ScalarSource::ValueBuffer(_) => { - let bytes = data_iter.next().unwrap(); + (ScalarSource::ValueBuffer(_), Some(bytes)) => { let buf = LanceBuffer::from_bytes(bytes, 1); lance_arrow::scalar::decode_scalar_from_value_buffer(&data_type, buf.as_ref())? } + _ => { + return Err(Error::internal( + "Constant page scalar buffer does not match its initialization layout", + )); + } }; - let rep = rep_range.map(|_| { - let rep = data_iter.next().unwrap(); + let rep = rep_buffer.map(|rep| { let rep = LanceBuffer::from_bytes(rep, 2); rep.borrow_to_typed_slice::() }); - let def = def_range.map(|_| { - let def = data_iter.next().unwrap(); + let def = def_buffer.map(|def| { let def = LanceBuffer::from_bytes(def, 2); def.borrow_to_typed_slice::() }); let cached = Arc::new(CachedConstantState { scalar, rep, def }); - self.repdef = Some(cached.clone()); Ok(cached as Arc) } .boxed() } - fn load(&mut self, data: &Arc) { - self.repdef = Some( - data.clone() - .as_arc_any() - .downcast::() - .unwrap(), - ); + fn try_load(&mut self, data: &Arc) -> Result<()> { + let cached: Arc = + data.clone().as_arc_any().downcast().map_err(|_| { + Error::invalid_input_source( + "Cached constant page data has an unexpected type".into(), + ) + })?; + if cached.scalar.data_type() != &self.data_type { + return Err(Error::invalid_input_source( + format!( + "Cached constant page scalar has data type {:?}, expected {:?}", + cached.scalar.data_type(), + self.data_type + ) + .into(), + )); + } + self.repdef = Some(cached); + Ok(()) } fn schedule_ranges( @@ -362,16 +342,14 @@ impl ConstantPageDecoder { let start = self.cursor_level; let end = if let Some(rep) = &self.rep { if start >= rep.len() { - return Err(Error::Internal { - message: "Invalid constant layout: repetition buffer too short".into(), - location: location!(), - }); + return Err(Error::internal( + "Invalid constant layout: repetition buffer too short", + )); } if rep[start] != self.max_rep { - return Err(Error::Internal { - message: "Invalid constant layout: row did not start at max_rep".into(), - location: location!(), - }); + return Err(Error::internal( + "Invalid constant layout: row did not start at max_rep", + )); } let mut end = start + 1; while end < rep.len() && rep[end] != self.max_rep { @@ -416,11 +394,11 @@ impl crate::encodings::logical::primitive::StructuralPageDecoder for ConstantPag for _ in range.start..range.end { let (level_range, visible) = self.take_row()?; visible_items_total += visible; - if let Some(last) = level_slices.last_mut() { - if last.end == level_range.start { - last.end = level_range.end; - continue; - } + if let Some(last) = level_slices.last_mut() + && last.end == level_range.start + { + last.end = level_range.end; + continue; } level_slices.push(level_range); } @@ -473,13 +451,13 @@ impl DecodeConstantTask { return Ok(new_empty_array(self.scalar.data_type())); } - if let DataType::Struct(fields) = self.scalar.data_type() { - if fields.is_empty() { - return Ok(Arc::new(arrow_array::StructArray::new_empty_fields( - num_values as usize, - None, - )) as ArrayRef); - } + if let DataType::Struct(fields) = self.scalar.data_type() + && fields.is_empty() + { + return Ok(Arc::new(arrow_array::StructArray::new_empty_fields( + num_values as usize, + None, + )) as ArrayRef); } let indices = arrow_array::UInt64Array::from(vec![0u64; num_values as usize]); @@ -513,3 +491,32 @@ impl crate::decoder::DecodePageTask for DecodeConstantTask { }) } } + +#[cfg(test)] +mod tests { + use arrow_array::BooleanArray; + + use super::*; + use crate::encodings::logical::primitive::StructuralPageScheduler; + + #[test] + fn rejects_cached_scalar_with_different_data_type() { + let mut scheduler = ConstantPageScheduler::try_new( + Arc::from([]), + Some(Bytes::new()), + DataType::UInt8, + Arc::from([DefinitionInterpretation::AllValidItem]), + ) + .unwrap(); + let cached: Arc = Arc::new(CachedConstantState { + scalar: Arc::new(BooleanArray::from(vec![true])), + rep: None, + def: None, + }); + + let error = scheduler.try_load(&cached).unwrap_err(); + assert!(matches!(error, Error::InvalidInput { .. })); + assert!(error.to_string().contains("Boolean")); + assert!(error.to_string().contains("UInt8")); + } +} diff --git a/rust/lance-encoding/src/encodings/logical/primitive/dict.rs b/rust/lance-encoding/src/encodings/logical/primitive/dict.rs index 22bb0b9a009..19582c167fb 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive/dict.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive/dict.rs @@ -9,18 +9,17 @@ pub const DICT_FIXED_WIDTH_BITS_PER_VALUE: u64 = 128; pub const DICT_INDICES_BITS_PER_VALUE: u64 = 32; use arrow_array::{ + Array, DictionaryArray, PrimitiveArray, UInt64Array, cast::AsArray, types::{ - ArrowDictionaryKeyType, Int16Type, Int32Type, Int64Type, Int8Type, UInt16Type, UInt32Type, - UInt64Type, UInt8Type, + ArrowDictionaryKeyType, Int8Type, Int16Type, Int32Type, Int64Type, UInt8Type, UInt16Type, + UInt32Type, UInt64Type, }, - Array, DictionaryArray, PrimitiveArray, UInt64Array, }; use arrow_buffer::ArrowNativeType; use arrow_schema::DataType; use arrow_select::take::TakeOptions; -use lance_core::{error::LanceOptionExt, utils::hash::U8SliceKey, Error, Result}; -use snafu::location; +use lance_core::{Error, Result, error::LanceOptionExt, utils::hash::U8SliceKey}; use crate::{ buffer::LanceBuffer, @@ -100,22 +99,81 @@ pub fn normalize_dict_nulls(array: Arc) -> Result> { DataType::Int16 => normalize_dict_nulls_impl::(array), DataType::Int32 => normalize_dict_nulls_impl::(array), DataType::Int64 => normalize_dict_nulls_impl::(array), - _ => Err(Error::NotSupported { - source: format!("Unsupported dictionary key type: {}", key_type).into(), - location: location!(), - }), + _ => Err(Error::not_supported_source( + format!("Unsupported dictionary key type: {}", key_type).into(), + )), }, - _ => Err(Error::Internal { - message: format!("Data type is not a dictionary: {}", array.data_type()), - location: location!(), - }), + _ => Err(Error::internal(format!( + "Data type is not a dictionary: {}", + array.data_type() + ))), + } +} + +fn clear_out_of_range_null_keys_impl( + array: Arc, +) -> Result> { + let dict_array = array.as_dictionary_opt::().expect_ok()?; + let num_values = dict_array.values().len(); + let Some(nulls) = dict_array.keys().nulls() else { + return Ok(array); + }; + + // There is no valid replacement key for an empty dictionary, so that case + // requires separate handling and must remain unchanged here. + if num_values == 0 { + return Ok(array); + } + + let has_out_of_range_null_key = dict_array + .keys() + .values() + .iter() + .zip(nulls.iter()) + .any(|(key, is_valid)| !is_valid && key.to_usize().is_none_or(|key| key >= num_values)); + if !has_out_of_range_null_key { + return Ok(array); + } + + // Building from the logical iterator writes the default physical key into + // every null slot while preserving the original validity bitmap. + let keys = PrimitiveArray::::from_iter(dict_array.keys().iter()); + let values = dict_array.values().clone(); + Ok(Arc::new(DictionaryArray::::try_new(keys, values)?) as Arc) +} + +/// Replaces out-of-range physical keys in null dictionary slots with a valid key. +/// +/// Arrow permits arbitrary keys in null slots, but the structural encoder removes +/// key validity after recording it as rep-def. The replacement keeps the array +/// valid when that null buffer is removed without changing its logical values. +pub(super) fn clear_out_of_range_null_keys(array: Arc) -> Result> { + match array.data_type() { + DataType::Dictionary(key_type, _) => match key_type.as_ref() { + DataType::UInt8 => clear_out_of_range_null_keys_impl::(array), + DataType::UInt16 => clear_out_of_range_null_keys_impl::(array), + DataType::UInt32 => clear_out_of_range_null_keys_impl::(array), + DataType::UInt64 => clear_out_of_range_null_keys_impl::(array), + DataType::Int8 => clear_out_of_range_null_keys_impl::(array), + DataType::Int16 => clear_out_of_range_null_keys_impl::(array), + DataType::Int32 => clear_out_of_range_null_keys_impl::(array), + DataType::Int64 => clear_out_of_range_null_keys_impl::(array), + _ => Err(Error::not_supported_source( + format!("Unsupported dictionary key type: {}", key_type).into(), + )), + }, + _ => Err(Error::internal(format!( + "Data type is not a dictionary: {}", + array.data_type() + ))), } } fn dict_encode_variable_width( variable_width_data_block: &VariableWidthBlock, bits_per_offset: u8, - cardinality: u64, + max_dict_entries: u32, + max_encoded_size: usize, ) -> Option<(DataBlock, DataBlock)> where T: ArrowNativeType, @@ -134,21 +192,17 @@ where let max_len = max_len.as_primitive::().value(0); let max_dict_data_len = variable_width_data_block.data.len(); - let expected_dict_data_len = max_len - .checked_mul(cardinality) - .and_then(|v| >::try_from(v).ok()); - let dict_data_capacity = expected_dict_data_len - .map(|len| len.min(max_dict_data_len)) - .unwrap_or(max_dict_data_len); + let max_len: usize = max_len.try_into().unwrap_or(usize::MAX); + let dict_data_capacity = max_len + .saturating_mul(32) + .max(1024) + .min(max_dict_data_len) + .min(max_encoded_size); let mut dictionary_buffer: Vec = Vec::with_capacity(dict_data_capacity); let mut dictionary_offsets_buffer = vec![T::default()]; let mut curr_idx = 0; let mut indices_buffer = Vec::with_capacity(variable_width_data_block.num_values as usize); - let original_size = variable_width_data_block - .data_size() - .try_into() - .unwrap_or(usize::MAX); let bytes_per_offset = (bits_per_offset / 8) as usize; for window in offsets.windows(2) { @@ -163,6 +217,9 @@ where let idx = match map.entry(U8SliceKey(key)) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + if max_dict_entries == 0 || curr_idx as u32 >= max_dict_entries { + return None; + } if curr_idx == i32::MAX { return None; } @@ -188,7 +245,7 @@ where .len() .saturating_add(indices_bytes) .saturating_add(offsets_bytes); - if encoded_size > original_size { + if encoded_size > max_encoded_size { return None; } } @@ -218,15 +275,13 @@ where /// Currently only supported for some common cases (string / binary / 64-bit / 128-bit) /// /// Returns a block of indices (will always be a fixed width data block) and a block of dictionary -pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBlock)> { - let cardinality = data_block - .get_stat(Stat::Cardinality) - .unwrap() - .as_primitive::() - .value(0); - let data_block_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); +pub fn dictionary_encode( + data_block: &DataBlock, + max_dict_entries: u32, + max_encoded_size: usize, +) -> Option<(DataBlock, DataBlock)> { match data_block { - DataBlock::FixedWidth(ref mut fixed_width_data_block) => { + DataBlock::FixedWidth(fixed_width_data_block) => { use std::collections::hash_map::Entry; let bytes_per_value = match fixed_width_data_block.bits_per_value { @@ -240,7 +295,8 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl let mut map = HashMap::new(); let u64_slice = fixed_width_data_block.data.borrow_to_typed_slice::(); let u64_slice = u64_slice.as_ref(); - let mut dictionary_buffer = Vec::with_capacity(cardinality as usize); + let mut dictionary_buffer = + Vec::with_capacity((fixed_width_data_block.num_values as usize).min(1024)); let mut indices_buffer = Vec::with_capacity(fixed_width_data_block.num_values as usize); let mut curr_idx: i32 = 0; @@ -249,6 +305,9 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl let idx = match map.entry(value) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + if max_dict_entries == 0 || curr_idx as u32 >= max_dict_entries { + return None; + } if curr_idx == i32::MAX { return None; } @@ -265,7 +324,7 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl .len() .saturating_mul(DICT_INDICES_BITS_PER_VALUE as usize / 8); let encoded_size = dict_bytes.saturating_add(indices_bytes); - if encoded_size > data_block_size { + if encoded_size > max_encoded_size { return None; } } @@ -292,7 +351,8 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl let mut map = HashMap::new(); let u128_slice = fixed_width_data_block.data.borrow_to_typed_slice::(); let u128_slice = u128_slice.as_ref(); - let mut dictionary_buffer = Vec::with_capacity(cardinality as usize); + let mut dictionary_buffer = + Vec::with_capacity((fixed_width_data_block.num_values as usize).min(1024)); let mut indices_buffer = Vec::with_capacity(fixed_width_data_block.num_values as usize); let mut curr_idx: i32 = 0; @@ -301,6 +361,9 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl let idx = match map.entry(value) { Entry::Occupied(entry) => *entry.get(), Entry::Vacant(entry) => { + if max_dict_entries == 0 || curr_idx as u32 >= max_dict_entries { + return None; + } if curr_idx == i32::MAX { return None; } @@ -317,7 +380,7 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl .len() .saturating_mul(DICT_INDICES_BITS_PER_VALUE as usize / 8); let encoded_size = dict_bytes.saturating_add(indices_bytes); - if encoded_size > data_block_size { + if encoded_size > max_encoded_size { return None; } } @@ -342,10 +405,20 @@ pub fn dictionary_encode(mut data_block: DataBlock) -> Option<(DataBlock, DataBl _ => None, } } - DataBlock::VariableWidth(ref variable_width_data_block) => { + DataBlock::VariableWidth(variable_width_data_block) => { match variable_width_data_block.bits_per_offset { - 32 => dict_encode_variable_width::(variable_width_data_block, 32, cardinality), - 64 => dict_encode_variable_width::(variable_width_data_block, 64, cardinality), + 32 => dict_encode_variable_width::( + variable_width_data_block, + 32, + max_dict_entries, + max_encoded_size, + ), + 64 => dict_encode_variable_width::( + variable_width_data_block, + 64, + max_dict_entries, + max_encoded_size, + ), _ => None, } } @@ -386,7 +459,8 @@ mod tests { data_block.compute_stat(); // Dictionary encoding should abort and return None - let result = dictionary_encode(data_block); + let max_encoded_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + let result = dictionary_encode(&data_block, 1000, max_encoded_size); assert!( result.is_none(), "Dictionary encoding should abort for high cardinality u128 data" @@ -416,7 +490,8 @@ mod tests { data_block.compute_stat(); // Dictionary encoding should succeed and return Some - let result = dictionary_encode(data_block); + let max_encoded_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + let result = dictionary_encode(&data_block, 1000, max_encoded_size); assert!( result.is_some(), "Dictionary encoding should succeed for low cardinality u128 data" @@ -455,7 +530,8 @@ mod tests { let data_block = DataBlock::from_array(Arc::new(array) as Arc); // Dictionary encoding should abort and return None - let result = dictionary_encode(data_block); + let max_encoded_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + let result = dictionary_encode(&data_block, 10, max_encoded_size); assert!( result.is_none(), "Dictionary encoding should abort for high cardinality string data" @@ -477,7 +553,8 @@ mod tests { let data_block = DataBlock::from_array(Arc::new(array) as Arc); // Dictionary encoding should succeed and return Some - let result = dictionary_encode(data_block); + let max_encoded_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + let result = dictionary_encode(&data_block, 100, max_encoded_size); assert!( result.is_some(), "Dictionary encoding should succeed for low cardinality data" @@ -512,6 +589,44 @@ mod tests { } other => panic!("Expected VariableWidth data block, got {:?}", other), }; - assert!(dictionary_encode(invalid_block).is_none()); + let max_encoded_size = usize::try_from(invalid_block.data_size()).unwrap_or(usize::MAX); + assert!(dictionary_encode(&invalid_block, 100, max_encoded_size).is_none()); + } + + #[test] + fn test_dictionary_encode_respects_size_limit() { + let num_values = 10_000u64; + let cardinality = 50u64; + + let mut values = Vec::with_capacity(num_values as usize); + for i in 0..num_values { + values.push(format!("value_{:08}", i % cardinality)); + } + + let array = StringArray::from(values); + let data_block = DataBlock::from_array(Arc::new(array) as Arc); + + let full_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + let too_small_limit = full_size / 10; + assert!(dictionary_encode(&data_block, 1000, too_small_limit).is_none()); + assert!(dictionary_encode(&data_block, 1000, full_size).is_some()); + } + + #[test] + fn test_dictionary_encode_respects_entry_limit() { + let num_values = 10_000u64; + let cardinality = 200u64; + + let mut values = Vec::with_capacity(num_values as usize); + for i in 0..num_values { + values.push(format!("value_{:08}", i % cardinality)); + } + + let array = StringArray::from(values); + let data_block = DataBlock::from_array(Arc::new(array) as Arc); + + let max_encoded_size = usize::try_from(data_block.data_size()).unwrap_or(usize::MAX); + assert!(dictionary_encode(&data_block, 10, max_encoded_size).is_none()); + assert!(dictionary_encode(&data_block, 500, max_encoded_size).is_some()); } } diff --git a/rust/lance-encoding/src/encodings/logical/primitive/layout.rs b/rust/lance-encoding/src/encodings/logical/primitive/layout.rs new file mode 100644 index 00000000000..95df12ede6b --- /dev/null +++ b/rust/lance-encoding/src/encodings/logical/primitive/layout.rs @@ -0,0 +1,72 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use lance_core::Result; + +use crate::repdef::MiniBlockRepDefBudget; + +/// Runs automatic sparse planning only after the dense mini-block budget makes it useful. +pub(super) fn select_automatic_sparse( + requested_encoding: Option<&str>, + dense_budget: &MiniBlockRepDefBudget, + candidate: impl FnOnce() -> Result>, +) -> Result> { + if requested_encoding.is_some() + || !matches!( + dense_budget, + MiniBlockRepDefBudget::RequiresPageSplit(_) + | MiniBlockRepDefBudget::SingleRowOverBudget(_) + ) + { + return Ok(None); + } + candidate() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::constants::{ + STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_SPARSE, + }; + + #[test] + fn within_budget_does_not_construct_sparse_candidate() { + let selected = + select_automatic_sparse::<()>(None, &MiniBlockRepDefBudget::WithinBudget, || { + panic!("within-budget pages must not construct sparse candidates") + }) + .unwrap(); + assert!(selected.is_none()); + } + + #[test] + fn over_budget_selects_only_eligible_candidates() { + let split = MiniBlockRepDefBudget::RequiresPageSplit(Vec::new()); + let selected = select_automatic_sparse(None, &split, || Ok(Some(42))).unwrap(); + assert_eq!(selected, Some(42)); + + let ineligible = select_automatic_sparse::<()>(None, &split, || Ok(None)).unwrap(); + assert!(ineligible.is_none()); + + let unsplittable = MiniBlockRepDefBudget::SingleRowOverBudget(70_000); + let selected = select_automatic_sparse(None, &unsplittable, || Ok(Some(7))).unwrap(); + assert_eq!(selected, Some(7)); + } + + #[test] + fn explicit_modes_and_lance_2_2_do_not_auto_select() { + let split = MiniBlockRepDefBudget::RequiresPageSplit(Vec::new()); + for requested in [ + STRUCTURAL_ENCODING_MINIBLOCK, + STRUCTURAL_ENCODING_FULLZIP, + STRUCTURAL_ENCODING_SPARSE, + ] { + let selected = select_automatic_sparse::<()>(Some(requested), &split, || { + panic!("explicit modes must not invoke automatic sparse planning") + }) + .unwrap(); + assert!(selected.is_none()); + } + } +} diff --git a/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs b/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs index 6da985e9ec0..0ee408a8510 100644 --- a/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs +++ b/rust/lance-encoding/src/encodings/logical/primitive/miniblock.rs @@ -17,7 +17,30 @@ 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; +/// Maximum number of values that any mini-block decoder accepts from page metadata. +pub(crate) const MAX_CONFIGURABLE_MINIBLOCK_VALUES: u64 = 32768; + +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, MAX_CONFIGURABLE_MINIBLOCK_VALUES) +} + +pub static MAX_MINIBLOCK_VALUES: std::sync::LazyLock = + std::sync::LazyLock::new(parse_max_miniblock_values); + +/// Maximum number of rep/def levels the structural planner should place into +/// a single mini-block chunk. +pub fn max_repdef_levels_per_chunk(bits_per_level: u64) -> u64 { + debug_assert!(bits_per_level > 0); + const REPDEF_BUDGET_BITS: u64 = 16 * 1024 * 8; + let budgeted_levels = REPDEF_BUDGET_BITS / bits_per_level; + budgeted_levels.min(u16::MAX as u64) +} /// Page data that has been compressed into a series of chunks put into /// a single buffer. @@ -31,15 +54,43 @@ pub struct MiniBlockCompressed { pub num_values: u64, } +/// Per-page framing details that can affect a mini-block compressor's choice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct MiniBlockCompressionContext { + common_chunk_buffers: u64, + support_large_chunk: bool, + allow_generic_offsets: bool, +} + +impl MiniBlockCompressionContext { + /// Creates the framing context supplied by the owning mini-block page. + pub fn new( + common_chunk_buffers: u64, + support_large_chunk: bool, + allow_generic_offsets: bool, + ) -> Self { + Self { + common_chunk_buffers, + support_large_chunk, + allow_generic_offsets, + } + } +} + /// Describes the size of a mini-block chunk of data /// /// 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 -/// 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. +/// By default we limit a chunk to 4Ki values and slightly less than +/// 8KiB of compressed value data. The byte budget remains the primary +/// constraint, so only encodings that compress many values into that +/// budget can use larger value counts when explicitly configured. +/// +/// 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. @@ -51,10 +102,10 @@ pub struct MiniBlockChunk { // then this should be 0 (the number of values will be calculated by subtracting the // size of all other chunks from the total size of the page) // - // For example, 1 would mean there are 2 values in the chunk and 12 would mean there - // are 4Ki values in the chunk. + // For example, 1 would mean there are 2 values in the chunk and 15 would mean there + // are 32Ki 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, } @@ -85,5 +136,66 @@ pub trait MiniBlockCompressor: std::fmt::Debug + Send + Sync { /// /// This method also returns a description of the encoding applied that will be /// used at decode time to read the data. - fn compress(&self, page: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)>; + fn compress( + &self, + context: MiniBlockCompressionContext, + 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_can_raise_to_32k() { + unsafe { std::env::set_var("LANCE_MINIBLOCK_MAX_VALUES", "32768") }; + assert_eq!(parse_max_miniblock_values(), 32768); + 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(), + MAX_CONFIGURABLE_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/logical/primitive/sparse.rs b/rust/lance-encoding/src/encodings/logical/primitive/sparse.rs new file mode 100644 index 00000000000..2de814b5ad8 --- /dev/null +++ b/rust/lance-encoding/src/encodings/logical/primitive/sparse.rs @@ -0,0 +1,5623 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use super::*; +use arrow_array::new_empty_array; +use arrow_buffer::ArrowNativeType; + +fn invalid_enum( + value: std::result::Result, + label: &str, +) -> Result { + value.map_err(|error| { + Error::invalid_input_source( + format!("Sparse structural {label} has an invalid enum value: {error}").into(), + ) + }) +} + +pub(super) mod writer; + +fn usize_from_u64(value: u64, label: &str) -> Result { + usize::try_from(value).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} {value} exceeds usize::MAX").into(), + ) + }) +} + +/// Native sparse structural representation used by the 2.3 sparse layout. +/// +/// Layers are stored from outer-most to inner-most, matching the order Arrow structural +/// encoders record offsets and validity. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SparseStructuralPlan { + pub(crate) layers: Vec, + pub(crate) num_items: u64, + pub(crate) num_visible_items: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SparsePositionSet { + Empty, + All { len: u64 }, + Range { start: u64, len: u64 }, + Explicit(Vec), +} + +impl SparsePositionSet { + pub(crate) fn from_positions( + positions: Vec, + domain_len: u64, + label: &str, + ) -> Result { + if positions.is_empty() { + return Ok(Self::Empty); + } + for window in positions.windows(2) { + let [previous, current] = window else { + continue; + }; + if previous >= current { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} positions must be strictly increasing") + .into(), + )); + } + } + if let Some(position) = positions.iter().find(|position| **position >= domain_len) { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} position {} is outside layer with {} slots", + position, domain_len + ) + .into(), + )); + } + + let len = u64::try_from(positions.len()).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} position count exceeds u64::MAX").into(), + ) + })?; + let first = positions.first().copied().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} positions are unexpectedly empty").into(), + ) + })?; + let last = positions.last().copied().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} positions are unexpectedly empty").into(), + ) + })?; + if first == 0 && len == domain_len && domain_len > 0 && last == domain_len - 1 { + return Ok(Self::All { len: domain_len }); + } + if last - first + 1 == len { + return Ok(Self::Range { start: first, len }); + } + Ok(Self::Explicit(positions)) + } + + pub(crate) fn empty() -> Self { + Self::Empty + } + + pub(crate) fn all(len: u64) -> Self { + if len == 0 { + Self::Empty + } else { + Self::All { len } + } + } + + pub(crate) fn range(start: u64, len: u64) -> Self { + if len == 0 { + Self::Empty + } else { + Self::Range { start, len } + } + } + + pub(crate) fn len(&self) -> u64 { + match self { + Self::Empty => 0, + Self::All { len } | Self::Range { len, .. } => *len, + Self::Explicit(positions) => positions.len() as u64, + } + } + + pub(crate) fn is_empty(&self) -> bool { + self.len() == 0 + } + + pub(crate) fn deep_size(&self) -> usize { + match self { + Self::Explicit(positions) => positions.len() * std::mem::size_of::(), + Self::Empty | Self::All { .. } | Self::Range { .. } => 0, + } + } + + pub(crate) fn materialize(&self) -> Result> { + match self { + Self::Empty => Ok(Vec::new()), + Self::All { len } => Self::materialize_range(0, *len), + Self::Range { start, len } => Self::materialize_range(*start, *len), + Self::Explicit(positions) => Ok(positions.clone()), + } + } + + fn materialize_range(start: u64, len: u64) -> Result> { + let len_usize = usize::try_from(len).map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse structural position range length {} exceeds usize::MAX", + len + ) + .into(), + ) + })?; + let end = start.checked_add(len).ok_or_else(|| { + Error::invalid_input_source("Sparse structural position range overflows".into()) + })?; + let mut positions = Vec::with_capacity(len_usize); + positions.extend(start..end); + Ok(positions) + } + + fn contains(&self, position: u64) -> bool { + match self { + Self::Empty => false, + Self::All { len } => position < *len, + Self::Range { start, len } => { + position >= *start && position < start.saturating_add(*len) + } + Self::Explicit(positions) => positions.binary_search(&position).is_ok(), + } + } + + fn is_subset_of(&self, other: &Self, domain_len: u64) -> Result { + self.validate_domain(domain_len, "subset")?; + other.validate_domain(domain_len, "superset")?; + Ok(match self { + Self::Empty => true, + Self::All { .. } => other.len() == domain_len, + Self::Range { start, len } => { + let end = start.checked_add(*len).ok_or_else(|| { + Error::invalid_input_source("Sparse structural subset range overflows".into()) + })?; + match other { + Self::All { .. } => true, + Self::Range { + start: other_start, + len: other_len, + } => { + let other_end = other_start.saturating_add(*other_len); + *start >= *other_start && end <= other_end + } + Self::Explicit(positions) => { + let first = positions.partition_point(|position| *position < *start); + let last = positions.partition_point(|position| *position < end); + u64::try_from(last.saturating_sub(first)).ok() == Some(*len) + } + Self::Empty => false, + } + } + Self::Explicit(positions) => positions.iter().all(|position| other.contains(*position)), + }) + } + + fn is_disjoint(&self, other: &Self, domain_len: u64) -> Result { + self.validate_domain(domain_len, "first disjoint set")?; + other.validate_domain(domain_len, "second disjoint set")?; + let (smaller, larger) = if self.len() <= other.len() { + (self, other) + } else { + (other, self) + }; + Ok(match smaller { + Self::Empty => true, + Self::All { .. } => larger.is_empty(), + Self::Range { start, len } => { + let end = start.saturating_add(*len); + match larger { + Self::Empty => true, + Self::All { .. } => false, + Self::Range { + start: other_start, + len: other_len, + } => end <= *other_start || other_start.saturating_add(*other_len) <= *start, + Self::Explicit(positions) => { + let index = positions.partition_point(|position| *position < *start); + positions.get(index).is_none_or(|position| *position >= end) + } + } + } + Self::Explicit(positions) => { + positions.iter().all(|position| !larger.contains(*position)) + } + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SparseValidityMeaning { + NullPositions, + ValidPositions, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SparseValiditySet { + pub(crate) meaning: SparseValidityMeaning, + pub(crate) positions: SparsePositionSet, +} + +impl SparseValiditySet { + pub(crate) fn deep_size(&self) -> usize { + self.positions.deep_size() + } + + fn contains_only_valid_positions( + &self, + positions: &SparsePositionSet, + num_slots: u64, + ) -> Result { + match self.meaning { + SparseValidityMeaning::NullPositions => { + positions.is_disjoint(&self.positions, num_slots) + } + SparseValidityMeaning::ValidPositions => { + positions.is_subset_of(&self.positions, num_slots) + } + } + } + + fn append_to(&self, validity: &mut BooleanBufferBuilder, num_slots: u64) -> Result<()> { + self.positions.validate_domain(num_slots, "validity")?; + let num_slots_usize = usize_from_u64(num_slots, "validity slot count")?; + match (self.meaning, &self.positions) { + (SparseValidityMeaning::NullPositions, SparsePositionSet::Empty) => { + validity.append_n(num_slots_usize, true); + } + (SparseValidityMeaning::ValidPositions, SparsePositionSet::Empty) => { + validity.append_n(num_slots_usize, false); + } + (SparseValidityMeaning::NullPositions, SparsePositionSet::All { .. }) => { + validity.append_n(num_slots_usize, false); + } + (SparseValidityMeaning::ValidPositions, SparsePositionSet::All { .. }) => { + validity.append_n(num_slots_usize, true); + } + (meaning, SparsePositionSet::Range { start, len }) => { + let range_end = start.checked_add(*len).ok_or_else(|| { + Error::invalid_input_source("Sparse structural validity range overflows".into()) + })?; + let default_valid = matches!(meaning, SparseValidityMeaning::NullPositions); + let range_valid = !default_valid; + validity.append_n( + usize_from_u64(*start, "validity range start")?, + default_valid, + ); + validity.append_n(usize_from_u64(*len, "validity range length")?, range_valid); + validity.append_n( + usize_from_u64(num_slots - range_end, "validity range tail")?, + default_valid, + ); + } + (_, SparsePositionSet::Explicit(_)) => { + let mut cursor = SparseValidityCursor::new(self, num_slots, "validity")?; + for slot in 0..num_slots { + validity.append(cursor.is_valid(slot)?); + } + cursor.finish()?; + } + } + Ok(()) + } +} + +impl SparsePositionSet { + fn validate_domain(&self, domain_len: u64, label: &str) -> Result<()> { + match self { + Self::Empty => {} + Self::All { len } => { + if *len != domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} all set length {} does not match domain {}", + len, domain_len + ) + .into(), + )); + } + } + Self::Range { start, len } => { + let end = start.checked_add(*len).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} range overflows").into(), + ) + })?; + if end > domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} range {}..{} is outside domain {}", + start, end, domain_len + ) + .into(), + )); + } + } + Self::Explicit(positions) => { + for window in positions.windows(2) { + let [previous, current] = window else { + continue; + }; + if previous >= current { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} positions must be strictly increasing" + ) + .into(), + )); + } + } + if let Some(position) = positions.last() + && *position >= domain_len + { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} position {} is outside layer with {} slots", + position, domain_len + ) + .into(), + )); + } + } + } + Ok(()) + } +} + +struct SparsePositionSetCursor<'a> { + set: &'a SparsePositionSet, + explicit: Option>>, +} + +impl<'a> SparsePositionSetCursor<'a> { + fn new(set: &'a SparsePositionSet, domain_len: u64, label: &str) -> Result { + set.validate_domain(domain_len, label)?; + let explicit = match set { + SparsePositionSet::Explicit(positions) => Some(positions.iter().peekable()), + _ => None, + }; + Ok(Self { set, explicit }) + } + + fn contains(&mut self, slot: u64) -> Result { + Ok(match self.set { + SparsePositionSet::Empty => false, + SparsePositionSet::All { .. } => true, + SparsePositionSet::Range { start, len } => { + slot >= *start && slot < start.saturating_add(*len) + } + SparsePositionSet::Explicit(_) => { + let iter = self.explicit.as_mut().ok_or_else(|| { + Error::internal("Sparse structural explicit cursor is missing".to_string()) + })?; + if let Some(position) = iter.peek() + && **position < slot + { + return Err(Error::invalid_input_source( + format!( + "Sparse structural explicit position {} was skipped before slot {}", + **position, slot + ) + .into(), + )); + } + if iter.peek().is_some_and(|position| **position == slot) { + iter.next(); + true + } else { + false + } + } + }) + } + + fn finish(&mut self) -> Result<()> { + if let Some(iter) = self.explicit.as_mut() + && let Some(position) = iter.next() + { + return Err(Error::invalid_input_source( + format!( + "Sparse structural explicit position {} was not consumed", + position + ) + .into(), + )); + } + Ok(()) + } +} + +struct SparseValidityCursor<'a> { + meaning: SparseValidityMeaning, + positions: SparsePositionSetCursor<'a>, +} + +impl<'a> SparseValidityCursor<'a> { + fn new(validity: &'a SparseValiditySet, domain_len: u64, label: &str) -> Result { + Ok(Self { + meaning: validity.meaning, + positions: SparsePositionSetCursor::new(&validity.positions, domain_len, label)?, + }) + } + + fn is_valid(&mut self, slot: u64) -> Result { + let is_stored = self.positions.contains(slot)?; + Ok(match self.meaning { + SparseValidityMeaning::NullPositions => !is_stored, + SparseValidityMeaning::ValidPositions => is_stored, + }) + } + + fn finish(&mut self) -> Result<()> { + self.positions.finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SparseCountSet { + Empty, + Constant { + value: u64, + len: u64, + }, + Explicit { + counts: Arc<[u64]>, + offsets: Arc<[u64]>, + }, +} + +impl SparseCountSet { + pub(crate) fn from_counts(counts: Vec) -> Result { + if counts.is_empty() { + return Ok(Self::Empty); + } + if let Some(first) = counts.first().copied() + && counts.iter().all(|count| *count == first) + { + return Ok(Self::Constant { + value: first, + len: counts.len() as u64, + }); + } + let offsets = offsets_from_counts(&counts)?; + Ok(Self::Explicit { + counts: counts.into(), + offsets: offsets.into(), + }) + } + + pub(crate) fn constant(value: u64, len: u64) -> Self { + if len == 0 { + Self::Empty + } else { + Self::Constant { value, len } + } + } + + pub(crate) fn len(&self) -> u64 { + match self { + Self::Empty => 0, + Self::Constant { len, .. } => *len, + Self::Explicit { counts, .. } => counts.len() as u64, + } + } + + pub(crate) fn deep_size(&self) -> usize { + match self { + Self::Explicit { counts, offsets } => { + counts.len() * std::mem::size_of::() + + offsets.len() * std::mem::size_of::() + } + Self::Empty | Self::Constant { .. } => 0, + } + } + + pub(crate) fn materialize(&self) -> Result> { + match self { + Self::Empty => Ok(Vec::new()), + Self::Constant { value, len } => { + let len = usize::try_from(*len).map_err(|_| { + Error::invalid_input_source( + "Sparse structural count set length exceeds usize::MAX".into(), + ) + })?; + Ok(vec![*value; len]) + } + Self::Explicit { counts, .. } => Ok(counts.to_vec()), + } + } + + pub(crate) fn sum(&self) -> Result { + match self { + Self::Empty => Ok(0), + Self::Constant { value, len } => value.checked_mul(*len).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural constant count sum overflows: value={}, len={}", + value, len + ) + .into(), + ) + }), + Self::Explicit { offsets, .. } => offsets.last().copied().ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural explicit count offsets are empty".into(), + ) + }), + } + } + + fn validate_positive(&self) -> Result<()> { + let has_zero = match self { + Self::Empty => false, + Self::Constant { value, .. } => *value == 0, + Self::Explicit { counts, .. } => counts.contains(&0), + }; + if has_zero { + return Err(Error::invalid_input_source( + "Sparse structural non-empty list count is zero".into(), + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SparseStructuralLayerPlan { + Validity { + num_slots: u64, + validity: SparseValiditySet, + }, + List { + num_slots: u64, + num_child_slots: u64, + non_empty_positions: SparsePositionSet, + counts: SparseCountSet, + validity: SparseValiditySet, + }, + FixedSizeList { + num_slots: u64, + dimension: u64, + validity: SparseValiditySet, + }, +} + +impl SparseStructuralPlan { + fn expected_num_items( + layers: &[SparseStructuralLayerPlan], + num_visible_items: u64, + ) -> Result { + layers.iter().try_fold(num_visible_items, |items, layer| { + let additional = match layer { + SparseStructuralLayerPlan::List { + num_slots, + non_empty_positions, + .. + } => num_slots + .checked_sub(non_empty_positions.len()) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list has more non-empty positions than slots".into(), + ) + })?, + SparseStructuralLayerPlan::Validity { .. } + | SparseStructuralLayerPlan::FixedSizeList { .. } => 0, + }; + items.checked_add(additional).ok_or_else(|| { + Error::invalid_input_source("Sparse structural item count overflows".into()) + }) + }) + } + + fn validate(&self, row_domain: u64) -> Result<()> { + usize_from_u64(self.num_visible_items, "visible item count")?; + let expected_num_items = Self::expected_num_items(&self.layers, self.num_visible_items)?; + if self.num_items != expected_num_items { + return Err(Error::invalid_input_source( + format!( + "Sparse structural item count {} does not match the {} items implied by its layers", + self.num_items, expected_num_items + ) + .into(), + )); + } + let mut expected_slots = row_domain; + for (layer_index, layer) in self.layers.iter().enumerate() { + let (num_slots, num_child_slots, validity) = match layer { + SparseStructuralLayerPlan::Validity { + num_slots, + validity, + } => (*num_slots, *num_slots, validity), + SparseStructuralLayerPlan::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + } => { + counts.validate_positive()?; + if non_empty_positions.len() != counts.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list layer {} has {} non-empty positions but {} counts", + layer_index, + non_empty_positions.len(), + counts.len() + ) + .into(), + )); + } + if counts.sum()? != *num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list layer {} count sum does not match {} child slots", + layer_index, num_child_slots + ) + .into(), + )); + } + if !validity.contains_only_valid_positions(non_empty_positions, *num_slots)? { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list layer {} contains a non-empty null slot", + layer_index + ) + .into(), + )); + } + (*num_slots, *num_child_slots, validity) + } + SparseStructuralLayerPlan::FixedSizeList { + num_slots, + dimension, + validity, + } => { + if *dimension == 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list layer {} has dimension zero", + layer_index + ) + .into(), + )); + } + let num_child_slots = num_slots.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural fixed-size-list child domain overflows".into(), + ) + })?; + (*num_slots, num_child_slots, validity) + } + }; + usize_from_u64(num_slots, "layer slot count")?; + usize_from_u64(num_child_slots, "layer child slot count")?; + if num_slots != expected_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural layer {} has {} slots, expected {}", + layer_index, num_slots, expected_slots + ) + .into(), + )); + } + validity.positions.validate_domain(num_slots, "validity")?; + expected_slots = num_child_slots; + } + if expected_slots != self.num_visible_items { + return Err(Error::invalid_input_source( + format!( + "Sparse structural terminal domain has {} slots, expected {} visible items", + expected_slots, self.num_visible_items + ) + .into(), + )); + } + Ok(()) + } +} + +#[derive(Debug)] +pub struct SparseStructuralUnraveler { + layers: Vec, + next_layer: usize, + pending_fixed_size_list: bool, +} + +impl SparseStructuralUnraveler { + pub(crate) fn new(plan: SparseStructuralPlan) -> Self { + let next_layer = plan.layers.len(); + Self { + layers: plan.layers, + next_layer, + pending_fixed_size_list: false, + } + } + + fn current_layer(&self) -> Option<&SparseStructuralLayerPlan> { + self.next_layer + .checked_sub(1) + .and_then(|idx| self.layers.get(idx)) + } + + fn consume_current_layer(&mut self) -> Result<()> { + self.next_layer = self.next_layer.checked_sub(1).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural metadata has fewer layers than the Arrow schema".into(), + ) + })?; + Ok(()) + } + + pub(crate) fn ensure_exhausted(&self) -> Result<()> { + if self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse structural metadata has an unconsumed fixed-size-list layer".into(), + )); + } + if self.next_layer != 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural metadata has {} unconsumed layer(s)", + self.next_layer + ) + .into(), + )); + } + Ok(()) + } + + pub(crate) fn is_all_valid(&self) -> bool { + match self.current_layer() { + Some(SparseStructuralLayerPlan::Validity { + num_slots, + validity, + }) + | Some(SparseStructuralLayerPlan::FixedSizeList { + num_slots, + validity, + .. + }) + | Some(SparseStructuralLayerPlan::List { + num_slots, + validity, + .. + }) => match validity.meaning { + SparseValidityMeaning::NullPositions => validity.positions.is_empty(), + SparseValidityMeaning::ValidPositions => validity.positions.len() == *num_slots, + }, + None => true, + } + } + + pub(crate) fn max_lists(&self) -> Result { + match self.current_layer() { + Some(SparseStructuralLayerPlan::List { num_slots, .. }) => { + usize_from_u64(*num_slots, "list slot count") + } + _ => Ok(0), + } + } + + pub(crate) fn skip_validity(&mut self) -> Result<()> { + match self.current_layer() { + Some(SparseStructuralLayerPlan::Validity { .. }) => { + if self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list schema does not match a validity layer".into(), + )); + } + self.consume_current_layer()?; + } + Some(SparseStructuralLayerPlan::FixedSizeList { .. }) => { + if !self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list layer does not match the Arrow schema".into(), + )); + } + self.pending_fixed_size_list = false; + self.consume_current_layer()?; + } + None => { + return Err(Error::invalid_input_source( + "Sparse structural metadata has fewer layers than the Arrow schema".into(), + )); + } + Some(SparseStructuralLayerPlan::List { .. }) => { + return Err(Error::invalid_input_source( + "Sparse structural list layer does not match an Arrow validity layer".into(), + )); + } + } + Ok(()) + } + + pub(crate) fn unravel_validity(&mut self, validity: &mut BooleanBufferBuilder) -> Result<()> { + match self.current_layer() { + Some(SparseStructuralLayerPlan::Validity { + num_slots, + validity: layer_validity, + }) => { + if self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list schema does not match a validity layer".into(), + )); + } + layer_validity.append_to(validity, *num_slots)?; + self.consume_current_layer()?; + } + Some(SparseStructuralLayerPlan::FixedSizeList { + num_slots, + validity: layer_validity, + .. + }) => { + if !self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list layer does not match the Arrow schema".into(), + )); + } + layer_validity.append_to(validity, *num_slots)?; + self.pending_fixed_size_list = false; + self.consume_current_layer()?; + } + None => { + return Err(Error::invalid_input_source( + "Sparse structural metadata has fewer layers than the Arrow schema".into(), + )); + } + Some(SparseStructuralLayerPlan::List { .. }) => { + return Err(Error::invalid_input_source( + "Sparse structural list layer does not match an Arrow validity layer".into(), + )); + } + } + Ok(()) + } + + pub(crate) fn decimate(&mut self, dimension: usize) -> Result<()> { + if self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list layer was decimated more than once".into(), + )); + } + let Some(SparseStructuralLayerPlan::FixedSizeList { + dimension: actual_dimension, + .. + }) = self.current_layer() + else { + return Err(Error::invalid_input_source( + "Sparse structural layer does not match an Arrow fixed-size-list layer".into(), + )); + }; + if usize_from_u64(*actual_dimension, "fixed-size-list dimension")? != dimension { + return Err(Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list dimension {} does not match Arrow dimension {}", + actual_dimension, dimension + ) + .into(), + )); + } + self.pending_fixed_size_list = true; + Ok(()) + } + + fn to_offset(value: u64) -> Result { + let value = usize::try_from(value).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural offset {} exceeds usize::MAX", value).into(), + ) + })?; + T::from_usize(value).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural offset does not fit the Arrow offset type".into(), + ) + }) + } + + pub(crate) fn unravel_offsets( + &mut self, + offsets: &mut Vec, + validity: Option<&mut BooleanBufferBuilder>, + ) -> Result<()> { + let Some(SparseStructuralLayerPlan::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity: layer_validity, + .. + }) = self.current_layer() + else { + return Err(Error::invalid_input_source( + "Sparse structural layer does not match an Arrow list layer".into(), + )); + }; + if self.pending_fixed_size_list { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list schema does not match an Arrow list layer".into(), + )); + } + + if non_empty_positions.len() != counts.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list has {} non-empty positions but {} counts", + non_empty_positions.len(), + counts.len() + ) + .into(), + )); + } + let actual_child_slots = counts.sum()?; + if actual_child_slots != *num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list count sum {} does not match child slots {}", + actual_child_slots, num_child_slots + ) + .into(), + )); + } + + let mut current_offset = offsets + .last() + .map(|offset| offset.as_usize() as u64) + .unwrap_or(0); + if offsets.is_empty() { + offsets.push(Self::to_offset(current_offset)?); + } + + if non_empty_positions.is_empty() { + if let Some(validity) = validity { + layer_validity.append_to(validity, *num_slots)?; + } + let offset = Self::to_offset(current_offset)?; + let new_len = offsets + .len() + .checked_add(usize_from_u64(*num_slots, "list slot count")?) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list offset length overflows usize".into(), + ) + })?; + offsets.resize(new_len, offset); + self.consume_current_layer()?; + return Ok(()); + } + + let non_empty_positions = non_empty_positions.materialize()?; + let counts = counts.materialize()?; + let mut non_empty_iter = non_empty_positions + .iter() + .copied() + .zip(counts.iter().copied()) + .peekable(); + let mut validity_cursor = + SparseValidityCursor::new(layer_validity, *num_slots, "list validity")?; + let mut validity = validity; + for slot in 0..*num_slots { + let is_valid = validity_cursor.is_valid(slot)?; + if let Some(validity) = validity.as_mut() { + validity.append(is_valid); + } + + if non_empty_iter + .peek() + .is_some_and(|(non_empty_pos, _)| *non_empty_pos == slot) + { + let (_, count) = non_empty_iter.next().ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list position is missing its child count".into(), + ) + })?; + if !is_valid { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list slot {} is both invalid and non-empty", + slot + ) + .into(), + )); + } + current_offset = current_offset.checked_add(count).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list offsets overflow u64".into(), + ) + })?; + } + offsets.push(Self::to_offset(current_offset)?); + } + validity_cursor.finish()?; + if let Some((extra_pos, _)) = non_empty_iter.next() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural non-empty position {} is outside layer with {} slots", + extra_pos, num_slots + ) + .into(), + )); + } + + self.consume_current_layer()?; + Ok(()) + } +} + +#[derive(Debug, Clone)] +enum SparsePositionSetDecoder { + Empty, + All { + len: u64, + }, + Range { + start: u64, + len: u64, + }, + Explicit { + decompressor: Arc, + encoding: CompressiveEncoding, + count: u64, + domain_len: u64, + }, +} + +#[derive(Debug, Clone)] +enum SparseCountSetDecoder { + Empty, + Constant { + value: u64, + len: u64, + }, + Explicit { + decompressor: Arc, + encoding: CompressiveEncoding, + count: u64, + }, +} + +#[derive(Debug, Clone)] +enum SparseLayerDecompressors { + Validity { + num_slots: u64, + validity: SparseValiditySetDecoder, + }, + List { + num_slots: u64, + num_child_slots: u64, + non_empty_positions: SparsePositionSetDecoder, + counts: SparseCountSetDecoder, + validity: SparseValiditySetDecoder, + }, + FixedSizeList { + num_slots: u64, + num_child_slots: u64, + dimension: u64, + validity: SparseValiditySetDecoder, + }, +} + +#[derive(Debug, Clone)] +struct SparseValiditySetDecoder { + meaning: SparseValidityMeaning, + positions: SparsePositionSetDecoder, +} + +#[derive(Debug)] +struct SparseStructuralCacheableState { + chunk_meta: Vec, + chunk_value_offsets: Arc<[u64]>, + plan: SparseStructuralPlan, + row_domain: u64, +} + +impl DeepSizeOf for SparseStructuralCacheableState { + fn deep_size_of_children(&self, _context: &mut Context) -> usize { + let structural_size = self + .plan + .layers + .iter() + .map(|layer| match layer { + SparseStructuralLayerPlan::Validity { validity, .. } => validity.deep_size(), + SparseStructuralLayerPlan::List { + non_empty_positions, + counts, + validity, + .. + } => non_empty_positions.deep_size() + counts.deep_size() + validity.deep_size(), + SparseStructuralLayerPlan::FixedSizeList { validity, .. } => validity.deep_size(), + }) + .sum::(); + self.chunk_meta.len() * std::mem::size_of::() + + self.chunk_value_offsets.len() * std::mem::size_of::() + + structural_size + } +} + +impl CachedPageData for SparseStructuralCacheableState { + fn as_arc_any(self: Arc) -> Arc { + self + } +} + +#[derive(Debug)] +pub(super) struct SparseStructuralScheduler { + buffer_offsets_and_sizes: Vec<(u64, u64)>, + priority: u64, + row_domain: u64, + row_scale: u64, + num_items: u64, + num_visible_items: u64, + num_buffers: u64, + value_encoding: CompressiveEncoding, + value_decompressor: Arc, + layer_decompressors: Vec, + data_type: DataType, + page_meta: Option>, + has_large_chunk: bool, +} + +impl SparseStructuralScheduler { + fn require_layer( + layer: &pb21::SparseStructuralLayer, + ) -> Result<&pb21::sparse_structural_layer::Layer> { + layer.layer.as_ref().ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural layer is missing its layer variant".into(), + ) + }) + } + + fn layer_num_slots(layer: &pb21::SparseStructuralLayer) -> Result { + Ok(match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::Validity(layer) => layer.num_slots, + pb21::sparse_structural_layer::Layer::List(layer) => layer.num_slots, + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => layer.num_slots, + }) + } + + fn layer_num_child_slots(layer: &pb21::SparseStructuralLayer) -> Result { + Ok(match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::Validity(layer) => layer.num_slots, + pb21::sparse_structural_layer::Layer::List(layer) => layer.num_child_slots, + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => layer + .num_slots + .checked_mul(layer.dimension) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count overflows: slots={}, dimension={}", + layer.num_slots, layer.dimension + ) + .into(), + ) + })?, + }) + } + + pub(super) fn try_new( + buffer_offsets_and_sizes: &[(u64, u64)], + priority: u64, + encoded_row_domain: u64, + data_type: DataType, + layout: &pb21::SparseLayout, + decompressors: &dyn DecompressionStrategy, + ) -> Result { + let value_compression = layout.value_compression.as_ref().ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing value compression".into()) + })?; + let value_buffer_count = Self::validate_value_encoding(value_compression)?; + if layout.num_buffers != value_buffer_count { + return Err(Error::invalid_input_source( + format!( + "Sparse layout declares {} value buffers, but its compression descriptor requires {}", + layout.num_buffers, value_buffer_count + ) + .into(), + )); + } + let row_domain = match layout.structural_layers.first() { + Some(layer) => Self::layer_num_slots(layer)?, + None => encoded_row_domain, + }; + Self::validate_domain_chain( + &layout.structural_layers, + row_domain, + layout.num_items, + layout.num_visible_items, + )?; + let expected_buffers = 2 + Self::structural_buffer_count(&layout.structural_layers)?; + if buffer_offsets_and_sizes.len() != expected_buffers { + return Err(Error::invalid_input_source( + format!( + "Sparse layout has {} buffers, expected {}", + buffer_offsets_and_sizes.len(), + expected_buffers + ) + .into(), + )); + } + Self::validate_page_buffers(buffer_offsets_and_sizes, layout.num_visible_items)?; + let row_scale = + layout + .structural_layers + .iter() + .try_fold(1_u64, |scale, layer| -> Result { + match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => { + scale.checked_mul(layer.dimension).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural fixed-size-list row scale overflows".into(), + ) + }) + } + pb21::sparse_structural_layer::Layer::Validity(_) + | pb21::sparse_structural_layer::Layer::List(_) => Ok(scale), + } + })?; + let expected_encoded_row_domain = row_domain.checked_mul(row_scale).ok_or_else(|| { + Error::invalid_input_source("Sparse structural encoded row domain overflows".into()) + })?; + if encoded_row_domain != expected_encoded_row_domain { + return Err(Error::invalid_input_source( + format!( + "Sparse structural encoded row domain {} does not match outer domain {} * fixed-size-list scale {}", + encoded_row_domain, row_domain, row_scale + ) + .into(), + )); + } + let value_decompressor = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + decompressors.create_miniblock_decompressor(value_compression, decompressors) + })) + .map_err(|_| { + Error::invalid_input_source( + "Sparse value compression descriptor caused decompressor construction to panic" + .into(), + ) + })? + .map_err(|error| { + Error::invalid_input_source( + format!("Sparse value decompressor construction failed: {error}").into(), + ) + })?; + let layer_decompressors = layout + .structural_layers + .iter() + .map(|layer| Self::layer_decompressors(layer, decompressors)) + .collect::>>()?; + + Ok(Self { + buffer_offsets_and_sizes: buffer_offsets_and_sizes.to_vec(), + priority, + row_domain, + row_scale, + num_items: layout.num_items, + num_visible_items: layout.num_visible_items, + num_buffers: layout.num_buffers, + value_encoding: value_compression.clone(), + value_decompressor: value_decompressor.into(), + layer_decompressors, + data_type, + page_meta: None, + has_large_chunk: layout.has_large_chunk, + }) + } + + fn validate_compression<'a>( + compression: &'a CompressiveEncoding, + label: &str, + ) -> Result<&'a CompressiveEncoding> { + compression.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} is missing compression details").into(), + ) + })?; + Ok(compression) + } + + fn validate_buffer_compression( + compression: Option<&pb21::BufferCompression>, + label: &str, + ) -> Result<()> { + let Some(compression) = compression else { + return Ok(()); + }; + match invalid_enum( + pb21::CompressionScheme::try_from(compression.scheme), + "compression scheme", + )? { + pb21::CompressionScheme::CompressionAlgorithmUnspecified => { + Err(Error::invalid_input_source( + format!("Sparse structural {label} buffer compression is unspecified").into(), + )) + } + pb21::CompressionScheme::CompressionAlgorithmLz4 + | pb21::CompressionScheme::CompressionAlgorithmZstd => Ok(()), + } + } + + fn encoding_contains_general(root: &CompressiveEncoding) -> bool { + use pb21::compressive_encoding::Compression; + + let mut stack = vec![root]; + while let Some(encoding) = stack.pop() { + let Some(compression) = encoding.compression.as_ref() else { + continue; + }; + match compression { + Compression::General(_) => return true, + Compression::Variable(variable) => { + stack.extend(variable.offsets.as_deref()); + } + Compression::OutOfLineBitpacking(bitpacking) => { + stack.extend(bitpacking.values.as_deref()); + } + Compression::Fsst(fsst) => stack.extend(fsst.values.as_deref()), + Compression::Dictionary(dictionary) => { + stack.extend(dictionary.indices.as_deref()); + stack.extend(dictionary.items.as_deref()); + } + Compression::Rle(rle) => { + stack.extend(rle.values.as_deref()); + stack.extend(rle.run_lengths.as_deref()); + } + Compression::ByteStreamSplit(split) => { + stack.extend(split.values.as_deref()); + } + Compression::FixedSizeList(fsl) => stack.extend(fsl.values.as_deref()), + Compression::PackedStruct(packed) => stack.extend(packed.values.as_deref()), + Compression::VariablePackedStruct(packed) => { + stack.extend( + packed + .fields + .iter() + .filter_map(|field| field.value.as_ref()), + ); + } + Compression::Flat(_) + | Compression::Constant(_) + | Compression::InlineBitpacking(_) => {} + } + } + false + } + + fn require_encoding<'a>( + encoding: &'a Option>, + label: &str, + ) -> Result<&'a CompressiveEncoding> { + encoding.as_deref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} encoding is required").into(), + ) + }) + } + + fn validate_flat(flat: &pb21::Flat, label: &str) -> Result<()> { + if flat.bits_per_value == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} flat bit width is zero").into(), + )); + } + if flat.data.is_some() { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} uses unsupported leaf buffer compression") + .into(), + )); + } + Ok(()) + } + + fn validate_value_encoding(encoding: &CompressiveEncoding) -> Result { + use pb21::compressive_encoding::Compression; + + let compression = encoding.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + "Sparse value compression is missing compression details".into(), + ) + })?; + match compression { + Compression::Flat(flat) => { + Self::validate_flat(flat, "value")?; + Ok(1) + } + Compression::InlineBitpacking(bitpacking) => { + if !matches!(bitpacking.uncompressed_bits_per_value, 8 | 16 | 32 | 64) { + return Err(Error::invalid_input_source( + format!( + "Sparse inline bitpacking width {} is not supported", + bitpacking.uncompressed_bits_per_value + ) + .into(), + )); + } + if bitpacking.values.is_some() { + return Err(Error::invalid_input_source( + "Sparse inline bitpacking uses unsupported leaf buffer compression".into(), + )); + } + Ok(1) + } + Compression::Variable(variable) => { + let offsets = variable.offsets.as_deref().ok_or_else(|| { + Error::invalid_input_source( + "Sparse variable compression is missing offsets".into(), + ) + })?; + let Some(Compression::Flat(offsets)) = offsets.compression.as_ref() else { + return Err(Error::invalid_input_source( + "Sparse variable offsets must use flat compression".into(), + )); + }; + Self::validate_flat(offsets, "variable offsets")?; + if !matches!(offsets.bits_per_value, 32 | 64) { + return Err(Error::invalid_input_source( + format!( + "Sparse variable offset width {} is not supported", + offsets.bits_per_value + ) + .into(), + )); + } + if variable.values.is_some() { + return Err(Error::invalid_input_source( + "Sparse variable values use unsupported leaf buffer compression".into(), + )); + } + Ok(1) + } + Compression::Fsst(fsst) => { + if fsst.symbol_table.is_empty() { + return Err(Error::invalid_input_source( + "Sparse FSST compression has an empty symbol table".into(), + )); + } + let values = Self::require_encoding(&fsst.values, "FSST values")?; + if !matches!(values.compression.as_ref(), Some(Compression::Variable(_))) { + return Err(Error::invalid_input_source( + "Sparse FSST values must use variable compression".into(), + )); + } + Self::validate_value_encoding(values) + } + Compression::ByteStreamSplit(split) => { + let values = Self::require_encoding(&split.values, "byte-stream-split values")?; + let Some(Compression::Flat(flat)) = values.compression.as_ref() else { + return Err(Error::invalid_input_source( + "Sparse byte-stream-split values must use flat compression".into(), + )); + }; + Self::validate_flat(flat, "byte-stream-split values")?; + if !matches!(flat.bits_per_value, 32 | 64) { + return Err(Error::invalid_input_source( + format!( + "Sparse byte-stream-split width {} is not supported", + flat.bits_per_value + ) + .into(), + )); + } + Ok(1) + } + Compression::FixedSizeList(fsl) => Self::validate_fsl_value_encoding(fsl), + Compression::PackedStruct(packed) => Self::validate_packed_value_encoding(packed), + Compression::Rle(rle) => { + let values = Self::require_encoding(&rle.values, "RLE values")?; + let lengths = Self::require_encoding(&rle.run_lengths, "RLE run lengths")?; + Self::validate_block_encoding(values, "RLE values")?; + Self::validate_block_encoding(lengths, "RLE run lengths")?; + Ok(2) + } + Compression::General(general) => { + let compression = general.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + "Sparse general compression is missing its buffer compression".into(), + ) + })?; + Self::validate_buffer_compression(Some(compression), "general")?; + Self::validate_value_encoding(Self::require_encoding( + &general.values, + "general values", + )?) + } + Compression::Constant(_) + | Compression::OutOfLineBitpacking(_) + | Compression::Dictionary(_) + | Compression::VariablePackedStruct(_) => Err(Error::invalid_input_source( + "Sparse value compression uses an unsupported mini-block encoding".into(), + )), + } + } + + fn validate_fsl_value_encoding(fsl: &pb21::FixedSizeList) -> Result { + use pb21::compressive_encoding::Compression; + + if fsl.items_per_value == 0 { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list value compression has dimension zero".into(), + )); + } + let values = Self::require_encoding(&fsl.values, "fixed-size-list values")?; + let child_buffers = match values.compression.as_ref() { + Some(Compression::Flat(flat)) => { + Self::validate_flat(flat, "fixed-size-list values")?; + 1_u64 + } + Some(Compression::FixedSizeList(inner)) => Self::validate_fsl_value_encoding(inner)?, + _ => { + return Err(Error::invalid_input_source( + "Sparse fixed-size-list values must use fixed-size-list or flat compression" + .into(), + )); + } + }; + child_buffers + .checked_add(u64::from(fsl.has_validity)) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse fixed-size-list value buffer count overflows".into(), + ) + }) + } + + fn validate_packed_value_encoding(packed: &pb21::PackedStruct) -> Result { + use pb21::compressive_encoding::Compression; + + if packed.bits_per_value.is_empty() + || packed + .bits_per_value + .iter() + .any(|bits| *bits == 0 || !bits.is_multiple_of(8)) + { + return Err(Error::invalid_input_source( + "Sparse packed-struct widths must be non-empty positive byte widths".into(), + )); + } + let values = Self::require_encoding(&packed.values, "packed-struct values")?; + let Some(Compression::Flat(flat)) = values.compression.as_ref() else { + return Err(Error::invalid_input_source( + "Sparse packed-struct values must use flat compression".into(), + )); + }; + Self::validate_flat(flat, "packed-struct values")?; + let total_bits = packed.bits_per_value.iter().try_fold(0_u64, |sum, bits| { + sum.checked_add(*bits).ok_or_else(|| { + Error::invalid_input_source("Sparse packed-struct bit width sum overflows".into()) + }) + })?; + if total_bits != flat.bits_per_value { + return Err(Error::invalid_input_source( + format!( + "Sparse packed-struct child widths sum to {}, but values use {} bits", + total_bits, flat.bits_per_value + ) + .into(), + )); + } + Ok(1) + } + + fn validate_block_encoding(encoding: &CompressiveEncoding, label: &str) -> Result<()> { + use pb21::compressive_encoding::Compression; + + match encoding.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} is missing compression details").into(), + ) + })? { + Compression::Flat(flat) => Self::validate_flat(flat, label), + Compression::InlineBitpacking(bitpacking) => { + if !matches!(bitpacking.uncompressed_bits_per_value, 8 | 16 | 32 | 64) { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} inline bitpacking width {} is unsupported", + bitpacking.uncompressed_bits_per_value + ) + .into(), + )); + } + if bitpacking.values.is_some() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} uses unsupported leaf buffer compression" + ) + .into(), + )); + } + Ok(()) + } + Compression::OutOfLineBitpacking(bitpacking) => { + if !matches!(bitpacking.uncompressed_bits_per_value, 8 | 16 | 32 | 64) { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} out-of-line bitpacking width {} is unsupported", + bitpacking.uncompressed_bits_per_value + ) + .into(), + )); + } + let values = Self::require_encoding(&bitpacking.values, label)?; + let Some(Compression::Flat(flat)) = values.compression.as_ref() else { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} bitpacked values must be flat").into(), + )); + }; + Self::validate_flat(flat, label) + } + Compression::Constant(constant) => { + if constant + .value + .as_ref() + .is_some_and(|value| value.len() != 8) + { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} constant must be 64 bits").into(), + )); + } + Ok(()) + } + Compression::General(general) => { + let compression = general.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} general compression is missing config") + .into(), + ) + })?; + Self::validate_buffer_compression(Some(compression), label)?; + Self::validate_block_encoding( + Self::require_encoding(&general.values, label)?, + label, + ) + } + Compression::Rle(rle) => { + Self::validate_block_encoding( + Self::require_encoding(&rle.values, "RLE values")?, + "RLE values", + )?; + Self::validate_block_encoding( + Self::require_encoding(&rle.run_lengths, "RLE run lengths")?, + "RLE run lengths", + ) + } + _ => Err(Error::invalid_input_source( + format!("Sparse structural {label} uses an unsupported block encoding").into(), + )), + } + } + + fn validate_domain_chain( + layers: &[pb21::SparseStructuralLayer], + row_domain: u64, + num_items: u64, + num_visible_items: u64, + ) -> Result<()> { + let expected_num_items = + layers + .iter() + .try_fold(num_visible_items, |items, layer| -> Result { + let additional = match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::List(layer) => { + let positions = Self::require_position_set( + &layer.non_empty_positions, + "list non-empty", + )?; + let num_non_empty = Self::position_cardinality( + positions, + layer.num_slots, + "list non-empty positions", + )?; + layer.num_slots.checked_sub(num_non_empty).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list has more non-empty positions than slots" + .into(), + ) + })? + } + pb21::sparse_structural_layer::Layer::Validity(_) + | pb21::sparse_structural_layer::Layer::FixedSizeList(_) => 0, + }; + items.checked_add(additional).ok_or_else(|| { + Error::invalid_input_source("Sparse structural item count overflows".into()) + }) + })?; + if num_items != expected_num_items { + return Err(Error::invalid_input_source( + format!( + "Sparse layout has {} structural items, but its layers imply {}", + num_items, expected_num_items + ) + .into(), + )); + } + let mut expected_slots = row_domain; + for (layer_index, layer) in layers.iter().enumerate() { + let num_slots = Self::layer_num_slots(layer)?; + let num_child_slots = Self::layer_num_child_slots(layer)?; + usize_from_u64(num_slots, "layer slot count")?; + usize_from_u64(num_child_slots, "layer child slot count")?; + if num_slots != expected_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural layer {} has {} slots, expected {} from the outer domain", + layer_index, num_slots, expected_slots + ) + .into(), + )); + } + expected_slots = num_child_slots; + } + if expected_slots != num_visible_items { + return Err(Error::invalid_input_source( + format!( + "Sparse structural terminal domain has {} slots, expected {} visible items", + expected_slots, num_visible_items + ) + .into(), + )); + } + Ok(()) + } + + fn metadata_buffer(&self) -> Result<(u64, u64)> { + self.buffer_offsets_and_sizes + .first() + .copied() + .ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing metadata buffer".into()) + }) + } + + fn value_buffer(&self) -> Result<(u64, u64)> { + self.buffer_offsets_and_sizes + .get(1) + .copied() + .ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing value buffer".into()) + }) + } + + fn checked_buffer_range(position: u64, size: u64, label: &str) -> Result> { + let end = position.checked_add(size).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} buffer range overflows").into(), + ) + })?; + Ok(position..end) + } + + fn validate_page_buffers( + buffer_offsets_and_sizes: &[(u64, u64)], + num_visible_items: u64, + ) -> Result<()> { + let (_, metadata_size) = buffer_offsets_and_sizes.first().copied().ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing metadata buffer".into()) + })?; + if !metadata_size.is_multiple_of(8) { + return Err(Error::invalid_input_source( + format!( + "Sparse layout metadata buffer has {metadata_size} bytes, which is not a multiple of 8" + ) + .into(), + )); + } + let chunk_count = metadata_size / 8; + if num_visible_items == 0 { + if chunk_count != 0 { + return Err(Error::invalid_input_source( + "Sparse layout with no visible items must have empty chunk metadata".into(), + )); + } + } else { + let min_chunks = + num_visible_items.div_ceil(miniblock::MAX_CONFIGURABLE_MINIBLOCK_VALUES); + if chunk_count < min_chunks || chunk_count > num_visible_items { + return Err(Error::invalid_input_source( + format!( + "Sparse layout metadata declares {chunk_count} chunks for {num_visible_items} visible items, expected {min_chunks}..={num_visible_items}" + ) + .into(), + )); + } + } + + let (_, value_size) = buffer_offsets_and_sizes.get(1).copied().ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing value buffer".into()) + })?; + if (num_visible_items == 0) != (value_size == 0) { + return Err(Error::invalid_input_source( + format!( + "Sparse layout value buffer has {value_size} bytes for {num_visible_items} visible items" + ) + .into(), + )); + } + + for (position, size) in buffer_offsets_and_sizes.iter().skip(2) { + Self::checked_buffer_range(*position, *size, "structural")?; + } + + for (position, size) in buffer_offsets_and_sizes.iter().take(2) { + Self::checked_buffer_range(*position, *size, "page")?; + } + Ok(()) + } + + fn require_position_set<'a>( + set: &'a Option, + label: &str, + ) -> Result<&'a pb21::SparsePositionSet> { + set.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} position set is required").into(), + ) + }) + } + + fn require_count_set<'a>( + set: &'a Option, + label: &str, + ) -> Result<&'a pb21::SparseCountSet> { + set.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} count set is required").into(), + ) + }) + } + + fn require_validity_set<'a>( + set: &'a Option, + label: &str, + ) -> Result<&'a pb21::SparseValiditySet> { + set.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} validity set is required").into(), + ) + }) + } + + fn validity_meaning( + validity_set: &pb21::SparseValiditySet, + label: &str, + ) -> Result { + match invalid_enum( + pb21::sparse_validity_set::Meaning::try_from(validity_set.meaning), + "validity meaning", + )? { + pb21::sparse_validity_set::Meaning::SparseValidityUnspecified => { + Err(Error::invalid_input_source( + format!("Sparse structural {label} meaning is unspecified").into(), + )) + } + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions => { + Ok(SparseValidityMeaning::NullPositions) + } + pb21::sparse_validity_set::Meaning::SparseValidityValidPositions => { + Ok(SparseValidityMeaning::ValidPositions) + } + } + } + + fn validity_buffer_count( + validity_set: &pb21::SparseValiditySet, + domain_len: u64, + label: &str, + ) -> Result<(usize, SparseValidityMeaning, u64)> { + let meaning = Self::validity_meaning(validity_set, label)?; + let position_set = validity_set.positions.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} positions are required").into(), + ) + })?; + let cardinality = Self::position_cardinality(position_set, domain_len, label)?; + let buffer_count = Self::position_buffer_count(position_set, domain_len, label)?; + Ok((buffer_count, meaning, cardinality)) + } + + fn position_cardinality( + position_set: &pb21::SparsePositionSet, + domain_len: u64, + label: &str, + ) -> Result { + let positions = position_set.positions.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} position set is missing its variant").into(), + ) + })?; + let cardinality = position_set.num_positions; + if cardinality > domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} cardinality {} exceeds domain {}", + cardinality, domain_len + ) + .into(), + )); + } + match positions { + pb21::sparse_position_set::Positions::Empty(_) => { + if cardinality != 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} empty set has cardinality {}", + cardinality + ) + .into(), + )); + } + } + pb21::sparse_position_set::Positions::All(_) => { + if domain_len == 0 || cardinality != domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} all set has cardinality {}, expected {}", + cardinality, domain_len + ) + .into(), + )); + } + } + pb21::sparse_position_set::Positions::Range(range) => { + let end = range.start.checked_add(range.length).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} range overflows").into(), + ) + })?; + if range.length == 0 || range.length != cardinality || end > domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} range {}..{} does not match cardinality {} in domain {}", + range.start, end, cardinality, domain_len + ) + .into(), + )); + } + } + pb21::sparse_position_set::Positions::Explicit(compression) => { + if cardinality == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} has compression but no values").into(), + )); + } + Self::validate_compression(compression, label)?; + } + } + Ok(cardinality) + } + + fn position_buffer_count( + position_set: &pb21::SparsePositionSet, + domain_len: u64, + label: &str, + ) -> Result { + Self::position_cardinality(position_set, domain_len, label)?; + let positions = position_set.positions.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} position set is missing its variant").into(), + ) + })?; + Ok(usize::from(matches!( + positions, + pb21::sparse_position_set::Positions::Explicit(_) + ))) + } + + fn count_buffer_count( + count_set: &pb21::SparseCountSet, + cardinality: u64, + label: &str, + ) -> Result { + let counts = count_set.counts.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} count set is missing its variant").into(), + ) + })?; + match counts { + pb21::sparse_count_set::Counts::Empty(_) => { + if cardinality != 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} empty count set has cardinality {}", + cardinality + ) + .into(), + )); + } + Ok(0) + } + pb21::sparse_count_set::Counts::Constant(constant) => { + if cardinality == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} constant count has no values").into(), + )); + } + if constant.value == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} constant count is zero").into(), + )); + } + Ok(0) + } + pb21::sparse_count_set::Counts::Explicit(compression) => { + if cardinality == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} has compression but no values").into(), + )); + } + Self::validate_compression(compression, label)?; + Ok(1) + } + } + } + + fn count_set_child_slots( + count_set: &pb21::SparseCountSet, + cardinality: u64, + label: &str, + ) -> Result> { + let counts = count_set.counts.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} count set is missing its variant").into(), + ) + })?; + match counts { + pb21::sparse_count_set::Counts::Empty(_) => Ok(Some(0)), + pb21::sparse_count_set::Counts::Constant(constant) => constant + .value + .checked_mul(cardinality) + .map(Some) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} constant count sum overflows: value={}, len={}", + constant.value, cardinality + ) + .into(), + ) + }), + pb21::sparse_count_set::Counts::Explicit(_) => Ok(None), + } + } + + fn create_position_decompressor( + compression: &CompressiveEncoding, + label: &str, + decompressors: &dyn DecompressionStrategy, + ) -> Result> { + let compression = Self::validate_compression(compression, label)?; + Self::validate_block_encoding(compression, label)?; + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + decompressors.create_block_decompressor(compression) + })) + .map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse structural {label} descriptor caused decompressor construction to panic" + ) + .into(), + ) + })? + .map(Arc::from) + .map_err(|error| { + Error::invalid_input_source( + format!("Sparse structural {label} decompressor construction failed: {error}") + .into(), + ) + }) + } + + fn position_set_decoder( + position_set: &pb21::SparsePositionSet, + domain_len: u64, + label: &str, + decompressors: &dyn DecompressionStrategy, + ) -> Result<(SparsePositionSetDecoder, u64)> { + let cardinality = Self::position_cardinality(position_set, domain_len, label)?; + let positions = position_set.positions.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} position set is missing its variant").into(), + ) + })?; + Ok(( + match positions { + pb21::sparse_position_set::Positions::Empty(_) => SparsePositionSetDecoder::Empty, + pb21::sparse_position_set::Positions::All(_) => { + SparsePositionSetDecoder::All { len: domain_len } + } + pb21::sparse_position_set::Positions::Range(range) => { + SparsePositionSetDecoder::Range { + start: range.start, + len: range.length, + } + } + pb21::sparse_position_set::Positions::Explicit(compression) => { + SparsePositionSetDecoder::Explicit { + decompressor: Self::create_position_decompressor( + compression, + label, + decompressors, + )?, + encoding: compression.clone(), + count: cardinality, + domain_len, + } + } + }, + cardinality, + )) + } + + fn validity_set_decoder( + validity_set: &pb21::SparseValiditySet, + domain_len: u64, + label: &str, + decompressors: &dyn DecompressionStrategy, + ) -> Result<(SparseValiditySetDecoder, u64)> { + let meaning = Self::validity_meaning(validity_set, label)?; + let position_set = validity_set.positions.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} positions are required").into(), + ) + })?; + let (positions, cardinality) = + Self::position_set_decoder(position_set, domain_len, label, decompressors)?; + Ok((SparseValiditySetDecoder { meaning, positions }, cardinality)) + } + + fn num_valid_slots( + meaning: SparseValidityMeaning, + cardinality: u64, + num_slots: u64, + label: &str, + ) -> Result { + match meaning { + SparseValidityMeaning::NullPositions => { + num_slots.checked_sub(cardinality).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} null cardinality {} exceeds slots {}", + cardinality, num_slots + ) + .into(), + ) + }) + } + SparseValidityMeaning::ValidPositions => Ok(cardinality), + } + } + + fn count_set_decoder( + count_set: &pb21::SparseCountSet, + cardinality: u64, + label: &str, + decompressors: &dyn DecompressionStrategy, + ) -> Result { + Self::count_buffer_count(count_set, cardinality, label)?; + let counts = count_set.counts.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} count set is missing its variant").into(), + ) + })?; + Ok(match counts { + pb21::sparse_count_set::Counts::Empty(_) => SparseCountSetDecoder::Empty, + pb21::sparse_count_set::Counts::Constant(constant) => SparseCountSetDecoder::Constant { + value: constant.value, + len: cardinality, + }, + pb21::sparse_count_set::Counts::Explicit(compression) => { + SparseCountSetDecoder::Explicit { + decompressor: Self::create_position_decompressor( + compression, + label, + decompressors, + )?, + encoding: compression.clone(), + count: cardinality, + } + } + }) + } + + fn add_buffer_count(count: &mut usize, additional: usize) -> Result<()> { + *count = count.checked_add(additional).ok_or_else(|| { + Error::invalid_input_source("Sparse structural buffer count overflows".into()) + })?; + Ok(()) + } + + fn structural_buffer_count(layers: &[pb21::SparseStructuralLayer]) -> Result { + layers + .iter() + .try_fold(0_usize, |mut count, layer| -> Result { + match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::Validity(layer) => { + let (validity_buffers, _, _) = Self::validity_buffer_count( + Self::require_validity_set(&layer.validity, "validity")?, + layer.num_slots, + "validity positions", + )?; + Self::add_buffer_count(&mut count, validity_buffers)?; + } + pb21::sparse_structural_layer::Layer::List(layer) => { + let non_empty_positions = Self::require_position_set( + &layer.non_empty_positions, + "list non-empty", + )?; + let num_non_empty = Self::position_cardinality( + non_empty_positions, + layer.num_slots, + "list non-empty positions", + )?; + let non_empty_buffers = Self::position_buffer_count( + non_empty_positions, + layer.num_slots, + "list non-empty positions", + )?; + Self::add_buffer_count(&mut count, non_empty_buffers)?; + let (validity_buffers, validity_meaning, validity_cardinality) = + Self::validity_buffer_count( + Self::require_validity_set(&layer.validity, "list")?, + layer.num_slots, + "list validity positions", + )?; + Self::add_buffer_count(&mut count, validity_buffers)?; + let num_valid_slots = Self::num_valid_slots( + validity_meaning, + validity_cardinality, + layer.num_slots, + "list validity", + )?; + if num_non_empty > num_valid_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list has {} non-empty slots but only {} valid slots", + num_non_empty, num_valid_slots + ) + .into(), + )); + } + let counts = Self::require_count_set(&layer.counts, "list counts")?; + let count_buffers = + Self::count_buffer_count(counts, num_non_empty, "list counts")?; + Self::add_buffer_count(&mut count, count_buffers)?; + if let Some(child_slots) = + Self::count_set_child_slots(counts, num_non_empty, "list counts")? + && child_slots != layer.num_child_slots + { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list count sum {} does not match child slots {}", + child_slots, layer.num_child_slots + ) + .into(), + )); + } + } + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => { + if layer.dimension == 0 { + return Err(Error::invalid_input_source( + "Sparse structural fixed-size-list dimension is zero".into(), + )); + } + layer.num_slots.checked_mul(layer.dimension).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count overflows: slots={}, dimension={}", + layer.num_slots, layer.dimension + ) + .into(), + ) + })?; + let (validity_buffers, _, _) = Self::validity_buffer_count( + Self::require_validity_set(&layer.validity, "fixed-size-list")?, + layer.num_slots, + "fixed-size-list validity positions", + )?; + Self::add_buffer_count(&mut count, validity_buffers)?; + } + } + Ok(count) + }) + } + + fn layer_decompressors( + layer: &pb21::SparseStructuralLayer, + decompressors: &dyn DecompressionStrategy, + ) -> Result { + Ok(match Self::require_layer(layer)? { + pb21::sparse_structural_layer::Layer::Validity(layer) => { + let (validity, _) = Self::validity_set_decoder( + Self::require_validity_set(&layer.validity, "validity")?, + layer.num_slots, + "validity positions", + decompressors, + )?; + SparseLayerDecompressors::Validity { + num_slots: layer.num_slots, + validity, + } + } + pb21::sparse_structural_layer::Layer::List(layer) => { + let non_empty_positions = + Self::require_position_set(&layer.non_empty_positions, "list non-empty")?; + let (non_empty_positions, num_non_empty) = Self::position_set_decoder( + non_empty_positions, + layer.num_slots, + "list non-empty positions", + decompressors, + )?; + let counts = Self::count_set_decoder( + Self::require_count_set(&layer.counts, "list counts")?, + num_non_empty, + "list counts", + decompressors, + )?; + let (validity, _) = Self::validity_set_decoder( + Self::require_validity_set(&layer.validity, "list")?, + layer.num_slots, + "list validity positions", + decompressors, + )?; + SparseLayerDecompressors::List { + num_slots: layer.num_slots, + num_child_slots: layer.num_child_slots, + non_empty_positions, + counts, + validity, + } + } + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => { + let (validity, _) = Self::validity_set_decoder( + Self::require_validity_set(&layer.validity, "fixed-size-list")?, + layer.num_slots, + "fixed-size-list validity positions", + decompressors, + )?; + SparseLayerDecompressors::FixedSizeList { + num_slots: layer.num_slots, + num_child_slots: layer.num_slots.checked_mul(layer.dimension).ok_or_else( + || { + Error::invalid_input_source( + "Sparse structural fixed-size-list child slot count overflows" + .into(), + ) + }, + )?, + dimension: layer.dimension, + validity, + } + } + }) + } + + fn parse_chunk_meta(&self, meta_bytes: Bytes) -> Result> { + if !meta_bytes.len().is_multiple_of(8) { + return Err(Error::invalid_input_source( + format!( + "Sparse layout metadata length {} is not a multiple of 8", + meta_bytes.len() + ) + .into(), + )); + } + + let (value_buf_position, value_buf_size) = self.value_buffer()?; + let value_buf_end = value_buf_position + .checked_add(value_buf_size) + .ok_or_else(|| { + Error::invalid_input_source("Sparse layout value buffer range overflows".into()) + })?; + let mut rows_counter = 0_u64; + let mut offset_bytes = value_buf_position; + let mut chunk_meta = Vec::with_capacity(meta_bytes.len() / 8); + for entry in meta_bytes.as_chunks::<8>().0 { + let divided_bytes_minus_one = u32::from_le_bytes( + entry + .get(..4) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse layout chunk byte-size field is malformed".into(), + ) + })?, + ); + let num_values = u64::from(u32::from_le_bytes( + entry + .get(4..) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse layout chunk value-count field is malformed".into(), + ) + })?, + )); + if num_values == 0 { + return Err(Error::invalid_input_source( + "Sparse layout contains an empty value chunk".into(), + )); + } + if num_values > miniblock::MAX_CONFIGURABLE_MINIBLOCK_VALUES { + return Err(Error::invalid_input_source( + format!( + "Sparse layout value chunk has {} values, exceeding the mini-block limit {}", + num_values, + miniblock::MAX_CONFIGURABLE_MINIBLOCK_VALUES + ) + .into(), + )); + } + let num_bytes = u64::from(divided_bytes_minus_one) + .checked_add(1) + .and_then(|units| units.checked_mul(MINIBLOCK_ALIGNMENT as u64)) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse layout value chunk byte size overflows".into(), + ) + })?; + rows_counter = rows_counter.checked_add(num_values).ok_or_else(|| { + Error::invalid_input_source("Sparse layout visible item count overflows".into()) + })?; + chunk_meta.push(ChunkMeta { + num_values, + chunk_size_bytes: num_bytes, + offset_bytes, + }); + offset_bytes = offset_bytes.checked_add(num_bytes).ok_or_else(|| { + Error::invalid_input_source("Sparse layout value chunk byte range overflows".into()) + })?; + } + if rows_counter != self.num_visible_items { + return Err(Error::invalid_input_source( + format!( + "Sparse layout visible item count mismatch: metadata has {}, layout has {}", + rows_counter, self.num_visible_items + ) + .into(), + )); + } + if offset_bytes != value_buf_end { + return Err(Error::invalid_input_source( + format!( + "Sparse layout chunk metadata describes {} value bytes, but value buffer has {} bytes", + offset_bytes - value_buf_position, + value_buf_size + ) + .into(), + )); + } + Ok(chunk_meta) + } + + fn validate_general_buffer_header( + general: &pb21::General, + data: &[u8], + label: &str, + ) -> Result<()> { + let compression = general.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} general compression is missing config").into(), + ) + })?; + Self::validate_buffer_compression(Some(compression), label)?; + let values = Self::require_encoding(&general.values, label)?; + if Self::encoding_contains_general(values) { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} contains nested general compression, which is unsupported" + ) + .into(), + )); + } + + let scheme = invalid_enum( + pb21::CompressionScheme::try_from(compression.scheme), + "compression scheme", + )?; + match scheme { + pb21::CompressionScheme::CompressionAlgorithmLz4 => { + data.get(..4).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} LZ4 buffer is missing its length prefix" + ) + .into(), + ) + })?; + } + pb21::CompressionScheme::CompressionAlgorithmZstd => { + data.get(..8).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} Zstd buffer is missing its length prefix" + ) + .into(), + ) + })?; + } + pb21::CompressionScheme::CompressionAlgorithmUnspecified => { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} general compression scheme is unspecified") + .into(), + )); + } + } + Ok(()) + } + + fn validate_general_child_buffer( + encoding: &CompressiveEncoding, + data: &[u8], + label: &str, + ) -> Result<()> { + if let Some(pb21::compressive_encoding::Compression::General(general)) = + encoding.compression.as_ref() + { + Self::validate_general_buffer_header(general, data, label)?; + } + Ok(()) + } + + fn validate_structural_buffer_headers( + encoding: &CompressiveEncoding, + data: &[u8], + label: &str, + ) -> Result<()> { + use pb21::compressive_encoding::Compression; + + match encoding.compression.as_ref() { + Some(Compression::General(general)) => { + Self::validate_general_buffer_header(general, data, label) + } + Some(Compression::Rle(rle)) => { + let values_size = u64::from_le_bytes( + data.get(..8) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} RLE buffer is missing its header" + ) + .into(), + ) + })? + .try_into() + .map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} RLE header is malformed").into(), + ) + })?, + ); + let values_size = usize_from_u64(values_size, "RLE values buffer size")?; + let values_end = 8_usize.checked_add(values_size).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} RLE values range overflows").into(), + ) + })?; + let values_data = data.get(8..values_end).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} RLE values buffer is truncated").into(), + ) + })?; + let lengths_data = data.get(values_end..).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} RLE run-length buffer is missing") + .into(), + ) + })?; + Self::validate_general_child_buffer( + Self::require_encoding(&rle.values, "RLE values")?, + values_data, + "RLE values", + )?; + Self::validate_general_child_buffer( + Self::require_encoding(&rle.run_lengths, "RLE run lengths")?, + lengths_data, + "RLE run lengths", + ) + } + _ => Ok(()), + } + } + + fn decode_u64_values( + decompressor: &dyn BlockDecompressor, + encoding: &CompressiveEncoding, + data: Bytes, + num_values: u64, + label: &str, + ) -> Result> { + Self::validate_structural_buffer_headers(encoding, &data, label)?; + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + decompressor.decompress(Some(LanceBuffer::from_bytes(data, 1)), num_values) + })) + .map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} decompression panicked").into(), + ) + })? + .map_err(|error| { + Error::invalid_input_source( + format!("Sparse structural {label} decompression failed: {error}").into(), + ) + })?; + let fixed = decoded.as_fixed_width().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} did not decode to fixed width data").into(), + ) + })?; + if fixed.bits_per_value != 64 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} decoded to {} bits per value, expected 64", + fixed.bits_per_value + ) + .into(), + )); + } + if fixed.num_values != num_values { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} decoded {} values, expected {}", + fixed.num_values, num_values + ) + .into(), + )); + } + let num_values_usize = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} value count exceeds usize::MAX").into(), + ) + })?; + let expected_len = num_values_usize + .checked_mul(std::mem::size_of::()) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} decoded byte length overflows").into(), + ) + })?; + if fixed.data.len() != expected_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} decoded {} bytes, expected {}", + fixed.data.len(), + expected_len + ) + .into(), + )); + } + let values = fixed.data.borrow_to_typed_slice::(); + if values.len() != num_values_usize { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} decoded {} u64 values, expected {}", + values.len(), + num_values + ) + .into(), + )); + } + Ok(values.to_vec()) + } + + fn decode_explicit_positions( + decompressor: &Arc, + encoding: &CompressiveEncoding, + data: Bytes, + num_positions: u64, + num_slots: u64, + label: &str, + ) -> Result { + let deltas = + Self::decode_u64_values(decompressor.as_ref(), encoding, data, num_positions, label)?; + let mut positions = Vec::with_capacity(deltas.len()); + let mut current = 0_u64; + for (idx, delta) in deltas.into_iter().enumerate() { + if idx == 0 { + current = delta; + } else { + if delta == 0 { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} positions must be strictly increasing") + .into(), + )); + } + current = current.checked_add(delta).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} position overflow").into(), + ) + })?; + } + if current >= num_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} position {} is outside layer with {} slots", + current, num_slots + ) + .into(), + )); + } + positions.push(current); + } + SparsePositionSet::from_positions(positions, num_slots, label) + } + + fn decode_position_set( + decoder: &SparsePositionSetDecoder, + buffers: &mut impl Iterator, + label: &str, + ) -> Result { + match decoder { + SparsePositionSetDecoder::Empty => Ok(SparsePositionSet::empty()), + SparsePositionSetDecoder::All { len } => Ok(SparsePositionSet::all(*len)), + SparsePositionSetDecoder::Range { start, len } => { + Ok(SparsePositionSet::range(*start, *len)) + } + SparsePositionSetDecoder::Explicit { + decompressor, + encoding, + count, + domain_len, + } => Self::decode_explicit_positions( + decompressor, + encoding, + Self::next_structural_buffer(buffers, label)?, + *count, + *domain_len, + label, + ), + } + } + + fn decode_validity_set( + decoder: &SparseValiditySetDecoder, + buffers: &mut impl Iterator, + label: &str, + ) -> Result { + Ok(SparseValiditySet { + meaning: decoder.meaning, + positions: Self::decode_position_set(&decoder.positions, buffers, label)?, + }) + } + + fn decode_count_set( + decoder: &SparseCountSetDecoder, + buffers: &mut impl Iterator, + label: &str, + ) -> Result { + match decoder { + SparseCountSetDecoder::Empty => Ok(SparseCountSet::Empty), + SparseCountSetDecoder::Constant { value, len } => { + Ok(SparseCountSet::constant(*value, *len)) + } + SparseCountSetDecoder::Explicit { + decompressor, + encoding, + count, + } => { + let counts = Self::decode_u64_values( + decompressor.as_ref(), + encoding, + Self::next_structural_buffer(buffers, label)?, + *count, + label, + )?; + SparseCountSet::from_counts(counts) + } + } + } + + fn next_structural_buffer( + buffers: &mut impl Iterator, + label: &str, + ) -> Result { + buffers.next().ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} is missing its buffer").into(), + ) + }) + } + + fn decode_layer( + layer: &SparseLayerDecompressors, + buffers: &mut impl Iterator, + ) -> Result { + Ok(match layer { + SparseLayerDecompressors::Validity { + num_slots, + validity, + } => SparseStructuralLayerPlan::Validity { + num_slots: *num_slots, + validity: Self::decode_validity_set(validity, buffers, "validity positions")?, + }, + SparseLayerDecompressors::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + } => { + let non_empty_positions = Self::decode_position_set( + non_empty_positions, + buffers, + "list non-empty positions", + )?; + let counts = Self::decode_count_set(counts, buffers, "list counts")?; + let validity = + Self::decode_validity_set(validity, buffers, "list validity positions")?; + let actual_child_slots = counts.sum()?; + if actual_child_slots != *num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list count sum {} does not match declared child slots {}", + actual_child_slots, num_child_slots + ) + .into(), + )); + } + SparseStructuralLayerPlan::List { + num_slots: *num_slots, + num_child_slots: *num_child_slots, + non_empty_positions, + counts, + validity, + } + } + SparseLayerDecompressors::FixedSizeList { + num_slots, + num_child_slots, + dimension, + validity, + } => { + let expected_child_slots = num_slots.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count overflows: slots={}, dimension={}", + num_slots, dimension + ) + .into(), + ) + })?; + if expected_child_slots != *num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count {} does not match slots {} * dimension {}", + num_child_slots, num_slots, dimension + ) + .into(), + )); + } + SparseStructuralLayerPlan::FixedSizeList { + num_slots: *num_slots, + dimension: *dimension, + validity: Self::decode_validity_set( + validity, + buffers, + "fixed-size-list validity positions", + )?, + } + } + }) + } + + fn lookup_value_chunks(&self, chunk_indices: &[usize]) -> Result> { + let page_meta = self.page_meta.as_ref().ok_or_else(|| { + Error::internal("Sparse page scheduler has not been initialized".to_string()) + })?; + chunk_indices + .iter() + .map(|&chunk_idx| { + let chunk_meta = page_meta.chunk_meta.get(chunk_idx).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse layout missing value chunk metadata for chunk {chunk_idx}") + .into(), + ) + })?; + let bytes_start = chunk_meta.offset_bytes; + let bytes_end = bytes_start + .checked_add(chunk_meta.chunk_size_bytes) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse layout value chunk {} byte range overflows", + chunk_idx + ) + .into(), + ) + })?; + Ok(LoadedChunk { + byte_range: bytes_start..bytes_end, + items_in_chunk: chunk_meta.num_values, + chunk_idx, + data: LanceBuffer::empty(), + }) + }) + .collect() + } + + fn value_chunk_index(chunk_value_offsets: &[u64], value: u64) -> Result { + let total_values = chunk_value_offsets.last().copied().ok_or_else(|| { + Error::invalid_input_source("Sparse layout has no value chunk offsets".into()) + })?; + if chunk_value_offsets.len() < 2 || value >= total_values { + return Err(Error::invalid_input_source( + format!( + "Sparse layout value index {} is outside {} visible items", + value, total_values + ) + .into(), + )); + } + chunk_value_offsets + .partition_point(|&offset| offset <= value) + .checked_sub(1) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse layout value index {value} is before the first chunk").into(), + ) + }) + } + + fn value_chunk_range( + chunk_value_offsets: &[u64], + value_range: Range, + ) -> Result> { + if value_range.is_empty() { + return Ok(0..0); + } + let total_values = chunk_value_offsets.last().copied().ok_or_else(|| { + Error::invalid_input_source("Sparse layout has no value chunk offsets".into()) + })?; + if value_range.start > value_range.end || value_range.end > total_values { + return Err(Error::invalid_input_source( + format!( + "Sparse layout value range {}..{} is outside {} visible items", + value_range.start, value_range.end, total_values + ) + .into(), + )); + } + let start = Self::value_chunk_index(chunk_value_offsets, value_range.start)?; + let end = chunk_value_offsets + .partition_point(|&offset| offset < value_range.end) + .max(start + 1); + Ok(start..end) + } +} + +impl StructuralPageScheduler for SparseStructuralScheduler { + fn init_layout(&self) -> Result { + let (meta_buf_position, meta_buf_size) = self.metadata_buffer()?; + let mut buffer_ranges = Vec::with_capacity(self.buffer_offsets_and_sizes.len() - 1); + buffer_ranges.push(Some(Self::checked_buffer_range( + meta_buf_position, + meta_buf_size, + "metadata", + )?)); + for (position, size) in self.buffer_offsets_and_sizes.iter().skip(2) { + buffer_ranges.push(Some(Self::checked_buffer_range( + *position, + *size, + "structural", + )?)); + } + Ok(PageInitialization::new(buffer_ranges)) + } + + fn init_from_buffers<'a>( + &'a mut self, + buffers: PageInitializationBuffers, + _io: &Arc, + ) -> BoxFuture<'a, Result>> { + async move { + let mut buffers = buffers.into_required("sparse")?.into_iter(); + let meta_bytes = buffers.next().ok_or_else(|| { + Error::invalid_input_source("Sparse layout is missing chunk metadata buffer".into()) + })?; + + let chunk_meta = self.parse_chunk_meta(meta_bytes)?; + let mut chunk_value_offsets = Vec::with_capacity(chunk_meta.len() + 1); + let mut value_offset = 0_u64; + chunk_value_offsets.push(value_offset); + for chunk in &chunk_meta { + value_offset = value_offset.checked_add(chunk.num_values).ok_or_else(|| { + Error::invalid_input_source( + "Sparse layout visible item offset overflows".into(), + ) + })?; + chunk_value_offsets.push(value_offset); + } + + let layers = self + .layer_decompressors + .iter() + .map(|layer| Self::decode_layer(layer, &mut buffers)) + .collect::>>()?; + let plan = SparseStructuralPlan { + layers, + num_items: self.num_items, + num_visible_items: self.num_visible_items, + }; + plan.validate(self.row_domain)?; + if buffers.next().is_some() { + return Err(Error::invalid_input_source( + "Sparse layout has unused structural buffers".into(), + )); + } + + let page_meta = Arc::new(SparseStructuralCacheableState { + chunk_meta, + chunk_value_offsets: chunk_value_offsets.into(), + plan, + row_domain: self.row_domain, + }); + Ok(page_meta as Arc) + } + .boxed() + } + + fn try_load(&mut self, data: &Arc) -> Result<()> { + self.page_meta = Some(data.clone().as_arc_any().downcast().map_err(|_| { + Error::invalid_input_source("Cached sparse page data has an unexpected type".into()) + })?); + Ok(()) + } + + fn schedule_ranges( + &self, + ranges: &[Range], + io: &Arc, + ) -> Result> { + let page_meta = self.page_meta.as_ref().ok_or_else(|| { + Error::internal("Sparse page scheduler has not been initialized".to_string()) + })?; + let encoded_row_domain = page_meta + .row_domain + .checked_mul(self.row_scale) + .ok_or_else(|| { + Error::invalid_input_source("Sparse structural encoded row domain overflows".into()) + })?; + let num_rows = validate_slice_ranges(ranges, encoded_row_domain, "row")?; + let ranges = ranges + .iter() + .map(|range| { + if !range.start.is_multiple_of(self.row_scale) + || !range.end.is_multiple_of(self.row_scale) + { + return Err(Error::invalid_input_source( + format!( + "Sparse structural encoded row range {}..{} is not aligned to fixed-size-list scale {}", + range.start, range.end, self.row_scale + ) + .into(), + )); + } + Ok((range.start / self.row_scale)..(range.end / self.row_scale)) + }) + .collect::>>()?; + + let mut chunks_needed = Vec::new(); + let selection = slice_sparse_plan(&page_meta.plan, &ranges, page_meta.row_domain)?; + for value_range in &selection.leaf_ranges { + chunks_needed.extend(Self::value_chunk_range( + &page_meta.chunk_value_offsets, + value_range.clone(), + )?); + } + chunks_needed.sort_unstable(); + chunks_needed.dedup(); + + let mut loaded_chunks = self.lookup_value_chunks(&chunks_needed)?; + let chunk_ranges = loaded_chunks + .iter() + .map(|chunk| chunk.byte_range.clone()) + .collect::>(); + let loaded_chunk_data = io.submit_request(chunk_ranges, self.priority); + let ranges = VecDeque::from(ranges); + let value_decompressor = self.value_decompressor.clone(); + let value_encoding = self.value_encoding.clone(); + let data_type = self.data_type.clone(); + let page_meta = page_meta.clone(); + let num_buffers = self.num_buffers; + let has_large_chunk = self.has_large_chunk; + let row_scale = self.row_scale; + + let res = async move { + let loaded_chunk_data = loaded_chunk_data.await?; + for (loaded_chunk, chunk_data) in loaded_chunks.iter_mut().zip(loaded_chunk_data) { + loaded_chunk.data = LanceBuffer::from_bytes(chunk_data, 1); + } + + Ok(Box::new(SparseStructuralDecoder { + value_decompressor, + value_encoding, + data_type, + page_meta, + loaded_chunks: Arc::new(loaded_chunks), + ranges, + offset_in_current_range: 0, + num_rows, + row_scale, + num_buffers, + has_large_chunk, + }) as Box) + } + .boxed(); + Ok(vec![PageLoadTask { + decoder_fut: res, + num_rows, + }]) + } +} + +#[derive(Debug)] +struct SparseStructuralDecoder { + value_decompressor: Arc, + value_encoding: CompressiveEncoding, + data_type: DataType, + page_meta: Arc, + loaded_chunks: Arc>, + ranges: VecDeque>, + offset_in_current_range: u64, + num_rows: u64, + row_scale: u64, + num_buffers: u64, + has_large_chunk: bool, +} + +impl SparseStructuralDecoder { + fn drain_ranges(&mut self, mut rows_desired: u64) -> Result>> { + if !rows_desired.is_multiple_of(self.row_scale) { + return Err(Error::invalid_input_source( + format!( + "Sparse page decoder drain of {} encoded rows is not aligned to fixed-size-list scale {}", + rows_desired, self.row_scale + ) + .into(), + )); + } + rows_desired /= self.row_scale; + let mut ranges = Vec::new(); + while rows_desired > 0 { + let range = self.ranges.front().ok_or_else(|| { + Error::invalid_input_source( + "Sparse page decoder was asked to drain more rows than were scheduled".into(), + ) + })?; + let start = range + .start + .checked_add(self.offset_in_current_range) + .ok_or_else(|| { + Error::invalid_input_source("Sparse page decoder row offset overflows".into()) + })?; + let rows_available = range.end.checked_sub(start).ok_or_else(|| { + Error::invalid_input_source( + "Sparse page decoder range offset exceeds the scheduled range".into(), + ) + })?; + let rows_to_take = rows_available.min(rows_desired); + let end = start.checked_add(rows_to_take).ok_or_else(|| { + Error::invalid_input_source("Sparse page decoder row range overflows".into()) + })?; + ranges.push(start..end); + rows_desired -= rows_to_take; + self.offset_in_current_range += rows_to_take; + if self.offset_in_current_range == range.end - range.start { + self.offset_in_current_range = 0; + self.ranges.pop_front(); + } + } + Ok(ranges) + } +} + +impl StructuralPageDecoder for SparseStructuralDecoder { + fn drain(&mut self, num_rows: u64) -> Result> { + Ok(Box::new(DecodeSparseStructuralTask { + row_ranges: self.drain_ranges(num_rows)?, + value_decompressor: self.value_decompressor.clone(), + value_encoding: self.value_encoding.clone(), + data_type: self.data_type.clone(), + page_meta: self.page_meta.clone(), + loaded_chunks: self.loaded_chunks.clone(), + num_buffers: self.num_buffers, + has_large_chunk: self.has_large_chunk, + })) + } + + fn num_rows(&self) -> u64 { + self.num_rows + } +} + +#[derive(Debug)] +struct DecodeSparseStructuralTask { + row_ranges: Vec>, + value_decompressor: Arc, + value_encoding: CompressiveEncoding, + data_type: DataType, + page_meta: Arc, + loaded_chunks: Arc>, + num_buffers: u64, + has_large_chunk: bool, +} + +impl DecodeSparseStructuralTask { + fn read_chunk_size( + buf: &[u8], + offset: &mut usize, + width: usize, + chunk_idx: usize, + ) -> Result { + let end = offset.checked_add(width).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural value chunk {chunk_idx} size header overflows").into(), + ) + })?; + let bytes = buf.get(*offset..end).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural value chunk {chunk_idx} has a truncated size header") + .into(), + ) + })?; + let size = match width { + 2 => u32::from(u16::from_le_bytes(bytes.try_into().map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural value chunk {chunk_idx} has a malformed u16 size") + .into(), + ) + })?)), + 4 => u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural value chunk {chunk_idx} has a malformed u32 size") + .into(), + ) + })?), + _ => { + return Err(Error::internal(format!( + "Unsupported sparse value chunk size width {width}" + ))); + } + }; + *offset = end; + Ok(size) + } + + fn expected_fixed_bytes(num_values: u64, bits_per_value: u64, label: &str) -> Result { + let bits = num_values.checked_mul(bits_per_value).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} decoded bit length overflows").into(), + ) + })?; + usize_from_u64(bits.div_ceil(8), label) + } + + fn validate_fixed_buffer( + buffer: &LanceBuffer, + num_values: u64, + bits_per_value: u64, + label: &str, + ) -> Result<()> { + let expected = Self::expected_fixed_bytes(num_values, bits_per_value, label)?; + if buffer.len() != expected { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} buffer has {} bytes, expected {}", + buffer.len(), + expected + ) + .into(), + )); + } + Ok(()) + } + + fn validate_variable_buffer( + buffer: &LanceBuffer, + num_values: u64, + bits_per_offset: u64, + ) -> Result<()> { + let width = usize_from_u64(bits_per_offset / 8, "variable offset width")?; + let offset_count = num_values.checked_add(1).ok_or_else(|| { + Error::invalid_input_source("Sparse variable offset count overflows".into()) + })?; + let table_len = usize_from_u64(offset_count, "variable offset count")? + .checked_mul(width) + .ok_or_else(|| { + Error::invalid_input_source("Sparse variable offset table size overflows".into()) + })?; + if buffer.len() < table_len { + return Err(Error::invalid_input_source( + format!( + "Sparse variable buffer has {} bytes, smaller than its {}-byte offset table", + buffer.len(), + table_len + ) + .into(), + )); + } + let mut previous = None; + for index in 0..usize_from_u64(offset_count, "variable offset count")? { + let start = index.checked_mul(width).ok_or_else(|| { + Error::invalid_input_source("Sparse variable offset index overflows".into()) + })?; + let end = start.checked_add(width).ok_or_else(|| { + Error::invalid_input_source("Sparse variable offset range overflows".into()) + })?; + let bytes = buffer.as_ref().get(start..end).ok_or_else(|| { + Error::invalid_input_source("Sparse variable offset table is truncated".into()) + })?; + let offset = match width { + 4 => u64::from(u32::from_le_bytes(bytes.try_into().map_err(|_| { + Error::invalid_input_source("Sparse variable u32 offset is malformed".into()) + })?)), + 8 => u64::from_le_bytes(bytes.try_into().map_err(|_| { + Error::invalid_input_source("Sparse variable u64 offset is malformed".into()) + })?), + _ => { + return Err(Error::invalid_input_source( + format!( + "Sparse variable offset width {} is unsupported", + bits_per_offset + ) + .into(), + )); + } + }; + if offset < table_len as u64 || offset > buffer.len() as u64 { + return Err(Error::invalid_input_source( + format!( + "Sparse variable offset {} is outside payload range {}..{}", + offset, + table_len, + buffer.len() + ) + .into(), + )); + } + if previous.is_some_and(|previous| offset < previous) { + return Err(Error::invalid_input_source( + "Sparse variable offsets are not monotonically increasing".into(), + )); + } + previous = Some(offset); + } + Ok(()) + } + + fn validate_fsl_buffers( + fsl: &pb21::FixedSizeList, + buffers: &[LanceBuffer], + num_values: u64, + buffer_index: &mut usize, + ) -> Result<()> { + use pb21::compressive_encoding::Compression; + + let child_values = num_values.checked_mul(fsl.items_per_value).ok_or_else(|| { + Error::invalid_input_source("Sparse fixed-size-list value count overflows".into()) + })?; + if fsl.has_validity { + let validity = buffers.get(*buffer_index).ok_or_else(|| { + Error::invalid_input_source( + "Sparse fixed-size-list value validity buffer is missing".into(), + ) + })?; + Self::validate_fixed_buffer(validity, child_values, 1, "fixed-size-list validity")?; + *buffer_index = buffer_index.checked_add(1).ok_or_else(|| { + Error::invalid_input_source("Sparse fixed-size-list buffer index overflows".into()) + })?; + } + let values = fsl.values.as_deref().ok_or_else(|| { + Error::invalid_input_source("Sparse fixed-size-list value encoding is missing".into()) + })?; + match values.compression.as_ref() { + Some(Compression::FixedSizeList(inner)) => { + Self::validate_fsl_buffers(inner, buffers, child_values, buffer_index) + } + Some(Compression::Flat(flat)) => { + let values = buffers.get(*buffer_index).ok_or_else(|| { + Error::invalid_input_source( + "Sparse fixed-size-list leaf value buffer is missing".into(), + ) + })?; + Self::validate_fixed_buffer( + values, + child_values, + flat.bits_per_value, + "fixed-size-list leaf values", + )?; + *buffer_index = buffer_index.checked_add(1).ok_or_else(|| { + Error::invalid_input_source( + "Sparse fixed-size-list buffer index overflows".into(), + ) + })?; + Ok(()) + } + _ => Err(Error::invalid_input_source( + "Sparse fixed-size-list value encoding is malformed".into(), + )), + } + } + + fn validate_value_buffers(&self, buffers: &[LanceBuffer], num_values: u64) -> Result<()> { + use pb21::compressive_encoding::Compression; + + let compression = self.value_encoding.compression.as_ref().ok_or_else(|| { + Error::invalid_input_source("Sparse value compression is missing".into()) + })?; + match compression { + Compression::Flat(flat) => Self::validate_fixed_buffer( + buffers.first().ok_or_else(|| { + Error::invalid_input_source("Sparse flat value buffer is missing".into()) + })?, + num_values, + flat.bits_per_value, + "flat values", + ), + Compression::InlineBitpacking(bitpacking) => { + let buffer = buffers.first().ok_or_else(|| { + Error::invalid_input_source( + "Sparse inline-bitpacked value buffer is missing".into(), + ) + })?; + if num_values > 1024 { + return Err(Error::invalid_input_source( + format!( + "Sparse inline-bitpacked chunk has {} values, exceeding 1024", + num_values + ) + .into(), + )); + } + let word_bytes = usize_from_u64( + bitpacking.uncompressed_bits_per_value / 8, + "inline bitpacking word width", + )?; + let header = buffer.as_ref().get(..word_bytes).ok_or_else(|| { + Error::invalid_input_source( + "Sparse inline-bitpacked buffer is missing its bit-width header".into(), + ) + })?; + let bit_width = + header + .iter() + .enumerate() + .try_fold(0_u64, |value, (idx, byte)| { + let shift = u32::try_from(idx.checked_mul(8).ok_or_else(|| { + Error::invalid_input_source( + "Sparse inline bit-width shift overflows".into(), + ) + })?) + .map_err(|_| { + Error::invalid_input_source( + "Sparse inline bit-width shift exceeds u32".into(), + ) + })?; + Ok::<_, Error>(value | (u64::from(*byte) << shift)) + })?; + if bit_width > bitpacking.uncompressed_bits_per_value { + return Err(Error::invalid_input_source( + format!( + "Sparse inline bit width {} exceeds uncompressed width {}", + bit_width, bitpacking.uncompressed_bits_per_value + ) + .into(), + )); + } + let payload_bytes = usize_from_u64( + bit_width.checked_mul(1024).ok_or_else(|| { + Error::invalid_input_source( + "Sparse inline-bitpacked payload size overflows".into(), + ) + })? / 8, + "inline-bitpacked payload size", + )?; + let expected = word_bytes.checked_add(payload_bytes).ok_or_else(|| { + Error::invalid_input_source( + "Sparse inline-bitpacked buffer size overflows".into(), + ) + })?; + if buffer.len() != expected { + return Err(Error::invalid_input_source( + format!( + "Sparse inline-bitpacked buffer has {} bytes, expected {}", + buffer.len(), + expected + ) + .into(), + )); + } + Ok(()) + } + Compression::Variable(variable) => { + let offsets = variable + .offsets + .as_deref() + .and_then(|encoding| encoding.compression.as_ref()) + .and_then(|compression| match compression { + Compression::Flat(flat) => Some(flat), + _ => None, + }) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse variable offset encoding is malformed".into(), + ) + })?; + Self::validate_variable_buffer( + buffers.first().ok_or_else(|| { + Error::invalid_input_source( + "Sparse variable value buffer is missing".into(), + ) + })?, + num_values, + offsets.bits_per_value, + ) + } + Compression::Fsst(fsst) => { + let variable = fsst + .values + .as_deref() + .and_then(|encoding| encoding.compression.as_ref()) + .and_then(|compression| match compression { + Compression::Variable(variable) => Some(variable), + _ => None, + }) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse FSST value encoding is malformed".into(), + ) + })?; + let offsets = variable + .offsets + .as_deref() + .and_then(|encoding| encoding.compression.as_ref()) + .and_then(|compression| match compression { + Compression::Flat(flat) => Some(flat), + _ => None, + }) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse FSST offset encoding is malformed".into(), + ) + })?; + Self::validate_variable_buffer( + buffers.first().ok_or_else(|| { + Error::invalid_input_source("Sparse FSST value buffer is missing".into()) + })?, + num_values, + offsets.bits_per_value, + ) + } + Compression::ByteStreamSplit(split) => { + let bits = split + .values + .as_deref() + .and_then(|encoding| encoding.compression.as_ref()) + .and_then(|compression| match compression { + Compression::Flat(flat) => Some(flat.bits_per_value), + _ => None, + }) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse byte-stream-split encoding is malformed".into(), + ) + })?; + Self::validate_fixed_buffer( + buffers.first().ok_or_else(|| { + Error::invalid_input_source( + "Sparse byte-stream-split buffer is missing".into(), + ) + })?, + num_values, + bits, + "byte-stream-split values", + ) + } + Compression::FixedSizeList(fsl) => { + let mut buffer_index = 0; + Self::validate_fsl_buffers(fsl, buffers, num_values, &mut buffer_index)?; + if buffer_index != buffers.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse fixed-size-list descriptor consumed {} of {} buffers", + buffer_index, + buffers.len() + ) + .into(), + )); + } + Ok(()) + } + Compression::PackedStruct(packed) => { + let bits = packed.bits_per_value.iter().try_fold(0_u64, |sum, bits| { + sum.checked_add(*bits).ok_or_else(|| { + Error::invalid_input_source( + "Sparse packed-struct bit width sum overflows".into(), + ) + }) + })?; + Self::validate_fixed_buffer( + buffers.first().ok_or_else(|| { + Error::invalid_input_source( + "Sparse packed-struct value buffer is missing".into(), + ) + })?, + num_values, + bits, + "packed-struct values", + ) + } + Compression::Rle(rle) => { + if buffers.len() != 2 { + return Err(Error::invalid_input_source( + format!( + "Sparse RLE value chunk has {} buffers, expected 2", + buffers.len() + ) + .into(), + )); + } + SparseStructuralScheduler::validate_general_child_buffer( + SparseStructuralScheduler::require_encoding(&rle.values, "RLE values")?, + buffers + .first() + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse RLE value buffer is missing after count validation".into(), + ) + })? + .as_ref(), + "value chunk RLE values", + )?; + SparseStructuralScheduler::validate_general_child_buffer( + SparseStructuralScheduler::require_encoding( + &rle.run_lengths, + "RLE run lengths", + )?, + buffers + .get(1) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse RLE run-length buffer is missing after count validation" + .into(), + ) + })? + .as_ref(), + "value chunk RLE run lengths", + ) + } + Compression::General(general) => { + let buffer = buffers.first().ok_or_else(|| { + Error::invalid_input_source( + "Sparse general-compressed value chunk is missing its first buffer".into(), + ) + })?; + SparseStructuralScheduler::validate_general_buffer_header( + general, + buffer.as_ref(), + "value chunk", + ) + } + _ => Err(Error::invalid_input_source( + "Sparse value chunk uses an unsupported compression descriptor".into(), + )), + } + } + + fn loaded_chunk(&self, chunk_idx: usize) -> Result<&LoadedChunk> { + let index = self + .loaded_chunks + .binary_search_by_key(&chunk_idx, |chunk| chunk.chunk_idx) + .map_err(|_| { + Error::internal(format!( + "Sparse structural decode missing loaded value chunk {}", + chunk_idx + )) + })?; + self.loaded_chunks.get(index).ok_or_else(|| { + Error::internal(format!( + "Sparse structural loaded chunk index {} is missing", + index + )) + }) + } + + fn decode_value_chunk(&self, chunk: &LoadedChunk) -> Result { + let buf = &chunk.data; + if buf.len() < 2 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} is too small for its header: {} bytes", + chunk.chunk_idx, + buf.len() + ) + .into(), + )); + } + let num_levels = u16::from_le_bytes( + buf.as_ref() + .get(..2) + .and_then(|bytes| bytes.try_into().ok()) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} has a malformed level header", + chunk.chunk_idx + ) + .into(), + ) + })?, + ); + let mut offset: usize = 2; + if num_levels != 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk unexpectedly contains {} rep/def levels", + num_levels + ) + .into(), + )); + } + + let size_width = if self.has_large_chunk { 4 } else { 2 }; + let num_buffers = usize::try_from(self.num_buffers).map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk has too many buffers: {}", + self.num_buffers + ) + .into(), + ) + })?; + let sizes_len = num_buffers.checked_mul(size_width).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural value chunk buffer-size header overflows".into(), + ) + })?; + let header_len = offset.checked_add(sizes_len).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural value chunk header length overflows".into(), + ) + })?; + if buf.len() < header_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} is too small for {} buffer sizes: {} bytes", + chunk.chunk_idx, + self.num_buffers, + buf.len() + ) + .into(), + )); + } + let buffer_sizes = (0..num_buffers) + .map(|_| Self::read_chunk_size(buf, &mut offset, size_width, chunk.chunk_idx)) + .collect::>>()?; + + offset = offset + .checked_add(pad_bytes::(offset)) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} padded header overflows", + chunk.chunk_idx + ) + .into(), + ) + })?; + if offset > buf.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} is missing padding after its header", + chunk.chunk_idx + ) + .into(), + )); + } + let buffers = buffer_sizes + .into_iter() + .map(|buf_size| { + let buf_size = buf_size as usize; + let end = offset.checked_add(buf_size).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} buffer size overflows", + chunk.chunk_idx + ) + .into(), + ) + })?; + if end > buf.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} buffer extends past chunk end", + chunk.chunk_idx + ) + .into(), + )); + } + let buffer = buf.slice_with_length(offset, buf_size); + offset = end; + offset = offset + .checked_add(pad_bytes::(offset)) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} padded buffer range overflows", + chunk.chunk_idx + ) + .into(), + ) + })?; + if offset > buf.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} padding extends past chunk end", + chunk.chunk_idx + ) + .into(), + )); + } + Ok(buffer) + }) + .collect::>>()?; + + if offset != buf.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} consumed {} of {} bytes", + chunk.chunk_idx, + offset, + buf.len() + ) + .into(), + )); + } + + self.validate_value_buffers(&buffers, chunk.items_in_chunk)?; + + let decoded = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + self.value_decompressor + .decompress(buffers, chunk.items_in_chunk) + })) + .map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} decompression panicked", + chunk.chunk_idx + ) + .into(), + ) + })? + .map_err(|error| { + Error::invalid_input_source( + format!( + "Sparse structural value chunk {} decompression failed: {error}", + chunk.chunk_idx + ) + .into(), + ) + })?; + if decoded.num_values() != chunk.items_in_chunk { + return Err(Error::invalid_input_source( + format!( + "Sparse structural value chunk {} decoded {} values, expected {}", + chunk.chunk_idx, + decoded.num_values(), + chunk.items_in_chunk + ) + .into(), + )); + } + Ok(decoded) + } + + fn append_value_range( + &self, + value_range: Range, + data_builder: &mut DataBlockBuilder, + chunk_cache: &mut Option<(usize, DataBlock)>, + ) -> Result<()> { + let mut value_start = value_range.start; + while value_start < value_range.end { + let chunk_idx = SparseStructuralScheduler::value_chunk_index( + &self.page_meta.chunk_value_offsets, + value_start, + )?; + let chunk_value_start = *self + .page_meta + .chunk_value_offsets + .get(chunk_idx) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse value chunk {} has no start offset", chunk_idx).into(), + ) + })?; + let chunk_value_end = *self + .page_meta + .chunk_value_offsets + .get(chunk_idx.checked_add(1).ok_or_else(|| { + Error::invalid_input_source("Sparse value chunk index overflows".into()) + })?) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse value chunk {} has no end offset", chunk_idx).into(), + ) + })?; + let take_end = value_range.end.min(chunk_value_end); + if value_start < chunk_value_start || take_end <= value_start { + return Err(Error::invalid_input_source( + format!( + "Sparse value range {}..{} does not make progress in chunk {} covering {}..{}", + value_range.start, + value_range.end, + chunk_idx, + chunk_value_start, + chunk_value_end + ) + .into(), + )); + } + + if !matches!(chunk_cache, Some((cached_idx, _)) if *cached_idx == chunk_idx) { + let chunk = self.loaded_chunk(chunk_idx)?; + *chunk_cache = Some((chunk_idx, self.decode_value_chunk(chunk)?)); + } + let values = &chunk_cache + .as_ref() + .ok_or_else(|| Error::internal("Sparse structural chunk cache is empty"))? + .1; + data_builder.append( + values, + value_start - chunk_value_start..take_end - chunk_value_start, + )?; + value_start = take_end; + } + Ok(()) + } + + fn decode_checked(self) -> Result { + let selection = slice_sparse_plan( + &self.page_meta.plan, + &self.row_ranges, + self.page_meta.row_domain, + )?; + let estimated_size_bytes = self + .loaded_chunks + .iter() + .map(|chunk| chunk.data.len()) + .try_fold(0_usize, |total, len| total.checked_add(len)) + .and_then(|total| total.checked_mul(2)) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural decode size estimate overflows".into(), + ) + })?; + let mut data_builder = DataBlockBuilder::with_capacity_estimate( + u64::try_from(estimated_size_bytes).map_err(|_| { + Error::invalid_input_source("Sparse structural decode size exceeds u64::MAX".into()) + })?, + ); + let mut chunk_cache: Option<(usize, DataBlock)> = None; + let mut appended_values = false; + for value_range in &selection.leaf_ranges { + self.append_value_range(value_range.clone(), &mut data_builder, &mut chunk_cache)?; + appended_values = true; + } + + let data = if appended_values { + data_builder.finish() + } else { + DataBlock::from_array(new_empty_array(&self.data_type)) + }; + let unraveler = RepDefUnraveler::new_sparse(selection.plan); + Ok(DecodedPage { + data, + repdef: unraveler, + }) + } +} + +impl DecodePageTask for DecodeSparseStructuralTask { + fn decode(self: Box) -> Result { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| (*self).decode_checked())) + .map_err(|_| { + Error::invalid_input_source( + "Sparse structural page decoding panicked on malformed input".into(), + ) + })? + } +} + +struct SparseStructuralSelection { + plan: SparseStructuralPlan, + leaf_ranges: Vec>, +} + +struct SparsePositionSelection { + positions: SparsePositionSet, + ordinal_ranges: Vec>, +} + +fn validate_slice_ranges(ranges: &[Range], domain_len: u64, label: &str) -> Result { + let mut total = 0_u64; + for range in ranges { + if range.start > range.end || range.end > domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} slice {}..{} is outside domain {}", + range.start, range.end, domain_len + ) + .into(), + )); + } + total = total.checked_add(range.end - range.start).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} slice length overflows").into(), + ) + })?; + } + Ok(total) +} + +fn push_coalesced_range(ranges: &mut Vec>, range: Range) { + if range.is_empty() { + return; + } + if let Some(last) = ranges.last_mut() + && last.end == range.start + { + last.end = range.end; + return; + } + ranges.push(range); +} + +fn position_segments_to_set( + segments: Vec>, + output_domain: u64, + label: &str, +) -> Result { + let segments = coalesce_ranges(segments); + if segments.is_empty() { + return Ok(SparsePositionSet::empty()); + } + if segments.len() == 1 { + let segment = segments.first().ok_or_else(|| { + Error::internal("Sparse structural segment unexpectedly missing".to_string()) + })?; + if segment.start == 0 && segment.end == output_domain { + return Ok(SparsePositionSet::all(output_domain)); + } + return Ok(SparsePositionSet::range( + segment.start, + segment.end - segment.start, + )); + } + + let total_len = segments + .iter() + .map(|range| range.end - range.start) + .try_fold(0_u64, |sum, len| { + sum.checked_add(len).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} segment length overflows").into(), + ) + }) + })?; + let total_len = usize::try_from(total_len).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} segment length exceeds usize::MAX").into(), + ) + })?; + let mut positions = Vec::with_capacity(total_len); + for segment in segments { + positions.extend(segment); + } + SparsePositionSet::from_positions(positions, output_domain, label) +} + +fn select_position_set( + positions: &SparsePositionSet, + ranges: &[Range], + domain_len: u64, + label: &str, +) -> Result { + let output_domain = validate_slice_ranges(ranges, domain_len, label)?; + let mut segments = Vec::new(); + let mut ordinal_ranges = Vec::new(); + let mut output_base = 0_u64; + + match positions { + SparsePositionSet::Empty => {} + SparsePositionSet::All { len } => { + if *len != domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} all set length {} does not match domain {}", + len, domain_len + ) + .into(), + )); + } + for range in ranges { + let range_len = range.end - range.start; + let output_end = output_base.checked_add(range_len).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} output domain overflows").into(), + ) + })?; + push_coalesced_range(&mut segments, output_base..output_end); + push_coalesced_range(&mut ordinal_ranges, range.clone()); + output_base = output_end; + } + } + SparsePositionSet::Range { start, len } => { + let source_end = start.checked_add(*len).ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} range overflows").into(), + ) + })?; + if source_end > domain_len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} range {}..{} is outside domain {}", + start, source_end, domain_len + ) + .into(), + )); + } + for range in ranges { + let intersect_start = range.start.max(*start); + let intersect_end = range.end.min(source_end); + if intersect_start < intersect_end { + let out_start = output_base + .checked_add(intersect_start - range.start) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} output position overflows") + .into(), + ) + })?; + let out_end = output_base + .checked_add(intersect_end - range.start) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} output position overflows") + .into(), + ) + })?; + push_coalesced_range(&mut segments, out_start..out_end); + push_coalesced_range( + &mut ordinal_ranges, + intersect_start - *start..intersect_end - *start, + ); + } + output_base = output_base + .checked_add(range.end - range.start) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} output domain overflows").into(), + ) + })?; + } + } + SparsePositionSet::Explicit(source_positions) => { + let mut out_positions = Vec::new(); + for range in ranges { + let idx_start = + source_positions.partition_point(|position| *position < range.start); + let idx_end = source_positions.partition_point(|position| *position < range.end); + if idx_start < idx_end { + let selected_positions = + source_positions.get(idx_start..idx_end).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} explicit position slice is invalid" + ) + .into(), + ) + })?; + for position in selected_positions { + out_positions.push( + output_base + .checked_add(*position - range.start) + .ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural {label} output position overflows" + ) + .into(), + ) + })?, + ); + } + push_coalesced_range(&mut ordinal_ranges, idx_start as u64..idx_end as u64); + } + output_base = output_base + .checked_add(range.end - range.start) + .ok_or_else(|| { + Error::invalid_input_source( + format!("Sparse structural {label} output domain overflows").into(), + ) + })?; + } + let positions = SparsePositionSet::from_positions(out_positions, output_domain, label)?; + return Ok(SparsePositionSelection { + positions, + ordinal_ranges, + }); + } + } + + Ok(SparsePositionSelection { + positions: position_segments_to_set(segments, output_domain, label)?, + ordinal_ranges, + }) +} + +fn select_validity_set( + validity: &SparseValiditySet, + ranges: &[Range], + domain_len: u64, + label: &str, +) -> Result { + Ok(SparseValiditySet { + meaning: validity.meaning, + positions: select_position_set(&validity.positions, ranges, domain_len, label)?.positions, + }) +} + +fn offsets_from_counts(counts: &[u64]) -> Result> { + let mut offsets = Vec::with_capacity(counts.len() + 1); + let mut offset = 0_u64; + offsets.push(offset); + for count in counts { + offset = offset.checked_add(*count).ok_or_else(|| { + Error::invalid_input_source("Sparse structural list count offsets overflow".into()) + })?; + offsets.push(offset); + } + Ok(offsets) +} + +fn coalesce_ranges(ranges: Vec>) -> Vec> { + let mut coalesced: Vec> = Vec::with_capacity(ranges.len()); + for range in ranges { + if range.is_empty() { + continue; + } + if let Some(last) = coalesced.last_mut() + && last.end == range.start + { + last.end = range.end; + continue; + } + coalesced.push(range); + } + coalesced +} + +fn slice_list_layer( + num_slots: u64, + num_child_slots: u64, + non_empty_positions: &SparsePositionSet, + counts: &SparseCountSet, + validity: &SparseValiditySet, + ranges: &[Range], +) -> Result<(SparseStructuralLayerPlan, Vec>)> { + if non_empty_positions.len() != counts.len() { + return Err(Error::invalid_input_source( + "Sparse structural list has mismatched non-empty positions and counts".into(), + )); + } + let count_sum = counts.sum()?; + if count_sum != num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list count sum {} does not match child slots {}", + count_sum, num_child_slots + ) + .into(), + )); + } + + let non_empty_selection = + select_position_set(non_empty_positions, ranges, num_slots, "list non-empty")?; + let out_counts = select_count_set( + counts, + &non_empty_selection.ordinal_ranges, + non_empty_selection.positions.len(), + )?; + let child_ranges = + child_ranges_from_counts(counts, num_child_slots, &non_empty_selection.ordinal_ranges)?; + let out_validity = select_validity_set(validity, ranges, num_slots, "list validity")?; + let out_num_slots = validate_slice_ranges(ranges, num_slots, "list")?; + let out_num_child_slots = out_counts.sum()?; + Ok(( + SparseStructuralLayerPlan::List { + num_slots: out_num_slots, + num_child_slots: out_num_child_slots, + non_empty_positions: non_empty_selection.positions, + counts: out_counts, + validity: out_validity, + }, + child_ranges, + )) +} + +fn select_count_set( + counts: &SparseCountSet, + ordinal_ranges: &[Range], + selected_len: u64, +) -> Result { + match counts { + SparseCountSet::Empty => { + if selected_len != 0 { + return Err(Error::invalid_input_source( + "Sparse structural selected non-empty positions but counts are empty".into(), + )); + } + Ok(SparseCountSet::Empty) + } + SparseCountSet::Constant { value, len } => { + validate_ordinal_ranges(ordinal_ranges, *len, "constant list counts")?; + Ok(SparseCountSet::constant(*value, selected_len)) + } + SparseCountSet::Explicit { + counts: source_counts, + .. + } => { + validate_ordinal_ranges( + ordinal_ranges, + source_counts.len() as u64, + "explicit list counts", + )?; + let selected_len = usize::try_from(selected_len).map_err(|_| { + Error::invalid_input_source( + "Sparse structural selected count length exceeds usize::MAX".into(), + ) + })?; + let mut out_counts = Vec::with_capacity(selected_len); + for range in ordinal_ranges { + let start = usize::try_from(range.start).map_err(|_| { + Error::invalid_input_source( + "Sparse structural count ordinal exceeds usize::MAX".into(), + ) + })?; + let end = usize::try_from(range.end).map_err(|_| { + Error::invalid_input_source( + "Sparse structural count ordinal exceeds usize::MAX".into(), + ) + })?; + out_counts.extend_from_slice(source_counts.get(start..end).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural explicit count slice is invalid".into(), + ) + })?); + } + SparseCountSet::from_counts(out_counts) + } + } +} + +fn validate_ordinal_ranges(ranges: &[Range], len: u64, label: &str) -> Result<()> { + for range in ranges { + if range.start > range.end || range.end > len { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} ordinal range {}..{} is outside {} values", + range.start, range.end, len + ) + .into(), + )); + } + } + Ok(()) +} + +fn child_ranges_from_counts( + counts: &SparseCountSet, + num_child_slots: u64, + ordinal_ranges: &[Range], +) -> Result>> { + if ordinal_ranges.is_empty() { + return Ok(Vec::new()); + } + let child_ranges = match counts { + SparseCountSet::Empty => { + return Err(Error::invalid_input_source( + "Sparse structural selected non-empty positions but counts are empty".into(), + )); + } + SparseCountSet::Constant { value, len } => { + let expected_child_slots = value.checked_mul(*len).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list constant count sum overflows child slots".into(), + ) + })?; + if expected_child_slots != num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list constant count sum {} does not match child slots {}", + expected_child_slots, num_child_slots + ) + .into(), + )); + } + validate_ordinal_ranges(ordinal_ranges, *len, "constant list counts")?; + ordinal_ranges + .iter() + .map(|range| { + let start = range.start.checked_mul(*value).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list child range start overflows".into(), + ) + })?; + let end = range.end.checked_mul(*value).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list child range end overflows".into(), + ) + })?; + Ok(start..end) + }) + .collect::>>()? + } + SparseCountSet::Explicit { + counts, + offsets: value_offsets, + } => { + let last_offset = *value_offsets.last().ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list count offsets are unexpectedly empty".into(), + ) + })?; + if last_offset != num_child_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list count sum {} does not match child slots {}", + last_offset, num_child_slots + ) + .into(), + )); + } + validate_ordinal_ranges(ordinal_ranges, counts.len() as u64, "explicit list counts")?; + ordinal_ranges + .iter() + .map(|range| { + let start = usize::try_from(range.start).map_err(|_| { + Error::invalid_input_source( + "Sparse structural count ordinal exceeds usize::MAX".into(), + ) + })?; + let end = usize::try_from(range.end).map_err(|_| { + Error::invalid_input_source( + "Sparse structural count ordinal exceeds usize::MAX".into(), + ) + })?; + let start_offset = *value_offsets.get(start).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list start offset is missing".into(), + ) + })?; + let end_offset = *value_offsets.get(end).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list end offset is missing".into(), + ) + })?; + Ok(start_offset..end_offset) + }) + .collect::>>()? + } + }; + Ok(coalesce_ranges(child_ranges)) +} + +fn slice_sparse_plan( + plan: &SparseStructuralPlan, + row_ranges: &[Range], + row_domain: u64, +) -> Result { + plan.validate(row_domain)?; + let mut selected_ranges = row_ranges.to_vec(); + let mut selected_domain = row_domain; + let mut sliced_layers = Vec::with_capacity(plan.layers.len()); + + for layer in &plan.layers { + match layer { + SparseStructuralLayerPlan::Validity { + num_slots, + validity, + } => { + if selected_domain != *num_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural validity slice domain {} does not match {} slots", + selected_domain, num_slots + ) + .into(), + )); + } + let out_num_slots = + validate_slice_ranges(&selected_ranges, *num_slots, "validity")?; + sliced_layers.push(SparseStructuralLayerPlan::Validity { + num_slots: out_num_slots, + validity: select_validity_set( + validity, + &selected_ranges, + *num_slots, + "validity", + )?, + }); + } + SparseStructuralLayerPlan::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + .. + } => { + if selected_domain != *num_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list slice domain {} does not match {} slots", + selected_domain, num_slots + ) + .into(), + )); + } + let (sliced_layer, child_ranges) = slice_list_layer( + *num_slots, + *num_child_slots, + non_empty_positions, + counts, + validity, + &selected_ranges, + )?; + sliced_layers.push(sliced_layer); + selected_ranges = child_ranges; + selected_domain = *num_child_slots; + } + SparseStructuralLayerPlan::FixedSizeList { + num_slots, + dimension, + validity, + } => { + if selected_domain != *num_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list slice domain {} does not match {} slots", + selected_domain, num_slots + ) + .into(), + )); + } + let num_child_slots = num_slots.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count overflows: slots={}, dimension={}", + num_slots, dimension + ) + .into(), + ) + })?; + let out_num_slots = + validate_slice_ranges(&selected_ranges, *num_slots, "fixed-size-list")?; + let child_ranges = selected_ranges + .iter() + .map(|range| { + let start = range.start.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural fixed-size-list child range start overflows" + .into(), + ) + })?; + let end = range.end.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural fixed-size-list child range end overflows" + .into(), + ) + })?; + Ok(start..end) + }) + .collect::>>()?; + sliced_layers.push(SparseStructuralLayerPlan::FixedSizeList { + num_slots: out_num_slots, + dimension: *dimension, + validity: select_validity_set( + validity, + &selected_ranges, + *num_slots, + "fixed-size-list validity", + )?, + }); + selected_ranges = child_ranges; + selected_domain = num_child_slots; + } + } + } + + if selected_domain != plan.num_visible_items { + return Err(Error::invalid_input_source( + format!( + "Sparse structural selected terminal domain {} does not match {} visible items", + selected_domain, plan.num_visible_items + ) + .into(), + )); + } + let num_visible_items = + validate_slice_ranges(&selected_ranges, plan.num_visible_items, "visible value")?; + let num_items = SparseStructuralPlan::expected_num_items(&sliced_layers, num_visible_items)?; + Ok(SparseStructuralSelection { + plan: SparseStructuralPlan { + layers: sliced_layers, + num_items, + num_visible_items, + }, + leaf_ranges: coalesce_ranges(selected_ranges), + }) +} + +#[cfg(test)] +mod tests { + use std::sync::Mutex; + + use crate::{ + compression::DefaultDecompressionStrategy, + encodings::physical::block::{CompressionConfig, CompressionScheme}, + testing::SimulatedScheduler, + }; + + use super::*; + + async fn initialize_scheduler( + scheduler: &mut SparseStructuralScheduler, + io: &Arc, + ) -> Result> { + let initialization = scheduler.init_layout()?; + let buffers = io + .submit_request(initialization.required_ranges().cloned().collect(), 0) + .await?; + let buffers = initialization.with_buffers(buffers)?; + let state = scheduler.init_from_buffers(buffers, io).await?; + scheduler.try_load(&state)?; + Ok(state) + } + + fn position_set( + positions: pb21::sparse_position_set::Positions, + num_positions: u64, + ) -> Option { + Some(pb21::SparsePositionSet { + positions: Some(positions), + num_positions, + }) + } + + fn position_empty() -> Option { + position_set( + pb21::sparse_position_set::Positions::Empty(pb21::SparsePositionEmpty {}), + 0, + ) + } + + fn position_all(num_positions: u64) -> Option { + position_set( + pb21::sparse_position_set::Positions::All(pb21::SparsePositionAll {}), + num_positions, + ) + } + + fn position_explicit(num_positions: u64) -> Option { + position_set( + pb21::sparse_position_set::Positions::Explicit(ProtobufUtils21::flat(64, None)), + num_positions, + ) + } + + fn general_lz4(values: CompressiveEncoding) -> CompressiveEncoding { + ProtobufUtils21::wrapped(CompressionConfig::new(CompressionScheme::Lz4, None), values) + .unwrap() + } + + fn validity( + meaning: pb21::sparse_validity_set::Meaning, + positions: Option, + ) -> Option { + Some(pb21::SparseValiditySet { + meaning: meaning as i32, + positions, + }) + } + + fn null_positions( + positions: Option, + ) -> Option { + validity( + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions, + positions, + ) + } + + fn count_empty() -> Option { + Some(pb21::SparseCountSet { + counts: Some(pb21::sparse_count_set::Counts::Empty( + pb21::SparseCountEmpty {}, + )), + }) + } + + fn count_constant(value: u64) -> Option { + Some(pb21::SparseCountSet { + counts: Some(pb21::sparse_count_set::Counts::Constant( + pb21::SparseCountConstant { value }, + )), + }) + } + + fn sparse_layout() -> pb21::SparseLayout { + pb21::SparseLayout { + value_compression: Some(ProtobufUtils21::flat(32, None)), + num_buffers: 1, + num_items: 1, + num_visible_items: 1, + has_large_chunk: false, + structural_layers: Vec::new(), + } + } + + fn validity_layer( + num_slots: u64, + validity: Option, + ) -> pb21::SparseStructuralLayer { + pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::Validity( + pb21::SparseValidityLayer { + num_slots, + validity, + }, + )), + } + } + + fn list_layer( + num_slots: u64, + num_child_slots: u64, + non_empty_positions: Option, + counts: Option, + validity: Option, + ) -> pb21::SparseStructuralLayer { + pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::List( + pb21::SparseListLayer { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + }, + )), + } + } + + fn fixed_size_list_layer( + num_slots: u64, + dimension: u64, + validity: Option, + ) -> pb21::SparseStructuralLayer { + pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::FixedSizeList( + pb21::SparseFixedSizeListLayer { + num_slots, + dimension, + validity, + }, + )), + } + } + + fn assert_invalid_input_contains(err: Error, expected: &str) { + let message = err.to_string(); + assert!( + matches!(&err, Error::InvalidInput { .. }), + "expected InvalidInput, got {err:?}" + ); + assert!( + message.contains(expected), + "expected error to contain {expected:?}, got {message}" + ); + } + + #[test] + fn rejects_missing_layer_variants_and_item_count_mismatches() { + let decompressors = DefaultDecompressionStrategy::default(); + + let mut layout = sparse_layout(); + layout + .structural_layers + .push(pb21::SparseStructuralLayer { layer: None }); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "missing its layer variant"); + + let mut layout = sparse_layout(); + layout.num_items = 2; + layout + .structural_layers + .push(validity_layer(1, null_positions(position_empty()))); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "layers imply 1"); + } + + #[test] + fn accepts_large_structural_descriptors() { + let explicit_values = 8_u64 * 1024 * 1024 + 1; + let metadata_size = explicit_values * 8; + let value_position = metadata_size; + let value_size = explicit_values * 16; + let structural_position = value_position + value_size; + let structural_size = explicit_values * std::mem::size_of::() as u64; + let mut layout = sparse_layout(); + layout.num_items = explicit_values; + layout.num_visible_items = explicit_values; + layout.structural_layers.push(validity_layer( + explicit_values, + null_positions(position_explicit(explicit_values)), + )); + + SparseStructuralScheduler::try_new( + &[ + (0, metadata_size), + (value_position, value_size), + (structural_position, structural_size), + ], + 0, + explicit_values, + DataType::Int32, + &layout, + &DefaultDecompressionStrategy::default(), + ) + .unwrap(); + } + + #[test] + fn accepts_deep_supported_value_encodings() { + let encoding = (0..300).fold(ProtobufUtils21::flat(32, None), |values, _| { + ProtobufUtils21::fsl(1, false, values) + }); + + assert_eq!( + SparseStructuralScheduler::validate_value_encoding(&encoding).unwrap(), + 1 + ); + } + + fn null_set(positions: SparsePositionSet) -> SparseValiditySet { + SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions, + } + } + + fn valid_set(positions: SparsePositionSet) -> SparseValiditySet { + SparseValiditySet { + meaning: SparseValidityMeaning::ValidPositions, + positions, + } + } + + #[test] + fn semantic_position_and_count_sets_project_without_materializing_ranges() { + let ranges = [1..4]; + assert_eq!( + select_position_set(&SparsePositionSet::Empty, &ranges, 5, "empty") + .unwrap() + .positions, + SparsePositionSet::Empty + ); + assert_eq!( + select_position_set(&SparsePositionSet::all(5), &ranges, 5, "all") + .unwrap() + .positions, + SparsePositionSet::all(3) + ); + assert_eq!( + select_position_set(&SparsePositionSet::range(1, 3), &ranges, 5, "range") + .unwrap() + .positions, + SparsePositionSet::all(3) + ); + assert_eq!( + select_position_set( + &SparsePositionSet::Explicit(vec![0, 2, 4]), + &[0..1, 4..5], + 5, + "explicit", + ) + .unwrap() + .positions, + SparsePositionSet::all(2) + ); + + assert_eq!( + select_count_set(&SparseCountSet::Empty, &[], 0).unwrap(), + SparseCountSet::Empty + ); + assert_eq!( + select_count_set(&SparseCountSet::constant(2, 3), &[0..1, 2..3], 2,).unwrap(), + SparseCountSet::constant(2, 2) + ); + assert_eq!( + select_count_set( + &SparseCountSet::from_counts(vec![1, 2, 3]).unwrap(), + &[0..1, 2..3], + 2, + ) + .unwrap(), + SparseCountSet::from_counts(vec![1, 3]).unwrap() + ); + } + + #[test] + fn validity_polarities_rebuild_the_same_arrow_domain() { + let mut null_builder = BooleanBufferBuilder::new(4); + null_set(SparsePositionSet::Explicit(vec![1, 3])) + .append_to(&mut null_builder, 4) + .unwrap(); + assert_eq!( + null_builder.finish().iter().collect::>(), + vec![true, false, true, false] + ); + + let mut valid_builder = BooleanBufferBuilder::new(4); + valid_set(SparsePositionSet::range(1, 2)) + .append_to(&mut valid_builder, 4) + .unwrap(); + assert_eq!( + valid_builder.finish().iter().collect::>(), + vec![false, true, true, false] + ); + } + + #[test] + fn schema_layer_mismatches_are_invalid_input() { + let mut missing = SparseStructuralUnraveler::new(SparseStructuralPlan { + layers: Vec::new(), + num_items: 1, + num_visible_items: 1, + }); + let mut validity = BooleanBufferBuilder::new(1); + let err = missing.unravel_validity(&mut validity).unwrap_err(); + assert_invalid_input_contains(err, "fewer layers than the Arrow schema"); + + let mut fixed_size_list = SparseStructuralUnraveler::new(SparseStructuralPlan { + layers: vec![SparseStructuralLayerPlan::FixedSizeList { + num_slots: 2, + dimension: 2, + validity: null_set(SparsePositionSet::empty()), + }], + num_items: 4, + num_visible_items: 4, + }); + let mut validity = BooleanBufferBuilder::new(2); + let err = fixed_size_list.unravel_validity(&mut validity).unwrap_err(); + assert_invalid_input_contains(err, "does not match the Arrow schema"); + + let extra = SparseStructuralUnraveler::new(SparseStructuralPlan { + layers: vec![SparseStructuralLayerPlan::Validity { + num_slots: 1, + validity: null_set(SparsePositionSet::empty()), + }], + num_items: 1, + num_visible_items: 1, + }); + let err = extra.ensure_exhausted().unwrap_err(); + assert_invalid_input_contains(err, "1 unconsumed layer"); + } + + fn nested_plan() -> SparseStructuralPlan { + SparseStructuralPlan { + layers: vec![ + SparseStructuralLayerPlan::Validity { + num_slots: 6, + validity: null_set(SparsePositionSet::Explicit(vec![1, 4])), + }, + SparseStructuralLayerPlan::List { + num_slots: 6, + num_child_slots: 5, + non_empty_positions: SparsePositionSet::Explicit(vec![0, 2, 5]), + counts: SparseCountSet::from_counts(vec![2, 1, 2]).unwrap(), + validity: null_set(SparsePositionSet::Explicit(vec![1, 4])), + }, + SparseStructuralLayerPlan::FixedSizeList { + num_slots: 5, + dimension: 2, + validity: valid_set(SparsePositionSet::range(1, 3)), + }, + ], + num_items: 13, + num_visible_items: 10, + } + } + + #[test] + fn discontiguous_projection_preserves_outer_to_inner_order() { + let selection = slice_sparse_plan(&nested_plan(), &[5..6, 0..1], 6).unwrap(); + assert_eq!(selection.leaf_ranges, vec![6..10, 0..4]); + assert_eq!(selection.plan.num_visible_items, 8); + selection.plan.validate(2).unwrap(); + + let SparseStructuralLayerPlan::List { + non_empty_positions, + counts, + .. + } = &selection.plan.layers[1] + else { + panic!("expected projected list layer"); + }; + assert_eq!(*non_empty_positions, SparsePositionSet::all(2)); + assert_eq!(*counts, SparseCountSet::constant(2, 2)); + } + + #[test] + fn no_value_projection_keeps_empty_and_null_list_structure() { + let selection = slice_sparse_plan(&nested_plan(), &[1..2, 3..4], 6).unwrap(); + assert!(selection.leaf_ranges.is_empty()); + assert_eq!(selection.plan.num_visible_items, 0); + selection.plan.validate(2).unwrap(); + + let SparseStructuralLayerPlan::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + } = &selection.plan.layers[1] + else { + panic!("expected projected list layer"); + }; + assert_eq!((*num_slots, *num_child_slots), (2, 0)); + assert_eq!(*non_empty_positions, SparsePositionSet::Empty); + assert_eq!(*counts, SparseCountSet::Empty); + assert_eq!(*validity, null_set(SparsePositionSet::range(0, 1))); + } + + #[test] + fn rejects_missing_value_compression_and_buffer_count_mismatch() { + let decompressors = DefaultDecompressionStrategy::default(); + let mut layout = sparse_layout(); + layout.value_compression = None; + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "missing value compression"); + + let mut layout = sparse_layout(); + layout.num_buffers = 2; + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "declares 2 value buffers"); + } + + #[test] + fn rejects_inconsistent_chunk_count_before_io() { + let decompressors = DefaultDecompressionStrategy::default(); + + let layout = sparse_layout(); + let err = SparseStructuralScheduler::try_new( + &[(0, 16), (16, 8)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "declares 2 chunks for 1 visible items"); + } + + #[cfg(feature = "lz4")] + #[test] + fn accepts_large_general_decompression_headers() { + let encoding = general_lz4(ProtobufUtils21::flat(64, None)); + let Some(pb21::compressive_encoding::Compression::General(general)) = + encoding.compression.as_ref() + else { + panic!("expected General compression"); + }; + let declared_size = 65_u32 * 1024 * 1024; + + SparseStructuralScheduler::validate_general_buffer_header( + general, + &declared_size.to_le_bytes(), + "test", + ) + .unwrap(); + } + + #[cfg(feature = "lz4")] + #[tokio::test] + async fn rejects_malformed_general_structural_buffers_as_invalid_input() { + let mut layout = sparse_layout(); + layout.structural_layers.push(validity_layer( + 1, + null_positions(position_set( + pb21::sparse_position_set::Positions::Explicit(general_lz4(ProtobufUtils21::flat( + 64, None, + ))), + 1, + )), + )); + let decompressors = DefaultDecompressionStrategy::default(); + + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8), (16, 8)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&[0; 8]); + data.extend_from_slice(&8_u32.to_le_bytes()); + data.extend_from_slice(&[0xff; 4]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + let Err(err) = initialize_scheduler(&mut scheduler, &io).await else { + panic!("expected malformed General buffer to be rejected"); + }; + assert_invalid_input_contains(err, "decompression failed"); + } + + #[cfg(feature = "lz4")] + #[tokio::test] + async fn rejects_malformed_general_value_buffer() { + let mut layout = sparse_layout(); + layout.value_compression = Some(general_lz4(ProtobufUtils21::flat(32, None))); + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&0_u16.to_le_bytes()); + data.extend_from_slice(&0_u16.to_le_bytes()); + data.extend_from_slice(&[0; 4]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + initialize_scheduler(&mut scheduler, &io).await.unwrap(); + let mut page_tasks = scheduler.schedule_ranges(&[0..1], &io).unwrap(); + let mut decoder = page_tasks.pop().unwrap().decoder_fut.await.unwrap(); + let Err(err) = decoder.drain(1).unwrap().decode() else { + panic!("expected malformed General value buffer to be rejected"); + }; + assert_invalid_input_contains(err, "missing its length prefix"); + } + + #[test] + fn rejects_layer_domain_and_fixed_size_list_mismatches() { + let decompressors = DefaultDecompressionStrategy::default(); + let mut layout = sparse_layout(); + layout.num_items = 2; + layout.num_visible_items = 2; + layout + .structural_layers + .push(validity_layer(1, null_positions(position_empty()))); + layout + .structural_layers + .push(validity_layer(2, null_positions(position_empty()))); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "layer 1 has 2 slots, expected 1"); + + let mut layout = sparse_layout(); + layout.num_items = 4; + layout.num_visible_items = 4; + layout.structural_layers.push(fixed_size_list_layer( + 2, + 3, + null_positions(position_empty()), + )); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 2, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "terminal domain has 6 slots"); + } + + #[test] + fn rejects_invalid_validity_and_list_count_semantics() { + let decompressors = DefaultDecompressionStrategy::default(); + let mut layout = sparse_layout(); + layout.structural_layers.push(validity_layer( + 1, + validity( + pb21::sparse_validity_set::Meaning::SparseValidityUnspecified, + position_empty(), + ), + )); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "meaning is unspecified"); + + let mut layout = sparse_layout(); + layout.num_items = 3; + layout.num_visible_items = 3; + layout.structural_layers.push(list_layer( + 2, + 3, + position_all(2), + count_constant(2), + null_positions(position_empty()), + )); + let err = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 2, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap_err(); + assert_invalid_input_contains(err, "count sum 4 does not match child slots 3"); + } + + #[tokio::test] + async fn rejects_unordered_explicit_positions_after_decompression() { + let mut layout = sparse_layout(); + layout.num_items = 4; + layout.num_visible_items = 4; + layout + .structural_layers + .push(validity_layer(4, null_positions(position_explicit(2)))); + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8), (16, 16)], + 0, + 4, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&4_u32.to_le_bytes()); + data.extend_from_slice(&[0; 8]); + data.extend_from_slice(&3_u64.to_le_bytes()); + data.extend_from_slice(&0_u64.to_le_bytes()); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + + let Err(err) = initialize_scheduler(&mut scheduler, &io).await else { + panic!("expected unordered sparse positions to be rejected"); + }; + assert_invalid_input_contains(err, "positions must be strictly increasing"); + } + + #[tokio::test] + async fn rejects_chunk_metadata_value_and_byte_sum_mismatches() { + let layout = sparse_layout(); + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let mut data = Vec::new(); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&[0; 8]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + let Err(err) = initialize_scheduler(&mut scheduler, &io).await else { + panic!("expected chunk byte sum mismatch"); + }; + assert_invalid_input_contains(err, "describes 16 value bytes"); + + let mut layout = sparse_layout(); + layout.num_items = 2; + layout.num_visible_items = 2; + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8)], + 0, + 2, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&[0; 8]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + let Err(err) = initialize_scheduler(&mut scheduler, &io).await else { + panic!("expected chunk value sum mismatch"); + }; + assert_invalid_input_contains(err, "metadata has 1, layout has 2"); + } + + #[tokio::test] + async fn rejects_malformed_value_chunk_before_decompression() { + let layout = sparse_layout(); + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, 8)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&[0; 8]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + initialize_scheduler(&mut scheduler, &io).await.unwrap(); + let mut page_tasks = scheduler.schedule_ranges(&[0..1], &io).unwrap(); + let page_task = page_tasks.pop().unwrap(); + let mut decoder = page_task.decoder_fut.await.unwrap(); + let decode_task = decoder.drain(1).unwrap(); + let Err(err) = decode_task.decode() else { + panic!("expected malformed value chunk to be rejected"); + }; + assert_invalid_input_contains(err, "flat values buffer has 0 bytes, expected 4"); + } + + #[tokio::test] + async fn accepts_value_chunks_larger_than_64_mib() { + let chunk_size = 65_u64 * 1024 * 1024; + let layout = sparse_layout(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 8), (8, chunk_size)], + 0, + 1, + DataType::Int32, + &layout, + &DefaultDecompressionStrategy::default(), + ) + .unwrap(); + let words_minus_one = u32::try_from(chunk_size / MINIBLOCK_ALIGNMENT as u64 - 1).unwrap(); + let mut metadata = Vec::new(); + metadata.extend_from_slice(&words_minus_one.to_le_bytes()); + metadata.extend_from_slice(&1_u32.to_le_bytes()); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(metadata))); + + initialize_scheduler(&mut scheduler, &io).await.unwrap(); + } + + #[tokio::test] + async fn rejects_value_chunks_above_the_miniblock_limit() { + let num_values = miniblock::MAX_CONFIGURABLE_MINIBLOCK_VALUES + 1; + let mut layout = sparse_layout(); + layout.num_items = num_values; + layout.num_visible_items = num_values; + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 16), (16, 16)], + 0, + num_values, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let mut data = Vec::new(); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&(num_values as u32).to_le_bytes()); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&0_u32.to_le_bytes()); + data.extend_from_slice(&[0; 16]); + let io: Arc = Arc::new(SimulatedScheduler::new(Bytes::from(data))); + + let Err(err) = initialize_scheduler(&mut scheduler, &io).await else { + panic!("expected oversized value chunk to be rejected"); + }; + assert_invalid_input_contains(err, "exceeding the mini-block limit"); + } + + #[derive(Debug, Clone)] + struct RecordingIo { + data: Bytes, + calls: Arc>>>>, + } + + impl RecordingIo { + fn new(data: Bytes) -> Self { + Self { + data, + calls: Arc::new(Mutex::new(Vec::new())), + } + } + } + + impl EncodingsIo for RecordingIo { + fn submit_request( + &self, + ranges: Vec>, + _priority: u64, + ) -> BoxFuture<'static, Result>> { + self.calls.lock().unwrap().push(ranges.clone()); + let data = self.data.clone(); + async move { + ranges + .into_iter() + .map(|range| { + let start = usize_from_u64(range.start, "test range start")?; + let end = usize_from_u64(range.end, "test range end")?; + if start > end || end > data.len() { + return Err(Error::invalid_input_source( + "Test I/O range is outside fixture data".into(), + )); + } + Ok(data.slice(start..end)) + }) + .collect() + } + .boxed() + } + } + + #[tokio::test] + async fn selective_read_requests_only_intersecting_value_chunk() { + let mut layout = sparse_layout(); + layout.num_items = 4; + layout.num_visible_items = 4; + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 16), (16, 32)], + 0, + 4, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + + let mut data = Vec::new(); + for _ in 0..2 { + data.extend_from_slice(&1_u32.to_le_bytes()); + data.extend_from_slice(&2_u32.to_le_bytes()); + } + for values in [[10_i32, 20], [30, 40]] { + data.extend_from_slice(&0_u16.to_le_bytes()); + data.extend_from_slice(&8_u16.to_le_bytes()); + data.extend_from_slice(&[0; 4]); + for value in values { + data.extend_from_slice(&value.to_le_bytes()); + } + } + + let io = Arc::new(RecordingIo::new(Bytes::from(data))); + let trait_io: Arc = io.clone(); + initialize_scheduler(&mut scheduler, &trait_io) + .await + .unwrap(); + let mut page_tasks = scheduler.schedule_ranges(&[2..3], &trait_io).unwrap(); + let page_task = page_tasks.pop().unwrap(); + let mut decoder = page_task.decoder_fut.await.unwrap(); + let decoded = decoder.drain(1).unwrap().decode().unwrap(); + assert_eq!(decoded.data.num_values(), 1); + + let calls = io.calls.lock().unwrap(); + assert_eq!(calls.as_slice(), &[vec![0..16], vec![32..48]]); + } + + #[tokio::test] + async fn empty_leaf_selection_rebuilds_offsets_without_value_io() { + let mut layout = sparse_layout(); + layout.num_visible_items = 0; + layout.structural_layers.push(list_layer( + 1, + 0, + position_empty(), + count_empty(), + null_positions(position_empty()), + )); + let decompressors = DefaultDecompressionStrategy::default(); + let mut scheduler = SparseStructuralScheduler::try_new( + &[(0, 0), (0, 0)], + 0, + 1, + DataType::Int32, + &layout, + &decompressors, + ) + .unwrap(); + let io = Arc::new(RecordingIo::new(Bytes::new())); + let trait_io: Arc = io.clone(); + initialize_scheduler(&mut scheduler, &trait_io) + .await + .unwrap(); + let mut page_tasks = scheduler.schedule_ranges(&[0..1], &trait_io).unwrap(); + let page_task = page_tasks.pop().unwrap(); + let mut decoder = page_task.decoder_fut.await.unwrap(); + let decoded = decoder.drain(1).unwrap().decode().unwrap(); + assert_eq!(decoded.data.num_values(), 0); + + let mut repdef = CompositeRepDefUnraveler::new(vec![decoded.repdef]); + let (offsets, validity) = repdef.unravel_offsets::().unwrap(); + assert_eq!(offsets.as_ref(), &[0, 0]); + assert!(validity.is_none()); + + let calls = io.calls.lock().unwrap(); + assert_eq!(calls.len(), 2); + assert!(calls[1].is_empty(), "value payload must not be requested"); + } +} diff --git a/rust/lance-encoding/src/encodings/logical/primitive/sparse/writer.rs b/rust/lance-encoding/src/encodings/logical/primitive/sparse/writer.rs new file mode 100644 index 00000000000..5ec1d32b5e1 --- /dev/null +++ b/rust/lance-encoding/src/encodings/logical/primitive/sparse/writer.rs @@ -0,0 +1,2407 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Sparse structural planning and serialization. + +use std::iter; + +use arrow_buffer::BooleanBuffer; +use lance_core::{Error, Result, datatypes::Field, utils::bit::pad_bytes}; + +use crate::{ + buffer::LanceBuffer, + compression::{CompressionStrategy, compress_required_block}, + data::{BlockInfo, DataBlock, FixedWidthDataBlock}, + decoder::PageEncoding, + encoder::EncodedPage, + format::pb21::{self, CompressiveEncoding}, + repdef::{NormalizedStructuralLayer, NormalizedStructuralPlan}, + statistics::ComputeStat, +}; + +use super::super::MiniblockChunkSize; + +use super::{ + SparseCountSet, SparsePositionSet, SparseStructuralLayerPlan, SparseStructuralPlan, + SparseValidityMeaning, SparseValiditySet, +}; +use crate::encodings::logical::primitive::{ + FILL_BYTE, MINIBLOCK_ALIGNMENT, + miniblock::{MiniBlockCompressed, MiniBlockCompressionContext}, +}; + +#[derive(Clone, Copy, Default)] +struct PositionSetStats { + count: u64, + first: u64, + last: u64, + is_contiguous: bool, +} + +impl PositionSetStats { + fn observe(&mut self, position: u64) { + if self.count == 0 { + self.first = position; + self.is_contiguous = true; + } else if self.last.checked_add(1) != Some(position) { + self.is_contiguous = false; + } + self.last = position; + self.count += 1; + } + + fn encoded_cost(&self, domain_len: u64) -> u64 { + if self.count == 0 || self.count == domain_len || self.is_contiguous { + 0 + } else { + self.count + } + } + + fn to_set( + self, + validity: &BooleanBuffer, + want_valid: bool, + domain_len: u64, + label: &str, + ) -> Result { + if self.count == 0 { + return Ok(SparsePositionSet::empty()); + } + if self.count == domain_len { + return Ok(SparsePositionSet::all(domain_len)); + } + if self.is_contiguous { + return Ok(SparsePositionSet::range(self.first, self.count)); + } + + let capacity = usize::try_from(self.count).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} positions exceed usize::MAX").into(), + ) + })?; + let mut positions = Vec::with_capacity(capacity); + for (index, is_valid) in validity.iter().enumerate() { + if is_valid == want_valid { + positions.push(u64::try_from(index).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} position exceeds u64::MAX").into(), + ) + })?); + } + } + SparsePositionSet::from_positions(positions, domain_len, label) + } +} + +fn usize_to_u64(value: usize, label: &str) -> Result { + u64::try_from(value).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural {label} {value} exceeds u64::MAX").into(), + ) + }) +} + +fn validity_set( + validity: Option<&BooleanBuffer>, + num_slots: usize, + label: &str, +) -> Result { + let Some(validity) = validity else { + return Ok(SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions: SparsePositionSet::empty(), + }); + }; + if validity.len() != num_slots { + return Err(Error::invalid_input_source( + format!( + "Sparse structural {label} validity length {} does not match {} slots", + validity.len(), + num_slots + ) + .into(), + )); + } + + let domain_len = usize_to_u64(num_slots, "validity domain")?; + let mut valid_stats = PositionSetStats::default(); + let mut null_stats = PositionSetStats::default(); + for (index, is_valid) in validity.iter().enumerate() { + let index = usize_to_u64(index, "validity position")?; + if is_valid { + valid_stats.observe(index); + } else { + null_stats.observe(index); + } + } + + if null_stats.count == 0 { + return Ok(SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions: SparsePositionSet::empty(), + }); + } + if valid_stats.count == 0 { + return Ok(SparseValiditySet { + meaning: SparseValidityMeaning::ValidPositions, + positions: SparsePositionSet::empty(), + }); + } + + let valid_cost = valid_stats.encoded_cost(domain_len); + let null_cost = null_stats.encoded_cost(domain_len); + if valid_cost < null_cost { + Ok(SparseValiditySet { + meaning: SparseValidityMeaning::ValidPositions, + positions: valid_stats.to_set(validity, true, domain_len, label)?, + }) + } else { + Ok(SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions: null_stats.to_set(validity, false, domain_len, label)?, + }) + } +} + +/// Builds the semantic sparse plan directly from the once-normalized Arrow layers. +pub(in crate::encodings::logical::primitive) fn plan( + normalized: &NormalizedStructuralPlan, + num_visible_items: u64, +) -> Result { + let mut layers = Vec::with_capacity(normalized.layers().len()); + let mut num_items = num_visible_items; + + for layer in normalized.layers() { + match layer { + NormalizedStructuralLayer::Validity { + validity, + num_slots, + } => { + layers.push(SparseStructuralLayerPlan::Validity { + num_slots: usize_to_u64(num_slots, "validity slot count")?, + validity: validity_set(validity, num_slots, "validity")?, + }); + } + NormalizedStructuralLayer::FixedSizeList { + validity, + dimension, + num_slots, + } => { + if dimension == 0 { + return Err(Error::invalid_input_source( + "Sparse structural fixed-size-list dimension is zero".into(), + )); + } + layers.push(SparseStructuralLayerPlan::FixedSizeList { + num_slots: usize_to_u64(num_slots, "fixed-size-list slot count")?, + dimension: usize_to_u64(dimension, "fixed-size-list dimension")?, + validity: validity_set(validity, num_slots, "fixed-size-list validity")?, + }); + } + NormalizedStructuralLayer::List { + offsets, + validity, + num_slots, + } => { + let expected_offsets = num_slots.checked_add(1).ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural list offset count overflows".into(), + ) + })?; + if offsets.len() != expected_offsets { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list has {} offsets for {} slots", + offsets.len(), + num_slots + ) + .into(), + )); + } + if offsets.first().copied() != Some(0) { + return Err(Error::invalid_input_source( + "Sparse structural list offsets must start at zero".into(), + )); + } + + let mut non_empty_positions = Vec::new(); + let mut counts = Vec::new(); + for slot in 0..num_slots { + let start = offsets[slot]; + let end = offsets[slot + 1]; + let count = end.checked_sub(start).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural list offsets decrease at slot {slot}: {start}..{end}" + ) + .into(), + ) + })?; + let is_valid = validity.is_none_or(|validity| validity.value(slot)); + if !is_valid && count != 0 { + return Err(Error::invalid_input_source( + format!( + "Sparse structural null list slot {slot} has {count} child slots" + ) + .into(), + )); + } + if is_valid && count > 0 { + non_empty_positions.push(usize_to_u64(slot, "list position")?); + counts.push(u64::try_from(count).map_err(|_| { + Error::invalid_input_source( + format!("Sparse structural list count {count} exceeds u64::MAX") + .into(), + ) + })?); + } + } + + let num_slots_u64 = usize_to_u64(num_slots, "list slot count")?; + let num_non_empty = usize_to_u64(non_empty_positions.len(), "list position count")?; + num_items = num_items + .checked_add(num_slots_u64 - num_non_empty) + .ok_or_else(|| { + Error::invalid_input_source( + "Sparse structural item count overflows u64".into(), + ) + })?; + let num_child_slots = offsets.last().copied().ok_or_else(|| { + Error::invalid_input_source("Sparse structural list has no offsets".into()) + })?; + let num_child_slots = u64::try_from(num_child_slots).map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse structural list child slot count {num_child_slots} is negative" + ) + .into(), + ) + })?; + layers.push(SparseStructuralLayerPlan::List { + num_slots: num_slots_u64, + num_child_slots, + non_empty_positions: SparsePositionSet::from_positions( + non_empty_positions, + num_slots_u64, + "list non-empty", + )?, + counts: SparseCountSet::from_counts(counts)?, + validity: validity_set(validity, num_slots, "list validity")?, + }); + } + } + } + + let row_domain = match layers.first() { + Some(SparseStructuralLayerPlan::Validity { num_slots, .. }) + | Some(SparseStructuralLayerPlan::List { num_slots, .. }) + | Some(SparseStructuralLayerPlan::FixedSizeList { num_slots, .. }) => *num_slots, + None => { + return Err(Error::invalid_input_source( + "Sparse structural encoding requires at least one Arrow structural layer".into(), + )); + } + }; + let plan = SparseStructuralPlan { + layers, + num_items, + num_visible_items, + }; + plan.validate(row_domain)?; + Ok(plan) +} + +/// ConstantLayout remains the canonical representation when no value payload exists. +pub(in crate::encodings::logical::primitive) fn uses_constant_layout( + plan: &SparseStructuralPlan, + field: &Field, +) -> bool { + if plan.num_visible_items == 0 { + return true; + } + if matches!(field.data_type(), arrow_schema::DataType::Struct(fields) if fields.is_empty()) { + return true; + } + + let Some(layer) = plan.layers.last() else { + return false; + }; + let (num_slots, validity) = match layer { + SparseStructuralLayerPlan::Validity { + num_slots, + validity, + } + | SparseStructuralLayerPlan::List { + num_slots, + validity, + .. + } + | SparseStructuralLayerPlan::FixedSizeList { + num_slots, + validity, + .. + } => (*num_slots, validity), + }; + match validity.meaning { + SparseValidityMeaning::NullPositions => validity.positions.len() == num_slots, + SparseValidityMeaning::ValidPositions => validity.positions.is_empty(), + } +} + +fn supports_fixed_size_list_values(data: &DataBlock) -> bool { + match data { + DataBlock::FixedWidth(_) => true, + DataBlock::FixedSizeList(list) => supports_fixed_size_list_values(list.child.as_ref()), + DataBlock::Nullable(nullable) => supports_fixed_size_list_values(nullable.data.as_ref()), + _ => false, + } +} + +/// Whether the sparse writer can encode this value block without changing its value path. +pub fn supports_value_block(data: &DataBlock) -> bool { + match data { + DataBlock::FixedWidth(_) | DataBlock::VariableWidth(_) => true, + DataBlock::Struct(data) => !data.has_variable_width_child(), + DataBlock::FixedSizeList(data) => supports_fixed_size_list_values(data.child.as_ref()), + DataBlock::Empty() + | DataBlock::Constant(_) + | DataBlock::AllNull(_) + | DataBlock::Nullable(_) + | DataBlock::Opaque(_) + | DataBlock::Dictionary(_) => false, + } +} + +struct SparseMiniBlockChunk { + buffer_sizes: Vec, + num_values: u32, +} + +struct SparseMiniBlockCompressed { + data: Vec, + chunks: Vec, +} + +struct SerializedValuePage { + num_buffers: u64, + data: LanceBuffer, + metadata: LanceBuffer, +} + +pub struct PreparedSparseValues { + num_values: u64, + value_compression: CompressiveEncoding, + values: SerializedValuePage, +} + +pub enum SparseValueInput { + Unprepared(DataBlock), + Prepared(PreparedSparseValues), +} + +struct EncodedStructuralPlan { + layers: Vec, + buffers: Vec, +} + +fn with_explicit_value_counts( + compressed: MiniBlockCompressed, +) -> Result { + let mut values_in_previous_chunks = 0_u64; + let mut chunks = Vec::with_capacity(compressed.chunks.len()); + for chunk in compressed.chunks { + let num_values = chunk.num_values(values_in_previous_chunks, compressed.num_values); + values_in_previous_chunks = values_in_previous_chunks + .checked_add(num_values) + .ok_or_else(|| Error::internal("Sparse value count overflows u64".to_string()))?; + chunks.push(SparseMiniBlockChunk { + buffer_sizes: chunk.buffer_sizes, + num_values: u32::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!( + "Sparse value chunk has {num_values} visible values, which exceeds the u32 metadata limit" + ) + .into(), + ) + })?, + }); + } + if values_in_previous_chunks != compressed.num_values { + return Err(Error::internal(format!( + "Sparse value chunks describe {values_in_previous_chunks} values, expected {}", + compressed.num_values + ))); + } + Ok(SparseMiniBlockCompressed { + data: compressed.data, + chunks, + }) +} + +fn serialize_value_chunks( + compressed: SparseMiniBlockCompressed, + miniblock_chunk_size: MiniblockChunkSize, +) -> Result { + let bytes_data = compressed.data.iter().map(LanceBuffer::len).sum::(); + let num_buffers = compressed.data.len(); + let mut data_buffer = Vec::with_capacity(bytes_data + 9 * num_buffers); + let mut metadata = Vec::with_capacity(compressed.chunks.len() * 8); + let mut buffer_offsets = vec![0_usize; num_buffers]; + + for chunk in compressed.chunks { + if chunk.buffer_sizes.len() != num_buffers { + return Err(Error::internal(format!( + "Sparse chunk has {} value buffer sizes, expected {num_buffers}", + chunk.buffer_sizes.len() + ))); + } + + let chunk_start = data_buffer.len(); + debug_assert_eq!(chunk_start % MINIBLOCK_ALIGNMENT, 0); + data_buffer.extend_from_slice(&0_u16.to_le_bytes()); + if miniblock_chunk_size == MiniblockChunkSize::U32 { + for buffer_size in &chunk.buffer_sizes { + data_buffer.extend_from_slice(&buffer_size.to_le_bytes()); + } + } else { + for buffer_size in &chunk.buffer_sizes { + let buffer_size = u16::try_from(*buffer_size).map_err(|_| { + Error::internal(format!( + "Sparse value buffer size ({buffer_size} bytes) exceeds 16-bit metadata" + )) + })?; + data_buffer.extend_from_slice(&buffer_size.to_le_bytes()); + } + } + let add_padding = |buffer: &mut Vec| { + let padding = pad_bytes::(buffer.len()); + buffer.extend(iter::repeat_n(FILL_BYTE, padding)); + }; + add_padding(&mut data_buffer); + + for (buffer_size, (buffer, buffer_offset)) in chunk + .buffer_sizes + .iter() + .zip(compressed.data.iter().zip(buffer_offsets.iter_mut())) + { + let start = *buffer_offset; + let end = start.checked_add(*buffer_size as usize).ok_or_else(|| { + Error::internal("Sparse value buffer range overflows".to_string()) + })?; + let bytes = buffer.as_ref().get(start..end).ok_or_else(|| { + Error::internal(format!( + "Sparse value chunk requests bytes {start}..{end} from a {}-byte buffer", + buffer.len() + )) + })?; + *buffer_offset = end; + data_buffer.extend_from_slice(bytes); + add_padding(&mut data_buffer); + } + + let chunk_bytes = data_buffer.len() - chunk_start; + if chunk_bytes == 0 || !chunk_bytes.is_multiple_of(MINIBLOCK_ALIGNMENT) { + return Err(Error::internal(format!( + "Sparse value chunk size {chunk_bytes} is not a positive multiple of {MINIBLOCK_ALIGNMENT}" + ))); + } + let words_minus_one = chunk_bytes / MINIBLOCK_ALIGNMENT - 1; + metadata.extend_from_slice( + &u32::try_from(words_minus_one) + .map_err(|_| { + Error::internal(format!( + "Sparse value chunk size {chunk_bytes} exceeds the metadata limit" + )) + })? + .to_le_bytes(), + ); + metadata.extend_from_slice(&chunk.num_values.to_le_bytes()); + } + + for (index, (consumed, buffer)) in buffer_offsets + .iter() + .zip(compressed.data.iter()) + .enumerate() + { + if *consumed != buffer.len() { + return Err(Error::internal(format!( + "Sparse value buffer {index} consumed {consumed} bytes, expected {}", + buffer.len() + ))); + } + } + + Ok(SerializedValuePage { + num_buffers: usize_to_u64(num_buffers, "value buffer count")?, + data: LanceBuffer::from(data_buffer), + metadata: LanceBuffer::from(metadata), + }) +} + +pub fn prepare_values( + field: &Field, + compression_strategy: &dyn CompressionStrategy, + data: DataBlock, + miniblock_chunk_size: MiniblockChunkSize, +) -> Result { + match &data { + DataBlock::AllNull(_) => { + return Err(Error::internal( + "All-null values must use ConstantLayout".to_string(), + )); + } + DataBlock::Dictionary(_) => { + return Err(Error::not_supported_source( + "Sparse layout does not support dictionary data blocks".into(), + )); + } + DataBlock::Struct(data) if data.has_variable_width_child() => { + return Err(Error::not_supported_source( + "Sparse layout does not support variable-width packed struct data blocks".into(), + )); + } + _ => {} + } + + let num_values = data.num_values(); + let compressor = compression_strategy.create_miniblock_compressor(field, &data)?; + let support_large_chunk = miniblock_chunk_size == MiniblockChunkSize::U32; + let compression_context = MiniBlockCompressionContext::new(0, support_large_chunk, false); + let (compressed, value_compression) = compressor.compress(compression_context, data)?; + let values = serialize_value_chunks( + with_explicit_value_counts(compressed)?, + miniblock_chunk_size, + )?; + Ok(PreparedSparseValues { + num_values, + value_compression, + values, + }) +} + +fn encode_u64_values( + values: Vec, + compression_strategy: &dyn CompressionStrategy, +) -> Result<(LanceBuffer, CompressiveEncoding)> { + let num_values = usize_to_u64(values.len(), "u64 value count")?; + let mut block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_vec(values), + bits_per_value: 64, + num_values, + block_info: BlockInfo::new(), + }); + block.compute_stat(); + let field = Field::new_arrow("", arrow_schema::DataType::UInt64, false)?; + compress_required_block(compression_strategy, &field, block) +} + +fn positions_to_deltas(positions: &[u64], label: &str) -> Result> { + let mut previous = 0_u64; + positions + .iter() + .copied() + .enumerate() + .map(|(index, position)| { + if index > 0 && position <= previous { + return Err(Error::invalid_input_source( + format!("Sparse structural {label} positions must be strictly increasing") + .into(), + )); + } + let delta = if index == 0 { + position + } else { + position - previous + }; + previous = position; + Ok(delta) + }) + .collect() +} + +fn encode_position_set( + positions: &SparsePositionSet, + compression_strategy: &dyn CompressionStrategy, + label: &str, +) -> Result<(Option, pb21::SparsePositionSet)> { + let (buffer, positions_pb) = match positions { + SparsePositionSet::Empty => ( + None, + pb21::sparse_position_set::Positions::Empty(pb21::SparsePositionEmpty {}), + ), + SparsePositionSet::All { .. } => ( + None, + pb21::sparse_position_set::Positions::All(pb21::SparsePositionAll {}), + ), + SparsePositionSet::Range { start, len } => ( + None, + pb21::sparse_position_set::Positions::Range(pb21::SparsePositionRange { + start: *start, + length: *len, + }), + ), + SparsePositionSet::Explicit(positions) => { + if positions.is_empty() { + return Err(Error::internal(format!( + "Sparse structural {label} explicit set is empty" + ))); + } + let (buffer, encoding) = + encode_u64_values(positions_to_deltas(positions, label)?, compression_strategy)?; + ( + Some(buffer), + pb21::sparse_position_set::Positions::Explicit(encoding), + ) + } + }; + Ok(( + buffer, + pb21::SparsePositionSet { + positions: Some(positions_pb), + num_positions: positions.len(), + }, + )) +} + +fn encode_count_set( + counts: &SparseCountSet, + compression_strategy: &dyn CompressionStrategy, +) -> Result<(Option, pb21::SparseCountSet)> { + let (buffer, counts_pb) = match counts { + SparseCountSet::Empty => ( + None, + pb21::sparse_count_set::Counts::Empty(pb21::SparseCountEmpty {}), + ), + SparseCountSet::Constant { value, .. } => ( + None, + pb21::sparse_count_set::Counts::Constant(pb21::SparseCountConstant { value: *value }), + ), + SparseCountSet::Explicit { counts, .. } => { + if counts.is_empty() { + return Err(Error::internal( + "Sparse structural explicit count set is empty".to_string(), + )); + } + let (buffer, encoding) = encode_u64_values(counts.to_vec(), compression_strategy)?; + ( + Some(buffer), + pb21::sparse_count_set::Counts::Explicit(encoding), + ) + } + }; + Ok(( + buffer, + pb21::SparseCountSet { + counts: Some(counts_pb), + }, + )) +} + +fn encode_validity_set( + validity: &SparseValiditySet, + compression_strategy: &dyn CompressionStrategy, + label: &str, +) -> Result<(Option, pb21::SparseValiditySet)> { + let (buffer, positions) = + encode_position_set(&validity.positions, compression_strategy, label)?; + let meaning = match validity.meaning { + SparseValidityMeaning::NullPositions => { + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions + } + SparseValidityMeaning::ValidPositions => { + pb21::sparse_validity_set::Meaning::SparseValidityValidPositions + } + }; + Ok(( + buffer, + pb21::SparseValiditySet { + meaning: meaning as i32, + positions: Some(positions), + }, + )) +} + +fn encode_structural_plan( + plan: &SparseStructuralPlan, + compression_strategy: &dyn CompressionStrategy, +) -> Result { + let mut layers = Vec::with_capacity(plan.layers.len()); + let mut buffers = Vec::new(); + + for layer in &plan.layers { + match layer { + SparseStructuralLayerPlan::Validity { + num_slots, + validity, + } => { + let (validity_buffer, validity) = + encode_validity_set(validity, compression_strategy, "validity")?; + buffers.extend(validity_buffer); + layers.push(pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::Validity( + pb21::SparseValidityLayer { + num_slots: *num_slots, + validity: Some(validity), + }, + )), + }); + } + SparseStructuralLayerPlan::List { + num_slots, + num_child_slots, + non_empty_positions, + counts, + validity, + } => { + if non_empty_positions.len() != counts.len() { + return Err(Error::invalid_input_source( + format!( + "Sparse structural list has {} non-empty positions but {} counts", + non_empty_positions.len(), + counts.len() + ) + .into(), + )); + } + let (position_buffer, non_empty_positions) = encode_position_set( + non_empty_positions, + compression_strategy, + "list non-empty", + )?; + buffers.extend(position_buffer); + let (count_buffer, counts) = encode_count_set(counts, compression_strategy)?; + buffers.extend(count_buffer); + let (validity_buffer, validity) = + encode_validity_set(validity, compression_strategy, "list validity")?; + buffers.extend(validity_buffer); + layers.push(pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::List( + pb21::SparseListLayer { + num_slots: *num_slots, + num_child_slots: *num_child_slots, + non_empty_positions: Some(non_empty_positions), + counts: Some(counts), + validity: Some(validity), + }, + )), + }); + } + SparseStructuralLayerPlan::FixedSizeList { + num_slots, + dimension, + validity, + } => { + let (validity_buffer, validity) = encode_validity_set( + validity, + compression_strategy, + "fixed-size-list validity", + )?; + buffers.extend(validity_buffer); + num_slots.checked_mul(*dimension).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Sparse structural fixed-size-list child slot count overflows: slots={num_slots}, dimension={dimension}" + ) + .into(), + ) + })?; + layers.push(pb21::SparseStructuralLayer { + layer: Some(pb21::sparse_structural_layer::Layer::FixedSizeList( + pb21::SparseFixedSizeListLayer { + num_slots: *num_slots, + dimension: *dimension, + validity: Some(validity), + }, + )), + }); + } + } + } + + Ok(EncodedStructuralPlan { layers, buffers }) +} + +#[allow(clippy::too_many_arguments)] +pub(in crate::encodings::logical::primitive) fn encode_page( + column_idx: u32, + field: &Field, + compression_strategy: &dyn CompressionStrategy, + values: SparseValueInput, + plan: SparseStructuralPlan, + row_number: u64, + num_rows: u64, + miniblock_chunk_size: MiniblockChunkSize, +) -> Result { + let PreparedSparseValues { + num_values, + value_compression, + values, + } = match values { + SparseValueInput::Unprepared(data) => { + prepare_values(field, compression_strategy, data, miniblock_chunk_size)? + } + SparseValueInput::Prepared(prepared) => prepared, + }; + if plan.num_visible_items != num_values { + return Err(Error::internal(format!( + "Sparse structural plan has {} visible items but data has {} values", + plan.num_visible_items, num_values + ))); + } + let structural = encode_structural_plan(&plan, compression_strategy)?; + let description = pb21::PageLayout { + layout: Some(pb21::page_layout::Layout::SparseLayout( + pb21::SparseLayout { + value_compression: Some(value_compression), + num_buffers: values.num_buffers, + num_items: plan.num_items, + num_visible_items: plan.num_visible_items, + has_large_chunk: miniblock_chunk_size == MiniblockChunkSize::U32, + structural_layers: structural.layers, + }, + )), + }; + + let mut page_data = Vec::with_capacity(2 + structural.buffers.len()); + page_data.push(values.metadata); + page_data.push(values.data); + page_data.extend(structural.buffers); + Ok(EncodedPage { + data: page_data, + description: PageEncoding::Structural(description), + num_rows, + row_number, + column_idx, + }) +} + +#[cfg(test)] +mod tests { + use std::{collections::HashMap, sync::Arc}; + + use arrow_array::{ + Array, ArrayRef, DictionaryArray, FixedSizeBinaryArray, FixedSizeListArray, Int8Array, + Int32Array, LargeListArray, ListArray, StringArray, StructArray, + builder::{Int32Builder, MapBuilder, StringBuilder}, + types::Int8Type, + }; + use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer}; + use arrow_schema::{DataType, Field as ArrowField, Fields}; + + use crate::{ + constants::{ + PACKED_STRUCT_META_KEY, STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, + STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_SPARSE, + }, + data::FixedSizeListBlock, + encoder::{ + ColumnIndexSequence, EncodingOptions, FieldEncoder, MIN_PAGE_BUFFER_ALIGNMENT, + OutOfLineBuffers, + }, + testing::{ + TestCases, TestEncoding, check_round_trip_encoding_of_data, test_encoding_strategy, + }, + }; + + use super::*; + + fn sparse_metadata() -> HashMap { + HashMap::from([( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_SPARSE.to_string(), + )]) + } + + fn structural_metadata(value: &str) -> HashMap { + HashMap::from([(STRUCTURAL_ENCODING_META_KEY.to_string(), value.to_string())]) + } + + fn null_buffer(validity: impl IntoIterator) -> NullBuffer { + NullBuffer::new(BooleanBuffer::from_iter(validity)) + } + + fn list_i32(offsets: Vec, validity: Option>) -> ArrayRef { + let num_values = offsets.last().copied().unwrap_or_default(); + let values = Arc::new(Int32Array::from_iter_values(0..num_values)) as ArrayRef; + Arc::new( + ListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Int32, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + values, + validity.map(null_buffer), + ) + .unwrap(), + ) + } + + fn sparse_list_values( + num_rows: usize, + stride: usize, + values: ArrayRef, + item_field: Arc, + ) -> ArrayRef { + let mut offsets = Vec::with_capacity(num_rows + 1); + let mut num_values = 0_i32; + offsets.push(num_values); + for row in 0..num_rows { + if (row + 1).is_multiple_of(stride) || row + 1 == num_rows { + num_values += 1; + } + offsets.push(num_values); + } + assert_eq!(values.len(), num_values as usize); + Arc::new( + ListArray::try_new( + item_field, + OffsetBuffer::new(ScalarBuffer::from(offsets)), + values, + None, + ) + .unwrap(), + ) + } + + fn sparse_i32_list(num_rows: usize, stride: usize) -> ArrayRef { + let num_values = num_rows.div_ceil(stride); + sparse_list_values( + num_rows, + stride, + Arc::new(Int32Array::from_iter_values(0..num_values as i32)), + Arc::new(ArrowField::new("item", DataType::Int32, true)), + ) + } + + fn unsplittable_nested_list(values: ArrayRef, item_field: Arc) -> ArrayRef { + const NUM_INNER_LISTS: usize = 70_000; + + let mut inner_offsets = vec![0_i32; NUM_INNER_LISTS + 1]; + assert!(!values.is_empty()); + assert!(values.len() <= NUM_INNER_LISTS); + for value_index in 1..=values.len() { + inner_offsets[NUM_INNER_LISTS - values.len() + value_index] = value_index as i32; + } + let inner = Arc::new( + ListArray::try_new( + item_field, + OffsetBuffer::new(ScalarBuffer::from(inner_offsets)), + values, + None, + ) + .unwrap(), + ) as ArrayRef; + Arc::new( + ListArray::try_new( + Arc::new(ArrowField::new("item", inner.data_type().clone(), true)), + OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, NUM_INNER_LISTS as i32])), + inner, + None, + ) + .unwrap(), + ) + } + + fn variable_packed_struct_values(num_values: usize) -> (ArrayRef, Arc) { + let fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let values = Arc::new(StructArray::new( + fields.clone(), + vec![Arc::new(StringArray::from_iter_values( + (0..num_values).map(|index| format!("value-{index}")), + ))], + None, + )) as ArrayRef; + let item_field = Arc::new( + ArrowField::new("item", DataType::Struct(fields), true).with_metadata(HashMap::from([ + (PACKED_STRUCT_META_KEY.to_string(), "true".to_string()), + ])), + ); + (values, item_field) + } + + fn dictionary_values(num_values: usize) -> (ArrayRef, Arc) { + let keys = Int8Array::from_iter_values((0..num_values).map(|index| (index % 2) as i8)); + let values = Arc::new(StringArray::from(vec!["value-0", "value-1"])); + let dictionary = Arc::new(DictionaryArray::::try_new(keys, values).unwrap()); + let item_field = Arc::new(ArrowField::new( + "item", + dictionary.data_type().clone(), + true, + )); + (dictionary, item_field) + } + + fn large_list_i32(offsets: Vec, validity: Option>) -> ArrayRef { + let num_values = offsets.last().copied().unwrap_or_default(); + let values = Arc::new(Int32Array::from_iter_values(0..num_values as i32)) as ArrayRef; + Arc::new( + LargeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Int32, true)), + OffsetBuffer::new(ScalarBuffer::from(offsets)), + values, + validity.map(null_buffer), + ) + .unwrap(), + ) + } + + fn map_i32() -> ArrayRef { + let mut builder = MapBuilder::new(None, StringBuilder::new(), Int32Builder::new()); + builder.keys().append_value("a"); + builder.values().append_value(1); + builder.append(true).unwrap(); + builder.append(false).unwrap(); + builder.append(true).unwrap(); + builder.keys().append_value("b"); + builder.values().append_null(); + builder.keys().append_value("c"); + builder.values().append_value(3); + builder.append(true).unwrap(); + Arc::new(builder.finish()) + } + + fn fixed_size_list_struct() -> ArrayRef { + let fields = Fields::from(vec![ArrowField::new("value", DataType::Int32, true)]); + let child = Arc::new(StructArray::new( + fields.clone(), + vec![Arc::new(Int32Array::from(vec![ + Some(0), + Some(1), + None, + Some(3), + Some(4), + Some(5), + None, + Some(7), + Some(8), + Some(9), + Some(10), + None, + ]))], + Some(null_buffer([ + true, true, false, true, true, true, true, false, true, true, true, true, + ])), + )) as ArrayRef; + Arc::new( + FixedSizeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Struct(fields), true)), + 2, + child, + Some(null_buffer([true, false, true, true, false, true])), + ) + .unwrap(), + ) + } + + fn list_fixed_size_list_struct() -> ArrayRef { + let struct_fields = Fields::from(vec![ArrowField::new("value", DataType::Int32, true)]); + let structs = Arc::new(StructArray::new( + struct_fields.clone(), + vec![Arc::new(Int32Array::from(vec![ + Some(0), + None, + Some(2), + Some(3), + None, + Some(5), + ]))], + Some(null_buffer([true, true, false, true, true, true])), + )) as ArrayRef; + let fixed_size_list = Arc::new( + FixedSizeListArray::try_new( + Arc::new(ArrowField::new( + "item", + DataType::Struct(struct_fields), + true, + )), + 2, + structs, + Some(null_buffer([true, false, true])), + ) + .unwrap(), + ) as ArrayRef; + Arc::new( + ListArray::try_new( + Arc::new(ArrowField::new( + "item", + fixed_size_list.data_type().clone(), + true, + )), + OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, 1, 1, 3, 3])), + fixed_size_list, + Some(null_buffer([true, false, true, true])), + ) + .unwrap(), + ) + } + + fn nullable_struct() -> ArrayRef { + let fields = Fields::from(vec![ArrowField::new("value", DataType::Int32, true)]); + Arc::new(StructArray::new( + fields, + vec![Arc::new(Int32Array::from(vec![ + Some(10), + Some(20), + None, + Some(40), + Some(50), + ]))], + Some(null_buffer([true, false, true, true, false])), + )) + } + + fn deeply_nested() -> ArrayRef { + let leaf = Arc::new(Int32Array::from(vec![ + Some(0), + None, + Some(2), + Some(3), + None, + Some(5), + Some(6), + Some(7), + ])) as ArrayRef; + let inner = Arc::new( + LargeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Int32, true)), + OffsetBuffer::new(ScalarBuffer::from(vec![0_i64, 2, 2, 3, 5, 5, 8])), + leaf, + Some(null_buffer([true, false, true, true, true, true])), + ) + .unwrap(), + ) as ArrayRef; + let struct_fields = Fields::from(vec![ArrowField::new( + "inner", + inner.data_type().clone(), + true, + )]); + let structs = Arc::new(StructArray::new( + struct_fields.clone(), + vec![inner], + Some(null_buffer([true, true, false, true, true, true])), + )) as ArrayRef; + Arc::new( + ListArray::try_new( + Arc::new(ArrowField::new( + "item", + DataType::Struct(struct_fields), + true, + )), + OffsetBuffer::new(ScalarBuffer::from(vec![0_i32, 2, 2, 2, 4, 6])), + structs, + Some(null_buffer([true, false, true, true, true])), + ) + .unwrap(), + ) + } + + fn page_layout(page: &EncodedPage) -> &pb21::page_layout::Layout { + let PageEncoding::Structural(layout) = &page.description else { + panic!("expected structural page encoding"); + }; + layout.layout.as_ref().expect("page layout must be present") + } + + fn create_encoder( + array: &ArrayRef, + version: TestEncoding, + metadata: HashMap, + ) -> Result> { + let arrow_field = + ArrowField::new("values", array.data_type().clone(), true).with_metadata(metadata); + let field = Field::try_from(&arrow_field)?; + let strategy = test_encoding_strategy(version); + let options = EncodingOptions { + cache_bytes_per_column: 1, + ..Default::default() + }; + crate::testing::create_test_field_encoder( + strategy.as_ref(), + &field, + &mut ColumnIndexSequence::default(), + &options, + ) + } + + async fn encode_pages( + array: ArrayRef, + version: TestEncoding, + metadata: HashMap, + ) -> Result> { + encode_chunks(vec![array], version, metadata).await + } + + async fn encode_chunks( + arrays: Vec, + version: TestEncoding, + metadata: HashMap, + ) -> Result> { + let first = arrays + .first() + .ok_or_else(|| Error::internal("test input has no arrays".to_string()))?; + let mut encoder = create_encoder(first, version, metadata)?; + let mut external_buffers = OutOfLineBuffers::new(0, MIN_PAGE_BUFFER_ALIGNMENT); + let mut pages = Vec::new(); + let mut row_number = 0_u64; + for array in arrays { + let num_rows = array.len() as u64; + for task in encoder.maybe_encode( + array, + &mut external_buffers, + crate::repdef::RepDefBuilder::default(), + row_number, + num_rows, + )? { + pages.push(task.await?); + } + row_number += num_rows; + } + for task in encoder.flush(&mut external_buffers)? { + pages.push(task.await?); + } + for column in encoder.finish(&mut external_buffers).await? { + pages.extend(column.final_pages); + } + Ok(pages) + } + + fn sparse_layout(page: &EncodedPage) -> &pb21::SparseLayout { + let pb21::page_layout::Layout::SparseLayout(sparse) = page_layout(page) else { + panic!("expected SparseLayout, got {:?}", page_layout(page)); + }; + sparse + } + + fn list_layer(sparse: &pb21::SparseLayout) -> &pb21::SparseListLayer { + sparse + .structural_layers + .iter() + .find_map(|layer| match layer.layer.as_ref() { + Some(pb21::sparse_structural_layer::Layer::List(layer)) => Some(layer), + _ => None, + }) + .expect("expected sparse list layer") + } + + fn validity_layer(layer: &pb21::SparseStructuralLayer) -> Option<&pb21::SparseValidityLayer> { + match layer.layer.as_ref() { + Some(pb21::sparse_structural_layer::Layer::Validity(layer)) => Some(layer), + _ => None, + } + } + + fn layer_num_slots(layer: &pb21::SparseStructuralLayer) -> u64 { + match layer.layer.as_ref().expect("expected sparse layer variant") { + pb21::sparse_structural_layer::Layer::Validity(layer) => layer.num_slots, + pb21::sparse_structural_layer::Layer::List(layer) => layer.num_slots, + pb21::sparse_structural_layer::Layer::FixedSizeList(layer) => layer.num_slots, + } + } + + fn fixed_size_list_dimension(layer: &pb21::SparseStructuralLayer) -> Option { + match layer.layer.as_ref() { + Some(pb21::sparse_structural_layer::Layer::FixedSizeList(layer)) => { + Some(layer.dimension) + } + _ => None, + } + } + + #[test] + fn test_sparse_value_block_eligibility() { + let fixed = DataBlock::from_array(Int32Array::from_iter_values(0..4)); + assert!(supports_value_block(&fixed)); + + let variable = DataBlock::from_array(StringArray::from(vec!["a", "b"])); + assert!(supports_value_block(&variable)); + + let nullable = DataBlock::from_array(Int32Array::from(vec![Some(1), None])); + assert!(!supports_value_block(&nullable)); + + let all_null = DataBlock::from_array(Int32Array::from(vec![None, None])); + assert!(!supports_value_block(&all_null)); + + let (dictionary, _) = dictionary_values(4); + assert!(!supports_value_block(&DataBlock::from_arrays( + &[dictionary], + 4 + ))); + + let fixed_fields = Fields::from(vec![ArrowField::new("value", DataType::Int32, false)]); + let fixed_struct = StructArray::new( + fixed_fields, + vec![Arc::new(Int32Array::from_iter_values(0..4))], + None, + ); + assert!(supports_value_block(&DataBlock::from_array(fixed_struct))); + + let variable_fields = Fields::from(vec![ArrowField::new("value", DataType::Utf8, false)]); + let variable_struct = StructArray::new( + variable_fields, + vec![Arc::new(StringArray::from(vec!["a", "b"]))], + None, + ); + assert!(!supports_value_block(&DataBlock::from_array( + variable_struct + ))); + + let fixed_size_list = FixedSizeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Int32, false)), + 2, + Arc::new(Int32Array::from_iter_values(0..4)), + None, + ) + .unwrap(); + assert!(supports_value_block(&DataBlock::from_array( + fixed_size_list + ))); + + let unsupported_fixed_size_list = DataBlock::FixedSizeList(FixedSizeListBlock { + child: Box::new(DataBlock::from_array(StringArray::from(vec![ + "a", "b", "c", "d", + ]))), + dimension: 2, + }); + assert!(!supports_value_block(&unsupported_fixed_size_list)); + } + + fn planned_list( + offsets: Vec, + list_validity: Option>, + leaf_validity: Option>, + ) -> SparseStructuralPlan { + let num_values = u64::try_from(*offsets.last().unwrap()).unwrap(); + let mut builder = crate::repdef::RepDefBuilder::default(); + assert!(!builder.add_offsets( + OffsetBuffer::new(ScalarBuffer::from(offsets)), + list_validity.map(null_buffer), + )); + if let Some(leaf_validity) = leaf_validity { + builder.add_validity_bitmap(null_buffer(leaf_validity)); + } else { + builder.add_no_null(num_values as usize); + } + let normalized = crate::repdef::RepDefBuilder::normalize(vec![builder]); + plan(&normalized, num_values).unwrap() + } + + fn planned_list_layer(plan: &SparseStructuralPlan) -> &SparseStructuralLayerPlan { + plan.layers + .iter() + .find(|layer| matches!(layer, SparseStructuralLayerPlan::List { .. })) + .expect("expected planned list layer") + } + + #[test] + fn test_semantic_position_and_count_forms() { + let empty = planned_list(vec![0, 0, 0, 0], None, None); + assert_eq!(empty.num_items, 3); + assert_eq!(empty.num_visible_items, 0); + assert!(matches!( + planned_list_layer(&empty), + SparseStructuralLayerPlan::List { + non_empty_positions: SparsePositionSet::Empty, + counts: SparseCountSet::Empty, + .. + } + )); + + let all = planned_list(vec![0, 2, 4, 6], None, None); + assert_eq!(all.num_items, 6); + assert!(matches!( + planned_list_layer(&all), + SparseStructuralLayerPlan::List { + non_empty_positions: SparsePositionSet::All { len: 3 }, + counts: SparseCountSet::Constant { value: 2, len: 3 }, + .. + } + )); + + let range = planned_list(vec![0, 2, 4, 4, 4], None, None); + assert_eq!(range.num_items, 6); + assert!(matches!( + planned_list_layer(&range), + SparseStructuralLayerPlan::List { + non_empty_positions: SparsePositionSet::Range { start: 0, len: 2 }, + counts: SparseCountSet::Constant { value: 2, len: 2 }, + .. + } + )); + + let explicit = planned_list(vec![0, 1, 1, 4, 4, 6], None, None); + assert_eq!(explicit.num_items, 8); + assert!(matches!( + planned_list_layer(&explicit), + SparseStructuralLayerPlan::List { + non_empty_positions: SparsePositionSet::Explicit(positions), + counts: SparseCountSet::Explicit { counts, .. }, + .. + } if positions == &vec![0, 2, 4] && counts.as_ref() == [1, 3, 2] + )); + } + + #[test] + fn test_validity_polarity_uses_semantic_encoded_cost() { + let mostly_valid = BooleanBuffer::from_iter([true, false, true, true, false, true]); + let validity = validity_set(Some(&mostly_valid), mostly_valid.len(), "test").unwrap(); + assert_eq!(validity.meaning, SparseValidityMeaning::NullPositions); + assert!(matches!(validity.positions, SparsePositionSet::Explicit(ref p) if p == &[1, 4])); + + let mostly_null = BooleanBuffer::from_iter([false, true, false, false, true, false]); + let validity = validity_set(Some(&mostly_null), mostly_null.len(), "test").unwrap(); + assert_eq!(validity.meaning, SparseValidityMeaning::ValidPositions); + assert!(matches!(validity.positions, SparsePositionSet::Explicit(ref p) if p == &[1, 4])); + + let valid_island = BooleanBuffer::from_iter([false, false, true, true, false]); + let validity = validity_set(Some(&valid_island), valid_island.len(), "test").unwrap(); + assert_eq!(validity.meaning, SparseValidityMeaning::ValidPositions); + assert!(matches!( + validity.positions, + SparsePositionSet::Range { start: 2, len: 2 } + )); + + let all_valid = BooleanBuffer::from_iter([true, true, true]); + let validity = validity_set(Some(&all_valid), all_valid.len(), "test").unwrap(); + assert_eq!(validity.meaning, SparseValidityMeaning::NullPositions); + assert!(matches!(validity.positions, SparsePositionSet::Empty)); + + let all_null = BooleanBuffer::from_iter([false, false, false]); + let validity = validity_set(Some(&all_null), all_null.len(), "test").unwrap(); + assert_eq!(validity.meaning, SparseValidityMeaning::ValidPositions); + assert!(matches!(validity.positions, SparsePositionSet::Empty)); + } + + #[tokio::test] + async fn test_explicit_sparse_nullable_primitive_roundtrip() { + let array = Arc::new(Int32Array::from(vec![ + Some(10), + None, + Some(20), + Some(30), + None, + Some(40), + ])) as ArrayRef; + let pages = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert_eq!(pages.len(), 1); + let sparse = sparse_layout(&pages[0]); + assert_eq!(sparse.num_items, 6); + assert_eq!(sparse.num_visible_items, 6); + assert_eq!(sparse.structural_layers.len(), 1); + let validity = validity_layer(&sparse.structural_layers[0]) + .unwrap() + .validity + .as_ref() + .unwrap(); + assert_eq!( + validity.meaning, + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions as i32 + ); + assert!(matches!( + validity.positions.as_ref().unwrap().positions, + Some(pb21::sparse_position_set::Positions::Explicit(_)) + )); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..5) + .with_indices(vec![0, 2, 5]); + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_nullable_struct_roundtrip() { + let array = nullable_struct(); + let pages = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert_eq!(pages.len(), 1); + let sparse = sparse_layout(&pages[0]); + assert_eq!(sparse.structural_layers.len(), 2); + assert!( + sparse + .structural_layers + .iter() + .all(|layer| validity_layer(layer).is_some()) + ); + let struct_validity = validity_layer(&sparse.structural_layers[0]) + .unwrap() + .validity + .as_ref() + .unwrap(); + assert_eq!( + struct_validity.meaning, + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions as i32 + ); + assert!(matches!( + struct_validity.positions.as_ref().unwrap().positions, + Some(pb21::sparse_position_set::Positions::Explicit(_)) + )); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..5) + .with_indices(vec![0, 2, 4]); + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_struct_with_constant_and_sparse_children() { + let fields = Fields::from(vec![ + ArrowField::new("constant", DataType::Int32, true), + ArrowField::new("sparse", DataType::Int32, true), + ]); + let array = Arc::new(StructArray::new( + fields, + vec![ + Arc::new(Int32Array::from(vec![None::; 5])), + Arc::new(Int32Array::from(vec![ + Some(10), + Some(20), + None, + Some(40), + Some(50), + ])), + ], + Some(null_buffer([true, false, true, true, false])), + )) as ArrayRef; + let pages = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(pages.iter().any(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::ConstantLayout(_) + ))); + assert!(pages.iter().any(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::SparseLayout(_) + ))); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..5) + .with_indices(vec![0, 2, 4]); + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_emits_both_validity_polarities() { + let mostly_valid = Arc::new(Int32Array::from(vec![ + Some(0), + None, + Some(2), + Some(3), + None, + Some(5), + ])) as ArrayRef; + let mostly_null = Arc::new(Int32Array::from(vec![ + None, + Some(1), + None, + None, + Some(4), + None, + ])) as ArrayRef; + + let null_positions = encode_pages( + mostly_valid.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + let validity = validity_layer(&sparse_layout(&null_positions[0]).structural_layers[0]) + .unwrap() + .validity + .as_ref() + .unwrap(); + assert_eq!( + validity.meaning, + pb21::sparse_validity_set::Meaning::SparseValidityNullPositions as i32 + ); + + let valid_positions = encode_pages( + mostly_null.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + let validity = validity_layer(&sparse_layout(&valid_positions[0]).structural_layers[0]) + .unwrap() + .validity + .as_ref() + .unwrap(); + assert_eq!( + validity.meaning, + pb21::sparse_validity_set::Meaning::SparseValidityValidPositions as i32 + ); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..5) + .with_indices(vec![0, 2, 5]); + for array in [mostly_valid, mostly_null] { + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + } + + #[tokio::test] + async fn test_explicit_sparse_nested_page_boundaries_range_and_take() { + let nested = deeply_nested(); + let chunks = vec![nested.slice(0, 2), nested.slice(2, 3)]; + let pages = encode_chunks( + chunks.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(pages.len() >= 2, "expected multiple sparse pages"); + let sparse = pages + .iter() + .map(sparse_layout) + .collect::>(); + assert!(sparse.iter().any(|layout| { + layout + .structural_layers + .iter() + .filter(|layer| { + matches!( + layer.layer.as_ref(), + Some(pb21::sparse_structural_layer::Layer::List(_)) + ) + }) + .count() + >= 2 + })); + assert!(sparse.iter().any(|layout| { + layout + .structural_layers + .iter() + .any(|layer| validity_layer(layer).is_some()) + })); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_batch_size(2) + .with_range(1..5) + .with_range(2..4) + .with_indices(vec![0, 2, 4]) + .with_indices(vec![1, 3]); + check_round_trip_encoding_of_data(chunks, &cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_list_and_large_list_null_empty_roundtrip() { + let list = list_i32( + vec![0, 0, 0, 2, 3, 3], + Some(vec![false, true, true, true, true]), + ); + let large_list = large_list_i32( + vec![0, 0, 0, 2, 3, 3], + Some(vec![false, true, true, true, true]), + ); + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(0..5) + .with_range(1..4) + .with_indices(vec![0, 1, 4]) + .with_indices(vec![2, 3]); + + for array in [list, large_list] { + let pages = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(pages.iter().map(sparse_layout).all(|layout| { + layout.structural_layers.iter().any(|layer| { + matches!( + layer.layer.as_ref(), + Some(pb21::sparse_structural_layer::Layer::List(_)) + ) + }) + })); + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + } + + #[tokio::test] + async fn test_explicit_sparse_map_and_fixed_size_list_roundtrip() { + let map = map_i32(); + let map_pages = encode_pages( + map.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(map_pages.iter().map(sparse_layout).any(|layout| { + layout.structural_layers.iter().any(|layer| { + matches!( + layer.layer.as_ref(), + Some(pb21::sparse_structural_layer::Layer::List(_)) + ) + }) + })); + let map_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..4) + .with_indices(vec![0, 2, 3]); + check_round_trip_encoding_of_data(vec![map], &map_cases, sparse_metadata()).await; + + let fsl = fixed_size_list_struct(); + let fsl_pages = encode_pages( + fsl.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + for page in &fsl_pages { + let layout = sparse_layout(page); + let outer_slots = layer_num_slots(layout.structural_layers.first().unwrap()); + let fixed_size_scale = layout + .structural_layers + .iter() + .filter_map(fixed_size_list_dimension) + .product::(); + assert_eq!(page.num_rows, outer_slots * fixed_size_scale); + } + assert!(fsl_pages.iter().map(sparse_layout).any(|layout| { + layout + .structural_layers + .iter() + .any(|layer| fixed_size_list_dimension(layer) == Some(2)) + })); + let fsl_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..6) + .with_indices(vec![0, 3, 5]); + check_round_trip_encoding_of_data(vec![fsl], &fsl_cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_list_fixed_size_list_struct_roundtrip() { + let array = list_fixed_size_list_struct(); + let pages = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(pages.iter().map(sparse_layout).any(|layout| { + let kinds = layout + .structural_layers + .iter() + .map(|layer| match layer.layer.as_ref().unwrap() { + pb21::sparse_structural_layer::Layer::Validity(_) => "validity", + pb21::sparse_structural_layer::Layer::List(_) => "list", + pb21::sparse_structural_layer::Layer::FixedSizeList(_) => "fixed-size-list", + }) + .collect::>(); + kinds.starts_with(&["list", "fixed-size-list"]) + })); + for page in &pages { + let layout = sparse_layout(page); + let outer_slots = layer_num_slots(layout.structural_layers.first().unwrap()); + assert_eq!(page.num_rows, outer_slots * 2); + } + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..4) + .with_indices(vec![0, 2, 3]) + .with_indices(vec![1, 3]); + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + + #[tokio::test] + async fn test_explicit_sparse_serializes_semantic_list_forms() { + let all_array = list_i32(vec![0, 2, 4, 6], None); + let all = encode_pages( + all_array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + let all_layer = list_layer(sparse_layout(&all[0])); + assert!(matches!( + all_layer.non_empty_positions.as_ref().unwrap().positions, + Some(pb21::sparse_position_set::Positions::All(_)) + )); + assert!(matches!( + all_layer.counts.as_ref().unwrap().counts, + Some(pb21::sparse_count_set::Counts::Constant( + pb21::SparseCountConstant { value: 2 } + )) + )); + assert_eq!(all[0].data.len(), 2); + + let range_array = list_i32(vec![0, 2, 4, 4, 4], None); + let range = encode_pages( + range_array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + let range_layer = list_layer(sparse_layout(&range[0])); + assert!(matches!( + range_layer.non_empty_positions.as_ref().unwrap().positions, + Some(pb21::sparse_position_set::Positions::Range( + pb21::SparsePositionRange { + start: 0, + length: 2 + } + )) + )); + assert!(matches!( + range_layer.counts.as_ref().unwrap().counts, + Some(pb21::sparse_count_set::Counts::Constant( + pb21::SparseCountConstant { value: 2 } + )) + )); + assert_eq!(range[0].data.len(), 2); + + let explicit_array = list_i32(vec![0, 1, 1, 4, 4, 6], None); + let explicit = encode_pages( + explicit_array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + let explicit_layer = list_layer(sparse_layout(&explicit[0])); + assert!(matches!( + explicit_layer + .non_empty_positions + .as_ref() + .unwrap() + .positions, + Some(pb21::sparse_position_set::Positions::Explicit(_)) + )); + assert!(matches!( + explicit_layer.counts.as_ref().unwrap().counts, + Some(pb21::sparse_count_set::Counts::Explicit(_)) + )); + assert_eq!(explicit[0].data.len(), 4); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1]) + .with_range(1..3) + .with_indices(vec![0, 2]); + for array in [all_array, range_array, explicit_array] { + check_round_trip_encoding_of_data(vec![array], &cases, sparse_metadata()).await; + } + } + + #[tokio::test] + async fn test_constant_layout_boundary_is_explicit() { + let structural_only = encode_pages( + list_i32(vec![0, 0, 0, 0], None), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&structural_only[0]), + pb21::page_layout::Layout::ConstantLayout(_) + )); + + let empty_struct = Arc::new(StructArray::new_empty_fields(3, None)) as ArrayRef; + let empty_struct_pages = encode_pages( + empty_struct, + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&empty_struct_pages[0]), + pb21::page_layout::Layout::ConstantLayout(_) + )); + + let all_null = Arc::new(Int32Array::from(vec![None, None, None])) as ArrayRef; + let all_null_pages = + encode_pages(all_null, TestEncoding::StructuralSparse, sparse_metadata()) + .await + .unwrap(); + assert!(matches!( + page_layout(&all_null_pages[0]), + pb21::page_layout::Layout::ConstantLayout(_) + )); + + let constant = Arc::new(Int32Array::from(vec![7, 7, 7])) as ArrayRef; + let constant_pages = + encode_pages(constant, TestEncoding::StructuralSparse, sparse_metadata()) + .await + .unwrap(); + assert!(matches!( + page_layout(&constant_pages[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + } + + #[tokio::test] + async fn test_default_and_explicit_dense_layouts_are_unchanged() { + let array = Arc::new(Int32Array::from_iter_values(0..16)) as ArrayRef; + let v2_2_default = encode_pages(array.clone(), TestEncoding::StructuralU32, HashMap::new()) + .await + .unwrap(); + let v2_2_miniblock = encode_pages( + array.clone(), + TestEncoding::StructuralU32, + structural_metadata(STRUCTURAL_ENCODING_MINIBLOCK), + ) + .await + .unwrap(); + assert_eq!(v2_2_default.len(), v2_2_miniblock.len()); + for (default, explicit) in v2_2_default.iter().zip(v2_2_miniblock.iter()) { + assert!(matches!( + page_layout(default), + pb21::page_layout::Layout::MiniBlockLayout(_) + )); + assert_eq!(page_layout(default), page_layout(explicit)); + assert_eq!(default.data, explicit.data); + } + + let v2_3_default = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&v2_3_default[0]), + pb21::page_layout::Layout::MiniBlockLayout(_) + )); + + let v2_3_miniblock = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_MINIBLOCK), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&v2_3_miniblock[0]), + pb21::page_layout::Layout::MiniBlockLayout(_) + )); + + let v2_3_fullzip = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_FULLZIP), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&v2_3_fullzip[0]), + pb21::page_layout::Layout::FullZipLayout(_) + )); + + let v2_3_sparse = encode_pages(array, TestEncoding::StructuralSparse, sparse_metadata()) + .await + .unwrap(); + assert!(matches!( + page_layout(&v2_3_sparse[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + } + + #[tokio::test] + async fn test_auto_sparse_for_split_required_page() { + const NUM_ROWS: usize = 70_000; + let array = sparse_i32_list(NUM_ROWS, 2_000); + + let within_budget = encode_pages( + sparse_i32_list(4_096, 1_024), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(within_budget.len(), 1); + assert!(matches!( + page_layout(&within_budget[0]), + pb21::page_layout::Layout::MiniBlockLayout(_) + )); + + let automatic = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(automatic.len(), 1); + assert!(matches!( + page_layout(&automatic[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + + let miniblock = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_MINIBLOCK), + ) + .await + .unwrap(); + assert!(miniblock.len() > 1); + assert!(miniblock.iter().all(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::MiniBlockLayout(_) + ))); + + let fullzip = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_FULLZIP), + ) + .await + .unwrap(); + assert_eq!(fullzip.len(), miniblock.len()); + assert!(fullzip.iter().all(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::FullZipLayout(_) + ))); + + let sparse = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert_eq!(sparse.len(), 1); + assert!(matches!( + page_layout(&sparse[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + + let v2_2_default = encode_pages(array.clone(), TestEncoding::StructuralU32, HashMap::new()) + .await + .unwrap(); + let v2_2_miniblock = encode_pages( + array.clone(), + TestEncoding::StructuralU32, + structural_metadata(STRUCTURAL_ENCODING_MINIBLOCK), + ) + .await + .unwrap(); + assert_eq!(v2_2_default.len(), v2_2_miniblock.len()); + for (default, explicit) in v2_2_default.iter().zip(v2_2_miniblock.iter()) { + assert_eq!(page_layout(default), page_layout(explicit)); + assert_eq!(default.data, explicit.data); + } + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1024 * 1024]) + .with_batch_size(NUM_ROWS as u32) + .with_range(1_999..2_002) + .with_indices(vec![0, 1_999, 2_000, NUM_ROWS as u64 - 1]); + check_round_trip_encoding_of_data(vec![array], &cases, HashMap::new()).await; + } + + #[tokio::test] + async fn test_auto_sparse_for_single_row_over_budget() { + let array = unsplittable_nested_list( + Arc::new(Int32Array::from(vec![42, 43])), + Arc::new(ArrowField::new("item", DataType::Int32, true)), + ); + + let automatic = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(automatic.len(), 1); + assert!(matches!( + page_layout(&automatic[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + + let v2_2 = encode_pages(array.clone(), TestEncoding::StructuralU32, HashMap::new()) + .await + .unwrap(); + assert_eq!(v2_2.len(), 1); + assert!(matches!( + page_layout(&v2_2[0]), + pb21::page_layout::Layout::FullZipLayout(_) + )); + + let miniblock_error = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_MINIBLOCK), + ) + .await + .unwrap_err(); + assert!( + miniblock_error + .to_string() + .contains("Mini-block cannot encode 70000 rep/def levels") + ); + + let fullzip = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + structural_metadata(STRUCTURAL_ENCODING_FULLZIP), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&fullzip[0]), + pb21::page_layout::Layout::FullZipLayout(_) + )); + + let explicit_sparse = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap(); + assert!(matches!( + page_layout(&explicit_sparse[0]), + pb21::page_layout::Layout::SparseLayout(_) + )); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_range(0..1) + .with_indices(vec![0]); + check_round_trip_encoding_of_data(vec![array], &cases, HashMap::new()).await; + } + + #[tokio::test] + async fn test_auto_sparse_keeps_unsupported_values_dense() { + const NUM_ROWS: usize = 70_000; + let num_values = NUM_ROWS.div_ceil(2_000); + + let (dictionary, dictionary_field) = dictionary_values(num_values); + let dictionary_array = sparse_list_values(NUM_ROWS, 2_000, dictionary, dictionary_field); + let dictionary_pages = encode_pages( + dictionary_array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert!(dictionary_pages.len() > 1); + assert!(dictionary_pages.iter().all(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::MiniBlockLayout(_) + ))); + + let (packed_values, packed_field) = variable_packed_struct_values(num_values); + let packed_array = sparse_list_values(NUM_ROWS, 2_000, packed_values, packed_field); + let packed_pages = encode_pages( + packed_array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert!(packed_pages.len() > 1); + assert!(packed_pages.iter().all(|page| matches!( + page_layout(page), + pb21::page_layout::Layout::FullZipLayout(_) + ))); + + let (packed_values, packed_field) = variable_packed_struct_values(2); + let unsplittable_packed = unsplittable_nested_list(packed_values, packed_field); + let packed_fallback = encode_pages( + unsplittable_packed.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + assert_eq!(packed_fallback.len(), 1); + assert!(matches!( + page_layout(&packed_fallback[0]), + pb21::page_layout::Layout::FullZipLayout(_) + )); + + let (dictionary, dictionary_field) = dictionary_values(2); + let unsplittable_dictionary = unsplittable_nested_list(dictionary, dictionary_field); + let v2_2_error = encode_pages( + unsplittable_dictionary.clone(), + TestEncoding::StructuralU32, + HashMap::new(), + ) + .await + .unwrap_err(); + let v2_3_error = encode_pages( + unsplittable_dictionary, + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap_err(); + assert_eq!(v2_3_error.to_string(), v2_2_error.to_string()); + assert!( + v2_3_error + .to_string() + .contains("Mini-block cannot encode 70000 rep/def levels") + ); + + let cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1024 * 1024]) + .with_batch_size(NUM_ROWS as u32) + .with_range(1_999..2_002) + .with_indices(vec![0, 2_000, NUM_ROWS as u64 - 1]); + check_round_trip_encoding_of_data(vec![dictionary_array], &cases, HashMap::new()).await; + let packed_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1024 * 1024]) + .with_batch_size(NUM_ROWS as u32); + check_round_trip_encoding_of_data(vec![packed_array], &packed_cases, HashMap::new()).await; + + let single_row_cases = TestCases::default() + .with_encoding(TestEncoding::StructuralSparse) + .with_page_sizes(vec![1024 * 1024]) + .with_range(0..1) + .with_indices(vec![0]); + check_round_trip_encoding_of_data( + vec![unsplittable_packed], + &single_row_cases, + HashMap::new(), + ) + .await; + } + + #[tokio::test] + async fn test_auto_sparse_wide_values_keep_dense_fallback() { + let first = vec![0xAB_u8; 5_000]; + let second = vec![0xCD_u8; 5_000]; + let fixed_size_binary = Arc::new( + FixedSizeBinaryArray::try_from_sparse_iter_with_size( + [Some(first.as_slice()), Some(second.as_slice())].into_iter(), + 5_000, + ) + .unwrap(), + ) as ArrayRef; + + const FSL_DIMENSION: i32 = 2_048; + let fixed_size_list = Arc::new( + FixedSizeListArray::try_new( + Arc::new(ArrowField::new("item", DataType::Int32, true)), + FSL_DIMENSION, + Arc::new(Int32Array::from_iter_values(0..(FSL_DIMENSION * 2))), + None, + ) + .unwrap(), + ) as ArrayRef; + + for (label, values) in [ + ("fixed-size binary", fixed_size_binary), + ("fixed-size list", fixed_size_list), + ] { + let item_field = Arc::new(ArrowField::new("item", values.data_type().clone(), true)); + let array = unsplittable_nested_list(values, item_field); + + let v2_2 = encode_pages(array.clone(), TestEncoding::StructuralU32, HashMap::new()) + .await + .unwrap(); + let v2_3 = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + HashMap::new(), + ) + .await + .unwrap(); + for pages in [&v2_2, &v2_3] { + assert_eq!(pages.len(), 1, "unexpected {label} page count"); + assert!( + matches!( + page_layout(&pages[0]), + pb21::page_layout::Layout::FullZipLayout(_) + ), + "{label} should retain the dense full-zip fallback" + ); + } + + let explicit_error = encode_pages( + array.clone(), + TestEncoding::StructuralSparse, + sparse_metadata(), + ) + .await + .unwrap_err(); + assert!( + explicit_error.to_string().contains("too wide"), + "explicit sparse should preserve the {label} value error: {explicit_error}" + ); + + let cases = TestCases::default() + .with_u32_structural_encodings() + .with_range(0..1) + .with_indices(vec![0]); + check_round_trip_encoding_of_data(vec![array], &cases, HashMap::new()).await; + } + } + + #[test] + fn test_explicit_sparse_rejects_lance_2_2() { + let array = Arc::new(Int32Array::from(vec![Some(1), None])) as ArrayRef; + let Err(error) = create_encoder(&array, TestEncoding::StructuralU32, sparse_metadata()) + else { + panic!("expected Lance 2.2 to reject explicit sparse encoding"); + }; + assert!( + error + .to_string() + .contains("not enabled by the selected file format") + ); + + let structural_only = list_i32(vec![0, 0, 0], None); + let Err(error) = create_encoder( + &structural_only, + TestEncoding::StructuralU32, + sparse_metadata(), + ) else { + panic!("expected Lance 2.2 structural-only input to reject explicit sparse encoding"); + }; + assert!( + error + .to_string() + .contains("not enabled by the selected file format") + ); + } +} diff --git a/rust/lance-encoding/src/encodings/logical/struct.rs b/rust/lance-encoding/src/encodings/logical/struct.rs index 3eb9a6bd250..3f27ae6eae8 100644 --- a/rust/lance-encoding/src/encodings/logical/struct.rs +++ b/rust/lance-encoding/src/encodings/logical/struct.rs @@ -21,19 +21,18 @@ use crate::{ format::pb, repdef::{CompositeRepDefUnraveler, RepDefBuilder}, }; -use arrow_array::{cast::AsArray, Array, ArrayRef, StructArray}; +use arrow_array::{Array, ArrayRef, StructArray, cast::AsArray}; use arrow_schema::{DataType, Fields}; use futures::{ + FutureExt, StreamExt, TryStreamExt, future::BoxFuture, stream::{FuturesOrdered, FuturesUnordered}, - FutureExt, StreamExt, TryStreamExt, }; use itertools::Itertools; use lance_arrow::FieldExt; use lance_arrow::{deepcopy::deep_copy_nulls, r#struct::StructArrayExt}; -use lance_core::{Error, Result}; +use lance_core::{Error, Result, datatypes::validate_fixed_size_list_dimensions}; use log::trace; -use snafu::location; #[derive(Debug)] struct StructuralSchedulingJobWithStatus<'a> { @@ -146,8 +145,7 @@ impl StructuralSchedulingJob for RepDefStructSchedulingJob<'_> { let child_scan = next_child.ready_scan_lines.pop_front().unwrap(); trace!( "Scheduled {} rows for child {}", - child_scan.rows_scheduled, - next_child.col_idx + child_scan.rows_scheduled, next_child.col_idx ); next_child.rows_scheduled += child_scan.rows_scheduled; next_child.rows_remaining -= child_scan.rows_scheduled; @@ -211,13 +209,16 @@ impl StructuralFieldScheduler for StructuralStructScheduler { fn initialize<'a>( &'a mut self, + requested_ranges: Option<&'a [Range]>, filter: &'a FilterExpression, context: &'a SchedulerContext, ) -> BoxFuture<'a, Result<()>> { + // Struct children share the parent's row coordinates 1:1 (see + // `schedule_ranges`), so every child gets the same ranges. let children_initialization = self .children .iter_mut() - .map(|child| child.initialize(filter, context)) + .map(|child| child.initialize(requested_ranges, filter, context)) .collect::>(); async move { children_initialization @@ -237,10 +238,16 @@ pub struct StructuralStructDecoder { child_fields: Fields, // The root decoder is slightly different because it cannot have nulls is_root: bool, + nullable: bool, } impl StructuralStructDecoder { - pub fn new(fields: Fields, should_validate: bool, is_root: bool) -> Result { + pub fn new( + fields: Fields, + should_validate: bool, + is_root: bool, + nullable: bool, + ) -> Result { let children = fields .iter() .map(|field| Self::field_to_decoder(field, should_validate)) @@ -251,6 +258,7 @@ impl StructuralStructDecoder { children, child_fields: fields, is_root, + nullable: !is_root && nullable, }) } @@ -265,7 +273,12 @@ impl StructuralStructDecoder { StructuralPrimitiveFieldDecoder::new(&field.clone(), should_validate); Ok(Box::new(decoder)) } else { - Ok(Box::new(Self::new(fields.clone(), should_validate, false)?)) + Ok(Box::new(Self::new( + fields.clone(), + should_validate, + false, + field.is_nullable(), + )?)) } } DataType::List(child_field) | DataType::LargeList(child_field) => { @@ -278,6 +291,12 @@ impl StructuralStructDecoder { DataType::FixedSizeList(child_field, _) if matches!(child_field.data_type(), DataType::Struct(_)) => { + // The scheduler factories run the same guard, but the decoder tree can be + // built independently (e.g. `create_decode_stream`) so a zero dimension from + // a malformed schema must be rejected here as well. Draining and unraveling + // validity both scale by the dimension and a zero would make that math + // degenerate. + validate_fixed_size_list_dimensions(field.name(), field.data_type())?; // FixedSizeList containing Struct needs structural decoding let child_decoder = Self::field_to_decoder(child_field, should_validate)?; Ok(Box::new(StructuralFixedSizeListDecoder::new( @@ -287,12 +306,11 @@ impl StructuralStructDecoder { } DataType::Map(entries_field, keys_sorted) => { if *keys_sorted { - return Err(Error::NotSupported { - source: "Map data type with keys_sorted=true is not supported yet" + return Err(Error::not_supported_source( + "Map data type with keys_sorted=true is not supported yet" .to_string() .into(), - location: location!(), - }); + )); } let child_decoder = Self::field_to_decoder(entries_field, should_validate)?; Ok(Box::new(StructuralMapDecoder::new( @@ -345,6 +363,24 @@ impl StructuralFieldDecoder for StructuralStructDecoder { fn data_type(&self) -> &DataType { &self.data_type } + + fn plan_decoded_bytes(&self, rows_remaining: u64) -> lance_core::Result<[u64; 8]> { + use crate::decoder::CANDIDATE_BATCH_SIZES; + let mut out = [0u64; 8]; + for child in &self.children { + let child_bytes = child.plan_decoded_bytes(rows_remaining)?; + for (o, c) in out.iter_mut().zip(child_bytes) { + *o += c; + } + } + if self.nullable { + for (i, &candidate) in CANDIDATE_BATCH_SIZES.iter().enumerate() { + let rows = (candidate as u64).min(rows_remaining); + out[i] += rows.div_ceil(8); + } + } + Ok(out) + } } #[derive(Debug)] @@ -361,6 +397,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, }); } @@ -370,34 +407,65 @@ impl StructuralDecodeArrayTask for RepDefStructDecodeTask { .map(|task| task.decode()) .collect::>>()?; let mut children = Vec::with_capacity(arrays.len()); + let mut repdefs = 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 first_array = arrays_iter.next().ok_or_else(|| { + Error::internal("Struct decoder unexpectedly has no child arrays".to_string()) + })?; let length = first_array.array.len(); // The repdef should be identical across all children at this point - let mut repdef = first_array.repdef; + repdefs.push(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()); + if length != array.array.len() { + return Err(Error::invalid_input_source( + format!( + "Struct child array length {} does not match sibling length {}", + array.array.len(), + length + ) + .into(), + )); + } + data_size += array.data_size; children.push(array.array); + repdefs.push(array.repdef); + } + + // Dense rep/def state can retain child-specific repetition information after a child + // decoder finishes, so comparing dense siblings is not meaningful. If any child carries + // sparse state, keep a sparse child as the canonical structural plan and compare it with + // every other sparse sibling so sparse metadata is never silently discarded. + let primary_repdef = repdefs + .iter() + .position(CompositeRepDefUnraveler::has_sparse) + .unwrap_or(0); + let mut repdef = repdefs.swap_remove(primary_repdef); + if repdef.has_sparse() { + for sibling in repdefs { + if sibling.has_sparse() { + repdef.add_compatibility_check(sibling); + } + } } let validity = if self.is_root { + repdef.ensure_exhausted()?; None } else { - repdef.unravel_validity(length) + repdef.unravel_validity(length)? }; - let array = StructArray::try_new(self.child_fields, children, validity).map_err(|e| { - Error::InvalidInput { - source: e.to_string().into(), - location: location!(), - } - })?; + let array = StructArray::try_new(self.child_fields, children, validity) + .map_err(|e| Error::invalid_input_source(e.to_string().into()))?; Ok(DecodedArray { array: Arc::new(array), repdef, + data_size, }) } } @@ -500,7 +568,6 @@ pub struct StructFieldEncoder { } impl StructFieldEncoder { - #[allow(dead_code)] pub fn new(children: Vec>, column_index: u32) -> Self { Self { children, @@ -519,8 +586,8 @@ impl FieldEncoder for StructFieldEncoder { row_number: u64, num_rows: u64, ) -> Result> { - self.num_rows_seen += array.len() as u64; let struct_array = array.as_struct(); + self.num_rows_seen += array.len() as u64; let child_tasks = self .children .iter_mut() @@ -598,25 +665,66 @@ mod tests { use std::{collections::HashMap, sync::Arc}; use arrow_array::{ + Array, ArrayRef, Float64Array, Int32Array, Int64Array, ListArray, StringArray, StructArray, builder::{Int32Builder, ListBuilder}, - Array, ArrayRef, Int32Array, ListArray, StructArray, }; use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, Fields}; - use crate::{ - testing::{check_basic_random, check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + use super::StructuralStructDecoder; + use crate::constants::{STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY}; + use crate::decoder::StructuralFieldDecoder; + use crate::testing::{ + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, }; + #[test] + fn test_zero_dimension_fsl_decoder_errors() { + // Simulates a stored schema declaring a zero-dimension FixedSizeList (writers reject + // it but old files may contain one). Building the decoder must fail cleanly instead + // of letting the zero dimension reach the rep/def decimation. + let item_fields = Fields::from(vec![Field::new("x", DataType::Int32, true)]); + let fields = Fields::from(vec![Field::new( + "vecs", + DataType::FixedSizeList( + Arc::new(Field::new("item", DataType::Struct(item_fields), true)), + 0, + ), + true, + )]); + + let err = StructuralStructDecoder::new( + fields, false, /*is_root=*/ true, /*nullable=*/ false, + ) + .unwrap_err(); + assert!(matches!(err, lance_core::Error::Schema { .. })); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + } + + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_simple_struct() { + async fn test_simple_struct( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let data_type = DataType::Struct(Fields::from(vec![ Field::new("a", DataType::Int32, false), Field::new("b", DataType::Int32, false), ])); let field = Field::new("", data_type, false); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[test_log::test(tokio::test)] @@ -666,7 +774,7 @@ mod tests { Some(rows_validity), ); - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(rows)], &test_cases, HashMap::new()).await; } @@ -696,7 +804,7 @@ mod tests { ); check_round_trip_encoding_of_data( vec![Arc::new(struct_array)], - &TestCases::default().with_min_file_version(LanceFileVersion::V2_1), + &TestCases::default().with_structural_encodings(), HashMap::new(), ) .await; @@ -727,14 +835,25 @@ mod tests { ); check_round_trip_encoding_of_data( vec![Arc::new(struct_array)], - &TestCases::default().with_min_file_version(LanceFileVersion::V2_1), + &TestCases::default().with_structural_encodings(), HashMap::new(), ) .await; } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_struct_list() { + async fn test_struct_list( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let data_type = DataType::Struct(Fields::from(vec![ Field::new( "inner_list", @@ -744,20 +863,42 @@ mod tests { Field::new("outer_int", DataType::Int32, true), ])); let field = Field::new("row", data_type, false); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_empty_struct() { + async fn test_empty_struct( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { // It's technically legal for a struct to have 0 children, need to // make sure we support that let data_type = DataType::Struct(Fields::from(Vec::::default())); let field = Field::new("row", data_type, false); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_complicated_struct() { + async fn test_complicated_struct( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let data_type = DataType::Struct(Fields::from(vec![ Field::new("int", DataType::Int32, true), Field::new( @@ -775,7 +916,192 @@ mod tests { Field::new("outer_binary", DataType::Binary, true), ])); let field = Field::new("row", data_type, false); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; + } + + #[test_log::test(tokio::test)] + async fn test_list_of_struct_with_null_struct_element() { + // Regression: a list containing structs where most struct elements are null + // causes a length mismatch during decoding with V2_2 encoding. + use arrow_array::StringArray; + + let tag_array = StringArray::from(vec![ + Some("valid"), + Some("null_struct"), + Some("valid"), + Some("valid"), + ]); + let struct_fields = Fields::from(vec![Field::new("tag", DataType::Utf8, true)]); + // 3 out of 4 struct elements are null + let struct_validity = NullBuffer::from(vec![false, true, false, false]); + let struct_array = StructArray::new( + struct_fields.clone(), + vec![Arc::new(tag_array)], + Some(struct_validity), + ); + + let offsets = OffsetBuffer::new(ScalarBuffer::::from(vec![0, 4])); + let list_field = Field::new("item", DataType::Struct(struct_fields), true); + let list_array = + ListArray::new(Arc::new(list_field), offsets, Arc::new(struct_array), None); + + check_round_trip_encoding_of_data( + vec![Arc::new(list_array)], + &TestCases::default().with_u32_structural_encodings(), + HashMap::new(), + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn test_list_of_struct_with_all_null_child() { + use arrow_array::StringArray; + + let a_array = StringArray::from(vec![Some("w"), Some("x"), Some("y"), Some("z")]); + let b_array = StringArray::from(vec![None::<&str>, None, None, None]); + let struct_fields = Fields::from(vec![ + Field::new("a", DataType::Utf8, true), + Field::new("b", DataType::Utf8, true), + ]); + let struct_array = StructArray::new( + struct_fields.clone(), + vec![Arc::new(a_array), Arc::new(b_array)], + None, + ); + + let offsets = OffsetBuffer::new(ScalarBuffer::::from(vec![0, 2, 4])); + let list_field = Field::new("item", DataType::Struct(struct_fields), true); + let list_array = + ListArray::new(Arc::new(list_field), offsets, Arc::new(struct_array), None); + + check_round_trip_encoding_of_data( + vec![Arc::new(list_array)], + &TestCases::default() + .with_range(0..1) + .with_range(1..2) + .with_indices(vec![0]) + .with_indices(vec![1]) + .with_structural_encodings(), + HashMap::new(), + ) + .await; + } + + #[test_log::test(tokio::test)] + async fn test_list_of_struct_with_constant_child_and_empty_lists() { + let item_fields = Fields::from(vec![ + Field::new("maneuver", DataType::Int64, true), + Field::new("remaining_dist", DataType::Float64, true), + ]); + let item_array = StructArray::new( + item_fields.clone(), + vec![ + Arc::new(Int64Array::from(vec![Some(3), Some(3), Some(3)])), + Arc::new(Float64Array::from(vec![Some(1.0), Some(2.0), Some(3.0)])), + ], + None, + ); + + let list_field = Field::new("item", DataType::Struct(item_fields), true); + let list_array = ListArray::new( + Arc::new(list_field), + OffsetBuffer::new(ScalarBuffer::::from(vec![0, 1, 2, 3, 3, 3, 3])), + Arc::new(item_array), + None, + ); + + let data_fields = Fields::from(vec![Field::new( + "maneuvers", + list_array.data_type().clone(), + true, + )]); + let data_array = StructArray::new(data_fields.clone(), vec![Arc::new(list_array)], None); + let row_validity = NullBuffer::from(vec![true, true, true, true, true, false]); + let row_array = StructArray::new( + Fields::from(vec![Field::new( + "data", + DataType::Struct(data_fields), + true, + )]), + vec![Arc::new(data_array)], + Some(row_validity), + ); + + check_round_trip_encoding_of_data( + vec![Arc::new(row_array)], + &TestCases::default().with_structural_encodings(), + HashMap::new(), + ) + .await; + } + + #[test] + fn test_plan_decoded_bytes_struct_non_nullable_primitive() { + // Nullable struct containing a non-nullable i32 child. + // Only the struct-level bitmap contributes; the child has no bitmap. + // + // N == CANDIDATE_BATCH_SIZES[5] == 1024 so that all sizes are + // naturally aligned to Arrow's 64-byte allocation boundary. + use arrow_buffer::NullBuffer; + + const N: usize = 1024; + let x_vals = Int32Array::from_iter_values(0..N as i32); + let struct_nulls = NullBuffer::from((0..N).map(|i| i % 2 == 0).collect::>()); + let struct_field = Field::new("x", DataType::Int32, false); + let struct_array = StructArray::new( + Fields::from(vec![struct_field]), + vec![Arc::new(x_vals) as ArrayRef], + Some(struct_nulls), + ); + let expected = struct_array.get_buffer_memory_size() as u64; + + let child_field = Arc::new(Field::new("x", DataType::Int32, false)); + let fields = Fields::from(vec![child_field]); + let decoder = StructuralStructDecoder::new( + fields, false, /*is_root=*/ false, /*nullable=*/ true, + ) + .unwrap(); + + let bytes = decoder.plan_decoded_bytes(N as u64).unwrap(); + // bytes[5] corresponds to CANDIDATE_BATCH_SIZES[5] == 1024 == N + assert_eq!( + bytes[5], expected, + "plan_decoded_bytes({N}) = {} but get_buffer_memory_size = {expected}", + bytes[5], + ); + } + + #[test] + fn test_plan_decoded_bytes_struct_nullable_primitive() { + // Nullable struct containing a nullable i32 child. + // Both the struct and the child contribute a validity bitmap. + use arrow_buffer::NullBuffer; + + const N: usize = 1024; + let y_vals = + Int32Array::from_iter((0..N as i32).map(|i| if i % 2 == 0 { Some(i) } else { None })); + let struct_nulls = NullBuffer::from((0..N).map(|i| i % 3 != 0).collect::>()); + let struct_field = Field::new("y", DataType::Int32, true); + let struct_array = StructArray::new( + Fields::from(vec![struct_field]), + vec![Arc::new(y_vals) as ArrayRef], + Some(struct_nulls), + ); + let expected = struct_array.get_buffer_memory_size() as u64; + + let child_field = Arc::new(Field::new("y", DataType::Int32, true)); + let fields = Fields::from(vec![child_field]); + let decoder = StructuralStructDecoder::new( + fields, false, /*is_root=*/ false, /*nullable=*/ true, + ) + .unwrap(); + + let bytes = decoder.plan_decoded_bytes(N as u64).unwrap(); + assert_eq!( + bytes[5], expected, + "plan_decoded_bytes({N}) = {} but get_buffer_memory_size = {expected}", + bytes[5], + ); } #[test_log::test(tokio::test)] @@ -807,4 +1133,48 @@ mod tests { check_round_trip_encoding_of_data(struct_arrays, &TestCases::default(), HashMap::new()) .await; } + + /// A null list produces an "invisible" item: a rep/def entry that owns no value in the + /// leaf page. The full-zip decoder works out which items own a value from + /// `max_visible_def`, and adding a second nullable layer *above* the list (the struct + /// itself) must not move that threshold. When it does, the decoder hands a value to + /// every null list and each later value shifts by one. + #[rstest::rstest] + #[test_log::test(tokio::test)] + async fn test_full_zip_invisible_items_under_nullable_struct( + #[values(false, true)] variable_width_items: bool, + ) { + // struct> with both layers nullable: + // {x: [_, _]} / {x: null} / {x: [_]} / null / {x: [_, _]} + let (item_type, items): (DataType, ArrayRef) = if variable_width_items { + ( + DataType::Utf8, + Arc::new(StringArray::from(vec!["a", "b", "c", "d", "e"])), + ) + } else { + ( + DataType::Int32, + Arc::new(Int32Array::from(vec![1, 2, 3, 4, 5])), + ) + }; + let lists = ListArray::new( + Arc::new(Field::new("item", item_type, true)), + OffsetBuffer::new(ScalarBuffer::from(vec![0, 2, 2, 3, 3, 5])), + items, + Some(NullBuffer::from(vec![true, false, true, true, true])), + ); + let fields = Fields::from(vec![Field::new("x", lists.data_type().clone(), true)]); + let rows = StructArray::new( + fields, + vec![Arc::new(lists)], + Some(NullBuffer::from(vec![true, true, true, false, true])), + ); + + let metadata = HashMap::from([( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_FULLZIP.to_string(), + )]); + let test_cases = TestCases::default().with_structural_encodings(); + check_round_trip_encoding_of_data(vec![Arc::new(rows)], &test_cases, metadata).await; + } } diff --git a/rust/lance-encoding/src/encodings/physical/binary.rs b/rust/lance-encoding/src/encodings/physical/binary.rs index 8872a1502ef..e07d586a4f6 100644 --- a/rust/lance-encoding/src/encodings/physical/binary.rs +++ b/rust/lance-encoding/src/encodings/physical/binary.rs @@ -12,21 +12,22 @@ use arrow_array::OffsetSizeTrait; use byteorder::{ByteOrder, LittleEndian}; use core::panic; -use snafu::location; use crate::compression::{ BlockCompressor, BlockDecompressor, MiniBlockDecompressor, VariablePerValueDecompressor, + require_block_payload, }; use crate::buffer::LanceBuffer; use crate::data::{BlockInfo, DataBlock, VariableWidthBlock}; use crate::encodings::logical::primitive::fullzip::{PerValueCompressor, PerValueDataBlock}; use crate::encodings::logical::primitive::miniblock::{ - MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressor, MAX_MINIBLOCK_VALUES, + MAX_MINIBLOCK_VALUES, MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressionContext, + MiniBlockCompressor, }; -use crate::format::pb21::compressive_encoding::Compression; use crate::format::pb21::CompressiveEncoding; -use crate::format::{pb21, ProtobufUtils21}; +use crate::format::pb21::compressive_encoding::Compression; +use crate::format::{ProtobufUtils21, pb21}; use lance_core::utils::bit::pad_bytes_to; use lance_core::{Error, Result}; @@ -187,7 +188,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; } @@ -246,17 +247,20 @@ impl BinaryMiniBlockEncoder { } impl MiniBlockCompressor for BinaryMiniBlockEncoder { - fn compress(&self, data: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + _context: MiniBlockCompressionContext, + data: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match data { DataBlock::VariableWidth(variable_width) => Ok(self.chunk_data(variable_width)), - _ => Err(Error::InvalidInput { - source: format!( + _ => Err(Error::invalid_input_source( + format!( "Cannot compress a data block of type {} with BinaryMiniBlockEncoder", data.name() ) .into(), - location: location!(), - }), + )), } } } @@ -290,52 +294,144 @@ impl BinaryMiniBlockDecompressor { } } +/// Cold path: pinpoint why the chunk-relative offsets of a binary mini-block +/// chunk failed validation. +fn chunk_offset_violation_error>(offsets: &[T], chunk_len: usize) -> Error { + let mut previous: u64 = offsets[0].into(); + for (position, &offset) in offsets.iter().enumerate().skip(1) { + let offset: u64 = offset.into(); + if offset < previous { + return Error::corrupt_file_named( + "binary mini-block", + format!( + "value offset at position {position} decreases: {offset} < {previous} \ + (chunk is {chunk_len} bytes)" + ), + ); + } + previous = offset; + } + Error::corrupt_file_named( + "binary mini-block", + format!("value offset {previous} is out of bounds for a chunk of {chunk_len} bytes"), + ) +} + impl MiniBlockDecompressor for BinaryMiniBlockDecompressor { // decompress a MiniBlock of binary data, the num_values must be less than or equal // to the number of values this MiniBlock has, BinaryMiniBlock doesn't store `the number of values` // it has so assertion can not be done here and the caller of `decompress` must ensure // `num_values` <= number of values in the chunk. + // + // The chunk-relative value offsets at the front of the chunk come straight + // from the file and are used to slice the chunk buffer, so corrupt values + // must surface as a typed error instead of a panic or an out-of-bounds + // read. The monotonicity check rides along the existing rebase loop (the + // `&=` accumulation keeps it branchless) so validation adds no extra pass. fn decompress(&self, data: Vec, num_values: u64) -> Result { assert_eq!(data.len(), 1); let data = data.into_iter().next().unwrap(); - if self.bits_per_offset == 64 { - // offset and at least one value - assert!(data.len() >= 16); + let bytes_per_offset = self.bits_per_offset as usize / 8; + if !data.len().is_multiple_of(bytes_per_offset) { + return Err(Error::corrupt_file_named( + "binary mini-block", + format!( + "chunk size {} is not a multiple of the {}-byte offset width", + data.len(), + bytes_per_offset + ), + )); + } + let num_offsets = (num_values as usize).checked_add(1).ok_or_else(|| { + Error::corrupt_file_named( + "binary mini-block", + format!("cannot decode {num_values} values from a single chunk"), + ) + })?; + if data.len() / bytes_per_offset < num_offsets { + return Err(Error::corrupt_file_named( + "binary mini-block", + format!( + "chunk of {} bytes holds {} offsets but decoding {} values requires {}", + data.len(), + data.len() / bytes_per_offset, + num_values, + num_offsets + ), + )); + } + // The value region must start past the offsets being decoded, otherwise + // the offset table itself aliases into the value bytes. A lower bound + // (not equality) because a prefix read of the chunk legitimately leaves + // unrequested offsets between the requested prefix and the values. + let min_value_region_start = num_offsets * bytes_per_offset; + let value_region_overlap_error = |first: u64| { + Error::corrupt_file_named( + "binary mini-block", + format!( + "value region starts at offset {first} which overlaps the {num_offsets} \ + requested offsets ({min_value_region_start} bytes)" + ), + ) + }; + + if self.bits_per_offset == 64 { let offsets_buffer = data.borrow_to_typed_slice::(); - let offsets = offsets_buffer.as_ref(); + let offsets = &offsets_buffer.as_ref()[..num_offsets]; - let result_offsets = offsets[0..(num_values + 1) as usize] + let first = offsets[0]; + if first < min_value_region_start as u64 { + return Err(value_region_overlap_error(first)); + } + let mut previous = first; + let mut is_monotonic = true; + let result_offsets = offsets .iter() - .map(|offset| offset - offsets[0]) + .map(|&offset| { + is_monotonic &= previous <= offset; + previous = offset; + offset.wrapping_sub(first) + }) .collect::>(); + let last = offsets[num_offsets - 1]; + if !is_monotonic || last as usize > data.len() { + return Err(chunk_offset_violation_error(offsets, data.len())); + } Ok(DataBlock::VariableWidth(VariableWidthBlock { - data: LanceBuffer::from( - data[offsets[0] as usize..offsets[num_values as usize] as usize].to_vec(), - ), + data: LanceBuffer::from(data[first as usize..last as usize].to_vec()), offsets: LanceBuffer::reinterpret_vec(result_offsets), bits_per_offset: 64, num_values, block_info: BlockInfo::new(), })) } else { - // offset and at least one value - assert!(data.len() >= 8); - let offsets_buffer = data.borrow_to_typed_slice::(); - let offsets = offsets_buffer.as_ref(); + let offsets = &offsets_buffer.as_ref()[..num_offsets]; - let result_offsets = offsets[0..(num_values + 1) as usize] + let first = offsets[0]; + if (first as u64) < min_value_region_start as u64 { + return Err(value_region_overlap_error(first as u64)); + } + let mut previous = first; + let mut is_monotonic = true; + let result_offsets = offsets .iter() - .map(|offset| offset - offsets[0]) + .map(|&offset| { + is_monotonic &= previous <= offset; + previous = offset; + offset.wrapping_sub(first) + }) .collect::>(); + let last = offsets[num_offsets - 1]; + if !is_monotonic || last as usize > data.len() { + return Err(chunk_offset_violation_error(offsets, data.len())); + } Ok(DataBlock::VariableWidth(VariableWidthBlock { - data: LanceBuffer::from( - data[offsets[0] as usize..offsets[num_values as usize] as usize].to_vec(), - ), + data: LanceBuffer::from(data[first as usize..last as usize].to_vec()), offsets: LanceBuffer::reinterpret_vec(result_offsets), bits_per_offset: 32, num_values, @@ -358,7 +454,15 @@ impl MiniBlockDecompressor for BinaryMiniBlockDecompressor { pub struct VariableEncoder {} impl BlockCompressor for VariableEncoder { - fn compress(&self, mut data: DataBlock) -> Result { + fn compress(&self, mut data: DataBlock) -> Result<(Option, CompressiveEncoding)> { + let bits_per_offset = match &data { + DataBlock::VariableWidth(data) => data.bits_per_offset, + _ => { + return Err(Error::invalid_input( + "BinaryBlockEncoder requires a variable-width block", + )); + } + }; match data { DataBlock::VariableWidth(ref mut variable_width_data) => { match variable_width_data.bits_per_offset { @@ -410,16 +514,23 @@ impl BlockCompressor for VariableEncoder { output.extend_from_slice(&variable_width_data.data); Ok(LanceBuffer::from(output)) } - _ => { - panic!("BinaryBlockEncoder does not work with {} bits per offset VariableWidth DataBlock.", - variable_width_data.bits_per_offset); - } + _ => Err(Error::invalid_input(format!( + "BinaryBlockEncoder does not support {}-bit offsets", + variable_width_data.bits_per_offset + ))), } } - _ => { - panic!("BinaryBlockEncoder can only work with Variable Width DataBlock."); - } + _ => unreachable!("variable-width input was validated above"), } + .map(|payload| { + ( + Some(payload), + ProtobufUtils21::variable( + ProtobufUtils21::flat(bits_per_offset as u64, None), + None, + ), + ) + }) } } @@ -450,7 +561,8 @@ impl VariablePerValueDecompressor for VariableDecoder { pub struct BinaryBlockDecompressor {} impl BlockDecompressor for BinaryBlockDecompressor { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "Binary block")?; // In older (not quite stable) versions we stored the bits per offset as a single byte and then the num_values // as four bytes. However, this led to alignment problems and was wasteful since we already store the num_values // in higher layers. @@ -462,27 +574,56 @@ impl BlockDecompressor for BinaryBlockDecompressor { // never be more than 255 and it's little endian so the last 3 bytes will always be 0. These will be the least // significant 3 bytes of the number of values in the old scheme. It's pretty unlikely these are all 0 (that would // mean there are at least 16M values in a single page) so we'll use this to determine if the old scheme is used. + // + // The header fields and the offsets themselves come straight from the file. + // The structural checks below (all O(1)) reject blocks whose regions do not + // line up; the offset *values* are validated later, by the mandatory layout + // validation in `VariableWidthBlock::into_arrow`, so they are not rescanned + // here. + if data.len() < 4 { + return Err(Error::corrupt_file_named( + "variable-width block", + format!( + "block of {} bytes is too small to hold a header", + data.len() + ), + )); + } let is_old_scheme = data[1] != 0 || data[2] != 0 || data[3] != 0; + let ensure_header = |header_len: usize| { + if data.len() < header_len { + return Err(Error::corrupt_file_named( + "variable-width block", + format!( + "block of {} bytes is too small for a {} byte header", + data.len(), + header_len + ), + )); + } + Ok(()) + }; let (bits_per_offset, bytes_start_offset, offset_start) = if is_old_scheme { // Old scheme let bits_per_offset = data[0]; match bits_per_offset { 32 => { + ensure_header(9)?; debug_assert_eq!(LittleEndian::read_u32(&data[1..5]), num_values as u32); let bytes_start_offset = LittleEndian::read_u32(&data[5..9]); - (bits_per_offset, bytes_start_offset as u64, 9) + (bits_per_offset, bytes_start_offset as u64, 9_u64) } 64 => { + ensure_header(17)?; debug_assert_eq!(LittleEndian::read_u64(&data[1..9]), num_values); let bytes_start_offset = LittleEndian::read_u64(&data[9..17]); (bits_per_offset, bytes_start_offset, 17) } _ => { - return Err(Error::InvalidInput { - source: format!("Unsupported bits_per_offset={}", bits_per_offset).into(), - location: location!(), - }); + return Err(Error::invalid_input_source( + format!("Unsupported bits_per_offset={}", bits_per_offset).into(), + )); } } } else { @@ -490,25 +631,72 @@ impl BlockDecompressor for BinaryBlockDecompressor { let bits_per_offset = LittleEndian::read_u32(&data[0..4]) as u8; match bits_per_offset { 32 => { + ensure_header(8)?; let bytes_start_offset = LittleEndian::read_u32(&data[4..8]); (bits_per_offset, bytes_start_offset as u64, 8) } 64 => { + ensure_header(16)?; let bytes_start_offset = LittleEndian::read_u64(&data[8..16]); (bits_per_offset, bytes_start_offset, 16) } _ => { - return Err(Error::InvalidInput { - source: format!("Unsupported bits_per_offset={}", bits_per_offset).into(), - location: location!(), - }); + return Err(Error::invalid_input_source( + format!("Unsupported bits_per_offset={}", bits_per_offset).into(), + )); } } }; + // The offsets region sits between the header and `bytes_start_offset` + // and must hold exactly `num_values + 1` offsets starting at zero. + let expected_offsets_bytes = num_values + .checked_add(1) + .and_then(|num_offsets| num_offsets.checked_mul(bits_per_offset as u64 / 8)) + .ok_or_else(|| { + Error::corrupt_file_named( + "variable-width block", + format!("offsets region size overflows for {num_values} values"), + ) + })?; + if bytes_start_offset < offset_start || bytes_start_offset > data.len() as u64 { + return Err(Error::corrupt_file_named( + "variable-width block", + format!( + "bytes start offset {} is outside the block (header: {} bytes, block: {} bytes)", + bytes_start_offset, + offset_start, + data.len() + ), + )); + } + if bytes_start_offset - offset_start != expected_offsets_bytes { + return Err(Error::corrupt_file_named( + "variable-width block", + format!( + "expected {} offset bytes for {} values but found {}", + expected_offsets_bytes, + num_values, + bytes_start_offset - offset_start + ), + )); + } + // the next `bytes_start_offset - offset_start` stores the offsets. - let offsets = - data.slice_with_length(offset_start, bytes_start_offset as usize - offset_start); + let offsets = data.slice_with_length( + offset_start as usize, + (bytes_start_offset - offset_start) as usize, + ); + let first_offset = match bits_per_offset { + 32 => LittleEndian::read_u32(&offsets[0..4]) as u64, + _ => LittleEndian::read_u64(&offsets[0..8]), + }; + if first_offset != 0 { + return Err(Error::corrupt_file_named( + "variable-width block", + format!("first offset must be 0 but found {first_offset}"), + )); + } // the rest are the binary bytes. let data = data.slice_with_length( @@ -527,37 +715,49 @@ impl BlockDecompressor for BinaryBlockDecompressor { } #[cfg(test)] -pub mod tests { +mod tests { use arrow_array::{ - builder::{LargeStringBuilder, StringBuilder}, ArrayRef, StringArray, + builder::{LargeStringBuilder, StringBuilder}, }; use arrow_schema::{DataType, Field}; use crate::{ + buffer::LanceBuffer, constants::{ COMPRESSION_META_KEY, STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, STRUCTURAL_ENCODING_MINIBLOCK, }, + data::{BlockInfo, DataBlock, VariableWidthBlock}, testing::check_specific_random, }; use rstest::rstest; use std::{collections::HashMap, sync::Arc, vec}; - use crate::{ - testing::{ - check_basic_random, check_round_trip_encoding_of_data, FnArrayGeneratorProvider, - TestCases, - }, - version::LanceFileVersion, + use crate::testing::{ + FnArrayGeneratorProvider, TestCases, TestEncoding, check_basic_random_case, + check_round_trip_encoding_generated, check_round_trip_encoding_of_data, }; + #[rstest] #[test_log::test(tokio::test)] - async fn test_utf8_binary() { + async fn test_utf8_binary( + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { let field = Field::new("", DataType::Utf8, false); check_specific_random( field, - TestCases::basic().with_min_file_version(LanceFileVersion::V2_1), + TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]), ) .await; } @@ -568,6 +768,15 @@ pub mod tests { #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, #[values(DataType::Utf8, DataType::Binary)] data_type: DataType, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, ) { let mut field_metadata = HashMap::new(); field_metadata.insert( @@ -576,7 +785,7 @@ pub mod tests { ); let field = Field::new("", data_type, false).with_metadata(field_metadata); - check_basic_random(field).await; + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[rstest] @@ -585,6 +794,14 @@ pub mod tests { #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, #[values(DataType::Binary, DataType::Utf8)] data_type: DataType, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, ) { let mut field_metadata = HashMap::new(); field_metadata.insert( @@ -594,7 +811,10 @@ pub mod tests { field_metadata.insert(COMPRESSION_META_KEY.to_string(), "fsst".into()); let field = Field::new("", data_type, true).with_metadata(field_metadata); // TODO (https://github.com/lance-format/lance/issues/4783) - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]); check_specific_random(field, test_cases).await; } @@ -604,6 +824,14 @@ pub mod tests { #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, #[values(DataType::LargeBinary, DataType::LargeUtf8)] data_type: DataType, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, ) { let mut field_metadata = HashMap::new(); field_metadata.insert( @@ -614,21 +842,30 @@ pub mod tests { let field = Field::new("", data_type, true).with_metadata(field_metadata); check_specific_random( field, - TestCases::basic().with_min_file_version(LanceFileVersion::V2_1), + TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]), ) .await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_large_binary() { - let field = Field::new("", DataType::LargeBinary, true); - check_basic_random(field).await; - } - - #[test_log::test(tokio::test)] - async fn test_large_utf8() { - let field = Field::new("", DataType::LargeUtf8, true); - check_basic_random(field).await; + async fn test_large_binary_types( + #[values(DataType::LargeBinary, DataType::LargeUtf8)] data_type: DataType, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { + let field = Field::new("", data_type, true); + check_basic_random_case(field, encoding, page_size, use_slicing).await; } #[rstest] @@ -636,20 +873,31 @@ pub mod tests { async fn test_small_strings( #[values(STRUCTURAL_ENCODING_MINIBLOCK, STRUCTURAL_ENCODING_FULLZIP)] structural_encoding: &str, + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, ) { - use crate::testing::check_basic_generated; - let mut field_metadata = HashMap::new(); field_metadata.insert( STRUCTURAL_ENCODING_META_KEY.to_string(), structural_encoding.into(), ); let field = Field::new("", DataType::Utf8, true).with_metadata(field_metadata); - check_basic_generated( + check_round_trip_encoding_generated( field, Box::new(FnArrayGeneratorProvider::new(move || { lance_datagen::array::utf8_prefix_plus_counter("user_", /*is_large=*/ false) })), + TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]), ) .await; } @@ -700,10 +948,19 @@ pub mod tests { .await; } + #[rstest] #[test_log::test(tokio::test)] - async fn test_bigger_than_max_page_size() { - // Create an array with one single 32MiB string - let big_string = String::from_iter((0..(32 * 1024 * 1024)).map(|_| '0')); + async fn test_value_bigger_than_max_page_size( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + // Create one value larger than the configured 1MiB page budget. + let big_string = String::from_iter((0..(2 * 1024 * 1024)).map(|_| '0')); let string_array = StringArray::from(vec![ Some(big_string), Some("abc".to_string()), @@ -713,7 +970,9 @@ pub mod tests { ]); // Drop the max page size to 1MiB - let test_cases = TestCases::default().with_max_page_size(1024 * 1024); + let test_cases = TestCases::default() + .with_max_page_size(1024 * 1024) + .with_encoding(encoding); check_round_trip_encoding_of_data( vec![Arc::new(string_array)], @@ -721,16 +980,29 @@ pub mod tests { HashMap::new(), ) .await; + } - // This is a regression testing the case where a page with X rows is split into Y parts - // where the number of parts is not evenly divisible by the number of rows. In this - // case we are splitting 90 rows into 4 parts. - let big_string = String::from_iter((0..(1000 * 1000)).map(|_| '0')); + #[rstest] + #[test_log::test(tokio::test)] + async fn test_page_split_parts_do_not_evenly_divide_rows( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + // Regression: split 90 rows into four parts, where the part count does + // not evenly divide the row count. + let big_string = String::from_iter((0..45_000).map(|_| '0')); let string_array = StringArray::from_iter_values((0..90).map(|_| big_string.clone())); check_round_trip_encoding_of_data( vec![Arc::new(string_array)], - &TestCases::default(), + &TestCases::default() + .with_max_page_size(1024 * 1024) + .with_encoding(encoding), HashMap::new(), ) .await; @@ -803,7 +1075,7 @@ pub mod tests { #[values(true, false)] with_nulls: bool, #[values(100, 500, 35000)] dict_size: u32, ) { - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); let strings = (0..dict_size) .map(|i| i.to_string()) .collect::>(); @@ -831,7 +1103,7 @@ pub mod tests { let test_cases = TestCases::default() .with_expected_encoding("variable") - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); // Test both automatic selection and explicit configuration // 1. Test automatic binary encoding selection (small strings that won't trigger FSST) @@ -970,4 +1242,195 @@ pub mod tests { } } } + + #[test] + fn test_binary_miniblock_rejects_corrupt_offsets() { + use super::BinaryMiniBlockDecompressor; + use crate::compression::MiniBlockDecompressor; + use lance_core::Error; + + // Chunk layout mirrors the on-disk format for ["alpha", "beta", "gamma"]: + // LE u32 offsets [16, 21, 25, 30] followed by the value bytes, padded to + // a multiple of 8 bytes. + fn chunk_u32(offsets: &[u32], values: &[u8]) -> LanceBuffer { + let mut chunk = offsets + .iter() + .flat_map(|offset| offset.to_le_bytes()) + .collect::>(); + chunk.extend_from_slice(values); + chunk.resize(chunk.len().next_multiple_of(8), 0); + LanceBuffer::from(chunk) + } + + let decompressor = BinaryMiniBlockDecompressor::new(32); + + // The tail offset points past the end of the 32-byte chunk. + let err = decompressor + .decompress( + vec![chunk_u32(&[16, 21, 25, 100_000], b"alphabetagamma")], + 3, + ) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("out of bounds"), "{err}"); + + // Offsets go backwards, which would underflow the rebase subtraction. + let err = decompressor + .decompress(vec![chunk_u32(&[16, 25, 21, 30], b"alphabetagamma")], 3) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("decreases"), "{err}"); + + // The first offset points inside the offset table, which would alias + // the serialized offsets into the value bytes. + let err = decompressor + .decompress(vec![chunk_u32(&[0, 21, 25, 30], b"alphabetagamma")], 3) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("overlaps"), "{err}"); + + // The chunk stores fewer offsets than the requested value count needs. + let err = decompressor + .decompress(vec![chunk_u32(&[8, 8], &[])], 3) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("requires 4"), "{err}"); + + // The chunk size is not a multiple of the offset width. + let err = decompressor + .decompress(vec![LanceBuffer::from(vec![0u8; 10])], 1) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("multiple"), "{err}"); + + // 64-bit offsets take the same validation path. + fn chunk_u64(offsets: &[u64], values: &[u8]) -> LanceBuffer { + let mut chunk = offsets + .iter() + .flat_map(|offset| offset.to_le_bytes()) + .collect::>(); + chunk.extend_from_slice(values); + chunk.resize(chunk.len().next_multiple_of(8), 0); + LanceBuffer::from(chunk) + } + let decompressor = BinaryMiniBlockDecompressor::new(64); + let err = decompressor + .decompress( + vec![chunk_u64(&[32, 37, 41, 100_000], b"alphabetagamma")], + 3, + ) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("out of bounds"), "{err}"); + let err = decompressor + .decompress(vec![chunk_u64(&[0, 37, 41, 46], b"alphabetagamma")], 3) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("overlaps"), "{err}"); + + // A valid chunk still decodes: offsets rebase to [0, 5, 9, 14]. + let decompressor = BinaryMiniBlockDecompressor::new(32); + let block = decompressor + .decompress(vec![chunk_u32(&[16, 21, 25, 30], b"alphabetagamma")], 3) + .unwrap(); + let DataBlock::VariableWidth(block) = block else { + panic!("expected a variable-width block"); + }; + assert_eq!(block.data.as_ref(), b"alphabetagamma"); + assert_eq!( + block.offsets, + LanceBuffer::reinterpret_vec(vec![0_u32, 5, 9, 14]) + ); + } + + fn encoded_binary_block(bits_per_offset: u8) -> Vec { + use crate::compression::BlockCompressor; + + let offsets = match bits_per_offset { + 32 => LanceBuffer::reinterpret_vec(vec![0_i32, 5, 9, 14]), + 64 => LanceBuffer::reinterpret_vec(vec![0_i64, 5, 9, 14]), + _ => unreachable!(), + }; + let block = DataBlock::VariableWidth(VariableWidthBlock { + data: LanceBuffer::copy_slice(b"alphabetagamma"), + offsets, + bits_per_offset, + num_values: 3, + block_info: BlockInfo::new(), + }); + BlockCompressor::compress(&super::VariableEncoder::default(), block) + .unwrap() + .0 + .as_ref() + .unwrap() + .to_vec() + } + + /// The block decompressor only checks the block structure (all O(1)); bad + /// offset values inside a structurally-sound block are rejected by the + /// mandatory layout validation when the block is converted to Arrow. + #[rstest] + #[case::i32_tail_out_of_bounds(32, 3, 100_000, "out of bounds")] + #[case::i64_tail_out_of_bounds(64, 3, 15, "out of bounds")] + #[case::i32_non_monotonic(32, 2, 4, "non-monotonic")] + #[case::i64_non_monotonic(64, 2, 4, "non-monotonic")] + fn test_binary_block_bad_offsets_rejected_at_arrow_conversion( + #[case] bits_per_offset: u8, + #[case] mutated_offset_index: usize, + #[case] mutated_offset_value: u64, + #[case] expected_message: &str, + ) { + use crate::compression::BlockDecompressor; + use lance_core::Error; + + let mut encoded = encoded_binary_block(bits_per_offset); + let bytes_per_offset = (bits_per_offset / 8) as usize; + // The standard scheme header is two offset-width fields. + let mutated_offset_start = bytes_per_offset * (2 + mutated_offset_index); + encoded[mutated_offset_start..mutated_offset_start + bytes_per_offset] + .copy_from_slice(&mutated_offset_value.to_le_bytes()[..bytes_per_offset]); + + let block = super::BinaryBlockDecompressor::default() + .decompress(Some(LanceBuffer::from(encoded)), 3) + .unwrap(); + let data_type = match bits_per_offset { + 32 => DataType::Binary, + _ => DataType::LargeBinary, + }; + let err = block.into_arrow(data_type, false).unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains(expected_message), "{err}"); + } + + #[test] + fn test_binary_block_rejects_corrupt_structure() { + use crate::compression::BlockDecompressor; + use lance_core::Error; + + let decompressor = super::BinaryBlockDecompressor::default(); + + // The first offset must be zero. + let mut encoded = encoded_binary_block(32); + encoded[8..12].copy_from_slice(&5_u32.to_le_bytes()); + let err = decompressor + .decompress(Some(LanceBuffer::from(encoded)), 3) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("first offset"), "{err}"); + + // The offsets region must hold exactly num_values + 1 offsets. + let encoded = encoded_binary_block(32); + let err = decompressor + .decompress(Some(LanceBuffer::from(encoded)), 4) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("offset bytes"), "{err}"); + + // A block too small to hold its header is rejected, not a panic. + let err = decompressor + .decompress(Some(LanceBuffer::from(vec![0_u8; 2])), 1) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err:?}"); + assert!(err.to_string().contains("too small"), "{err}"); + } } diff --git a/rust/lance-encoding/src/encodings/physical/bitpacking.rs b/rust/lance-encoding/src/encodings/physical/bitpacking.rs index 207219b58f0..6c957e31e71 100644 --- a/rust/lance-encoding/src/encodings/physical/bitpacking.rs +++ b/rust/lance-encoding/src/encodings/physical/bitpacking.rs @@ -18,26 +18,27 @@ use arrow_array::types::UInt64Type; use arrow_array::{Array, PrimitiveArray}; use arrow_buffer::ArrowNativeType; -use byteorder::{ByteOrder, LittleEndian}; -use lance_bitpacking::BitPacking; -use snafu::location; +use lance_bitpacking::BitPackingUninit; use lance_core::{Error, Result}; use crate::buffer::LanceBuffer; -use crate::compression::{BlockCompressor, BlockDecompressor, MiniBlockDecompressor}; +use crate::compression::{ + BlockCompressor, BlockDecompressor, MiniBlockDecompressor, require_block_payload, +}; use crate::data::BlockInfo; use crate::data::{DataBlock, FixedWidthDataBlock}; use crate::encodings::logical::primitive::miniblock::{ - MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressor, + MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor, }; use crate::format::pb21::CompressiveEncoding; -use crate::format::{pb21, ProtobufUtils21}; +use crate::format::{ProtobufUtils21, pb21}; use crate::statistics::{GetStat, Stat}; -use bytemuck::{cast_slice, AnyBitPattern}; +use bytemuck::Pod; -const LOG_ELEMS_PER_CHUNK: u8 = 10; -const ELEMS_PER_CHUNK: u64 = 1 << LOG_ELEMS_PER_CHUNK; +pub(crate) const LOG_ELEMS_PER_CHUNK: u8 = 10; +/// Number of values encoded in each inline bitpacking chunk. +pub const ELEMS_PER_CHUNK: u64 = 1 << LOG_ELEMS_PER_CHUNK; #[derive(Debug, Default)] pub struct InlineBitpacking { @@ -71,7 +72,7 @@ impl InlineBitpacking { /// Each chunk can have a different bit width /// /// Each chunk has the compressed bit width stored inline in the chunk itself. - fn bitpack_chunked( + fn bitpack_chunked( data: FixedWidthDataBlock, ) -> MiniBlockCompressed { debug_assert!(data.num_values > 0); @@ -112,12 +113,13 @@ impl InlineBitpacking { output.push(T::from_usize(bit_width).unwrap()); let output_len = output.len(); unsafe { - output.set_len(output_len + *packed_chunk_size); - BitPacking::unchecked_pack( + BitPackingUninit::unchecked_pack_uninit( bit_width, &data_buffer[start_elem..][..ELEMS_PER_CHUNK as usize], - &mut output[output_len..][..*packed_chunk_size], + &mut output.spare_capacity_mut()[..*packed_chunk_size], ); + // The bitpacking kernel initialized every reserved output word. + output.set_len(output_len + *packed_chunk_size); } chunks.push(MiniBlockChunk { buffer_sizes: vec![((1 + *packed_chunk_size) * std::mem::size_of::()) as u32], @@ -138,13 +140,15 @@ impl InlineBitpacking { let bit_width = bit_widths_array.value(bit_widths_array.len() - 1) as usize; output.push(T::from_usize(bit_width).unwrap()); let output_len = output.len(); + let packed_chunk_size = packed_chunk_sizes[bit_widths_array.len() - 1]; unsafe { - output.set_len(output_len + packed_chunk_sizes[bit_widths_array.len() - 1]); - BitPacking::unchecked_pack( + BitPackingUninit::unchecked_pack_uninit( bit_width, &last_chunk, - &mut output[output_len..][..packed_chunk_sizes[bit_widths_array.len() - 1]], + &mut output.spare_capacity_mut()[..packed_chunk_size], ); + // The bitpacking kernel initialized every reserved output word. + output.set_len(output_len + packed_chunk_size); } chunks.push(MiniBlockChunk { buffer_sizes: vec![ @@ -182,27 +186,91 @@ impl InlineBitpacking { ) } - fn unchunk( + fn unchunk( data: LanceBuffer, num_values: u64, ) -> Result { - // Ensure at least the header is present - assert!(data.len() >= std::mem::size_of::()); - assert!(num_values <= ELEMS_PER_CHUNK); - // This macro decompresses a chunk(1024 values) of bitpacked values. let uncompressed_bit_width = std::mem::size_of::() * 8; - let mut decompressed = vec![T::from_usize(0).unwrap(); ELEMS_PER_CHUNK as usize]; - - // Copy for memory alignment - let chunk_in_u8: Vec = data.to_vec(); - let bit_width_bytes = &chunk_in_u8[..std::mem::size_of::()]; - let bit_width_value = LittleEndian::read_uint(bit_width_bytes, std::mem::size_of::()); - let chunk = cast_slice(&chunk_in_u8[std::mem::size_of::()..]); - // The bit-packed chunk should have number of bytes (bit_width_value * ELEMS_PER_CHUNK / 8) - assert!(std::mem::size_of_val(chunk) == (bit_width_value * ELEMS_PER_CHUNK) as usize / 8); + let word_size = std::mem::size_of::(); + + if data.len() < word_size { + return Err(Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking chunk is too small for {}-byte header: {} bytes", + word_size, + data.len() + ), + )); + } + if !data.len().is_multiple_of(word_size) { + return Err(Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking chunk size must be a multiple of {} bytes, got {} bytes", + word_size, + data.len() + ), + )); + } + if num_values > ELEMS_PER_CHUNK { + return Err(Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking chunk has {} values, expected at most {}", + num_values, ELEMS_PER_CHUNK + ), + )); + } + + let chunk_words = data.borrow_to_typed_view::(); + let bit_width_value = chunk_words[0].as_usize(); + if bit_width_value > uncompressed_bit_width { + return Err(Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking width {} exceeds {}-bit values", + bit_width_value, uncompressed_bit_width + ), + )); + } + let chunk = &chunk_words[1..]; + // bit_width_value has already been verified to be <= uncompressed_bit_width + // (8/16/32/64), so bit_width_value * ELEMS_PER_CHUNK (1024) can never + // overflow usize on supported targets. Keep checked_mul as defense in depth. + let expected_num_bits = bit_width_value + .checked_mul(ELEMS_PER_CHUNK as usize) + .ok_or_else(|| { + Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking width {} overflows chunk bit count", + bit_width_value + ), + ) + })?; + let expected_num_bytes = expected_num_bits / 8; + let actual_num_bytes = std::mem::size_of_val(chunk); + if actual_num_bytes != expected_num_bytes { + return Err(Error::corrupt_file_named( + "inline_bitpacking", + format!( + "Inline bitpacking payload has {} bytes, expected {} bytes for bit width {}", + actual_num_bytes, expected_num_bytes, bit_width_value + ), + )); + } + + let mut decompressed = Vec::with_capacity(ELEMS_PER_CHUNK as usize); unsafe { - BitPacking::unchecked_unpack(bit_width_value as usize, chunk, &mut decompressed); + BitPackingUninit::unchecked_unpack_uninit( + bit_width_value, + chunk, + &mut decompressed.spare_capacity_mut()[..ELEMS_PER_CHUNK as usize], + ); + // The bitpacking kernel initialized all 1024 decoded values. + decompressed.set_len(ELEMS_PER_CHUNK as usize); } decompressed.truncate(num_values as usize); @@ -213,29 +281,60 @@ impl InlineBitpacking { block_info: BlockInfo::new(), })) } + + /// An empty fixed-width block, used for the `num_values == 0` short-circuit in + /// both decompressor entry points so empty blocks skip chunk validation entirely. + fn empty_block(&self) -> DataBlock { + DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::empty(), + bits_per_value: self.uncompressed_bit_width, + num_values: 0, + block_info: BlockInfo::new(), + }) + } } impl MiniBlockCompressor for InlineBitpacking { - fn compress(&self, chunk: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + _context: MiniBlockCompressionContext, + chunk: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match chunk { DataBlock::FixedWidth(fixed_width) => Ok(self.chunk_data(fixed_width)), - _ => Err(Error::InvalidInput { - source: format!( + _ => Err(Error::invalid_input_source( + format!( "Cannot compress a data block of type {} with BitpackMiniBlockEncoder", chunk.name() ) .into(), - location: location!(), - }), + )), } } } impl BlockCompressor for InlineBitpacking { - fn compress(&self, data: DataBlock) -> Result { - let fixed_width = data.as_fixed_width().unwrap(); + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)> { + let DataBlock::FixedWidth(fixed_width) = data else { + return Err(Error::invalid_input( + "Inline bitpacking requires fixed-width data", + )); + }; + if fixed_width.bits_per_value != self.uncompressed_bit_width { + return Err(Error::invalid_input(format!( + "Inline bitpacking expects {}-bit values, got {}", + self.uncompressed_bit_width, fixed_width.bits_per_value + ))); + } let (chunked, _) = self.chunk_data(fixed_width); - Ok(chunked.data.into_iter().next().unwrap()) + let payload = + chunked.data.into_iter().next().ok_or_else(|| { + Error::internal("Inline bitpacking produced no payload".to_string()) + })?; + Ok(( + Some(payload), + ProtobufUtils21::inline_bitpacking(self.uncompressed_bit_width, None), + )) } } @@ -243,6 +342,10 @@ impl MiniBlockDecompressor for InlineBitpacking { fn decompress(&self, data: Vec, num_values: u64) -> Result { assert_eq!(data.len(), 1); let data = data.into_iter().next().unwrap(); + if num_values == 0 { + // Empty mini-blocks have no inline bit-width header to decode. + return Ok(self.empty_block()); + } match self.uncompressed_bit_width { 8 => Self::unchunk::(data, num_values), 16 => Self::unchunk::(data, num_values), @@ -251,10 +354,23 @@ impl MiniBlockDecompressor for InlineBitpacking { _ => unimplemented!("Bitpacking word size must be 8, 16, 32, or 64"), } } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + num_values + .checked_mul(self.uncompressed_bit_width) + .map(|bits| bits.div_ceil(8)) + } } impl BlockDecompressor for InlineBitpacking { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "Inline bitpacking")?; + if num_values == 0 { + // Empty blocks carry no inline bit-width header to decode; avoid + // spurious "too small for header" corrupt-file errors and mirror + // the MiniBlockDecompressor path. See #7794. + return Ok(self.empty_block()); + } match self.uncompressed_bit_width { 8 => Self::unchunk::(data, num_values), 16 => Self::unchunk::(data, num_values), @@ -270,7 +386,7 @@ impl BlockDecompressor for InlineBitpacking { /// Each chunk of 1024 values is packed with a constant bit width. For the tail we compare the /// cost of padding and packing against storing the raw values: if padding yields a smaller /// representation we pack; otherwise we append the raw tail. -fn bitpack_out_of_line( +fn bitpack_out_of_line( data: FixedWidthDataBlock, compressed_bits_per_value: usize, ) -> LanceBuffer { @@ -281,12 +397,7 @@ fn bitpack_out_of_line( let last_chunk_is_runt = data_buffer.len() % ELEMS_PER_CHUNK as usize != 0; let words_per_chunk = (ELEMS_PER_CHUNK as usize * compressed_bits_per_value) .div_ceil(data.bits_per_value as usize); - #[allow(clippy::uninit_vec)] let mut output: Vec = Vec::with_capacity(num_chunks * words_per_chunk); - #[allow(clippy::uninit_vec)] - unsafe { - output.set_len(num_chunks * words_per_chunk); - } let num_whole_chunks = if last_chunk_is_runt { num_chunks - 1 @@ -298,14 +409,15 @@ fn bitpack_out_of_line( for i in 0..num_whole_chunks { let input_start = i * ELEMS_PER_CHUNK as usize; let input_end = input_start + ELEMS_PER_CHUNK as usize; - let output_start = i * words_per_chunk; - let output_end = output_start + words_per_chunk; + let output_start = output.len(); unsafe { - BitPacking::unchecked_pack( + BitPackingUninit::unchecked_pack_uninit( compressed_bits_per_value, &data_buffer[input_start..input_end], - &mut output[output_start..output_end], + &mut output.spare_capacity_mut()[..words_per_chunk], ); + // The bitpacking kernel initialized this complete packed chunk. + output.set_len(output_start + words_per_chunk); } } @@ -314,10 +426,6 @@ fn bitpack_out_of_line( } let last_chunk_start = num_whole_chunks * ELEMS_PER_CHUNK as usize; - // Safety: output ensures to have those values. - unsafe { - output.set_len(num_whole_chunks * words_per_chunk); - } let remaining_items = data_buffer.len() - last_chunk_start; let uncompressed_bits = data.bits_per_value as usize; @@ -333,13 +441,13 @@ fn bitpack_out_of_line( last_chunk[..remaining_items].copy_from_slice(&data_buffer[last_chunk_start..]); let start = output.len(); unsafe { - // Capacity reserves a full chunk for each block; extend the visible length and fill it immediately. - output.set_len(start + words_per_chunk); - BitPacking::unchecked_pack( + BitPackingUninit::unchecked_pack_uninit( compressed_bits_per_value, &last_chunk, - &mut output[start..start + words_per_chunk], + &mut output.spare_capacity_mut()[..words_per_chunk], ); + // The bitpacking kernel initialized the padded tail chunk. + output.set_len(start + words_per_chunk); } } else { // Padding would waste space; append tail values as-is. @@ -354,7 +462,7 @@ fn bitpack_out_of_line( /// The compressed bit width is provided while the uncompressed width comes from `T`. /// Depending on the encoding decision the final chunk may be fully packed (with padding) /// or stored as raw tail values. We infer the layout from the buffer length. -fn unpack_out_of_line( +fn unpack_out_of_line( data: FixedWidthDataBlock, num_values: usize, compressed_bits_per_value: usize, @@ -370,25 +478,21 @@ fn unpack_out_of_line( let tail_is_raw = tail_values > 0 && compressed_words.len() == expected_new_len; let extra_tail_capacity = ELEMS_PER_CHUNK as usize; - #[allow(clippy::uninit_vec)] let mut decompressed: Vec = Vec::with_capacity(num_values.saturating_add(extra_tail_capacity)); - let chunk_value_len = num_whole_chunks * ELEMS_PER_CHUNK as usize; - unsafe { - decompressed.set_len(chunk_value_len); - } for chunk_idx in 0..num_whole_chunks { let input_start = chunk_idx * words_per_chunk; let input_end = input_start + words_per_chunk; - let output_start = chunk_idx * ELEMS_PER_CHUNK as usize; - let output_end = output_start + ELEMS_PER_CHUNK as usize; + let output_start = decompressed.len(); unsafe { - BitPacking::unchecked_unpack( + BitPackingUninit::unchecked_unpack_uninit( compressed_bits_per_value, &compressed_words[input_start..input_end], - &mut decompressed[output_start..output_end], + &mut decompressed.spare_capacity_mut()[..ELEMS_PER_CHUNK as usize], ); + // The bitpacking kernel initialized this complete decoded chunk. + decompressed.set_len(output_start + ELEMS_PER_CHUNK as usize); } } @@ -402,14 +506,13 @@ fn unpack_out_of_line( let tail_start = expected_full_words; let output_start = decompressed.len(); unsafe { - decompressed.set_len(output_start + ELEMS_PER_CHUNK as usize); - } - unsafe { - BitPacking::unchecked_unpack( + BitPackingUninit::unchecked_unpack_uninit( compressed_bits_per_value, &compressed_words[tail_start..tail_start + words_per_chunk], - &mut decompressed[output_start..output_start + ELEMS_PER_CHUNK as usize], + &mut decompressed.spare_capacity_mut()[..ELEMS_PER_CHUNK as usize], ); + // The kernel initialized a full chunk; only the requested tail stays visible. + decompressed.set_len(output_start + ELEMS_PER_CHUNK as usize); } decompressed.truncate(output_start + tail_values); } @@ -466,21 +569,43 @@ impl OutOfLineBitpacking { } impl BlockCompressor for OutOfLineBitpacking { - fn compress(&self, data: DataBlock) -> Result { - let fixed_width = data.as_fixed_width().unwrap(); + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)> { + let DataBlock::FixedWidth(fixed_width) = data else { + return Err(Error::invalid_input( + "Out-of-line bitpacking requires fixed-width data", + )); + }; + if fixed_width.bits_per_value != self.uncompressed_bit_width { + return Err(Error::invalid_input(format!( + "Out-of-line bitpacking expects {}-bit values, got {}", + self.uncompressed_bit_width, fixed_width.bits_per_value + ))); + } let compressed = match fixed_width.bits_per_value { 8 => bitpack_out_of_line::(fixed_width, self.compressed_bit_width as usize), 16 => bitpack_out_of_line::(fixed_width, self.compressed_bit_width as usize), 32 => bitpack_out_of_line::(fixed_width, self.compressed_bit_width as usize), 64 => bitpack_out_of_line::(fixed_width, self.compressed_bit_width as usize), - _ => panic!("Bitpacking word size must be 8,16,32,64"), + _ => { + return Err(Error::invalid_input(format!( + "Bitpacking word size must be 8, 16, 32, or 64, got {}", + fixed_width.bits_per_value + ))); + } }; - Ok(compressed) + Ok(( + Some(compressed), + ProtobufUtils21::out_of_line_bitpacking( + self.uncompressed_bit_width, + ProtobufUtils21::flat(self.compressed_bit_width, None), + ), + )) } } impl BlockDecompressor for OutOfLineBitpacking { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "Out-of-line bitpacking")?; let word_size = match self.uncompressed_bit_width { 8 => std::mem::size_of::(), 16 => std::mem::size_of::(), @@ -528,20 +653,135 @@ impl BlockDecompressor for OutOfLineBitpacking { mod test { use std::{collections::HashMap, sync::Arc}; - use arrow_array::{Array, Int64Array, Int8Array}; + use arrow_array::{Array, Int8Array, Int64Array}; + use arrow_buffer::ArrowNativeType; use arrow_schema::DataType; + use bytemuck::Pod; + use lance_bitpacking::{BitPacking, BitPackingUninit}; + use rstest::rstest; - use super::{bitpack_out_of_line, unpack_out_of_line, ELEMS_PER_CHUNK}; + use super::{ELEMS_PER_CHUNK, InlineBitpacking, bitpack_out_of_line, unpack_out_of_line}; use crate::{ buffer::LanceBuffer, - data::{BlockInfo, FixedWidthDataBlock}, - testing::{check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + compression::{BlockDecompressor, MiniBlockDecompressor}, + data::{BlockInfo, DataBlock, FixedWidthDataBlock}, + testing::{TestCases, check_round_trip_encoding_of_data}, }; + #[rstest] + #[case::u8(8)] + #[case::u16(16)] + #[case::u32(32)] + #[case::u64(64)] + fn test_inline_bitpacking_decompress_empty_miniblock(#[case] bit_width: u64) { + let decompressor = InlineBitpacking::new(bit_width); + let decompressed = + MiniBlockDecompressor::decompress(&decompressor, vec![LanceBuffer::empty()], 0) + .unwrap(); + + let DataBlock::FixedWidth(block) = decompressed else { + panic!("Expected FixedWidth block"); + }; + assert_eq!(block.bits_per_value, bit_width); + assert_eq!(block.num_values, 0); + assert_eq!(block.data.len(), 0); + } + + // Regression test for #7794: the block-level decompressor must short-circuit + // on num_values == 0 the same way the mini-block decompressor does, instead + // of reporting a spurious "too small for header" corrupt-file error. + #[rstest] + #[case::u8(8)] + #[case::u16(16)] + #[case::u32(32)] + #[case::u64(64)] + fn test_inline_bitpacking_decompress_empty_block(#[case] bit_width: u64) { + let decompressor = InlineBitpacking::new(bit_width); + let decompressed = + BlockDecompressor::decompress(&decompressor, Some(LanceBuffer::empty()), 0).unwrap(); + + let DataBlock::FixedWidth(block) = decompressed else { + panic!("Expected FixedWidth block"); + }; + assert_eq!(block.bits_per_value, bit_width); + assert_eq!(block.num_values, 0); + assert_eq!(block.data.len(), 0); + } + + fn roundtrip_unchunk(values: &[T], bit_width: usize) + where + T: ArrowNativeType + BitPackingUninit + Pod, + { + assert!(values.len() <= ELEMS_PER_CHUNK as usize); + let num_values = values.len() as u64; + + let mut padded = vec![T::from_usize(0).unwrap(); ELEMS_PER_CHUNK as usize]; + padded[..values.len()].copy_from_slice(values); + + let packed_words = ELEMS_PER_CHUNK as usize * bit_width / (std::mem::size_of::() * 8); + let mut chunk: Vec = Vec::with_capacity(1 + packed_words); + chunk.push(T::from_usize(bit_width).unwrap()); + let out_len = chunk.len(); + chunk.resize(out_len + packed_words, T::from_usize(0).unwrap()); + unsafe { + BitPacking::unchecked_pack(bit_width, &padded, &mut chunk[out_len..]); + } + + let data = LanceBuffer::reinterpret_vec(chunk); + let decoded = InlineBitpacking::unchunk::(data, num_values).unwrap(); + let DataBlock::FixedWidth(fixed) = decoded else { + panic!("expected FixedWidth DataBlock"); + }; + let decoded_values = fixed.data.borrow_to_typed_view::(); + assert_eq!(decoded_values.as_ref(), values); + } + + fn assert_corrupt_unchunk(data: LanceBuffer, num_values: u64, expected_message: &str) + where + T: ArrowNativeType + BitPackingUninit + Pod, + { + let err = InlineBitpacking::unchunk::(data, num_values).unwrap_err(); + assert!(matches!(err, lance_core::Error::CorruptFile { .. })); + let err = err.to_string(); + assert!( + err.contains(expected_message), + "expected error containing {expected_message:?}, got {err:?}" + ); + } + + #[test] + fn unchunk_u32_bw12_tail() { + let values: Vec = (0..500).map(|i| ((i * 7) % (1 << 12)) as u32).collect(); + roundtrip_unchunk(&values, 12); + } + + #[test] + fn unchunk_u64_bw23_full() { + let values: Vec = (0..1024).map(|i| ((i * 3) % (1 << 23)) as u64).collect(); + roundtrip_unchunk(&values, 23); + } + + #[rstest] + #[case::too_small_header(LanceBuffer::from(vec![1, 2, 3]), 1, "too small")] + #[case::misaligned_chunk_size(LanceBuffer::from(vec![0, 0, 0, 0, 0]), 1, "multiple")] + #[case::too_many_values( + LanceBuffer::reinterpret_vec(vec![0_u32]), + ELEMS_PER_CHUNK + 1, + "expected at most" + )] + #[case::payload_size_mismatch(LanceBuffer::reinterpret_vec(vec![12_u32]), 1, "payload")] + #[case::invalid_bit_width(LanceBuffer::reinterpret_vec(vec![33_u32]), 1, "exceeds")] + fn unchunk_rejects( + #[case] data: LanceBuffer, + #[case] num_values: u64, + #[case] expected_message: &str, + ) { + assert_corrupt_unchunk::(data, num_values, expected_message); + } + #[test_log::test(tokio::test)] async fn test_miniblock_bitpack() { - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); let arrays = vec![ Arc::new(Int8Array::from(vec![100; 1024])) as Arc, @@ -580,7 +820,7 @@ mod test { // Test bitpacking encoding verification with varied small values that should trigger bitpacking let test_cases = TestCases::default() .with_expected_encoding("inline_bitpacking") - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); // Generate data with varied small values to avoid RLE // Mix different values but keep them small to trigger bitpacking @@ -604,7 +844,7 @@ mod test { let test_cases = TestCases::default() .with_expected_encoding("inline_bitpacking") - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); // Build 2048 values: first 1024 all zeros (bit_width=0), // next 1024 small varied values to avoid RLE and trigger bitpacking. diff --git a/rust/lance-encoding/src/encodings/physical/block.rs b/rust/lance-encoding/src/encodings/physical/block.rs index 9104fd9bdd1..9d97c9e72b9 100644 --- a/rust/lance-encoding/src/encodings/physical/block.rs +++ b/rust/lance-encoding/src/encodings/physical/block.rs @@ -23,15 +23,14 @@ use arrow_buffer::ArrowNativeType; use lance_core::{Error, Result}; -use snafu::location; use std::str::FromStr; -use crate::compression::{BlockCompressor, BlockDecompressor}; +use crate::compression::{BlockCompressor, BlockDecompressor, require_block_payload}; use crate::encodings::physical::binary::{BinaryBlockDecompressor, VariableEncoder}; use crate::format::{ - pb21::{self, CompressiveEncoding}, ProtobufUtils21, + pb21::{self, CompressiveEncoding}, }; use crate::{ buffer::LanceBuffer, @@ -47,9 +46,20 @@ pub struct CompressionConfig { } impl CompressionConfig { - pub(crate) fn new(scheme: CompressionScheme, level: Option) -> Self { + /// Create a compression configuration for an encoding mechanism. + pub fn new(scheme: CompressionScheme, level: Option) -> Self { Self { scheme, level } } + + /// Return the selected compression scheme. + pub fn scheme(&self) -> CompressionScheme { + self.scheme + } + + /// Return the optional compression level. + pub fn level(&self) -> Option { + self.level + } } impl Default for CompressionConfig { @@ -76,10 +86,10 @@ impl TryFrom for pb21::CompressionScheme { match scheme { CompressionScheme::Lz4 => Ok(Self::CompressionAlgorithmLz4), CompressionScheme::Zstd => Ok(Self::CompressionAlgorithmZstd), - _ => Err(Error::invalid_input( - format!("Unsupported compression scheme: {:?}", scheme), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unsupported compression scheme: {:?}", + scheme + ))), } } } @@ -91,10 +101,10 @@ impl TryFrom for CompressionScheme { match scheme { pb21::CompressionScheme::CompressionAlgorithmLz4 => Ok(Self::Lz4), pb21::CompressionScheme::CompressionAlgorithmZstd => Ok(Self::Zstd), - _ => Err(Error::invalid_input( - format!("Unsupported compression scheme: {:?}", scheme), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unsupported compression scheme: {:?}", + scheme + ))), } } } @@ -120,10 +130,10 @@ impl FromStr for CompressionScheme { "fsst" => Ok(Self::Fsst), "zstd" => Ok(Self::Zstd), "lz4" => Ok(Self::Lz4), - _ => Err(Error::invalid_input( - format!("Unknown compression scheme: {}", s), - location!(), - )), + _ => Err(Error::invalid_input(format!( + "Unknown compression scheme: {}", + s + ))), } } } @@ -141,7 +151,7 @@ mod zstd { use super::*; - use ::zstd::bulk::{decompress_to_buffer, Compressor}; + use ::zstd::bulk::{Compressor, decompress_to_buffer}; use ::zstd::stream::copy_decode; /// A zstd buffer compressor that lazily creates and reuses compression contexts. @@ -186,10 +196,7 @@ mod zstd { .map_err(|e| e.to_string()) }) .as_ref() - .map_err(|e| Error::Internal { - message: format!("Failed to create zstd compressor: {}", e), - location: location!(), - }) + .map_err(|e| Error::internal(format!("Failed to create zstd compressor: {}", e))) } // https://datatracker.ietf.org/doc/html/rfc8878 @@ -262,10 +269,7 @@ mod zstd { .lock() .unwrap() .compress_to_buffer(input_buf, &mut output_buf[start_pos..]) - .map_err(|e| Error::Internal { - message: format!("Zstd compression error: {}", e), - location: location!(), - })?; + .map_err(|e| Error::internal(format!("Zstd compression error: {}", e)))?; output_buf.truncate(start_pos + compressed_size); Ok(()) @@ -318,10 +322,7 @@ mod lz4 { true, &mut output_buf[start_pos..], ) - .map_err(|err| Error::Internal { - message: format!("LZ4 compression error: {}", err), - location: location!(), - })?; + .map_err(|err| Error::internal(format!("LZ4 compression error: {}", err)))?; // Truncate to actual size output_buf.truncate(start_pos + compressed_size); @@ -332,10 +333,7 @@ mod lz4 { // When prepend_size is true, LZ4 stores the uncompressed size in the first 4 bytes // We can read this to know exactly how much space we need if input_buf.len() < 4 { - return Err(Error::Internal { - message: "LZ4 compressed data too short".to_string(), - location: location!(), - }); + return Err(Error::internal("LZ4 compressed data too short".to_string())); } // Read the uncompressed size from the first 4 bytes (little-endian) @@ -352,10 +350,7 @@ mod lz4 { // Now decompress directly into the buffer slice let decompressed_size = ::lz4::block::decompress_to_buffer(input_buf, None, &mut output_buf[start_pos..]) - .map_err(|err| Error::Internal { - message: format!("LZ4 decompression error: {}", err), - location: location!(), - })?; + .map_err(|err| Error::internal(format!("LZ4 decompression error: {}", err)))?; // Truncate to actual decompressed size (should be same as uncompressed_size) output_buf.truncate(start_pos + decompressed_size); @@ -402,10 +397,9 @@ impl GeneralBufferCompressor { ) -> Result> { match compression_config.scheme { // FSST has its own compression path and isn't implemented as a generic buffer compressor - CompressionScheme::Fsst => Err(Error::InvalidInput { - source: "fsst is not usable as a general buffer compressor".into(), - location: location!(), - }), + CompressionScheme::Fsst => Err(Error::invalid_input_source( + "fsst is not usable as a general buffer compressor".into(), + )), CompressionScheme::Zstd => { #[cfg(feature = "zstd")] { @@ -415,10 +409,9 @@ impl GeneralBufferCompressor { } #[cfg(not(feature = "zstd"))] { - Err(Error::InvalidInput { - source: "package was not built with zstd support".into(), - location: location!(), - }) + Err(Error::invalid_input_source( + "package was not built with zstd support".into(), + )) } } CompressionScheme::Lz4 => { @@ -428,10 +421,9 @@ impl GeneralBufferCompressor { } #[cfg(not(feature = "lz4"))] { - Err(Error::InvalidInput { - source: "package was not built with lz4 support".into(), - location: location!(), - }) + Err(Error::invalid_input_source( + "package was not built with lz4 support".into(), + )) } } CompressionScheme::None => Ok(Box::new(NoopBufferCompressor {})), @@ -458,11 +450,12 @@ impl GeneralBlockDecompressor { } impl BlockDecompressor for GeneralBlockDecompressor { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "General block compression")?; let mut decompressed = Vec::new(); self.compressor.decompress(&data, &mut decompressed)?; self.inner - .decompress(LanceBuffer::from(decompressed), num_values) + .decompress(Some(LanceBuffer::from(decompressed)), num_values) } } @@ -470,6 +463,9 @@ impl BlockDecompressor for GeneralBlockDecompressor { #[derive(Debug)] pub struct CompressedBufferEncoder { pub(crate) compressor: Box, + // Runtime compressors normalize levels that they default or ignore. Block descriptors must + // retain the selected configuration so stable writers preserve those present/absent values. + block_compression: CompressionConfig, } impl Default for CompressedBufferEncoder { @@ -482,25 +478,33 @@ impl Default for CompressedBufferEncoder { #[cfg(not(any(feature = "zstd", feature = "lz4")))] let (scheme, level) = (CompressionScheme::None, None); - let compressor = - GeneralBufferCompressor::get_compressor(CompressionConfig { scheme, level }).unwrap(); - Self { compressor } + let block_compression = CompressionConfig { scheme, level }; + let compressor = GeneralBufferCompressor::get_compressor(block_compression).unwrap(); + Self { + compressor, + block_compression, + } } } impl CompressedBufferEncoder { pub fn try_new(compression_config: CompressionConfig) -> Result { let compressor = GeneralBufferCompressor::get_compressor(compression_config)?; - Ok(Self { compressor }) + Ok(Self { + compressor, + block_compression: compression_config, + }) } pub fn from_scheme(scheme: pb21::CompressionScheme) -> Result { let scheme = CompressionScheme::try_from(scheme)?; + let block_compression = CompressionConfig { + scheme, + level: Some(0), + }; Ok(Self { - compressor: GeneralBufferCompressor::get_compressor(CompressionConfig { - scheme, - level: Some(0), - })?, + compressor: GeneralBufferCompressor::get_compressor(block_compression)?, + block_compression, }) } } @@ -549,13 +553,10 @@ impl CompressedBufferEncoder { impl PerValueCompressor for CompressedBufferEncoder { fn compress(&self, data: DataBlock) -> Result<(PerValueDataBlock, CompressiveEncoding)> { let data_type = data.name(); - let data = data.as_variable_width().ok_or(Error::Internal { - message: format!( - "Attempt to use CompressedBufferEncoder on data of type {}", - data_type - ), - location: location!(), - })?; + let data = data.as_variable_width().ok_or(Error::internal(format!( + "Attempt to use CompressedBufferEncoder on data of type {}", + data_type + )))?; let data_bytes = &data.data; let mut compressed = Vec::with_capacity(data_bytes.len()); @@ -625,36 +626,52 @@ impl VariablePerValueDecompressor for CompressedBufferEncoder { } impl BlockCompressor for CompressedBufferEncoder { - fn compress(&self, data: DataBlock) -> Result { - let encoded = match data { - DataBlock::FixedWidth(fixed_width) => fixed_width.data, + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)> { + let (encoded, inner_encoding) = match data { + DataBlock::FixedWidth(fixed_width) => ( + fixed_width.data, + ProtobufUtils21::flat(fixed_width.bits_per_value, None), + ), DataBlock::VariableWidth(variable_width) => { // Wrap VariableEncoder to handle the encoding let encoder = VariableEncoder::default(); - BlockCompressor::compress(&encoder, DataBlock::VariableWidth(variable_width))? + let (payload, encoding) = + BlockCompressor::compress(&encoder, DataBlock::VariableWidth(variable_width))?; + ( + payload.ok_or_else(|| { + Error::internal( + "VariableEncoder returned no payload for general compression" + .to_string(), + ) + })?, + encoding, + ) } _ => { - return Err(Error::InvalidInput { - source: "Unsupported data block type".into(), - location: location!(), - }) + return Err(Error::invalid_input_source( + "Unsupported data block type".into(), + )); } }; let mut compressed = Vec::new(); self.compressor.compress(&encoded, &mut compressed)?; - Ok(LanceBuffer::from(compressed)) + Ok(( + Some(LanceBuffer::from(compressed)), + ProtobufUtils21::wrapped(self.block_compression, inner_encoding)?, + )) } } impl BlockDecompressor for CompressedBufferEncoder { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "Compressed variable block")?; let mut decompressed = Vec::new(); self.compressor.decompress(&data, &mut decompressed)?; // Delegate to BinaryBlockDecompressor which handles the inline metadata let inner_decoder = BinaryBlockDecompressor::default(); - inner_decoder.decompress(LanceBuffer::from(decompressed), num_values) + inner_decoder.decompress(Some(LanceBuffer::from(decompressed)), num_values) } } @@ -787,8 +804,10 @@ mod tests { STRUCTURAL_ENCODING_META_KEY, }, encodings::physical::block::lz4::Lz4BufferCompressor, - testing::{check_round_trip_encoding_generated, FnArrayGeneratorProvider, TestCases}, - version::LanceFileVersion, + testing::{ + FnArrayGeneratorProvider, TestCases, TestEncoding, + check_round_trip_encoding_generated, + }, }; #[test] @@ -807,49 +826,59 @@ mod tests { assert_eq!(input_data, decompressed_data.as_slice()); } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_lz4_compress_round_trip() { - for data_type in &[ + async fn test_lz4_compress_round_trip( + #[values( DataType::Utf8, DataType::LargeUtf8, DataType::Binary, - DataType::LargeBinary, - ] { - let field = Field::new("", data_type.clone(), false); - let mut field_meta = HashMap::new(); - field_meta.insert(COMPRESSION_META_KEY.to_string(), "lz4".to_string()); - // Some bad cardinality estimatation causes us to use dictionary encoding currently - // which causes the expected encoding check to fail. - field_meta.insert(DICT_DIVISOR_META_KEY.to_string(), "100000".to_string()); - field_meta.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.0001".to_string()); - // Also disable size-based dictionary encoding - field_meta.insert( - STRUCTURAL_ENCODING_META_KEY.to_string(), - STRUCTURAL_ENCODING_FULLZIP.to_string(), - ); - let field = field.with_metadata(field_meta); - let test_cases = TestCases::basic() - // Need to use large pages as small pages might be too small to compress - .with_page_sizes(vec![1024 * 1024]) - .with_expected_encoding("zstd") - .with_min_file_version(LanceFileVersion::V2_1); - - // Can't use the default random provider because random data isn't compressible - // and we will fallback to uncompressed encoding - let datagen = Box::new(FnArrayGeneratorProvider::new(move || match data_type { - DataType::Utf8 => utf8_prefix_plus_counter("compressme", false), - DataType::Binary => { - binary_prefix_plus_counter(Arc::from(b"compressme".to_owned()), false) - } - DataType::LargeUtf8 => utf8_prefix_plus_counter("compressme", true), - DataType::LargeBinary => { - binary_prefix_plus_counter(Arc::from(b"compressme".to_owned()), true) - } - _ => panic!("Unsupported data type: {:?}", data_type), - })); - - check_round_trip_encoding_generated(field, datagen, test_cases).await; - } + DataType::LargeBinary + )] + data_type: DataType, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(false, true)] use_slicing: bool, + ) { + let field = Field::new("", data_type.clone(), false); + let mut field_meta = HashMap::new(); + field_meta.insert(COMPRESSION_META_KEY.to_string(), "lz4".to_string()); + // Some bad cardinality estimatation causes us to use dictionary encoding currently + // which causes the expected encoding check to fail. + field_meta.insert(DICT_DIVISOR_META_KEY.to_string(), "100000".to_string()); + field_meta.insert(DICT_SIZE_RATIO_META_KEY.to_string(), "0.0001".to_string()); + // Also disable size-based dictionary encoding + field_meta.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_FULLZIP.to_string(), + ); + let field = field.with_metadata(field_meta); + let test_cases = TestCases::basic() + // Need to use large pages as small pages might be too small to compress + .with_page_sizes(vec![1024 * 1024]) + .with_expected_encoding("zstd") + .with_encoding(encoding) + .with_slicing_modes([use_slicing]); + + // Can't use the default random provider because random data isn't compressible + // and we will fallback to uncompressed encoding + let datagen = Box::new(FnArrayGeneratorProvider::new(move || match data_type { + DataType::Utf8 => utf8_prefix_plus_counter("compressme", false), + DataType::Binary => { + binary_prefix_plus_counter(Arc::from(b"compressme".to_owned()), false) + } + DataType::LargeUtf8 => utf8_prefix_plus_counter("compressme", true), + DataType::LargeBinary => { + binary_prefix_plus_counter(Arc::from(b"compressme".to_owned()), true) + } + _ => panic!("Unsupported data type: {:?}", data_type), + })); + + check_round_trip_encoding_generated(field, datagen, test_cases).await; } } } diff --git a/rust/lance-encoding/src/encodings/physical/byte_stream_split.rs b/rust/lance-encoding/src/encodings/physical/byte_stream_split.rs index 6b442428389..ae098886eeb 100644 --- a/rust/lance-encoding/src/encodings/physical/byte_stream_split.rs +++ b/rust/lance-encoding/src/encodings/physical/byte_stream_split.rs @@ -62,14 +62,13 @@ use crate::compression::MiniBlockDecompressor; use crate::compression_config::BssMode; use crate::data::{BlockInfo, DataBlock, FixedWidthDataBlock}; use crate::encodings::logical::primitive::miniblock::{ - MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressor, + MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor, }; -use crate::format::pb21::CompressiveEncoding; use crate::format::ProtobufUtils21; +use crate::format::pb21::CompressiveEncoding; use crate::statistics::{GetStat, Stat}; use arrow_array::{cast::AsArray, types::UInt64Type}; use lance_core::Result; -use snafu::location; /// Byte Stream Split encoder for floating point values /// @@ -108,7 +107,11 @@ impl ByteStreamSplitEncoder { } impl MiniBlockCompressor for ByteStreamSplitEncoder { - fn compress(&self, page: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + _context: MiniBlockCompressionContext, + page: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match page { DataBlock::FixedWidth(data) => { let num_values = data.num_values; @@ -183,10 +186,9 @@ impl MiniBlockCompressor for ByteStreamSplitEncoder { encoding, )) } - _ => Err(lance_core::Error::InvalidInput { - source: "ByteStreamSplit encoding only supports FixedWidth data blocks".into(), - location: location!(), - }), + _ => Err(lance_core::Error::invalid_input_source( + "ByteStreamSplit encoding only supports FixedWidth data blocks".into(), + )), } } } @@ -226,28 +228,26 @@ impl MiniBlockDecompressor for ByteStreamSplitDecompressor { let total_bytes = num_values as usize * bytes_per_value; if data.len() != 1 { - return Err(lance_core::Error::InvalidInput { - source: format!( + return Err(lance_core::Error::invalid_input_source( + format!( "ByteStreamSplit decompression expects 1 buffer, but got {}", data.len() ) .into(), - location: location!(), - }); + )); } let input_buffer = &data[0]; if input_buffer.len() != total_bytes { - return Err(lance_core::Error::InvalidInput { - source: format!( + return Err(lance_core::Error::invalid_input_source( + format!( "Expected {} bytes for decompression, but got {}", total_bytes, input_buffer.len() ) .into(), - location: location!(), - }); + )); } let mut output = vec![0u8; total_bytes]; @@ -267,6 +267,10 @@ impl MiniBlockDecompressor for ByteStreamSplitDecompressor { block_info: BlockInfo::new(), })) } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + num_values.checked_mul(self.bytes_per_value() as u64) + } } /// Determine if BSS should be used based on mode and data characteristics @@ -349,7 +353,9 @@ mod tests { }); // Compress - let (compressed, _encoding) = encoder.compress(data_block).unwrap(); + let (compressed, _encoding) = encoder + .compress(MiniBlockCompressionContext::new(0, true, true), data_block) + .unwrap(); // Decompress let decompressed = decompressor @@ -362,8 +368,10 @@ mod tests { // Verify let result_bytes = decompressed_fixed.data.as_ref(); let result_values: Vec = result_bytes - .chunks_exact(4) - .map(|chunk| f32::from_le_bytes(chunk.try_into().unwrap())) + .as_chunks::<4>() + .0 + .iter() + .map(|chunk| f32::from_le_bytes(*chunk)) .collect(); assert_eq!(values, result_values); @@ -395,7 +403,9 @@ mod tests { }); // Compress - let (compressed, _encoding) = encoder.compress(data_block).unwrap(); + let (compressed, _encoding) = encoder + .compress(MiniBlockCompressionContext::new(0, true, true), data_block) + .unwrap(); // Decompress let decompressed = decompressor @@ -408,8 +418,10 @@ mod tests { // Verify let result_bytes = decompressed_fixed.data.as_ref(); let result_values: Vec = result_bytes - .chunks_exact(8) - .map(|chunk| f64::from_le_bytes(chunk.try_into().unwrap())) + .as_chunks::<8>() + .0 + .iter() + .map(|chunk| f64::from_le_bytes(*chunk)) .collect(); assert_eq!(values, result_values); @@ -428,7 +440,9 @@ mod tests { }); // Compress empty data - let (compressed, _encoding) = encoder.compress(data_block).unwrap(); + let (compressed, _encoding) = encoder + .compress(MiniBlockCompressionContext::new(0, true, true), data_block) + .unwrap(); // Decompress empty data let decompressed = decompressor.decompress(compressed.data, 0).unwrap(); diff --git a/rust/lance-encoding/src/encodings/physical/constant.rs b/rust/lance-encoding/src/encodings/physical/constant.rs index c3fa16863f4..dd153f789d5 100644 --- a/rust/lance-encoding/src/encodings/physical/constant.rs +++ b/rust/lance-encoding/src/encodings/physical/constant.rs @@ -5,7 +5,7 @@ use crate::{ buffer::LanceBuffer, - compression::{BlockDecompressor, FixedPerValueDecompressor}, + compression::{BlockDecompressor, FixedPerValueDecompressor, require_no_block_payload}, data::{AllNullDataBlock, ConstantDataBlock, DataBlock, FixedWidthDataBlock}, }; @@ -24,7 +24,8 @@ impl ConstantDecompressor { } impl BlockDecompressor for ConstantDecompressor { - fn decompress(&self, _data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + require_no_block_payload(data, "Constant")?; if let Some(scalar) = self.scalar.clone() { Ok(DataBlock::Constant(ConstantDataBlock { data: scalar, @@ -34,6 +35,10 @@ impl BlockDecompressor for ConstantDecompressor { Ok(DataBlock::AllNull(AllNullDataBlock { num_values })) } } + + fn requires_payload(&self) -> bool { + false + } } impl FixedPerValueDecompressor for ConstantDecompressor { @@ -55,3 +60,22 @@ impl FixedPerValueDecompressor for ConstantDecompressor { .unwrap_or(0) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn block_constant_requires_no_payload() { + let decompressor = ConstantDecompressor::new(None); + + assert!(!decompressor.requires_payload()); + assert!(matches!( + BlockDecompressor::decompress(&decompressor, None, 3).unwrap(), + DataBlock::AllNull(AllNullDataBlock { num_values: 3 }) + )); + assert!( + BlockDecompressor::decompress(&decompressor, Some(LanceBuffer::empty()), 3).is_err() + ); + } +} diff --git a/rust/lance-encoding/src/encodings/physical/fsst.rs b/rust/lance-encoding/src/encodings/physical/fsst.rs index 25dac9b7896..38acd24af0b 100644 --- a/rust/lance-encoding/src/encodings/physical/fsst.rs +++ b/rust/lance-encoding/src/encodings/physical/fsst.rs @@ -16,7 +16,6 @@ //! FSST encoding is transparent. use lance_core::{Error, Result}; -use snafu::location; use crate::{ buffer::LanceBuffer, @@ -24,16 +23,23 @@ use crate::{ data::{BlockInfo, DataBlock, VariableWidthBlock}, encodings::logical::primitive::{ fullzip::{PerValueCompressor, PerValueDataBlock}, - miniblock::{MiniBlockCompressed, MiniBlockCompressor}, + miniblock::{MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor}, }, format::{ - pb21::{self, CompressiveEncoding}, ProtobufUtils21, + pb21::{self, CompressiveEncoding}, }, }; use super::binary::BinaryMiniBlockEncoder; +pub(crate) fn map_fsst_error(err: std::io::Error) -> Error { + match err.kind() { + std::io::ErrorKind::InvalidData => Error::corrupt_file_named("fsst", err.to_string()), + _ => err.into(), + } +} + struct FsstCompressed { data: VariableWidthBlock, symbol_table: Vec, @@ -116,14 +122,13 @@ impl FsstCompressed { ), } } - _ => Err(Error::InvalidInput { - source: format!( + _ => Err(Error::invalid_input_source( + format!( "Cannot compress a data block of type {} with FsstEncoder", data.name() ) .into(), - location: location!(), - }), + )), } } } @@ -140,7 +145,11 @@ impl FsstMiniBlockEncoder { } impl MiniBlockCompressor for FsstMiniBlockEncoder { - fn compress(&self, data: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + context: MiniBlockCompressionContext, + data: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { let compressed = FsstCompressed::fsst_compress(data)?; let data_block = DataBlock::VariableWidth(compressed.data); @@ -150,7 +159,7 @@ impl MiniBlockCompressor for FsstMiniBlockEncoder { as Box; let (binary_miniblock_compressed, binary_array_encoding) = - binary_compressor.compress(data_block)?; + binary_compressor.compress(context, data_block)?; Ok(( binary_miniblock_compressed, @@ -234,7 +243,8 @@ impl VariablePerValueDecompressor for FsstPerValueDecompressor { offsets, &mut decompress_bytes_buf, &mut decompress_offset_buf, - )?; + ) + .map_err(map_fsst_error)?; // Ensure the offsets array is trimmed to exactly num_values + 1 elements decompress_offset_buf.truncate((num_values + 1) as usize); @@ -264,7 +274,8 @@ impl VariablePerValueDecompressor for FsstPerValueDecompressor { offsets, &mut decompress_bytes_buf, &mut decompress_offset_buf, - )?; + ) + .map_err(map_fsst_error)?; // Ensure the offsets array is trimmed to exactly num_values + 1 elements decompress_offset_buf.truncate((num_values + 1) as usize); @@ -329,7 +340,8 @@ impl MiniBlockDecompressor for FsstMiniBlockDecompressor { offsets, &mut decompress_bytes_buf, &mut decompress_offset_buf, - )?; + ) + .map_err(map_fsst_error)?; // Ensure the offsets array is trimmed to exactly num_values + 1 elements decompress_offset_buf.truncate((num_values + 1) as usize); @@ -352,7 +364,8 @@ impl MiniBlockDecompressor for FsstMiniBlockDecompressor { offsets, &mut decompress_bytes_buf, &mut decompress_offset_buf, - )?; + ) + .map_err(map_fsst_error)?; // Ensure the offsets array is trimmed to exactly num_values + 1 elements decompress_offset_buf.truncate((num_values + 1) as usize); @@ -377,18 +390,28 @@ impl MiniBlockDecompressor for FsstMiniBlockDecompressor { mod tests { use std::collections::HashMap; + use arrow_array::StringArray; + use fsst::fsst::{FSST_SYMBOL_TABLE_SIZE, compress, decompress}; + use lance_core::Error; use lance_datagen::{ByteCount, RowCount}; - use crate::{ - testing::{check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, - }; + use super::map_fsst_error; + use crate::testing::{TestCases, TestEncoding, check_round_trip_encoding_of_data}; + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_fsst() { + async fn test_fsst( + #[values(false, true)] explicit: bool, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { let test_cases = TestCases::default() .with_expected_encoding("fsst") - .with_min_file_version(LanceFileVersion::V2_1); + .with_encoding(encoding); // Generate data suitable for FSST (large strings, total size > 32KB) let arr = lance_datagen::gen_batch() @@ -398,14 +421,49 @@ mod tests { .column(0) .clone(); - // Test both explicit metadata and automatic selection - // 1. Test with explicit FSST metadata - let metadata_explicit = - HashMap::from([("lance-encoding:compression".to_string(), "fsst".to_string())]); - check_round_trip_encoding_of_data(vec![arr.clone()], &test_cases, metadata_explicit).await; + let metadata = if explicit { + HashMap::from([("lance-encoding:compression".to_string(), "fsst".to_string())]) + } else { + // Automatic selection requires max_len >= 5 and total_size >= 32KB. + HashMap::new() + }; + check_round_trip_encoding_of_data(vec![arr], &test_cases, metadata).await; + } - // 2. Test automatic FSST selection based on data characteristics - // FSST should be chosen automatically: max_len >= 5 and total_size >= 32KB - check_round_trip_encoding_of_data(vec![arr], &test_cases, HashMap::new()).await; + #[test] + fn test_corrupt_fsst_symbol_table_is_corrupt_file() { + let input = "the rain in spain stays mainly in the plain ".repeat(2048); + let array = StringArray::from(vec![input.as_str()]); + let mut symbol_table = [0u8; FSST_SYMBOL_TABLE_SIZE]; + let mut compressed = vec![0u8; array.value_data().len().max(1)]; + let mut compressed_offsets = vec![0i32; array.value_offsets().len()]; + compress( + symbol_table.as_mut(), + array.value_data(), + array.value_offsets(), + &mut compressed, + &mut compressed_offsets, + ) + .unwrap(); + + let st_info = u64::from_ne_bytes(symbol_table[..8].try_into().unwrap()); + assert!(st_info & (1 << 24) != 0, "expected decoder_switch_on input"); + let n_symbols = (st_info & 255) as usize; + assert!(n_symbols > 0); + symbol_table[8 + n_symbols * 8] = 9; + + let mut out = vec![0u8; compressed.len() * 8]; + let mut out_offsets = vec![0i32; compressed_offsets.len()]; + let err = decompress( + &symbol_table, + &compressed, + &compressed_offsets, + &mut out, + &mut out_offsets, + ) + .map_err(map_fsst_error) + .unwrap_err(); + assert!(matches!(err, Error::CorruptFile { .. }), "{err}"); + assert!(err.to_string().contains("symbol length"), "{err}"); } } diff --git a/rust/lance-encoding/src/encodings/physical/general.rs b/rust/lance-encoding/src/encodings/physical/general.rs index faa00fdb541..bea6a85cb01 100644 --- a/rust/lance-encoding/src/encodings/physical/general.rs +++ b/rust/lance-encoding/src/encodings/physical/general.rs @@ -4,15 +4,17 @@ use log::trace; use crate::{ + Result, buffer::LanceBuffer, compression::MiniBlockDecompressor, data::DataBlock, encodings::{ - logical::primitive::miniblock::{MiniBlockCompressed, MiniBlockCompressor}, + logical::primitive::miniblock::{ + MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor, + }, physical::block::{CompressionConfig, GeneralBufferCompressor}, }, - format::{pb21::CompressiveEncoding, ProtobufUtils21}, - Result, + format::{ProtobufUtils21, pb21::CompressiveEncoding}, }; /// A miniblock compressor that wraps another miniblock compressor and applies @@ -35,9 +37,13 @@ const MIN_BUFFER_SIZE_FOR_COMPRESSION: usize = 4 * 1024; use super::super::logical::primitive::miniblock::MiniBlockChunk; impl MiniBlockCompressor for GeneralMiniBlockCompressor { - fn compress(&self, page: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + context: MiniBlockCompressionContext, + page: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { // First, compress with the inner compressor - let (inner_compressed, inner_encoding) = self.inner.compress(page)?; + let (inner_compressed, inner_encoding) = self.inner.compress(context, page)?; // Return the original encoding without compression if there's no data or // the first buffer is not large enough @@ -132,6 +138,10 @@ impl MiniBlockDecompressor for GeneralMiniBlockDecompressor { self.inner.decompress(data, num_values) } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + self.inner.decoded_size_bytes(num_values) + } } #[cfg(test)] @@ -146,6 +156,10 @@ mod tests { use crate::format::pb21::compressive_encoding::Compression; use arrow_array::{Float64Array, Int32Array}; + fn miniblock_context() -> MiniBlockCompressionContext { + MiniBlockCompressionContext::new(0, true, true) + } + #[derive(Debug)] struct TestCase { name: &'static str, @@ -161,7 +175,7 @@ mod tests { // Small data with RLE - should not compress due to size threshold TestCase { name: "small_rle_data", - inner_encoder: Box::new(RleEncoder), + inner_encoder: Box::new(RleEncoder::new()), compression: CompressionConfig { scheme: CompressionScheme::Lz4, level: None, @@ -173,7 +187,7 @@ mod tests { // Large repeated data with RLE + LZ4 TestCase { name: "large_rle_lz4", - inner_encoder: Box::new(RleEncoder), + inner_encoder: Box::new(RleEncoder::new()), compression: CompressionConfig { scheme: CompressionScheme::Lz4, level: None, @@ -185,7 +199,7 @@ mod tests { // Large repeated data with RLE + Zstd TestCase { name: "large_rle_zstd", - inner_encoder: Box::new(RleEncoder), + inner_encoder: Box::new(RleEncoder::new()), compression: CompressionConfig { scheme: CompressionScheme::Zstd, level: Some(3), @@ -249,7 +263,9 @@ mod tests { GeneralMiniBlockCompressor::new(test_case.inner_encoder, test_case.compression); // Compress the data - let (compressed, encoding) = compressor.compress(test_case.data).unwrap(); + let (compressed, encoding) = compressor + .compress(miniblock_context(), test_case.data) + .unwrap(); // Check if compression was applied as expected match &encoding.compression { @@ -403,7 +419,7 @@ mod tests { // Test that small buffers don't get compressed let small_test = TestCase { name: "small_buffer_no_compression", - inner_encoder: Box::new(RleEncoder), + inner_encoder: Box::new(RleEncoder::new()), compression: CompressionConfig { scheme: CompressionScheme::Lz4, level: None, @@ -461,7 +477,7 @@ mod tests { let compressor = GeneralMiniBlockCompressor::new(inner, compression); // Compress the data - let (compressed, encoding) = compressor.compress(block).unwrap(); + let (compressed, encoding) = compressor.compress(miniblock_context(), block).unwrap(); // Should get GeneralMiniBlock encoding since buffer is 4KB match &encoding.compression { @@ -496,14 +512,14 @@ mod tests { // RLE produces 2 buffers (values and lengths), test that both are handled correctly let data = create_repeated_i32_block(vec![1; 100]); let compressor = GeneralMiniBlockCompressor::new( - Box::new(RleEncoder), + Box::new(RleEncoder::new()), CompressionConfig { scheme: CompressionScheme::Lz4, level: None, }, ); - let (compressed, _) = compressor.compress(data).unwrap(); + let (compressed, _) = compressor.compress(miniblock_context(), data).unwrap(); // RLE produces 2 buffers, but only the first one is compressed assert_eq!(compressed.data.len(), 2); } @@ -519,7 +535,7 @@ mod tests { // Test case 1: 32-bit RLE data let test_32 = TestCase { name: "rle_32bit_with_general_wrapper", - inner_encoder: Box::new(RleEncoder), + inner_encoder: Box::new(RleEncoder::new()), compression: CompressionConfig { scheme: CompressionScheme::Lz4, level: None, @@ -532,14 +548,16 @@ mod tests { // For 32-bit RLE, the compression strategy should automatically wrap it // Let's directly test the compressor let compressor = GeneralMiniBlockCompressor::new( - Box::new(RleEncoder), + Box::new(RleEncoder::new()), CompressionConfig { scheme: CompressionScheme::Lz4, level: None, }, ); - let (_compressed, encoding) = compressor.compress(test_32.data).unwrap(); + let (_compressed, encoding) = compressor + .compress(miniblock_context(), test_32.data) + .unwrap(); // Verify the encoding structure match &encoding.compression { @@ -589,14 +607,16 @@ mod tests { let block_64 = DataBlock::from_array(array_64); let compressor_64 = GeneralMiniBlockCompressor::new( - Box::new(RleEncoder), + Box::new(RleEncoder::new()), CompressionConfig { scheme: CompressionScheme::Lz4, level: None, }, ); - let (_compressed_64, encoding_64) = compressor_64.compress(block_64).unwrap(); + let (_compressed_64, encoding_64) = compressor_64 + .compress(miniblock_context(), block_64) + .unwrap(); // Verify the encoding structure for 64-bit match &encoding_64.compression { @@ -650,7 +670,7 @@ mod tests { }, ); - let result = compressor.compress(empty_block); + let result = compressor.compress(miniblock_context(), empty_block); match result { Ok((compressed, _)) => { assert_eq!(compressed.num_values, 0); diff --git a/rust/lance-encoding/src/encodings/physical/packed.rs b/rust/lance-encoding/src/encodings/physical/packed.rs index 31ce7acd1e4..3ae3dc4d6f8 100644 --- a/rust/lance-encoding/src/encodings/physical/packed.rs +++ b/rust/lance-encoding/src/encodings/physical/packed.rs @@ -12,14 +12,12 @@ use std::{convert::TryInto, sync::Arc}; use arrow_array::types::UInt64Type; - -use lance_core::{datatypes::Field, Error, Result}; -use snafu::location; +use lance_core::{Error, Result, datatypes::Field}; use crate::{ buffer::LanceBuffer, compression::{ - DefaultCompressionStrategy, FixedPerValueDecompressor, MiniBlockDecompressor, + CompressionStrategy, FixedPerValueDecompressor, MiniBlockDecompressor, VariablePerValueDecompressor, }, data::{ @@ -28,11 +26,11 @@ use crate::{ }, encodings::logical::primitive::{ fullzip::{PerValueCompressor, PerValueDataBlock}, - miniblock::{MiniBlockCompressed, MiniBlockCompressor}, + miniblock::{MiniBlockCompressed, MiniBlockCompressionContext, MiniBlockCompressor}, }, format::{ - pb21::{compressive_encoding::Compression, CompressiveEncoding, PackedStruct}, ProtobufUtils21, + pb21::{CompressiveEncoding, PackedStruct, compressive_encoding::Compression}, }, statistics::{GetStat, Stat}, }; @@ -74,7 +72,11 @@ fn struct_data_block_to_fixed_width_data_block( pub struct PackedStructFixedWidthMiniBlockEncoder {} impl MiniBlockCompressor for PackedStructFixedWidthMiniBlockEncoder { - fn compress(&self, data: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + context: MiniBlockCompressionContext, + data: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match data { DataBlock::Struct(struct_data_block) => { let bits_per_values = struct_data_block.children.iter().map(|data_block| data_block.as_fixed_width_ref().unwrap().bits_per_value).collect::>(); @@ -85,21 +87,18 @@ impl MiniBlockCompressor for PackedStructFixedWidthMiniBlockEncoder { // store and transformed fixed-width data block. let value_miniblock_compressor = Box::new(ValueEncoder::default()) as Box; let (value_miniblock_compressed, value_array_encoding) = - value_miniblock_compressor.compress(data_block)?; + value_miniblock_compressor.compress(context, data_block)?; Ok(( value_miniblock_compressed, ProtobufUtils21::packed_struct(value_array_encoding, bits_per_values), )) } - _ => Err(Error::InvalidInput { - source: format!( - "Cannot compress a data block of type {} with PackedStructFixedWidthBlockEncoder", - data.name() - ) - .into(), - location: location!(), - }), + _ => Err(Error::invalid_input_source(format!( + "Cannot compress a data block of type {} with PackedStructFixedWidthBlockEncoder", + data.name() + ) + .into())), } } } @@ -112,9 +111,18 @@ pub struct PackedStructFixedWidthMiniBlockDecompressor { impl PackedStructFixedWidthMiniBlockDecompressor { pub fn new(description: &PackedStruct) -> Self { - let array_encoding: Box = match description.values.as_ref().unwrap().compression.as_ref().unwrap() { + let array_encoding: Box = match description + .values + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap() + { Compression::Flat(flat) => Box::new(ValueDecompressor::from_flat(flat)), - _ => panic!("Currently only `ArrayEncoding::Flat` is supported in packed struct encoding in Lance 2.1."), + _ => panic!( + "Currently only `ArrayEncoding::Flat` is supported in packed struct encoding in Lance 2.1." + ), }; Self { bits_per_values: description.bits_per_value.clone(), @@ -177,11 +185,43 @@ impl MiniBlockDecompressor for PackedStructFixedWidthMiniBlockDecompressor { } } +#[derive(Debug)] +struct FixedPackedFieldData { + block: FixedWidthDataBlock, +} + +impl FixedPackedFieldData { + fn append_row_bytes(&self, row_idx: usize, output: &mut Vec) -> Result<()> { + let bits_per_value = self.block.bits_per_value; + if !bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input( + "Packed struct encoding requires byte-aligned fixed-width children", + )); + } + let bytes_per_value = (bits_per_value / 8) as usize; + let start = row_idx + .checked_mul(bytes_per_value) + .ok_or_else(|| Error::invalid_input("Packed struct row size overflow"))?; + let end = start.checked_add(bytes_per_value).ok_or_else(|| { + Error::invalid_input(format!( + "Packed struct fixed child range overflow: row_idx={row_idx}, \ + bytes_per_value={bytes_per_value}" + )) + })?; + let data = self.block.data.as_ref(); + if end > data.len() { + return Err(Error::invalid_input( + "Packed struct fixed child out of bounds", + )); + } + output.extend_from_slice(&data[start..end]); + Ok(()) + } +} + #[derive(Debug)] enum VariablePackedFieldData { - Fixed { - block: FixedWidthDataBlock, - }, + Fixed(FixedPackedFieldData), Variable { block: VariableWidthBlock, bits_per_length: u64, @@ -191,44 +231,20 @@ enum VariablePackedFieldData { impl VariablePackedFieldData { fn append_row_bytes(&self, row_idx: usize, output: &mut Vec) -> Result<()> { match self { - Self::Fixed { block } => { - let bits_per_value = block.bits_per_value; - if bits_per_value % 8 != 0 { - return Err(Error::invalid_input( - "Packed struct variable encoding requires byte-aligned fixed-width children", - location!(), - )); - } - let bytes_per_value = (bits_per_value / 8) as usize; - let start = row_idx.checked_mul(bytes_per_value).ok_or_else(|| { - Error::invalid_input("Packed struct row size overflow", location!()) - })?; - let end = start + bytes_per_value; - let data = block.data.as_ref(); - if end > data.len() { - return Err(Error::invalid_input( - "Packed struct fixed child out of bounds", - location!(), - )); - } - output.extend_from_slice(&data[start..end]); - Ok(()) - } + Self::Fixed(fixed_data) => fixed_data.append_row_bytes(row_idx, output), Self::Variable { block, bits_per_length, } => { - if bits_per_length % 8 != 0 { + if !bits_per_length.is_multiple_of(8) { return Err(Error::invalid_input( "Packed struct variable children must have byte-aligned length prefixes", - location!(), )); } let prefix_bytes = (*bits_per_length / 8) as usize; if !(prefix_bytes == 4 || prefix_bytes == 8) { return Err(Error::invalid_input( "Packed struct variable children must use 32 or 64-bit length prefixes", - location!(), )); } match block.bits_per_offset { @@ -239,14 +255,12 @@ impl VariablePackedFieldData { if end > block.data.len() { return Err(Error::invalid_input( "Packed struct variable child offsets out of bounds", - location!(), )); } let len = (end - start) as u32; if prefix_bytes != std::mem::size_of::() { return Err(Error::invalid_input( "Packed struct variable child length prefix mismatch", - location!(), )); } output.extend_from_slice(&len.to_le_bytes()); @@ -260,14 +274,12 @@ impl VariablePackedFieldData { if end > block.data.len() { return Err(Error::invalid_input( "Packed struct variable child offsets out of bounds", - location!(), )); } let len = (end - start) as u64; if prefix_bytes != std::mem::size_of::() { return Err(Error::invalid_input( "Packed struct variable child length prefix mismatch", - location!(), )); } output.extend_from_slice(&len.to_le_bytes()); @@ -276,7 +288,6 @@ impl VariablePackedFieldData { } _ => Err(Error::invalid_input( "Packed struct variable child must use 32 or 64-bit offsets", - location!(), )), } } @@ -284,59 +295,56 @@ impl VariablePackedFieldData { } } +fn check_struct_validity(data: DataBlock, field_length: usize) -> Result { + let DataBlock::Struct(struct_block) = data else { + return Err(Error::invalid_input( + "Packed struct encoder requires Struct data block", + )); + }; + + if struct_block.children.is_empty() { + return Err(Error::invalid_input( + "Packed struct encoder requires at least one child field", + )); + } + if struct_block.children.len() != field_length { + return Err(Error::invalid_input( + "Struct field metadata does not match number of children", + )); + } + + let num_values = struct_block.children[0].num_values(); + for child in struct_block.children.iter() { + if child.num_values() != num_values { + return Err(Error::invalid_input( + "Packed struct children must have matching value counts", + )); + } + } + Ok(struct_block) +} + #[derive(Debug)] pub struct PackedStructVariablePerValueEncoder { - strategy: DefaultCompressionStrategy, + strategy: Arc, fields: Vec, } impl PackedStructVariablePerValueEncoder { - pub fn new(strategy: DefaultCompressionStrategy, fields: Vec) -> Self { + pub fn new(strategy: Arc, fields: Vec) -> Self { Self { strategy, fields } } } impl PerValueCompressor for PackedStructVariablePerValueEncoder { fn compress(&self, data: DataBlock) -> Result<(PerValueDataBlock, CompressiveEncoding)> { - let DataBlock::Struct(struct_block) = data else { - return Err(Error::invalid_input( - "Packed struct encoder requires Struct data block", - location!(), - )); - }; - - if struct_block.children.is_empty() { - return Err(Error::invalid_input( - "Packed struct encoder requires at least one child field", - location!(), - )); - } - if struct_block.children.len() != self.fields.len() { - return Err(Error::invalid_input( - "Struct field metadata does not match number of children", - location!(), - )); - } - + let struct_block = check_struct_validity(data, self.fields.len())?; let num_values = struct_block.children[0].num_values(); - for child in struct_block.children.iter() { - if child.num_values() != num_values { - return Err(Error::invalid_input( - "Packed struct children must have matching value counts", - location!(), - )); - } - } - let mut field_data = Vec::with_capacity(self.fields.len()); let mut field_metadata = Vec::with_capacity(self.fields.len()); - for (field, child_block) in self.fields.iter().zip(struct_block.children.into_iter()) { - let compressor = crate::compression::CompressionStrategy::create_per_value( - &self.strategy, - field, - &child_block, - )?; + for (field, child_block) in self.fields.iter().zip(struct_block.children) { + let compressor = self.strategy.create_per_value(field, &child_block)?; let (compressed, encoding) = compressor.compress(child_block)?; match compressed { PerValueDataBlock::Fixed(block) => { @@ -344,7 +352,8 @@ impl PerValueCompressor for PackedStructVariablePerValueEncoder { encoding, block.bits_per_value, )); - field_data.push(VariablePackedFieldData::Fixed { block }); + let block = FixedPackedFieldData { block }; + field_data.push(VariablePackedFieldData::Fixed(block)); } PerValueDataBlock::Variable(block) => { let bits_per_length = block.bits_per_offset as u64; @@ -373,9 +382,9 @@ impl PerValueCompressor for PackedStructVariablePerValueEncoder { let end = row_data.len(); let row_len = end - start; max_row_len = max_row_len.max(row_len); - total_bytes = total_bytes.checked_add(row_len).ok_or_else(|| { - Error::invalid_input("Packed struct row data size overflow", location!()) - })?; + total_bytes = total_bytes + .checked_add(row_len) + .ok_or_else(|| Error::invalid_input("Packed struct row data size overflow"))?; row_offsets.push(end as u64); } debug_assert_eq!(total_bytes, row_data.len()); @@ -407,6 +416,83 @@ impl PerValueCompressor for PackedStructVariablePerValueEncoder { } } +#[derive(Debug)] +pub(crate) struct PackedStructFixedPerValueEncoder { + field_len: usize, +} + +impl PackedStructFixedPerValueEncoder { + pub(crate) fn new(fields: Vec) -> Self { + Self { + field_len: fields.len(), + } + } +} + +impl PerValueCompressor for PackedStructFixedPerValueEncoder { + fn compress(&self, data: DataBlock) -> Result<(PerValueDataBlock, CompressiveEncoding)> { + let struct_block = check_struct_validity(data, self.field_len)?; + + if struct_block.has_variable_width_child() { + return Err(Error::invalid_input( + "Packed struct fixed encoding requires all children to be fixed-width", + )); + } + + let num_values = struct_block.children[0].num_values(); + // Fixed length - supporting only flat encoding + let compressor = Box::new(ValueEncoder::default()) as Box; + let mut field_data = Vec::with_capacity(self.field_len); + let mut field_bits_per_value = Vec::with_capacity(self.field_len); + let mut bits_per_row: u64 = 0; + + for child_block in struct_block.children.into_iter() { + let (compressed, ..) = compressor.compress(child_block)?; + match compressed { + PerValueDataBlock::Fixed(block) => { + bits_per_row = bits_per_row + .checked_add(block.bits_per_value) + .ok_or_else(|| Error::invalid_input("Packed struct row width overflow"))?; + field_bits_per_value.push(block.bits_per_value); + field_data.push(FixedPackedFieldData { block }); + } + _ => { + return Err(Error::invalid_input( + "Packed struct fixed encoding requires all children to be fixed-width", + )); + } + } + } + + // Children are validated byte-aligned in `append_row_bytes`, so the row width + // is an exact number of bytes. + let bytes_per_row = (bits_per_row / 8) as usize; + let mut row_data: Vec = + Vec::with_capacity(bytes_per_row.saturating_mul(num_values as usize)); + for row in 0..num_values as usize { + for field in &field_data { + field.append_row_bytes(row, &mut row_data)?; + } + debug_assert_eq!(row_data.len(), bytes_per_row * (row + 1)); + } + + let data_block = FixedWidthDataBlock { + data: LanceBuffer::from(row_data), + bits_per_value: bits_per_row, + num_values, + block_info: BlockInfo::new(), + }; + + Ok(( + PerValueDataBlock::Fixed(data_block), + ProtobufUtils21::packed_struct( + ProtobufUtils21::flat(bits_per_row, None), + field_bits_per_value, + ), + )) + } +} + #[derive(Debug)] pub(crate) enum VariablePackedStructFieldKind { Fixed { @@ -435,19 +521,88 @@ impl PackedStructVariablePerValueDecompressor { } } +#[derive(Debug)] +struct FixedFieldAccumulator { + builder: DataBlockBuilder, + bits_per_value: u64, + empty_value: DataBlock, +} + +impl FixedFieldAccumulator { + fn append_empty(&mut self) -> Result<()> { + self.builder.append(&self.empty_value, 0..1) + } + + fn new(bits_per_value: u64, num_values: u64) -> Result { + if !bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input( + "Packed struct fixed child must be byte-aligned", + )); + } + + let bytes_per_value = bits_per_value.checked_div(8).ok_or_else(|| { + Error::invalid_input("Invalid bits per value for packed struct field") + })?; + + let estimate = bytes_per_value + .checked_mul(num_values) + .ok_or_else(|| Error::invalid_input("Packed struct fixed child allocation overflow"))?; + + let empty_value = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::from(vec![0_u8; bytes_per_value as usize]), + bits_per_value, + num_values: 1, + block_info: BlockInfo::new(), + }); + + Ok(Self { + builder: DataBlockBuilder::with_capacity_estimate(estimate), + bits_per_value, + empty_value, + }) + } + + fn finish(self) -> Result { + let DataBlock::FixedWidth(block) = self.builder.finish() else { + return Err(Error::invalid_input( + "Expected fixed-width datablock from builder", + )); + }; + Ok(block) + } +} + enum FieldAccumulator { - Fixed { - builder: DataBlockBuilder, - bits_per_value: u64, - }, + Fixed(FixedFieldAccumulator), Variable32 { builder: DataBlockBuilder, + empty_value: DataBlock, }, Variable64 { builder: DataBlockBuilder, + empty_value: DataBlock, }, } +impl FieldAccumulator { + // In full-zip variable packed decoding, rep/def may produce a visible row + // with an empty payload (e.g. null/invalid item). We still need to append + // one placeholder per child so child row counts remain aligned. + fn append_empty(&mut self) -> Result<()> { + match self { + Self::Fixed(fixed_field_accumulator) => fixed_field_accumulator.append_empty(), + Self::Variable32 { + builder, + empty_value, + } => builder.append(empty_value, 0..1), + Self::Variable64 { + builder, + empty_value, + } => builder.append(empty_value, 0..1), + } + } +} + impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { fn decompress(&self, data: VariableWidthBlock) -> Result { let num_values = data.num_values; @@ -466,15 +621,13 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { _ => { return Err(Error::invalid_input( "Packed struct row offsets must be 32 or 64 bits", - location!(), - )) + )); } }; if offsets_u64.len() != num_values as usize + 1 { return Err(Error::invalid_input( "Packed struct row offsets length mismatch", - location!(), )); } @@ -482,43 +635,36 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { for field in &self.fields { match &field.kind { VariablePackedStructFieldKind::Fixed { bits_per_value, .. } => { - if bits_per_value % 8 != 0 { - return Err(Error::invalid_input( - "Packed struct fixed child must be byte-aligned", - location!(), - )); - } - let bytes_per_value = bits_per_value.checked_div(8).ok_or_else(|| { - Error::invalid_input( - "Invalid bits per value for packed struct field", - location!(), - ) - })?; - let estimate = bytes_per_value.checked_mul(num_values).ok_or_else(|| { - Error::invalid_input( - "Packed struct fixed child allocation overflow", - location!(), - ) - })?; - accumulators.push(FieldAccumulator::Fixed { - builder: DataBlockBuilder::with_capacity_estimate(estimate), - bits_per_value: *bits_per_value, - }); + let accumulator = FixedFieldAccumulator::new(*bits_per_value, num_values)?; + accumulators.push(FieldAccumulator::Fixed(accumulator)); } VariablePackedStructFieldKind::Variable { bits_per_length, .. } => match bits_per_length { 32 => accumulators.push(FieldAccumulator::Variable32 { builder: DataBlockBuilder::with_capacity_estimate(data.data.len() as u64), + empty_value: DataBlock::VariableWidth(VariableWidthBlock { + data: LanceBuffer::empty(), + bits_per_offset: 32, + offsets: LanceBuffer::reinterpret_vec(vec![0_u32, 0_u32]), + num_values: 1, + block_info: BlockInfo::new(), + }), }), 64 => accumulators.push(FieldAccumulator::Variable64 { builder: DataBlockBuilder::with_capacity_estimate(data.data.len() as u64), + empty_value: DataBlock::VariableWidth(VariableWidthBlock { + data: LanceBuffer::empty(), + bits_per_offset: 64, + offsets: LanceBuffer::reinterpret_vec(vec![0_u64, 0_u64]), + num_values: 1, + block_info: BlockInfo::new(), + }), }), _ => { return Err(Error::invalid_input( "Packed struct variable child must use 32 or 64-bit length prefixes", - location!(), - )) + )); } }, } @@ -530,26 +676,27 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { if row_end > data.data.len() || row_start > row_end { return Err(Error::invalid_input( "Packed struct row bounds exceed buffer", - location!(), )); } + if row_start == row_end { + for accumulator in accumulators.iter_mut() { + accumulator.append_empty()?; + } + continue; + } let mut cursor = row_start; for (field, accumulator) in self.fields.iter().zip(accumulators.iter_mut()) { match (&field.kind, accumulator) { ( VariablePackedStructFieldKind::Fixed { bits_per_value, .. }, - FieldAccumulator::Fixed { - builder, - bits_per_value: acc_bits, - }, + FieldAccumulator::Fixed(fixed_accumulator), ) => { - debug_assert_eq!(bits_per_value, acc_bits); - let bytes_per_value = (bits_per_value / 8) as usize; + debug_assert_eq!(*bits_per_value, fixed_accumulator.bits_per_value); + let bytes_per_value = (*bits_per_value / 8) as usize; let end = cursor + bytes_per_value; if end > row_end { return Err(Error::invalid_input( "Packed struct fixed child exceeds row bounds", - location!(), )); } let value_block = DataBlock::FixedWidth(FixedWidthDataBlock { @@ -558,26 +705,24 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { num_values: 1, block_info: BlockInfo::new(), }); - builder.append(&value_block, 0..1); + fixed_accumulator.builder.append(&value_block, 0..1)?; cursor = end; } ( VariablePackedStructFieldKind::Variable { bits_per_length, .. }, - FieldAccumulator::Variable32 { builder }, + FieldAccumulator::Variable32 { builder, .. }, ) => { if *bits_per_length != 32 { return Err(Error::invalid_input( "Packed struct length prefix size mismatch", - location!(), )); } let end = cursor + std::mem::size_of::(); if end > row_end { return Err(Error::invalid_input( "Packed struct variable child length prefix out of bounds", - location!(), )); } let len = u32::from_le_bytes( @@ -590,7 +735,6 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { if value_end > row_end { return Err(Error::invalid_input( "Packed struct variable child exceeds row bounds", - location!(), )); } let value_block = DataBlock::VariableWidth(VariableWidthBlock { @@ -600,26 +744,24 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { num_values: 1, block_info: BlockInfo::new(), }); - builder.append(&value_block, 0..1); + builder.append(&value_block, 0..1)?; cursor = value_end; } ( VariablePackedStructFieldKind::Variable { bits_per_length, .. }, - FieldAccumulator::Variable64 { builder }, + FieldAccumulator::Variable64 { builder, .. }, ) => { if *bits_per_length != 64 { return Err(Error::invalid_input( "Packed struct length prefix size mismatch", - location!(), )); } let end = cursor + std::mem::size_of::(); if end > row_end { return Err(Error::invalid_input( "Packed struct variable child length prefix out of bounds", - location!(), )); } let len = u64::from_le_bytes( @@ -632,7 +774,6 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { if value_end > row_end { return Err(Error::invalid_input( "Packed struct variable child exceeds row bounds", - location!(), )); } let value_block = DataBlock::VariableWidth(VariableWidthBlock { @@ -642,38 +783,34 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { num_values: 1, block_info: BlockInfo::new(), }); - builder.append(&value_block, 0..1); + builder.append(&value_block, 0..1)?; cursor = value_end; } _ => { return Err(Error::invalid_input( "Packed struct accumulator kind mismatch", - location!(), - )) + )); } } } if cursor != row_end { return Err(Error::invalid_input( "Packed struct row parsing did not consume full row", - location!(), )); } } let mut children = Vec::with_capacity(self.fields.len()); - for (field, accumulator) in self.fields.iter().zip(accumulators.into_iter()) { + for (field, accumulator) in self.fields.iter().zip(accumulators) { match (field, accumulator) { ( VariablePackedStructFieldDecoder { kind: VariablePackedStructFieldKind::Fixed { decompressor, .. }, }, - FieldAccumulator::Fixed { builder, .. }, + FieldAccumulator::Fixed(fixed_accumulator), ) => { - let DataBlock::FixedWidth(block) = builder.finish() else { - panic!("Expected fixed-width datablock from builder"); - }; - let decoded = decompressor.decompress(block, num_values)?; + let finished_accumulator = fixed_accumulator.finish()?; + let decoded = decompressor.decompress(finished_accumulator, num_values)?; children.push(decoded); } ( @@ -684,7 +821,7 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { decompressor, }, }, - FieldAccumulator::Variable32 { builder }, + FieldAccumulator::Variable32 { builder, .. }, ) => { let DataBlock::VariableWidth(mut block) = builder.finish() else { panic!("Expected variable-width datablock from builder"); @@ -702,7 +839,7 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { decompressor, }, }, - FieldAccumulator::Variable64 { builder }, + FieldAccumulator::Variable64 { builder, .. }, ) => { let DataBlock::VariableWidth(mut block) = builder.finish() else { panic!("Expected variable-width datablock from builder"); @@ -715,10 +852,117 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { _ => { return Err(Error::invalid_input( "Packed struct accumulator mismatch during finalize", - location!(), - )) + )); + } + } + } + + Ok(DataBlock::Struct(StructDataBlock { + children, + block_info: BlockInfo::new(), + validity: None, + })) + } +} + +#[derive(Debug)] +struct PackedStructFixedFieldDecoder { + bits_per_value: u64, + decompressor: Box, +} + +#[derive(Debug)] +pub(crate) struct PackedStructFixedPerValueDecompressor { + decoders: Vec, +} + +impl PackedStructFixedPerValueDecompressor { + pub(crate) fn new(description: &PackedStruct) -> Result { + let compression = description + .values + .as_ref() + .ok_or_else(|| Error::invalid_input("PackedStruct missing values encoding"))? + .compression + .as_ref() + .ok_or_else(|| { + Error::invalid_input("PackedStruct values missing compression encoding") + })?; + + // The encoder always flat-encodes each child, so that is the only layout we can decode. + if !matches!(compression, Compression::Flat(..)) { + return Err(Error::invalid_input( + "PackedStruct fixed encoding currently requires flat compression", + )); + } + + let decoders = description + .bits_per_value + .iter() + .map(|&bits_per_value| { + let flat = crate::format::pb21::Flat { + bits_per_value, + data: None, + }; + PackedStructFixedFieldDecoder { + bits_per_value, + decompressor: Box::new(ValueDecompressor::from_flat(&flat)), } + }) + .collect(); + Ok(Self { decoders }) + } +} + +impl FixedPerValueDecompressor for PackedStructFixedPerValueDecompressor { + fn decompress(&self, data: FixedWidthDataBlock, num_values: u64) -> Result { + if !data.bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input( + "Packed struct fixed encoding requires byte-aligned children", + )); + } + let bytes_per_row = (data.bits_per_value / 8) as usize; + + // Byte offset of each child within a packed row (a running prefix sum of the + // child widths). The final offset must equal the packed row width. + let mut child_bytes = Vec::with_capacity(self.decoders.len()); + for decoder in &self.decoders { + if !decoder.bits_per_value.is_multiple_of(8) { + return Err(Error::invalid_input( + "Packed struct fixed child must be byte-aligned", + )); } + child_bytes.push((decoder.bits_per_value / 8) as usize); + } + if child_bytes.iter().sum::() != bytes_per_row { + return Err(Error::invalid_input( + "Packed struct child widths do not sum to the packed row width", + )); + } + if bytes_per_row.saturating_mul(num_values as usize) > data.data.len() { + return Err(Error::invalid_input( + "Packed struct row bounds exceed buffer", + )); + } + + // Un-zip the row-major buffer one child at a time by gathering that child's + // slice out of every row, then hand the column to the child decompressor. + let bytes = data.data.as_ref(); + let mut children = Vec::with_capacity(self.decoders.len()); + let mut field_offset = 0; + for (decoder, &field_bytes) in self.decoders.iter().zip(child_bytes.iter()) { + let mut child_buf = Vec::with_capacity(field_bytes * num_values as usize); + for row_idx in 0..num_values as usize { + let start = row_idx * bytes_per_row + field_offset; + child_buf.extend_from_slice(&bytes[start..start + field_bytes]); + } + let child_block = FixedWidthDataBlock { + data: LanceBuffer::from(child_buf), + bits_per_value: decoder.bits_per_value, + num_values, + block_info: BlockInfo::new(), + }; + children.push(decoder.decompressor.decompress(child_block, num_values)?); + field_offset += field_bytes; } Ok(DataBlock::Struct(StructDataBlock { @@ -727,6 +971,13 @@ impl VariablePerValueDecompressor for PackedStructVariablePerValueDecompressor { validity: None, })) } + + fn bits_per_value(&self) -> u64 { + self.decoders + .iter() + .map(|decoder| decoder.bits_per_value) + .sum() + } } #[cfg(test)] @@ -734,11 +985,15 @@ mod tests { use super::*; use crate::{ compression::CompressionStrategy, - compression::{DefaultCompressionStrategy, DefaultDecompressionStrategy}, - constants::PACKED_STRUCT_META_KEY, + compression::DefaultDecompressionStrategy, + compression_config::CompressionParams, + constants::{ + PACKED_STRUCT_META_KEY, STRUCTURAL_ENCODING_FULLZIP, STRUCTURAL_ENCODING_META_KEY, + }, statistics::ComputeStat, - testing::{check_round_trip_encoding_of_data, TestCases}, - version::LanceFileVersion, + testing::{ + TestCases, TestEncoding, check_round_trip_encoding_of_data, test_compression_strategy, + }, }; use arrow_array::{ Array, ArrayRef, BinaryArray, Int32Array, Int64Array, LargeStringArray, StringArray, @@ -838,9 +1093,9 @@ mod tests { let data_block = DataBlock::Struct(struct_block); let compression_strategy = - DefaultCompressionStrategy::new().with_version(LanceFileVersion::V2_2); + test_compression_strategy(TestEncoding::StructuralU32, CompressionParams::default()); let compressor = crate::compression::CompressionStrategy::create_per_value( - &compression_strategy, + compression_strategy.as_ref(), &struct_field, &data_block, )?; @@ -906,9 +1161,9 @@ mod tests { let data_block = DataBlock::Struct(struct_block); let compression_strategy = - DefaultCompressionStrategy::new().with_version(LanceFileVersion::V2_2); + test_compression_strategy(TestEncoding::StructuralU32, CompressionParams::default()); let compressor = crate::compression::CompressionStrategy::create_per_value( - &compression_strategy, + compression_strategy.as_ref(), &struct_field, &data_block, )?; @@ -988,7 +1243,7 @@ mod tests { ])); let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_2) + .with_u32_structural_encodings() .with_expected_encoding("variable_packed_struct"); check_round_trip_encoding_of_data(vec![array], &test_cases, meta).await; @@ -1027,9 +1282,9 @@ mod tests { let data_block = DataBlock::Struct(struct_block); let compression_strategy = - DefaultCompressionStrategy::new().with_version(LanceFileVersion::V2_2); + test_compression_strategy(TestEncoding::StructuralU32, CompressionParams::default()); let compressor = crate::compression::CompressionStrategy::create_per_value( - &compression_strategy, + compression_strategy.as_ref(), &struct_field, &data_block, )?; @@ -1110,10 +1365,207 @@ mod tests { }; let compression_strategy = - DefaultCompressionStrategy::new().with_version(LanceFileVersion::V2_1); + test_compression_strategy(TestEncoding::StructuralU16, CompressionParams::default()); let result = compression_strategy.create_per_value(&struct_field, &DataBlock::Struct(struct_block)); assert!(matches!(result, Err(Error::NotSupported { .. }))); } + + #[test] + fn variable_packed_struct_decompress_empty_row() -> Result<()> { + let strategy = DefaultDecompressionStrategy::default(); + let fixed_decompressor = Arc::from( + crate::compression::DecompressionStrategy::create_fixed_per_value_decompressor( + &strategy, + &ProtobufUtils21::flat(32, None), + )?, + ); + let variable_decompressor = Arc::from( + crate::compression::DecompressionStrategy::create_variable_per_value_decompressor( + &strategy, + &ProtobufUtils21::variable(ProtobufUtils21::flat(32, None), None), + )?, + ); + + let decompressor = PackedStructVariablePerValueDecompressor::new(vec![ + VariablePackedStructFieldDecoder { + kind: VariablePackedStructFieldKind::Fixed { + bits_per_value: 32, + decompressor: fixed_decompressor, + }, + }, + VariablePackedStructFieldDecoder { + kind: VariablePackedStructFieldKind::Variable { + bits_per_length: 32, + decompressor: variable_decompressor, + }, + }, + ]); + + let mut row_data = Vec::new(); + row_data.extend_from_slice(&1_u32.to_le_bytes()); + row_data.extend_from_slice(&1_u32.to_le_bytes()); + row_data.extend_from_slice(b"a"); + row_data.extend_from_slice(&2_u32.to_le_bytes()); + row_data.extend_from_slice(&0_u32.to_le_bytes()); + + let input = VariableWidthBlock { + data: LanceBuffer::from(row_data), + bits_per_offset: 32, + offsets: LanceBuffer::reinterpret_vec(vec![0_u32, 9_u32, 9_u32, 17_u32]), + num_values: 3, + block_info: BlockInfo::new(), + }; + + let decoded = decompressor.decompress(input)?; + let DataBlock::Struct(decoded_struct) = decoded else { + panic!("expected struct output"); + }; + + let fixed = decoded_struct.children[0].as_fixed_width_ref().unwrap(); + assert_eq!(fixed.bits_per_value, 32); + assert_eq!( + fixed.data.borrow_to_typed_slice::().as_ref(), + &[1, 0, 2] + ); + + let variable = decoded_struct.children[1].as_variable_width_ref().unwrap(); + assert_eq!(variable.bits_per_offset, 32); + assert_eq!( + variable.offsets.borrow_to_typed_slice::().as_ref(), + &[0_u32, 1_u32, 1_u32, 1_u32] + ); + assert_eq!(variable.data.as_ref(), b"a"); + + Ok(()) + } + + #[test] + fn fixed_packed_struct_round_trip() -> Result<()> { + let arrow_fields: Fields = vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("value", DataType::Int64, false), + ] + .into(); + let arrow_struct = ArrowField::new("item", DataType::Struct(arrow_fields), false); + let struct_field = Field::try_from(&arrow_struct)?; + + let id_block = fixed_i32_block_from_array(Int32Array::from(vec![1, 2, 3, 4])); + let value_block = fixed_block_from_array(Int64Array::from(vec![10, 20, 30, 40])); + + let struct_block = StructDataBlock { + children: vec![ + DataBlock::FixedWidth(id_block.clone()), + DataBlock::FixedWidth(value_block.clone()), + ], + block_info: BlockInfo::new(), + validity: None, + }; + + let data_block = DataBlock::Struct(struct_block); + + let compression_strategy = + test_compression_strategy(TestEncoding::StructuralU32, CompressionParams::default()); + let compressor = CompressionStrategy::create_per_value( + compression_strategy.as_ref(), + &struct_field, + &data_block, + )?; + let (compressed, encoding) = compressor.compress(data_block)?; + + let PerValueDataBlock::Fixed(zipped) = compressed else { + panic!("expected fixed-width packed struct output"); + }; + + let decompression_strategy = DefaultDecompressionStrategy::default(); + let decompressor = + crate::compression::DecompressionStrategy::create_fixed_per_value_decompressor( + &decompression_strategy, + &encoding, + )?; + let decoded = decompressor.decompress(zipped, 4)?; + + let DataBlock::Struct(decoded_struct) = decoded else { + panic!("expected struct datablock after decode"); + }; + + let decoded_id = decoded_struct.children[0].as_fixed_width_ref().unwrap(); + assert_eq!(decoded_id.bits_per_value, 32); + assert_eq!(decoded_id.data.as_ref(), id_block.data.as_ref()); + + let decoded_value = decoded_struct.children[1].as_fixed_width_ref().unwrap(); + assert_eq!(decoded_value.bits_per_value, 64); + assert_eq!(decoded_value.data.as_ref(), value_block.data.as_ref()); + + Ok(()) + } + + // End-to-end round trip through the file writer. Requesting full-zip on an + // all-fixed-width struct routes to `PackedStructFixedPerValueEncoder` (mini-block + // is only chosen for narrow structs), exercising the writer/reader wiring rather + // than just the block-level compress/decompress above. + #[tokio::test] + async fn fixed_packed_struct_full_zip_round_trip() { + let fields = Fields::from(vec![ + Arc::new(ArrowField::new("id", DataType::Int32, false)), + Arc::new(ArrowField::new("value", DataType::Int64, false)), + ]); + + let mut meta = HashMap::new(); + meta.insert(PACKED_STRUCT_META_KEY.to_string(), "true".to_string()); + meta.insert( + STRUCTURAL_ENCODING_META_KEY.to_string(), + STRUCTURAL_ENCODING_FULLZIP.to_string(), + ); + + let array = Arc::new(StructArray::from(vec![ + ( + fields[0].clone(), + Arc::new(Int32Array::from(vec![1, 2, 3, 4])) as ArrayRef, + ), + ( + fields[1].clone(), + Arc::new(Int64Array::from(vec![10, 20, 30, 40])) as ArrayRef, + ), + ])); + + let test_cases = TestCases::default() + .with_u32_structural_encodings() + .with_expected_encoding("packed_struct"); + + check_round_trip_encoding_of_data(vec![array], &test_cases, meta).await; + } + + #[test] + fn fixed_packed_struct_rejects_variable_child() -> Result<()> { + let arrow_fields: Fields = vec![ + ArrowField::new("id", DataType::Int32, false), + ArrowField::new("name", DataType::Utf8, false), + ] + .into(); + let arrow_struct = ArrowField::new("item", DataType::Struct(arrow_fields), false); + let struct_field = Field::try_from(&arrow_struct)?; + + let struct_block = DataBlock::Struct(StructDataBlock { + children: vec![ + DataBlock::FixedWidth(fixed_i32_block_from_array(Int32Array::from(vec![1, 2]))), + DataBlock::VariableWidth(variable_block_from_string_array(StringArray::from( + vec!["a", "bb"], + ))), + ], + block_info: BlockInfo::new(), + validity: None, + }); + + let encoder = PackedStructFixedPerValueEncoder::new(struct_field.children); + let err = encoder.compress(struct_block).unwrap_err(); + assert!(matches!(&err, Error::InvalidInput { .. })); + assert!( + err.to_string().contains("fixed-width"), + "unexpected error: {err}" + ); + + Ok(()) + } } diff --git a/rust/lance-encoding/src/encodings/physical/rle.rs b/rust/lance-encoding/src/encodings/physical/rle.rs index 06580c93ece..b4f17b44cb8 100644 --- a/rust/lance-encoding/src/encodings/physical/rle.rs +++ b/rust/lance-encoding/src/encodings/physical/rle.rs @@ -10,7 +10,7 @@ //! RLE uses a dual-buffer format to store compressed data: //! //! - **Values Buffer**: Stores unique values in their original data type -//! - **Lengths Buffer**: Stores the repeat count for each value as u8 +//! - **Lengths Buffer**: Stores the repeat count for each value as u8, u16, or u32 //! //! ### Example //! @@ -18,13 +18,13 @@ //! //! Encoded as: //! - Values buffer: `[1, 2, 3]` (3 × 4 bytes for i32) -//! - Lengths buffer: `[3, 2, 4]` (3 × 1 byte for u8) +//! - Lengths buffer: `[3, 2, 4]` (3 × 1 byte for u8 in compatibility mode) //! //! ### Long Run Handling //! -//! When a run exceeds 255 values, it is split into multiple runs of 255 -//! followed by a final run with the remainder. For example, a run of 1000 -//! identical values becomes 4 runs: [255, 255, 255, 235]. +//! In compatibility mode, when a run exceeds 255 values, it is split into multiple +//! runs of 255 followed by a final run with the remainder. RLE v2 can use u16 or +//! u32 run lengths to reduce this splitting. //! //! ## Supported Types //! @@ -54,30 +54,332 @@ //! When used in the block compression path, the encoded output is a single buffer: //! `[8-byte header: values buffer size][values buffer][run_lengths buffer]`. -use arrow_buffer::ArrowNativeType; +use arrow_buffer::{ArrowNativeType, ScalarBuffer}; use log::trace; -use snafu::location; use crate::buffer::LanceBuffer; -use crate::compression::{BlockCompressor, BlockDecompressor, MiniBlockDecompressor}; +use crate::compression::{ + BlockCompressor, BlockDecompressor, MiniBlockDecompressor, require_block_payload, +}; use crate::data::DataBlock; use crate::data::{BlockInfo, FixedWidthDataBlock}; use crate::encodings::logical::primitive::miniblock::{ - MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressor, MAX_MINIBLOCK_BYTES, - MAX_MINIBLOCK_VALUES, + MAX_MINIBLOCK_BYTES, MAX_MINIBLOCK_VALUES, MiniBlockChunk, MiniBlockCompressed, + MiniBlockCompressionContext, MiniBlockCompressor, }; -use crate::format::pb21::CompressiveEncoding; +use crate::encodings::physical::block::{CompressionConfig, GeneralBufferCompressor}; use crate::format::ProtobufUtils21; +use crate::format::pb21::CompressiveEncoding; use lance_core::{Error, Result}; +/// Width used to encode RLE run lengths. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RunLengthWidth { + /// Compatibility mode. Runs longer than 255 values are split. + U8, + /// RLE v2 mode for runs up to 65,535 values per entry. + U16, + /// RLE v2 mode for runs up to 4,294,967,295 values per entry. + U32, +} + +impl RunLengthWidth { + pub(crate) fn from_bits(bits_per_value: u64) -> Option { + match bits_per_value { + 8 => Some(Self::U8), + 16 => Some(Self::U16), + 32 => Some(Self::U32), + _ => None, + } + } + + pub(crate) fn bits_per_value(self) -> u64 { + match self { + Self::U8 => 8, + Self::U16 => 16, + Self::U32 => 32, + } + } + + fn bytes_per_value(self) -> usize { + match self { + Self::U8 => 1, + Self::U16 => 2, + Self::U32 => 4, + } + } + + fn max_run_length(self) -> u64 { + match self { + Self::U8 => u8::MAX as u64, + Self::U16 => u16::MAX as u64, + Self::U32 => u32::MAX as u64, + } + } + + fn write_length(self, length: u64, dst: &mut Vec) { + match self { + Self::U8 => dst.push(length as u8), + Self::U16 => dst.extend_from_slice(&(length as u16).to_le_bytes()), + Self::U32 => dst.extend_from_slice(&(length as u32).to_le_bytes()), + } + } + + fn read_length(self, bytes: &[u8]) -> u64 { + match self { + Self::U8 => bytes[0] as u64, + Self::U16 => u16::from_le_bytes([bytes[0], bytes[1]]) as u64, + Self::U32 => u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as u64, + } + } +} + +const RUN_LENGTH_WIDTHS: [RunLengthWidth; 3] = + [RunLengthWidth::U8, RunLengthWidth::U16, RunLengthWidth::U32]; + +/// Select the lowest-cost run length width from precomputed entry counts. +pub(crate) fn select_run_length_width_from_entries( + entries: &[u64], + bits_per_value: u64, +) -> Result<(RunLengthWidth, u128)> { + if entries.len() != RUN_LENGTH_WIDTHS.len() { + return Err(Error::invalid_input_source( + format!( + "RLE run length entry statistics must have {} values, got {}", + RUN_LENGTH_WIDTHS.len(), + entries.len() + ) + .into(), + )); + } + + if !matches!(bits_per_value, 8 | 16 | 32 | 64) { + return Err(Error::invalid_input_source( + format!("RLE encoding bits_per_value must be 8, 16, 32, or 64, got {bits_per_value}") + .into(), + )); + } + + let mut best_width = RUN_LENGTH_WIDTHS[0]; + let mut best_cost = rle_encoded_size_from_entries(entries[0], bits_per_value, best_width); + for (&width, &entry_count) in RUN_LENGTH_WIDTHS.iter().zip(entries.iter()).skip(1) { + let cost = rle_encoded_size_from_entries(entry_count, bits_per_value, width); + if cost < best_cost { + best_width = width; + best_cost = cost; + } + } + + Ok((best_width, best_cost)) +} + +pub(crate) fn rle_encoded_size_from_entries( + entry_count: u64, + bits_per_value: u64, + run_length_width: RunLengthWidth, +) -> u128 { + let bytes_per_value = (bits_per_value / 8) as u128; + let bytes_per_length = run_length_width.bytes_per_value() as u128; + (entry_count as u128) * (bytes_per_value + bytes_per_length) +} + +pub(crate) fn run_length_width_index(run_length_width: RunLengthWidth) -> usize { + match run_length_width { + RunLengthWidth::U8 => 0, + RunLengthWidth::U16 => 1, + RunLengthWidth::U32 => 2, + } +} + +pub(crate) fn select_run_length_width( + data: &LanceBuffer, + num_values: u64, + bits_per_value: u64, + max_segment_values: Option, +) -> Result<(RunLengthWidth, u128)> { + let entries = collect_run_length_entries(data, num_values, bits_per_value, max_segment_values)?; + select_run_length_width_from_entries(&entries, bits_per_value) +} + +pub(crate) fn rle_encoded_size( + data: &LanceBuffer, + num_values: u64, + bits_per_value: u64, + max_segment_values: Option, + run_length_width: RunLengthWidth, +) -> Result { + let entries = collect_run_length_entries(data, num_values, bits_per_value, max_segment_values)?; + let width_idx = run_length_width_index(run_length_width); + Ok(rle_encoded_size_from_entries( + entries[width_idx], + bits_per_value, + run_length_width, + )) +} + +fn collect_run_length_entries( + data: &LanceBuffer, + num_values: u64, + bits_per_value: u64, + max_segment_values: Option, +) -> Result<[u64; 3]> { + let num_values = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("RLE num_values does not fit in usize: {num_values}").into(), + ) + })?; + + macro_rules! collect_entries { + ($ty:ty) => {{ + let type_size = std::mem::size_of::<$ty>(); + let expected_bytes = num_values.checked_mul(type_size).ok_or_else(|| { + Error::invalid_input_source( + format!( + "RLE input byte length overflow: {num_values} values of {type_size} bytes" + ) + .into(), + ) + })?; + if data.len() != expected_bytes { + return Err(Error::invalid_input_source( + format!( + "RLE input data size mismatch: {} bytes for {} values of {} bytes", + data.len(), + num_values, + type_size + ) + .into(), + )); + } + let values = data.borrow_to_typed_slice::<$ty>(); + let values = values.get(..num_values).ok_or_else(|| { + Error::invalid_input_source( + format!( + "RLE data has {} values but {} were expected", + values.len(), + num_values + ) + .into(), + ) + })?; + Ok(collect_run_length_entries_from_slice( + values, + max_segment_values, + )) + }}; + } + + match bits_per_value { + 8 => collect_entries!(u8), + 16 => collect_entries!(u16), + 32 => collect_entries!(u32), + 64 => collect_entries!(u64), + _ => Err(Error::invalid_input_source( + format!("RLE encoding bits_per_value must be 8, 16, 32, or 64, got {bits_per_value}") + .into(), + )), + } +} + +fn collect_run_length_entries_from_slice( + values: &[T], + max_segment_values: Option, +) -> [u64; 3] { + if values.is_empty() { + return [0; 3]; + } + + let mut entries = [0u64; 3]; + let mut prev = values[0]; + let mut current_length = 1u64; + + for &value in &values[1..] { + if value != prev { + accumulate_run_length_entries(current_length, max_segment_values, &mut entries); + prev = value; + current_length = 1; + } else { + current_length += 1; + } + } + accumulate_run_length_entries(current_length, max_segment_values, &mut entries); + + entries +} + +pub(crate) fn accumulate_run_length_entries( + run_length: u64, + max_segment_values: Option, + entries: &mut [u64; 3], +) { + let max_segment_values = max_segment_values.unwrap_or(run_length).max(1); + let mut remaining = run_length; + while remaining > 0 { + let segment = remaining.min(max_segment_values); + for (idx, width) in RUN_LENGTH_WIDTHS.iter().enumerate() { + let entry_count = segment.div_ceil(width.max_run_length()); + entries[idx] = entries[idx].saturating_add(entry_count); + } + remaining -= segment; + } +} + /// RLE encoder for miniblock format -#[derive(Debug, Default)] -pub struct RleEncoder; +#[derive(Debug)] +pub struct RleEncoder { + run_length_width: RunLengthWidth, + values_compression: Option, + run_lengths_compression: Option, + use_child_bitpacking: bool, +} + +#[derive(Clone)] +struct RleChildCandidate { + encoding: CompressiveEncoding, + data: LanceBuffer, + chunk_sizes: Vec, + size: usize, + requires_num_values: bool, +} + +impl Default for RleEncoder { + fn default() -> Self { + Self::new() + } +} impl RleEncoder { pub fn new() -> Self { - Self + Self { + run_length_width: RunLengthWidth::U8, + values_compression: None, + run_lengths_compression: None, + use_child_bitpacking: false, + } + } + + pub(crate) fn with_run_length_width(run_length_width: RunLengthWidth) -> Self { + Self { + run_length_width, + values_compression: None, + run_lengths_compression: None, + use_child_bitpacking: false, + } + } + + pub(crate) fn with_child_encoding( + run_length_width: RunLengthWidth, + values_compression: Option, + run_lengths_compression: Option, + use_child_bitpacking: bool, + ) -> Self { + Self { + run_length_width, + values_compression, + run_lengths_compression, + use_child_bitpacking, + } } fn encode_data( @@ -90,17 +392,23 @@ impl RleEncoder { return Ok((Vec::new(), Vec::new())); } + let num_values = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("RLE num_values does not fit in usize: {num_values}").into(), + ) + })?; let bytes_per_value = (bits_per_value / 8) as usize; + let bytes_per_length = self.run_length_width.bytes_per_value(); // Pre-allocate global buffers with estimated capacity // Assume average compression ratio of ~10:1 (10 values per run) - let estimated_runs = num_values as usize / 10; + let estimated_runs = num_values / 10; let mut all_values = Vec::with_capacity(estimated_runs * bytes_per_value); - let mut all_lengths = Vec::with_capacity(estimated_runs); + let mut all_lengths = Vec::with_capacity(estimated_runs * bytes_per_length); let mut chunks = Vec::new(); let mut offset = 0usize; - let mut values_remaining = num_values as usize; + let mut values_remaining = num_values; while values_remaining > 0 { let values_start = all_values.len(); @@ -135,11 +443,26 @@ impl RleEncoder { &mut all_values, &mut all_lengths, ), - _ => unreachable!("RLE encoding bits_per_value must be 8, 16, 32 or 64"), + _ => { + return Err(Error::invalid_input_source( + format!( + "RLE encoding bits_per_value must be 8, 16, 32, or 64, got {bits_per_value}" + ) + .into(), + )); + } }; if values_processed == 0 { - break; + // A non-final chunk needs at least two values because log_num_values == 0 + // identifies the final chunk. Report an error instead of returning partial data. + return Err(Error::internal(format!( + "RLE encoder made no progress: values_remaining={values_remaining}, \ + offset={offset}, data_len={}, bits_per_value={bits_per_value}, \ + max_miniblock_values={}", + data.len(), + *MAX_MINIBLOCK_VALUES + ))); } let log_num_values = if is_last_chunk { @@ -176,22 +499,75 @@ impl RleEncoder { )) } - /// Encodes a chunk of data using RLE compression with dynamic boundary detection. - /// - /// This function processes values sequentially, detecting runs (sequences of identical values) - /// and encoding them as (value, length) pairs. It dynamically determines whether this chunk - /// should be the last chunk based on how many values were processed. - /// - /// # Key Features: - /// - Tracks byte usage to ensure we don't exceed MAX_MINIBLOCK_BYTES - /// - Maintains power-of-2 checkpoints for non-last chunks - /// - Splits long runs (>255) into multiple entries - /// - Dynamically determines if this is the last chunk - /// - /// # Returns: - /// - num_runs: Number of runs encoded - /// - values_processed: Number of input values processed - /// - is_last_chunk: Whether this chunk processed all remaining values + fn encode_block_data( + &self, + data: &LanceBuffer, + num_values: u64, + bits_per_value: u64, + ) -> Result> { + match bits_per_value { + 8 => self.encode_block_data_generic::(data, num_values), + 16 => self.encode_block_data_generic::(data, num_values), + 32 => self.encode_block_data_generic::(data, num_values), + 64 => self.encode_block_data_generic::(data, num_values), + _ => Err(Error::invalid_input_source( + format!( + "RLE encoding bits_per_value must be 8, 16, 32, or 64, got {bits_per_value}" + ) + .into(), + )), + } + } + + fn encode_block_data_generic( + &self, + data: &LanceBuffer, + num_values: u64, + ) -> Result> + where + T: bytemuck::Pod + PartialEq + Copy + ArrowNativeType, + { + let num_values = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("RLE num_values does not fit in usize: {num_values}").into(), + ) + })?; + let type_size = std::mem::size_of::(); + let expected_bytes = num_values.checked_mul(type_size).ok_or_else(|| { + Error::invalid_input_source( + format!("RLE input byte length overflow: {num_values} values of {type_size} bytes") + .into(), + ) + })?; + if data.len() != expected_bytes { + return Err(Error::invalid_input_source( + format!( + "RLE input data size mismatch: {} bytes for {} values of {} bytes", + data.len(), + num_values, + type_size + ) + .into(), + )); + } + if num_values == 0 { + return Ok(vec![LanceBuffer::empty(), LanceBuffer::empty()]); + } + + let values_ref = data.borrow_to_typed_slice::(); + let values = values_ref.as_ref(); + let estimated_runs = num_values / 10; + let mut all_values = Vec::with_capacity(estimated_runs * type_size); + let mut all_lengths = + Vec::with_capacity(estimated_runs * self.run_length_width.bytes_per_value()); + self.encode_values(values, &mut all_values, &mut all_lengths); + Ok(vec![ + LanceBuffer::from(all_values), + LanceBuffer::from(all_lengths), + ]) + } + + /// Encodes the largest valid mini-block prefix from `offset`. fn encode_chunk_rolling( &self, data: &LanceBuffer, @@ -204,9 +580,8 @@ impl RleEncoder { T: bytemuck::Pod + PartialEq + Copy + std::fmt::Debug + ArrowNativeType, { 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; @@ -218,112 +593,101 @@ impl RleEncoder { let chunk_buffer = data.slice_with_length(chunk_start, chunk_len); let typed_data_ref = chunk_buffer.borrow_to_typed_slice::(); let typed_data: &[T] = typed_data_ref.as_ref(); + let max_values = max_values.min(typed_data.len()); if typed_data.is_empty() { return (0, 0, false); } - // Record starting positions for this chunk let values_start = all_values.len(); + let all_remaining_values_fit = values_remaining <= max_by_count; + let encoded_size = self.encoded_size(&typed_data[..max_values]); + let (values_to_encode, is_last_chunk) = if all_remaining_values_fit + && encoded_size <= MAX_MINIBLOCK_BYTES as usize + { + (max_values, true) + } else if let Some(values_to_encode) = self.largest_power_of_two_prefix::(typed_data) { + (values_to_encode, false) + } else { + return (0, 0, false); + }; - let mut current_value = typed_data[0]; - let mut current_length = 1u64; - let mut bytes_used = 0usize; - let mut total_values_encoded = 0usize; // Track total encoded values + self.encode_values(&typed_data[..values_to_encode], all_values, all_lengths); - // Power-of-2 checkpoints for ensuring non-last chunks have valid sizes. - // - // We start from a slightly larger minimum checkpoint for smaller types since - // they encode more compactly and are less likely to hit MAX_MINIBLOCK_BYTES. - let min_checkpoint_log2 = match type_size { - 1 => 8, // 256 - 2 => 7, // 128 - _ => 6, // 64 - }; - let max_checkpoint_log2 = (values_remaining.min(MAX_MINIBLOCK_VALUES as usize)) - .next_power_of_two() - .ilog2(); - let mut checkpoint_log2 = min_checkpoint_log2; + let num_runs = (all_values.len() - values_start) / type_size; + (num_runs, values_to_encode, is_last_chunk) + } + + fn largest_power_of_two_prefix(&self, values: &[T]) -> Option + where + T: bytemuck::Pod + PartialEq + Copy, + { + let max_prefix = values.len().min(*MAX_MINIBLOCK_VALUES as usize); + let mut prefix = 1usize << max_prefix.ilog2(); + while prefix > 1 { + if self.encoded_size(&values[..prefix]) <= MAX_MINIBLOCK_BYTES as usize { + return Some(prefix); + } + prefix >>= 1; + } + None + } + + fn encoded_size(&self, values: &[T]) -> usize + where + T: bytemuck::Pod + PartialEq + Copy, + { + if values.is_empty() { + return 0; + } - // Save state at checkpoints so we can roll back if needed - let mut last_checkpoint_state = None; + let mut current_value = values[0]; + let mut current_length = 1u64; + let mut encoded_size = 0usize; - for &value in typed_data[1..].iter() { + for &value in values.iter().skip(1) { if value == current_value { current_length += 1; } else { - // Calculate space needed (may need multiple u8s if run > 255) - let run_chunks = current_length.div_ceil(255) as usize; - let bytes_needed = run_chunks * (type_size + 1); - - // Stop if adding this run would exceed byte limit - if bytes_used + bytes_needed > MAX_MINIBLOCK_BYTES as usize { - if let Some((val_pos, len_pos, _, checkpoint_values)) = last_checkpoint_state { - // Roll back to last power-of-2 checkpoint - all_values.truncate(val_pos); - all_lengths.truncate(len_pos); - let num_runs = (val_pos - values_start) / type_size; - return (num_runs, checkpoint_values, false); - } - break; - } - - bytes_used += self.add_run(¤t_value, current_length, all_values, all_lengths); - total_values_encoded += current_length as usize; + encoded_size += self.run_size::(current_length); current_value = value; current_length = 1; } - - // Check if we reached a power-of-2 checkpoint. - while checkpoint_log2 <= max_checkpoint_log2 { - let checkpoint_values = 1usize << checkpoint_log2; - if checkpoint_values > values_remaining || total_values_encoded < checkpoint_values - { - break; - } - last_checkpoint_state = Some(( - all_values.len(), - all_lengths.len(), - bytes_used, - checkpoint_values, - )); - checkpoint_log2 += 1; - } } + encoded_size += self.run_size::(current_length); + encoded_size + } - // After the loop, we always have a pending run that needs to be added - // unless we've exceeded the byte limit - if current_length > 0 { - let run_chunks = current_length.div_ceil(255) as usize; - let bytes_needed = run_chunks * (type_size + 1); + fn run_size(&self, length: u64) -> usize + where + T: bytemuck::Pod, + { + let type_size = std::mem::size_of::(); + let run_chunks = length.div_ceil(self.run_length_width.max_run_length()) as usize; + run_chunks * (type_size + self.run_length_width.bytes_per_value()) + } - if bytes_used + bytes_needed <= MAX_MINIBLOCK_BYTES as usize { - let _ = self.add_run(¤t_value, current_length, all_values, all_lengths); - total_values_encoded += current_length as usize; - } + fn encode_values(&self, values: &[T], all_values: &mut Vec, all_lengths: &mut Vec) + where + T: bytemuck::Pod + PartialEq + Copy, + { + if values.is_empty() { + return; } - // Determine if we've processed all remaining values - let is_last_chunk = total_values_encoded == values_remaining; + let mut current_value = values[0]; + let mut current_length = 1u64; - // Non-last chunks must have power-of-2 values for miniblock format - if !is_last_chunk { - if total_values_encoded.is_power_of_two() { - // Already at power-of-2 boundary - } else if let Some((val_pos, len_pos, _, checkpoint_values)) = last_checkpoint_state { - // Roll back to last valid checkpoint - all_values.truncate(val_pos); - all_lengths.truncate(len_pos); - let num_runs = (val_pos - values_start) / type_size; - return (num_runs, checkpoint_values, false); + for &value in values.iter().skip(1) { + if value == current_value { + current_length += 1; } else { - // No valid checkpoint, can't create a valid chunk - return (0, 0, false); + self.add_run(¤t_value, current_length, all_values, all_lengths); + current_value = value; + current_length = 1; } } - - let num_runs = (all_values.len() - values_start) / type_size; - (num_runs, total_values_encoded, is_last_chunk) + self.add_run(¤t_value, current_length, all_values, all_lengths); } fn add_run( @@ -338,29 +702,356 @@ impl RleEncoder { { let value_bytes = bytemuck::bytes_of(value); let type_size = std::mem::size_of::(); - let num_full_chunks = (length / 255) as usize; - let remainder = (length % 255) as u8; + let max_run_length = self.run_length_width.max_run_length(); + let num_full_chunks = (length / max_run_length) as usize; + let remainder = length % max_run_length; let total_chunks = num_full_chunks + if remainder > 0 { 1 } else { 0 }; all_values.reserve(total_chunks * type_size); - all_lengths.reserve(total_chunks); + all_lengths.reserve(total_chunks * self.run_length_width.bytes_per_value()); for _ in 0..num_full_chunks { all_values.extend_from_slice(value_bytes); - all_lengths.push(255); + self.run_length_width + .write_length(max_run_length, all_lengths); } if remainder > 0 { all_values.extend_from_slice(value_bytes); - all_lengths.push(remainder); + self.run_length_width.write_length(remainder, all_lengths); } - total_chunks * (type_size + 1) + total_chunks * (type_size + self.run_length_width.bytes_per_value()) + } + + fn flat_child_candidate( + buffers: &[LanceBuffer], + chunks: &[MiniBlockChunk], + buffer_index: usize, + bits_per_value: u64, + ) -> RleChildCandidate { + RleChildCandidate { + encoding: ProtobufUtils21::flat(bits_per_value, None), + data: buffers[buffer_index].clone(), + chunk_sizes: chunks + .iter() + .map(|chunk| chunk.buffer_sizes[buffer_index]) + .collect(), + size: buffers[buffer_index].len(), + requires_num_values: false, + } + } + + fn general_child_candidate( + buffers: &[LanceBuffer], + chunks: &[MiniBlockChunk], + buffer_index: usize, + bits_per_value: u64, + compression: CompressionConfig, + ) -> Result> { + if buffers.is_empty() || buffers[buffer_index].is_empty() { + return Ok(None); + }; + + let compressor = GeneralBufferCompressor::get_compressor(compression)?; + let original = &buffers[buffer_index]; + let mut compressed = Vec::new(); + let mut offset = 0usize; + let mut total_original_size = 0usize; + let mut compressed_sizes = Vec::with_capacity(chunks.len()); + + for chunk in chunks.iter() { + let chunk_size = chunk.buffer_sizes[buffer_index] as usize; + let end = offset.checked_add(chunk_size).ok_or_else(|| { + Error::invalid_input_source("RLE child buffer offset overflow".into()) + })?; + if end > original.len() { + return Err(Error::invalid_input_source( + format!( + "RLE child buffer {} chunk size exceeds buffer length: end {}, len {}", + buffer_index, + end, + original.len() + ) + .into(), + )); + } + + let start = compressed.len(); + compressor.compress(&original.as_ref()[offset..end], &mut compressed)?; + let compressed_size = compressed.len() - start; + let compressed_size = u32::try_from(compressed_size).map_err(|_| { + Error::invalid_input_source( + format!( + "RLE child buffer {} compressed chunk is too large: {} bytes", + buffer_index, compressed_size + ) + .into(), + ) + })?; + compressed_sizes.push(compressed_size); + total_original_size += chunk_size; + offset = end; + } + + if compressed.len() >= total_original_size { + return Ok(None); + } + + let encoding = + ProtobufUtils21::wrapped(compression, ProtobufUtils21::flat(bits_per_value, None))?; + Ok(Some( + RleChildCandidate { + encoding, + data: LanceBuffer::from(compressed), + chunk_sizes: compressed_sizes, + size: 0, + requires_num_values: false, + } + .with_size_from_data(), + )) + } + + #[cfg(feature = "bitpacking")] + fn bitpacked_child_candidate( + buffers: &[LanceBuffer], + chunks: &[MiniBlockChunk], + buffer_index: usize, + bits_per_value: u64, + ) -> Result> { + let original = &buffers[buffer_index]; + if original.is_empty() { + return Ok(None); + } + let packed_bits = Self::required_bits(original, bits_per_value)?; + if packed_bits >= bits_per_value { + return Ok(None); + } + + let compressor = crate::encodings::physical::bitpacking::OutOfLineBitpacking::new( + packed_bits, + bits_per_value, + ); + let mut packed = Vec::new(); + let mut offset = 0usize; + let mut packed_sizes = Vec::with_capacity(chunks.len()); + let bytes_per_value = usize::try_from(bits_per_value / 8).map_err(|_| { + Error::invalid_input_source( + format!("RLE child bit width is too large: {bits_per_value}").into(), + ) + })?; + + for chunk in chunks { + let chunk_size = chunk.buffer_sizes[buffer_index] as usize; + let end = offset.checked_add(chunk_size).ok_or_else(|| { + Error::invalid_input_source("RLE child buffer offset overflow".into()) + })?; + if end > original.len() { + return Err(Error::invalid_input_source( + format!( + "RLE child buffer {} chunk size exceeds buffer length: end {}, len {}", + buffer_index, + end, + original.len() + ) + .into(), + )); + } + if bytes_per_value == 0 || !chunk_size.is_multiple_of(bytes_per_value) { + return Err(Error::invalid_input_source( + format!( + "RLE child buffer {} chunk has invalid size {} for {} bits per value", + buffer_index, chunk_size, bits_per_value + ) + .into(), + )); + } + + let child_values = (chunk_size / bytes_per_value) as u64; + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value, + data: original.slice_with_length(offset, chunk_size), + num_values: child_values, + block_info: BlockInfo::default(), + }); + let (chunk_packed, _) = BlockCompressor::compress(&compressor, block)?; + let chunk_packed = chunk_packed.ok_or_else(|| { + Error::internal("RLE bitpacking child returned no payload".to_string()) + })?; + let packed_size = u32::try_from(chunk_packed.len()).map_err(|_| { + Error::invalid_input_source( + format!( + "RLE child buffer {} bitpacked chunk is too large: {} bytes", + buffer_index, + chunk_packed.len() + ) + .into(), + ) + })?; + packed_sizes.push(packed_size); + packed.extend_from_slice(chunk_packed.as_ref()); + offset = end; + } + + if packed.len() >= original.len() { + return Ok(None); + } + + Ok(Some( + RleChildCandidate { + encoding: ProtobufUtils21::out_of_line_bitpacking( + bits_per_value, + ProtobufUtils21::flat(packed_bits, None), + ), + data: LanceBuffer::from(packed), + chunk_sizes: packed_sizes, + size: 0, + requires_num_values: true, + } + .with_size_from_data(), + )) + } + + #[cfg(feature = "bitpacking")] + fn required_bits(buffer: &LanceBuffer, bits_per_value: u64) -> Result { + let max_value = match bits_per_value { + 8 => buffer.as_ref().iter().map(|value| *value as u64).max(), + 16 => buffer + .as_ref() + .as_chunks::<2>() + .0 + .iter() + .map(|value| u16::from_le_bytes(*value) as u64) + .max(), + 32 => buffer + .as_ref() + .as_chunks::<4>() + .0 + .iter() + .map(|value| u32::from_le_bytes(*value) as u64) + .max(), + 64 => buffer + .as_ref() + .as_chunks::<8>() + .0 + .iter() + .map(|value| u64::from_le_bytes(*value)) + .max(), + _ => { + return Err(Error::invalid_input_source( + format!( + "RLE child bitpacking only supports 8, 16, 32, or 64-bit values, got {bits_per_value}" + ) + .into(), + )); + } + } + .unwrap_or(0); + Ok((u64::BITS - max_value.leading_zeros()).max(1) as u64) + } + + fn child_candidates( + buffers: &[LanceBuffer], + chunks: &[MiniBlockChunk], + buffer_index: usize, + bits_per_value: u64, + compression: Option, + use_child_bitpacking: bool, + ) -> Result> { + #[cfg(not(feature = "bitpacking"))] + let _ = use_child_bitpacking; + let mut candidates = vec![Self::flat_child_candidate( + buffers, + chunks, + buffer_index, + bits_per_value, + )]; + if let Some(compression) = compression + && let Some(candidate) = Self::general_child_candidate( + buffers, + chunks, + buffer_index, + bits_per_value, + compression, + )? + { + candidates.push(candidate); + } + #[cfg(feature = "bitpacking")] + { + if use_child_bitpacking + && let Some(candidate) = + Self::bitpacked_child_candidate(buffers, chunks, buffer_index, bits_per_value)? + { + candidates.push(candidate); + } + } + Ok(candidates) + } + + fn select_child_candidates( + values: Vec, + run_lengths: Vec, + ) -> (RleChildCandidate, RleChildCandidate) { + let mut best: Option<(usize, usize, usize)> = None; + for (value_idx, value) in values.iter().enumerate() { + for (length_idx, length) in run_lengths.iter().enumerate() { + if value.requires_num_values && length.requires_num_values { + continue; + } + let size = value.size + length.size; + if best.is_none_or(|(_, _, best_size)| size < best_size) { + best = Some((value_idx, length_idx, size)); + } + } + } + let (value_idx, length_idx, _) = + best.expect("flat RLE child candidates should always be selectable"); + (values[value_idx].clone(), run_lengths[length_idx].clone()) + } + + pub(crate) fn selected_payload_size(&self, data: &FixedWidthDataBlock) -> Result { + let (all_buffers, chunks) = + self.encode_data(&data.data, data.num_values, data.bits_per_value)?; + if all_buffers.is_empty() { + return Ok(0); + } + + let values_candidates = Self::child_candidates( + &all_buffers, + &chunks, + 0, + data.bits_per_value, + self.values_compression, + self.use_child_bitpacking, + )?; + let run_lengths_candidates = Self::child_candidates( + &all_buffers, + &chunks, + 1, + self.run_length_width.bits_per_value(), + self.run_lengths_compression, + self.use_child_bitpacking, + )?; + let (values, run_lengths) = + Self::select_child_candidates(values_candidates, run_lengths_candidates); + Ok((values.size as u128).saturating_add(run_lengths.size as u128)) + } +} + +impl RleChildCandidate { + fn with_size_from_data(mut self) -> Self { + self.size = self.data.len(); + self } } impl MiniBlockCompressor for RleEncoder { - fn compress(&self, data: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + _context: MiniBlockCompressionContext, + data: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match data { DataBlock::FixedWidth(fixed_width) => { let num_values = fixed_width.num_values; @@ -368,38 +1059,73 @@ impl MiniBlockCompressor for RleEncoder { let (all_buffers, chunks) = self.encode_data(&fixed_width.data, num_values, bits_per_value)?; + if all_buffers.is_empty() { + let compressed = MiniBlockCompressed { + data: all_buffers, + chunks, + num_values, + }; + let encoding = ProtobufUtils21::rle( + ProtobufUtils21::flat(bits_per_value, None), + ProtobufUtils21::flat(self.run_length_width.bits_per_value(), None), + ); + return Ok((compressed, encoding)); + } + + let values_candidates = Self::child_candidates( + &all_buffers, + &chunks, + 0, + bits_per_value, + self.values_compression, + self.use_child_bitpacking, + )?; + let run_lengths_candidates = Self::child_candidates( + &all_buffers, + &chunks, + 1, + self.run_length_width.bits_per_value(), + self.run_lengths_compression, + self.use_child_bitpacking, + )?; + let (values, run_lengths) = + Self::select_child_candidates(values_candidates, run_lengths_candidates); + let chunks = chunks + .into_iter() + .enumerate() + .map(|(idx, chunk)| MiniBlockChunk { + buffer_sizes: vec![values.chunk_sizes[idx], run_lengths.chunk_sizes[idx]], + log_num_values: chunk.log_num_values, + }) + .collect(); let compressed = MiniBlockCompressed { - data: all_buffers, + data: vec![values.data, run_lengths.data], chunks, num_values, }; - let encoding = ProtobufUtils21::rle( - ProtobufUtils21::flat(bits_per_value, None), - ProtobufUtils21::flat(/*bits_per_value=*/ 8, None), - ); + let encoding = ProtobufUtils21::rle(values.encoding, run_lengths.encoding); Ok((compressed, encoding)) } - _ => Err(Error::InvalidInput { - location: location!(), - source: "RLE encoding only supports FixedWidth data blocks".into(), - }), + _ => Err(Error::invalid_input_source( + "RLE encoding only supports FixedWidth data blocks".into(), + )), } } } impl BlockCompressor for RleEncoder { // Block format: [8-byte header: values buffer size][values buffer][run_lengths buffer] - fn compress(&self, data: DataBlock) -> Result { + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)> { match data { DataBlock::FixedWidth(fixed_width) => { let num_values = fixed_width.num_values; let bits_per_value = fixed_width.bits_per_value; - let (all_buffers, _) = - self.encode_data(&fixed_width.data, num_values, bits_per_value)?; + let all_buffers = + self.encode_block_data(&fixed_width.data, num_values, bits_per_value)?; let values_size = all_buffers[0].len() as u64; @@ -407,12 +1133,17 @@ impl BlockCompressor for RleEncoder { combined.extend_from_slice(&values_size.to_le_bytes()); combined.extend_from_slice(&all_buffers[0]); combined.extend_from_slice(&all_buffers[1]); - Ok(LanceBuffer::from(combined)) + Ok(( + Some(LanceBuffer::from(combined)), + ProtobufUtils21::rle( + ProtobufUtils21::flat(bits_per_value, None), + ProtobufUtils21::flat(self.run_length_width.bits_per_value(), None), + ), + )) } - _ => Err(Error::InvalidInput { - location: location!(), - source: "RLE encoding only supports FixedWidth data blocks".into(), - }), + _ => Err(Error::invalid_input_source( + "RLE encoding only supports FixedWidth data blocks".into(), + )), } } } @@ -421,14 +1152,170 @@ impl BlockCompressor for RleEncoder { #[derive(Debug)] pub struct RleDecompressor { bits_per_value: u64, + run_length_width: RunLengthWidth, + values: RleChildDecompressor, + run_lengths: RleChildDecompressor, +} + +#[derive(Debug)] +pub(crate) struct RleChildDecompressor { + bits_per_value: u64, + inner: RleChildDecompressorInner, +} + +#[derive(Debug)] +enum RleChildDecompressorInner { + Flat, + Block { + decompressor: Box, + requires_num_values: bool, + }, +} + +impl RleChildDecompressor { + pub(crate) fn flat(bits_per_value: u64) -> Self { + Self { + bits_per_value, + inner: RleChildDecompressorInner::Flat, + } + } + + pub(crate) fn block( + bits_per_value: u64, + decompressor: Box, + requires_num_values: bool, + ) -> Self { + Self { + bits_per_value, + inner: RleChildDecompressorInner::Block { + decompressor, + requires_num_values, + }, + } + } + + pub(crate) fn bits_per_value(&self) -> u64 { + self.bits_per_value + } + + pub(crate) fn requires_num_values(&self) -> bool { + match &self.inner { + RleChildDecompressorInner::Flat => false, + RleChildDecompressorInner::Block { + requires_num_values, + .. + } => *requires_num_values, + } + } + + pub(crate) fn is_identity(&self) -> bool { + matches!(self.inner, RleChildDecompressorInner::Flat) + } + + fn decode( + &self, + data: LanceBuffer, + num_values: Option, + label: &str, + ) -> Result { + match &self.inner { + RleChildDecompressorInner::Flat => Ok(data), + RleChildDecompressorInner::Block { + decompressor, + requires_num_values, + } => { + let num_values = if *requires_num_values { + num_values.ok_or_else(|| { + Error::invalid_input_source( + format!("RLE {label} child compression requires the run count").into(), + ) + })? + } else { + num_values.unwrap_or(0) + }; + let decoded = decompressor.decompress(Some(data), num_values)?; + self.extract_fixed_width(decoded, num_values, label) + } + } + } + + fn extract_fixed_width( + &self, + data: DataBlock, + expected_num_values: u64, + label: &str, + ) -> Result { + match data { + DataBlock::FixedWidth(block) => { + if block.bits_per_value != self.bits_per_value { + return Err(Error::invalid_input_source( + format!( + "RLE {label} child decoded {}-bit values, expected {}", + block.bits_per_value, self.bits_per_value + ) + .into(), + )); + } + if expected_num_values != 0 && block.num_values != expected_num_values { + return Err(Error::invalid_input_source( + format!( + "RLE {label} child decoded {} values, expected {}", + block.num_values, expected_num_values + ) + .into(), + )); + } + Ok(block.data) + } + _ => Err(Error::invalid_input_source( + format!("RLE {label} child decoded to a non fixed-width block").into(), + )), + } + } } impl RleDecompressor { pub fn new(bits_per_value: u64) -> Self { - Self { bits_per_value } + Self { + bits_per_value, + run_length_width: RunLengthWidth::U8, + values: RleChildDecompressor::flat(bits_per_value), + run_lengths: RleChildDecompressor::flat(RunLengthWidth::U8.bits_per_value()), + } + } + + pub(crate) fn with_run_length_width( + bits_per_value: u64, + run_length_width: RunLengthWidth, + ) -> Self { + Self { + bits_per_value, + run_length_width, + values: RleChildDecompressor::flat(bits_per_value), + run_lengths: RleChildDecompressor::flat(run_length_width.bits_per_value()), + } } - fn decode_data(&self, data: Vec, num_values: u64) -> Result { + pub(crate) fn with_child_decompressors( + bits_per_value: u64, + run_length_width: RunLengthWidth, + values: RleChildDecompressor, + run_lengths: RleChildDecompressor, + ) -> Self { + Self { + bits_per_value, + run_length_width, + values, + run_lengths, + } + } + + fn decode_data( + &self, + data: Vec, + num_values: u64, + clamp_overflow: bool, + ) -> Result { if num_values == 0 { return Ok(DataBlock::FixedWidth(FixedWidthDataBlock { bits_per_value: self.bits_per_value, @@ -439,25 +1326,65 @@ impl RleDecompressor { } if data.len() != 2 { - return Err(Error::InvalidInput { - location: location!(), - source: format!( + return Err(Error::invalid_input_source( + format!( "RLE decompressor expects exactly 2 buffers, got {}", data.len() ) .into(), - }); + )); } - let values_buffer = &data[0]; - let lengths_buffer = &data[1]; + let mut data_iter = data.into_iter(); + let values_buffer = data_iter.next().unwrap(); + let lengths_buffer = data_iter.next().unwrap(); + let (values_buffer, lengths_buffer) = + self.decode_child_buffers(values_buffer, lengths_buffer)?; + + self.decode_child_data(&values_buffer, &lengths_buffer, num_values, clamp_overflow) + } + fn decode_child_data( + &self, + values_buffer: &LanceBuffer, + lengths_buffer: &LanceBuffer, + num_values: u64, + clamp_overflow: bool, + ) -> Result { let decoded_data = match self.bits_per_value { - 8 => self.decode_generic::(values_buffer, lengths_buffer, num_values)?, - 16 => self.decode_generic::(values_buffer, lengths_buffer, num_values)?, - 32 => self.decode_generic::(values_buffer, lengths_buffer, num_values)?, - 64 => self.decode_generic::(values_buffer, lengths_buffer, num_values)?, - _ => unreachable!("RLE decoding bits_per_value must be 8, 16, 32, 64, or 128"), + 8 => self.decode_generic::( + values_buffer, + lengths_buffer, + num_values, + clamp_overflow, + )?, + 16 => self.decode_generic::( + values_buffer, + lengths_buffer, + num_values, + clamp_overflow, + )?, + 32 => self.decode_generic::( + values_buffer, + lengths_buffer, + num_values, + clamp_overflow, + )?, + 64 => self.decode_generic::( + values_buffer, + lengths_buffer, + num_values, + clamp_overflow, + )?, + _ => { + return Err(Error::invalid_input_source( + format!( + "RLE decoding bits_per_value must be 8, 16, 32, or 64, got {}", + self.bits_per_value + ) + .into(), + )); + } }; Ok(DataBlock::FixedWidth(FixedWidthDataBlock { @@ -468,11 +1395,147 @@ impl RleDecompressor { })) } + fn sum_run_lengths( + &self, + values_buffer: &LanceBuffer, + lengths_buffer: &LanceBuffer, + ) -> Result { + let (value_size, value_type) = match self.bits_per_value { + 8 => (1, "u8"), + 16 => (2, "u16"), + 32 => (4, "u32"), + 64 => (8, "u64"), + _ => { + return Err(Error::invalid_input_source( + format!( + "RLE decoding bits_per_value must be 8, 16, 32, or 64, got {}", + self.bits_per_value + ) + .into(), + )); + } + }; + let length_size = + self.validate_buffer_sizes(values_buffer, lengths_buffer, value_size, value_type)?; + + lengths_buffer + .chunks_exact(length_size) + .try_fold(0_u64, |num_values, length_bytes| { + let length = self.run_length_width.read_length(length_bytes); + if length == 0 { + return Err(Error::invalid_input_source( + "RLE decoding encountered a zero run length".into(), + )); + } + num_values.checked_add(length).ok_or_else(|| { + Error::invalid_input_source("RLE run length sum overflowed u64".into()) + }) + }) + } + + fn validate_buffer_sizes( + &self, + values_buffer: &LanceBuffer, + lengths_buffer: &LanceBuffer, + value_size: usize, + value_type: &str, + ) -> Result { + let length_size = self.run_length_width.bytes_per_value(); + if !values_buffer.len().is_multiple_of(value_size) + || !lengths_buffer.len().is_multiple_of(length_size) + { + return Err(Error::invalid_input_source(format!( + "Invalid buffer sizes for RLE {value_type} decoding: values {} bytes (not divisible by {}), lengths {} bytes (not divisible by {})", + values_buffer.len(), + value_size, + lengths_buffer.len(), + length_size + ) + .into())); + } + + let num_runs = values_buffer.len() / value_size; + let num_length_entries = lengths_buffer.len() / length_size; + if num_runs != num_length_entries { + return Err(Error::invalid_input_source( + format!( + "Inconsistent RLE buffers: {} runs but {} length entries", + num_runs, num_length_entries + ) + .into(), + )); + } + Ok(length_size) + } + + fn decode_child_buffers( + &self, + values_buffer: LanceBuffer, + lengths_buffer: LanceBuffer, + ) -> Result<(LanceBuffer, LanceBuffer)> { + let values_requires_num_runs = self.values.requires_num_values(); + let lengths_requires_num_runs = self.run_lengths.requires_num_values(); + if values_requires_num_runs && lengths_requires_num_runs { + return Err(Error::invalid_input_source( + "RLE values and run lengths child compression both require the run count".into(), + )); + } + + if values_requires_num_runs { + let lengths_buffer = self + .run_lengths + .decode(lengths_buffer, None, "run lengths")?; + let num_runs = Self::num_child_values( + &lengths_buffer, + self.run_lengths.bits_per_value(), + "run lengths", + )?; + let values_buffer = self + .values + .decode(values_buffer, Some(num_runs), "values")?; + Ok((values_buffer, lengths_buffer)) + } else if lengths_requires_num_runs { + let values_buffer = self.values.decode(values_buffer, None, "values")?; + let num_runs = + Self::num_child_values(&values_buffer, self.values.bits_per_value(), "values")?; + let lengths_buffer = + self.run_lengths + .decode(lengths_buffer, Some(num_runs), "run lengths")?; + Ok((values_buffer, lengths_buffer)) + } else { + let values_buffer = self.values.decode(values_buffer, None, "values")?; + let lengths_buffer = self + .run_lengths + .decode(lengths_buffer, None, "run lengths")?; + Ok((values_buffer, lengths_buffer)) + } + } + + fn num_child_values(buffer: &LanceBuffer, bits_per_value: u64, label: &str) -> Result { + let bytes_per_value = usize::try_from(bits_per_value / 8).map_err(|_| { + Error::invalid_input_source( + format!("RLE {label} child bit width is too large: {bits_per_value}").into(), + ) + })?; + if bytes_per_value == 0 || !buffer.len().is_multiple_of(bytes_per_value) { + return Err(Error::invalid_input_source( + format!( + "RLE {label} child decoded to {} bytes, not divisible by {}", + buffer.len(), + bytes_per_value + ) + .into(), + )); + } + Ok((buffer.len() / bytes_per_value) as u64) + } + fn decode_generic( &self, values_buffer: &LanceBuffer, lengths_buffer: &LanceBuffer, num_values: u64, + clamp_overflow: bool, ) -> Result where T: bytemuck::Pod + Copy + std::fmt::Debug + ArrowNativeType, @@ -483,141 +1546,654 @@ impl RleDecompressor { if num_values == 0 { return Ok(LanceBuffer::empty()); } else { - return Err(Error::InvalidInput { - location: location!(), - source: format!("Empty buffers but expected {} values", num_values).into(), - }); + return Err(Error::invalid_input_source( + format!("Empty buffers but expected {} values", num_values).into(), + )); } } - if !values_buffer.len().is_multiple_of(type_size) || lengths_buffer.is_empty() { - return Err(Error::InvalidInput { - location: location!(), - source: format!( - "Invalid buffer sizes for RLE {} decoding: values {} bytes (not divisible by {}), lengths {} bytes", - std::any::type_name::(), - values_buffer.len(), - type_size, - lengths_buffer.len() + let length_size = self.validate_buffer_sizes( + values_buffer, + lengths_buffer, + type_size, + std::any::type_name::(), + )?; + + let values_ref = values_buffer.borrow_to_typed_slice::(); + let values: &[T] = values_ref.as_ref(); + let lengths = lengths_buffer.as_ref(); + + let expected_value_count = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("RLE num_values does not fit in usize: {num_values}").into(), + ) + })?; + // Legacy miniblock encoders rolled back to a power-of-2 checkpoint after a run + // had already crossed it, so a chunk's run lengths can sum past its declared + // value count (the excess values are re-encoded at the start of the next chunk). + // The pre-run-length-width decoder truncated the excess, so miniblock decoding + // clamps rather than rejects to keep those files readable. Block payloads never + // legitimately overflow, so they decode strictly. + let mut decoded: Vec = Vec::new(); + decoded + .try_reserve_exact(expected_value_count) + .map_err(|_| { + Error::invalid_input_source( + format!("RLE decoding cannot allocate {expected_value_count} values").into(), + ) + })?; + for (value, length_bytes) in values.iter().zip(lengths.chunks_exact(length_size)) { + let length = self.run_length_width.read_length(length_bytes); + if length == 0 { + return Err(Error::invalid_input_source( + "RLE decoding encountered a zero run length".into(), + )); + } + let length = usize::try_from(length).map_err(|_| { + Error::invalid_input_source( + format!("RLE run length does not fit in usize: {length}").into(), + ) + })?; + let remaining = expected_value_count - decoded.len(); + if length > remaining { + if !clamp_overflow { + return Err(Error::invalid_input_source( + format!( + "RLE decoding overflowed expected value count: produced at least {}, expected {}", + decoded.len() + length, + expected_value_count + ) + .into(), + )); + } + decoded.resize(expected_value_count, *value); + break; + } + decoded.resize(decoded.len() + length, *value); + } + + if decoded.len() != expected_value_count { + return Err(Error::invalid_input_source( + format!( + "RLE decoding produced {} values, expected {}", + decoded.len(), + expected_value_count ) .into(), - }); + )); } - let num_runs = values_buffer.len() / type_size; - let num_length_entries = lengths_buffer.len(); - if num_runs != num_length_entries { - return Err(Error::InvalidInput { - location: location!(), - source: format!( - "Inconsistent RLE buffers: {} runs but {} length entries", - num_runs, num_length_entries + trace!( + "RLE decoded {} {} values", + num_values, + std::any::type_name::() + ); + Ok(LanceBuffer::reinterpret_vec(decoded)) + } +} + +impl MiniBlockDecompressor for RleDecompressor { + fn decompress(&self, data: Vec, num_values: u64) -> Result { + self.decode_data(data, num_values, true) + } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + num_values + .checked_mul(self.bits_per_value) + .map(|bits| bits.div_ceil(8)) + } +} + +impl BlockDecompressor for RleDecompressor { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "RLE")?; + let (values_buffer, lengths_buffer) = parse_rle_block_frame(&data)?; + self.decode_data(vec![values_buffer, lengths_buffer], num_values, false) + } + + fn infer_num_values(&self, data: &LanceBuffer) -> Result> { + // Pylance 6.0.1 used this exact RLE signature for structural levels. Newer RLE + // variants are not part of that compatibility case and may contain much wider, + // untrusted run lengths. + if self.bits_per_value != 16 + || self.run_length_width != RunLengthWidth::U8 + || !self.values.is_identity() + || !self.run_lengths.is_identity() + { + return Ok(None); + } + let (values_buffer, lengths_buffer) = parse_rle_block_frame(data)?; + self.sum_run_lengths(&values_buffer, &lengths_buffer) + .map(Some) + } +} + +/// Split an RLE block-format buffer into its `(values, lengths)` sub-buffers. +/// Frame: `[values_size: u64-le][values bytes][run-length bytes]`. +fn parse_rle_block_frame(data: &LanceBuffer) -> Result<(LanceBuffer, LanceBuffer)> { + // fetch the values_size + if data.len() < 8 { + return Err(Error::invalid_input_source( + format!("Insufficient data size: {}", data.len()).into(), + )); + } + + let values_size_bytes: [u8; 8] = data[..8].try_into().expect("slice length already checked"); + let values_size: usize = u64::from_le_bytes(values_size_bytes) + .try_into() + .map_err(|_| { + Error::invalid_input_source( + format!( + "Invalid values buffer size: {}", + u64::from_le_bytes(values_size_bytes) ) .into(), - }); + ) + })?; + + // parse values + let values_start: usize = 8; + let lengths_start = values_start + .checked_add(values_size) + .ok_or_else(|| Error::invalid_input_source("Invalid RLE values buffer size".into()))?; + + if data.len() < lengths_start { + return Err(Error::invalid_input_source( + format!("Insufficient data size: {}", data.len()).into(), + )); + } + + let values_buffer = data.slice_with_length(values_start, values_size); + let lengths_buffer = data.slice_with_length(lengths_start, data.len() - lengths_start); + Ok((values_buffer, lengths_buffer)) +} + +#[derive(Clone, Debug)] +enum RleRunLengths { + U8(ScalarBuffer), + U16(ScalarBuffer), + U32(ScalarBuffer), +} + +impl RleRunLengths { + fn try_new(buffer: LanceBuffer, width: RunLengthWidth) -> Result { + let width_bytes = width.bytes_per_value(); + if !buffer.len().is_multiple_of(width_bytes) { + return Err(Error::invalid_input_source( + format!( + "Invalid RLE run lengths buffer: {} bytes (not divisible by {})", + buffer.len(), + width_bytes + ) + .into(), + )); } + Ok(match width { + RunLengthWidth::U8 => Self::U8(buffer.borrow_to_typed_slice()), + RunLengthWidth::U16 => Self::U16(buffer.borrow_to_typed_slice()), + RunLengthWidth::U32 => Self::U32(buffer.borrow_to_typed_slice()), + }) + } - let values_ref = values_buffer.borrow_to_typed_slice::(); - let values: &[T] = values_ref.as_ref(); - let lengths: &[u8] = lengths_buffer.as_ref(); + fn len(&self) -> usize { + match self { + Self::U8(lengths) => lengths.len(), + Self::U16(lengths) => lengths.len(), + Self::U32(lengths) => lengths.len(), + } + } - let expected_value_count = num_values as usize; - let mut decoded: Vec = Vec::with_capacity(expected_value_count); + fn get(&self, index: usize) -> usize { + match self { + Self::U8(lengths) => lengths[index] as usize, + Self::U16(lengths) => lengths[index] as usize, + Self::U32(lengths) => lengths[index] as usize, + } + } - for (value, &length) in values.iter().zip(lengths.iter()) { - if decoded.len() == expected_value_count { - break; - } + fn owned_size(&self) -> usize { + match self { + Self::U8(lengths) => std::mem::size_of_val(lengths.as_ref()), + Self::U16(lengths) => std::mem::size_of_val(lengths.as_ref()), + Self::U32(lengths) => std::mem::size_of_val(lengths.as_ref()), + } + } + fn into_owned(self) -> Self { + match self { + Self::U8(lengths) => Self::U8(ScalarBuffer::from(lengths.as_ref().to_vec())), + Self::U16(lengths) => Self::U16(ScalarBuffer::from(lengths.as_ref().to_vec())), + Self::U32(lengths) => Self::U32(ScalarBuffer::from(lengths.as_ref().to_vec())), + } + } + + fn deep_size(&self) -> usize { + match self { + Self::U8(lengths) => lengths.inner().capacity(), + Self::U16(lengths) => lengths.inner().capacity(), + Self::U32(lengths) => lengths.inner().capacity(), + } + } +} + +/// Validated physical RLE runs for `u16` values. +/// +/// The values and original-width lengths remain unexpanded. The constructor +/// verifies that every length is non-zero and that the runs cover exactly +/// `num_values` logical values. +#[derive(Clone, Debug)] +pub(crate) struct RleRuns { + values: ScalarBuffer, + lengths: RleRunLengths, + num_values: usize, + coalesced_runs: usize, +} + +impl RleRuns { + fn try_new( + values_buffer: LanceBuffer, + lengths_buffer: LanceBuffer, + run_length_width: RunLengthWidth, + num_values: u64, + ) -> Result { + let num_values = usize::try_from(num_values).map_err(|_| { + Error::invalid_input_source( + format!("RLE num_values does not fit in usize: {num_values}").into(), + ) + })?; + let type_size = std::mem::size_of::(); + if !values_buffer.len().is_multiple_of(type_size) { + return Err(Error::invalid_input_source( + format!( + "Invalid RLE u16 values buffer: {} bytes (not divisible by {})", + values_buffer.len(), + type_size + ) + .into(), + )); + } + + let values = values_buffer.borrow_to_typed_slice::(); + let lengths = RleRunLengths::try_new(lengths_buffer, run_length_width)?; + if values.len() != lengths.len() { + return Err(Error::invalid_input_source( + format!( + "Inconsistent RLE buffers: {} runs but {} length entries", + values.len(), + lengths.len() + ) + .into(), + )); + } + if values.is_empty() && num_values != 0 { + return Err(Error::invalid_input_source( + format!("Empty RLE buffers but expected {num_values} values").into(), + )); + } + + let mut decoded_values = 0usize; + let mut coalesced_runs = 0usize; + let mut previous_value = None; + for run in 0..values.len() { + let length = lengths.get(run); if length == 0 { - return Err(Error::InvalidInput { - location: location!(), - source: "RLE decoding encountered a zero run length".into(), - }); + return Err(Error::invalid_input_source( + "RLE decoding encountered a zero run length".into(), + )); + } + decoded_values = decoded_values.checked_add(length).ok_or_else(|| { + Error::invalid_input_source("RLE run length sum overflowed usize".into()) + })?; + if decoded_values > num_values { + return Err(Error::invalid_input_source( + format!( + "RLE decoding overflowed expected value count: produced at least {}, expected {}", + decoded_values, num_values + ) + .into(), + )); + } + if previous_value != Some(values[run]) { + coalesced_runs += 1; + previous_value = Some(values[run]); } + } + if decoded_values != num_values { + return Err(Error::invalid_input_source( + format!( + "RLE decoding produced {} values, expected {}", + decoded_values, num_values + ) + .into(), + )); + } + + Ok(Self { + values, + lengths, + num_values, + coalesced_runs, + }) + } + + pub(crate) fn num_values(&self) -> usize { + self.num_values + } + + pub(crate) fn num_runs(&self) -> usize { + self.values.len() + } + + pub(crate) fn coalesced_runs(&self) -> usize { + self.coalesced_runs + } + + pub(crate) fn owned_size(&self) -> usize { + std::mem::size_of_val(self.values.as_ref()) + self.lengths.owned_size() + } + + pub(crate) fn into_owned(self) -> Self { + Self { + values: ScalarBuffer::from(self.values.as_ref().to_vec()), + lengths: self.lengths.into_owned(), + num_values: self.num_values, + coalesced_runs: self.coalesced_runs, + } + } + + pub(crate) fn deep_size(&self) -> usize { + self.values.inner().capacity() + self.lengths.deep_size() + } + + pub(crate) fn value(&self, run: usize) -> u16 { + self.values[run] + } + + pub(crate) fn length(&self, run: usize) -> usize { + self.lengths.get(run) + } + + pub(crate) fn iter(&self) -> impl ExactSizeIterator + '_ { + (0..self.num_runs()).map(|run| (self.value(run), self.length(run))) + } +} + +impl RleDecompressor { + /// Decode and validate a block frame while preserving its physical runs. + pub(crate) fn decode_u16_runs(&self, data: LanceBuffer, num_values: u64) -> Result { + if self.bits_per_value != 16 { + return Err(Error::invalid_input_source( + format!( + "RLE level values must be 16 bits, got {}", + self.bits_per_value + ) + .into(), + )); + } + let (values_buffer, lengths_buffer) = parse_rle_block_frame(&data)?; + let (values_buffer, lengths_buffer) = + self.decode_child_buffers(values_buffer, lengths_buffer)?; + RleRuns::try_new( + values_buffer, + lengths_buffer, + self.run_length_width, + num_values, + ) + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use super::*; + use crate::compression::{ + DecompressionStrategy, DefaultDecompressionStrategy, create_rle_decompressor, + }; + use crate::data::DataBlock; + use crate::encodings::logical::primitive::miniblock::MAX_MINIBLOCK_VALUES; + use crate::encodings::physical::block::{CompressionConfig, CompressionScheme}; + use crate::{ + buffer::LanceBuffer, + compression::{BlockCompressor, BlockDecompressor}, + }; + use arrow_array::Int32Array; + use rstest::rstest; - let remaining = expected_value_count - decoded.len(); - let write_len = (length as usize).min(remaining); + fn compress_miniblock( + compressor: &dyn MiniBlockCompressor, + data: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + compressor.compress(MiniBlockCompressionContext::new(0, true, true), data) + } - decoded.resize(decoded.len() + write_len, *value); + fn expand_u16_runs(runs: &RleRuns) -> Vec { + let mut expanded = Vec::with_capacity(runs.num_values()); + for (value, length) in runs.iter() { + expanded.extend(std::iter::repeat_n(value, length)); } + expanded + } - if decoded.len() != expected_value_count { - return Err(Error::InvalidInput { - location: location!(), - source: format!( - "RLE decoding produced {} values, expected {}", - decoded.len(), - expected_value_count - ) - .into(), - }); - } + #[test] + fn decode_u16_runs_matches_eager() { + // Near-constant u16 levels (the all-null shape): a few long runs. + let mut levels: Vec = vec![1u16; 1000]; + levels.extend(std::iter::repeat_n(0u16, 500)); + levels.extend(std::iter::repeat_n(2u16, 300)); + let num_values = levels.len() as u64; - trace!( - "RLE decoded {} {} values", + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_slice(Arc::from(levels.clone())), + bits_per_value: 16, num_values, - std::any::type_name::() + block_info: BlockInfo::new(), + }); + let frame = BlockCompressor::compress(&RleEncoder::new(), block) + .unwrap() + .0 + .unwrap(); + + let eager = BlockDecompressor::decompress( + &RleDecompressor::new(16), + Some(frame.clone()), + num_values, + ) + .unwrap(); + let DataBlock::FixedWidth(eager) = eager else { + panic!("expected fixed-width block"); + }; + assert_eq!( + eager.data.borrow_to_typed_slice::().as_ref(), + levels.as_slice() ); - Ok(LanceBuffer::reinterpret_vec(decoded)) + + // Lazy run form preserves boundaries and expands identically. + let runs = RleDecompressor::new(16) + .decode_u16_runs(frame, num_values) + .unwrap(); + assert_eq!(runs.num_values(), num_values as usize); + // The encoder splits each value into <=255-length runs (4 + 2 + 2 = 8 + // on-disk runs here); the scan identifies 3 coalesced logical runs. + assert_eq!(runs.num_runs(), 8); + assert_eq!(runs.coalesced_runs(), 3); + assert_eq!(expand_u16_runs(&runs), levels); } -} -impl MiniBlockDecompressor for RleDecompressor { - fn decompress(&self, data: Vec, num_values: u64) -> Result { - self.decode_data(data, num_values) + #[test] + fn decode_u16_runs_empty() { + let mut empty_frame = Vec::new(); + empty_frame.extend_from_slice(&0u64.to_le_bytes()); + let runs = RleDecompressor::new(16) + .decode_u16_runs(LanceBuffer::from(empty_frame), 0) + .unwrap(); + assert_eq!(runs.num_values(), 0); + assert_eq!(runs.num_runs(), 0); + assert_eq!(runs.coalesced_runs(), 0); + + let error = RleDecompressor::new(16) + .decode_u16_runs(LanceBuffer::empty(), 0) + .unwrap_err(); + assert!(error.to_string().contains("Insufficient data size: 0")); } -} -impl BlockDecompressor for RleDecompressor { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { - // fetch the values_size - if data.len() < 8 { - return Err(Error::InvalidInput { - location: location!(), - source: format!("Insufficient data size: {}", data.len()).into(), - }); + #[test] + fn legacy_block_rle_infers_value_count_without_materializing() { + let num_values = u64::from(u16::MAX) + 8; + let full_runs = num_values / u64::from(u8::MAX); + let remainder = num_values % u64::from(u8::MAX); + let num_runs = full_runs + u64::from(remainder != 0); + let mut frame = Vec::new(); + frame.extend_from_slice(&(num_runs * 2).to_le_bytes()); + frame.extend(std::iter::repeat_n(7_u16, num_runs as usize).flat_map(u16::to_le_bytes)); + frame.extend(std::iter::repeat_n(u8::MAX, full_runs as usize)); + if remainder != 0 { + frame.push(remainder as u8); } - let values_size_bytes: [u8; 8] = - data[..8].try_into().expect("slice length already checked"); - let values_size: u64 = u64::from_le_bytes(values_size_bytes); + let inferred_num_values = RleDecompressor::new(16) + .infer_num_values(&LanceBuffer::from(frame)) + .unwrap(); + assert_eq!(inferred_num_values, Some(num_values)); + } - // parse values - let values_start: usize = 8; - let values_size: usize = values_size.try_into().map_err(|_| Error::InvalidInput { - location: location!(), - source: format!("Invalid values buffer size: {}", values_size).into(), - })?; - let lengths_start = - values_start - .checked_add(values_size) - .ok_or_else(|| Error::InvalidInput { - location: location!(), - source: "Invalid RLE values buffer size".into(), - })?; - - if data.len() < lengths_start { - return Err(Error::InvalidInput { - location: location!(), - source: format!("Insufficient data size: {}", data.len()).into(), - }); - } + #[test] + fn newer_block_rle_does_not_infer_untrusted_run_sum() { + let mut frame = Vec::new(); + frame.extend_from_slice(&2_u64.to_le_bytes()); + frame.extend_from_slice(&7_u16.to_le_bytes()); + frame.extend_from_slice(&u32::MAX.to_le_bytes()); + let frame = LanceBuffer::from(frame); + let decompressor = RleDecompressor::with_run_length_width(16, RunLengthWidth::U32); + + assert_eq!(decompressor.infer_num_values(&frame).unwrap(), None); + let error = BlockDecompressor::decompress(&decompressor, Some(frame), u64::from(u16::MAX)) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("RLE decoding overflowed expected value count") + ); + } + + #[rstest] + #[case::zero(0, 1, "zero run length")] + #[case::underflow(1, 2, "produced 1 values, expected 2")] + #[case::overflow(2, 1, "overflowed expected value count")] + #[case::nonempty_for_empty(1, 0, "overflowed expected value count")] + fn decode_u16_runs_rejects_invalid_coverage( + #[case] run_length: u8, + #[case] num_values: u64, + #[case] expected_message: &str, + ) { + let mut frame = Vec::new(); + frame.extend_from_slice(&2u64.to_le_bytes()); + frame.extend_from_slice(&7u16.to_le_bytes()); + frame.push(run_length); + + let error = RleDecompressor::new(16) + .decode_u16_runs(LanceBuffer::from(frame), num_values) + .unwrap_err(); + assert!(matches!(error, lance_core::Error::InvalidInput { .. })); + assert!(error.to_string().contains(expected_message)); + } - let values_buffer = data.slice_with_length(values_start, values_size); - let lengths_buffer = data.slice_with_length(lengths_start, data.len() - lengths_start); + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn decode_u16_runs_supports_compressed_values_child() { + let levels: Vec = (0..1024) + .flat_map(|run| std::iter::repeat_n((run % 8) as u16, 4)) + .collect(); + let num_values = levels.len() as u64; + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_slice(Arc::from(levels.clone())), + bits_per_value: 16, + num_values, + block_info: BlockInfo::new(), + }); + let frame = BlockCompressor::compress(&RleEncoder::new(), block) + .unwrap() + .0 + .unwrap(); + let (values, lengths) = parse_rle_block_frame(&frame).unwrap(); - self.decode_data(vec![values_buffer, lengths_buffer], num_values) + let compression = test_general_compression(); + let compressor = GeneralBufferCompressor::get_compressor(compression).unwrap(); + let mut compressed_values = Vec::new(); + compressor + .compress(values.as_ref(), &mut compressed_values) + .unwrap(); + let mut compressed_frame = Vec::new(); + compressed_frame.extend_from_slice(&(compressed_values.len() as u64).to_le_bytes()); + compressed_frame.extend_from_slice(&compressed_values); + compressed_frame.extend_from_slice(lengths.as_ref()); + + let encoding = ProtobufUtils21::rle( + ProtobufUtils21::wrapped(compression, ProtobufUtils21::flat(16, None)).unwrap(), + ProtobufUtils21::flat(8, None), + ); + let decompressor = create_rle_decompressor( + expect_rle(&encoding), + &DefaultDecompressionStrategy::default(), + ) + .unwrap(); + let runs = decompressor + .decode_u16_runs(LanceBuffer::from(compressed_frame), num_values) + .unwrap(); + assert_eq!(expand_u16_runs(&runs), levels); + } + + #[test] + fn decode_u16_runs_counts_coalesced_runs() { + // A logically constant page is emitted as ceil(N / 255) equal-valued + // runs (the encoder caps run lengths at 255); the validated view records + // that they can collapse to a single logical run. + let num_values = 5000u64; + let constant: Vec = vec![7u16; num_values as usize]; + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_slice(Arc::from(constant)), + bits_per_value: 16, + num_values, + block_info: BlockInfo::new(), + }); + let frame = BlockCompressor::compress(&RleEncoder::new(), block) + .unwrap() + .0 + .unwrap(); + let runs = RleDecompressor::new(16) + .decode_u16_runs(frame, num_values) + .unwrap(); + assert_eq!( + runs.num_runs(), + num_values.div_ceil(u8::MAX as u64) as usize + ); + assert_eq!(runs.coalesced_runs(), 1); + assert_eq!(expand_u16_runs(&runs), vec![7u16; num_values as usize]); + + // Distinct adjacent values must not be merged: alternating single-value + // runs stay separate and still expand to the original. + let alternating: Vec = (0..200u16).map(|i| i % 2).collect(); + let n = alternating.len() as u64; + let block = DataBlock::FixedWidth(FixedWidthDataBlock { + data: LanceBuffer::reinterpret_slice(Arc::from(alternating.clone())), + bits_per_value: 16, + num_values: n, + block_info: BlockInfo::new(), + }); + let frame = BlockCompressor::compress(&RleEncoder::new(), block) + .unwrap() + .0 + .unwrap(); + let runs = RleDecompressor::new(16).decode_u16_runs(frame, n).unwrap(); + assert_eq!( + runs.coalesced_runs() as u64, + n, + "no two adjacent values are equal" + ); + assert_eq!(expand_u16_runs(&runs), alternating); } -} -#[cfg(test)] -mod tests { - use super::*; - use crate::data::DataBlock; - use crate::encodings::logical::primitive::miniblock::MAX_MINIBLOCK_VALUES; - use crate::{buffer::LanceBuffer, compression::BlockDecompressor}; - use arrow_array::Int32Array; // ========== Core Functionality Tests ========== #[test] @@ -628,7 +2204,7 @@ mod tests { let array = Int32Array::from(vec![1, 1, 1, 2, 2, 3, 3, 3, 3]); let data_block = DataBlock::from_array(array); - let (compressed, _) = MiniBlockCompressor::compress(&encoder, data_block).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, data_block).unwrap(); assert_eq!(compressed.num_values, 9); assert_eq!(compressed.chunks.len(), 1); @@ -649,14 +2225,381 @@ mod tests { data.extend(&[100i32; 300]); // Will be split into 255+45 let array = Int32Array::from(data); - let (compressed, _) = - MiniBlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); // Should have 6 runs total (4 for first value, 2 for second) let lengths_buffer = &compressed.data[1]; assert_eq!(lengths_buffer.len(), 6); } + #[test] + fn test_rle_v2_u16_miniblock_encoding() { + let encoder = RleEncoder::with_run_length_width(RunLengthWidth::U16); + + let data = vec![42i32; 1000]; + let array = Int32Array::from(data); + let (compressed, encoding) = + compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); + + assert_eq!(compressed.data[0].len(), 4); + assert_eq!(compressed.data[1].len(), 2); + assert_eq!(compressed.data[1].as_ref(), &1000u16.to_le_bytes()); + + let rle = match encoding.compression.as_ref().unwrap() { + crate::format::pb21::compressive_encoding::Compression::Rle(rle) => rle, + other => panic!("expected RLE encoding, got {other:?}"), + }; + let run_lengths = rle.run_lengths.as_ref().unwrap(); + let flat = match run_lengths.compression.as_ref().unwrap() { + crate::format::pb21::compressive_encoding::Compression::Flat(flat) => flat, + other => panic!("expected flat run lengths, got {other:?}"), + }; + assert_eq!(flat.bits_per_value, 16); + + let decompressor = RleDecompressor::with_run_length_width(32, RunLengthWidth::U16); + let decompressed = MiniBlockDecompressor::decompress( + &decompressor, + compressed.data, + compressed.num_values, + ) + .unwrap(); + match decompressed { + DataBlock::FixedWidth(block) => { + let values = block.data.borrow_to_typed_slice::(); + assert_eq!(values.as_ref(), vec![42i32; 1000]); + } + _ => panic!("Expected FixedWidth block"), + } + } + + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_rle_miniblock_compressed_values_child() { + let compression = test_general_compression(); + let encoder = + RleEncoder::with_child_encoding(RunLengthWidth::U8, Some(compression), None, false); + let array = Int32Array::from(repeating_runs(1024, 4)); + let (compressed, encoding) = + compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); + + let rle = expect_rle(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + crate::format::pb21::compressive_encoding::Compression::General(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + crate::format::pb21::compressive_encoding::Compression::Flat(_) + )); + + let decompressor = DefaultDecompressionStrategy::default() + .create_miniblock_decompressor(&encoding, &DefaultDecompressionStrategy::default()) + .unwrap(); + let decoded = + MiniBlockDecompressor::decompress(decompressor.as_ref(), compressed.data, 1024 * 4) + .unwrap(); + assert_decoded_i32_eq(decoded, &repeating_runs(1024, 4)); + } + + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_rle_miniblock_compressed_run_lengths_child() { + let compression = test_general_compression(); + let encoder = + RleEncoder::with_child_encoding(RunLengthWidth::U8, None, Some(compression), false); + let expected = repeating_runs(1024, 4); + let (compressed, encoding) = compress_miniblock( + &encoder, + DataBlock::from_array(Int32Array::from(expected.clone())), + ) + .unwrap(); + + let rle = expect_rle(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + crate::format::pb21::compressive_encoding::Compression::Flat(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + crate::format::pb21::compressive_encoding::Compression::General(_) + )); + + let decompressor = DefaultDecompressionStrategy::default() + .create_miniblock_decompressor(&encoding, &DefaultDecompressionStrategy::default()) + .unwrap(); + let decoded = + MiniBlockDecompressor::decompress(decompressor.as_ref(), compressed.data, 1024 * 4) + .unwrap(); + assert_decoded_i32_eq(decoded, &expected); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_miniblock_bitpacked_run_lengths_child() { + use crate::encodings::physical::bitpacking::OutOfLineBitpacking; + + let expected = repeating_runs(1024, 4); + let (compressed, _) = compress_miniblock( + &RleEncoder::new(), + DataBlock::from_array(Int32Array::from(expected.clone())), + ) + .unwrap(); + let run_lengths = compressed.data[1].clone(); + let num_runs = run_lengths.len() as u64; + let run_lengths_block = DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value: 8, + data: run_lengths, + num_values: num_runs, + block_info: BlockInfo::default(), + }); + let bitpacked_run_lengths = + BlockCompressor::compress(&OutOfLineBitpacking::new(3, 8), run_lengths_block) + .unwrap() + .0 + .unwrap(); + let encoding = ProtobufUtils21::rle( + ProtobufUtils21::flat(32, None), + ProtobufUtils21::out_of_line_bitpacking(8, ProtobufUtils21::flat(3, None)), + ); + + let decompressor = DefaultDecompressionStrategy::default() + .create_miniblock_decompressor(&encoding, &DefaultDecompressionStrategy::default()) + .unwrap(); + let decoded = MiniBlockDecompressor::decompress( + decompressor.as_ref(), + vec![compressed.data[0].clone(), bitpacked_run_lengths], + expected.len() as u64, + ) + .unwrap(); + assert_decoded_i32_eq(decoded, &expected); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_rejects_two_count_dependent_child_encodings() { + let encoding = ProtobufUtils21::rle( + ProtobufUtils21::out_of_line_bitpacking(32, ProtobufUtils21::flat(3, None)), + ProtobufUtils21::out_of_line_bitpacking(8, ProtobufUtils21::flat(3, None)), + ); + + let err = DefaultDecompressionStrategy::default() + .create_miniblock_decompressor(&encoding, &DefaultDecompressionStrategy::default()) + .unwrap_err(); + assert!( + err.to_string() + .contains("cannot both require the run count") + ); + } + + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_general_compression() -> CompressionConfig { + if cfg!(feature = "zstd") { + CompressionConfig::new(CompressionScheme::Zstd, Some(3)) + } else { + CompressionConfig::new(CompressionScheme::Lz4, None) + } + } + + fn repeating_runs(num_runs: usize, run_length: usize) -> Vec { + let mut values = Vec::with_capacity(num_runs * run_length); + for run in 0..num_runs { + values.extend(std::iter::repeat_n((run % 8) as i32, run_length)); + } + values + } + + fn expect_rle(encoding: &CompressiveEncoding) -> &crate::format::pb21::Rle { + match encoding.compression.as_ref().unwrap() { + crate::format::pb21::compressive_encoding::Compression::Rle(rle) => rle, + other => panic!("expected RLE encoding, got {other:?}"), + } + } + + fn assert_decoded_i32_eq(decoded: DataBlock, expected: &[i32]) { + match decoded { + DataBlock::FixedWidth(block) => { + let values = block.data.borrow_to_typed_slice::(); + assert_eq!(values.as_ref(), expected); + } + _ => panic!("Expected FixedWidth block"), + } + } + + #[test] + #[cfg(any(feature = "lz4", feature = "zstd"))] + fn test_rle_miniblock_compressed_children_multiple_chunks() { + let compression = test_general_compression(); + let encoder = RleEncoder::with_child_encoding( + RunLengthWidth::U8, + Some(compression), + Some(compression), + false, + ); + let expected = repeating_runs(8192, 4); + let (compressed, encoding) = compress_miniblock( + &encoder, + DataBlock::from_array(Int32Array::from(expected.clone())), + ) + .unwrap(); + + assert!(compressed.chunks.len() > 1); + let rle = expect_rle(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + crate::format::pb21::compressive_encoding::Compression::General(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + crate::format::pb21::compressive_encoding::Compression::General(_) + )); + + let decoded = decompress_i32_chunks(&compressed, &encoding); + assert_eq!(decoded, expected); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_miniblock_bitpacks_values_child_when_smaller() { + let encoder = RleEncoder::with_child_encoding(RunLengthWidth::U8, None, None, true); + let expected = monotonic_runs(2048, 4); + let (compressed, encoding) = compress_miniblock( + &encoder, + DataBlock::from_array(Int32Array::from(expected.clone())), + ) + .unwrap(); + + let rle = expect_rle(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + crate::format::pb21::compressive_encoding::Compression::OutOfLineBitpacking(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + crate::format::pb21::compressive_encoding::Compression::Flat(_) + )); + + let decoded = decompress_i32_chunks(&compressed, &encoding); + assert_eq!(decoded, expected); + } + + #[test] + #[cfg(feature = "bitpacking")] + fn test_rle_miniblock_bitpacks_run_lengths_when_values_do_not_shrink() { + let encoder = RleEncoder::with_child_encoding(RunLengthWidth::U8, None, None, true); + let expected = high_entropy_runs(2048, 4); + let (compressed, encoding) = compress_miniblock( + &encoder, + DataBlock::from_array(Int32Array::from(expected.clone())), + ) + .unwrap(); + + let rle = expect_rle(&encoding); + assert!(matches!( + rle.values.as_ref().unwrap().compression.as_ref().unwrap(), + crate::format::pb21::compressive_encoding::Compression::Flat(_) + )); + assert!(matches!( + rle.run_lengths + .as_ref() + .unwrap() + .compression + .as_ref() + .unwrap(), + crate::format::pb21::compressive_encoding::Compression::OutOfLineBitpacking(_) + )); + + let decoded = decompress_i32_chunks(&compressed, &encoding); + assert_eq!(decoded, expected); + } + + fn decompress_i32_chunks( + compressed: &MiniBlockCompressed, + encoding: &CompressiveEncoding, + ) -> Vec { + let strategy = DefaultDecompressionStrategy::default(); + let decompressor = strategy + .create_miniblock_decompressor(encoding, &strategy) + .unwrap(); + let mut offsets = vec![0usize; compressed.data.len()]; + let mut values_processed = 0u64; + let mut decoded_values = Vec::new(); + + for chunk in &compressed.chunks { + let chunk_values = chunk.num_values(values_processed, compressed.num_values); + let mut chunk_buffers = Vec::with_capacity(chunk.buffer_sizes.len()); + for (idx, size) in chunk.buffer_sizes.iter().enumerate() { + let size = *size as usize; + chunk_buffers.push(compressed.data[idx].slice_with_length(offsets[idx], size)); + offsets[idx] += size; + } + + let decoded = decompressor + .decompress(chunk_buffers, chunk_values) + .unwrap(); + match decoded { + DataBlock::FixedWidth(block) => { + let values = block.data.borrow_to_typed_slice::(); + decoded_values.extend_from_slice(values.as_ref()); + } + _ => panic!("Expected FixedWidth block"), + } + values_processed += chunk_values; + } + + assert_eq!(values_processed, compressed.num_values); + decoded_values + } + + #[cfg(feature = "bitpacking")] + fn monotonic_runs(num_runs: usize, run_length: usize) -> Vec { + let mut values = Vec::with_capacity(num_runs * run_length); + for run in 0..num_runs { + values.extend(std::iter::repeat_n(run as i32, run_length)); + } + values + } + + #[cfg(feature = "bitpacking")] + fn high_entropy_runs(num_runs: usize, run_length: usize) -> Vec { + let mut values = Vec::with_capacity(num_runs * run_length); + let mut state = 7u64; + for _ in 0..num_runs { + state = state + .wrapping_mul(6364136223846793005) + .wrapping_add(1442695040888963407); + values.extend(std::iter::repeat_n((state >> 32) as i32, run_length)); + } + values + } + + #[test] + fn test_select_run_length_width_prefers_u16_for_long_runs() { + let mut entries = [0u64; 3]; + accumulate_run_length_entries(300, Some(*MAX_MINIBLOCK_VALUES), &mut entries); + let (width, _) = select_run_length_width_from_entries(&entries, 32).unwrap(); + assert_eq!(width, RunLengthWidth::U16); + } + // ========== Round-trip Tests for Different Types ========== #[test] @@ -692,7 +2635,7 @@ mod tests { block_info: BlockInfo::default(), }); - let (compressed, _) = MiniBlockCompressor::compress(&encoder, block).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, block).unwrap(); let decompressor = RleDecompressor::new(bits_per_value); let decompressed = MiniBlockDecompressor::decompress( &decompressor, @@ -726,7 +2669,7 @@ mod tests { let array = Int32Array::from(data); let (compressed, _) = - MiniBlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); // Verify all non-last chunks have power-of-2 values for (i, chunk) in compressed.chunks.iter().enumerate() { @@ -734,7 +2677,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); } @@ -742,8 +2685,73 @@ mod tests { } } + #[rstest] + #[case::u8_lengths(RunLengthWidth::U8)] + #[case::u16_lengths(RunLengthWidth::U16)] + #[case::u32_lengths(RunLengthWidth::U32)] + fn test_miniblock_chunk_counts_match_encoded_runs(#[case] run_length_width: RunLengthWidth) { + // This pattern crosses the 2,048-value boundary in the middle of a two-value run. + let levels = (0..4098) + .map(|index| if index % 3 == 0 { 1u16 } else { 0u16 }) + .collect::>(); + let num_values = levels.len() as u64; + let encoder = RleEncoder::with_run_length_width(run_length_width); + let (buffers, chunks) = encoder + .encode_data( + &LanceBuffer::reinterpret_vec(levels), + num_values, + u16::BITS as u64, + ) + .unwrap(); + + assert_eq!(buffers.len(), 2); + let bytes_per_length = run_length_width.bytes_per_value(); + let mut values_offset = 0usize; + let mut lengths_offset = 0usize; + let mut values_processed = 0u64; + + for chunk in &chunks { + let values_size = chunk.buffer_sizes[0] as usize; + let lengths_size = chunk.buffer_sizes[1] as usize; + let lengths_end = lengths_offset + lengths_size; + let chunk_lengths = &buffers[1].as_ref()[lengths_offset..lengths_end]; + let length_chunks = chunk_lengths.chunks_exact(bytes_per_length); + assert!(length_chunks.remainder().is_empty()); + let num_runs = length_chunks.len(); + let encoded_values = length_chunks + .map(|bytes| run_length_width.read_length(bytes)) + .sum::(); + let declared_values = chunk.num_values(values_processed, num_values); + + assert_eq!(values_size, num_runs * size_of::()); + assert_eq!(encoded_values, declared_values); + + values_offset += values_size; + lengths_offset = lengths_end; + values_processed += declared_values; + } + + assert_eq!(values_processed, num_values); + assert_eq!(values_offset, buffers[0].len()); + assert_eq!(lengths_offset, buffers[1].len()); + } + // ========== Error Handling Tests ========== + #[test] + fn test_encoder_rejects_zero_progress() { + let error = RleEncoder::new() + .encode_data(&LanceBuffer::empty(), 1, u16::BITS as u64) + .unwrap_err(); + + assert!( + matches!(&error, Error::Internal { .. }), + "expected internal error, got: {error:?}" + ); + assert!(error.to_string().contains("made no progress")); + assert!(error.to_string().contains("values_remaining=1")); + } + #[test] fn test_invalid_buffer_count() { let decompressor = RleDecompressor::new(32); @@ -753,10 +2761,12 @@ mod tests { 10, ); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("expects exactly 2 buffers")); + assert!( + result + .unwrap_err() + .to_string() + .contains("expects exactly 2 buffers") + ); } #[test] @@ -766,10 +2776,125 @@ mod tests { let lengths = LanceBuffer::from(vec![5, 10]); // 2 lengths - mismatch! let result = MiniBlockDecompressor::decompress(&decompressor, vec![values, lengths], 15); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Inconsistent RLE buffers")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Inconsistent RLE buffers") + ); + } + + #[test] + fn test_u16_length_buffer_must_be_aligned() { + let decompressor = RleDecompressor::with_run_length_width(32, RunLengthWidth::U16); + let values = LanceBuffer::from(vec![1, 0, 0, 0]); + let lengths = LanceBuffer::from(vec![5]); + let result = MiniBlockDecompressor::decompress(&decompressor, vec![values, lengths], 5); + assert!(matches!(&result, Err(Error::InvalidInput { .. }))); + assert!( + result + .unwrap_err() + .to_string() + .contains("not divisible by 2") + ); + } + + #[test] + fn test_rle_rejects_underflow_and_zero_lengths_and_clamps_overflow() { + let decompressor = RleDecompressor::with_run_length_width(32, RunLengthWidth::U16); + let value = LanceBuffer::from(1i32.to_le_bytes().to_vec()); + + let underflow = MiniBlockDecompressor::decompress( + &decompressor, + vec![ + value.clone(), + LanceBuffer::from(4u16.to_le_bytes().to_vec()), + ], + 5, + ) + .unwrap_err(); + assert!(underflow.to_string().contains("produced 4 values")); + + let overflow = MiniBlockDecompressor::decompress( + &decompressor, + vec![ + value.clone(), + LanceBuffer::from(6u16.to_le_bytes().to_vec()), + ], + 5, + ) + .unwrap(); + match overflow { + DataBlock::FixedWidth(block) => { + assert_eq!(block.num_values, 5); + let decoded = block.data.borrow_to_typed_slice::(); + assert_eq!(decoded.as_ref(), &[1i32; 5]); + } + _ => panic!("Expected FixedWidth block"), + } + + let zero = MiniBlockDecompressor::decompress( + &decompressor, + vec![value, LanceBuffer::from(0u16.to_le_bytes().to_vec())], + 5, + ) + .unwrap_err(); + assert!(zero.to_string().contains("zero run length")); + } + + #[test] + fn test_block_rle_rejects_overflow() { + // Block payloads have no chunk boundaries, so run lengths summing past + // num_values can only be corruption and must stay a hard error. + let decompressor = RleDecompressor::with_run_length_width(32, RunLengthWidth::U16); + let values = 1i32.to_le_bytes(); + let lengths = 6u16.to_le_bytes(); + let mut payload = Vec::new(); + payload.extend_from_slice(&(values.len() as u64).to_le_bytes()); + payload.extend_from_slice(&values); + payload.extend_from_slice(&lengths); + + let error = + BlockDecompressor::decompress(&decompressor, Some(LanceBuffer::from(payload)), 5) + .unwrap_err(); + assert!(matches!(&error, Error::InvalidInput { .. })); + assert!( + error + .to_string() + .contains("overflowed expected value count") + ); + } + + #[test] + fn test_rle_truncates_legacy_chunk_boundary_overflow() { + // Legacy encoders emitted chunks declaring 2048 values whose final run crossed + // the checkpoint boundary (e.g. run lengths summing to 2080); the excess values + // are duplicated at the start of the next chunk and must be ignored here. + let decompressor = RleDecompressor::with_run_length_width(32, RunLengthWidth::U16); + let mut values = Vec::new(); + values.extend_from_slice(&7i32.to_le_bytes()); + values.extend_from_slice(&8i32.to_le_bytes()); + let mut lengths = Vec::new(); + lengths.extend_from_slice(&2000u16.to_le_bytes()); + lengths.extend_from_slice(&80u16.to_le_bytes()); + + let decoded = MiniBlockDecompressor::decompress( + &decompressor, + vec![LanceBuffer::from(values), LanceBuffer::from(lengths)], + 2048, + ) + .unwrap(); + match decoded { + DataBlock::FixedWidth(block) => { + assert_eq!(block.num_values, 2048); + let decoded = block.data.borrow_to_typed_slice::(); + let decoded = decoded.as_ref(); + assert_eq!(decoded.len(), 2048); + assert!(decoded[..2000].iter().all(|&v| v == 7)); + assert!(decoded[2000..].iter().all(|&v| v == 8)); + } + _ => panic!("Expected FixedWidth block"), + } } #[test] @@ -784,7 +2909,7 @@ mod tests { block_info: BlockInfo::default(), }); - let (compressed, _) = MiniBlockCompressor::compress(&encoder, empty_block).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, empty_block).unwrap(); assert_eq!(compressed.num_values, 0); assert!(compressed.data.is_empty()); @@ -818,8 +2943,7 @@ mod tests { data.extend(vec![777i32; 2000]); let array = Int32Array::from(data.clone()); - let (compressed, _) = - MiniBlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); // Manually decompress all chunks let mut reconstructed = Vec::new(); @@ -932,7 +3056,7 @@ mod tests { // Compress the data let array = Int32Array::from(data.clone()); let (compressed, _) = - MiniBlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + compress_miniblock(&encoder, DataBlock::from_array(array)).unwrap(); // Decompress and verify match MiniBlockDecompressor::decompress( @@ -1002,7 +3126,7 @@ mod tests { block_info: BlockInfo::default(), }); - let (compressed, _) = MiniBlockCompressor::compress(&encoder, block).unwrap(); + let (compressed, _) = compress_miniblock(&encoder, block).unwrap(); // Debug first few chunks for (i, chunk) in compressed.chunks.iter().take(5).enumerate() { @@ -1051,8 +3175,7 @@ mod tests { #[test_log::test(tokio::test)] async fn test_rle_encoding_verification() { - use crate::testing::{check_round_trip_encoding_of_data, TestCases}; - use crate::version::LanceFileVersion; + use crate::testing::{TestCases, check_round_trip_encoding_of_data}; use arrow_array::{Array, Int32Array}; use lance_datagen::{ArrayGenerator, RowCount}; use std::collections::HashMap; @@ -1060,7 +3183,7 @@ mod tests { let test_cases = TestCases::default() .with_expected_encoding("rle") - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); // Test both explicit metadata and automatic selection // 1. Test with explicit RLE threshold metadata (also disable BSS) @@ -1110,6 +3233,30 @@ mod tests { ); // 20% variety let arr = Arc::new(Int32Array::from(values)) as Arc; check_round_trip_encoding_of_data(vec![arr], &test_cases, metadata).await; + + #[cfg(any(feature = "lz4", feature = "zstd"))] + { + let mut metadata = HashMap::new(); + metadata.insert( + "lance-encoding:rle-threshold".to_string(), + "0.8".to_string(), + ); + metadata.insert("lance-encoding:bss".to_string(), "off".to_string()); + metadata.insert( + "lance-encoding:compression".to_string(), + if cfg!(feature = "zstd") { + "zstd".to_string() + } else { + "lz4".to_string() + }, + ); + let mut values = Vec::with_capacity(2048 * 4); + for run in 0..2048 { + values.extend(std::iter::repeat_n(i32::MIN + (run % 8), 4)); + } + let arr = Arc::new(Int32Array::from(values)) as Arc; + check_round_trip_encoding_of_data(vec![arr], &test_cases, metadata).await; + } } /// Generator that produces repetitive patterns suitable for RLE @@ -1160,24 +3307,31 @@ mod tests { let mut data = Vec::new(); data.extend_from_slice(&u64::MAX.to_le_bytes()); - let result = BlockDecompressor::decompress(&decompressor, LanceBuffer::from(data), 1); + let result = BlockDecompressor::decompress(&decompressor, Some(LanceBuffer::from(data)), 1); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Invalid RLE values buffer size")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Invalid RLE values buffer size") + ); } #[test] fn test_block_decompressor_too_small() { let decompressor = RleDecompressor::new(32); - let result = - BlockDecompressor::decompress(&decompressor, LanceBuffer::from(vec![1, 2, 3]), 10); + let result = BlockDecompressor::decompress( + &decompressor, + Some(LanceBuffer::from(vec![1, 2, 3])), + 10, + ); assert!(result.is_err()); - assert!(result - .unwrap_err() - .to_string() - .contains("Insufficient data size: 3")); + assert!( + result + .unwrap_err() + .to_string() + .contains("Insufficient data size: 3") + ); } #[test] @@ -1186,7 +3340,10 @@ mod tests { let data = vec![1i32, 1, 1]; let array = Int32Array::from(data); - let compressed = BlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + let compressed = BlockCompressor::compress(&encoder, DataBlock::from_array(array)) + .unwrap() + .0 + .unwrap(); // Verify header format: first 8 bytes should be values_size as u64 assert!(compressed.len() >= 8); @@ -1210,7 +3367,7 @@ mod tests { let array = Int32Array::from(data.clone()); let data_block = DataBlock::from_array(array); - let compressed = BlockCompressor::compress(&encoder, data_block).unwrap(); + let compressed = BlockCompressor::compress(&encoder, data_block).unwrap().0; let decompressed = BlockDecompressor::decompress(&decompressor, compressed, data.len() as u64).unwrap(); @@ -1239,7 +3396,9 @@ mod tests { assert_eq!(total_values, 10000); let array = Int32Array::from(data.clone()); - let compressed = BlockCompressor::compress(&encoder, DataBlock::from_array(array)).unwrap(); + let compressed = BlockCompressor::compress(&encoder, DataBlock::from_array(array)) + .unwrap() + .0; let decompressed = BlockDecompressor::decompress(&decompressor, compressed, total_values as u64).unwrap(); diff --git a/rust/lance-encoding/src/encodings/physical/value.rs b/rust/lance-encoding/src/encodings/physical/value.rs index a0ea0c5ba2d..0dafa478f2d 100644 --- a/rust/lance-encoding/src/encodings/physical/value.rs +++ b/rust/lance-encoding/src/encodings/physical/value.rs @@ -1,24 +1,25 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow_buffer::{bit_util, BooleanBufferBuilder}; -use snafu::location; +use arrow_buffer::{BooleanBufferBuilder, bit_util}; use crate::buffer::LanceBuffer; use crate::compression::{ BlockCompressor, BlockDecompressor, FixedPerValueDecompressor, MiniBlockDecompressor, + require_block_payload, }; use crate::data::{ - BlockInfo, DataBlock, FixedSizeListBlock, FixedWidthDataBlock, NullableDataBlock, + AllNullDataBlock, BlockInfo, DataBlock, FixedSizeListBlock, FixedWidthDataBlock, + NullableDataBlock, }; use crate::encodings::logical::primitive::fullzip::{PerValueCompressor, PerValueDataBlock}; use crate::encodings::logical::primitive::miniblock::{ - MiniBlockChunk, MiniBlockCompressed, MiniBlockCompressor, MAX_MINIBLOCK_BYTES, - MAX_MINIBLOCK_VALUES, + MAX_MINIBLOCK_BYTES, MAX_MINIBLOCK_VALUES, MiniBlockChunk, MiniBlockCompressed, + MiniBlockCompressionContext, MiniBlockCompressor, }; +use crate::format::ProtobufUtils21; use crate::format::pb21::compressive_encoding::Compression; use crate::format::pb21::{self, CompressiveEncoding}; -use crate::format::ProtobufUtils21; use lance_core::{Error, Result}; @@ -28,7 +29,7 @@ pub struct ValueEncoder {} impl ValueEncoder { /// Use the largest chunk we can smaller than 4KiB - fn find_log_vals_per_chunk(bytes_per_word: u64, values_per_word: u64) -> (u64, u64) { + fn find_log_vals_per_chunk(bytes_per_word: u64, values_per_word: u64) -> Result<(u64, u64)> { let mut size_bytes = 2 * bytes_per_word; let (mut log_num_vals, mut num_vals) = match values_per_word { 1 => (1, 2), @@ -36,19 +37,25 @@ impl ValueEncoder { _ => unreachable!(), }; - // 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); + if size_bytes >= MAX_MINIBLOCK_BYTES { + let num_values = 2 * values_per_word; + return Err(Error::invalid_input(format!( + "Value is too wide for miniblock encoding: {} values require {} bytes but a \ + miniblock chunk is limited to {} bytes.", + num_values, 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; } - (log_num_vals, num_vals) + Ok((log_num_vals, num_vals)) } - fn chunk_data(data: FixedWidthDataBlock) -> MiniBlockCompressed { + fn chunk_data(data: FixedWidthDataBlock) -> Result { // Usually there are X bytes per value. However, when working with boolean // or FSL we might have some number of bits per value that isn't // divisible by 8. In this case, to avoid chunking in the middle of a byte @@ -61,7 +68,7 @@ impl ValueEncoder { // Aim for 4KiB chunks let (log_vals_per_chunk, vals_per_chunk) = - Self::find_log_vals_per_chunk(bytes_per_word, values_per_word); + Self::find_log_vals_per_chunk(bytes_per_word, values_per_word)?; let num_chunks = bit_util::ceil(data.num_values as usize, vals_per_chunk as usize); debug_assert_eq!(vals_per_chunk % values_per_word, 0); let bytes_per_chunk = bytes_per_word * (vals_per_chunk / values_per_word); @@ -100,11 +107,11 @@ impl ValueEncoder { debug_assert_eq!(chunks.len(), num_chunks); - MiniBlockCompressed { + Ok(MiniBlockCompressed { chunks, data: vec![data_buffer], num_values: data.num_values, - } + }) } } @@ -178,7 +185,7 @@ impl ValueEncoder { data: FixedWidthDataBlock, layers: Vec, num_rows: u64, - ) -> (MiniBlockCompressed, CompressiveEncoding) { + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { // Count size to calculate rows per chunk let mut ceil_bytes_validity = 0; let mut cum_dim = 1; @@ -199,7 +206,7 @@ impl ValueEncoder { }; let est_bytes_per_word = (ceil_bytes_validity * vals_per_word) + cum_bytes_per_word; let (log_rows_per_chunk, rows_per_chunk) = - Self::find_log_vals_per_chunk(est_bytes_per_word, vals_per_word); + Self::find_log_vals_per_chunk(est_bytes_per_word, vals_per_word)?; let num_chunks = num_rows.div_ceil(rows_per_chunk) as usize; @@ -259,17 +266,17 @@ impl ValueEncoder { .chain(std::iter::once(data.data)) .collect::>(); - ( + Ok(( MiniBlockCompressed { chunks, data: buffers, num_values: num_rows, }, encoding, - ) + )) } - fn miniblock_fsl(data: DataBlock) -> (MiniBlockCompressed, CompressiveEncoding) { + fn miniblock_fsl(data: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { let num_rows = data.num_values(); let fsl = data.as_fixed_size_list().unwrap(); let mut layers = Vec::new(); @@ -316,17 +323,18 @@ impl ValueEncoder { fn fsl_to_encoding(fsl: &FixedSizeListBlock) -> CompressiveEncoding { let mut inner = fsl.child.as_ref(); let mut has_validity = false; - inner = match inner { - DataBlock::Nullable(nullable) => { - has_validity = true; - nullable.data.as_ref() - } + if let DataBlock::Nullable(nullable) = inner { + has_validity = true; + inner = nullable.data.as_ref(); + } + let inner_encoding = match inner { + // All inner values are null. Reserve validity bits (has_validity=true) so that + // the decoder knows one validity byte per cum_dim items is stored per row. + // constant(None) signals that every stored item decodes to null. DataBlock::AllNull(_) => { - return ProtobufUtils21::constant(None); + has_validity = true; + ProtobufUtils21::constant(None) } - _ => inner, - }; - let inner_encoding = match inner { DataBlock::FixedWidth(fixed_width) => { ProtobufUtils21::flat(fixed_width.bits_per_value, None) } @@ -405,6 +413,19 @@ impl ValueEncoder { break; } DataBlock::AllNull(_) => { + // All inner values are null. Add all-zero validity bits so that + // bytes_per_row > 0 and the FullZip layout doesn't write bits_per_value=0, + // which would crash the reader when there are also no ctrl-word bytes. + bytes_per_row += cum_dim.div_ceil(8) as usize; + validity_iters.push(PerValueFslValidityIter { + buffer: LanceBuffer::from(vec![ + 0u8; + cum_dim.div_ceil(8) as usize + * num_values as usize + ]), + bits_per_row: cum_dim as usize, + offset: 0, + }); data_bytes_per_row = 0; data_buffer = LanceBuffer::empty(); break; @@ -453,34 +474,39 @@ impl ValueEncoder { } impl BlockCompressor for ValueEncoder { - fn compress(&self, data: DataBlock) -> Result { - let data = match data { - DataBlock::FixedWidth(fixed_width) => fixed_width.data, - _ => unimplemented!( - "Cannot compress block of type {} with ValueEncoder", + fn compress(&self, data: DataBlock) -> Result<(Option, CompressiveEncoding)> { + let DataBlock::FixedWidth(fixed_width) = data else { + return Err(Error::invalid_input(format!( + "ValueEncoder cannot compress a {} block", data.name() - ), + ))); }; - Ok(data) + Ok(( + Some(fixed_width.data), + ProtobufUtils21::flat(fixed_width.bits_per_value, None), + )) } } impl MiniBlockCompressor for ValueEncoder { - fn compress(&self, chunk: DataBlock) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { + fn compress( + &self, + _context: MiniBlockCompressionContext, + chunk: DataBlock, + ) -> Result<(MiniBlockCompressed, CompressiveEncoding)> { match chunk { DataBlock::FixedWidth(fixed_width) => { let encoding = ProtobufUtils21::flat(fixed_width.bits_per_value, None); - Ok((Self::chunk_data(fixed_width), encoding)) + Ok((Self::chunk_data(fixed_width)?, encoding)) } - DataBlock::FixedSizeList(_) => Ok(Self::miniblock_fsl(chunk)), - _ => Err(Error::InvalidInput { - source: format!( + DataBlock::FixedSizeList(_) => Self::miniblock_fsl(chunk), + _ => Err(Error::invalid_input_source( + format!( "Cannot compress a data block of type {} with ValueEncoder", chunk.name() ) .into(), - location: location!(), - }), + )), } } } @@ -517,7 +543,7 @@ impl ValueDecompressor { } } - pub fn from_fsl(mut description: &pb21::FixedSizeList) -> Self { + pub fn from_fsl(mut description: &pb21::FixedSizeList) -> Result { let mut layers = Vec::new(); let mut cum_dim = 1; let mut bytes_per_value = 0; @@ -530,28 +556,45 @@ impl ValueDecompressor { if description.has_validity { bytes_per_value += cum_dim.div_ceil(8); } - match description + let encoding = description .values .as_ref() - .unwrap() + .ok_or_else(|| Error::invalid_input("FSL encoding missing inner values field"))? .compression .as_ref() - .unwrap() - { + .ok_or_else(|| { + Error::invalid_input("FSL encoding missing inner compression field") + })?; + match encoding { Compression::FixedSizeList(inner) => { description = inner; } Compression::Flat(flat) => { let mut bits_per_value = bytes_per_value * 8; bits_per_value += flat.bits_per_value * cum_dim; - return Self { + return Ok(Self { bits_per_item: flat.bits_per_value, bits_per_value, items_per_value: cum_dim, layers, - }; + }); + } + // All inner values are null: only validity bytes are stored per row. + // bits_per_item=0 signals unzip_decompress to emit AllNull for the values. + Compression::Constant(_) => { + return Ok(Self { + bits_per_item: 0, + bits_per_value: bytes_per_value * 8, + items_per_value: cum_dim, + layers, + }); + } + _ => { + return Err(Error::invalid_input(format!( + "Unexpected inner encoding type in FSL descriptor: {:?}", + encoding + ))); } - _ => unreachable!(), } } } @@ -567,7 +610,8 @@ impl ValueDecompressor { } impl BlockDecompressor for ValueDecompressor { - fn decompress(&self, data: LanceBuffer, num_values: u64) -> Result { + fn decompress(&self, data: Option, num_values: u64) -> Result { + let data = require_block_payload(data, "Flat block")?; let block = self.buffer_to_block(data, num_values); assert_eq!(block.num_values(), num_values); Ok(block) @@ -602,6 +646,15 @@ impl MiniBlockDecompressor for ValueDecompressor { assert_eq!(lists.num_values(), num_values); Ok(lists) } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + if self.has_validity() { + return None; + } + num_values + .checked_mul(self.bits_per_value) + .map(|bits| bits.div_ceil(8)) + } } struct FslDecompressorValidityBuilder { @@ -686,12 +739,23 @@ impl ValueDecompressor { } // Finally, restore the structure - let mut block = DataBlock::FixedWidth(FixedWidthDataBlock { - bits_per_value: self.bits_per_item, - num_values: num_items as u64, - data: LanceBuffer::from(data_buffer), - block_info: BlockInfo::new(), - }); + // + // bits_per_item=0 means the terminal encoding is constant-null (all inner values + // are null and only validity bits were stored). Use AllNull so that into_arrow + // produces a properly typed all-null array without a zero-width data buffer. + let mut block = if self.bits_per_item == 0 { + debug_assert!(data_buffer.is_empty()); + DataBlock::AllNull(AllNullDataBlock { + num_values: num_items as u64, + }) + } else { + DataBlock::FixedWidth(FixedWidthDataBlock { + bits_per_value: self.bits_per_item, + num_values: num_items as u64, + data: LanceBuffer::from(data_buffer), + block_info: BlockInfo::new(), + }) + }; let mut validity_bufs = buffer_builders .into_iter() @@ -730,6 +794,15 @@ impl FixedPerValueDecompressor for ValueDecompressor { fn bits_per_value(&self) -> u64 { self.bits_per_value } + + fn decoded_size_bytes(&self, num_values: u64) -> Option { + if self.has_validity() { + return None; + } + num_values + .checked_mul(self.bits_per_value) + .map(|bits| bits.div_ceil(8)) + } } impl PerValueCompressor for ValueEncoder { @@ -751,19 +824,16 @@ impl PerValueCompressor for ValueEncoder { // public tests module because we share the PRIMITIVE_TYPES constant with fixed_size_list #[cfg(test)] -pub(crate) mod tests { - use std::{ - collections::HashMap, - sync::{Arc, LazyLock}, - }; +mod tests { + use std::{collections::HashMap, sync::Arc}; use arrow_array::{ - make_array, new_null_array, types::UInt32Type, Array, ArrayRef, Decimal128Array, - FixedSizeListArray, Int32Array, ListArray, UInt8Array, + Array, ArrayRef, Decimal128Array, FixedSizeListArray, Int32Array, ListArray, UInt8Array, + make_array, new_null_array, types::UInt32Type, }; use arrow_buffer::{BooleanBuffer, NullBuffer, OffsetBuffer, ScalarBuffer}; use arrow_schema::{DataType, Field, TimeUnit}; - use lance_datagen::{array, gen_batch, ArrayGeneratorExt, Dimension, RowCount}; + use lance_datagen::{ArrayGeneratorExt, Dimension, RowCount, Seed, array, gen_batch}; use crate::{ compression::{FixedPerValueDecompressor, MiniBlockDecompressor}, @@ -771,20 +841,22 @@ pub(crate) mod tests { encodings::{ logical::primitive::{ fullzip::{PerValueCompressor, PerValueDataBlock}, - miniblock::MiniBlockCompressor, + miniblock::{MiniBlockCompressionContext, MiniBlockCompressor}, }, physical::value::ValueDecompressor, }, format::pb21::compressive_encoding::Compression, testing::{ - check_basic_random, check_round_trip_encoding_generated, - check_round_trip_encoding_of_data, FnArrayGeneratorProvider, TestCases, + TestCases, TestEncoding, check_basic_random_case, check_round_trip_encoding_of_data, }, - version::LanceFileVersion, }; use super::ValueEncoder; + fn miniblock_context() -> MiniBlockCompressionContext { + MiniBlockCompressionContext::new(0, true, true) + } + const PRIMITIVE_TYPES: &[DataType] = &[ DataType::Null, DataType::FixedSizeBinary(2), @@ -830,112 +902,125 @@ pub(crate) mod tests { .with_indices(vec![0, 1, 2]) .with_indices(vec![1]) .with_indices(vec![2]) - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); check_round_trip_encoding_of_data(vec![items], &test_cases, HashMap::default()).await; } #[test_log::test(tokio::test)] async fn test_simple_range() { - let items = Arc::new(Int32Array::from_iter((0..5000).map(|i| { - if i % 2 == 0 { - Some(i) - } else { - None - } - }))); + let items = Arc::new(Int32Array::from_iter( + (0..5000).map(|i| if i % 2 == 0 { Some(i) } else { None }), + )); - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![items], &test_cases, HashMap::default()).await; } #[test_log::test(tokio::test)] async fn test_value_primitive() { - for data_type in PRIMITIVE_TYPES { + const NUM_ROWS: u32 = 1025; + + let test_cases = TestCases::default() + .with_batch_size(NUM_ROWS) + .with_page_sizes(vec![4096]) + .with_expected_encoding("flat"); + let value_metadata = + HashMap::from([("lance-encoding:compression".to_string(), "none".to_string())]); + + for (seed, data_type) in PRIMITIVE_TYPES.iter().enumerate() { log::info!("Testing encoding for {:?}", data_type); - let field = Field::new("", data_type.clone(), false); - check_basic_random(field).await; + let data = gen_batch() + .with_seed(Seed::from(seed as u64)) + .anon_col(array::rand_type(data_type)) + .into_batch_rows(RowCount::from(NUM_ROWS as u64)) + .unwrap() + .column(0) + .clone(); + + check_round_trip_encoding_of_data(vec![data], &test_cases, value_metadata.clone()) + .await; } } - static LARGE_TYPES: LazyLock> = LazyLock::new(|| { - vec![DataType::FixedSizeList( - Arc::new(Field::new("", DataType::Int32, false)), - 128, - )] - }); - + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_large_primitive() { - for data_type in LARGE_TYPES.iter() { - log::info!("Testing encoding for {:?}", data_type); - let field = Field::new("", data_type.clone(), false); - check_basic_random(field).await; - } + async fn test_large_primitive( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + #[values(4096, 1024 * 1024)] page_size: u64, + #[values(false, true)] use_slicing: bool, + ) { + let data_type = + DataType::FixedSizeList(Arc::new(Field::new("", DataType::Int32, false)), 128); + let field = Field::new("", data_type, false); + check_basic_random_case(field, encoding, page_size, use_slicing).await; } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_decimal128_dictionary_encoding() { - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + async fn test_decimal128_dictionary_encoding( + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + let test_cases = TestCases::default() + .with_encoding(encoding) + .with_expected_encoding("dictionary"); let decimals: Vec = (0..100).collect(); let repeated_strings: Vec<_> = decimals .iter() .cycle() - .take(decimals.len() * 10000) + .take(decimals.len() * 1000) .map(|&v| Some(v as i128)) .collect(); let decimal_array = Arc::new(Decimal128Array::from(repeated_strings)) as ArrayRef; check_round_trip_encoding_of_data(vec![decimal_array], &test_cases, HashMap::new()).await; } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_miniblock_stress() { + async fn test_miniblock_stress( + #[values(false, true)] mixed_validity: bool, + #[values(10, 100, 1500, 15000)] batch_size: u32, + #[values(1000, 2000, 3000, 60000)] page_size: u64, + #[values( + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { // Tests for strange page sizes and batch sizes and validity scenarios for miniblock - // 10K integers, 100 per array, all valid - let data1 = (0..100) - .map(|_| Arc::new(Int32Array::from_iter_values(0..100)) as Arc) - .collect::>(); - - // Same as above but with mixed validity - let data2 = (0..100) + // 10K integers, 100 per array, either all valid or mixed validity. + let data = (0..100) .map(|_| { - Arc::new(Int32Array::from_iter((0..100).map(|i| { - if i % 2 == 0 { - Some(i) - } else { - None - } - }))) as Arc - }) - .collect::>(); - - // Same as above but with all null for first half then all valid - // TODO: Re-enable once the all-null path is complete - let _data3 = (0..100) - .map(|chunk_idx| { - Arc::new(Int32Array::from_iter((0..100).map(|i| { - if chunk_idx < 50 { - None - } else { - Some(i) - } - }))) as Arc + if mixed_validity { + Arc::new(Int32Array::from_iter( + (0..100).map(|i| if i % 2 == 0 { Some(i) } else { None }), + )) as Arc + } else { + Arc::new(Int32Array::from_iter_values(0..100)) as Arc + } }) .collect::>(); - for data in [data1, data2 /*data3*/] { - for batch_size in [10, 100, 1500, 15000] { - // 40000 bytes of data - let test_cases = TestCases::default() - .with_page_sizes(vec![1000, 2000, 3000, 60000]) - .with_batch_size(batch_size) - .with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default() + .with_page_sizes(vec![page_size]) + .with_batch_size(batch_size) + .with_encoding(encoding); - check_round_trip_encoding_of_data(data.clone(), &test_cases, HashMap::new()).await; - } - } + check_round_trip_encoding_of_data(data, &test_cases, HashMap::new()).await; } fn create_simple_fsl() -> FixedSizeListArray { @@ -977,7 +1062,8 @@ pub(crate) mod tests { let starting_data = DataBlock::from_array(sample_list.clone()); let encoder = ValueEncoder::default(); - let (data, compression) = MiniBlockCompressor::compress(&encoder, starting_data).unwrap(); + let (data, compression) = + MiniBlockCompressor::compress(&encoder, miniblock_context(), starting_data).unwrap(); assert_eq!(data.num_values, 3); assert_eq!(data.data.len(), 3); @@ -989,7 +1075,7 @@ pub(crate) mod tests { panic!() }; - let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()); + let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(); let decompressed = MiniBlockDecompressor::decompress(&decompressor, data.data, data.num_values).unwrap(); @@ -1003,6 +1089,59 @@ pub(crate) mod tests { assert_eq!(decompressed.as_ref(), &sample_list); } + fn wide_fixed_size_binary() -> ArrayRef { + let wide_value = vec![0xABu8; 5000]; + Arc::new( + arrow_array::FixedSizeBinaryArray::try_from_sparse_iter_with_size( + std::iter::repeat_n(Some(wide_value.as_slice()), 4), + 5000, + ) + .unwrap(), + ) + } + + fn wide_fixed_size_list_bool() -> ArrayRef { + // A wide FSL is sub-byte, so it chunks eight values per word and the + // smallest unit is 16 values rather than 2. + let dimension = 4095; + let values = arrow_array::BooleanArray::from(vec![false; dimension * 2]); + let field = Arc::new(Field::new("item", DataType::Boolean, true)); + Arc::new(FixedSizeListArray::new( + field, + dimension as i32, + Arc::new(values), + None, + )) + } + + #[rstest::rstest] + #[case::fixed_size_binary(wide_fixed_size_binary(), 2)] + #[case::fixed_size_list_bool(wide_fixed_size_list_bool(), 16)] + fn test_wide_value_miniblock_returns_error( + #[case] array: ArrayRef, + #[case] expected_min_values: u64, + ) { + let starting_data = DataBlock::from_array(array); + + let encoder = ValueEncoder::default(); + let result = MiniBlockCompressor::compress(&encoder, miniblock_context(), starting_data); + + let err = result.expect_err("wide values should not be encodable as miniblock"); + assert!( + matches!(err, lance_core::Error::InvalidInput { .. }), + "expected InvalidInput, got {err:?}" + ); + let msg = err.to_string(); + assert!( + msg.contains("too wide for miniblock encoding"), + "unexpected error message: {msg}" + ); + assert!( + msg.contains(&format!("{expected_min_values} values require")), + "unexpected error message: {msg}" + ); + } + #[test] fn test_fsl_value_compression_per_value() { let sample_list = create_simple_fsl(); @@ -1024,9 +1163,14 @@ pub(crate) mod tests { panic!() }; - let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()); + let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(); let num_values = data.num_values; + assert_eq!( + FixedPerValueDecompressor::decoded_size_bytes(&decompressor, num_values), + None, + "nullable FSL output uses multiple buffers and requires the fallback estimate" + ); let decompressed = FixedPerValueDecompressor::decompress(&decompressor, data, num_values).unwrap(); @@ -1047,12 +1191,29 @@ pub(crate) mod tests { let list_array = FixedSizeListArray::new(items_field, 2, items, Some(NullBuffer::new(list_nulls))); - let test_cases = TestCases::default().with_min_file_version(LanceFileVersion::V2_1); + let test_cases = TestCases::default().with_structural_encodings(); check_round_trip_encoding_of_data(vec![Arc::new(list_array)], &test_cases, HashMap::new()) .await; } + // Regression: nullable_per_value_fsl wrote bits_per_value=0 when the child block was + // AllNull but the FSL had no outer null buffer (no ctrl-word bytes), making + // total_bytes_per_value=0 and crashing the reader with "per-row byte width must be > 0". + #[test_log::test(tokio::test)] + async fn test_fsl_nullable_child_all_null_no_outer_nulls() { + // FSL, 2 outer rows, no outer null buffer, all 8 child + // Float32 values are null. DataBlock::from_arrays returns AllNull for the child, + // which routes into nullable_per_value_fsl. Without this fix the encoder writes + // bits_per_value=0 and the decoder errors on read. + let items = new_null_array(&DataType::Float32, 8); + let items_field = Arc::new(Field::new("item", DataType::Float32, true)); + let fsl = FixedSizeListArray::new(items_field, 4, items, None); + + let test_cases = TestCases::default().with_structural_encodings(); + check_round_trip_encoding_of_data(vec![Arc::new(fsl)], &test_cases, HashMap::new()).await; + } + #[test_log::test(tokio::test)] async fn regress_list_fsl() { // This regresses a case where rows are large lists that span multiple @@ -1065,7 +1226,7 @@ pub(crate) mod tests { let list_arr = ListArray::new(list_field, OffsetBuffer::new(offsets), Arc::new(fsl), None); let test_cases = TestCases::default() - .with_min_file_version(LanceFileVersion::V2_1) + .with_structural_encodings() .with_batch_size(1); check_round_trip_encoding_of_data(vec![Arc::new(list_arr)], &test_cases, HashMap::new()) @@ -1097,13 +1258,14 @@ pub(crate) mod tests { ); let encoder = ValueEncoder::default(); - let (data, compression) = MiniBlockCompressor::compress(&encoder, starting_data).unwrap(); + let (data, compression) = + MiniBlockCompressor::compress(&encoder, miniblock_context(), starting_data).unwrap(); let Compression::FixedSizeList(fsl) = compression.compression.unwrap() else { panic!() }; - let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()); + let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(); let decompressed = MiniBlockDecompressor::decompress(&decompressor, data.data, data.num_values).unwrap(); @@ -1133,7 +1295,7 @@ pub(crate) mod tests { panic!() }; - let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()); + let decompressor = ValueDecompressor::from_fsl(fsl.as_ref()).unwrap(); let PerValueDataBlock::Fixed(data) = data else { panic!() @@ -1152,18 +1314,43 @@ pub(crate) mod tests { assert_eq!(decompressed.as_ref(), sample_array.as_ref()); } + #[rstest::rstest] #[test_log::test(tokio::test)] - async fn test_fsl_nullable_items() { - let datagen = Box::new(FnArrayGeneratorProvider::new(move || { - lance_datagen::array::rand_vec_nullable::(Dimension::from(128), 0.5) - })); - - let field = Field::new( - "", - DataType::FixedSizeList(Arc::new(Field::new("item", DataType::UInt32, true)), 128), - false, - ); - check_round_trip_encoding_generated(field, datagen, TestCases::default()).await; + async fn test_fsl_nullable_items( + #[values( + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse + )] + encoding: TestEncoding, + ) { + let mut generator = + gen_batch() + .with_seed(Seed::from(0)) + .anon_col(array::rand_vec_nullable::( + Dimension::from(128), + 0.5, + )); + generator.with_random_nulls(0.2); + let source = generator + .into_batch_rows(RowCount::from(1026)) + .unwrap() + .column(0) + .clone(); + let test_cases = TestCases::default() + .with_page_sizes(vec![4096]) + .with_encoding(encoding) + .with_batch_size(257) + .with_range(510..515) + .with_indices(vec![0, 511, 512, 1024]); + + check_round_trip_encoding_of_data( + vec![source.slice(1, 512), source.slice(513, 513)], + &test_cases, + HashMap::new(), + ) + .await; } #[test_log::test(tokio::test)] @@ -1172,7 +1359,7 @@ pub(crate) mod tests { let test_cases = TestCases::default() .with_expected_encoding("flat") - .with_min_file_version(LanceFileVersion::V2_1); + .with_structural_encodings(); // Test both explicit configuration and automatic fallback scenarios // 1. Test explicit "none" compression to force flat encoding diff --git a/rust/lance-encoding/src/format.rs b/rust/lance-encoding/src/format.rs index 7114f17e31f..f37e69b0216 100644 --- a/rust/lance-encoding/src/format.rs +++ b/rust/lance-encoding/src/format.rs @@ -33,12 +33,12 @@ pub mod pb21 { } use pb::{ - array_encoding::ArrayEncoding as ArrayEncodingEnum, - buffer::BufferType, - nullable::{AllNull, NoNull, Nullability, SomeNull}, ArrayEncoding, Binary, Bitpacked, BitpackedForNonNeg, Block, Dictionary, FixedSizeBinary, FixedSizeList, Flat, Fsst, InlineBitpacking, Nullable, OutOfLineBitpacking, PackedStruct, PackedStructFixedWidthMiniBlock, Rle, Variable, + array_encoding::ArrayEncoding as ArrayEncodingEnum, + buffer::BufferType, + nullable::{AllNull, NoNull, Nullability, SomeNull}, }; use crate::{encodings::physical::block::CompressionConfig, repdef::DefinitionInterpretation}; @@ -678,6 +678,34 @@ impl ProtobufUtils21 { layout: Some(crate::format::pb21::page_layout::Layout::ConstantLayout( crate::format::pb21::ConstantLayout { inline_value: inline_value.map(bytes::Bytes::from), + rep_compression: None, + def_compression: None, + num_rep_values: 0, + num_def_values: 0, + layers: def_meaning + .iter() + .map(|&def| Self::def_inter_to_repdef_layer(def)) + .collect(), + }, + )), + } + } + + pub fn compressed_all_null_constant_layout( + def_meaning: &[DefinitionInterpretation], + rep_compression: Option, + def_compression: Option, + num_rep_values: u64, + num_def_values: u64, + ) -> crate::format::pb21::PageLayout { + crate::format::pb21::PageLayout { + layout: Some(crate::format::pb21::page_layout::Layout::ConstantLayout( + crate::format::pb21::ConstantLayout { + inline_value: None, + rep_compression, + def_compression, + num_rep_values, + num_def_values, layers: def_meaning .iter() .map(|&def| Self::def_inter_to_repdef_layer(def)) diff --git a/rust/lance-encoding/src/lib.rs b/rust/lance-encoding/src/lib.rs index 19749e4df75..338028b79d0 100644 --- a/rust/lance-encoding/src/lib.rs +++ b/rust/lance-encoding/src/lib.rs @@ -1,13 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use std::ops::Range; +use std::{ops::Range, sync::Arc}; use bytes::Bytes; -use futures::{future::BoxFuture, FutureExt, TryFutureExt}; +use futures::{FutureExt, TryFutureExt, future::BoxFuture}; use lance_core::Result; +mod array_encoding; pub mod buffer; pub mod compression; pub mod compression_config; @@ -17,13 +18,11 @@ pub mod decoder; pub mod encoder; pub mod encodings; pub mod format; -pub mod previous; pub mod repdef; pub mod statistics; #[cfg(test)] pub mod testing; pub mod utils; -pub mod version; // We can definitely add support for big-endian machines someday. However, it's not a priority and // would involve extensive testing (probably through emulation) to ensure that the encodings are @@ -75,6 +74,33 @@ pub trait EncodingsIo: std::fmt::Debug + Send + Sync { .map_ok(|mut v| v.pop().unwrap()) .boxed() } + + /// Returns a version of this I/O service that bypasses backpressure for all requests. + /// + /// This is intended for indirect I/O (e.g. fetching items after decoding offsets) where + /// blocking on backpressure could cause deadlocks or excessive latency. + /// + /// Returns `None` if this implementation does not support bypass (e.g. in-memory or test + /// schedulers), in which case the caller should fall back to using self. + fn with_bypass_backpressure(&self) -> Option> { + None + } + + /// Returns a version of this I/O service that additionally records the I/O it + /// performs into `stats`, on top of any global accounting. This is the seam + /// used to measure exact per-scope (e.g. per-query) I/O without re-opening + /// files: wrap a reader's I/O service, perform the reads, then inspect the + /// recorder. + /// + /// Returns `None` if this implementation does not support per-scope I/O + /// statistics (e.g. in-memory or test schedulers), in which case the caller + /// should fall back to using self (and no statistics are recorded). + fn with_io_stats( + &self, + _stats: Arc, + ) -> Option> { + None + } } /// An implementation of EncodingsIo that serves data from an in-memory buffer diff --git a/rust/lance-encoding/src/previous.rs b/rust/lance-encoding/src/previous.rs deleted file mode 100644 index eb4e1bcdb0d..00000000000 --- a/rust/lance-encoding/src/previous.rs +++ /dev/null @@ -1,8 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -//! Legacy code for the 2.0 format that is no longer used in 2.1+ - -pub mod decoder; -pub mod encoder; -pub mod encodings; diff --git a/rust/lance-encoding/src/previous/decoder.rs b/rust/lance-encoding/src/previous/decoder.rs deleted file mode 100644 index fd224e57f34..00000000000 --- a/rust/lance-encoding/src/previous/decoder.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::{collections::VecDeque, ops::Range}; - -use snafu::location; - -use crate::decoder::{ - FilterExpression, NextDecodeTask, PriorityRange, ScheduledScanLine, SchedulerContext, -}; - -use arrow_schema::DataType; -use futures::future::BoxFuture; -use lance_core::{Error, Result}; - -pub trait SchedulingJob: std::fmt::Debug { - fn schedule_next( - &mut self, - context: &mut SchedulerContext, - priority: &dyn PriorityRange, - ) -> Result; - - fn num_rows(&self) -> u64; -} - -/// A scheduler for a field's worth of data -/// -/// Each field in a reader's output schema maps to one field scheduler. This scheduler may -/// map to more than one column. For example, one field of struct data may -/// cover many columns of child data. In fact, the entire file is treated as one -/// top-level struct field. -/// -/// The scheduler is responsible for calculating the necessary I/O. One schedule_range -/// request could trigger multiple batches of I/O across multiple columns. The scheduler -/// should emit decoders into the sink as quickly as possible. -/// -/// As soon as the scheduler encounters a batch of data that can decoded then the scheduler -/// should emit a decoder in the "unloaded" state. The decode stream will pull the decoder -/// and start decoding. -/// -/// The order in which decoders are emitted is important. Pages should be emitted in -/// row-major order allowing decode of complete rows as quickly as possible. -/// -/// The `FieldScheduler` should be stateless and `Send` and `Sync`. This is -/// because it might need to be shared. For example, a list page has a reference to -/// the field schedulers for its items column. This is shared with the follow-up I/O -/// task created when the offsets are loaded. -/// -/// See [`crate::decoder`] for more information -pub trait FieldScheduler: Send + Sync + std::fmt::Debug { - /// Called at the beginning of scheduling to initialize the scheduler - fn initialize<'a>( - &'a self, - filter: &'a FilterExpression, - context: &'a SchedulerContext, - ) -> BoxFuture<'a, Result<()>>; - /// Schedules I/O for the requested portions of the field. - /// - /// Note: `ranges` must be ordered and non-overlapping - /// TODO: Support unordered or overlapping ranges in file scheduler - fn schedule_ranges<'a>( - &'a self, - ranges: &[Range], - filter: &FilterExpression, - ) -> Result>; - /// The number of rows in this field - fn num_rows(&self) -> u64; -} - -#[derive(Debug)] -pub struct DecoderReady { - // The decoder that is ready to be decoded - pub decoder: Box, - // The path to the decoder, the first value is the column index - // following values, if present, are nested child indices - // - // For example, a path of [1, 1, 0] would mean to grab the second - // column, then the second child, and then the first child. - // - // It could represent x in the following schema: - // - // score: float64 - // points: struct - // color: string - // location: struct - // x: float64 - // - // Currently, only struct decoders have "children" although other - // decoders may at some point as well. List children are only - // handled through indirect I/O at the moment and so they don't - // need to be represented (yet) - pub path: VecDeque, -} - -/// A decoder for a field's worth of data -/// -/// The decoder is initially "unloaded" (doesn't have all its data). The [`Self::wait_for_loaded`] -/// method should be called to wait for the needed I/O data before attempting to decode -/// any further. -/// -/// Unlike the other decoder types it is assumed that `LogicalPageDecoder` is stateful -/// and only `Send`. This is why we don't need a `rows_to_skip` argument in [`Self::drain`] -pub trait LogicalPageDecoder: std::fmt::Debug + Send { - /// Add a newly scheduled child decoder - /// - /// The default implementation does not expect children and returns - /// an error. - fn accept_child(&mut self, _child: DecoderReady) -> Result<()> { - Err(Error::Internal { - message: format!( - "The decoder {:?} does not expect children but received a child", - self - ), - location: location!(), - }) - } - /// Waits until at least `num_rows` have been loaded - fn wait_for_loaded(&'_ mut self, loaded_need: u64) -> BoxFuture<'_, Result<()>>; - /// The number of rows loaded so far - fn rows_loaded(&self) -> u64; - /// The number of rows that still need loading - fn rows_unloaded(&self) -> u64 { - self.num_rows() - self.rows_loaded() - } - /// The total number of rows in the field - fn num_rows(&self) -> u64; - /// The number of rows that have been drained so far - fn rows_drained(&self) -> u64; - /// The number of rows that are still available to drain - fn rows_left(&self) -> u64 { - self.num_rows() - self.rows_drained() - } - /// Creates a task to decode `num_rows` of data into an array - fn drain(&mut self, num_rows: u64) -> Result; - /// The data type of the decoded data - fn data_type(&self) -> &DataType; -} diff --git a/rust/lance-encoding/src/previous/encoder.rs b/rust/lance-encoding/src/previous/encoder.rs deleted file mode 100644 index b6ab35722f7..00000000000 --- a/rust/lance-encoding/src/previous/encoder.rs +++ /dev/null @@ -1,811 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::{collections::HashMap, env, hash::RandomState, sync::Arc}; - -use arrow_array::{cast::AsArray, ArrayRef, UInt8Array}; -use arrow_schema::DataType; -use hyperloglogplus::{HyperLogLog, HyperLogLogPlus}; -use snafu::location; - -use crate::{ - buffer::LanceBuffer, - data::DataBlock, - encoder::{ColumnIndexSequence, EncodingOptions, FieldEncoder, FieldEncodingStrategy}, - encodings::{ - logical::r#struct::StructFieldEncoder, - physical::{ - block::{CompressionConfig, CompressionScheme}, - value::ValueEncoder, - }, - }, - format::pb, - previous::encodings::{ - logical::{ - blob::BlobFieldEncoder, list::ListFieldEncoder, primitive::PrimitiveFieldEncoder, - }, - physical::{ - basic::BasicEncoder, - binary::BinaryEncoder, - dictionary::{AlreadyDictionaryEncoder, DictionaryEncoder}, - fixed_size_binary::FixedSizeBinaryEncoder, - fixed_size_list::FslEncoder, - fsst::FsstArrayEncoder, - packed_struct::PackedStructEncoder, - }, - }, - version::LanceFileVersion, -}; - -#[cfg(feature = "bitpacking")] -use crate::previous::encodings::physical::bitpack::{ - compute_compressed_bit_width_for_non_neg, BitpackedForNonNegArrayEncoder, -}; - -use crate::constants::{ - COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY, PACKED_STRUCT_LEGACY_META_KEY, - PACKED_STRUCT_META_KEY, -}; - -use lance_arrow::BLOB_META_KEY; -use lance_core::datatypes::{Field, BLOB_DESC_FIELD}; -use lance_core::{Error, Result}; - -/// An encoded array -/// -/// Maps to a single Arrow array -/// -/// This contains the encoded data as well as a description of the encoding that was applied which -/// can be used to decode the data later. -#[derive(Debug)] -pub struct EncodedArray { - /// The encoded buffers - pub data: DataBlock, - /// A description of the encoding used to encode the array - pub encoding: pb::ArrayEncoding, -} - -impl EncodedArray { - pub fn new(data: DataBlock, encoding: pb::ArrayEncoding) -> Self { - Self { data, encoding } - } - - pub fn into_buffers(self) -> (Vec, pb::ArrayEncoding) { - let buffers = self.data.into_buffers(); - (buffers, self.encoding) - } -} - -/// Encodes data from one format to another (hopefully more compact or useful) format -/// -/// The array encoder must be Send + Sync. Encoding is always done on its own -/// thread task in the background and there could potentially be multiple encode -/// tasks running for a column at once. -pub trait ArrayEncoder: std::fmt::Debug + Send + Sync { - /// Encode data - /// - /// The result should contain a description of the encoding that was chosen. - /// This can be used to decode the data later. - fn encode( - &self, - data: DataBlock, - data_type: &DataType, - buffer_index: &mut u32, - ) -> Result; -} - -/// A trait to pick which encoding strategy to use for a single page -/// of data -/// -/// Presumably, implementations will make encoding decisions based on -/// array statistics. -pub trait ArrayEncodingStrategy: Send + Sync + std::fmt::Debug { - fn create_array_encoder( - &self, - arrays: &[ArrayRef], - field: &Field, - ) -> Result>; -} - -/// The core field encoding strategy is a set of basic encodings that -/// are generally applicable in most scenarios. -#[derive(Debug)] -pub struct CoreFieldEncodingStrategy { - pub array_encoding_strategy: Arc, - pub version: LanceFileVersion, -} - -impl CoreFieldEncodingStrategy { - pub fn new(version: LanceFileVersion) -> Self { - Self { - array_encoding_strategy: Arc::new(CoreArrayEncodingStrategy::new(version)), - version, - } - } - - fn is_primitive_type(data_type: &DataType) -> bool { - matches!( - data_type, - DataType::Boolean - | DataType::Date32 - | DataType::Date64 - | DataType::Decimal128(_, _) - | DataType::Decimal256(_, _) - | DataType::Duration(_) - | DataType::Float16 - | DataType::Float32 - | DataType::Float64 - | DataType::Int16 - | DataType::Int32 - | DataType::Int64 - | DataType::Int8 - | DataType::Interval(_) - | DataType::Null - | DataType::Time32(_) - | DataType::Time64(_) - | DataType::Timestamp(_, _) - | DataType::UInt16 - | DataType::UInt32 - | DataType::UInt64 - | DataType::UInt8 - | DataType::FixedSizeBinary(_) - | DataType::FixedSizeList(_, _) - | DataType::Binary - | DataType::LargeBinary - | DataType::Utf8 - | DataType::LargeUtf8, - ) - } -} - -impl FieldEncodingStrategy for CoreFieldEncodingStrategy { - fn create_field_encoder( - &self, - encoding_strategy_root: &dyn FieldEncodingStrategy, - field: &Field, - column_index: &mut ColumnIndexSequence, - options: &EncodingOptions, - ) -> Result> { - let data_type = field.data_type(); - if Self::is_primitive_type(&data_type) { - let column_index = column_index.next_column_index(field.id as u32); - if field.metadata.contains_key(BLOB_META_KEY) { - let mut packed_meta = HashMap::new(); - packed_meta.insert(PACKED_STRUCT_META_KEY.to_string(), "true".to_string()); - let desc_field = - Field::try_from(BLOB_DESC_FIELD.clone().with_metadata(packed_meta)).unwrap(); - let desc_encoder = Box::new(PrimitiveFieldEncoder::try_new( - options, - self.array_encoding_strategy.clone(), - column_index, - desc_field, - )?); - Ok(Box::new(BlobFieldEncoder::new(desc_encoder))) - } else { - Ok(Box::new(PrimitiveFieldEncoder::try_new( - options, - self.array_encoding_strategy.clone(), - column_index, - field.clone(), - )?)) - } - } else { - match data_type { - DataType::List(_child) | DataType::LargeList(_child) => { - let list_idx = column_index.next_column_index(field.id as u32); - let inner_encoding = encoding_strategy_root.create_field_encoder( - encoding_strategy_root, - &field.children[0], - column_index, - options, - )?; - let offsets_encoder = - Arc::new(BasicEncoder::new(Box::new(ValueEncoder::default()))); - Ok(Box::new(ListFieldEncoder::new( - inner_encoding, - offsets_encoder, - options.cache_bytes_per_column, - options.keep_original_array, - list_idx, - ))) - } - DataType::Struct(_) => { - let field_metadata = &field.metadata; - if field_metadata - .get(PACKED_STRUCT_LEGACY_META_KEY) - .map(|v| v == "true") - .unwrap_or(field_metadata.contains_key(PACKED_STRUCT_META_KEY)) - { - Ok(Box::new(PrimitiveFieldEncoder::try_new( - options, - self.array_encoding_strategy.clone(), - column_index.next_column_index(field.id as u32), - field.clone(), - )?)) - } else { - let header_idx = column_index.next_column_index(field.id as u32); - let children_encoders = field - .children - .iter() - .map(|field| { - self.create_field_encoder( - encoding_strategy_root, - field, - column_index, - options, - ) - }) - .collect::>>()?; - Ok(Box::new(StructFieldEncoder::new( - children_encoders, - header_idx, - ))) - } - } - DataType::Dictionary(_, value_type) => { - // A dictionary of primitive is, itself, primitive - if Self::is_primitive_type(&value_type) { - Ok(Box::new(PrimitiveFieldEncoder::try_new( - options, - self.array_encoding_strategy.clone(), - column_index.next_column_index(field.id as u32), - field.clone(), - )?)) - } else { - // A dictionary of logical is, itself, logical and we don't support that today - // It could be possible (e.g. store indices in one column and values in remaining columns) - // but would be a significant amount of work - // - // An easier fallback implementation would be to decode-on-write and encode-on-read - Err(Error::NotSupported { source: format!("cannot encode a dictionary column whose value type is a logical type ({})", value_type).into(), location: location!() }) - } - } - _ => todo!("Implement encoding for field {}", field), - } - } - } -} - -/// The core array encoding strategy is a set of basic encodings that -/// are generally applicable in most scenarios. -#[derive(Debug)] -pub struct CoreArrayEncodingStrategy { - pub version: LanceFileVersion, -} - -const BINARY_DATATYPES: [DataType; 4] = [ - DataType::Binary, - DataType::LargeBinary, - DataType::Utf8, - DataType::LargeUtf8, -]; - -impl CoreArrayEncodingStrategy { - fn new(version: LanceFileVersion) -> Self { - Self { version } - } -} - -impl CoreArrayEncodingStrategy { - fn can_use_fsst(data_type: &DataType, data_size: u64, version: LanceFileVersion) -> bool { - version >= LanceFileVersion::V2_1 - && matches!(data_type, DataType::Utf8 | DataType::Binary) - && data_size > 4 * 1024 * 1024 - } - - fn get_field_compression(field_meta: &HashMap) -> Option { - let compression = field_meta.get(COMPRESSION_META_KEY)?; - let compression_scheme = compression.parse::(); - match compression_scheme { - Ok(compression_scheme) => Some(CompressionConfig::new( - compression_scheme, - field_meta - .get(COMPRESSION_LEVEL_META_KEY) - .and_then(|level| level.parse().ok()), - )), - Err(_) => None, - } - } - - fn default_binary_encoder( - arrays: &[ArrayRef], - data_type: &DataType, - field_meta: Option<&HashMap>, - data_size: u64, - version: LanceFileVersion, - ) -> Result> { - let bin_indices_encoder = - Self::choose_array_encoder(arrays, &DataType::UInt64, data_size, false, version, None)?; - - if let Some(compression) = field_meta.and_then(Self::get_field_compression) { - if compression.scheme == CompressionScheme::Fsst { - // User requested FSST - let raw_encoder = Box::new(BinaryEncoder::try_new(bin_indices_encoder, None)?); - Ok(Box::new(FsstArrayEncoder::new(raw_encoder))) - } else { - // Generic compression - Ok(Box::new(BinaryEncoder::try_new( - bin_indices_encoder, - Some(compression), - )?)) - } - } else { - // No user-specified compression, use FSST if we can - let bin_encoder = Box::new(BinaryEncoder::try_new(bin_indices_encoder, None)?); - if Self::can_use_fsst(data_type, data_size, version) { - Ok(Box::new(FsstArrayEncoder::new(bin_encoder))) - } else { - Ok(bin_encoder) - } - } - } - - fn choose_array_encoder( - arrays: &[ArrayRef], - data_type: &DataType, - data_size: u64, - use_dict_encoding: bool, - version: LanceFileVersion, - field_meta: Option<&HashMap>, - ) -> Result> { - match data_type { - DataType::FixedSizeList(inner, dimension) => { - Ok(Box::new(BasicEncoder::new(Box::new(FslEncoder::new( - Self::choose_array_encoder( - arrays, - inner.data_type(), - data_size, - use_dict_encoding, - version, - None, - )?, - *dimension as u32, - ))))) - } - DataType::Dictionary(key_type, value_type) => { - let key_encoder = - Self::choose_array_encoder(arrays, key_type, data_size, false, version, None)?; - let value_encoder = Self::choose_array_encoder( - arrays, value_type, data_size, false, version, None, - )?; - - Ok(Box::new(AlreadyDictionaryEncoder::new( - key_encoder, - value_encoder, - ))) - } - DataType::Utf8 | DataType::LargeUtf8 | DataType::Binary | DataType::LargeBinary => { - if use_dict_encoding { - let dict_indices_encoder = Self::choose_array_encoder( - // We need to pass arrays to this method to figure out what kind of compression to - // use but we haven't actually calculated the indices yet. For now, we just assume - // worst case and use the full range. In the future maybe we can pass in statistics - // instead of the actual data - &[Arc::new(UInt8Array::from_iter_values(0_u8..255_u8))], - &DataType::UInt8, - data_size, - false, - version, - None, - )?; - let dict_items_encoder = Self::choose_array_encoder( - arrays, - &DataType::Utf8, - data_size, - false, - version, - None, - )?; - - Ok(Box::new(DictionaryEncoder::new( - dict_indices_encoder, - dict_items_encoder, - ))) - } - // The parent datatype should be binary or utf8 to use the fixed size encoding - // The variable 'data_type' is passed through recursion so comparing with it would be incorrect - else if BINARY_DATATYPES.contains(arrays[0].data_type()) { - if let Some(byte_width) = check_fixed_size_encoding(arrays, version) { - // use FixedSizeBinaryEncoder - let bytes_encoder = Self::choose_array_encoder( - arrays, - &DataType::UInt8, - data_size, - false, - version, - None, - )?; - - Ok(Box::new(BasicEncoder::new(Box::new( - FixedSizeBinaryEncoder::new(bytes_encoder, byte_width as usize), - )))) - } else { - Self::default_binary_encoder( - arrays, data_type, field_meta, data_size, version, - ) - } - } else { - Self::default_binary_encoder(arrays, data_type, field_meta, data_size, version) - } - } - DataType::Struct(fields) => { - let num_fields = fields.len(); - let mut inner_encoders = Vec::new(); - - for i in 0..num_fields { - let inner_datatype = fields[i].data_type(); - let inner_encoder = Self::choose_array_encoder( - arrays, - inner_datatype, - data_size, - use_dict_encoding, - version, - None, - )?; - inner_encoders.push(inner_encoder); - } - - Ok(Box::new(PackedStructEncoder::new(inner_encoders))) - } - DataType::UInt8 | DataType::UInt16 | DataType::UInt32 | DataType::UInt64 => { - if version >= LanceFileVersion::V2_1 && arrays[0].data_type() == data_type { - #[cfg(feature = "bitpacking")] - { - let compressed_bit_width = compute_compressed_bit_width_for_non_neg(arrays); - Ok(Box::new(BitpackedForNonNegArrayEncoder::new( - compressed_bit_width as usize, - data_type.clone(), - ))) - } - #[cfg(not(feature = "bitpacking"))] - { - Ok(Box::new(BasicEncoder::new(Box::new( - ValueEncoder::default(), - )))) - } - } else { - Ok(Box::new(BasicEncoder::new(Box::new( - ValueEncoder::default(), - )))) - } - } - - // TODO: for signed integers, I intend to make it a cascaded encoding, a sparse array for the negative values and very wide(bit-width) values, - // then a bitpacked array for the narrow(bit-width) values, I need `BitpackedForNeg` to be merged first, I am - // thinking about putting this sparse array in the metadata so bitpacking remain using one page buffer only. - DataType::Int8 | DataType::Int16 | DataType::Int32 | DataType::Int64 => { - if version >= LanceFileVersion::V2_1 && arrays[0].data_type() == data_type { - #[cfg(feature = "bitpacking")] - { - let compressed_bit_width = compute_compressed_bit_width_for_non_neg(arrays); - Ok(Box::new(BitpackedForNonNegArrayEncoder::new( - compressed_bit_width as usize, - data_type.clone(), - ))) - } - #[cfg(not(feature = "bitpacking"))] - { - Ok(Box::new(BasicEncoder::new(Box::new( - ValueEncoder::default(), - )))) - } - } else { - Ok(Box::new(BasicEncoder::new(Box::new( - ValueEncoder::default(), - )))) - } - } - _ => Ok(Box::new(BasicEncoder::new(Box::new( - ValueEncoder::default(), - )))), - } - } -} - -fn get_dict_encoding_threshold() -> u64 { - env::var("LANCE_DICT_ENCODING_THRESHOLD") - .ok() - .and_then(|val| val.parse().ok()) - .unwrap_or(100) -} - -// check whether we want to use dictionary encoding or not -// by applying a threshold on cardinality -// returns true if cardinality < threshold but false if the total number of rows is less than the threshold -// The choice to use 100 is just a heuristic for now -// hyperloglog is used for cardinality estimation -// error rate = 1.04 / sqrt(2^p), where p is the precision -// and error rate is 1.04 / sqrt(2^12) = 1.56% -fn check_dict_encoding(arrays: &[ArrayRef], threshold: u64) -> bool { - let num_total_rows = arrays.iter().map(|arr| arr.len()).sum::(); - if num_total_rows < threshold as usize { - return false; - } - const PRECISION: u8 = 12; - - let mut hll: HyperLogLogPlus = - HyperLogLogPlus::new(PRECISION, RandomState::new()).unwrap(); - - for arr in arrays { - let string_array = arrow_array::cast::as_string_array(arr); - for value in string_array.iter().flatten() { - hll.insert(value); - let estimated_cardinality = hll.count() as u64; - if estimated_cardinality >= threshold { - return false; - } - } - } - - true -} - -fn check_fixed_size_encoding(arrays: &[ArrayRef], version: LanceFileVersion) -> Option { - if version < LanceFileVersion::V2_1 || arrays.is_empty() { - return None; - } - - // make sure no array has an empty string - if !arrays.iter().all(|arr| { - if let Some(arr) = arr.as_string_opt::() { - arr.iter().flatten().all(|s| !s.is_empty()) - } else if let Some(arr) = arr.as_binary_opt::() { - arr.iter().flatten().all(|s| !s.is_empty()) - } else if let Some(arr) = arr.as_string_opt::() { - arr.iter().flatten().all(|s| !s.is_empty()) - } else if let Some(arr) = arr.as_binary_opt::() { - arr.iter().flatten().all(|s| !s.is_empty()) - } else { - panic!("wrong dtype"); - } - }) { - return None; - } - - let lengths = arrays - .iter() - .flat_map(|arr| { - if let Some(arr) = arr.as_string_opt::() { - let offsets = arr.offsets().inner(); - offsets - .windows(2) - .map(|w| (w[1] - w[0]) as u64) - .collect::>() - } else if let Some(arr) = arr.as_binary_opt::() { - let offsets = arr.offsets().inner(); - offsets - .windows(2) - .map(|w| (w[1] - w[0]) as u64) - .collect::>() - } else if let Some(arr) = arr.as_string_opt::() { - let offsets = arr.offsets().inner(); - offsets - .windows(2) - .map(|w| (w[1] - w[0]) as u64) - .collect::>() - } else if let Some(arr) = arr.as_binary_opt::() { - let offsets = arr.offsets().inner(); - offsets - .windows(2) - .map(|w| (w[1] - w[0]) as u64) - .collect::>() - } else { - panic!("wrong dtype"); - } - }) - .collect::>(); - - // find first non-zero value in lengths - let first_non_zero = lengths.iter().position(|&x| x != 0); - if let Some(first_non_zero) = first_non_zero { - // make sure all lengths are equal to first_non_zero length or zero - if !lengths - .iter() - .all(|&x| x == 0 || x == lengths[first_non_zero]) - { - return None; - } - - // set the byte width - Some(lengths[first_non_zero]) - } else { - None - } -} - -impl ArrayEncodingStrategy for CoreArrayEncodingStrategy { - fn create_array_encoder( - &self, - arrays: &[ArrayRef], - field: &Field, - ) -> Result> { - let data_size = arrays - .iter() - .map(|arr| arr.get_buffer_memory_size() as u64) - .sum::(); - let data_type = arrays[0].data_type(); - - let use_dict_encoding = data_type == &DataType::Utf8 - && check_dict_encoding(arrays, get_dict_encoding_threshold()); - - Self::choose_array_encoder( - arrays, - data_type, - data_size, - use_dict_encoding, - self.version, - Some(&field.metadata), - ) - } -} - -#[cfg(test)] -pub mod tests { - use crate::constants::{COMPRESSION_LEVEL_META_KEY, COMPRESSION_META_KEY}; - use crate::previous::encoder::{ - check_dict_encoding, check_fixed_size_encoding, ArrayEncodingStrategy, - CoreArrayEncodingStrategy, - }; - use crate::version::LanceFileVersion; - use arrow_array::{ArrayRef, StringArray}; - use arrow_schema::Field; - use std::collections::HashMap; - use std::sync::Arc; - - fn is_dict_encoding_applicable(arr: Vec>, threshold: u64) -> bool { - let arr = StringArray::from(arr); - let arr = Arc::new(arr) as ArrayRef; - check_dict_encoding(&[arr], threshold) - } - - #[test] - fn test_dict_encoding_should_be_applied_if_cardinality_less_than_threshold() { - assert!(is_dict_encoding_applicable( - vec![Some("a"), Some("b"), Some("a"), Some("b")], - 3, - )); - } - - #[test] - fn test_dict_encoding_should_not_be_applied_if_cardinality_larger_than_threshold() { - assert!(!is_dict_encoding_applicable( - vec![Some("a"), Some("b"), Some("c"), Some("d")], - 3, - )); - } - - #[test] - fn test_dict_encoding_should_not_be_applied_if_cardinality_equal_to_threshold() { - assert!(!is_dict_encoding_applicable( - vec![Some("a"), Some("b"), Some("c"), Some("a")], - 3, - )); - } - - #[test] - fn test_dict_encoding_should_not_be_applied_for_empty_arrays() { - assert!(!is_dict_encoding_applicable(vec![], 3)); - } - - #[test] - fn test_dict_encoding_should_not_be_applied_for_smaller_than_threshold_arrays() { - assert!(!is_dict_encoding_applicable(vec![Some("a"), Some("a")], 3)); - } - - fn is_fixed_size_encoding_applicable( - arrays: Vec>>, - version: LanceFileVersion, - ) -> bool { - let mut final_arrays = Vec::new(); - for arr in arrays { - let arr = StringArray::from(arr); - let arr = Arc::new(arr) as ArrayRef; - final_arrays.push(arr); - } - - check_fixed_size_encoding(&final_arrays.clone(), version).is_some() - } - - #[test] - fn test_fixed_size_binary_encoding_applicable() { - assert!(!is_fixed_size_encoding_applicable( - vec![vec![]], - LanceFileVersion::V2_1 - )); - - assert!(is_fixed_size_encoding_applicable( - vec![vec![Some("a"), Some("b")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some("abc"), Some("de")]], - LanceFileVersion::V2_1 - )); - - assert!(is_fixed_size_encoding_applicable( - vec![vec![Some("pqr"), None]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some("pqr"), Some("")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some(""), Some("")]], - LanceFileVersion::V2_1 - )); - } - - #[test] - fn test_fixed_size_binary_encoding_applicable_multiple_arrays() { - assert!(is_fixed_size_encoding_applicable( - vec![vec![Some("a"), Some("b")], vec![Some("c"), Some("d")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some("ab"), Some("bc")], vec![Some("c"), Some("d")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some("ab"), None], vec![None, Some("d")]], - LanceFileVersion::V2_1 - )); - - assert!(is_fixed_size_encoding_applicable( - vec![vec![Some("a"), None], vec![None, Some("d")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![Some(""), None], vec![None, Some("")]], - LanceFileVersion::V2_1 - )); - - assert!(!is_fixed_size_encoding_applicable( - vec![vec![None, None], vec![None, None]], - LanceFileVersion::V2_1 - )); - } - - fn verify_array_encoder( - array: ArrayRef, - field_meta: Option>, - version: LanceFileVersion, - expected_encoder: &str, - ) { - let encoding_strategy = CoreArrayEncodingStrategy { version }; - let mut field = Field::new("test_field", array.data_type().clone(), true); - if let Some(field_meta) = field_meta { - field.set_metadata(field_meta); - } - let lance_field = lance_core::datatypes::Field::try_from(field).unwrap(); - let encoder_result = encoding_strategy.create_array_encoder(&[array], &lance_field); - assert!(encoder_result.is_ok()); - let encoder = encoder_result.unwrap(); - assert_eq!(format!("{:?}", encoder).as_str(), expected_encoder); - } - - #[test] - fn test_choose_encoder_for_zstd_compressed_string_field() { - verify_array_encoder(Arc::new(StringArray::from(vec!["a", "bb", "ccc"])), - Some(HashMap::from([(COMPRESSION_META_KEY.to_string(), "zstd".to_string())])), - LanceFileVersion::V2_1, - "BinaryEncoder { indices_encoder: BasicEncoder { values_encoder: ValueEncoder }, compression_config: Some(CompressionConfig { scheme: Zstd, level: None }), buffer_compressor: Some(ZstdBufferCompressor { compression_level: 0 }) }"); - } - - #[test] - fn test_choose_encoder_for_zstd_compression_level() { - verify_array_encoder(Arc::new(StringArray::from(vec!["a", "bb", "ccc"])), - Some(HashMap::from([ - (COMPRESSION_META_KEY.to_string(), "zstd".to_string()), - (COMPRESSION_LEVEL_META_KEY.to_string(), "22".to_string()) - ])), - LanceFileVersion::V2_1, - "BinaryEncoder { indices_encoder: BasicEncoder { values_encoder: ValueEncoder }, compression_config: Some(CompressionConfig { scheme: Zstd, level: Some(22) }), buffer_compressor: Some(ZstdBufferCompressor { compression_level: 22 }) }"); - } -} diff --git a/rust/lance-encoding/src/previous/encodings.rs b/rust/lance-encoding/src/previous/encodings.rs deleted file mode 100644 index 67a43993508..00000000000 --- a/rust/lance-encoding/src/previous/encodings.rs +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -//! Legacy code for the 2.0 format that is no longer used in 2.1+ - -pub mod logical; -pub mod physical; diff --git a/rust/lance-encoding/src/previous/encodings/physical/bitpack.rs b/rust/lance-encoding/src/previous/encodings/physical/bitpack.rs deleted file mode 100644 index 5d018469645..00000000000 --- a/rust/lance-encoding/src/previous/encodings/physical/bitpack.rs +++ /dev/null @@ -1,1693 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::sync::Arc; - -use arrow_array::types::{ - Int16Type, Int32Type, Int64Type, Int8Type, UInt16Type, UInt32Type, UInt64Type, UInt8Type, -}; -use arrow_array::{cast::AsArray, Array, ArrayRef, ArrowPrimitiveType, PrimitiveArray}; -use arrow_buffer::bit_util::ceil; -use arrow_buffer::ArrowNativeType; -use arrow_schema::DataType; -use bytes::Bytes; -use futures::future::{BoxFuture, FutureExt}; -use log::trace; -use num_traits::{AsPrimitive, PrimInt}; -use snafu::location; - -use lance_arrow::DataTypeExt; -use lance_bitpacking::BitPacking; -use lance_core::{Error, Result}; - -use crate::buffer::LanceBuffer; -use crate::data::BlockInfo; -use crate::data::{DataBlock, FixedWidthDataBlock, NullableDataBlock}; -use crate::decoder::{PageScheduler, PrimitivePageDecoder}; -use crate::format::ProtobufUtils; -use crate::previous::encoder::{ArrayEncoder, EncodedArray}; -use bytemuck::cast_slice; - -const LOG_ELEMS_PER_CHUNK: u8 = 10; -const ELEMS_PER_CHUNK: u64 = 1 << LOG_ELEMS_PER_CHUNK; - -// Compute the compressed_bit_width for a given array of integers -// todo: compute all statistics before encoding -// todo: see how to use rust macro to rewrite this function -pub fn compute_compressed_bit_width_for_non_neg(arrays: &[ArrayRef]) -> u64 { - debug_assert!(!arrays.is_empty()); - - let res; - - match arrays[0].data_type() { - DataType::UInt8 => { - let mut global_max: u8 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max = arrow_arith::aggregate::bit_or(primitive_array); - global_max = global_max.max(array_max.unwrap_or(0)); - } - let num_bits = - arrays[0].data_type().byte_width() as u64 * 8 - global_max.leading_zeros() as u64; - // we will have constant encoding later - if num_bits == 0 { - res = 1; - } else { - res = num_bits; - } - } - - DataType::Int8 => { - let mut global_max_width: u64 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max_width = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max_width = global_max_width.max(8 - array_max_width.leading_zeros() as u64); - } - if global_max_width == 0 { - res = 1; - } else { - res = global_max_width; - } - } - - DataType::UInt16 => { - let mut global_max: u16 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max = global_max.max(array_max); - } - let num_bits = - arrays[0].data_type().byte_width() as u64 * 8 - global_max.leading_zeros() as u64; - if num_bits == 0 { - res = 1; - } else { - res = num_bits; - } - } - - DataType::Int16 => { - let mut global_max_width: u64 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max_width = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max_width = - global_max_width.max(16 - array_max_width.leading_zeros() as u64); - } - if global_max_width == 0 { - res = 1; - } else { - res = global_max_width; - } - } - - DataType::UInt32 => { - let mut global_max: u32 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max = global_max.max(array_max); - } - let num_bits = - arrays[0].data_type().byte_width() as u64 * 8 - global_max.leading_zeros() as u64; - if num_bits == 0 { - res = 1; - } else { - res = num_bits; - } - } - - DataType::Int32 => { - let mut global_max_width: u64 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max_width = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max_width = - global_max_width.max(32 - array_max_width.leading_zeros() as u64); - } - if global_max_width == 0 { - res = 1; - } else { - res = global_max_width; - } - } - - DataType::UInt64 => { - let mut global_max: u64 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max = global_max.max(array_max); - } - let num_bits = - arrays[0].data_type().byte_width() as u64 * 8 - global_max.leading_zeros() as u64; - if num_bits == 0 { - res = 1; - } else { - res = num_bits; - } - } - - DataType::Int64 => { - let mut global_max_width: u64 = 0; - for array in arrays { - let primitive_array = array - .as_any() - .downcast_ref::>() - .unwrap(); - let array_max_width = arrow_arith::aggregate::bit_or(primitive_array).unwrap_or(0); - global_max_width = - global_max_width.max(64 - array_max_width.leading_zeros() as u64); - } - if global_max_width == 0 { - res = 1; - } else { - res = global_max_width; - } - } - _ => { - panic!("BitpackedForNonNegArrayEncoder only supports data types of UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64"); - } - }; - res -} - -// Bitpack integers using fastlanes algorithm, the input is sliced into chunks of 1024 integers, and bitpacked -// chunk by chunk. when the input is not a multiple of 1024, the last chunk is padded with zeros, this is fine because -// we also know the number of rows we have. -// Here self is a borrow of BitpackedForNonNegArrayEncoder, unpacked is a mutable borrow of FixedWidthDataBlock, -// data_type can be one of u8, u16, u32, or u64. -// buffer_index is a mutable borrow of u32, indicating the buffer index of the output EncodedArray. -// It outputs an fastlanes bitpacked EncodedArray -macro_rules! encode_fixed_width { - ($self:expr, $unpacked:expr, $data_type:ty, $buffer_index:expr) => {{ - let num_chunks = $unpacked.num_values.div_ceil(ELEMS_PER_CHUNK); - let num_full_chunks = $unpacked.num_values / ELEMS_PER_CHUNK; - let uncompressed_bit_width = std::mem::size_of::<$data_type>() as u64 * 8; - - // the output vector type is the same as the input type, for example, when input is u16, output is Vec - let packed_chunk_size = 1024 * $self.compressed_bit_width as usize / uncompressed_bit_width as usize; - - let input_slice = $unpacked.data.borrow_to_typed_slice::<$data_type>(); - let input = input_slice.as_ref(); - - let mut output = Vec::with_capacity(num_chunks as usize * packed_chunk_size); - - // Loop over all but the last chunk. - (0..num_full_chunks).for_each(|i| { - let start_elem = (i * ELEMS_PER_CHUNK) as usize; - - let output_len = output.len(); - unsafe { - output.set_len(output_len + packed_chunk_size); - BitPacking::unchecked_pack( - $self.compressed_bit_width, - &input[start_elem..][..ELEMS_PER_CHUNK as usize], - &mut output[output_len..][..packed_chunk_size], - ); - } - }); - - if num_chunks != num_full_chunks { - let last_chunk_elem_num = $unpacked.num_values % ELEMS_PER_CHUNK; - let mut last_chunk = vec![0 as $data_type; ELEMS_PER_CHUNK as usize]; - last_chunk[..last_chunk_elem_num as usize].clone_from_slice( - &input[$unpacked.num_values as usize - last_chunk_elem_num as usize..], - ); - - let output_len = output.len(); - unsafe { - output.set_len(output_len + packed_chunk_size); - BitPacking::unchecked_pack( - $self.compressed_bit_width, - &last_chunk, - &mut output[output_len..][..packed_chunk_size], - ); - } - } - - let bitpacked_for_non_neg_buffer_index = *$buffer_index; - *$buffer_index += 1; - - let encoding = ProtobufUtils::bitpacked_for_non_neg_encoding( - $self.compressed_bit_width as u64, - uncompressed_bit_width, - bitpacked_for_non_neg_buffer_index, - ); - let packed = DataBlock::FixedWidth(FixedWidthDataBlock { - bits_per_value: $self.compressed_bit_width as u64, - data: LanceBuffer::reinterpret_vec(output), - num_values: $unpacked.num_values, - block_info: BlockInfo::new(), - }); - - Result::Ok(EncodedArray { - data: packed, - encoding, - }) - }}; -} - -#[derive(Debug)] -pub struct BitpackedForNonNegArrayEncoder { - pub compressed_bit_width: usize, - pub original_data_type: DataType, -} - -impl BitpackedForNonNegArrayEncoder { - pub fn new(compressed_bit_width: usize, data_type: DataType) -> Self { - Self { - compressed_bit_width, - original_data_type: data_type, - } - } -} - -impl ArrayEncoder for BitpackedForNonNegArrayEncoder { - fn encode( - &self, - data: DataBlock, - data_type: &DataType, - buffer_index: &mut u32, - ) -> Result { - match data { - DataBlock::AllNull(_) => { - let encoding = ProtobufUtils::basic_all_null_encoding(); - Ok(EncodedArray { data, encoding }) - } - DataBlock::FixedWidth(unpacked) => { - match data_type { - DataType::UInt8 | DataType::Int8 => encode_fixed_width!(self, unpacked, u8, buffer_index), - DataType::UInt16 | DataType::Int16 => encode_fixed_width!(self, unpacked, u16, buffer_index), - DataType::UInt32 | DataType::Int32 => encode_fixed_width!(self, unpacked, u32, buffer_index), - DataType::UInt64 | DataType::Int64 => encode_fixed_width!(self, unpacked, u64, buffer_index), - _ => unreachable!("BitpackedForNonNegArrayEncoder only supports data types of UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64"), - } - } - DataBlock::Nullable(nullable) => { - let validity_buffer_index = *buffer_index; - *buffer_index += 1; - - let validity_desc = ProtobufUtils::flat_encoding( - 1, - validity_buffer_index, - /*compression=*/ None, - ); - let encoded_values: EncodedArray; - match *nullable.data { - DataBlock::FixedWidth(unpacked) => { - match data_type { - DataType::UInt8 | DataType::Int8 => encoded_values = encode_fixed_width!(self, unpacked, u8, buffer_index)?, - DataType::UInt16 | DataType::Int16 => encoded_values = encode_fixed_width!(self, unpacked, u16, buffer_index)?, - DataType::UInt32 | DataType::Int32 => encoded_values = encode_fixed_width!(self, unpacked, u32, buffer_index)?, - DataType::UInt64 | DataType::Int64 => encoded_values = encode_fixed_width!(self, unpacked, u64, buffer_index)?, - _ => unreachable!("BitpackedForNonNegArrayEncoder only supports data types of UInt8, Int8, UInt16, Int16, UInt32, Int32, UInt64, Int64"), - } - } - _ => { - return Err(Error::InvalidInput { - source: "Bitpacking only supports fixed width data blocks or a nullable data block with fixed width data block inside or a all null data block".into(), - location: location!(), - }); - } - } - let encoding = - ProtobufUtils::basic_some_null_encoding(validity_desc, encoded_values.encoding); - let encoded = DataBlock::Nullable(NullableDataBlock { - data: Box::new(encoded_values.data), - nulls: nullable.nulls, - block_info: BlockInfo::new(), - }); - Ok(EncodedArray { - data: encoded, - encoding, - }) - } - _ => { - Err(Error::InvalidInput { - source: "Bitpacking only supports fixed width data blocks or a nullable data block with fixed width data block inside or a all null data block".into(), - location: location!(), - }) - } - } - } -} - -#[derive(Debug)] -pub struct BitpackedForNonNegScheduler { - compressed_bit_width: u64, - uncompressed_bits_per_value: u64, - buffer_offset: u64, -} - -impl BitpackedForNonNegScheduler { - pub fn new( - compressed_bit_width: u64, - uncompressed_bits_per_value: u64, - buffer_offset: u64, - ) -> Self { - Self { - compressed_bit_width, - uncompressed_bits_per_value, - buffer_offset, - } - } - - fn locate_chunk_start(&self, relative_row_num: u64) -> u64 { - let chunk_size = ELEMS_PER_CHUNK * self.compressed_bit_width / 8; - self.buffer_offset + (relative_row_num / ELEMS_PER_CHUNK * chunk_size) - } - - fn locate_chunk_end(&self, relative_row_num: u64) -> u64 { - let chunk_size = ELEMS_PER_CHUNK * self.compressed_bit_width / 8; - self.buffer_offset + (relative_row_num / ELEMS_PER_CHUNK * chunk_size) + chunk_size - } -} - -impl PageScheduler for BitpackedForNonNegScheduler { - fn schedule_ranges( - &self, - ranges: &[std::ops::Range], - scheduler: &Arc, - top_level_row: u64, - ) -> BoxFuture<'static, Result>> { - assert!(!ranges.is_empty()); - - let mut byte_ranges = vec![]; - - // map one bytes to multiple ranges, one bytes has at least one range corresponding to it - let mut bytes_idx_to_range_indices = vec![]; - let first_byte_range = std::ops::Range { - start: self.locate_chunk_start(ranges[0].start), - end: self.locate_chunk_end(ranges[0].end - 1), - }; // the ranges are half-open - byte_ranges.push(first_byte_range); - bytes_idx_to_range_indices.push(vec![ranges[0].clone()]); - - for (i, range) in ranges.iter().enumerate().skip(1) { - let this_start = self.locate_chunk_start(range.start); - let this_end = self.locate_chunk_end(range.end - 1); - - // when the current range start is in the same chunk as the previous range's end, we colaesce this two bytes ranges - // when the current range start is not in the same chunk as the previous range's end, we create a new bytes range - if this_start == self.locate_chunk_start(ranges[i - 1].end - 1) { - byte_ranges.last_mut().unwrap().end = this_end; - bytes_idx_to_range_indices - .last_mut() - .unwrap() - .push(range.clone()); - } else { - byte_ranges.push(this_start..this_end); - bytes_idx_to_range_indices.push(vec![range.clone()]); - } - } - - trace!( - "Scheduling I/O for {} ranges spread across byte range {}..{}", - byte_ranges.len(), - byte_ranges[0].start, - byte_ranges.last().unwrap().end - ); - - let bytes = scheduler.submit_request(byte_ranges.clone(), top_level_row); - - // copy the necessary data from `self` to move into the async block - let compressed_bit_width = self.compressed_bit_width; - let uncompressed_bits_per_value = self.uncompressed_bits_per_value; - let num_rows = ranges.iter().map(|range| range.end - range.start).sum(); - - async move { - let bytes = bytes.await?; - let decompressed_output = bitpacked_for_non_neg_decode( - compressed_bit_width, - uncompressed_bits_per_value, - &bytes, - &bytes_idx_to_range_indices, - num_rows, - ); - Ok(Box::new(BitpackedForNonNegPageDecoder { - uncompressed_bits_per_value, - decompressed_buf: decompressed_output, - }) as Box) - } - .boxed() - } -} - -#[derive(Debug)] -struct BitpackedForNonNegPageDecoder { - // number of bits in the uncompressed value. E.g. this will be 32 for DataType::UInt32 - uncompressed_bits_per_value: u64, - - decompressed_buf: LanceBuffer, -} - -impl PrimitivePageDecoder for BitpackedForNonNegPageDecoder { - fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { - if ![8, 16, 32, 64].contains(&self.uncompressed_bits_per_value) { - return Err(Error::InvalidInput { - source: "BitpackedForNonNegPageDecoder should only has uncompressed_bits_per_value of 8, 16, 32, or 64".into(), - location: location!(), - }); - } - - let elem_size_in_bytes = self.uncompressed_bits_per_value / 8; - - Ok(DataBlock::FixedWidth(FixedWidthDataBlock { - data: self.decompressed_buf.slice_with_length( - (rows_to_skip * elem_size_in_bytes) as usize, - (num_rows * elem_size_in_bytes) as usize, - ), - bits_per_value: self.uncompressed_bits_per_value, - num_values: num_rows, - block_info: BlockInfo::new(), - })) - } -} - -macro_rules! bitpacked_decode { - ($uncompressed_type:ty, $compressed_bit_width:expr, $data:expr, $bytes_idx_to_range_indices:expr, $num_rows:expr) => {{ - let mut decompressed: Vec<$uncompressed_type> = Vec::with_capacity($num_rows as usize); - let packed_chunk_size_in_byte: usize = (ELEMS_PER_CHUNK * $compressed_bit_width) as usize / 8; - let mut decompress_chunk_buf = vec![0 as $uncompressed_type; ELEMS_PER_CHUNK as usize]; - - for (i, bytes) in $data.iter().enumerate() { - let mut ranges_idx = 0; - let mut curr_range_start = $bytes_idx_to_range_indices[i][0].start; - let mut chunk_num = 0; - - while chunk_num * packed_chunk_size_in_byte < bytes.len() { - // Copy for memory alignment - // TODO: This copy should not be needed - let chunk_in_u8: Vec = bytes[chunk_num * packed_chunk_size_in_byte..] - [..packed_chunk_size_in_byte] - .to_vec(); - chunk_num += 1; - let chunk = cast_slice(&chunk_in_u8); - unsafe { - BitPacking::unchecked_unpack( - $compressed_bit_width as usize, - chunk, - &mut decompress_chunk_buf, - ); - } - - loop { - // Case 1: All the elements after (curr_range_start % ELEMS_PER_CHUNK) inside this chunk are needed. - let elems_after_curr_range_start_in_this_chunk = - ELEMS_PER_CHUNK - curr_range_start % ELEMS_PER_CHUNK; - if curr_range_start + elems_after_curr_range_start_in_this_chunk - <= $bytes_idx_to_range_indices[i][ranges_idx].end - { - decompressed.extend_from_slice( - &decompress_chunk_buf[(curr_range_start % ELEMS_PER_CHUNK) as usize..], - ); - curr_range_start += elems_after_curr_range_start_in_this_chunk; - break; - } else { - // Case 2: Only part of the elements after (curr_range_start % ELEMS_PER_CHUNK) inside this chunk are needed. - let elems_this_range_needed_in_this_chunk = - ($bytes_idx_to_range_indices[i][ranges_idx].end - curr_range_start) - .min(ELEMS_PER_CHUNK - curr_range_start % ELEMS_PER_CHUNK); - decompressed.extend_from_slice( - &decompress_chunk_buf[(curr_range_start % ELEMS_PER_CHUNK) as usize..] - [..elems_this_range_needed_in_this_chunk as usize], - ); - if curr_range_start + elems_this_range_needed_in_this_chunk - == $bytes_idx_to_range_indices[i][ranges_idx].end - { - ranges_idx += 1; - if ranges_idx == $bytes_idx_to_range_indices[i].len() { - break; - } - curr_range_start = $bytes_idx_to_range_indices[i][ranges_idx].start; - } else { - curr_range_start += elems_this_range_needed_in_this_chunk; - } - } - } - } - } - - LanceBuffer::reinterpret_vec(decompressed) - }}; -} - -fn bitpacked_for_non_neg_decode( - compressed_bit_width: u64, - uncompressed_bits_per_value: u64, - data: &[Bytes], - bytes_idx_to_range_indices: &[Vec>], - num_rows: u64, -) -> LanceBuffer { - match uncompressed_bits_per_value { - 8 => bitpacked_decode!( - u8, - compressed_bit_width, - data, - bytes_idx_to_range_indices, - num_rows - ), - 16 => bitpacked_decode!( - u16, - compressed_bit_width, - data, - bytes_idx_to_range_indices, - num_rows - ), - 32 => bitpacked_decode!( - u32, - compressed_bit_width, - data, - bytes_idx_to_range_indices, - num_rows - ), - 64 => bitpacked_decode!( - u64, - compressed_bit_width, - data, - bytes_idx_to_range_indices, - num_rows - ), - _ => unreachable!( - "bitpacked_for_non_neg_decode only supports 8, 16, 32, 64 uncompressed_bits_per_value" - ), - } -} - -#[derive(Debug)] -pub struct BitpackParams { - pub num_bits: u64, - - pub signed: bool, -} - -// Compute the number of bits to use for each item, if this array can be encoded using -// bitpacking encoding. Returns `None` if the type or array data is not supported. -pub fn bitpack_params(arr: &dyn Array) -> Option { - match arr.data_type() { - DataType::UInt8 => bitpack_params_for_type::(arr.as_primitive()), - DataType::UInt16 => bitpack_params_for_type::(arr.as_primitive()), - DataType::UInt32 => bitpack_params_for_type::(arr.as_primitive()), - DataType::UInt64 => bitpack_params_for_type::(arr.as_primitive()), - DataType::Int8 => bitpack_params_for_signed_type::(arr.as_primitive()), - DataType::Int16 => bitpack_params_for_signed_type::(arr.as_primitive()), - DataType::Int32 => bitpack_params_for_signed_type::(arr.as_primitive()), - DataType::Int64 => bitpack_params_for_signed_type::(arr.as_primitive()), - // TODO -- eventually we could support temporal types as well - _ => None, - } -} - -// Compute the number bits to use for bitpacking generically. -// returns None if the array is empty or all nulls -fn bitpack_params_for_type(arr: &PrimitiveArray) -> Option -where - T: ArrowPrimitiveType, - T::Native: PrimInt + AsPrimitive, -{ - let max = arrow_arith::aggregate::bit_or(arr); - let num_bits = - max.map(|max| arr.data_type().byte_width() as u64 * 8 - max.leading_zeros() as u64); - - // we can't bitpack into 0 bits, so the minimum is 1 - num_bits - .map(|num_bits| num_bits.max(1)) - .map(|bits| BitpackParams { - num_bits: bits, - signed: false, - }) -} - -/// determine the minimum number of bits that can be used to represent -/// an array of signed values. It includes all the significant bits for -/// the value + plus 1 bit to represent the sign. If there are no negative values -/// then it will not add a signed bit -fn bitpack_params_for_signed_type(arr: &PrimitiveArray) -> Option -where - T: ArrowPrimitiveType, - T::Native: PrimInt + AsPrimitive, -{ - let mut add_signed_bit = false; - let mut min_leading_bits: Option = None; - for val in arr.iter() { - if val.is_none() { - continue; - } - let val = val.unwrap(); - if min_leading_bits.is_none() { - min_leading_bits = Some(u64::MAX); - } - - if val.to_i64().unwrap() < 0i64 { - min_leading_bits = min_leading_bits.map(|bits| bits.min(val.leading_ones() as u64)); - add_signed_bit = true; - } else { - min_leading_bits = min_leading_bits.map(|bits| bits.min(val.leading_zeros() as u64)); - } - } - - let mut min_leading_bits = arr.data_type().byte_width() as u64 * 8 - min_leading_bits?; - if add_signed_bit { - // Need extra sign bit - min_leading_bits += 1; - } - // cannot bitpack into <1 bit - let num_bits = min_leading_bits.max(1); - Some(BitpackParams { - num_bits, - signed: add_signed_bit, - }) -} -#[derive(Debug)] -pub struct BitpackedArrayEncoder { - num_bits: u64, - signed_type: bool, -} - -impl BitpackedArrayEncoder { - pub fn new(num_bits: u64, signed_type: bool) -> Self { - Self { - num_bits, - signed_type, - } - } -} - -impl ArrayEncoder for BitpackedArrayEncoder { - fn encode( - &self, - data: DataBlock, - _data_type: &DataType, - buffer_index: &mut u32, - ) -> Result { - // calculate the total number of bytes we need to allocate for the destination. - // this will be the number of items in the source array times the number of bits. - let dst_bytes_total = ceil(data.num_values() as usize * self.num_bits as usize, 8); - - let mut dst_buffer = vec![0u8; dst_bytes_total]; - let mut dst_idx = 0; - let mut dst_offset = 0; - - let DataBlock::FixedWidth(unpacked) = data else { - return Err(Error::InvalidInput { - source: "Bitpacking only supports fixed width data blocks".into(), - location: location!(), - }); - }; - - pack_bits( - &unpacked.data, - self.num_bits, - &mut dst_buffer, - &mut dst_idx, - &mut dst_offset, - ); - - let packed = DataBlock::FixedWidth(FixedWidthDataBlock { - bits_per_value: self.num_bits, - data: LanceBuffer::from(dst_buffer), - num_values: unpacked.num_values, - block_info: BlockInfo::new(), - }); - - let bitpacked_buffer_index = *buffer_index; - *buffer_index += 1; - - let encoding = ProtobufUtils::bitpacked_encoding( - self.num_bits, - unpacked.bits_per_value, - bitpacked_buffer_index, - self.signed_type, - ); - - Ok(EncodedArray { - data: packed, - encoding, - }) - } -} - -fn pack_bits( - src: &LanceBuffer, - num_bits: u64, - dst: &mut [u8], - dst_idx: &mut usize, - dst_offset: &mut u8, -) { - let bit_len = src.len() as u64 * 8; - - let mask = u64::MAX >> (64 - num_bits); - - let mut src_idx = 0; - while src_idx < src.len() { - let mut curr_mask = mask; - let mut curr_src = src[src_idx] & curr_mask as u8; - let mut src_offset = 0; - let mut src_bits_written = 0; - - while src_bits_written < num_bits { - dst[*dst_idx] += (curr_src >> src_offset) << *dst_offset as u64; - let bits_written = (num_bits - src_bits_written) - .min(8 - src_offset) - .min(8 - *dst_offset as u64); - src_bits_written += bits_written; - *dst_offset += bits_written as u8; - src_offset += bits_written; - - if *dst_offset == 8 { - *dst_idx += 1; - *dst_offset = 0; - } - - if src_offset == 8 { - src_idx += 1; - src_offset = 0; - curr_mask >>= 8; - if src_idx == src.len() { - break; - } - curr_src = src[src_idx] & curr_mask as u8; - } - } - - // advance source_offset to the next byte if we're not at the end.. - // note that we don't need to do this if we wrote the full number of bits - // because source index would have been advanced by the inner loop above - if bit_len != num_bits { - let partial_bytes_written = ceil(num_bits as usize, 8); - - // we also want to the next location in src, unless we wrote something - // byte-aligned in which case the logic above would have already advanced - let mut to_next_byte = 1; - if num_bits.is_multiple_of(8) { - to_next_byte = 0; - } - - src_idx += src.len() - partial_bytes_written + to_next_byte; - } - } -} - -// A physical scheduler for bitpacked buffers -#[derive(Debug, Clone, Copy)] -pub struct BitpackedScheduler { - bits_per_value: u64, - uncompressed_bits_per_value: u64, - buffer_offset: u64, - signed: bool, -} - -impl BitpackedScheduler { - pub fn new( - bits_per_value: u64, - uncompressed_bits_per_value: u64, - buffer_offset: u64, - signed: bool, - ) -> Self { - Self { - bits_per_value, - uncompressed_bits_per_value, - buffer_offset, - signed, - } - } -} - -impl PageScheduler for BitpackedScheduler { - fn schedule_ranges( - &self, - ranges: &[std::ops::Range], - scheduler: &Arc, - top_level_row: u64, - ) -> BoxFuture<'static, Result>> { - let mut min = u64::MAX; - let mut max = 0; - - let mut buffer_bit_start_offsets: Vec = vec![]; - let mut buffer_bit_end_offsets: Vec> = vec![]; - let byte_ranges = ranges - .iter() - .map(|range| { - let start_byte_offset = range.start * self.bits_per_value / 8; - let mut end_byte_offset = range.end * self.bits_per_value / 8; - if !(range.end * self.bits_per_value).is_multiple_of(8) { - // If the end of the range is not byte-aligned, we need to read one more byte - end_byte_offset += 1; - - let end_bit_offset = range.end * self.bits_per_value % 8; - buffer_bit_end_offsets.push(Some(end_bit_offset as u8)); - } else { - buffer_bit_end_offsets.push(None); - } - - let start_bit_offset = range.start * self.bits_per_value % 8; - buffer_bit_start_offsets.push(start_bit_offset as u8); - - let start = self.buffer_offset + start_byte_offset; - let end = self.buffer_offset + end_byte_offset; - min = min.min(start); - max = max.max(end); - - start..end - }) - .collect::>(); - - trace!( - "Scheduling I/O for {} ranges spread across byte range {}..{}", - byte_ranges.len(), - min, - max - ); - - let bytes = scheduler.submit_request(byte_ranges, top_level_row); - - let bits_per_value = self.bits_per_value; - let uncompressed_bits_per_value = self.uncompressed_bits_per_value; - let signed = self.signed; - async move { - let bytes = bytes.await?; - Ok(Box::new(BitpackedPageDecoder { - buffer_bit_start_offsets, - buffer_bit_end_offsets, - bits_per_value, - uncompressed_bits_per_value, - signed, - data: bytes, - }) as Box) - } - .boxed() - } -} - -#[derive(Debug)] -struct BitpackedPageDecoder { - // bit offsets of the first value within each buffer - buffer_bit_start_offsets: Vec, - - // bit offsets of the last value within each buffer. e.g. if there was a buffer - // with 2 values, packed into 5 bits, this would be [Some(3)], indicating that - // the bits from the 3rd->8th bit in the last byte shouldn't be decoded. - buffer_bit_end_offsets: Vec>, - - // the number of bits used to represent a compressed value. E.g. if the max value - // in the page was 7 (0b111), then this will be 3 - bits_per_value: u64, - - // number of bits in the uncompressed value. E.g. this will be 32 for u32 - uncompressed_bits_per_value: u64, - - // whether or not to use the msb as a sign bit during decoding - signed: bool, - - data: Vec, -} - -impl PrimitivePageDecoder for BitpackedPageDecoder { - fn decode(&self, rows_to_skip: u64, num_rows: u64) -> Result { - let num_bytes = self.uncompressed_bits_per_value / 8 * num_rows; - let mut dest = vec![0; num_bytes as usize]; - - // current maximum supported bits per value = 64 - debug_assert!(self.bits_per_value <= 64); - - let mut rows_to_skip = rows_to_skip; - let mut rows_taken = 0; - let byte_len = self.uncompressed_bits_per_value / 8; - let mut dst_idx = 0; // index for current byte being written to destination buffer - - // create bit mask for source bits - let mask = u64::MAX >> (64 - self.bits_per_value); - - for i in 0..self.data.len() { - let src = &self.data[i]; - let (mut src_idx, mut src_offset) = match compute_start_offset( - rows_to_skip, - src.len(), - self.bits_per_value, - self.buffer_bit_start_offsets[i], - self.buffer_bit_end_offsets[i], - ) { - StartOffset::SkipFull(rows_to_skip_here) => { - rows_to_skip -= rows_to_skip_here; - continue; - } - StartOffset::SkipSome(buffer_start_offset) => ( - buffer_start_offset.index, - buffer_start_offset.bit_offset as u64, - ), - }; - - while src_idx < src.len() && rows_taken < num_rows { - rows_taken += 1; - let mut curr_mask = mask; // copy mask - - // current source byte being written to destination - let mut curr_src = src[src_idx] & (curr_mask << src_offset) as u8; - - // how many bits from the current source value have been written to destination - let mut src_bits_written = 0; - - // the offset within the current destination byte to write to - let mut dst_offset = 0; - - let is_negative = is_encoded_item_negative( - src, - src_idx, - src_offset, - self.bits_per_value as usize, - ); - - while src_bits_written < self.bits_per_value { - // write bits from current source byte into destination - dest[dst_idx] += (curr_src >> src_offset) << dst_offset; - let bits_written = (self.bits_per_value - src_bits_written) - .min(8 - src_offset) - .min(8 - dst_offset); - src_bits_written += bits_written; - dst_offset += bits_written; - src_offset += bits_written; - curr_mask >>= bits_written; - - if dst_offset == 8 { - dst_idx += 1; - dst_offset = 0; - } - - if src_offset == 8 { - src_idx += 1; - src_offset = 0; - if src_idx == src.len() { - break; - } - curr_src = src[src_idx] & curr_mask as u8; - } - } - - // if the type is signed, need to pad out the rest of the byte with 1s - let mut negative_padded_current_byte = false; - if self.signed && is_negative && dst_offset > 0 { - negative_padded_current_byte = true; - while dst_offset < 8 { - dest[dst_idx] |= 1 << dst_offset; - dst_offset += 1; - } - } - - // advance destination offset to the next location - // note that we don't need to do this if we wrote the full number of bits - // because source index would have been advanced by the inner loop above - if self.uncompressed_bits_per_value != self.bits_per_value { - let partial_bytes_written = ceil(self.bits_per_value as usize, 8); - - // we also want to move one location to the next location in destination, - // unless we wrote something byte-aligned in which case the logic above - // would have already advanced dst_idx - let mut to_next_byte = 1; - if self.bits_per_value.is_multiple_of(8) { - to_next_byte = 0; - } - let next_dst_idx = - dst_idx + byte_len as usize - partial_bytes_written + to_next_byte; - - // pad remaining bytes with 1 for negative signed numbers - if self.signed && is_negative { - if !negative_padded_current_byte { - dest[dst_idx] = 0xFF; - } - for i in dest.iter_mut().take(next_dst_idx).skip(dst_idx + 1) { - *i = 0xFF; - } - } - - dst_idx = next_dst_idx; - } - - // If we've reached the last byte, there may be some extra bits from the - // next value outside the range. We don't want to be taking those. - if let Some(buffer_bit_end_offset) = self.buffer_bit_end_offsets[i] { - if src_idx == src.len() - 1 && src_offset >= buffer_bit_end_offset as u64 { - break; - } - } - } - } - - Ok(DataBlock::FixedWidth(FixedWidthDataBlock { - data: LanceBuffer::from(dest), - bits_per_value: self.uncompressed_bits_per_value, - num_values: num_rows, - block_info: BlockInfo::new(), - })) - } -} - -fn is_encoded_item_negative(src: &Bytes, src_idx: usize, src_offset: u64, num_bits: usize) -> bool { - let mut last_byte_idx = src_idx + ((src_offset as usize + num_bits) / 8); - let shift_amount = (src_offset as usize + num_bits) % 8; - let shift_amount = if shift_amount == 0 { - last_byte_idx -= 1; - 7 - } else { - shift_amount - 1 - }; - let last_byte = src[last_byte_idx]; - let sign_bit_mask = 1 << shift_amount; - let sign_bit = last_byte & sign_bit_mask; - - sign_bit > 0 -} - -#[derive(Debug, PartialEq)] -struct BufferStartOffset { - index: usize, - bit_offset: u8, -} - -#[derive(Debug, PartialEq)] -enum StartOffset { - // skip the full buffer. The value is how many rows are skipped - // by skipping the full buffer (e.g., # rows in buffer) - SkipFull(u64), - - // skip to some start offset in the buffer - SkipSome(BufferStartOffset), -} - -/// compute how far ahead in this buffer should we skip ahead and start reading -/// -/// * `rows_to_skip` - how many rows to skip -/// * `buffer_len` - length buf buffer (in bytes) -/// * `bits_per_value` - number of bits used to represent a single bitpacked value -/// * `buffer_start_bit_offset` - offset of the start of the first value within the -/// buffer's first byte -/// * `buffer_end_bit_offset` - end bit of the last value within the buffer. Can be -/// `None` if the end of the last value is byte aligned with end of buffer. -fn compute_start_offset( - rows_to_skip: u64, - buffer_len: usize, - bits_per_value: u64, - buffer_start_bit_offset: u8, - buffer_end_bit_offset: Option, -) -> StartOffset { - let rows_in_buffer = rows_in_buffer( - buffer_len, - bits_per_value, - buffer_start_bit_offset, - buffer_end_bit_offset, - ); - if rows_to_skip >= rows_in_buffer { - return StartOffset::SkipFull(rows_in_buffer); - } - - let start_bit = rows_to_skip * bits_per_value + buffer_start_bit_offset as u64; - let start_byte = start_bit / 8; - - StartOffset::SkipSome(BufferStartOffset { - index: start_byte as usize, - bit_offset: (start_bit % 8) as u8, - }) -} - -/// calculates the number of rows in a buffer -fn rows_in_buffer( - buffer_len: usize, - bits_per_value: u64, - buffer_start_bit_offset: u8, - buffer_end_bit_offset: Option, -) -> u64 { - let mut bits_in_buffer = (buffer_len * 8) as u64 - buffer_start_bit_offset as u64; - - // if the end of the last value of the buffer isn't byte aligned, subtract the - // end offset from the total number of bits in buffer - if let Some(buffer_end_bit_offset) = buffer_end_bit_offset { - bits_in_buffer -= (8 - buffer_end_bit_offset) as u64; - } - - bits_in_buffer / bits_per_value -} - -#[cfg(test)] -pub mod test { - use crate::{ - format::pb, - testing::{check_round_trip_encoding_generated, ArrayGeneratorProvider, TestCases}, - version::LanceFileVersion, - }; - - use super::*; - use std::{marker::PhantomData, sync::Arc}; - - use arrow_array::{ - types::{UInt16Type, UInt8Type}, - ArrayRef, Float32Array, Float64Array, Int16Array, Int32Array, Int64Array, Int8Array, - UInt16Array, UInt32Array, UInt64Array, UInt8Array, - }; - - use arrow_schema::Field; - use lance_datagen::{ - array::{fill, rand_with_distribution}, - gen_batch, ArrayGenerator, ArrayGeneratorExt, RowCount, - }; - use rand::distr::Uniform; - - #[test] - fn test_bitpack_params() { - fn gen_array(generator: Box) -> ArrayRef { - let arr = gen_batch() - .anon_col(generator) - .into_batch_rows(RowCount::from(10000)) - .unwrap() - .column(0) - .clone(); - - arr - } - - macro_rules! do_test { - ($num_bits:expr, $data_type:ident, $null_probability:expr) => { - let max = 1 << $num_bits - 1; - let mut arr = - gen_array(fill::<$data_type>(max).with_random_nulls($null_probability)); - - // ensure we don't randomly generate all nulls, that won't work - while arr.null_count() == arr.len() { - arr = gen_array(fill::<$data_type>(max).with_random_nulls($null_probability)); - } - let result = bitpack_params(arr.as_ref()); - assert!(result.is_some()); - assert_eq!($num_bits, result.unwrap().num_bits); - }; - } - - let test_cases = vec![ - (5u64, 0.0f64), - (5u64, 0.9f64), - (1u64, 0.0f64), - (1u64, 0.5f64), - (8u64, 0.0f64), - (8u64, 0.5f64), - ]; - - for (num_bits, null_probability) in &test_cases { - do_test!(*num_bits, UInt8Type, *null_probability); - do_test!(*num_bits, UInt16Type, *null_probability); - do_test!(*num_bits, UInt32Type, *null_probability); - do_test!(*num_bits, UInt64Type, *null_probability); - } - - // do some test cases that that will only work on larger types - let test_cases = vec![ - (13u64, 0.0f64), - (13u64, 0.5f64), - (16u64, 0.0f64), - (16u64, 0.5f64), - ]; - for (num_bits, null_probability) in &test_cases { - do_test!(*num_bits, UInt16Type, *null_probability); - do_test!(*num_bits, UInt32Type, *null_probability); - do_test!(*num_bits, UInt64Type, *null_probability); - } - let test_cases = vec![ - (25u64, 0.0f64), - (25u64, 0.5f64), - (32u64, 0.0f64), - (32u64, 0.5f64), - ]; - for (num_bits, null_probability) in &test_cases { - do_test!(*num_bits, UInt32Type, *null_probability); - do_test!(*num_bits, UInt64Type, *null_probability); - } - let test_cases = vec![ - (48u64, 0.0f64), - (48u64, 0.5f64), - (64u64, 0.0f64), - (64u64, 0.5f64), - ]; - for (num_bits, null_probability) in &test_cases { - do_test!(*num_bits, UInt64Type, *null_probability); - } - - // test that it returns None for datatypes that don't support bitpacking - let arr = Float64Array::from_iter_values(vec![0.1, 0.2, 0.3]); - let result = bitpack_params(&arr); - assert!(result.is_none()); - } - - #[test] - fn test_num_compressed_bits_signed_types() { - let values = Int32Array::from(vec![1, 2, -7]); - let arr = values; - let result = bitpack_params(&arr); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(4, result.num_bits); - assert!(result.signed); - - // check that it doesn't add a sign bit if it doesn't need to - let values = Int32Array::from(vec![1, 2, 7]); - let arr = values; - let result = bitpack_params(&arr); - assert!(result.is_some()); - let result = result.unwrap(); - assert_eq!(3, result.num_bits); - assert!(!result.signed); - } - - #[test] - fn test_rows_in_buffer() { - let test_cases = vec![ - (5usize, 5u64, 0u8, None, 8u64), - (2, 3, 0, Some(5), 4), - (2, 3, 7, Some(6), 2), - ]; - - for ( - buffer_len, - bits_per_value, - buffer_start_bit_offset, - buffer_end_bit_offset, - expected, - ) in test_cases - { - let result = rows_in_buffer( - buffer_len, - bits_per_value, - buffer_start_bit_offset, - buffer_end_bit_offset, - ); - assert_eq!(expected, result); - } - } - - #[test] - fn test_compute_start_offset() { - let result = compute_start_offset(0, 5, 5, 0, None); - assert_eq!( - StartOffset::SkipSome(BufferStartOffset { - index: 0, - bit_offset: 0 - }), - result - ); - - let result = compute_start_offset(10, 5, 5, 0, None); - assert_eq!(StartOffset::SkipFull(8), result); - } - - #[test_log::test(test)] - fn test_will_bitpack_allowed_types_when_possible() { - let test_cases: Vec<(DataType, ArrayRef, u64)> = vec![ - ( - DataType::UInt8, - Arc::new(UInt8Array::from_iter_values(vec![0, 1, 2, 3, 4, 5])), - 3, // bits per value - ), - ( - DataType::UInt16, - Arc::new(UInt16Array::from_iter_values(vec![0, 1, 2, 3, 4, 5 << 8])), - 11, - ), - ( - DataType::UInt32, - Arc::new(UInt32Array::from_iter_values(vec![0, 1, 2, 3, 4, 5 << 16])), - 19, - ), - ( - DataType::UInt64, - Arc::new(UInt64Array::from_iter_values(vec![0, 1, 2, 3, 4, 5 << 32])), - 35, - ), - ( - DataType::Int8, - Arc::new(Int8Array::from_iter_values(vec![0, 2, 3, 4, -5])), - 4, - ), - ( - // check it will not pack with signed bit if all values of signed type are positive - DataType::Int8, - Arc::new(Int8Array::from_iter_values(vec![0, 2, 3, 4, 5])), - 3, - ), - ( - DataType::Int16, - Arc::new(Int16Array::from_iter_values(vec![0, 1, 2, 3, -4, 5 << 8])), - 12, - ), - ( - DataType::Int32, - Arc::new(Int32Array::from_iter_values(vec![0, 1, 2, 3, 4, -5 << 16])), - 20, - ), - ( - DataType::Int64, - Arc::new(Int64Array::from_iter_values(vec![ - 0, - 1, - 2, - -3, - -4, - -5 << 32, - ])), - 36, - ), - ]; - - for (data_type, arr, bits_per_value) in test_cases { - let mut buffed_index = 1; - let params = bitpack_params(arr.as_ref()).unwrap(); - let encoder = BitpackedArrayEncoder { - num_bits: params.num_bits, - signed_type: params.signed, - }; - let data = DataBlock::from_array(arr); - let result = encoder.encode(data, &data_type, &mut buffed_index).unwrap(); - - let data = result.data.as_fixed_width().unwrap(); - assert_eq!(bits_per_value, data.bits_per_value); - - let array_encoding = result.encoding.array_encoding.unwrap(); - - match array_encoding { - pb::array_encoding::ArrayEncoding::Bitpacked(bitpacked) => { - assert_eq!(bits_per_value, bitpacked.compressed_bits_per_value); - assert_eq!( - (data_type.byte_width() * 8) as u64, - bitpacked.uncompressed_bits_per_value - ); - } - _ => { - panic!("Array did not use bitpacking encoding") - } - } - } - - // check it will otherwise use flat encoding - let test_cases: Vec<(DataType, ArrayRef)> = vec![ - // it should use flat encoding for datatypes that don't support bitpacking - ( - DataType::Float32, - Arc::new(Float32Array::from_iter_values(vec![0.1, 0.2, 0.3])), - ), - // it should still use flat encoding if bitpacked encoding would be packed - // into the full byte range - ( - DataType::UInt8, - Arc::new(UInt8Array::from_iter_values(vec![0, 1, 2, 3, 4, 250])), - ), - ( - DataType::UInt16, - Arc::new(UInt16Array::from_iter_values(vec![0, 1, 2, 3, 4, 250 << 8])), - ), - ( - DataType::UInt32, - Arc::new(UInt32Array::from_iter_values(vec![ - 0, - 1, - 2, - 3, - 4, - 250 << 24, - ])), - ), - ( - DataType::UInt64, - Arc::new(UInt64Array::from_iter_values(vec![ - 0, - 1, - 2, - 3, - 4, - 250 << 56, - ])), - ), - ( - DataType::Int8, - Arc::new(Int8Array::from_iter_values(vec![-100])), - ), - ( - DataType::Int16, - Arc::new(Int16Array::from_iter_values(vec![-100 << 8])), - ), - ( - DataType::Int32, - Arc::new(Int32Array::from_iter_values(vec![-100 << 24])), - ), - ( - DataType::Int64, - Arc::new(Int64Array::from_iter_values(vec![-100 << 56])), - ), - ]; - - for (data_type, arr) in test_cases { - if let Some(params) = bitpack_params(arr.as_ref()) { - assert_eq!(params.num_bits, data_type.byte_width() as u64 * 8); - } - } - } - - struct DistributionArrayGeneratorProvider< - DataType, - Dist: rand::distr::Distribution + Clone + Send + Sync + 'static, - > - where - DataType::Native: Copy + 'static, - PrimitiveArray: From> + 'static, - DataType: ArrowPrimitiveType, - { - phantom: PhantomData, - distribution: Dist, - } - - impl DistributionArrayGeneratorProvider - where - Dist: rand::distr::Distribution + Clone + Send + Sync + 'static, - DataType::Native: Copy + 'static, - PrimitiveArray: From> + 'static, - DataType: ArrowPrimitiveType, - { - fn new(dist: Dist) -> Self { - Self { - distribution: dist, - phantom: Default::default(), - } - } - } - - impl ArrayGeneratorProvider for DistributionArrayGeneratorProvider - where - Dist: rand::distr::Distribution + Clone + Send + Sync + 'static, - DataType::Native: Copy + 'static, - PrimitiveArray: From> + 'static, - DataType: ArrowPrimitiveType, - { - fn provide(&self) -> Box { - rand_with_distribution::(self.distribution.clone()) - } - - fn copy(&self) -> Box { - Box::new(Self { - phantom: self.phantom, - distribution: self.distribution.clone(), - }) - } - } - - #[test_log::test(tokio::test)] - async fn test_bitpack_primitive() { - let bitpacked_test_cases: &Vec<(DataType, Box)> = &vec![ - // check less than one byte for multi-byte type - ( - DataType::UInt32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(0, 19).unwrap(), - ), - ), - ), - // // check that more than one byte for multi-byte type - ( - DataType::UInt32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(5 << 7, 6 << 7).unwrap(), - ), - ), - ), - ( - DataType::UInt64, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(5 << 42, 6 << 42).unwrap(), - ), - ), - ), - // check less than one byte for single-byte type - ( - DataType::UInt8, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(0, 19).unwrap(), - ), - ), - ), - // check less than one byte for single-byte type - ( - DataType::UInt64, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(129, 259).unwrap(), - ), - ), - ), - // check byte aligned for single byte - ( - DataType::UInt32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - // this range should always give 8 bits - Uniform::new(200, 250).unwrap(), - ), - ), - ), - // check where the num_bits divides evenly into the bit length of the type - ( - DataType::UInt64, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(1, 3).unwrap(), // 2 bits - ), - ), - ), - // check byte aligned for multiple bytes - ( - DataType::UInt32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - // this range should always always give 16 bits - Uniform::new(200 << 8, 250 << 8).unwrap(), - ), - ), - ), - // check byte aligned where the num bits doesn't divide evenly into the byte length - ( - DataType::UInt64, - Box::new( - DistributionArrayGeneratorProvider::>::new( - // this range should always give 24 hits - Uniform::new(200 << 16, 250 << 16).unwrap(), - ), - ), - ), - // check that we can still encode an all-0 array - ( - DataType::UInt32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(0, 1).unwrap(), - ), - ), - ), - // check for signed types - ( - DataType::Int16, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(-5, 5).unwrap(), - ), - ), - ), - ( - DataType::Int64, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(-(5 << 42), 6 << 42).unwrap(), - ), - ), - ), - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(-(5 << 7), 6 << 7).unwrap(), - ), - ), - ), - // check signed where packed to < 1 byte for multi-byte type - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(-19, 19).unwrap(), - ), - ), - ), - // check signed byte aligned to single byte - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - // this range should always give 8 bits - Uniform::new(-120, 120).unwrap(), - ), - ), - ), - // check signed byte aligned to multiple bytes - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - // this range should always give 16 bits - Uniform::new(-120 << 8, 120 << 8).unwrap(), - ), - ), - ), - // check that it works for all positive integers even if type is signed - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(10, 20).unwrap(), - ), - ), - ), - // check that all 0 works for signed type - ( - DataType::Int32, - Box::new( - DistributionArrayGeneratorProvider::>::new( - Uniform::new(0, 1).unwrap(), - ), - ), - ), - ]; - - for (data_type, array_gen_provider) in bitpacked_test_cases { - let field = Field::new("", data_type.clone(), false); - let test_cases = TestCases::basic().with_min_file_version(LanceFileVersion::V2_1); - check_round_trip_encoding_generated(field, array_gen_provider.copy(), test_cases).await; - } - } -} diff --git a/rust/lance-encoding/src/repdef.rs b/rust/lance-encoding/src/repdef.rs index 1cbe5592f1e..1c102d1e740 100644 --- a/rust/lance-encoding/src/repdef.rs +++ b/rust/lance-encoding/src/repdef.rs @@ -108,6 +108,7 @@ use std::{ iter::{Copied, Zip}, + ops::Range, sync::Arc, }; @@ -115,13 +116,41 @@ use arrow_array::OffsetSizeTrait; use arrow_buffer::{ ArrowNativeType, BooleanBuffer, BooleanBufferBuilder, NullBuffer, OffsetBuffer, ScalarBuffer, }; -use lance_core::{utils::bit::log_2_ceil, Error, Result}; -use snafu::location; +use lance_core::{Error, Result, utils::bit::log_2_ceil}; -use crate::buffer::LanceBuffer; +use crate::{ + buffer::LanceBuffer, + encodings::logical::primitive::sparse::{SparseStructuralPlan, SparseStructuralUnraveler}, +}; pub type LevelBuffer = Vec; +/// A top-level-row range whose dense rep/def stream fits one mini-block page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct MiniBlockRepDefSplit { + /// Top-level row offset, relative to the original unsplit page. + pub(crate) row_start: u64, + /// Number of top-level rows in this split. + pub(crate) num_rows: u64, + /// Rep/def level range, relative to the original unsplit page. + pub(crate) level_range: Range, + /// Visible value offset, relative to the original unsplit page. + pub(crate) value_start: u64, + /// Number of visible values in this split. + pub(crate) num_values: u64, +} + +/// Dense mini-block rep/def budget result for one accumulated page. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum MiniBlockRepDefBudget { + /// The dense rep/def stream fits one mini-block structural page. + WithinBudget, + /// The dense rep/def stream fits after splitting on top-level row boundaries. + RequiresPageSplit(Vec), + /// A single top-level row has this many rep/def levels and exceeds the budget. + SingleRowOverBudget(u64), +} + // As we build def levels we add this to special values to indicate that they // are special so that we can skip over them when processing lower levels. // @@ -173,6 +202,143 @@ enum RawRepDef { Fsl(FslDesc), } +/// A normalized Arrow structural layer shared by dense and sparse serializers. +#[derive(Clone, Copy, Debug)] +pub(crate) enum NormalizedStructuralLayer<'a> { + List { + offsets: &'a [i64], + validity: Option<&'a BooleanBuffer>, + num_slots: usize, + }, + Validity { + validity: Option<&'a BooleanBuffer>, + num_slots: usize, + }, + FixedSizeList { + validity: Option<&'a BooleanBuffer>, + dimension: usize, + num_slots: usize, + }, +} + +/// Structural layers concatenated across input batches exactly once. +/// +/// Dense rep/def serialization and sparse metadata planning both consume this +/// representation so the Arrow nesting is not independently reconstructed. +#[derive(Debug)] +pub(crate) struct NormalizedStructuralPlan { + layers: Vec, + dense_all_valid: bool, +} + +impl NormalizedStructuralPlan { + pub(crate) fn layers(&self) -> impl ExactSizeIterator> { + self.layers.iter().map(|layer| match layer { + RawRepDef::Offsets(OffsetDesc { + offsets, + validity, + num_values, + .. + }) => NormalizedStructuralLayer::List { + offsets, + validity: validity.as_ref(), + num_slots: *num_values, + }, + RawRepDef::Validity(ValidityDesc { + validity, + num_values, + }) => NormalizedStructuralLayer::Validity { + validity: validity.as_ref(), + num_slots: *num_values, + }, + RawRepDef::Fsl(FslDesc { + validity, + dimension, + num_values, + }) => NormalizedStructuralLayer::FixedSizeList { + validity: validity.as_ref(), + dimension: *dimension, + num_slots: *num_values, + }, + }) + } + + fn to_serializer(&self) -> (SerializerContext, Option) { + if self.dense_all_valid { + let def_meaning = self + .layers + .iter() + .map(|_| DefinitionInterpretation::AllValidItem) + .collect::>(); + return ( + SerializerContext { + def_meaning, + rep_levels: LevelBuffer::default(), + spare_rep: LevelBuffer::default(), + def_levels: LevelBuffer::default(), + spare_def: LevelBuffer::default(), + current_rep: 0, + current_def: 0, + current_len: 0, + current_num_specials: 0, + has_fsl: false, + }, + None, + ); + } + + let total_len = self.layers.last().map_or(0, RawRepDef::num_values) + + self + .layers + .iter() + .map(RawRepDef::num_specials) + .sum::(); + let max_rep = self.layers.iter().map(RawRepDef::max_rep).sum::(); + let max_def = self.layers.iter().map(RawRepDef::max_def).sum::(); + let bits_per_rep = if max_rep > 0 { + u64::from(u16::BITS - max_rep.leading_zeros()) + } else { + 0 + }; + let bits_per_def = if max_def > 0 { + u64::from(u16::BITS - max_def.leading_zeros()) + } else { + 0 + }; + let bits_per_level = + (bits_per_rep + bits_per_def > 0).then_some(bits_per_rep + bits_per_def); + + let num_layers = self.layers.len(); + let mut context = SerializerContext::new(total_len, num_layers, max_rep, max_def); + for layer in &self.layers { + match layer { + RawRepDef::Validity(def) => context.record_validity(def), + RawRepDef::Offsets(rep) => context.record_offsets(rep), + RawRepDef::Fsl(fsl) => context.record_fsl(fsl), + } + } + (context, bits_per_level) + } + + pub(crate) fn serialize(&self) -> SerializedRepDefs { + self.to_serializer().0.build() + } + + pub(crate) fn serialize_with_miniblock_repdef_budget( + &self, + max_levels_for_bits: impl FnOnce(u64) -> u64, + num_rows: u64, + num_values: u64, + ) -> Result<(SerializedRepDefs, MiniBlockRepDefBudget)> { + let (context, bits_per_level) = self.to_serializer(); + context.build_with_miniblock_repdef_budget( + bits_per_level.map(max_levels_for_bits), + num_rows, + num_values, + ) + } +} + impl RawRepDef { // Are there any nulls in this layer fn has_nulls(&self) -> bool { @@ -254,27 +420,62 @@ pub struct SerializedRepDefs { /// /// This is None if there are no lists pub max_visible_level: Option, + has_fsl: bool, +} + +/// The highest definition level whose items still map to a value in the leaf array +/// +/// `def_meaning` is ordered leaf-first, so every layer ahead of the first list layer sits +/// underneath that list. Once a list boundary is crossed, a null (or empty) list at any +/// outer layer has no value in the leaf at all and its item is "invisible". +/// +/// Both the writer and every decoder decide whether an item owns a value by comparing +/// `def <= max_visible_level`, so the two sides must derive it the same way. When there +/// are no lists this sums every layer, i.e. `max_def`, which makes all items visible. +pub fn max_visible_level(def_meaning: &[DefinitionInterpretation]) -> u16 { + def_meaning + .iter() + .take_while(|level| !level.is_list()) + .map(|level| level.num_def_levels()) + .sum() } impl SerializedRepDefs { + /// The same rule as [`max_visible_level`], but `None` when there are no lists and so + /// no item can be invisible in the first place. + fn max_visible_level(def_meaning: &[DefinitionInterpretation]) -> Option { + def_meaning + .iter() + .any(|level| level.is_list()) + .then(|| max_visible_level(def_meaning)) + } + pub fn new( repetition_levels: Option, definition_levels: Option, def_meaning: Vec, ) -> Self { - let first_list = def_meaning.iter().position(|level| level.is_list()); - let max_visible_level = first_list.map(|first_list| { - def_meaning - .iter() - .map(|level| level.num_def_levels()) - .take(first_list) - .sum::() - }); + Self::new_with_fixed_size_list_levels( + repetition_levels, + definition_levels, + def_meaning, + false, + ) + } + + pub(crate) fn new_with_fixed_size_list_levels( + repetition_levels: Option, + definition_levels: Option, + def_meaning: Vec, + has_fsl: bool, + ) -> Self { + let max_visible_level = Self::max_visible_level(&def_meaning); Self { repetition_levels: repetition_levels.map(Arc::from), definition_levels: definition_levels.map(Arc::from), def_meaning, max_visible_level, + has_fsl, } } @@ -285,6 +486,7 @@ impl SerializedRepDefs { definition_levels: None, def_meaning, max_visible_level: None, + has_fsl: false, } } @@ -299,6 +501,10 @@ impl SerializedRepDefs { .as_ref() .map(|def| RepDefSlicer::new(self, def.clone())) } + + pub(crate) fn has_fixed_size_list_levels(&self) -> bool { + self.has_fsl + } } /// Slices a level buffer into pieces @@ -462,6 +668,7 @@ struct SerializerContext { current_def: u16, current_len: usize, current_num_specials: usize, + has_fsl: bool, } impl SerializerContext { @@ -493,6 +700,7 @@ impl SerializerContext { current_def: max_def, current_len: 0, current_num_specials: 0, + has_fsl: false, } } @@ -630,7 +838,7 @@ impl SerializerContext { debug_assert!( self.current_len == 0 || self.current_len == validity.len() + self.current_num_specials ); - self.current_len = validity.len(); + self.current_len = validity.len() + self.current_num_specials; let mut def_read_itr = self.def_levels.iter().copied(); let mut def_write_itr = self.spare_def.iter_mut(); @@ -734,6 +942,7 @@ impl SerializerContext { } fn record_fsl(&mut self, fsl_desc: &FslDesc) { + self.has_fsl = true; self.record_validity_buf(&fsl_desc.validity); self.multiply_levels(fsl_desc.dimension); } @@ -746,9 +955,202 @@ impl SerializerContext { } } + fn normalize_specials_and_plan_splits( + &mut self, + def_meaning: &[DefinitionInterpretation], + max_levels_per_page: Option, + num_rows: u64, + num_values: u64, + ) -> Result { + // Extremely sparse lists can have many rep/def levels for very few + // visible leaf values. If this ratio becomes too skewed then a + // mini-block rep/def chunk can exceed its packed metadata budget even + // though the value buffers are small. We detect that case while + // normalizing special def levels and split on top-level row boundaries + // so each emitted dense mini-block page stays within the budget. + if self.def_levels.is_empty() { + return Ok(MiniBlockRepDefBudget::WithinBudget); + } + + if self.rep_levels.is_empty() { + self.normalize_specials(); + return Ok(MiniBlockRepDefBudget::WithinBudget); + } + + if self.rep_levels.len() != self.def_levels.len() { + return Err(Error::internal(format!( + "Cannot plan structural page splits with mismatched rep/def lengths: rep={}, def={}", + self.rep_levels.len(), + self.def_levels.len() + ))); + } + + let Some(max_levels_per_page) = max_levels_per_page else { + self.normalize_specials(); + return Ok(MiniBlockRepDefBudget::WithinBudget); + }; + + if num_values == 0 { + self.normalize_specials(); + return Ok(MiniBlockRepDefBudget::WithinBudget); + } + + let max_schema_rep = def_meaning.iter().filter(|level| level.is_list()).count() as u16; + let max_visible_level = SerializedRepDefs::max_visible_level(def_meaning); + let should_plan = !self.has_fsl && max_schema_rep > 0 && max_visible_level.is_some(); + + if !should_plan { + self.normalize_specials(); + return Ok(MiniBlockRepDefBudget::WithinBudget); + } + + let max_visible_level = max_visible_level.unwrap(); + let mut splits = Vec::new(); + let mut counted_rows = 0u64; + let mut counted_values = 0u64; + let mut saw_structural_overhead = false; + let mut single_row_over_budget_levels = None; + + let mut current_row_level_start = None; + let mut current_row_num_values = 0u64; + + let mut current_page_row_start = 0u64; + let mut current_page_num_rows = 0u64; + let mut current_page_level_start = 0usize; + let mut current_page_level_end = 0usize; + let mut current_page_value_start = 0u64; + let mut current_page_num_values = 0u64; + let mut current_page_num_levels = 0u64; + let mut current_page_has_structural_overhead = false; + + let mut finish_row = + |row_level_start: usize, row_level_end: usize, row_num_values: u64| -> Result<()> { + let row_num_levels = (row_level_end - row_level_start) as u64; + let row_has_structural_overhead = row_num_levels > row_num_values; + saw_structural_overhead |= row_has_structural_overhead; + + if row_has_structural_overhead && row_num_levels > max_levels_per_page { + single_row_over_budget_levels = Some(row_num_levels); + } + + if current_page_num_rows > 0 + && (current_page_has_structural_overhead || row_has_structural_overhead) + && current_page_num_levels + row_num_levels > max_levels_per_page + { + splits.push(MiniBlockRepDefSplit { + row_start: current_page_row_start, + num_rows: current_page_num_rows, + level_range: current_page_level_start..current_page_level_end, + value_start: current_page_value_start, + num_values: current_page_num_values, + }); + current_page_row_start = counted_rows; + current_page_num_rows = 0; + current_page_level_start = row_level_start; + current_page_value_start = counted_values; + current_page_num_values = 0; + current_page_num_levels = 0; + current_page_has_structural_overhead = false; + } + + if current_page_num_rows == 0 { + current_page_level_start = row_level_start; + } + current_page_num_rows += 1; + current_page_level_end = row_level_end; + current_page_num_values += row_num_values; + current_page_num_levels += row_num_levels; + current_page_has_structural_overhead |= row_has_structural_overhead; + counted_rows += 1; + counted_values += row_num_values; + Ok(()) + }; + + for (idx, (rep_level, def_level)) in self + .rep_levels + .iter() + .copied() + .zip(self.def_levels.iter_mut()) + .enumerate() + { + if *def_level > SPECIAL_THRESHOLD { + *def_level -= SPECIAL_THRESHOLD; + } + + if rep_level == max_schema_rep { + if let Some(level_start) = current_row_level_start { + finish_row(level_start, idx, current_row_num_values)?; + current_row_num_values = 0; + } else if idx != 0 { + return Err(Error::internal(format!( + "Cannot plan structural page splits: first top-level row starts at level {}, expected 0", + idx + ))); + } + current_row_level_start = Some(idx); + } + + if current_row_level_start.is_none() { + return Err(Error::internal( + "Cannot plan structural page splits: found levels before the first top-level row start", + )); + } + if *def_level <= max_visible_level { + current_row_num_values += 1; + } + } + + let Some(level_start) = current_row_level_start else { + return Err(Error::internal( + "Cannot plan structural page splits: found no top-level row starts", + )); + }; + finish_row(level_start, self.rep_levels.len(), current_row_num_values)?; + + if counted_rows != num_rows { + return Err(Error::internal(format!( + "Cannot plan structural page splits: expected {} top-level row starts, found {}", + num_rows, counted_rows + ))); + } + if counted_values != num_values { + return Err(Error::internal(format!( + "Cannot plan structural page splits: counted {} visible values, expected {}", + counted_values, num_values + ))); + } + if !saw_structural_overhead { + return Ok(MiniBlockRepDefBudget::WithinBudget); + } + if let Some(row_num_levels) = single_row_over_budget_levels { + return Ok(MiniBlockRepDefBudget::SingleRowOverBudget(row_num_levels)); + } + + if current_page_num_rows > 0 { + splits.push(MiniBlockRepDefSplit { + row_start: current_page_row_start, + num_rows: current_page_num_rows, + level_range: current_page_level_start..current_page_level_end, + value_start: current_page_value_start, + num_values: current_page_num_values, + }); + } + + if splits.len() > 1 { + Ok(MiniBlockRepDefBudget::RequiresPageSplit(splits)) + } else { + Ok(MiniBlockRepDefBudget::WithinBudget) + } + } + fn build(mut self) -> SerializedRepDefs { if self.current_len == 0 { - return SerializedRepDefs::new(None, None, self.def_meaning); + return SerializedRepDefs::new_with_fixed_size_list_levels( + None, + None, + self.def_meaning, + self.has_fsl, + ); } self.normalize_specials(); @@ -767,7 +1169,64 @@ impl SerializerContext { // Need to reverse the def meaning since we build rep / def levels in reverse let def_meaning = self.def_meaning.into_iter().rev().collect::>(); - SerializedRepDefs::new(repetition_levels, definition_levels, def_meaning) + SerializedRepDefs::new_with_fixed_size_list_levels( + repetition_levels, + definition_levels, + def_meaning, + self.has_fsl, + ) + } + + fn build_with_miniblock_repdef_budget( + mut self, + max_levels_per_page: Option, + num_rows: u64, + num_values: u64, + ) -> Result<(SerializedRepDefs, MiniBlockRepDefBudget)> { + if self.current_len == 0 { + return Ok(( + SerializedRepDefs::new_with_fixed_size_list_levels( + None, + None, + self.def_meaning, + self.has_fsl, + ), + MiniBlockRepDefBudget::WithinBudget, + )); + } + + // Need to reverse the def meaning since we build rep / def levels in reverse + let def_meaning = std::mem::take(&mut self.def_meaning) + .into_iter() + .rev() + .collect::>(); + let budget = self.normalize_specials_and_plan_splits( + &def_meaning, + max_levels_per_page, + num_rows, + num_values, + )?; + + let definition_levels = if self.def_levels.is_empty() { + None + } else { + Some(self.def_levels) + }; + let repetition_levels = if self.rep_levels.is_empty() { + None + } else { + Some(self.rep_levels) + }; + + Ok(( + SerializedRepDefs::new_with_fixed_size_list_levels( + repetition_levels, + definition_levels, + def_meaning, + self.has_fsl, + ), + budget, + )) } } @@ -818,6 +1277,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()), @@ -1000,7 +1463,7 @@ impl RepDefBuilder { validity: None, num_values: all_num_values, dimension: all_dimension, - }) + }); } LayerKind::Offsets => {} } @@ -1100,22 +1563,18 @@ impl RepDefBuilder { /// Converts the validity / offsets buffers that have been gathered so far /// into repetition and definition levels pub fn serialize(builders: Vec) -> SerializedRepDefs { - assert!(!builders.is_empty()); - if builders.iter().all(|b| b.is_empty()) { - // No repetition, all-valid - return SerializedRepDefs::empty( - builders - .first() - .unwrap() - .repdefs - .iter() - .map(|_| DefinitionInterpretation::AllValidItem) - .collect::>(), - ); - } + Self::normalize(builders).serialize() + } + pub(crate) fn normalize(builders: Vec) -> NormalizedStructuralPlan { + assert!(!builders.is_empty()); let num_layers = builders[0].num_layers(); - let combined_layers = (0..num_layers) + debug_assert!( + builders + .iter() + .all(|builder| builder.num_layers() == num_layers) + ); + let layers = (0..num_layers) .map(|layer_index| { Self::concat_layers( builders.iter().map(|b| &b.repdefs[layer_index]), @@ -1123,33 +1582,10 @@ impl RepDefBuilder { ) }) .collect::>(); - debug_assert!(builders - .iter() - .all(|b| b.num_layers() == builders[0].num_layers())); - - let total_len = combined_layers.last().unwrap().num_values() - + combined_layers - .iter() - .map(|l| l.num_specials()) - .sum::(); - let max_rep = combined_layers.iter().map(|l| l.max_rep()).sum::(); - let max_def = combined_layers.iter().map(|l| l.max_def()).sum::(); - - let mut context = SerializerContext::new(total_len, num_layers, max_rep, max_def); - for layer in combined_layers.into_iter() { - match layer { - RawRepDef::Validity(def) => { - context.record_validity(&def); - } - RawRepDef::Offsets(rep) => { - context.record_offsets(&rep); - } - RawRepDef::Fsl(fsl) => { - context.record_fsl(&fsl); - } - } + NormalizedStructuralPlan { + layers, + dense_all_valid: builders.iter().all(Self::is_empty), } - context.build() } } @@ -1159,6 +1595,7 @@ impl RepDefBuilder { /// This is used during decoding to create the necessary arrow structures #[derive(Debug)] pub struct RepDefUnraveler { + sparse: Option, rep_levels: Option, def_levels: Option, // Maps from definition level to the rep level at which that definition level is visible @@ -1212,6 +1649,7 @@ impl RepDefUnraveler { } } Self { + sparse: None, rep_levels, def_levels, current_def_cmp: 0, @@ -1223,8 +1661,36 @@ impl RepDefUnraveler { } } + pub(crate) fn new_sparse(plan: SparseStructuralPlan) -> Self { + Self { + sparse: Some(SparseStructuralUnraveler::new(plan)), + rep_levels: None, + def_levels: None, + levels_to_rep: Vec::new(), + def_meaning: Arc::new([]), + current_def_cmp: 0, + current_rep_cmp: 0, + current_layer: 0, + num_items: 0, + } + } + + fn ensure_exhausted(&self) -> Result<()> { + if let Some(sparse) = &self.sparse { + sparse.ensure_exhausted()?; + } + Ok(()) + } + + fn is_sparse(&self) -> bool { + self.sparse.is_some() + } + pub fn is_all_valid(&self) -> bool { - self.def_meaning[self.current_layer].is_all_valid() + if let Some(sparse) = &self.sparse { + return sparse.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 @@ -1232,15 +1698,19 @@ impl RepDefUnraveler { /// /// This is not valid to call when the current level is a struct/primitive layer because /// in some cases there may be no rep or def information to know this. - pub fn max_lists(&self) -> usize { + pub fn max_lists(&self) -> Result { + if let Some(sparse) = &self.sparse { + return sparse.max_lists(); + } debug_assert!( self.def_meaning[self.current_layer] != DefinitionInterpretation::NullableItem ); - self.rep_levels + Ok(self + .rep_levels .as_ref() // Worst case every rep item is max_rep and a new list .map(|levels| levels.len()) - .unwrap_or(0) + .unwrap_or(0)) } /// Unravels a layer of offsets from the unraveler into the given offset width @@ -1252,6 +1722,9 @@ impl RepDefUnraveler { offsets: &mut Vec, validity: Option<&mut BooleanBufferBuilder>, ) -> Result<()> { + if let Some(sparse) = self.sparse.as_mut() { + return sparse.unravel_offsets(offsets, validity); + } let rep_levels = self .rep_levels .as_mut() @@ -1278,7 +1751,7 @@ impl RepDefUnraveler { // This is the highest def level that is still visible. Once we hit a list then // we stop looking because any null / empty list (or list masked by a higher level // null) will not be visible - let mut max_level = null_level.max(empty_level); + let mut max_level = null_level.max(empty_level).max(valid_level); // Anything higher than this (but less than max_level) is a null struct masking our // list. We will materialize this is a null list. let upper_null = max_level; @@ -1307,7 +1780,7 @@ impl RepDefUnraveler { let to_offset = |val: usize| { T::from_usize(val) - .ok_or_else(|| Error::invalid_input("A single batch had more than i32::MAX values and so a large container type is required", location!())) + .ok_or_else(|| Error::invalid_input("A single batch had more than i32::MAX values and so a large container type is required")) }; self.current_rep_cmp += 1; if let Some(def_levels) = &mut self.def_levels { @@ -1388,7 +1861,11 @@ impl RepDefUnraveler { } let num_new_lists = offsets.len() - old_offsets_len; offsets.push(to_offset(curlen)?); - rep_levels.truncate(offsets.len() - 1); + // Truncate to the number of lists THIS unraveler produced (write_idx), + // not `offsets.len() - 1` — the latter includes offsets contributed by + // earlier unravelers in a multi-page read, which would leave too many + // rep levels for the next (outer) layer and over-count its lists. + rep_levels.truncate(write_idx); if let Some(validity) = validity { // Even though we don't have validity it is possible another unraveler did and so we need // to push all valids @@ -1398,19 +1875,25 @@ impl RepDefUnraveler { } } - pub fn skip_validity(&mut self) { - debug_assert!( - self.def_meaning[self.current_layer] == DefinitionInterpretation::AllValidItem - ); + pub fn skip_validity(&mut self) -> Result<()> { + if let Some(sparse) = self.sparse.as_mut() { + return sparse.skip_validity(); + } + debug_assert!(self.is_all_valid()); self.current_layer += 1; + Ok(()) } /// 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 { + pub fn unravel_validity(&mut self, validity: &mut BooleanBufferBuilder) -> Result<()> { + if let Some(sparse) = self.sparse.as_mut() { + return sparse.unravel_validity(validity); + } + 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; + return Ok(()); } self.current_layer += 1; @@ -1428,9 +1911,28 @@ impl RepDefUnraveler { }) { validity.append(is_valid); } + Ok(()) } - pub fn decimate(&mut self, dimension: usize) { + /// Removes all but the first definition level of each fixed-size-list slot + /// + /// The definition levels arrive with one entry per item. A fixed-size-list + /// layer has a single definition level per slot (all `dimension` items in a + /// slot share it) so we keep every `dimension`-th level and drop the rest. + /// + /// `dimension` must be non-zero. A zero dimension can only come from a + /// malformed schema (writers reject it, see + /// [`lance_core::datatypes::validate_fixed_size_list_dimensions`]) and is + /// rejected here rather than allowed to run off the end of the buffer. + pub fn decimate(&mut self, dimension: usize) -> Result<()> { + if dimension == 0 { + return Err(Error::invalid_input( + "Cannot decimate repetition/definition levels with a fixed-size-list dimension of 0; dimension must be a positive integer", + )); + } + if let Some(sparse) = self.sparse.as_mut() { + return sparse.decimate(dimension); + } if self.rep_levels.is_some() { // If we need to support this then I think we need to walk through the rep def levels to find // the spots at which we keep. E.g. if we have: @@ -1446,11 +1948,14 @@ impl RepDefUnraveler { todo!("Not yet supported FSL<...List<...>>"); } let Some(def_levels) = self.def_levels.as_mut() else { - return; + return Ok(()); }; let mut read_idx = 0; let mut write_idx = 0; while read_idx < def_levels.len() { + // SAFETY: `read_idx` is checked against the length by the loop condition and + // `dimension >= 1` (checked above) means `write_idx <= read_idx`, so both + // indices are in bounds. unsafe { *def_levels.get_unchecked_mut(write_idx) = *def_levels.get_unchecked(read_idx); } @@ -1458,6 +1963,7 @@ impl RepDefUnraveler { read_idx += dimension; } def_levels.truncate(write_idx); + Ok(()) } } @@ -1477,44 +1983,104 @@ impl RepDefUnraveler { #[derive(Debug)] pub struct CompositeRepDefUnraveler { unravelers: Vec, + comparisons: Vec, } impl CompositeRepDefUnraveler { pub fn new(unravelers: Vec) -> Self { - Self { unravelers } + Self { + unravelers, + comparisons: Vec::new(), + } + } + + pub(crate) fn add_compatibility_check(&mut self, other: Self) { + self.comparisons.push(other); + } + + pub(crate) fn has_sparse(&self) -> bool { + self.unravelers.iter().any(RepDefUnraveler::is_sparse) + || self.comparisons.iter().any(Self::has_sparse) + } + + pub(crate) fn ensure_exhausted(&self) -> Result<()> { + for unraveler in &self.unravelers { + unraveler.ensure_exhausted()?; + } + for comparison in &self.comparisons { + comparison.ensure_exhausted()?; + } + Ok(()) + } + + fn null_buffers_equal( + left: &Option, + right: &Option, + expected_len: usize, + ) -> bool { + match (left, right) { + (None, None) => true, + (Some(left), Some(right)) => { + left.len() == expected_len + && right.len() == expected_len + && left.iter().eq(right.iter()) + } + (None, Some(right)) => right.len() == expected_len && right.null_count() == 0, + (Some(left), None) => left.len() == expected_len && left.null_count() == 0, + } + } + + fn decimate(&mut self, dimension: usize) -> Result<()> { + for unraveler in &mut self.unravelers { + unraveler.decimate(dimension)?; + } + for comparison in &mut self.comparisons { + comparison.decimate(dimension)?; + } + Ok(()) } /// Unravels a layer of validity /// /// Returns None if there are no null items in this layer - pub fn unravel_validity(&mut self, num_values: usize) -> Option { + pub fn unravel_validity(&mut self, num_values: usize) -> Result> { let is_all_valid = self .unravelers .iter() .all(|unraveler| unraveler.is_all_valid()); - if is_all_valid { + let validity = if is_all_valid { for unraveler in self.unravelers.iter_mut() { - unraveler.skip_validity(); + unraveler.skip_validity()?; } None } else { let mut validity = BooleanBufferBuilder::new(num_values); for unraveler in self.unravelers.iter_mut() { - unraveler.unravel_validity(&mut validity); + unraveler.unravel_validity(&mut validity)?; } Some(NullBuffer::new(validity.finish())) + }; + for comparison in &mut self.comparisons { + let other = comparison.unravel_validity(num_values)?; + if !Self::null_buffers_equal(&validity, &other, num_values) { + return Err(Error::invalid_input_source( + format!( + "Structural sibling fields have incompatible validity metadata for {num_values} values" + ) + .into(), + )); + } } + Ok(validity) } pub fn unravel_fsl_validity( &mut self, num_values: usize, dimension: usize, - ) -> Option { - for unraveler in self.unravelers.iter_mut() { - unraveler.decimate(dimension); - } + ) -> Result> { + self.decimate(dimension)?; self.unravel_validity(num_values) } @@ -1523,10 +2089,16 @@ impl CompositeRepDefUnraveler { &mut self, ) -> Result<(OffsetBuffer, Option)> { let mut is_all_valid = true; - let mut max_num_lists = 0; + let mut max_num_lists: usize = 0; for unraveler in self.unravelers.iter() { is_all_valid &= unraveler.is_all_valid(); - max_num_lists += unraveler.max_lists(); + max_num_lists = max_num_lists + .checked_add(unraveler.max_lists()?) + .ok_or_else(|| { + Error::invalid_input_source( + "Combined repetition/definition list count exceeds usize::MAX".into(), + ) + })?; } let mut validity = if is_all_valid { @@ -1543,10 +2115,28 @@ impl CompositeRepDefUnraveler { unraveler.unravel_offsets(&mut offsets, validity.as_mut())?; } - Ok(( - OffsetBuffer::new(ScalarBuffer::from(offsets)), - validity.map(|mut v| NullBuffer::new(v.finish())), - )) + let offsets = OffsetBuffer::new(ScalarBuffer::from(offsets)); + let validity = validity.map(|mut v| NullBuffer::new(v.finish())); + for comparison in &mut self.comparisons { + let (other_offsets, other_validity) = comparison.unravel_offsets::()?; + if offsets.as_ref() != other_offsets.as_ref() + || !Self::null_buffers_equal( + &validity, + &other_validity, + offsets.len().saturating_sub(1), + ) + { + return Err(Error::invalid_input_source( + format!( + "Structural sibling fields have incompatible list metadata for {} slots", + offsets.len().saturating_sub(1) + ) + .into(), + )); + } + } + + Ok((offsets, validity)) } } @@ -1840,6 +2430,15 @@ pub fn build_control_word_iterator<'a>( }; let def_mask = if max_def == 0 { 0 } else { get_mask(def_width) }; let total_width = rep_width + def_width; + // A levels array can be non-empty while the corresponding max level is 0: + // e.g. a page holding only valid rows after a page split has all zero + // definition levels. With a zero total width the decoder reads a NIL + // layout (zero bytes per control word), so the writer must also emit the + // NIL writer instead of a Unary/Binary writer that would write one + // all-zero byte per word. + if total_width == 0 { + return ControlWordIterator::Nilary(NilaryControlWordIterator { len, idx: 0 }); + } match (rep, def) { (Some(rep), Some(def)) => { let iter = rep.iter().copied().zip(def.iter().copied()); @@ -2245,12 +2844,47 @@ impl ControlWordParser { mod tests { use arrow_buffer::{NullBuffer, OffsetBuffer, ScalarBuffer}; + use crate::encodings::logical::primitive::sparse::{ + SparsePositionSet, SparseStructuralLayerPlan, SparseStructuralPlan, SparseValidityMeaning, + SparseValiditySet, + }; use crate::repdef::{ CompositeRepDefUnraveler, DefinitionInterpretation, RepDefUnraveler, SerializedRepDefs, + max_visible_level, }; use super::RepDefBuilder; + /// `def_meaning` is leaf-first, so only the layers ahead of the first list sit + /// underneath it. Counting nullable layers *past* the list makes a decoder treat + /// invisible items as though they owned a leaf value. + #[rstest::rstest] + // No lists: nothing can be invisible, so every level is visible + #[case::no_lists( + &[DefinitionInterpretation::NullableItem, DefinitionInterpretation::NullableItem], + 2 + )] + // A nullable leaf under a list keeps its own level visible + #[case::nullable_leaf_under_list( + &[DefinitionInterpretation::NullableItem, DefinitionInterpretation::NullableList], + 1 + )] + // Regression: a nullable struct above the list must not raise the threshold + #[case::nullable_struct_above_list( + &[ + DefinitionInterpretation::AllValidItem, + DefinitionInterpretation::NullableList, + DefinitionInterpretation::NullableItem, + ], + 0 + )] + fn test_max_visible_level( + #[case] def_meaning: &[DefinitionInterpretation], + #[case] expected: u16, + ) { + assert_eq!(max_visible_level(def_meaning), expected); + } + fn validity(values: &[bool]) -> NullBuffer { NullBuffer::from_iter(values.iter().copied()) } @@ -2263,6 +2897,31 @@ mod tests { OffsetBuffer::::new(ScalarBuffer::from_iter(values.iter().copied())) } + #[test] + fn sparse_sibling_validity_mismatch_is_invalid_input() { + let sparse = |positions| { + RepDefUnraveler::new_sparse(SparseStructuralPlan { + layers: vec![SparseStructuralLayerPlan::Validity { + num_slots: 2, + validity: SparseValiditySet { + meaning: SparseValidityMeaning::NullPositions, + positions, + }, + }], + num_items: 2, + num_visible_items: 2, + }) + }; + let mut repdef = CompositeRepDefUnraveler::new(vec![sparse(SparsePositionSet::Empty)]); + repdef.add_compatibility_check(CompositeRepDefUnraveler::new(vec![sparse( + SparsePositionSet::Explicit(vec![0]), + )])); + + let err = repdef.unravel_validity(2).unwrap_err(); + assert!(matches!(err, lance_core::Error::InvalidInput { .. })); + assert!(err.to_string().contains("incompatible validity metadata")); + } + #[test] fn test_repdef_empty_offsets() { // Empty offsets should serialize without panicking. @@ -2308,7 +2967,7 @@ mod tests { // Note: validity doesn't exactly round-trip because repdef normalizes some of the // redundant validity values assert_eq!( - unraveler.unravel_validity(9), + unraveler.unravel_validity(9).unwrap(), Some(validity(&[ true, true, true, false, false, false, true, true, false ])) @@ -2446,18 +3105,48 @@ mod tests { )]); assert_eq!( - unraveler.unravel_validity(8), + unraveler.unravel_validity(8).unwrap(), Some(validity(&[ true, false, true, false, false, false, false, false ])) ); - assert_eq!(unraveler.unravel_fsl_validity(4, 2), None); + assert_eq!(unraveler.unravel_fsl_validity(4, 2).unwrap(), None); assert_eq!( - unraveler.unravel_fsl_validity(2, 2), + unraveler.unravel_fsl_validity(2, 2).unwrap(), Some(validity(&[true, false])) ); } + #[test] + fn test_repdef_fsl_zero_dimension_is_invalid_input() { + // A zero dimension can only reach us from a malformed schema. Decimating with it + // used to loop forever, writing past the end of the definition levels buffer. + let mut builder = RepDefBuilder::default(); + builder.add_fsl(Some(validity(&[true, false])), 2, 2); + builder.add_validity_bitmap(validity(&[true, false, true, false])); + + let repdefs = RepDefBuilder::serialize(vec![builder]); + let def = repdefs.definition_levels.unwrap(); + + let mut unraveler = CompositeRepDefUnraveler::new(vec![RepDefUnraveler::new( + None, + Some(def.as_ref().to_vec()), + repdefs.def_meaning.into(), + 4, + )]); + // Consume the item layer so the fixed-size-list layer is next + unraveler.unravel_validity(4).unwrap(); + + let err = unraveler.unravel_fsl_validity(2, 0).unwrap_err(); + assert!(matches!(err, lance_core::Error::InvalidInput { .. })); + assert!( + err.to_string() + .contains("dimension must be a positive integer"), + "unexpected error: {}", + err + ); + } + #[test] fn test_repdef_fsl_allvalid_item() { let mut builder = RepDefBuilder::default(); @@ -2489,10 +3178,10 @@ mod tests { 8, )]); - assert_eq!(unraveler.unravel_validity(8), None); - assert_eq!(unraveler.unravel_fsl_validity(4, 2), None); + assert_eq!(unraveler.unravel_validity(8).unwrap(), None); + assert_eq!(unraveler.unravel_fsl_validity(4, 2).unwrap(), None); assert_eq!( - unraveler.unravel_fsl_validity(2, 2), + unraveler.unravel_fsl_validity(2, 2).unwrap(), Some(validity(&[true, false])) ); } @@ -2570,7 +3259,7 @@ mod tests { 8, )]); - assert_eq!(unraveler.unravel_validity(6), None); + assert_eq!(unraveler.unravel_validity(6).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 4, 4, 4, 6]).inner()); assert_eq!(val, None); @@ -2596,7 +3285,7 @@ mod tests { 9, )]); - assert_eq!(unraveler.unravel_validity(9), None); + assert_eq!(unraveler.unravel_validity(9).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 1, 3, 5, 7, 9]).inner()); assert_eq!(val, None); @@ -2605,6 +3294,40 @@ mod tests { assert_eq!(val, None); } + #[test] + fn test_repdef_nested_list_multibatch_matches_single() { + // Single builder: List>, 3 rows. + // outer [0,2,3,5] -> rows have 2,1,2 inner lists + // inner [0,1,3,5,7,9] -> 5 inner lists, lengths 1,2,2,2,2 (9 leaf) + let mut single = RepDefBuilder::default(); + single.add_offsets(offsets_64(&[0, 2, 3, 5]), None); + single.add_offsets(offsets_64(&[0, 1, 3, 5, 7, 9]), None); + single.add_no_null(9); + let single_rep = RepDefBuilder::serialize(vec![single]) + .repetition_levels + .unwrap(); + + // Same logical data split into two batches: + // batch0 = rows 0,1 : outer [0,2,3], inner [0,1,3,5] (3 inner, 5 leaf) + // batch1 = row 2 : outer [0,2], inner [0,2,4] (2 inner, 4 leaf) + let mut b0 = RepDefBuilder::default(); + b0.add_offsets(offsets_64(&[0, 2, 3]), None); + b0.add_offsets(offsets_64(&[0, 1, 3, 5]), None); + b0.add_no_null(5); + let mut b1 = RepDefBuilder::default(); + b1.add_offsets(offsets_64(&[0, 2]), None); + b1.add_offsets(offsets_64(&[0, 2, 4]), None); + b1.add_no_null(4); + let multi_rep = RepDefBuilder::serialize(vec![b0, b1]) + .repetition_levels + .unwrap(); + + assert_eq!( + *single_rep, *multi_rep, + "multi-batch nested-list rep levels must equal single-batch" + ); + } + #[test] fn test_only_empty_lists() { let mut builder = RepDefBuilder::default(); @@ -2626,7 +3349,7 @@ mod tests { 8, )]); - assert_eq!(unraveler.unravel_validity(6), None); + assert_eq!(unraveler.unravel_validity(6).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 4, 4, 4, 6]).inner()); assert_eq!(val, None); @@ -2656,7 +3379,7 @@ mod tests { 8, )]); - assert_eq!(unraveler.unravel_validity(6), None); + assert_eq!(unraveler.unravel_validity(6).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 4, 4, 4, 6]).inner()); assert_eq!(val, Some(validity(&[true, false, false, true]))); @@ -2686,12 +3409,77 @@ mod tests { 8, )]); - assert_eq!(unraveler.unravel_validity(6), None); + assert_eq!(unraveler.unravel_validity(6).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 4, 4, 4, 6]).inner()); assert_eq!(val, Some(validity(&[true, false, true, true]))); } + #[test] + fn test_repdef_nullable_struct_in_null_and_empty_lists() { + let mut builder = RepDefBuilder::default(); + builder.add_offsets( + offsets_32(&[0, 0, 3, 3]), + Some(validity(&[false, true, true])), + ); + builder.add_validity_bitmap(validity(&[false, true, true])); + builder.add_validity_bitmap(validity(&[true, false, true])); + + let repdefs = RepDefBuilder::serialize(vec![builder]); + let mut unraveler = CompositeRepDefUnraveler::new(vec![RepDefUnraveler::new( + repdefs.repetition_levels.map(|levels| levels.to_vec()), + repdefs.definition_levels.map(|levels| levels.to_vec()), + repdefs.def_meaning.into(), + 3, + )]); + + assert_eq!( + unraveler.unravel_validity(3).unwrap(), + Some(validity(&[false, false, true])) + ); + assert_eq!( + unraveler.unravel_validity(3).unwrap(), + Some(validity(&[false, true, true])) + ); + let (offsets, nulls) = unraveler.unravel_offsets::().unwrap(); + assert_eq!(offsets.inner(), offsets_32(&[0, 0, 3, 3]).inner()); + assert_eq!(nulls, Some(validity(&[false, true, true]))); + } + + #[test] + fn test_repdef_null_struct_valid_list() { + // This regresses a bug + + let rep = vec![1, 0, 0, 0]; + let def = vec![2, 0, 2, 2]; + // AllValidList> + let def_meaning = vec![ + DefinitionInterpretation::NullableItem, + DefinitionInterpretation::NullableItem, + DefinitionInterpretation::AllValidList, + ]; + let num_items = 4; + + let mut unraveler = CompositeRepDefUnraveler::new(vec![RepDefUnraveler::new( + Some(rep), + Some(def), + def_meaning.into(), + num_items, + )]); + + assert_eq!( + unraveler.unravel_validity(4).unwrap(), + Some(validity(&[false, true, false, false])) + ); + assert_eq!( + unraveler.unravel_validity(4).unwrap(), + Some(validity(&[false, true, false, false])) + ); + let (off, val) = unraveler.unravel_offsets::().unwrap(); + assert_eq!(off.inner(), offsets_32(&[0, 4]).inner()); + assert_eq!(val, None); + } + #[test] fn test_repdef_no_rep() { let mut builder = RepDefBuilder::default(); @@ -2713,14 +3501,14 @@ mod tests { )]); assert_eq!( - unraveler.unravel_validity(5), + unraveler.unravel_validity(5).unwrap(), Some(validity(&[false, false, true, true, false])) ); assert_eq!( - unraveler.unravel_validity(5), + unraveler.unravel_validity(5).unwrap(), Some(validity(&[false, false, true, true, true])) ); - assert_eq!(unraveler.unravel_validity(5), None); + assert_eq!(unraveler.unravel_validity(5).unwrap(), None); } #[test] @@ -2762,7 +3550,7 @@ mod tests { let mut unraveler = CompositeRepDefUnraveler::new(vec![unravel1, unravel2]); - assert!(unraveler.unravel_validity(9).is_none()); + assert!(unraveler.unravel_validity(9).unwrap().is_none()); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!( off.inner(), @@ -2799,6 +3587,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(); @@ -2933,6 +3741,102 @@ mod tests { check(&[], &[], Vec::default(), 0, 0, 0); } + #[test] + fn test_control_words_def_only_all_zero_levels() { + // A page split can produce a page whose rows are all valid, so its + // definition levels are all zero and the max value in the (non-empty) + // buffer is 0. The decoder reads a zero-bit layout as NIL (zero bytes + // per control word), so the writer must emit the NIL iterator too: + // zero bytes per word, no writes, one new-row/visible item per level. + // Writing one all-zero byte per word (the pre-fix Unary behavior) + // would disagree with the decoder and corrupt the stream. + let def = [0_u16; 5]; + + let mut iter = super::build_control_word_iterator( + None, + 0, + Some(&def), + /*max_def=*/ 0, + /*max_visible_def=*/ u16::MAX, + def.len(), + ); + assert_eq!(iter.bytes_per_word(), 0); + assert_eq!(iter.bits_rep(), 0); + assert_eq!(iter.bits_def(), 0); + + let mut cw_vec = Vec::new(); + for _ in 0..def.len() { + let word_desc = iter.append_next(&mut cw_vec).unwrap(); + assert!(word_desc.is_new_row); + assert!(word_desc.is_visible); + } + assert!(iter.append_next(&mut cw_vec).is_none()); + // The NIL layout writes nothing, matching what ControlWordParser::new(0, 0) + // reads back (Self::NIL parses nothing). + assert!(cw_vec.is_empty()); + + // The parser side of the same layout must also be NIL: zero bits on + // both levels parse zero bytes and yield nothing. + let parser = super::ControlWordParser::new(0, 0); + let mut rep_out = Vec::new(); + let mut def_out = Vec::new(); + parser.parse(&[], &mut rep_out, &mut def_out); + assert!(rep_out.is_empty()); + assert!(def_out.is_empty()); + } + + #[test] + fn test_control_words_rep_only_all_zero_levels() { + // Same NIL-layout requirement as the definition-only case, for the + // repetition-only branches. Repetition levels are normally never all + // zero (the first entry of every list row is the schema max rep), so + // this is a defensive check that the writer matches the decoder if a + // zero-width rep buffer is ever produced. + let rep = [0_u16; 5]; + let mut iter = super::build_control_word_iterator( + Some(&rep), + /*max_rep=*/ 0, + None, + 0, + /*max_visible_def=*/ u16::MAX, + rep.len(), + ); + assert_eq!(iter.bytes_per_word(), 0); + let mut cw_vec = Vec::new(); + for _ in 0..rep.len() { + assert!(iter.append_next(&mut cw_vec).unwrap().is_new_row); + } + assert!(iter.append_next(&mut cw_vec).is_none()); + assert!(cw_vec.is_empty()); + } + + #[test] + fn test_control_words_both_levels_all_zero() { + // Both levels non-empty but all zero: the Binary writer would emit one + // zero byte per word while the decoder reads a NIL layout (zero bytes + // per word). The unified zero-width early return avoids that + // write/read asymmetry. + let rep = [0_u16; 5]; + let def = [0_u16; 5]; + let mut iter = super::build_control_word_iterator( + Some(&rep), + /*max_rep=*/ 0, + Some(&def), + /*max_def=*/ 0, + /*max_visible_def=*/ u16::MAX, + rep.len(), + ); + assert_eq!(iter.bytes_per_word(), 0); + assert_eq!(iter.bits_rep(), 0); + assert_eq!(iter.bits_def(), 0); + let mut cw_vec = Vec::new(); + for _ in 0..rep.len() { + assert!(iter.append_next(&mut cw_vec).unwrap().is_new_row); + } + assert!(iter.append_next(&mut cw_vec).is_none()); + assert!(cw_vec.is_empty()); + } + #[test] fn test_control_words_rep_index() { fn check( @@ -3038,11 +3942,11 @@ mod tests { 0, )]); - assert_eq!(unraveler.unravel_validity(0), None); + assert_eq!(unraveler.unravel_validity(0).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 0, 0, 0]).inner()); assert_eq!(val, Some(validity(&[false, false, false]))); - let val = unraveler.unravel_validity(3).unwrap(); + let val = unraveler.unravel_validity(3).unwrap().unwrap(); assert_eq!(val.inner(), validity(&[true, false, true]).inner()); } @@ -3070,7 +3974,7 @@ mod tests { 1, )]); - assert_eq!(unraveler.unravel_validity(1), None); + assert_eq!(unraveler.unravel_validity(1).unwrap(), None); let (off, val) = unraveler.unravel_offsets::().unwrap(); assert_eq!(off.inner(), offsets_32(&[0, 1, 1]).inner()); assert_eq!(val, Some(validity(&[true, false]))); @@ -3101,7 +4005,7 @@ mod tests { ]); assert_eq!( - unraveler.unravel_validity(8), + unraveler.unravel_validity(8).unwrap(), Some(validity(&[ true, false, true, false, true, true, true, true ])) @@ -3138,7 +4042,7 @@ mod tests { ]); assert_eq!( - unraveler.unravel_validity(4), + unraveler.unravel_validity(4).unwrap(), Some(validity(&[true, false, true, true])) ); assert_eq!( @@ -3149,4 +4053,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).unwrap(), + Some(validity(&[ + true, false, true, false, true, true, true, true + ])) + ); + } } diff --git a/rust/lance-encoding/src/statistics.rs b/rust/lance-encoding/src/statistics.rs index b5cb65ee879..e312bc51389 100644 --- a/rust/lance-encoding/src/statistics.rs +++ b/rust/lance-encoding/src/statistics.rs @@ -7,7 +7,7 @@ use std::{ sync::Arc, }; -use arrow_array::{cast::AsArray, types::UInt64Type, Array, ArrowPrimitiveType, UInt64Array}; +use arrow_array::{Array, ArrowPrimitiveType, UInt64Array, cast::AsArray, types::UInt64Type}; use hyperloglogplus::{HyperLogLog, HyperLogLogPlus}; use num_traits::PrimInt; @@ -78,13 +78,10 @@ impl ComputeStat for VariableWidthBlock { let data_size = self.data_size(); let data_size_array = Arc::new(UInt64Array::from(vec![data_size])); - let cardinality_array = self.cardinality(); - let max_length_array = self.max_length(); let mut info = self.block_info.0.write().unwrap(); info.insert(Stat::DataSize, data_size_array); - info.insert(Stat::Cardinality, cardinality_array); info.insert(Stat::MaxLength, max_length_array); } } @@ -189,12 +186,31 @@ impl GetStat for NullableDataBlock { impl GetStat for VariableWidthBlock { fn get_stat(&self, stat: Stat) -> Option> { - let block_info = self.block_info.0.read().unwrap(); + { + let block_info = self.block_info.0.read().unwrap(); + if block_info.is_empty() { + panic!("get_stat should be called after statistics are computed."); + } + if let Some(stat_value) = block_info.get(&stat) { + return Some(stat_value.clone()); + } + } + if stat != Stat::Cardinality { + return None; + } + + let computed = self.compute_cardinality(); + let mut block_info = self.block_info.0.write().unwrap(); if block_info.is_empty() { panic!("get_stat should be called after statistics are computed."); } - block_info.get(&stat).cloned() + Some( + block_info + .entry(stat) + .or_insert_with(|| computed.clone()) + .clone(), + ) } } @@ -216,7 +232,7 @@ impl GetStat for FixedSizeListBlock { impl VariableWidthBlock { // Caveat: the computation here assumes VariableWidthBlock.offsets maps directly to VariableWidthBlock.data // without any adjustment(for example, no null_adjustment for offsets) - fn cardinality(&mut self) -> Arc { + fn compute_cardinality(&self) -> Arc { const PRECISION: u8 = 4; // The default hasher (currently sip hash 1-3) does not seem to give good results // with HLL. @@ -583,12 +599,12 @@ mod tests { use std::sync::Arc; use arrow_array::{ - ArrayRef, Int16Array, Int32Array, Int64Array, Int8Array, LargeStringArray, StringArray, - UInt16Array, UInt32Array, UInt64Array, UInt8Array, + ArrayRef, Int8Array, Int16Array, Int32Array, Int64Array, LargeStringArray, StringArray, + UInt8Array, UInt16Array, UInt32Array, UInt64Array, }; use arrow_schema::{DataType, Field}; use lance_arrow::DataTypeExt; - use lance_datagen::{array, ArrayGeneratorExt, RowCount, DEFAULT_SEED}; + use lance_datagen::{ArrayGeneratorExt, DEFAULT_SEED, RowCount, array}; use rand::SeedableRng; use crate::statistics::{GetStat, Stat}; @@ -596,9 +612,9 @@ mod tests { use super::DataBlock; use arrow_array::{ + Array, cast::AsArray, types::{Int32Type, UInt64Type}, - Array, }; use arrow_select::concat::concat; #[test] @@ -1227,4 +1243,27 @@ mod tests { let info = fixed.block_info.0.read().unwrap(); assert!(info.contains_key(&Stat::Cardinality)); } + + #[test] + fn test_variable_width_cardinality_is_lazy() { + let string_array = StringArray::from(vec!["a", "b", "a"]); + let block = DataBlock::from_array(string_array); + + let DataBlock::VariableWidth(var) = &block else { + panic!("Expected VariableWidth datablock"); + }; + + { + let info = var.block_info.0.read().unwrap(); + assert!(info.contains_key(&Stat::DataSize)); + assert!(info.contains_key(&Stat::MaxLength)); + assert!(!info.contains_key(&Stat::Cardinality)); + } + + let cardinality = block.expect_single_stat::(Stat::Cardinality); + assert_eq!(cardinality, 2); + + let info = var.block_info.0.read().unwrap(); + assert!(info.contains_key(&Stat::Cardinality)); + } } diff --git a/rust/lance-encoding/src/testing.rs b/rust/lance-encoding/src/testing.rs index 37df889035f..22f7efe8e02 100644 --- a/rust/lance-encoding/src/testing.rs +++ b/rust/lance-encoding/src/testing.rs @@ -7,41 +7,358 @@ use crate::{ decoder::DecoderConfig, encodings::physical::block::CompressionScheme, format::pb21::{ - compressive_encoding::Compression, BufferCompression, CompressiveEncoding, PageLayout, + BufferCompression, CompressiveEncoding, PageLayout, compressive_encoding::Compression, }, }; -use arrow_array::{make_array, Array, StructArray, UInt64Array}; +use arrow_array::{Array, StructArray, UInt64Array, make_array}; use arrow_data::transform::{Capacities, MutableArrayData}; use arrow_ord::ord::make_comparator; use arrow_schema::{DataType, Field, Field as ArrowField, FieldRef, Schema, SortOptions}; use arrow_select::concat::concat; use bytes::{Bytes, BytesMut}; -use futures::{future::BoxFuture, FutureExt, StreamExt}; +use futures::{FutureExt, StreamExt, future::BoxFuture}; use log::{debug, info, trace}; use tokio::sync::mpsc::{self, UnboundedSender}; -use lance_core::{utils::bit::pad_bytes, Result}; -use lance_datagen::{array, gen_batch, ArrayGenerator, RowCount, Seed}; +use lance_core::{Error, Result, datatypes::Field as LanceField, utils::bit::pad_bytes}; +use lance_datagen::{ArrayGenerator, RowCount, Seed, array, gen_batch}; +use crate::compression::try_packed_struct_per_value; use crate::{ + EncodingsIo, buffer::LanceBuffer, + compression::{ + BlockCompressor, CompressionStrategy, field_metadata_params, finalize_miniblock_compressor, + reject_packed_struct_per_value, try_bitpacking_block, try_bitpacking_miniblock, + try_byte_stream_split_miniblock, try_child_rle_miniblock, + try_fixed_packed_struct_miniblock, try_fixed_u8_rle_block, try_fixed_u8_rle_miniblock, + try_general_block, try_raw_block, try_raw_fixed_size_list_miniblock, + try_raw_fixed_width_miniblock, try_raw_per_value, try_uncompressed_fixed_width_miniblock, + try_variable_rle_block, try_variable_width_miniblock, try_variable_width_per_value, + }, + compression_config::{CompressionFieldParams, CompressionParams}, + data::DataBlock, decoder::{ - create_decode_stream, ColumnInfo, DecodeBatchScheduler, DecoderMessage, DecoderPlugins, - FilterExpression, PageInfo, + ColumnInfo, DecodeBatchScheduler, DecoderMessage, DecoderPlugins, FilterExpression, + PageInfo, create_decode_stream, }, encoder::{ - default_encoding_strategy, ColumnIndexSequence, EncodedColumn, EncodedPage, - EncodingOptions, FieldEncoder, OutOfLineBuffers, MIN_PAGE_BUFFER_ALIGNMENT, + ColumnIndexSequence, EncodedColumn, EncodedPage, EncodingOptions, FieldEncoder, + FieldEncodingContext, FieldEncodingStrategy, MIN_PAGE_BUFFER_ALIGNMENT, OutOfLineBuffers, + structural::{ + PrimitiveFieldEncoding, PrimitivePageEncoding, try_create_binary_blob, try_create_list, + try_create_map, try_create_struct, try_create_structural_blob, + try_create_structural_fixed_size_list, + }, }, + encodings::logical::primitive::{fullzip::PerValueCompressor, miniblock::MiniBlockCompressor}, repdef::RepDefBuilder, - version::LanceFileVersion, - EncodingsIo, }; const MAX_PAGE_BYTES: u64 = 32 * 1024 * 1024; const TEST_ALIGNMENT: usize = MIN_PAGE_BUFFER_ALIGNMENT as usize; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TestEncoding { + Array, + StructuralU16, + StructuralU32, + StructuralSparse, +} + +impl TestEncoding { + fn all() -> impl Iterator { + [ + Self::Array, + Self::StructuralU16, + Self::StructuralU32, + Self::StructuralSparse, + ] + .into_iter() + } + + fn is_structural(self) -> bool { + self != Self::Array + } +} + +impl std::fmt::Display for TestEncoding { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Array => write!(f, "array"), + Self::StructuralU16 => write!(f, "structural-u16"), + Self::StructuralU32 => write!(f, "structural-u32"), + Self::StructuralSparse => write!(f, "structural-sparse"), + } + } +} + +#[derive(Debug, Clone)] +struct TestCompressionStrategy { + encoding: TestEncoding, + params: CompressionParams, +} + +impl TestCompressionStrategy { + fn field_params(&self, field: &LanceField) -> CompressionFieldParams { + let mut params = self + .params + .get_field_params(&field.name, &field.data_type()); + let mut metadata = field_metadata_params(field); + if self.encoding == TestEncoding::StructuralU16 + && metadata + .minichunk_size + .is_some_and(|size| size >= 32 * 1024) + { + metadata.minichunk_size = None; + } + params.merge(&metadata); + params + } +} + +impl CompressionStrategy for TestCompressionStrategy { + fn create_miniblock_compressor( + &self, + field: &LanceField, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + let compressor = + if let Some(compressor) = try_uncompressed_fixed_width_miniblock(data, ¶ms) { + compressor + } else if let Some(compressor) = try_byte_stream_split_miniblock(data, ¶ms) { + compressor + } else if let Some(compressor) = match self.encoding { + TestEncoding::StructuralSparse => try_child_rle_miniblock(data, ¶ms), + TestEncoding::Array | TestEncoding::StructuralU16 | TestEncoding::StructuralU32 => { + try_fixed_u8_rle_miniblock(data, ¶ms) + } + } { + compressor + } else if let Some(compressor) = try_bitpacking_miniblock(data) { + compressor + } else if let Some(compressor) = try_raw_fixed_width_miniblock(data) { + compressor + } else if let Some(compressor) = try_variable_width_miniblock(field, data, ¶ms)? { + compressor + } else if let Some(compressor) = try_fixed_packed_struct_miniblock(data)? { + compressor + } else if let Some(compressor) = try_raw_fixed_size_list_miniblock(data) { + compressor + } else { + return Err(lance_core::Error::not_supported_source( + format!( + "Mini-block compression not yet supported for block type {}", + data.name() + ) + .into(), + )); + }; + finalize_miniblock_compressor(data, compressor, ¶ms) + } + + fn create_per_value( + &self, + field: &LanceField, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + if let Some(compressor) = try_raw_per_value(data) { + return Ok(compressor); + } + let packed = match self.encoding { + TestEncoding::Array | TestEncoding::StructuralU16 => { + reject_packed_struct_per_value(field, data)? + } + TestEncoding::StructuralU32 | TestEncoding::StructuralSparse => { + try_packed_struct_per_value(Arc::new(self.clone()), field, data)? + } + }; + if let Some(compressor) = packed { + return Ok(compressor); + } + if let Some(compressor) = try_variable_width_per_value(field, data, ¶ms)? { + return Ok(compressor); + } + Err(lance_core::Error::not_supported_source( + format!( + "Per-value compression not yet supported for block type {}", + data.name() + ) + .into(), + )) + } + + fn create_block_compressor( + &self, + field: &LanceField, + data: &DataBlock, + ) -> Result> { + let params = self.field_params(field); + let rle = match self.encoding { + TestEncoding::Array | TestEncoding::StructuralU16 => None, + TestEncoding::StructuralU32 => try_fixed_u8_rle_block(data, ¶ms)?, + TestEncoding::StructuralSparse => try_variable_rle_block(data, ¶ms)?, + }; + if let Some(compressor) = rle { + return Ok(compressor); + } + if let Some(compressor) = try_bitpacking_block(data) { + return Ok(compressor); + } + if matches!( + self.encoding, + TestEncoding::StructuralU32 | TestEncoding::StructuralSparse + ) && let Some(compressor) = try_general_block(data, ¶ms)? + { + return Ok(compressor); + } + if let Some(compressor) = try_raw_block(data) { + return Ok(compressor); + } + Err(lance_core::Error::not_supported_source( + format!( + "Block compression not yet supported for block type {}", + data.name() + ) + .into(), + )) + } +} + +pub fn test_compression_strategy( + encoding: TestEncoding, + params: CompressionParams, +) -> Arc { + Arc::new(TestCompressionStrategy { encoding, params }) +} + +#[derive(Debug)] +struct TestFieldEncodingStrategy { + encoding: TestEncoding, + primitive: PrimitiveFieldEncoding, +} + +impl FieldEncodingStrategy for TestFieldEncodingStrategy { + fn create_field_encoder( + &self, + field: &LanceField, + column_index: &mut ColumnIndexSequence, + context: &FieldEncodingContext<'_>, + ) -> Result> { + if let Some(encoder) = + try_create_binary_blob(&self.primitive, field, column_index, context)? + { + return Ok(encoder); + } + if self.encoding != TestEncoding::StructuralU16 + && let Some(encoder) = + try_create_structural_blob(&self.primitive, field, column_index, context)? + { + return Ok(encoder); + } + if field.is_blob() { + return Err(Error::invalid_input_source( + format!( + "Blob encoding is not available for field '{}' with data type {}", + field.name, + field.data_type() + ) + .into(), + )); + } + if self.encoding != TestEncoding::StructuralU16 { + if let Some(encoder) = try_create_map(field, column_index, context)? { + return Ok(encoder); + } + if let Some(encoder) = + try_create_structural_fixed_size_list(field, column_index, context)? + { + return Ok(encoder); + } + } + if let Some(encoder) = self.primitive.try_create(field, column_index, context)? { + return Ok(encoder); + } + if self.encoding == TestEncoding::StructuralU16 { + if matches!( + field.data_type(), + DataType::FixedSizeList(item, _) + if matches!(item.data_type(), DataType::Struct(_)) + ) { + return Err(Error::not_supported_source( + "FixedSizeList is not enabled by the selected file format".into(), + )); + } + if matches!(field.data_type(), DataType::Map(_, _)) { + return Err(Error::not_supported_source( + "Map data type is not enabled by the selected file format".into(), + )); + } + } + if let Some(encoder) = try_create_list(field, column_index, context)? { + return Ok(encoder); + } + if let Some(encoder) = try_create_struct(field, column_index, context)? { + return Ok(encoder); + } + Err(Error::not_supported_source( + format!( + "{} has no field encoding for '{}' with data type {}", + self.encoding, + field.name, + field.data_type() + ) + .into(), + )) + } +} + +pub fn test_encoding_strategy(encoding: TestEncoding) -> Box { + if encoding == TestEncoding::Array { + return Box::new(crate::array_encoding::ArrayFieldEncodingStrategy::new()); + } + + let compression = test_compression_strategy(encoding, CompressionParams::default()); + let page_encodings = match encoding { + TestEncoding::StructuralU16 => vec![ + PrimitivePageEncoding::reject_sparse(), + PrimitivePageEncoding::dense_u16(compression), + ], + TestEncoding::StructuralU32 => vec![ + PrimitivePageEncoding::reject_sparse(), + PrimitivePageEncoding::constant(), + PrimitivePageEncoding::dense_u32(compression), + ], + TestEncoding::StructuralSparse => vec![ + PrimitivePageEncoding::sparse(compression.clone()), + PrimitivePageEncoding::constant(), + PrimitivePageEncoding::dense_u32(compression), + ], + TestEncoding::Array => unreachable!(), + }; + Box::new(TestFieldEncodingStrategy { + encoding, + primitive: PrimitiveFieldEncoding::new(page_encodings), + }) +} + +pub fn create_test_field_encoder( + strategy: &dyn FieldEncodingStrategy, + field: &lance_core::datatypes::Field, + column_index: &mut ColumnIndexSequence, + options: &EncodingOptions, +) -> Result> { + let context = FieldEncodingContext { + strategy, + options, + root_field_metadata: &field.metadata, + }; + strategy.create_field_encoder(field, column_index, &context) +} + #[derive(Debug)] pub(crate) struct SimulatedScheduler { data: Bytes, @@ -179,6 +496,9 @@ async fn test_decode( expected: Option>, io: Arc, is_structural_encoding: bool, + // The rows this read will schedule, used to exercise range-scoped page + // initialization. `None` initializes every page (the eager path). + requested_ranges: Option]>>, schedule_fn: impl FnOnce( DecodeBatchScheduler, UnboundedSender>, @@ -189,7 +509,7 @@ async fn test_decode( 128 * 1024 * 1024, )); let column_indices = column_indices_from_schema(schema, is_structural_encoding); - let decode_scheduler = DecodeBatchScheduler::try_new( + let decode_scheduler = DecodeBatchScheduler::try_new_with_ranges( &lance_schema, &column_indices, column_infos, @@ -198,6 +518,7 @@ async fn test_decode( Arc::::default(), io, cache, + requested_ranges.as_deref(), &FilterExpression::no_filter(), &DecoderConfig::default(), ) @@ -216,7 +537,9 @@ async fn test_decode( batch_size, is_structural_encoding, /*should_validate=*/ true, + /*spawn_structural_batch_decode_tasks=*/ is_structural_encoding, rx, + /*batch_size_bytes=*/ None, ) .unwrap(); @@ -244,14 +567,14 @@ async fn test_decode( for i in 0..expected.len() { if !matches!(comparator(i, i), Ordering::Equal) { panic!( - "Mismatch at index {} (offset={}) expected {:?} but got {:?} first mismatch is expected {:?} but got {:?}", - i, - offset, - expected, - actual, - expected.slice(i, 1), - actual.slice(i, 1) - ); + "Mismatch at index {} (offset={}) expected {:?} but got {:?} first mismatch is expected {:?} but got {:?}", + i, + offset, + expected, + actual, + expected.slice(i, 1), + actual.slice(i, 1) + ); } } } else { @@ -293,6 +616,27 @@ pub async fn check_basic_random(field: Field) { check_specific_random(field, TestCases::basic()).await; } +/// Runs one independently schedulable slice of [`check_basic_random`]. +/// +/// The complete matrix is the Cartesian product of all encodings, page sizes, +/// and slicing modes. Keeping these axes outside the helper lets expensive +/// data types preserve the full matrix without concentrating it in one test. +pub async fn check_basic_random_case( + field: Field, + encoding: TestEncoding, + page_size: u64, + use_slicing: bool, +) { + check_specific_random( + field, + TestCases::basic() + .with_encoding(encoding) + .with_page_sizes(vec![page_size]) + .with_slicing_modes([use_slicing]), + ) + .await; +} + pub async fn check_specific_random(field: Field, test_cases: TestCases) { let array_generator_provider = RandomArrayGeneratorProvider { field: field.clone(), @@ -340,24 +684,22 @@ pub async fn check_round_trip_encoding_generated( let lance_field = lance_core::datatypes::Field::try_from(&field).unwrap(); for page_size in test_cases.page_sizes.iter().copied() { debug!("Testing random data with a page size of {}", page_size); - let encoder_factory = |version: LanceFileVersion| { - let encoding_strategy = default_encoding_strategy(version); + let encoder_factory = |encoding: TestEncoding| { + let encoding_strategy = test_encoding_strategy(encoding); let mut column_index_seq = ColumnIndexSequence::default(); let encoding_options = EncodingOptions { max_page_bytes: MAX_PAGE_BYTES, cache_bytes_per_column: page_size, keep_original_array: true, buffer_alignment: MIN_PAGE_BUFFER_ALIGNMENT, - version, }; - encoding_strategy - .create_field_encoder( - encoding_strategy.as_ref(), - &lance_field, - &mut column_index_seq, - &encoding_options, - ) - .unwrap() + create_test_field_encoder( + encoding_strategy.as_ref(), + &lance_field, + &mut column_index_seq, + &encoding_options, + ) + .unwrap() }; check_round_trip_random( @@ -370,9 +712,9 @@ pub async fn check_round_trip_encoding_generated( } } -fn supports_nulls(data_type: &DataType, version: LanceFileVersion) -> bool { +fn supports_nulls(data_type: &DataType, encoding: TestEncoding) -> bool { if let DataType::Struct(fields) = data_type { - if version == LanceFileVersion::V2_0 { + if encoding == TestEncoding::Array { // 2.0 doesn't support nullability for structs false } else if fields.is_empty() { @@ -387,7 +729,7 @@ fn supports_nulls(data_type: &DataType, version: LanceFileVersion) -> bool { } } -type EncodingVerificationFn = dyn Fn(&[EncodedColumn], &LanceFileVersion); +type EncodingVerificationFn = dyn Fn(&[EncodedColumn], &TestEncoding); // The default will just test the full read #[derive(Clone)] @@ -398,8 +740,9 @@ pub struct TestCases { skip_validation: bool, max_page_size: Option, page_sizes: Vec, - min_file_version: Option, - max_file_version: Option, + slicing_modes: Vec, + ingest_batch_counts: Vec, + encodings: Vec, verify_encoding: Option>, expected_encoding: Option>, } @@ -413,8 +756,9 @@ impl Default for TestCases { skip_validation: false, max_page_size: None, page_sizes: vec![4096, 1024 * 1024], - min_file_version: None, - max_file_version: None, + slicing_modes: vec![false, true], + ingest_batch_counts: vec![1, 5, 10], + encodings: TestEncoding::all().collect(), verify_encoding: None, expected_encoding: None, } @@ -457,21 +801,58 @@ impl TestCases { self } - pub fn with_min_file_version(mut self, version: LanceFileVersion) -> Self { - self.min_file_version = Some(version); + pub fn with_encoding(mut self, encoding: TestEncoding) -> Self { + self.encodings = vec![encoding]; self } - pub fn with_max_file_version(mut self, version: LanceFileVersion) -> Self { - self.max_file_version = Some(version); + pub fn with_encodings(mut self, encodings: impl IntoIterator) -> Self { + self.encodings = encodings.into_iter().collect(); self } + pub fn with_structural_encodings(self) -> Self { + self.with_encodings([ + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + TestEncoding::StructuralSparse, + ]) + } + + pub fn with_u32_structural_encodings(self) -> Self { + self.with_encodings([TestEncoding::StructuralU32, TestEncoding::StructuralSparse]) + } + + pub fn with_dense_encodings(self) -> Self { + self.with_encodings([ + TestEncoding::Array, + TestEncoding::StructuralU16, + TestEncoding::StructuralU32, + ]) + } + + pub fn with_array_and_u16_encodings(self) -> Self { + self.with_encodings([TestEncoding::Array, TestEncoding::StructuralU16]) + } + pub fn with_page_sizes(mut self, page_sizes: Vec) -> Self { self.page_sizes = page_sizes; self } + pub fn with_slicing_modes(mut self, slicing_modes: impl IntoIterator) -> Self { + self.slicing_modes = slicing_modes.into_iter().collect(); + self + } + + pub fn with_ingest_batch_counts( + mut self, + ingest_batch_counts: impl IntoIterator, + ) -> Self { + self.ingest_batch_counts = ingest_batch_counts.into_iter().collect(); + self + } + pub fn with_max_page_size(mut self, max_page_size: u64) -> Self { self.max_page_size = Some(max_page_size); self @@ -481,22 +862,8 @@ impl TestCases { self.max_page_size.unwrap_or(MAX_PAGE_BYTES) } - fn get_versions(&self) -> Vec { - LanceFileVersion::iter_non_legacy() - .filter(|v| { - if let Some(min_file_version) = &self.min_file_version { - if v < min_file_version { - return false; - } - } - if let Some(max_file_version) = &self.max_file_version { - if v > max_file_version { - return false; - } - } - true - }) - .collect() + fn encodings(&self) -> impl Iterator + '_ { + self.encodings.iter().copied() } pub fn with_verify_encoding(mut self, verify_encoding: Arc) -> Self { @@ -504,9 +871,9 @@ impl TestCases { self } - fn verify_encoding(&self, encoding: &[EncodedColumn], version: &LanceFileVersion) { + fn verify_encoding(&self, columns: &[EncodedColumn], encoding: &TestEncoding) { if let Some(verify_encoding) = self.verify_encoding.as_ref() { - verify_encoding(encoding, version); + verify_encoding(columns, encoding); } } @@ -664,6 +1031,11 @@ fn collect_page_encoding(layout: &PageLayout, actual_chain: &mut Vec) -> collect_page_encoding(inner_layout.as_ref(), actual_chain)? } } + Layout::SparseLayout(sparse) => { + if let Some(value_compression) = &sparse.value_compression { + actual_chain.extend(extract_array_encoding_chain(value_compression)); + } + } } } @@ -678,7 +1050,6 @@ fn verify_page_encoding( ) -> Result<()> { use crate::decoder::PageEncoding; use lance_core::Error; - use snafu::location; let mut actual_chain = Vec::new(); @@ -689,32 +1060,30 @@ fn verify_page_encoding( // All-null structural pages may legitimately contain no encodings to verify. // This can happen even when compression is configured because there is no value data // (and rep/def compression is not currently described in the page layout). - if actual_chain.is_empty() && page.data.is_empty() { - if let Some(crate::format::pb21::page_layout::Layout::ConstantLayout(cl)) = + if actual_chain.is_empty() + && page.data.is_empty() + && let Some(crate::format::pb21::page_layout::Layout::ConstantLayout(cl)) = layout.layout.as_ref() - { - if cl.inline_value.is_none() { - return Ok(()); - } - } + && cl.inline_value.is_none() + { + return Ok(()); } } PageEncoding::Legacy(_) => { - // We don't need to care about legacy. + // We don't need to care about the v2.0 array encoding. } } // Check that all expected encodings appear in the actual chain for expected in expected_chain { if !actual_chain.iter().any(|actual| actual.contains(expected)) { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "Column {} expected encoding chain {:?} but got {:?}", col_idx, expected_chain, actual_chain ) .into(), - location: location!(), - }); + )); } } Ok(()) @@ -744,28 +1113,26 @@ pub async fn check_round_trip_encoding_of_data_with_expected( let mut field = Field::new("", example_data.data_type().clone(), true); field = field.with_metadata(metadata); let lance_field = lance_core::datatypes::Field::try_from(&field).unwrap(); - for file_version in test_cases.get_versions() { + for encoding in test_cases.encodings() { for page_size in test_cases.page_sizes.iter() { - let encoding_strategy = default_encoding_strategy(file_version); + let encoding_strategy = test_encoding_strategy(encoding); let mut column_index_seq = ColumnIndexSequence::default(); let encoding_options = EncodingOptions { cache_bytes_per_column: *page_size, max_page_bytes: test_cases.get_max_page_size(), keep_original_array: true, buffer_alignment: MIN_PAGE_BUFFER_ALIGNMENT, - version: file_version, }; - let encoder = encoding_strategy - .create_field_encoder( - encoding_strategy.as_ref(), - &lance_field, - &mut column_index_seq, - &encoding_options, - ) - .unwrap(); + let encoder = create_test_field_encoder( + encoding_strategy.as_ref(), + &lance_field, + &mut column_index_seq, + &encoding_options, + ) + .unwrap(); info!( - "Testing round trip encoding of data with file version {} and page size {}", - file_version, page_size + "Testing round trip encoding of data with test encoding {} and page size {}", + encoding, page_size ); check_round_trip_encoding_inner( encoder, @@ -773,7 +1140,7 @@ pub async fn check_round_trip_encoding_of_data_with_expected( data.clone(), expected_override.clone(), test_cases, - file_version, + encoding, ) .await } @@ -846,7 +1213,7 @@ async fn check_round_trip_encoding_inner( data: Vec>, expected_override: Option>, test_cases: &TestCases, - file_version: LanceFileVersion, + encoding: TestEncoding, ) { let mut writer = SimulatedWriter::new(encoder.num_columns()); @@ -887,11 +1254,11 @@ async fn check_round_trip_encoding_inner( log_page(&encoded_page); // For V2.1, verify encoding in the page if expected - if file_version >= LanceFileVersion::V2_1 { - if let Some(ref expected) = test_cases.expected_encoding { - verify_page_encoding(&encoded_page, expected, encoded_page.column_idx as usize) - .unwrap(); - } + if encoding.is_structural() + && let Some(ref expected) = test_cases.expected_encoding + { + verify_page_encoding(&encoded_page, expected, encoded_page.column_idx as usize) + .unwrap(); } writer.write_page(encoded_page); @@ -909,11 +1276,11 @@ async fn check_round_trip_encoding_inner( log_page(&encoded_page); // For V2.1, verify encoding in the page if expected - if file_version >= LanceFileVersion::V2_1 { - if let Some(ref expected) = test_cases.expected_encoding { - verify_page_encoding(&encoded_page, expected, encoded_page.column_idx as usize) - .unwrap(); - } + if encoding.is_structural() + && let Some(ref expected) = test_cases.expected_encoding + { + verify_page_encoding(&encoded_page, expected, encoded_page.column_idx as usize) + .unwrap(); } writer.write_page(encoded_page); @@ -921,7 +1288,7 @@ async fn check_round_trip_encoding_inner( let mut external_buffers = writer.new_external_buffers(); let encoded_columns = encoder.finish(&mut external_buffers).await.unwrap(); - test_cases.verify_encoding(&encoded_columns, &file_version); + test_cases.verify_encoding(&encoded_columns, &encoding); for buffer in external_buffers.take_buffers() { writer.write_lance_buffer(buffer); } @@ -974,7 +1341,7 @@ async fn check_round_trip_encoding_inner( let expected_data = expected_override.clone().or_else(|| concat_data.clone()); - let is_structural_encoding = file_version >= LanceFileVersion::V2_1; + let is_structural_encoding = encoding.is_structural(); let decode_field = if is_structural_encoding { let mut lance_field = lance_core::datatypes::Field::try_from(field).unwrap(); @@ -1004,6 +1371,8 @@ async fn check_round_trip_encoding_inner( expected_data.clone(), scheduler_copy.clone(), is_structural_encoding, + // Full scan exercises the eager (initialize-everything) path. + None, |mut decode_scheduler, tx| { async move { decode_scheduler.schedule_range( @@ -1027,6 +1396,9 @@ async fn check_round_trip_encoding_inner( .map(|arr| arr.slice(range.start as usize, num_rows as usize)); let scheduler = scheduler.clone(); let range = range.clone(); + // Range reads exercise the range-scoped (lazy) path: only the pages this + // contiguous range overlaps should be initialized. + let requested_ranges = Some(Arc::<[Range]>::from(vec![range.clone()])); test_decode( num_rows, test_cases.batch_size, @@ -1035,6 +1407,7 @@ async fn check_round_trip_encoding_inner( expected, scheduler.clone(), is_structural_encoding, + requested_ranges, |mut decode_scheduler, tx| { async move { decode_scheduler.schedule_range( @@ -1083,6 +1456,11 @@ async fn check_round_trip_encoding_inner( let scheduler = scheduler.clone(); let indices = indices.clone(); + // Take reads exercise the lazy path with scattered rows. One range per + // index is a superset of the pages `schedule_take` later touches. + let requested_ranges = Some(Arc::<[Range]>::from( + indices.iter().map(|&i| i..i + 1).collect::>(), + )); test_decode( num_rows, test_cases.batch_size, @@ -1091,6 +1469,7 @@ async fn check_round_trip_encoding_inner( expected, scheduler.clone(), is_structural_encoding, + requested_ranges, |mut decode_scheduler, tx| { async move { decode_scheduler.schedule_take( @@ -1114,20 +1493,20 @@ const NUM_RANDOM_ROWS: u32 = 10000; /// /// To test specific test cases use the async fn check_round_trip_random( - encoder_factory: impl Fn(LanceFileVersion) -> Box, + encoder_factory: impl Fn(TestEncoding) -> Box, field: Field, array_generator_provider: Box, test_cases: &TestCases, ) { for null_rate in [None, Some(0.5), Some(1.0)] { - for use_slicing in [false, true] { - for file_version in test_cases.get_versions() { + for use_slicing in test_cases.slicing_modes.iter().copied() { + for encoding in test_cases.encodings() { if null_rate != Some(1.0) && matches!(field.data_type(), DataType::Null) { continue; } let field = if null_rate.is_some() { - if !supports_nulls(field.data_type(), file_version) { + if !supports_nulls(field.data_type(), encoding) { continue; } field.clone().with_nullable(true) @@ -1135,7 +1514,7 @@ async fn check_round_trip_random( field.clone().with_nullable(false) }; - for num_ingest_batches in [1, 5, 10] { + for num_ingest_batches in test_cases.ingest_batch_counts.iter().copied() { let rows_per_batch = NUM_RANDOM_ROWS / num_ingest_batches; let mut data = Vec::new(); @@ -1185,8 +1564,8 @@ async fn check_round_trip_random( } info!( - "Testing version {} with {} rows divided across {} batches for {} rows per batch with null_rate={:?} and use_slicing={}", - file_version, + "Testing encoding {} with {} rows divided across {} batches for {} rows per batch with null_rate={:?} and use_slicing={}", + encoding, NUM_RANDOM_ROWS, num_ingest_batches, rows_per_batch, @@ -1194,12 +1573,12 @@ async fn check_round_trip_random( use_slicing ); check_round_trip_encoding_inner( - encoder_factory(file_version), + encoder_factory(encoding), &field, data, None, test_cases, - file_version, + encoding, ) .await } diff --git a/rust/lance-encoding/src/utils/accumulation.rs b/rust/lance-encoding/src/utils/accumulation.rs index ebd64abb58d..f6255d12ecd 100644 --- a/rust/lance-encoding/src/utils/accumulation.rs +++ b/rust/lance-encoding/src/utils/accumulation.rs @@ -67,8 +67,7 @@ impl AccumulationQueue { } else { trace!( "Accumulating data for column {}. Now at {} bytes", - self.column_index, - self.current_bytes + self.column_index, self.current_bytes ); if self.keep_original_array { self.buffered_arrays.push(array); @@ -89,8 +88,7 @@ impl AccumulationQueue { } else { trace!( "Final flush of column {} which has {} bytes", - self.column_index, - self.current_bytes + self.column_index, self.current_bytes ); self.current_bytes = 0; let row_number = self.row_number; diff --git a/rust/lance-encoding/src/utils/bytepack.rs b/rust/lance-encoding/src/utils/bytepack.rs index 1fbf17277c1..7c92b8f86cf 100644 --- a/rust/lance-encoding/src/utils/bytepack.rs +++ b/rust/lance-encoding/src/utils/bytepack.rs @@ -4,6 +4,8 @@ //! Utilities for byte (not bit) packing for situations where saving a few //! bits is less important than simplicity and speed. +use lance_core::{Error, Result}; + pub struct U8BytePacker { data: Vec, } @@ -15,8 +17,8 @@ impl U8BytePacker { } } - fn append(&mut self, value: u64) { - self.data.push(value as u8); + fn append(&mut self, value: u8) { + self.data.push(value); } } @@ -31,8 +33,8 @@ impl U16BytePacker { } } - fn append(&mut self, value: u64) { - self.data.extend_from_slice(&(value as u16).to_le_bytes()); + fn append(&mut self, value: u16) { + self.data.extend_from_slice(&value.to_le_bytes()); } } @@ -47,8 +49,8 @@ impl U32BytePacker { } } - fn append(&mut self, value: u64) { - self.data.extend_from_slice(&(value as u32).to_le_bytes()); + fn append(&mut self, value: u32) { + self.data.extend_from_slice(&value.to_le_bytes()); } } @@ -105,16 +107,48 @@ impl BytepackedIntegerEncoder { /// Append a value to the encoder. /// - /// # Safety + /// # Errors /// - /// This function is unsafe because it doesn't check for overflow. If the - /// value is too large to fit in the chosen integer type, it will be silently - /// truncated. - pub unsafe fn append(&mut self, value: u64) { + /// Returns an error if `value` does not fit in the width selected at + /// construction time. + pub fn append(&mut self, value: u64) -> Result<()> { + match self { + Self::U8(_) if value > u8::MAX as u64 => { + return Err(Error::invalid_input(format!( + "value {value} does not fit in bytepacked u8" + ))); + } + Self::U16(_) if value > u16::MAX as u64 => { + return Err(Error::invalid_input(format!( + "value {value} does not fit in bytepacked u16" + ))); + } + Self::U32(_) if value > u32::MAX as u64 => { + return Err(Error::invalid_input(format!( + "value {value} does not fit in bytepacked u32" + ))); + } + _ => {} + } + self.append_trusted(value); + Ok(()) + } + + /// Append a value whose range is guaranteed by the caller's construction. + pub(crate) fn append_trusted(&mut self, value: u64) { match self { - Self::U8(packer) => packer.append(value), - Self::U16(packer) => packer.append(value), - Self::U32(packer) => packer.append(value), + Self::U8(packer) => { + debug_assert!(u8::try_from(value).is_ok()); + packer.append(value as u8); + } + Self::U16(packer) => { + debug_assert!(u16::try_from(value).is_ok()); + packer.append(value as u16); + } + Self::U32(packer) => { + debug_assert!(u32::try_from(value).is_ok()); + packer.append(value as u32); + } Self::U64(packer) => packer.append(value), Self::Zero => {} } @@ -197,11 +231,9 @@ mod tests { fn test_bytepacked_integer_encoder() { // Fits in u8 let mut encoder = BytepackedIntegerEncoder::with_capacity(10, 100); - unsafe { - encoder.append(50); - encoder.append(20); - encoder.append(30); - } + encoder.append(50).unwrap(); + encoder.append(20).unwrap(); + encoder.append(30).unwrap(); let data = encoder.into_data(); assert_eq!(data, vec![50, 20, 30]); @@ -212,11 +244,9 @@ mod tests { // Requires u16 let mut encoder = BytepackedIntegerEncoder::with_capacity(10, 1000); - unsafe { - encoder.append(500); - encoder.append(200); - encoder.append(300); - } + encoder.append(500).unwrap(); + encoder.append(200).unwrap(); + encoder.append(300).unwrap(); let data = encoder.into_data(); assert_eq!(data, vec![244, 1, 200, 0, 44, 1]); @@ -227,11 +257,9 @@ mod tests { // Requires u32 let mut encoder = BytepackedIntegerEncoder::with_capacity(10, 1000000); - unsafe { - encoder.append(500000); - encoder.append(200000); - encoder.append(300000); - } + encoder.append(500000).unwrap(); + encoder.append(200000).unwrap(); + encoder.append(300000).unwrap(); let data = encoder.into_data(); assert_eq!(data, vec![32, 161, 7, 0, 64, 13, 3, 0, 224, 147, 4, 0]); @@ -242,15 +270,15 @@ mod tests { // Requires u64 let mut encoder = BytepackedIntegerEncoder::with_capacity(10, 0x10000000000); - unsafe { - encoder.append(0x5000000000); - encoder.append(0x2000000000); - encoder.append(0x3000000000); - } + encoder.append(0x5000000000).unwrap(); + encoder.append(0x2000000000).unwrap(); + encoder.append(0x3000000000).unwrap(); let data = encoder.into_data(); assert_eq!( data, - vec![0, 0, 0, 0, 80, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, 48, 0, 0, 0] + vec![ + 0, 0, 0, 0, 80, 0, 0, 0, 0, 0, 0, 0, 32, 0, 0, 0, 0, 0, 0, 0, 48, 0, 0, 0 + ] ); assert_eq!( @@ -258,4 +286,21 @@ mod tests { vec![0x5000000000, 0x2000000000, 0x3000000000] ); } + + #[test] + fn test_bytepacked_integer_encoder_rejects_overflow() { + for (max_value, invalid_value, expected_width) in [ + (u8::MAX as u64, u8::MAX as u64 + 1, "u8"), + (u16::MAX as u64, u16::MAX as u64 + 1, "u16"), + (u32::MAX as u64, u32::MAX as u64 + 1, "u32"), + ] { + let mut encoder = BytepackedIntegerEncoder::with_capacity(1, max_value); + let error = encoder.append(invalid_value).unwrap_err(); + assert!(error.to_string().contains(expected_width), "{error}"); + } + + let mut disabled = BytepackedIntegerEncoder::with_capacity(1, 0); + disabled.append(u64::MAX).unwrap(); + assert!(disabled.into_data().is_empty()); + } } diff --git a/rust/lance-encoding/src/version.rs b/rust/lance-encoding/src/version.rs deleted file mode 100644 index 726f36ec3cb..00000000000 --- a/rust/lance-encoding/src/version.rs +++ /dev/null @@ -1,137 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -use std::str::FromStr; - -use lance_arrow::DataTypeExt; -use lance_core::datatypes::Field; -use lance_core::{Error, Result}; -use snafu::location; - -pub const LEGACY_FORMAT_VERSION: &str = "0.1"; -pub const V2_FORMAT_2_0: &str = "2.0"; -pub const V2_FORMAT_2_1: &str = "2.1"; -pub const V2_FORMAT_2_2: &str = "2.2"; - -/// Lance file version -#[derive(Debug, Default, PartialEq, Eq, Clone, Copy, Ord, PartialOrd, strum::EnumIter)] -pub enum LanceFileVersion { - // This is a little confusing but we rely on the following facts: - // - // Any version <= Next is stable - // The latest version before Stable is the default version for new datasets - // Any version >= Next is unstable - // - // As a result, 'Stable' is not the divider between stable and unstable (Next does this) - // but only serves to mark the default version for new datasets. - // - /// The legacy (0.1) format - Legacy, - #[default] - V2_0, - /// The latest stable release (also the default version for new datasets) - Stable, - V2_1, - /// The latest unstable release - Next, - V2_2, -} - -impl LanceFileVersion { - /// Convert Stable or Next to the actual version - pub fn resolve(&self) -> Self { - match self { - Self::Stable => Self::V2_0, - Self::Next => Self::V2_1, - _ => *self, - } - } - - pub fn is_unstable(&self) -> bool { - self >= &Self::Next - } - - pub fn try_from_major_minor(major: u32, minor: u32) -> Result { - match (major, minor) { - (0, 0) => Ok(Self::Legacy), - (0, 1) => Ok(Self::Legacy), - (0, 2) => Ok(Self::Legacy), - (0, 3) => Ok(Self::V2_0), - (2, 0) => Ok(Self::V2_0), - (2, 1) => Ok(Self::V2_1), - (2, 2) => Ok(Self::V2_2), - _ => Err(Error::InvalidInput { - source: format!("Unknown Lance storage version: {}.{}", major, minor).into(), - location: location!(), - }), - } - } - - pub fn to_numbers(&self) -> (u32, u32) { - match self { - Self::Legacy => (0, 2), - Self::V2_0 => (2, 0), - Self::V2_1 => (2, 1), - Self::V2_2 => (2, 2), - Self::Stable => self.resolve().to_numbers(), - Self::Next => self.resolve().to_numbers(), - } - } - - pub fn iter_non_legacy() -> impl Iterator { - use strum::IntoEnumIterator; - - Self::iter().filter(|&v| v != Self::Stable && v != Self::Next && v != Self::Legacy) - } - - pub fn support_add_sub_column(&self) -> bool { - self > &Self::V2_1 - } - - pub fn support_remove_sub_column(&self, field: &Field) -> bool { - if self <= &Self::V2_1 { - field.data_type().is_struct() - } else { - field.data_type().is_nested() - } - } -} - -impl std::fmt::Display for LanceFileVersion { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "{}", - match self { - Self::Legacy => LEGACY_FORMAT_VERSION, - Self::V2_0 => V2_FORMAT_2_0, - Self::V2_1 => V2_FORMAT_2_1, - Self::V2_2 => V2_FORMAT_2_2, - Self::Stable => "stable", - Self::Next => "next", - } - ) - } -} - -impl FromStr for LanceFileVersion { - type Err = Error; - - fn from_str(value: &str) -> Result { - match value.to_lowercase().as_str() { - LEGACY_FORMAT_VERSION => Ok(Self::Legacy), - V2_FORMAT_2_0 => Ok(Self::V2_0), - V2_FORMAT_2_1 => Ok(Self::V2_1), - V2_FORMAT_2_2 => Ok(Self::V2_2), - "stable" => Ok(Self::Stable), - "legacy" => Ok(Self::Legacy), - "next" => Ok(Self::Next), - // Version 0.3 is an alias of 2.0 - "0.3" => Ok(Self::V2_0), - _ => Err(Error::InvalidInput { - source: format!("Unknown Lance storage version: {}", value).into(), - location: location!(), - }), - } - } -} diff --git a/rust/lance-file/Cargo.toml b/rust/lance-file/Cargo.toml index abf3ea07bf1..2dfd09e8d33 100644 --- a/rust/lance-file/Cargo.toml +++ b/rust/lance-file/Cargo.toml @@ -19,6 +19,7 @@ lance-io.workspace = true arrow-arith.workspace = true arrow-array.workspace = true arrow-buffer.workspace = true +arrow-cast.workspace = true arrow-data.workspace = true arrow-schema.workspace = true arrow-select.workspace = true @@ -27,14 +28,13 @@ async-trait.workspace = true byteorder.workspace = true bytes.workspace = true datafusion-common.workspace = true -deepsize.workspace = true futures.workspace = true log.workspace = true num-traits.workspace = true object_store.workspace = true prost.workspace = true prost-types.workspace = true -snafu.workspace = true +serde.workspace = true tokio.workspace = true tracing.workspace = true @@ -42,19 +42,16 @@ tracing.workspace = true lance-datagen.workspace = true lance-testing.workspace = true criterion.workspace = true -rand.workspace = true rstest.workspace = true proptest.workspace = true pretty_assertions.workspace = true +rand.workspace = true test-log.workspace = true libc.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 } +protobuf-src = { workspace = true, optional = true } [features] protoc = ["dep:protobuf-src"] @@ -67,5 +64,9 @@ features = ["protoc"] name = "reader" harness = false +[[bench]] +name = "schema" +harness = false + [lints] workspace = true diff --git a/rust/lance-file/benches/reader.rs b/rust/lance-file/benches/reader.rs index a00af5015fa..5d280f00d26 100644 --- a/rust/lance-file/benches/reader.rs +++ b/rust/lance-file/benches/reader.rs @@ -2,9 +2,11 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::sync::{Arc, Mutex}; -use arrow_array::{cast::AsArray, types::Int32Type, UInt32Array}; +use arrow_array::{UInt32Array, cast::AsArray, types::Int32Type}; use arrow_schema::DataType; -use criterion::{black_box, criterion_group, criterion_main, BatchSize, Criterion, Throughput}; +use std::hint::black_box; + +use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; use futures::{FutureExt, StreamExt}; use lance_core::utils::{tempfile::TempDir, tokio::get_num_compute_intensive_cpus}; use lance_datagen::ArrayGeneratorExt; @@ -12,8 +14,9 @@ use lance_encoding::decoder::{DecoderConfig, DecoderPlugins, FilterExpression}; use lance_file::{ reader::{FileReader, FileReaderOptions}, testing::test_cache, - version::LanceFileVersion, - writer::{FileWriter, FileWriterOptions}, + version::ConcreteFileVersion, + versions as file_versions, + writer::FileWriterOptions, }; use lance_io::{ object_store::ObjectStore, @@ -25,7 +28,11 @@ use std::collections::HashMap; use tokio::runtime::Runtime; fn bench_reader(c: &mut Criterion) { - for version in [LanceFileVersion::V2_0, LanceFileVersion::V2_1] { + for version in [ + ConcreteFileVersion::V2_0, + ConcreteFileVersion::V2_1, + ConcreteFileVersion::V2_2, + ] { let mut group = c.benchmark_group(format!("reader_{}", version)); let data = lance_datagen::gen_batch() .anon_col(lance_datagen::array::rand_type(&DataType::Int32)) @@ -38,16 +45,14 @@ fn bench_reader(c: &mut Criterion) { .block_on(ObjectStore::from_uri(&tmpdir.path_str())) .unwrap(); - let file_path = base_path.child("foo.lance"); + let file_path = base_path.clone().join("foo.lance"); let object_writer = rt.block_on(object_store.create(&file_path)).unwrap(); - let mut writer = FileWriter::try_new( + let mut writer = file_versions::create_writer( + version, object_writer, data.schema().as_ref().try_into().unwrap(), - FileWriterOptions { - format_version: Some(version), - ..Default::default() - }, + FileWriterOptions::default(), ) .unwrap(); rt.block_on(writer.write_batch(&data)).unwrap(); @@ -85,6 +90,7 @@ fn bench_reader(c: &mut Criterion) { None, FilterExpression::no_filter(), ) + .await .unwrap(); let stats = Arc::new(Mutex::new((0, 0))); let mut stream = stream @@ -168,7 +174,7 @@ fn get_cached_readers( tmpdir: &TempDir, filesystem: &str, rt: &Runtime, - version: LanceFileVersion, + version: ConcreteFileVersion, ) -> Arc { use std::sync::{LazyLock, Mutex}; @@ -196,7 +202,7 @@ fn get_cached_readers( // Create filename with version to avoid collisions let filename = format!("bench_{}.lance", version); - let file_path = base_path.child(filename.as_str()); + let file_path = base_path.join(filename.as_str()); // Generate data let data = lance_datagen::gen_batch() @@ -206,13 +212,11 @@ fn get_cached_readers( // Write file let object_writer = rt.block_on(object_store.create(&file_path)).unwrap(); - let mut writer = FileWriter::try_new( + let mut writer = file_versions::create_writer( + version, object_writer, data.schema().as_ref().try_into().unwrap(), - FileWriterOptions { - format_version: Some(version), - ..Default::default() - }, + FileWriterOptions::default(), ) .unwrap(); rt.block_on(writer.write_batch(&data)).unwrap(); @@ -301,6 +305,7 @@ fn read_task( None, FilterExpression::no_filter(), ) + .await .unwrap(); let stats = Arc::new(Mutex::new((0, 0))); let mut stream = stream.then(|batch_task| { @@ -357,7 +362,11 @@ fn bench_random_access(c: &mut Criterion) { let mut group = c.benchmark_group("take"); - let versions = [LanceFileVersion::V2_0, LanceFileVersion::V2_1]; + let versions = [ + ConcreteFileVersion::V2_0, + ConcreteFileVersion::V2_1, + ConcreteFileVersion::V2_2, + ]; for filesystem in filesystems { for version in versions { @@ -367,7 +376,8 @@ fn bench_random_access(c: &mut Criterion) { for multithreaded in [false, true] { for rows_at_a_time in [1, 100] { for cached in [true, false] { - if !cached && (filesystem == "mem" || version == LanceFileVersion::V2_0) { + if !cached && (filesystem == "mem" || version == ConcreteFileVersion::V2_0) + { continue; } @@ -455,7 +465,7 @@ fn bench_random_access(c: &mut Criterion) { criterion_group!( name=benches; config = Criterion::default().significance_level(0.1).sample_size(10) - .with_profiler(pprof::criterion::PProfProfiler::new(100, pprof::criterion::Output::Flamegraph(None))); + .with_profiler(lance_testing::pprof::PProfProfiler::new(100, lance_testing::pprof::Output::Flamegraph(None))); targets = bench_reader, bench_random_access); // Non-linux version does not support pprof. diff --git a/rust/lance-file/benches/schema.rs b/rust/lance-file/benches/schema.rs new file mode 100644 index 00000000000..b8da23d9777 --- /dev/null +++ b/rust/lance-file/benches/schema.rs @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::hint::black_box; + +use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use lance_core::datatypes::Schema; +use lance_file::{datatypes::Fields, format::pb}; + +fn proto_field(id: i32, parent_id: i32, name: String, logical_type: &str) -> pb::Field { + pb::Field { + id, + parent_id, + name, + logical_type: logical_type.to_owned(), + ..Default::default() + } +} + +/// Builds a pre-order flat schema with `num_physical_columns` physical leaves. +/// +/// Each struct contributes one parent and two `int32` leaves. Root fields use +/// `-1` as `parent_id`, and each struct consumes a three-ID block. +fn wide_two_leaf_structs(num_physical_columns: usize) -> Fields { + assert_eq!(num_physical_columns % 2, 0); + let num_structs = num_physical_columns / 2; + let mut fields = Vec::with_capacity(num_structs + num_physical_columns); + + for struct_index in 0..num_structs { + let parent_id = (struct_index * 3) as i32; + fields.push(proto_field( + parent_id, + -1, + format!("struct_{struct_index}"), + "struct", + )); + fields.push(proto_field( + parent_id + 1, + parent_id, + format!("left_{struct_index}"), + "int32", + )); + fields.push(proto_field( + parent_id + 2, + parent_id, + format!("right_{struct_index}"), + "int32", + )); + } + + Fields(fields) +} + +fn bench_schema_reconstruction(c: &mut Criterion) { + let mut group = c.benchmark_group("schema_from_flat_fields"); + + for num_physical_columns in [1024, 4096, 16_384, 65_536] { + let fields = wide_two_leaf_structs(num_physical_columns); + group.throughput(Throughput::Elements(fields.0.len() as u64)); + group.bench_with_input( + BenchmarkId::new("physical_columns", num_physical_columns), + &fields, + |bencher, fields| { + bencher.iter(|| Schema::try_from(black_box(fields)).unwrap()); + }, + ); + } + + group.finish(); +} + +criterion_group!(benches, bench_schema_reconstruction); +criterion_main!(benches); diff --git a/rust/lance-file/build.rs b/rust/lance-file/build.rs index 29dcd18a700..70ccdc250c8 100644 --- a/rust/lance-file/build.rs +++ b/rust/lance-file/build.rs @@ -8,7 +8,9 @@ fn main() -> Result<()> { #[cfg(feature = "protoc")] // Use vendored protobuf compiler if requested. - std::env::set_var("PROTOC", protobuf_src::protoc()); + unsafe { + std::env::set_var("PROTOC", protobuf_src::protoc()); + } let mut prost_build = prost_build::Config::new(); prost_build.protoc_arg("--experimental_allow_proto3_optional"); diff --git a/rust/lance-file/src/compatibility_tests.rs b/rust/lance-file/src/compatibility_tests.rs new file mode 100644 index 00000000000..c63137ec6e2 --- /dev/null +++ b/rust/lance-file/src/compatibility_tests.rs @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +use std::collections::HashMap; +use std::sync::Arc; + +use arrow_array::builder::StringDictionaryBuilder; +use arrow_array::cast::AsArray; +use arrow_array::types::{Int8Type, Int32Type}; +use arrow_array::{ + Array, ArrayRef, Int32Array, LargeBinaryArray, ListArray, RecordBatch, StringArray, +}; +use arrow_schema::{DataType, Field, Schema as ArrowSchema}; +use bytes::Bytes; +use futures::TryStreamExt; +use lance_core::cache::LanceCache; +use lance_core::datatypes::Schema as LanceSchema; +use lance_encoding::decoder::{DecoderPlugins, EncodedBatchLayout, FilterExpression, decode_batch}; +use lance_encoding::encoder::{EncodedBatch, EncodingOptions, encode_batch}; +use lance_io::ReadBatchParams; +use lance_io::traits::Writer; +use lance_io::utils::CachedFileSize; +use rstest::rstest; +use tokio::io::AsyncWriteExt; + +use crate::reader::{EncodedBatchReaderExt, FileReader, FileReaderOptions}; +use crate::testing::FsFixture; +use crate::version::ConcreteFileVersion; +use crate::versions; +use crate::versions::v1::reader::FileReader as V1Reader; +use crate::versions::v1::writer::{ + FileWriter as V1Writer, FileWriterOptions as V1WriterOptions, NotSelfDescribing, +}; +use crate::writer::FileWriterOptions; + +fn compatibility_fixture_batch() -> RecordBatch { + let row_count = 4097; + let ids = Arc::new(Int32Array::from_iter_values(0..row_count)) as ArrayRef; + let names = Arc::new(StringArray::from_iter((0..row_count).map(|index| { + (index % 7 != 0).then(|| format!("value-{index:04}-deterministic-fixture")) + }))) as ArrayRef; + let items = Arc::new(ListArray::from_iter_primitive::( + (0..row_count).map(|index| { + (index % 11 != 0).then(|| { + vec![ + Some(index), + (index % 5 != 0).then_some(index * 2), + Some(index * 3), + ] + }) + }), + )) as ArrayRef; + let mut categories = StringDictionaryBuilder::::new(); + for index in 0..row_count { + if index % 13 == 0 { + categories.append_null(); + } else { + categories + .append(match index % 3 { + 0 => "red", + 1 => "green", + _ => "blue", + }) + .unwrap(); + } + } + let categories = Arc::new(categories.finish()) as ArrayRef; + let blobs = Arc::new(LargeBinaryArray::from_iter_values( + (0..row_count).map(|index| format!("blob-{index:04}-deterministic-payload").into_bytes()), + )) as ArrayRef; + + let schema = Arc::new(ArrowSchema::new(vec![ + Field::new("id", DataType::Int32, false), + Field::new("name", DataType::Utf8, true).with_metadata(HashMap::from([( + "lance-encoding:compression".to_string(), + "none".to_string(), + )])), + Field::new( + "items", + DataType::List(Arc::new(Field::new("item", DataType::Int32, true))), + true, + ), + Field::new( + "category", + DataType::Dictionary(Box::new(DataType::Int8), Box::new(DataType::Utf8)), + true, + ) + .with_metadata(HashMap::from([( + "lance-encoding:dict-values-compression".to_string(), + "none".to_string(), + )])), + Field::new("blob", DataType::LargeBinary, true).with_metadata(HashMap::from([( + "lance-encoding:blob".to_string(), + "true".to_string(), + )])), + ])); + RecordBatch::try_new(schema, vec![ids, names, items, categories, blobs]).unwrap() +} + +fn v1_reader_expected_batch(batch: &RecordBatch) -> RecordBatch { + // The V1 reader historically materializes null lists as empty, null child integers as zero, + // and null dictionary keys as the first dictionary value. + let items = Arc::new(ListArray::from_iter_primitive::( + (0..batch.num_rows() as i32).map(|index| { + Some(if index % 11 == 0 { + Vec::new() + } else { + vec![ + Some(index), + Some(if index % 5 == 0 { 0 } else { index * 2 }), + Some(index * 3), + ] + }) + }), + )) as ArrayRef; + let mut categories = StringDictionaryBuilder::::new(); + for index in 0..batch.num_rows() { + categories + .append(if index % 13 == 0 { + "green" + } else { + match index % 3 { + 0 => "red", + 1 => "green", + _ => "blue", + } + }) + .unwrap(); + } + let categories = Arc::new(categories.finish()) as ArrayRef; + let mut columns = batch.columns().to_vec(); + columns[2] = items; + columns[3] = categories; + RecordBatch::try_new(batch.schema(), columns).unwrap() +} + +fn stable_fixture(version: ConcreteFileVersion) -> &'static [u8] { + match version { + ConcreteFileVersion::V1 => include_bytes!("../test_data/exact_versions/v1.lance"), + ConcreteFileVersion::V2_0 => { + include_bytes!("../test_data/exact_versions/v2_0.lance") + } + ConcreteFileVersion::V2_1 => { + include_bytes!("../test_data/exact_versions/v2_1.lance") + } + ConcreteFileVersion::V2_2 => { + include_bytes!("../test_data/exact_versions/v2_2.lance") + } + ConcreteFileVersion::V2_3 => { + unreachable!("v2.3 is unstable and has no compatibility fixture") + } + } +} + +fn assert_blob_column_eq(actual: &dyn Array, expected: &dyn Array) { + let actual = actual.as_binary::(); + let expected = expected.as_binary::(); + assert_eq!(actual.len(), expected.len()); + for index in 0..actual.len() { + assert_eq!( + actual.is_null(index), + expected.is_null(index), + "blob validity differs at row {index}" + ); + if actual.is_valid(index) { + assert_eq!( + actual.value(index), + expected.value(index), + "blob payload differs at row {index}" + ); + } + } +} + +fn assert_record_batch_eq(actual: &RecordBatch, expected: &RecordBatch) { + assert_eq!(actual.schema_ref(), expected.schema_ref()); + assert_eq!(actual.num_rows(), expected.num_rows()); + assert_eq!(actual.num_columns(), expected.num_columns()); + + for column_index in 0..actual.num_columns() { + if expected.schema().field(column_index).name() == "blob" { + assert_blob_column_eq( + actual.column(column_index).as_ref(), + expected.column(column_index).as_ref(), + ); + } else if actual.column(column_index).to_data() != expected.column(column_index).to_data() { + let row_index = (0..actual.num_rows()) + .find(|row_index| { + actual.column(column_index).slice(*row_index, 1).to_data() + != expected.column(column_index).slice(*row_index, 1).to_data() + }) + .unwrap(); + panic!( + "column {} ({}) differs at row {}: actual={:?}, expected={:?}", + column_index, + expected.schema().field(column_index).name(), + row_index, + actual.column(column_index).slice(row_index, 1), + expected.column(column_index).slice(row_index, 1) + ); + } + } +} + +fn footer_version(bytes: &[u8]) -> (u16, u16) { + let version_start = bytes.len() - 8; + ( + u16::from_le_bytes([bytes[version_start], bytes[version_start + 1]]), + u16::from_le_bytes([bytes[version_start + 2], bytes[version_start + 3]]), + ) +} + +fn assert_wire_bytes_equal(actual: &[u8], expected: &[u8]) { + if let Some(offset) = actual + .iter() + .zip(expected) + .position(|(actual, expected)| actual != expected) + { + panic!( + "wire fixture first differs at byte {offset}: actual={}, expected={}", + actual[offset], expected[offset] + ); + } + assert_eq!( + actual.len(), + expected.len(), + "wire fixture length changed after a common {}-byte prefix", + actual.len().min(expected.len()) + ); +} + +async fn write_current_fixture( + version: ConcreteFileVersion, + batch: &RecordBatch, + schema: &LanceSchema, +) -> Vec { + let fs = FsFixture::default(); + let object_writer = fs.object_store.create(&fs.tmp_path).await.unwrap(); + let options = FileWriterOptions { + data_cache_bytes: Some(1), + max_page_bytes: Some(1024), + ..Default::default() + }; + let summary = match version { + ConcreteFileVersion::V1 => { + unreachable!("legacy fixtures use the legacy writer") + } + ConcreteFileVersion::V2_0 => { + let mut writer = + versions::v2_0::create_writer(object_writer, schema.clone(), options).unwrap(); + for offset in (0..batch.num_rows()).step_by(1024) { + let slice = batch.slice(offset, (batch.num_rows() - offset).min(1024)); + writer.write_batch(&slice).await.unwrap(); + } + writer.finish().await.unwrap() + } + ConcreteFileVersion::V2_1 => { + let mut writer = + versions::v2_1::create_writer(object_writer, schema.clone(), options).unwrap(); + for offset in (0..batch.num_rows()).step_by(1024) { + let slice = batch.slice(offset, (batch.num_rows() - offset).min(1024)); + writer.write_batch(&slice).await.unwrap(); + } + writer.finish().await.unwrap() + } + ConcreteFileVersion::V2_2 => { + let mut writer = + versions::v2_2::create_writer(object_writer, schema.clone(), options).unwrap(); + for offset in (0..batch.num_rows()).step_by(1024) { + let slice = batch.slice(offset, (batch.num_rows() - offset).min(1024)); + writer.write_batch(&slice).await.unwrap(); + } + writer.finish().await.unwrap() + } + ConcreteFileVersion::V2_3 => { + let mut writer = + versions::v2_3::create_writer(object_writer, schema.clone(), options).unwrap(); + for offset in (0..batch.num_rows()).step_by(1024) { + let slice = batch.slice(offset, (batch.num_rows() - offset).min(1024)); + writer.write_batch(&slice).await.unwrap(); + } + writer.finish().await.unwrap() + } + }; + fs.object_store + .open(&fs.tmp_path) + .await + .unwrap() + .get_range(0..summary.size_bytes as usize) + .await + .unwrap() + .to_vec() +} + +async fn write_v2_0_embedded_fixtures(batch: &RecordBatch, schema: &LanceSchema) -> (Bytes, Bytes) { + let options = EncodingOptions { + cache_bytes_per_column: 1, + max_page_bytes: 1024, + keep_original_array: true, + buffer_alignment: 64, + }; + let encoding_strategy = crate::versions::v2_0::encoding_strategy(); + let encoded_batch = encode_batch( + batch, + Arc::new(schema.clone()), + encoding_strategy.as_ref(), + &options, + ) + .await + .unwrap(); + + ( + versions::v2_0::encode_self_described_batch(&encoded_batch).unwrap(), + versions::v2_0::encode_mini_batch(&encoded_batch).unwrap(), + ) +} + +async fn assert_current_reader_roundtrip( + fixture: &[u8], + version: ConcreteFileVersion, + expected: &RecordBatch, +) { + let fs = FsFixture::default(); + let mut fixture_writer = fs.object_store.create(&fs.tmp_path).await.unwrap(); + fixture_writer.write_all(fixture).await.unwrap(); + Writer::shutdown(fixture_writer.as_mut()).await.unwrap(); + let scheduler = fs + .scheduler + .open_file(&fs.tmp_path, &CachedFileSize::new(fixture.len() as u64)) + .await + .unwrap(); + let reader = FileReader::try_open( + scheduler, + None, + Arc::::default(), + &LanceCache::no_cache(), + FileReaderOptions::default(), + ) + .await + .unwrap(); + assert_eq!(reader.metadata().version(), version); + assert!( + reader + .metadata() + .column_metadatas + .iter() + .any(|metadata| metadata.pages.len() > 1) + ); + let batches = reader + .read_stream( + ReadBatchParams::RangeFull, + 1024, + 16, + FilterExpression::no_filter(), + ) + .await + .unwrap() + .try_collect::>() + .await + .unwrap(); + assert_eq!( + batches.iter().map(RecordBatch::num_rows).sum::(), + expected.num_rows() + ); + assert!( + batches + .iter() + .all(|actual| actual.schema_ref() == expected.schema_ref()) + ); + let mut row_offset = 0; + for actual in &batches { + let expected = expected.slice(row_offset, actual.num_rows()); + assert_record_batch_eq(actual, &expected); + row_offset += actual.num_rows(); + } + assert_eq!(row_offset, expected.num_rows()); +} + +#[rstest] +#[case::v2_0(ConcreteFileVersion::V2_0)] +#[case::v2_1(ConcreteFileVersion::V2_1)] +#[case::v2_2(ConcreteFileVersion::V2_2)] +#[tokio::test] +async fn stable_current_writer_and_reader_are_wire_compatible( + #[case] version: ConcreteFileVersion, +) { + let batch = compatibility_fixture_batch(); + let mut schema = LanceSchema::try_from(batch.schema().as_ref()).unwrap(); + schema.set_dictionary(&batch).unwrap(); + + let actual = write_current_fixture(version, &batch, &schema).await; + let expected = stable_fixture(version); + assert_wire_bytes_equal(&actual, expected); + assert_eq!( + footer_version(expected), + version.to_standard_footer_numbers() + ); + assert_current_reader_roundtrip(expected, version, &batch).await; +} + +#[tokio::test] +async fn v2_0_embedded_writer_and_reader_are_wire_compatible() { + let batch = compatibility_fixture_batch() + .project(&[0, 1]) + .unwrap() + .slice(0, 257); + let mut schema = LanceSchema::try_from(batch.schema().as_ref()).unwrap(); + schema.set_dictionary(&batch).unwrap(); + + let (actual_self_described, actual_mini) = write_v2_0_embedded_fixtures(&batch, &schema).await; + let expected_self_described = + include_bytes!("../test_data/exact_versions/v2_0_self_described.lance"); + let expected_mini = include_bytes!("../test_data/exact_versions/v2_0_mini.lance"); + assert_wire_bytes_equal(&actual_self_described, expected_self_described); + assert_wire_bytes_equal(&actual_mini, expected_mini); + + let expected_footer = ConcreteFileVersion::V2_0.to_embedded_footer_numbers(); + assert_eq!(footer_version(expected_self_described), expected_footer); + assert_eq!(footer_version(expected_mini), expected_footer); + + let self_described = + EncodedBatch::try_from_self_described_lance(Bytes::from_static(expected_self_described)) + .unwrap(); + let decoded = decode_batch( + &self_described, + &FilterExpression::no_filter(), + Arc::::default(), + false, + EncodedBatchLayout::Array, + None, + ) + .await + .unwrap(); + assert_record_batch_eq(&decoded, &batch); + + let mini = + EncodedBatch::try_from_mini_lance(Bytes::from_static(expected_mini), &schema).unwrap(); + let decoded = decode_batch( + &mini, + &FilterExpression::no_filter(), + Arc::::default(), + false, + EncodedBatchLayout::Array, + None, + ) + .await + .unwrap(); + assert_record_batch_eq(&decoded, &batch); +} + +#[tokio::test] +async fn v2_3_output_is_deterministic_within_the_current_revision() { + let batch = compatibility_fixture_batch(); + let mut schema = LanceSchema::try_from(batch.schema().as_ref()).unwrap(); + schema.set_dictionary(&batch).unwrap(); + + let first = write_current_fixture(ConcreteFileVersion::V2_3, &batch, &schema).await; + let second = write_current_fixture(ConcreteFileVersion::V2_3, &batch, &schema).await; + assert_eq!(first, second); + assert_eq!( + footer_version(&first), + ConcreteFileVersion::V2_3.to_standard_footer_numbers() + ); + assert_current_reader_roundtrip(&first, ConcreteFileVersion::V2_3, &batch).await; +} + +#[tokio::test] +async fn v1_writer_and_reader_are_wire_compatible() { + let expected = stable_fixture(ConcreteFileVersion::V1); + let batch = compatibility_fixture_batch(); + let mut schema = LanceSchema::try_from(batch.schema().as_ref()).unwrap(); + schema.set_dictionary(&batch).unwrap(); + let fs = FsFixture::default(); + let mut writer = V1Writer::::try_new( + fs.object_store.as_ref(), + &fs.tmp_path, + schema.clone(), + &V1WriterOptions { + collect_stats_for_fields: Some(Vec::new()), + }, + ) + .await + .unwrap(); + for offset in (0..batch.num_rows()).step_by(1024) { + let slice = batch.slice(offset, (batch.num_rows() - offset).min(1024)); + writer.write(std::slice::from_ref(&slice)).await.unwrap(); + } + let summary = writer.finish().await.unwrap(); + let actual = fs + .object_store + .open(&fs.tmp_path) + .await + .unwrap() + .get_range(0..summary.size_bytes as usize) + .await + .unwrap(); + assert_wire_bytes_equal(actual.as_ref(), expected); + assert_eq!( + footer_version(expected), + ConcreteFileVersion::V1.to_standard_footer_numbers() + ); + + let fixture_fs = FsFixture::default(); + let mut fixture_writer = fixture_fs + .object_store + .create(&fixture_fs.tmp_path) + .await + .unwrap(); + fixture_writer.write_all(expected).await.unwrap(); + Writer::shutdown(fixture_writer.as_mut()).await.unwrap(); + let reader = V1Reader::try_new( + fixture_fs.object_store.as_ref(), + &fixture_fs.tmp_path, + schema.clone(), + ) + .await + .unwrap(); + let actual_batch = reader + .read_range(0..batch.num_rows(), &schema) + .await + .unwrap(); + assert_eq!(reader.num_batches(), 5); + assert_record_batch_eq(&actual_batch, &v1_reader_expected_batch(&batch)); +} diff --git a/rust/lance-file/src/concat.rs b/rust/lance-file/src/concat.rs new file mode 100644 index 00000000000..8b8928186da --- /dev/null +++ b/rust/lance-file/src/concat.rs @@ -0,0 +1,1600 @@ +// SPDX-License-Identifier: Apache-2.0 +// SPDX-FileCopyrightText: Copyright The Lance Authors + +//! Concatenation of complete encoded Lance files. +//! +//! This module owns compatibility checks and metadata relocation for copying +//! already-encoded pages into a new ordinary Lance file. Callers retain +//! responsibility for dataset-level grouping, transactions, and fallbacks. + +use std::{ + collections::{BTreeMap, BTreeSet, VecDeque}, + fmt, + future::Future, + ops::Range, + sync::Arc, +}; + +use arrow_array::{Array, ArrayRef, cast::AsArray, types::UInt8Type}; +use arrow_schema::{DataType as ArrowDataType, Field as ArrowField}; +use futures::TryStreamExt; +use lance_arrow::FieldExt; +use lance_core::{ + Error, Result, + cache::LanceCache, + datatypes::{BLOB_V2_DESC_LANCE_FIELD, BlobHandling, BlobKind, Field, Schema}, +}; +use lance_encoding::decoder::{ColumnInfo, DecoderPlugins, FilterExpression, PageInfo}; +use lance_io::{ReadBatchParams, scheduler::FileScheduler, traits::Writer as ObjectWriter}; +use prost::Message; +use prost_types::Any; + +use crate::{ + reader::{CachedFileMetadata, FileReader, RawFileMetadataOpen}, + version::ConcreteFileVersion, + versions, + writer::{FileWriteSummary, FileWriterOptions}, +}; + +/// Caller-defined runtime identity of the final target for Blob-bearing parts. +/// +/// Lance only compares this value when assembling data-file parts. It does not +/// interpret it as a dataset, base, or object-store identity, persist it, or +/// define a recovery protocol for it. The caller must provide the same identity +/// for every part and target that belong to one assembly operation. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlobTargetId(Arc); + +impl BlobTargetId { + /// Create an opaque Blob target identity. + pub fn new(identity: impl Into>) -> Self { + Self(identity.into()) + } + + /// Return the caller-defined target identity. + pub fn as_str(&self) -> &str { + self.0.as_ref() + } +} + +/// One complete immutable Lance file supplied to [`concat_files`]. +#[derive(Clone)] +pub struct EncodedFileInput { + scheduler: FileScheduler, + expected_num_rows: Option, +} + +impl EncodedFileInput { + /// Create an input from an already-open file scheduler. + pub fn new(scheduler: FileScheduler) -> Self { + Self { + scheduler, + expected_num_rows: None, + } + } + + /// Require the file metadata to report this physical row count. + /// + /// A mismatch is an input error, not a compatibility result. + pub fn with_expected_num_rows(mut self, expected_num_rows: u64) -> Self { + self.expected_num_rows = Some(expected_num_rows); + self + } + + /// The path used to read this input. + pub fn path(&self) -> &object_store::path::Path { + self.scheduler.reader().path() + } + + fn scheduler(&self) -> FileScheduler { + self.scheduler.clone() + } +} + +/// A complete ordinary Lance file validated for data-file concatenation. +/// +/// A part is independently readable and is not an incomplete file-format +/// fragment or a persisted Manifest entity. Opening one reads its real footer, +/// verifies that its physical columns form a complete rectangular file, and +/// checks Blob v2 descriptors against the caller-provided ID lease. Parts with +/// Blob v2 columns are also bound to the caller-provided [`BlobTargetId`]. +/// +/// This runtime value has no fragment, source-row, range, dataset, or storage +/// identity. Lance defines no serialization or recovery contract for it. The +/// caller owns the input storage and must keep every Blob-bearing part associated +/// with the dataset and base where its managed payloads were written. The order +/// passed to [`concat_data_file_parts`] determines final physical row order. +/// +/// # Example +/// +/// ``` +/// use lance_file::concat::{DataFilePart, EncodedFileInput}; +/// use lance_io::scheduler::FileScheduler; +/// +/// # async fn open_part(file: FileScheduler) -> lance_core::Result<()> { +/// let part = DataFilePart::open(EncodedFileInput::new(file), None, None).await?; +/// println!("part rows: {}", part.num_rows()); +/// # Ok(()) +/// # } +/// ``` +#[derive(Clone)] +pub struct DataFilePart { + input: EncodedFileInput, + metadata: Arc, + schema: Arc, + blob_ids: Option>, + blob_target_id: Option, +} + +impl fmt::Debug for DataFilePart { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("DataFilePart") + .field("path", &self.input.path()) + .field("version", &self.metadata.version) + .field("num_rows", &self.metadata.num_rows) + .field("blob_ids", &self.blob_ids) + .field("blob_target_id", &self.blob_target_id) + .finish() + } +} + +impl DataFilePart { + /// Open and validate one complete encoded file. + /// + /// `blob_ids` is the half-open ID range leased to this part. Managed + /// Packed and Dedicated descriptors must fall inside it. `blob_target_id` + /// associates the part with one final target for runtime equality checks and + /// is required whenever the part contains Blob v2 columns. It does not prove + /// that the target belongs to a particular dataset or storage namespace. + /// Non-empty Inline Blob v2 descriptors and legacy Blob v1 columns are + /// rejected because their payload locations cannot be reused in a different + /// data file. + pub async fn open( + input: EncodedFileInput, + blob_ids: Option>, + blob_target_id: Option, + ) -> Result { + validate_blob_id_range(blob_ids.as_ref())?; + let metadata = Arc::new(FileReader::read_all_metadata(&input.scheduler()).await?); + let schema = Arc::new(normalize_blob_footer_schema(metadata.file_schema.as_ref())); + if let Some(expected_num_rows) = input.expected_num_rows + && metadata.num_rows != expected_num_rows + { + return Err(Error::invalid_input(format!( + "part at '{}' has {} physical rows but {} were expected", + input.path(), + metadata.num_rows, + expected_num_rows + ))); + } + let has_blob_v1 = schema + .fields_pre_order() + .any(|field| field.is_blob() && !field.is_blob_v2()); + if has_blob_v1 { + return Err(Error::not_supported(format!( + "part at '{}' contains legacy Blob v1 columns", + input.path() + ))); + } + let validation_schema = descriptor_projection_schema(schema.as_ref()); + let normalized_rows = versions::validate_external_metadata( + metadata.version, + &validation_schema, + metadata.as_ref(), + ) + .map_err(|error| { + Error::corrupt_file( + input.path().clone(), + format!("part has incomplete file metadata: {error}"), + ) + })?; + if normalized_rows != metadata.num_rows { + return Err(Error::corrupt_file( + input.path().clone(), + format!( + "part descriptor reports {} physical rows but its columns normalize to {normalized_rows}", + metadata.num_rows + ), + )); + } + + let has_blob_v2 = schema.fields_pre_order().any(|field| field.is_blob_v2()); + if has_blob_v2 { + validate_blob_descriptors( + &input, + metadata.as_ref(), + schema.as_ref(), + blob_ids.as_ref(), + ) + .await?; + if blob_target_id.is_none() { + return Err(Error::invalid_input(format!( + "part at '{}' contains Blob v2 columns but no Blob target ID was provided", + input.path() + ))); + } + } + + Ok(Self { + input, + metadata, + schema, + blob_ids, + blob_target_id, + }) + } + + /// Number of physical rows described by the part footer. + pub fn num_rows(&self) -> u64 { + self.metadata.num_rows + } +} + +fn descriptor_projection_schema(schema: &Schema) -> Schema { + let mut projected = schema.clone(); + projected.fields = projected + .fields + .into_iter() + .map(|field| BlobHandling::BlobsDescriptions.unload_if_needed(field)) + .collect(); + projected +} + +fn descriptor_child_matches(field: &Field, expected: &Field) -> bool { + field.id == -1 + && field.parent_id == -1 + && field.name == expected.name + && field.logical_type == expected.logical_type + && field.children.is_empty() +} + +fn attach_blob_descriptor_children( + fields: &mut [Field], + descriptor_children: &mut VecDeque>, +) { + for field in fields { + if field.is_blob() && field.children.is_empty() { + if let Some(children) = descriptor_children.pop_front() { + field.children = children; + } + } else { + attach_blob_descriptor_children(&mut field.children, descriptor_children); + } + } +} + +/// Blob descriptor children historically use anonymous field IDs in the file +/// descriptor. Reconstruct their tree shape before applying ordinary schema +/// projection rules. This interprets the existing footer representation and +/// does not add persisted metadata or alter the file grammar. +fn normalize_blob_footer_schema(schema: &Schema) -> Schema { + let expected = &BLOB_V2_DESC_LANCE_FIELD.children; + let missing_descriptor_count = schema + .fields_pre_order() + .filter(|field| field.is_blob() && field.children.is_empty()) + .count(); + if missing_descriptor_count == 0 { + return schema.clone(); + } + let mut normalized = schema.clone(); + let mut descriptor_children = VecDeque::new(); + let mut field_index = 0; + while descriptor_children.len() < missing_descriptor_count + && field_index + expected.len() <= normalized.fields.len() + { + if normalized.fields[field_index..field_index + expected.len()] + .iter() + .zip(expected) + .all(|(field, expected)| descriptor_child_matches(field, expected)) + { + descriptor_children.push_back( + normalized + .fields + .drain(field_index..field_index + expected.len()) + .collect(), + ); + } else { + field_index += 1; + } + } + attach_blob_descriptor_children(&mut normalized.fields, &mut descriptor_children); + normalized +} + +fn validate_blob_id_range(blob_ids: Option<&Range>) -> Result<()> { + if let Some(blob_ids) = blob_ids + && (blob_ids.start == 0 || blob_ids.start >= blob_ids.end) + { + return Err(Error::invalid_input(format!( + "part Blob ID range must be non-empty and start at 1 or greater, got {}..{}", + blob_ids.start, blob_ids.end + ))); + } + Ok(()) +} + +async fn validate_blob_descriptors( + input: &EncodedFileInput, + metadata: &CachedFileMetadata, + schema: &Schema, + blob_ids: Option<&Range>, +) -> Result<()> { + let projected_schema = descriptor_projection_schema(schema); + let blob_field_ids = projected_schema + .fields_pre_order() + .filter(|field| field.is_blob_v2()) + .map(|field| field.id) + .collect::>(); + let unique_blob_field_ids = blob_field_ids.iter().copied().collect::>(); + if unique_blob_field_ids.len() != blob_field_ids.len() + || unique_blob_field_ids + .first() + .is_some_and(|field_id| *field_id < 0) + { + return Err(Error::corrupt_file( + input.path().clone(), + "Blob v2 fields in a data-file part must have unique non-negative field IDs", + )); + } + let blob_schema = projected_schema.project_by_ids(&blob_field_ids, true); + let (field_ids, column_indices) = + versions::data_file_columns(metadata.version, &projected_schema); + let field_id_to_column_index = field_ids + .into_iter() + .zip(column_indices) + .filter_map(|(field_id, column_index)| { + (field_id >= 0 && column_index >= 0).then_some((field_id as u32, column_index as u32)) + }) + .collect::>(); + let projection = versions::reader_projection_from_field_ids( + metadata.version, + &blob_schema, + &field_id_to_column_index, + )?; + let reader = FileReader::try_open( + input.scheduler(), + Some(projection), + Arc::::default(), + &LanceCache::no_cache(), + Default::default(), + ) + .await?; + let mut batches = reader + .read_stream( + ReadBatchParams::RangeFull, + 8192, + 4, + FilterExpression::no_filter(), + ) + .await?; + while let Some(batch) = batches.try_next().await? { + let selected = vec![true; batch.num_rows()]; + for (field, array) in batch.schema().fields().iter().zip(batch.columns()) { + validate_blob_field(field.as_ref(), array, &selected, blob_ids, input.path())?; + } + } + Ok(()) +} + +fn validate_blob_field( + field: &ArrowField, + array: &ArrayRef, + selected: &[bool], + blob_ids: Option<&Range>, + path: &object_store::path::Path, +) -> Result<()> { + if field.is_blob() { + let descriptors = array.as_struct(); + let kinds = descriptors + .column_by_name("kind") + .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no kind"))? + .as_primitive::(); + let positions = descriptors + .column_by_name("position") + .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no position"))? + .as_primitive::(); + let sizes = descriptors + .column_by_name("size") + .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no size"))? + .as_primitive::(); + let ids = descriptors + .column_by_name("blob_id") + .ok_or_else(|| Error::corrupt_file(path.clone(), "Blob v2 descriptor has no blob_id"))? + .as_primitive::(); + for (row, is_selected) in selected.iter().copied().enumerate() { + if !is_selected || descriptors.is_null(row) { + continue; + } + let kind = BlobKind::try_from(kinds.value(row))?; + match kind { + BlobKind::Inline if sizes.value(row) > 0 => { + return Err(Error::invalid_input(format!( + "part at '{}' contains a non-empty Inline Blob v2 descriptor at row {row}; data-file part concatenation requires Packed or Dedicated storage", + path + ))); + } + BlobKind::Packed | BlobKind::Dedicated => { + let blob_id = ids.value(row); + let Some(blob_ids) = blob_ids else { + return Err(Error::invalid_input(format!( + "part at '{}' contains managed Blob ID {blob_id} at row {row} but no Blob ID range was provided", + path + ))); + }; + if !blob_ids.contains(&blob_id) { + return Err(Error::invalid_input(format!( + "part at '{}' contains managed Blob ID {blob_id} at row {row}, outside declared range {}..{}", + path, blob_ids.start, blob_ids.end + ))); + } + if kind == BlobKind::Dedicated && positions.value(row) != 0 { + return Err(Error::corrupt_file( + path.clone(), + format!( + "Dedicated Blob descriptor at row {row} has non-zero position {}", + positions.value(row) + ), + )); + } + } + BlobKind::Managed | BlobKind::ManagedWithBase => { + let uris = descriptors + .column_by_name("blob_uri") + .ok_or_else(|| { + Error::corrupt_file(path.clone(), "Managed descriptor has no blob_uri") + })? + .as_string::(); + lance_core::utils::blob::validate_managed_reference( + uris.value(row), + positions.value(row), + sizes.value(row), + )?; + } + BlobKind::Inline | BlobKind::External => {} + } + } + return Ok(()); + } + + match field.data_type() { + ArrowDataType::Struct(children) => { + let struct_array = array.as_struct(); + let child_selected = selected + .iter() + .copied() + .enumerate() + .map(|(row, is_selected)| is_selected && struct_array.is_valid(row)) + .collect::>(); + for (child, child_array) in children.iter().zip(struct_array.columns()) { + validate_blob_field(child.as_ref(), child_array, &child_selected, blob_ids, path)?; + } + } + ArrowDataType::List(child) => { + let list = array.as_list::(); + let mut child_selected = vec![false; list.values().len()]; + for (row, is_selected) in selected.iter().copied().enumerate() { + if is_selected && list.is_valid(row) { + let start = list.value_offsets()[row] as usize; + let end = list.value_offsets()[row + 1] as usize; + child_selected[start..end].fill(true); + } + } + validate_blob_field( + child.as_ref(), + list.values(), + &child_selected, + blob_ids, + path, + )?; + } + ArrowDataType::LargeList(child) => { + let list = array.as_list::(); + let mut child_selected = vec![false; list.values().len()]; + for (row, is_selected) in selected.iter().copied().enumerate() { + if is_selected && list.is_valid(row) { + let start = list.value_offsets()[row] as usize; + let end = list.value_offsets()[row + 1] as usize; + child_selected[start..end].fill(true); + } + } + validate_blob_field( + child.as_ref(), + list.values(), + &child_selected, + blob_ids, + path, + )?; + } + _ => {} + } + Ok(()) +} + +/// The exact file grammar and schema required for concatenated output. +#[derive(Debug, Clone)] +pub struct FileConcatTarget { + /// Exact output grammar. Release aliases are resolved before this boundary. + pub version: ConcreteFileVersion, + /// Complete schema stored in every input and regenerated in the output. + pub schema: Arc, + blob_target_id: Option, +} + +impl FileConcatTarget { + /// Create a concatenation target. + pub fn new(version: ConcreteFileVersion, schema: Arc) -> Self { + Self { + version, + schema, + blob_target_id: None, + } + } + + /// Bind Blob-bearing parts to one caller-defined final target. + pub fn with_blob_target_id(mut self, blob_target_id: BlobTargetId) -> Self { + self.blob_target_id = Some(blob_target_id); + self + } +} + +/// Runtime controls for encoded-file concatenation. +#[derive(Debug, Clone)] +pub struct FileConcatOptions { + /// Maximum page-buffer bytes requested in one read batch. + pub read_batch_bytes: usize, + /// Options passed to the exact-version footer writer. + pub writer_options: FileWriterOptions, +} + +impl Default for FileConcatOptions { + fn default() -> Self { + Self { + read_batch_bytes: 16 * 1024 * 1024, + writer_options: FileWriterOptions::default(), + } + } +} + +/// Metadata describing the complete file represented by a concat result. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FileConcatOutput { + /// Exact grammar of the completed or reused file. + pub version: ConcreteFileVersion, + /// Total physical rows in input order. + pub num_rows: u64, + /// Size of the completed or reused object. + pub size_bytes: u64, +} + +/// A compatibility reason that requires a caller-controlled decode/re-encode fallback. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileConcatReason { + /// Lance v1 does not support encoded-file concatenation. + LegacyVersion, + /// An input uses a different exact grammar than the target. + VersionMismatch { + /// Zero-based input position. + input_index: usize, + /// Version found in the file footer. + actual: ConcreteFileVersion, + /// Version requested by the target. + expected: ConcreteFileVersion, + }, + /// An input's persisted schema differs from the target schema. + SchemaMismatch { + /// Zero-based input position. + input_index: usize, + }, + /// Inputs do not describe the same physical columns. + ColumnLayoutMismatch { + /// Zero-based input position. + input_index: usize, + /// Zero-based physical column when one could be identified. + column_index: Option, + }, + /// A column-level encoding cannot safely combine its buffers. + ColumnEncodingMismatch { + /// Zero-based input position. + input_index: usize, + /// Zero-based physical column. + column_index: usize, + }, + /// A column uses file-level buffers whose page references cannot be relocated. + ColumnBuffers { + /// Zero-based input position. + input_index: usize, + /// Zero-based physical column. + column_index: usize, + /// Number of column buffers referenced by the column metadata. + count: usize, + }, + /// A file contains global buffers whose relocation semantics are not defined. + ExtraGlobalBuffers { + /// Zero-based input position. + input_index: usize, + /// Number of global buffers, including the schema descriptor. + count: usize, + }, + /// The schema contains offsets into external blob storage. + BlobColumns, +} + +impl fmt::Display for FileConcatReason { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::LegacyVersion => f.write_str("Lance v1 files cannot be concatenated"), + Self::VersionMismatch { + input_index, + actual, + expected, + } => write!( + f, + "input {input_index} has file version {actual}, expected {expected}" + ), + Self::SchemaMismatch { input_index } => { + write!(f, "input {input_index} has a different file schema") + } + Self::ColumnLayoutMismatch { + input_index, + column_index, + } => match column_index { + Some(column_index) => write!( + f, + "input {input_index} has a different layout for physical column {column_index}" + ), + None => write!( + f, + "input {input_index} has a different physical column count" + ), + }, + Self::ColumnEncodingMismatch { + input_index, + column_index, + } => write!( + f, + "input {input_index} has an incompatible encoding for physical column {column_index}" + ), + Self::ColumnBuffers { + input_index, + column_index, + count, + } => write!( + f, + "input {input_index} physical column {column_index} has {count} column buffers whose references cannot be relocated" + ), + Self::ExtraGlobalBuffers { input_index, count } => write!( + f, + "input {input_index} has {count} global buffers; only the schema descriptor is supported" + ), + Self::BlobColumns => { + f.write_str("schemas containing blob columns cannot be concatenated") + } + } + } +} + +/// Result of one encoded-file concatenation attempt. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FileConcatResult { + /// A new ordinary Lance file was written. + Written(FileConcatOutput), + /// One compatible complete input already is the requested output. + Reused(usize, FileConcatOutput), + /// Compatibility was rejected before the output factory was called. + Unsupported(FileConcatReason), +} + +struct PreparedInput<'a> { + input: &'a EncodedFileInput, + metadata: &'a CachedFileMetadata, + schema: &'a Schema, +} + +fn encoded_column_encoding(column: &ColumnInfo) -> Result> { + Ok(Any::from_msg(&column.encoding)?.encode_to_vec()) +} + +fn check_compatibility( + target: &FileConcatTarget, + inputs: &[PreparedInput<'_>], + allow_blob_columns: bool, +) -> Result> { + if !allow_blob_columns + && target + .schema + .fields_pre_order() + .any(|field| field.is_blob()) + { + return Ok(Some(FileConcatReason::BlobColumns)); + } + + let Some(first) = inputs.first() else { + return Err(Error::invalid_input( + "concat_files requires at least one complete input file", + )); + }; + let baseline_columns = &first.metadata.column_infos; + let expected_schema = if allow_blob_columns { + descriptor_projection_schema(target.schema.as_ref()) + } else { + target.schema.as_ref().clone() + }; + let baseline_encodings = baseline_columns + .iter() + .map(|column| encoded_column_encoding(column)) + .collect::>>()?; + + for (input_index, prepared) in inputs.iter().enumerate() { + let metadata = &prepared.metadata; + if let Some(expected_num_rows) = prepared.input.expected_num_rows + && metadata.num_rows != expected_num_rows + { + return Err(Error::invalid_input(format!( + "input {input_index} at '{}' has {} physical rows but {} were expected", + prepared.input.path(), + metadata.num_rows, + expected_num_rows + ))); + } + if metadata.version != target.version { + return Ok(Some(FileConcatReason::VersionMismatch { + input_index, + actual: metadata.version, + expected: target.version, + })); + } + if prepared.schema != &expected_schema { + return Ok(Some(FileConcatReason::SchemaMismatch { input_index })); + } + let normalized_rows = + versions::validate_external_metadata(metadata.version, prepared.schema, metadata) + .map_err(|error| { + Error::corrupt_file( + prepared.input.path().clone(), + format!("input {input_index} has incomplete file metadata: {error}"), + ) + })?; + if normalized_rows != metadata.num_rows { + return Err(Error::corrupt_file( + prepared.input.path().clone(), + format!( + "input {input_index} descriptor reports {} physical rows but its columns normalize to {normalized_rows}", + metadata.num_rows + ), + )); + } + if metadata.file_buffers.len() > 1 { + return Ok(Some(FileConcatReason::ExtraGlobalBuffers { + input_index, + count: metadata.file_buffers.len(), + })); + } + if metadata.column_infos.len() != baseline_columns.len() { + return Ok(Some(FileConcatReason::ColumnLayoutMismatch { + input_index, + column_index: None, + })); + } + for (column_index, (column, baseline)) in metadata + .column_infos + .iter() + .zip(baseline_columns) + .enumerate() + { + if !column.buffer_offsets_and_sizes.is_empty() { + return Ok(Some(FileConcatReason::ColumnBuffers { + input_index, + column_index, + count: column.buffer_offsets_and_sizes.len(), + })); + } + if column.index != baseline.index { + return Ok(Some(FileConcatReason::ColumnLayoutMismatch { + input_index, + column_index: Some(column_index), + })); + } + if encoded_column_encoding(column)? != baseline_encodings[column_index] { + return Ok(Some(FileConcatReason::ColumnEncodingMismatch { + input_index, + column_index, + })); + } + } + } + Ok(None) +} + +async fn copy_page_buffers( + writer: &mut crate::writer::FileWriter, + scheduler: &FileScheduler, + pages: &[PageInfo], + read_batch_bytes: u64, + input_index: usize, + column_index: usize, + row_offset: u64, +) -> Result> { + let mut copied = Vec::with_capacity(pages.len()); + let mut page_index = 0; + while page_index < pages.len() { + let batch_start = page_index; + let mut batch_bytes = 0u64; + let mut batch_ranges = Vec::new(); + let mut batch_buffer_counts = Vec::new(); + while page_index < pages.len() { + let page = &pages[page_index]; + let page_bytes = page.buffer_offsets_and_sizes.iter().try_fold( + 0u64, + |total, (offset, size)| { + offset.checked_add(*size).ok_or_else(|| { + Error::corrupt_file( + scheduler.reader().path().clone(), + format!( + "input {input_index} column {column_index} page {page_index} buffer range overflows" + ), + ) + })?; + total.checked_add(*size).ok_or_else(|| { + Error::corrupt_file( + scheduler.reader().path().clone(), + format!( + "input {input_index} column {column_index} page {page_index} buffer sizes overflow" + ), + ) + }) + }, + )?; + if page_index > batch_start + && batch_bytes + .checked_add(page_bytes) + .is_none_or(|total| total > read_batch_bytes) + { + break; + } + batch_bytes = batch_bytes.checked_add(page_bytes).ok_or_else(|| { + Error::corrupt_file( + scheduler.reader().path().clone(), + format!("input {input_index} column {column_index} read batch size overflows"), + ) + })?; + batch_buffer_counts.push(page.buffer_offsets_and_sizes.len()); + batch_ranges.extend( + page.buffer_offsets_and_sizes + .iter() + .filter(|(_, size)| *size > 0) + .map(|(offset, size)| *offset..(*offset + *size)), + ); + page_index += 1; + } + + let batch_data = if batch_ranges.is_empty() { + Vec::new() + } else { + scheduler.submit_request(batch_ranges, 0).await? + }; + let mut batch_data = batch_data.into_iter(); + for (relative_page_index, (page, buffer_count)) in pages[batch_start..page_index] + .iter() + .zip(batch_buffer_counts) + .enumerate() + { + let source_page_index = batch_start + relative_page_index; + let mut relocated_buffers = Vec::with_capacity(buffer_count); + for (buffer_index, (_, size)) in page.buffer_offsets_and_sizes.iter().enumerate() { + let data = if *size == 0 { + None + } else { + let data = batch_data.next().ok_or_else(|| { + Error::io(format!( + "short read for input {input_index} column {column_index} page {source_page_index} buffer {buffer_index}: expected {size} bytes" + )) + })?; + if data.len() as u64 != *size { + return Err(Error::io(format!( + "short read for input {input_index} column {column_index} page {source_page_index} buffer {buffer_index}: expected {size} bytes, got {}", + data.len() + ))); + } + Some(data) + }; + relocated_buffers.push( + writer + .write_external_buffer(data.as_deref().unwrap_or_default()) + .await?, + ); + } + copied.push(PageInfo { + num_rows: page.num_rows, + priority: page.priority.checked_add(row_offset).ok_or_else(|| { + Error::invalid_input_source( + format!( + "input {input_index} column {column_index} page {source_page_index} priority overflows after row relocation" + ) + .into(), + ) + })?, + encoding: page.encoding.clone(), + buffer_offsets_and_sizes: Arc::from(relocated_buffers), + }); + } + if batch_data.next().is_some() { + return Err(Error::io(format!( + "read for input {input_index} column {column_index} returned more buffers than requested" + ))); + } + } + Ok(copied) +} + +async fn concat_prepared( + target: &FileConcatTarget, + prepared: &[PreparedInput<'_>], + allow_blob_columns: bool, + reuse_single_input: bool, + output_factory: Factory, + options: FileConcatOptions, +) -> Result +where + Factory: FnOnce() -> FactoryFuture, + FactoryFuture: Future>>, +{ + if options.read_batch_bytes == 0 { + return Err(Error::invalid_input( + "FileConcatOptions.read_batch_bytes must be greater than zero", + )); + } + if let Some(reason) = check_compatibility(target, prepared, allow_blob_columns)? { + return Ok(FileConcatResult::Unsupported(reason)); + } + + let total_rows = prepared.iter().try_fold(0u64, |total, input| { + total.checked_add(input.metadata.num_rows).ok_or_else(|| { + Error::invalid_input_source("concat_files total physical row count overflows".into()) + }) + })?; + if prepared.len() == 1 && reuse_single_input { + return Ok(FileConcatResult::Reused( + 0, + FileConcatOutput { + version: target.version, + num_rows: total_rows, + size_bytes: prepared[0].metadata.file_size_bytes, + }, + )); + } + + let object_writer = output_factory().await?; + let mut writer = + versions::create_lazy_writer(target.version, object_writer, options.writer_options)?; + let write_result: Result = async { + let column_count = prepared[0].metadata.column_infos.len(); + let mut output_pages = std::iter::repeat_with(Vec::new) + .take(column_count) + .collect::>>(); + let mut row_offset = 0u64; + + for (input_index, prepared_input) in prepared.iter().enumerate() { + for (column_index, column) in prepared_input.metadata.column_infos.iter().enumerate() { + let has_existing_pages = !output_pages[column_index].is_empty(); + versions::copy_external_metadata_column( + target.version, + target.schema.as_ref(), + column_index, + has_existing_pages, + || async { + let pages = copy_page_buffers( + &mut writer, + &prepared_input.input.scheduler, + &column.page_infos, + options.read_batch_bytes as u64, + input_index, + column_index, + row_offset, + ) + .await?; + output_pages[column_index].extend(pages); + + Ok(()) + }, + ) + .await?; + } + row_offset = row_offset + .checked_add(prepared_input.metadata.num_rows) + .ok_or_else(|| { + Error::invalid_input_source("concat_files physical row offset overflows".into()) + })?; + } + + let mut columns = Vec::with_capacity(column_count); + for (column_index, pages) in output_pages.iter_mut().enumerate() { + versions::finalize_external_metadata_column( + target.version, + target.schema.as_ref(), + column_index, + pages, + total_rows, + )?; + let baseline = &prepared[0].metadata.column_infos[column_index]; + columns.push(Arc::new(ColumnInfo::new( + baseline.index, + Arc::from(std::mem::take(pages)), + Vec::new(), + baseline.encoding.clone(), + ))); + } + // The schema descriptor is the first global buffer and must start at + // the page-buffer alignment required by the reader. + writer.write_external_buffer(&[]).await?; + writer.initialize_with_external_columns( + target.schema.as_ref().clone(), + &columns, + total_rows, + )?; + writer.finish().await + } + .await; + + match write_result { + Ok(summary) => Ok(FileConcatResult::Written(FileConcatOutput { + version: target.version, + num_rows: summary.num_rows, + size_bytes: summary.size_bytes, + })), + Err(error) => { + writer.abort().await; + Err(error) + } + } +} + +/// Concatenate complete compatible encoded files in the supplied order. +/// +/// Metadata is read exactly once per input. The factory is invoked only after +/// all compatibility checks succeed and is never invoked for [`FileConcatResult::Reused`] +/// or [`FileConcatResult::Unsupported`]. Page payloads are copied without Arrow +/// decoding; offsets, priorities, exact-version structural metadata, and the +/// footer are regenerated. +/// +/// ``` +/// # use std::sync::Arc; +/// # use lance_core::Result; +/// # use lance_file::concat::{concat_files, EncodedFileInput, FileConcatOptions, FileConcatResult, FileConcatTarget}; +/// # use lance_io::object_store::ObjectStore; +/// # use object_store::path::Path; +/// # async fn stitch( +/// # target: &FileConcatTarget, +/// # inputs: &[EncodedFileInput], +/// # output_store: Arc, +/// # output_path: Path, +/// # ) -> Result { +/// let store = output_store.clone(); +/// concat_files( +/// target, +/// inputs, +/// move || async move { store.create(&output_path).await }, +/// FileConcatOptions::default(), +/// ) +/// .await +/// # } +/// ``` +pub async fn concat_files( + target: &FileConcatTarget, + ordered_inputs: &[EncodedFileInput], + output_factory: Factory, + options: FileConcatOptions, +) -> Result +where + Factory: FnOnce() -> FactoryFuture, + FactoryFuture: Future>>, +{ + if ordered_inputs.is_empty() { + return Err(Error::invalid_input( + "concat_files requires at least one complete input file", + )); + } + let raw_metadata = futures::future::try_join_all( + ordered_inputs + .iter() + .map(|input| FileReader::read_raw_metadata_for_dispatch(&input.scheduler)), + ) + .await?; + if target.version == ConcreteFileVersion::V1 + || raw_metadata + .iter() + .any(|metadata| matches!(metadata, RawFileMetadataOpen::Legacy { .. })) + { + return Ok(FileConcatResult::Unsupported( + FileConcatReason::LegacyVersion, + )); + } + let metadata = raw_metadata + .into_iter() + .map(|metadata| match metadata { + RawFileMetadataOpen::Current { version, metadata } => { + versions::finish_metadata(version, metadata) + } + RawFileMetadataOpen::Legacy { .. } => Err(Error::internal( + "legacy concat input reached current metadata finalization".to_string(), + )), + }) + .collect::>>()?; + let prepared = ordered_inputs + .iter() + .zip(metadata.iter()) + .map(|(input, metadata)| PreparedInput { + input, + metadata, + schema: metadata.file_schema.as_ref(), + }) + .collect::>(); + concat_prepared(target, &prepared, false, true, output_factory, options).await +} + +/// Concatenate validated data-file parts in caller-supplied row order. +/// +/// Unlike [`concat_files`], this entry point accepts Blob v2 columns because +/// every [`DataFilePart`] has already rejected file-relative Inline payloads +/// and validated managed descriptors against an explicit ID lease. Leases from +/// different parts must not overlap. No logical values or Blob payloads are +/// decoded and re-encoded during concatenation. +pub async fn concat_data_file_parts( + target: &FileConcatTarget, + ordered_parts: &[DataFilePart], + output_factory: Factory, + options: FileConcatOptions, +) -> Result +where + Factory: FnOnce() -> FactoryFuture, + FactoryFuture: Future>>, +{ + if ordered_parts.is_empty() { + return Err(Error::invalid_input( + "concat_data_file_parts requires at least one data-file part", + )); + } + for (part_index, part) in ordered_parts.iter().enumerate() { + if part.blob_target_id != target.blob_target_id { + return Err(Error::invalid_input(format!( + "part {part_index} Blob target ID {:?} does not match target ID {:?}", + part.blob_target_id.as_ref().map(BlobTargetId::as_str), + target.blob_target_id.as_ref().map(BlobTargetId::as_str) + ))); + } + } + let mut ranges = ordered_parts + .iter() + .enumerate() + .filter_map(|(part_index, part)| { + part.blob_ids + .clone() + .map(|range| (range.start, range.end, part_index)) + }) + .collect::>(); + ranges.sort_unstable_by_key(|(start, _, _)| *start); + for pair in ranges.windows(2) { + let (left_start, left_end, left_index) = pair[0]; + let (right_start, right_end, right_index) = pair[1]; + if right_start < left_end { + return Err(Error::invalid_input(format!( + "part Blob ID ranges overlap: part {left_index} uses {left_start}..{left_end}, part {right_index} uses {right_start}..{right_end}" + ))); + } + } + + let prepared = ordered_parts + .iter() + .map(|part| PreparedInput { + input: &part.input, + metadata: part.metadata.as_ref(), + schema: part.schema.as_ref(), + }) + .collect::>(); + concat_prepared(target, &prepared, true, false, output_factory, options).await +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use lance_core::utils::tempfile::TempObjFile; + use lance_io::{ + object_store::ObjectStore, + scheduler::{ScanScheduler, SchedulerConfig}, + traits::Writer, + utils::CachedFileSize, + }; + use tokio::io::AsyncWriteExt; + + use super::*; + + async fn write_file( + store: &Arc, + path: &object_store::path::Path, + version: ConcreteFileVersion, + values: &[i32], + ) -> Arc { + let batch = arrow_array::record_batch!(("value", Int32, values.to_vec())).unwrap(); + let schema = Arc::new(Schema::try_from(batch.schema_ref().as_ref()).unwrap()); + let mut writer = versions::create_writer( + version, + store.create(path).await.unwrap(), + schema.as_ref().clone(), + FileWriterOptions::default(), + ) + .unwrap(); + writer.write_batch(&batch).await.unwrap(); + writer.finish().await.unwrap(); + schema + } + + async fn input( + store: Arc, + path: &object_store::path::Path, + expected_num_rows: u64, + ) -> EncodedFileInput { + let scheduler = ScanScheduler::new(store, SchedulerConfig::default_for_testing()); + let file = scheduler + .open_file(path, &CachedFileSize::unknown()) + .await + .unwrap(); + EncodedFileInput::new(file).with_expected_num_rows(expected_num_rows) + } + + #[tokio::test] + async fn concat_writes_relocated_metadata_and_reuses_single_input() { + let store = Arc::new(ObjectStore::local()); + let first_path = TempObjFile::default(); + let second_path = TempObjFile::default(); + let output_path = TempObjFile::default(); + let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1, 2, 3]).await; + write_file(&store, &second_path, ConcreteFileVersion::V2_1, &[4, 5]).await; + let inputs = vec![ + input(store.clone(), &first_path, 3).await, + input(store.clone(), &second_path, 2).await, + ]; + let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let result = concat_files( + &target, + &inputs, + { + let store = store.clone(); + let output_path = output_path.clone(); + let factory_calls = factory_calls.clone(); + move || async move { + factory_calls.fetch_add(1, Ordering::SeqCst); + store.create(&output_path).await + } + }, + FileConcatOptions::default(), + ) + .await + .unwrap(); + assert!(matches!( + result, + FileConcatResult::Written(FileConcatOutput { num_rows: 5, .. }) + )); + assert_eq!(factory_calls.load(Ordering::SeqCst), 1); + let output = input(store.clone(), &output_path, 5).await; + let metadata = FileReader::read_all_metadata(&output.scheduler) + .await + .unwrap(); + assert_eq!(metadata.num_rows, 5); + assert_eq!(metadata.column_infos[0].page_infos.len(), 2); + assert!( + metadata.column_infos[0].page_infos[0].priority + < metadata.column_infos[0].page_infos[1].priority + ); + + let reuse_calls = Arc::new(AtomicUsize::new(0)); + let result = concat_files( + &target, + &inputs[..1], + { + let reuse_calls = reuse_calls.clone(); + move || async move { + reuse_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::internal("reuse factory must not be called")) + } + }, + FileConcatOptions::default(), + ) + .await + .unwrap(); + assert!(matches!(result, FileConcatResult::Reused(0, _))); + assert_eq!(reuse_calls.load(Ordering::SeqCst), 0); + } + + #[rstest::rstest] + #[case(ConcreteFileVersion::V2_0)] + #[case(ConcreteFileVersion::V2_1)] + #[case(ConcreteFileVersion::V2_2)] + #[case(ConcreteFileVersion::V2_3)] + #[tokio::test] + async fn concat_preserves_schema_metadata(#[case] version: ConcreteFileVersion) { + let store = Arc::new(ObjectStore::local()); + let first_path = TempObjFile::default(); + let second_path = TempObjFile::default(); + let output_path = TempObjFile::default(); + let batch = arrow_array::record_batch!(("value", Int32, [1, 2])).unwrap(); + let mut schema = Schema::try_from(batch.schema_ref().as_ref()).unwrap(); + schema + .metadata + .insert("review-key".into(), "review-value".into()); + let schema = Arc::new(schema); + + for path in [&first_path, &second_path] { + let mut writer = versions::create_writer( + version, + store.create(path).await.unwrap(), + schema.as_ref().clone(), + FileWriterOptions::default(), + ) + .unwrap(); + writer.write_batch(&batch).await.unwrap(); + writer.finish().await.unwrap(); + } + let inputs = vec![ + input(store.clone(), &first_path, 2).await, + input(store.clone(), &second_path, 2).await, + ]; + let result = concat_files( + &FileConcatTarget::new(version, schema), + &inputs, + { + let store = store.clone(); + let output_path = output_path.clone(); + move || async move { store.create(&output_path).await } + }, + FileConcatOptions::default(), + ) + .await + .unwrap(); + assert!(matches!(result, FileConcatResult::Written(_))); + + let output = input(store, &output_path, 4).await; + let metadata = FileReader::read_all_metadata(&output.scheduler) + .await + .unwrap(); + assert_eq!( + metadata.file_schema.metadata.get("review-key"), + Some(&"review-value".to_string()) + ); + } + + #[tokio::test] + async fn unsupported_does_not_create_output() { + let store = Arc::new(ObjectStore::local()); + let first_path = TempObjFile::default(); + let second_path = TempObjFile::default(); + let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1]).await; + write_file(&store, &second_path, ConcreteFileVersion::V2_2, &[2]).await; + let inputs = vec![ + input(store.clone(), &first_path, 1).await, + input(store, &second_path, 1).await, + ]; + let factory_calls = Arc::new(AtomicUsize::new(0)); + let result = concat_files( + &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema), + &inputs, + { + let factory_calls = factory_calls.clone(); + move || async move { + factory_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::internal("unsupported factory must not be called")) + } + }, + FileConcatOptions::default(), + ) + .await + .unwrap(); + assert!(matches!( + result, + FileConcatResult::Unsupported(FileConcatReason::VersionMismatch { input_index: 1, .. }) + )); + assert_eq!(factory_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn legacy_input_is_unsupported_without_creating_output() { + let store = Arc::new(ObjectStore::local()); + let current_path = TempObjFile::default(); + let legacy_path = TempObjFile::default(); + let schema = write_file(&store, ¤t_path, ConcreteFileVersion::V2_1, &[1]).await; + let mut legacy_writer = store.create(&legacy_path).await.unwrap(); + legacy_writer + .write_all(include_bytes!("../test_data/exact_versions/v1.lance")) + .await + .unwrap(); + Writer::shutdown(&mut legacy_writer).await.unwrap(); + let factory_calls = Arc::new(AtomicUsize::new(0)); + + let result = concat_files( + &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema), + &[input(store, &legacy_path, 0).await], + { + let factory_calls = factory_calls.clone(); + move || async move { + factory_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::internal("legacy factory must not be called")) + } + }, + FileConcatOptions::default(), + ) + .await + .unwrap(); + + assert!(matches!( + result, + FileConcatResult::Unsupported(FileConcatReason::LegacyVersion) + )); + assert_eq!(factory_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn incompatible_column_buffers_and_incomplete_metadata_are_rejected() { + let store = Arc::new(ObjectStore::local()); + let path = TempObjFile::default(); + let schema = write_file(&store, &path, ConcreteFileVersion::V2_1, &[1, 2]).await; + let encoded_input = input(store, &path, 2).await; + let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema); + + let mut with_column_buffer = FileReader::read_all_metadata(&encoded_input.scheduler) + .await + .unwrap(); + let column = with_column_buffer.column_infos[0].as_ref(); + with_column_buffer.column_infos[0] = Arc::new(ColumnInfo::new( + column.index, + column.page_infos.clone(), + vec![(0, 1)], + column.encoding.clone(), + )); + let prepared = [PreparedInput { + input: &encoded_input, + metadata: &with_column_buffer, + schema: with_column_buffer.file_schema.as_ref(), + }]; + assert!(matches!( + check_compatibility(&target, &prepared, false).unwrap(), + Some(FileConcatReason::ColumnBuffers { + input_index: 0, + column_index: 0, + count: 1 + }) + )); + + let mut missing_column = FileReader::read_all_metadata(&encoded_input.scheduler) + .await + .unwrap(); + missing_column.column_infos.clear(); + let prepared = [PreparedInput { + input: &encoded_input, + metadata: &missing_column, + schema: missing_column.file_schema.as_ref(), + }]; + let error = check_compatibility(&target, &prepared, false).unwrap_err(); + assert!(matches!(error, Error::CorruptFile { .. })); + assert!( + error + .to_string() + .contains("schema requires 1 physical columns") + ); + + let mut wrong_rows = FileReader::read_all_metadata(&encoded_input.scheduler) + .await + .unwrap(); + let column = wrong_rows.column_infos[0].as_ref(); + let mut pages = column + .page_infos + .iter() + .map(|page| PageInfo { + num_rows: page.num_rows, + priority: page.priority, + encoding: page.encoding.clone(), + buffer_offsets_and_sizes: page.buffer_offsets_and_sizes.clone(), + }) + .collect::>(); + pages[0].num_rows -= 1; + wrong_rows.column_infos[0] = Arc::new(ColumnInfo::new( + column.index, + Arc::from(pages), + Vec::new(), + column.encoding.clone(), + )); + let prepared = [PreparedInput { + input: &encoded_input, + metadata: &wrong_rows, + schema: wrong_rows.file_schema.as_ref(), + }]; + let error = check_compatibility(&target, &prepared, false).unwrap_err(); + assert!(matches!(error, Error::CorruptFile { .. })); + assert!( + error + .to_string() + .contains("descriptor reports 2 physical rows") + ); + } + + #[tokio::test] + async fn data_file_parts_reject_overlapping_blob_leases_before_output() { + let store = Arc::new(ObjectStore::local()); + let first_path = TempObjFile::default(); + let second_path = TempObjFile::default(); + let schema = write_file(&store, &first_path, ConcreteFileVersion::V2_1, &[1]).await; + write_file(&store, &second_path, ConcreteFileVersion::V2_1, &[2]).await; + let first = DataFilePart::open( + input(store.clone(), &first_path, 1).await, + Some(1..10), + None, + ) + .await + .unwrap(); + let second = DataFilePart::open(input(store, &second_path, 1).await, Some(5..20), None) + .await + .unwrap(); + let factory_calls = Arc::new(AtomicUsize::new(0)); + + let error = concat_data_file_parts( + &FileConcatTarget::new(ConcreteFileVersion::V2_1, schema), + &[first, second], + { + let factory_calls = factory_calls.clone(); + move || async move { + factory_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::internal("overlap factory must not be called")) + } + }, + FileConcatOptions::default(), + ) + .await + .unwrap_err(); + + assert!(error.to_string().contains("part 0 uses 1..10"), "{error}"); + assert!(error.to_string().contains("part 1 uses 5..20"), "{error}"); + assert_eq!(factory_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn missing_and_corrupt_inputs_are_errors_without_output() { + let store = Arc::new(ObjectStore::local()); + let valid_path = TempObjFile::default(); + let missing_path = TempObjFile::default(); + let corrupt_path = TempObjFile::default(); + let schema = write_file(&store, &valid_path, ConcreteFileVersion::V2_1, &[1, 2]).await; + write_file(&store, &missing_path, ConcreteFileVersion::V2_1, &[3, 4]).await; + let missing_input = input(store.clone(), &missing_path, 2).await; + store.delete(&missing_path).await.unwrap(); + + let target = FileConcatTarget::new(ConcreteFileVersion::V2_1, schema.clone()); + let factory_calls = Arc::new(AtomicUsize::new(0)); + let result = concat_files( + &target, + &[input(store.clone(), &valid_path, 2).await, missing_input], + { + let factory_calls = factory_calls.clone(); + move || async move { + factory_calls.fetch_add(1, Ordering::SeqCst); + Err(Error::internal("error factory must not be called")) + } + }, + FileConcatOptions::default(), + ) + .await; + assert!(result.is_err()); + assert_eq!(factory_calls.load(Ordering::SeqCst), 0); + + let mut corrupt_writer = store.create(&corrupt_path).await.unwrap(); + corrupt_writer.write_all(b"not a Lance file").await.unwrap(); + Writer::shutdown(&mut corrupt_writer).await.unwrap(); + let corrupt_input = input(store.clone(), &corrupt_path, 2).await; + let result = concat_files( + &target, + &[input(store, &valid_path, 2).await, corrupt_input], + || async { Err(Error::internal("error factory must not be called")) }, + FileConcatOptions::default(), + ) + .await; + assert!(result.is_err()); + } +} diff --git a/rust/lance-file/src/datatypes.rs b/rust/lance-file/src/datatypes.rs index c0966bf0268..3d84e99267c 100644 --- a/rust/lance-file/src/datatypes.rs +++ b/rust/lance-file/src/datatypes.rs @@ -1,15 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use arrow_schema::DataType; -use async_recursion::async_recursion; -use lance_arrow::DataTypeExt; use lance_arrow::ARROW_EXT_NAME_KEY; use lance_core::datatypes::{Dictionary, Encoding, Field, LogicalType, Schema}; use lance_core::{Error, Result}; -use lance_io::traits::Reader; -use lance_io::utils::{read_binary_array, read_fixed_stride_array}; -use snafu::location; use std::collections::HashMap; use crate::format::pb; @@ -52,6 +46,11 @@ impl From<&pb::Field> for Field { } else { None }, + unenforced_clustering_key_position: if field.unenforced_clustering_key_position > 0 { + Some(field.unenforced_clustering_key_position) + } else { + None + }, } } } @@ -85,12 +84,42 @@ impl From<&Field> for pb::Field { r#type: 0, unenforced_primary_key: field.unenforced_primary_key_position.is_some(), unenforced_primary_key_position: field.unenforced_primary_key_position.unwrap_or(0), + unenforced_clustering_key: false, + unenforced_clustering_key_position: field + .unenforced_clustering_key_position + .unwrap_or(0), } } } pub struct Fields(pub Vec); +struct FieldNode { + field: Field, + child_indices: Vec, +} + +/// Searches in pre-order depth-first order and returns the first matching node, +/// preserving the legacy parent tie-break for duplicate field IDs. +fn first_field_index_by_id( + nodes: &[FieldNode], + root_indices: &[usize], + field_id: i32, +) -> Option { + let mut to_visit = Vec::with_capacity(nodes.len()); + to_visit.extend(root_indices.iter().rev().copied()); + + while let Some(node_index) = to_visit.pop() { + let node = &nodes[node_index]; + if node.field.id == field_id { + return Some(node_index); + } + to_visit.extend(node.child_indices.iter().rev().copied()); + } + + None +} + impl From<&Field> for Fields { fn from(field: &Field) -> Self { let mut protos = vec![pb::Field::from(field)]; @@ -99,24 +128,119 @@ impl From<&Field> for Fields { } } -/// Convert list of protobuf `Field` to a Schema. -impl From<&Fields> for Schema { - fn from(fields: &Fields) -> Self { - let mut schema = Self { - fields: vec![], - metadata: HashMap::default(), - }; +/// Reconstruct a schema from a flat, pre-order protobuf field list. +/// +/// Parent fields must appear before their children. Historical manifests may +/// contain duplicate field IDs, so an ID may not identify a unique parent. For +/// those references, reconstruction preserves the legacy +/// [`Schema::mut_field_by_id`] tie-break by selecting the first matching field +/// in pre-order depth-first traversal. +/// +/// # Examples +/// +/// ``` +/// use lance_core::datatypes::Schema; +/// use lance_file::{datatypes::Fields, format::pb}; +/// +/// let field = pb::Field { +/// id: 0, +/// parent_id: -1, +/// name: "value".to_owned(), +/// logical_type: "int32".to_owned(), +/// ..Default::default() +/// }; +/// let fields = Fields(vec![field]); +/// let schema = Schema::try_from(&fields)?; +/// assert_eq!(schema.fields[0].name, "value"); +/// # Ok::<(), lance_core::Error>(()) +/// ``` +impl TryFrom<&Fields> for Schema { + type Error = Error; + + fn try_from(fields: &Fields) -> Result { + let mut nodes: Vec = Vec::with_capacity(fields.0.len()); + let mut root_indices = Vec::with_capacity(fields.0.len()); + let mut field_indices: HashMap> = HashMap::with_capacity(fields.0.len()); + + for proto_field in &fields.0 { + let parent_index = if proto_field.parent_id == -1 { + None + } else { + let parent_index = match field_indices.get(&proto_field.parent_id) { + Some(Some(parent_index)) => *parent_index, + Some(None) => { + // Duplicate IDs are invalid but occur in historical + // manifests. Match the legacy tree traversal only for + // these ambiguous parent references so valid schemas + // retain the linear fast path. + first_field_index_by_id(&nodes, &root_indices, proto_field.parent_id) + .ok_or_else(|| { + Error::internal(format!( + "Duplicate field id {} has no existing arena node", + proto_field.parent_id + )) + })? + } + None => { + return Err(Error::schema(format!( + "Field '{}' (id={}) references parent id {}, which must appear earlier in the protobuf field list", + proto_field.name, proto_field.id, proto_field.parent_id + ))); + } + }; + Some(parent_index) + }; - fields.0.iter().for_each(|f| { - if f.parent_id == -1 { - schema.fields.push(Field::from(f)); + let node_index = nodes.len(); + if let Some(parent_index) = parent_index { + nodes[parent_index].child_indices.push(node_index); } else { - let parent = schema.mut_field_by_id(f.parent_id).unwrap(); - parent.children.push(Field::from(f)); + root_indices.push(node_index); } - }); + nodes.push(FieldNode { + field: Field::from(proto_field), + child_indices: Vec::new(), + }); + + field_indices + .entry(proto_field.id) + .and_modify(|field_index| *field_index = None) + .or_insert(Some(node_index)); + } + + let mut fields_by_node = Vec::with_capacity(nodes.len()); + fields_by_node.resize_with(nodes.len(), || None); + for (node_index, mut node) in nodes.into_iter().enumerate().rev() { + node.field.children.reserve(node.child_indices.len()); + for child_index in node.child_indices { + let child = fields_by_node + .get_mut(child_index) + .and_then(Option::take) + .ok_or_else(|| { + Error::internal(format!( + "Schema field arena node {child_index} was not materialized before its parent" + )) + })?; + node.field.children.push(child); + } + fields_by_node[node_index] = Some(node.field); + } + + let fields = root_indices + .into_iter() + .map(|root_index| { + fields_by_node[root_index].take().ok_or_else(|| { + Error::internal(format!( + "Schema field arena root node {root_index} was not materialized" + )) + }) + }) + .collect::>>()?; - schema + Ok(Self { + fields, + metadata: HashMap::default(), + }) } } @@ -125,9 +249,28 @@ pub struct FieldsWithMeta { pub metadata: HashMap>, } -/// Convert list of protobuf `Field` and Metadata to a Schema. -impl From for Schema { - fn from(fields_with_meta: FieldsWithMeta) -> Self { +/// Reconstruct a schema from flat protobuf fields and schema metadata. +/// +/// # Examples +/// +/// ``` +/// use std::collections::HashMap; +/// +/// use lance_core::datatypes::Schema; +/// use lance_file::datatypes::{Fields, FieldsWithMeta}; +/// +/// let fields = FieldsWithMeta { +/// fields: Fields(Vec::new()), +/// metadata: HashMap::from([("owner".to_owned(), b"lance".to_vec())]), +/// }; +/// let schema = Schema::try_from(fields)?; +/// assert_eq!(schema.metadata["owner"], "lance"); +/// # Ok::<(), lance_core::Error>(()) +/// ``` +impl TryFrom for Schema { + type Error = Error; + + fn try_from(fields_with_meta: FieldsWithMeta) -> Result { let lance_metadata = fields_with_meta .metadata .into_iter() @@ -137,11 +280,11 @@ impl From for Schema { }) .collect(); - let schema_with_fields = Self::from(&fields_with_meta.fields); - Self { + let schema_with_fields = Self::try_from(&fields_with_meta.fields)?; + Ok(Self { fields: schema_with_fields.fields, metadata: lance_metadata, - } + }) } } @@ -200,79 +343,29 @@ impl From for pb::Encoding { } } -#[async_recursion] -async fn load_field_dictionary<'a>(field: &mut Field, reader: &dyn Reader) -> Result<()> { - if let DataType::Dictionary(_, value_type) = field.data_type() { - assert!(field.dictionary.is_some()); - if let Some(dict_info) = field.dictionary.as_mut() { - use DataType::*; - match value_type.as_ref() { - _ if value_type.is_binary_like() => { - dict_info.values = Some( - read_binary_array( - reader, - value_type.as_ref(), - true, // Empty values are null - dict_info.offset, - dict_info.length, - .., - ) - .await?, - ); - } - Int8 | Int16 | Int32 | Int64 | UInt8 | UInt16 | UInt32 | UInt64 => { - dict_info.values = Some( - read_fixed_stride_array( - reader, - value_type.as_ref(), - dict_info.offset, - dict_info.length, - .., - ) - .await?, - ); - } - _ => { - return Err(Error::Schema { - message: format!( - "Does not support {} as dictionary value type", - value_type - ), - location: location!(), - }); - } - } - } else { - panic!("Should not reach here: dictionary field does not load dictionary info") - } - Ok(()) - } else { - for child in field.children.as_mut_slice() { - load_field_dictionary(child, reader).await?; - } - Ok(()) - } -} - -/// Load dictionary value array from manifest files. -// TODO: pub(crate) -pub async fn populate_schema_dictionary(schema: &mut Schema, reader: &dyn Reader) -> Result<()> { - for field in schema.fields.as_mut_slice() { - load_field_dictionary(field, reader).await?; - } - Ok(()) -} - #[cfg(test)] mod tests { + use std::collections::HashMap; + use arrow_schema::DataType; use arrow_schema::Field as ArrowField; use arrow_schema::Fields as ArrowFields; use arrow_schema::Schema as ArrowSchema; + use lance_core::Error; use lance_core::datatypes::Schema; - use std::collections::HashMap; use super::{Fields, FieldsWithMeta}; + use crate::format::pb; + + fn proto_field(id: i32, parent_id: i32, name: String, logical_type: &str) -> pb::Field { + pb::Field { + id, + parent_id, + name, + logical_type: logical_type.to_owned(), + ..Default::default() + } + } #[test] fn test_schema_set_ids() { @@ -312,7 +405,161 @@ mod tests { let expected_schema = Schema::try_from(&arrow_schema).unwrap(); let fields_with_meta: FieldsWithMeta = (&expected_schema).into(); - let schema = Schema::from(fields_with_meta); + let schema = Schema::try_from(fields_with_meta).unwrap(); assert_eq!(expected_schema, schema); } + + #[test] + fn test_reconstruct_wide_nested_schema() { + const NUM_STRUCTS: usize = 4096; + + let mut proto_fields = Vec::with_capacity(NUM_STRUCTS * 3); + for struct_index in 0..NUM_STRUCTS { + let parent_id = (struct_index * 3) as i32; + proto_fields.push(proto_field( + parent_id, + -1, + format!("struct_{struct_index}"), + "struct", + )); + proto_fields.push(proto_field( + parent_id + 1, + parent_id, + format!("left_{struct_index}"), + "int32", + )); + proto_fields.push(proto_field( + parent_id + 2, + parent_id, + format!("right_{struct_index}"), + "int32", + )); + } + + let fields = Fields(proto_fields); + let schema = Schema::try_from(&fields).unwrap(); + assert_eq!(schema.fields.len(), NUM_STRUCTS); + for (struct_index, field) in schema.fields.iter().enumerate() { + let parent_id = (struct_index * 3) as i32; + assert_eq!(field.id, parent_id); + assert_eq!(field.name, format!("struct_{struct_index}")); + assert_eq!(field.children.len(), 2); + assert_eq!(field.children[0].id, parent_id + 1); + assert_eq!(field.children[0].name, format!("left_{struct_index}")); + assert_eq!(field.children[1].id, parent_id + 2); + assert_eq!(field.children[1].name, format!("right_{struct_index}")); + } + } + + #[test] + fn test_reconstruct_deep_nested_schema() { + const DEPTH: usize = 1024; + + let proto_fields = (0..DEPTH) + .map(|depth| { + proto_field( + depth as i32, + if depth == 0 { -1 } else { depth as i32 - 1 }, + format!("level_{depth}"), + if depth + 1 == DEPTH { + "int32" + } else { + "struct" + }, + ) + }) + .collect(); + + let fields = Fields(proto_fields); + let schema = Schema::try_from(&fields).unwrap(); + assert_eq!(schema.fields.len(), 1); + let mut field = &schema.fields[0]; + for depth in 0..DEPTH { + assert_eq!(field.id, depth as i32); + assert_eq!(field.name, format!("level_{depth}")); + if depth + 1 == DEPTH { + assert!(field.children.is_empty()); + } else { + assert_eq!(field.children.len(), 1); + field = &field.children[0]; + } + } + } + + #[test] + fn test_reconstruct_schema_reports_missing_parent() { + let fields = Fields(vec![proto_field(7, 42, "child".to_owned(), "int32")]); + + let error = Schema::try_from(&fields).unwrap_err(); + assert!(matches!(&error, Error::Schema { .. })); + assert!( + error.to_string().contains( + "Field 'child' (id=7) references parent id 42, which must appear earlier" + ) + ); + } + + #[test] + fn test_reconstruct_schema_preserves_legacy_duplicate_id_match() { + let fields = Fields(vec![ + proto_field(1, -1, "root_a".to_owned(), "struct"), + proto_field(2, -1, "root_b".to_owned(), "struct"), + proto_field(2, 1, "nested_duplicate".to_owned(), "struct"), + proto_field(3, 2, "child".to_owned(), "int32"), + ]); + + let schema = Schema::try_from(&fields).unwrap(); + assert_eq!(schema.fields.len(), 2); + assert_eq!(schema.fields[0].name, "root_a"); + assert_eq!(schema.fields[0].children.len(), 1); + assert_eq!(schema.fields[0].children[0].name, "nested_duplicate"); + assert_eq!(schema.fields[0].children[0].children.len(), 1); + assert_eq!(schema.fields[0].children[0].children[0].name, "child"); + assert_eq!(schema.fields[1].name, "root_b"); + assert!(schema.fields[1].children.is_empty()); + } + + #[test] + fn test_clustering_key_roundtrip() { + let arrow_schema = ArrowSchema::new(vec![ + ArrowField::new("region", DataType::Utf8, true).with_metadata( + vec![( + "lance-schema:unenforced-clustering-key:position".to_owned(), + "1".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("date", DataType::Int32, false).with_metadata( + vec![( + "lance-schema:unenforced-clustering-key:position".to_owned(), + "2".to_owned(), + )] + .into_iter() + .collect::>(), + ), + ArrowField::new("value", DataType::Float64, true), + ]); + + let schema = Schema::try_from(&arrow_schema).unwrap(); + let ck = schema.unenforced_clustering_key(); + assert_eq!(ck.len(), 2); + assert_eq!(ck[0].name, "region"); + assert_eq!(ck[1].name, "date"); + + // Round-trip through protobuf + let fields_with_meta: FieldsWithMeta = (&schema).into(); + let restored = Schema::try_from(fields_with_meta).unwrap(); + + let ck2 = restored.unenforced_clustering_key(); + assert_eq!(ck2.len(), 2); + assert_eq!(ck2[0].name, "region"); + assert_eq!(ck2[1].name, "date"); + assert_eq!(ck2[0].unenforced_clustering_key_position, Some(1)); + assert_eq!(ck2[1].unenforced_clustering_key_position, Some(2)); + + // Non-clustering-key field should not have position + let value_field = restored.field("value").unwrap(); + assert!(!value_field.is_unenforced_clustering_key()); + } } diff --git a/rust/lance-file/src/io.rs b/rust/lance-file/src/io.rs index 594ba64817b..86e5189a81d 100644 --- a/rust/lance-file/src/io.rs +++ b/rust/lance-file/src/io.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors -use futures::{future::BoxFuture, FutureExt}; +use std::sync::Arc; + +use futures::{FutureExt, future::BoxFuture}; use lance_encoding::EncodingsIo; use lance_io::scheduler::FileScheduler; @@ -29,6 +31,23 @@ impl LanceEncodingsIo { } impl EncodingsIo for LanceEncodingsIo { + fn with_bypass_backpressure(&self) -> Option> { + Some(Arc::new(Self { + scheduler: self.scheduler.with_bypass_backpressure(), + read_chunk_size: self.read_chunk_size, + })) + } + + fn with_io_stats( + &self, + stats: Arc, + ) -> Option> { + Some(Arc::new(Self { + scheduler: self.scheduler.with_io_stats(stats), + read_chunk_size: self.read_chunk_size, + })) + } + fn submit_request( &self, ranges: Vec>, @@ -36,14 +55,27 @@ impl EncodingsIo for LanceEncodingsIo { ) -> BoxFuture<'static, lance_core::Result>> { let mut split_ranges = Vec::new(); let mut split_indices = Vec::new(); // Track which original range each split came from + // Large ranges (above read_chunk_size) will be split into + // multiple reads. Empty ranges will skip the I/O layer + // entirely. If we have either of these we will need to + // reassemble our results, inserting empties and merging parts + let mut needs_reassembly = false; // Split large ranges into smaller chunks // // TODO: consider read_chunk_size before submitting requests. for (idx, range) in ranges.iter().enumerate() { + if range.start == range.end { + // EncodingsIo requires one result per input range. Zero-length + // ranges schedule no I/O, so their empty results are restored + // after the non-empty requests complete. + needs_reassembly = true; + continue; + } let range_size = range.end - range.start; if range_size > self.read_chunk_size { + needs_reassembly = true; let num_chunks = range_size.div_ceil(self.read_chunk_size); let chunk_size = range_size / num_chunks; @@ -68,34 +100,48 @@ impl EncodingsIo for LanceEncodingsIo { async move { let split_results = fut.await?; - // Fast path: if no splitting occurred, return results directly - if split_results.len() == ranges.len() { + if split_results.len() != split_indices.len() { + return Err(lance_core::Error::internal(format!( + "Encoding I/O returned {} results for {} requested range chunks", + split_results.len(), + split_indices.len() + ))); + } + if !needs_reassembly { return Ok(split_results); } - // Slow path: reassemble split results let mut results = vec![Vec::new(); ranges.len()]; - for (split_result, &orig_idx) in split_results.iter().zip(split_indices.iter()) { - results[orig_idx].push(split_result.clone()); + for (split_result, orig_idx) in split_results.into_iter().zip(split_indices) { + results[orig_idx].push(split_result); } - Ok(results - .into_iter() - .map(|chunks| { - if chunks.len() == 1 { - chunks.into_iter().next().unwrap() - } else { - // Concatenate multiple chunks - let total_size: usize = chunks.iter().map(|c| c.len()).sum(); - let mut combined = Vec::with_capacity(total_size); - for chunk in chunks { - combined.extend_from_slice(&chunk); - } - bytes::Bytes::from(combined) + let mut reassembled = Vec::with_capacity(ranges.len()); + for (range, chunks) in ranges.iter().zip(results) { + if chunks.is_empty() { + if range.start == range.end { + reassembled.push(bytes::Bytes::new()); + continue; } - }) - .collect()) + return Err(lance_core::Error::internal(format!( + "Encoding I/O returned no data for non-empty range {}..{}", + range.start, range.end + ))); + } + if chunks.len() == 1 { + reassembled.push(chunks[0].clone()); + continue; + } + + let total_size: usize = chunks.iter().map(|c| c.len()).sum(); + let mut combined = Vec::with_capacity(total_size); + for chunk in chunks { + combined.extend_from_slice(&chunk); + } + reassembled.push(bytes::Bytes::from(combined)); + } + Ok(reassembled) } .boxed() } diff --git a/rust/lance-file/src/lib.rs b/rust/lance-file/src/lib.rs index b69f21f7cf0..c1e6714076e 100644 --- a/rust/lance-file/src/lib.rs +++ b/rust/lance-file/src/lib.rs @@ -1,58 +1,60 @@ // SPDX-License-Identifier: Apache-2.0 // SPDX-FileCopyrightText: Copyright The Lance Authors +pub mod concat; pub mod datatypes; pub mod format; pub(crate) mod io; -pub mod previous; pub mod reader; pub mod testing; +pub mod version; +pub mod versions; pub mod writer; +#[cfg(test)] +mod compatibility_tests; + pub use io::LanceEncodingsIo; use format::MAGIC; -pub use lance_encoding::version; - use lance_core::{Error, Result}; -use lance_encoding::version::LanceFileVersion; use lance_io::object_store::ObjectStore; use object_store::path::Path; -use snafu::location; +use version::ConcreteFileVersion; pub async fn determine_file_version( store: &ObjectStore, path: &Path, known_size: Option, -) -> Result { +) -> Result { let size = match known_size { - None => store.size(path).await.unwrap() as usize, + None => usize::try_from(store.size(path).await?).map_err(|_| { + Error::invalid_input(format!("file {} is too large for this platform", path)) + })?, Some(size) => size, }; if size < 8 { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "the file {} does not appear to be a lance file (too small)", path ) .into(), - location: location!(), - }); + )); } let reader = store.open_with_size(path, size).await?; let footer = reader.get_range((size - 8)..size).await?; if &footer[4..] != MAGIC { - return Err(Error::InvalidInput { - source: format!( + return Err(Error::invalid_input_source( + format!( "the file {} does not appear to be a lance file (magic mismatch)", path ) .into(), - location: location!(), - }); + )); } let major_version = u16::from_le_bytes([footer[0], footer[1]]); let minor_version = u16::from_le_bytes([footer[2], footer[3]]); - LanceFileVersion::try_from_major_minor(major_version as u32, minor_version as u32) + ConcreteFileVersion::from_footer_numbers(major_version, minor_version) } diff --git a/rust/lance-file/src/previous/mod.rs b/rust/lance-file/src/previous/mod.rs deleted file mode 100644 index 9031d2b4992..00000000000 --- a/rust/lance-file/src/previous/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -// SPDX-FileCopyrightText: Copyright The Lance Authors - -//! Legacy Lance file v1 implementation kept for backwards compatibility. - -pub mod format; -pub mod page_table; -pub mod reader; -pub mod writer; diff --git a/rust/lance-file/src/reader.rs b/rust/lance-file/src/reader.rs index 4c48edf5e9e..97ccd54f379 100644 --- a/rust/lance-file/src/reader.rs +++ b/rust/lance-file/src/reader.rs @@ -2,7 +2,9 @@ // SPDX-FileCopyrightText: Copyright The Lance Authors use std::{ + borrow::Cow, collections::{BTreeMap, BTreeSet}, + fmt::Debug, io::Cursor, ops::Range, pin::Pin, @@ -11,45 +13,46 @@ use std::{ use arrow_array::RecordBatchReader; use arrow_schema::Schema as ArrowSchema; +use async_trait::async_trait; use byteorder::{ByteOrder, LittleEndian, ReadBytesExt}; use bytes::{Bytes, BytesMut}; -use deepsize::{Context, DeepSizeOf}; -use futures::{stream::BoxStream, Stream, StreamExt}; +use futures::{Stream, StreamExt, stream::BoxStream}; +use lance_core::deepsize::{Context, DeepSizeOf}; use lance_encoding::{ + EncodingsIo, decoder::{ - schedule_and_decode, schedule_and_decode_blocking, ColumnInfo, DecoderConfig, - DecoderPlugins, FilterExpression, PageEncoding, PageInfo, ReadBatchTask, RequestedRows, - SchedulerDecoderConfig, + ColumnInfo, DecoderConfig, DecoderPlugins, FilterExpression, PageEncoding, ReadBatchTask, + RequestedRows, SchedulerDecoderConfig, schedule_and_decode, schedule_and_decode_blocking, }, encoder::EncodedBatch, - version::LanceFileVersion, - EncodingsIo, }; use log::debug; use object_store::path::Path; -use prost::{Message, Name}; -use snafu::location; +use prost::Message; use lance_core::{ - cache::LanceCache, - datatypes::{Field, Schema}, Error, Result, + cache::{CacheKey, CacheKeySchema, KeyBuilder, LanceCache}, + datatypes::{Field, Schema}, }; use lance_encoding::format::pb as pbenc; use lance_encoding::format::pb21 as pbenc21; use lance_io::{ + ReadBatchParams, scheduler::FileScheduler, stream::{RecordBatchStream, RecordBatchStreamAdapter}, - ReadBatchParams, }; use crate::{ datatypes::{Fields, FieldsWithMeta}, - format::{pb, pbfile, MAGIC, MAJOR_VERSION, MINOR_VERSION}, + format::{MAGIC, pb, pbfile}, io::LanceEncodingsIo, - writer::PAGE_BUFFER_ALIGNMENT, + version::ConcreteFileVersion, + versions, }; +pub(crate) mod structural; + /// Default chunk size for reading large pages (8MiB) /// Pages larger than this will be split into multiple chunks during read pub const DEFAULT_READ_CHUNK_SIZE: u64 = 8 * 1024 * 1024; @@ -64,6 +67,39 @@ pub struct BufferDescriptor { pub size: u64, } +impl BufferDescriptor { + fn checked_range(&self, buffer_index: usize, file_len: u64) -> Result> { + let end = self.position.checked_add(self.size).ok_or_else(|| { + Error::invalid_input_source( + format!( + "Global buffer {} range overflows: position={}, size={}", + buffer_index, self.position, self.size + ) + .into(), + ) + })?; + if self.position > file_len { + return Err(Error::invalid_input_source( + format!( + "Global buffer {} position {} is outside file of size {}", + buffer_index, self.position, file_len + ) + .into(), + )); + } + if end > file_len { + return Err(Error::invalid_input_source( + format!( + "Global buffer {} range {}..{} is outside file of size {}", + buffer_index, self.position, end, file_len + ) + .into(), + )); + } + Ok(self.position..end) + } +} + /// Statistics summarize some of the file metadata for quick summary info #[derive(Debug)] pub struct FileStatistics { @@ -102,34 +138,146 @@ pub struct CachedFileMetadata { pub num_global_buffer_bytes: u64, /// The number of bytes contained in the CMO and GBO tables pub num_footer_bytes: u64, + /// The major version number stored in the file footer. pub major_version: u16, + /// The minor version number stored in the file footer. pub minor_version: u16, + pub version: ConcreteFileVersion, + /// The actual total file size in bytes, as reported by the object store. + pub file_size_bytes: u64, + /// User global buffers (index >= 1) whose bytes were already captured by the + /// tail read that `read_all_metadata` performs at open, keyed by buffer index. + /// + /// All global buffers are laid out contiguously starting at the schema, so on + /// small/medium files they land inside the captured tail window. Retaining + /// those bytes lets `read_global_buffer` serve them with zero additional I/O. + /// The bytes are copied out of the tail (rather than sliced) so the much + /// larger tail allocation can be dropped — we only hold what we will serve. + /// + /// The schema (buffer 0) is excluded: it is already decoded at open and is + /// not fetched through `read_global_buffer`. Buffers that fall outside the + /// window (large files) are absent here and fall back to a dedicated read. + pub retained_global_buffers: BTreeMap, +} + +impl CachedFileMetadata { + /// Total file size in bytes. + pub fn file_size(&self) -> u64 { + self.file_size_bytes + } +} + +fn column_metadata_deep_size(column_metadatas: &[pbfile::ColumnMetadata]) -> usize { + column_metadatas + .iter() + .map(|cm| cm.encoded_len() * 4) + .sum::() + + std::mem::size_of_val(column_metadatas) } 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 = column_metadata_deep_size(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 = self.column_infos.deep_size_of_children(context); + + // Global buffer bytes retained for zero-IO reads (copied out of the tail). + let retained_buffers_size = self.retained_global_buffers.deep_size_of_children(context); + + schema_size + + buffers_size + + column_metadatas_size + + column_infos_size + + retained_buffers_size + } +} + +/// Lightweight file metadata used to locate per-column metadata on demand. +/// +/// This contains the file-level schema, row count, global buffer descriptors, +/// and column metadata offset table. Unlike [`CachedFileMetadata`], it does not +/// hold decoded metadata for every column. +#[derive(Debug, DeepSizeOf)] +pub struct FileMetadataIndex { + pub(crate) file_schema: Arc, + pub(crate) num_rows: u64, + pub(crate) file_buffers: Vec, + pub(crate) column_metadata_offsets: Arc<[(u64, u64)]>, + pub(crate) num_columns: u32, + pub(crate) version: ConcreteFileVersion, + pub(crate) file_size_bytes: u64, + pub(crate) retained_global_buffers: BTreeMap, +} + +impl FileMetadataIndex { + /// Returns the total size of the file in bytes. + pub fn file_size(&self) -> u64 { + self.file_size_bytes + } + + /// Returns the number of physical columns in the file. + pub fn num_columns(&self) -> u32 { + self.num_columns + } +} + +#[derive(Debug)] +struct CachedColumnMetadata { + column_metadata: pbfile::ColumnMetadata, + column_info: Arc, +} + +impl DeepSizeOf for CachedColumnMetadata { + fn deep_size_of_children(&self, context: &mut Context) -> usize { + column_metadata_deep_size(std::slice::from_ref(&self.column_metadata)) + + self.column_info.deep_size_of_children(context) + } +} + +#[derive(Debug, Clone)] +struct ColumnMetadataCacheKey { + column_index: u32, +} + +impl CacheKey for ColumnMetadataCacheKey { + type ValueType = CachedColumnMetadata; + + fn key(&self) -> Cow<'_, str> { + Cow::Owned(format!("column_metadata/{}", self.column_index)) + } + + fn type_name() -> &'static str { + "ColumnMetadata" + } + + fn schema() -> CacheKeySchema { + CacheKeySchema::new("lance.file.column-metadata-key", 1) + } + + fn write_key(&self, builder: &mut KeyBuilder) { + builder.write_u32(self.column_index); } } impl CachedFileMetadata { - pub fn version(&self) -> LanceFileVersion { - match (self.major_version, self.minor_version) { - (0, 3) => LanceFileVersion::V2_0, - (2, 0) => LanceFileVersion::V2_0, - (2, 1) => LanceFileVersion::V2_1, - (2, 2) => LanceFileVersion::V2_2, - _ => panic!( - "Unsupported version: {}.{}", - self.major_version, self.minor_version - ), - } + pub fn version(&self) -> ConcreteFileVersion { + self.version } } @@ -202,128 +350,14 @@ pub struct ReaderProjection { } impl ReaderProjection { - fn from_field_ids_helper<'a>( - file_version: LanceFileVersion, - fields: impl Iterator, - field_id_to_column_index: &BTreeMap, - column_indices: &mut Vec, - ) -> Result<()> { - for field in fields { - let is_structural = file_version >= LanceFileVersion::V2_1; - // In the 2.0 system we needed ids for intermediate fields. In 2.1+ - // we only need ids for leaf fields. - if !is_structural - || field.children.is_empty() - || field.is_blob() - || field.is_packed_struct() - { - if let Some(column_idx) = field_id_to_column_index.get(&(field.id as u32)).copied() - { - column_indices.push(column_idx); - } - } - // Don't recurse into children if the field is a blob or packed struct in 2.1 - if !is_structural || (!field.is_blob() && !field.is_packed_struct()) { - Self::from_field_ids_helper( - file_version, - field.children.iter(), - field_id_to_column_index, - column_indices, - )?; - } - } - Ok(()) - } - - /// Creates a projection using a mapping from field IDs to column indices - /// - /// You can obtain such a mapping when the file is written using the - /// [`crate::writer::FileWriter::field_id_to_column_indices`] method. - pub fn from_field_ids( - file_version: LanceFileVersion, - schema: &Schema, - field_id_to_column_index: &BTreeMap, - ) -> Result { - let mut column_indices = Vec::new(); - Self::from_field_ids_helper( - file_version, - schema.fields.iter(), - field_id_to_column_index, - &mut column_indices, - )?; - let projection = Self { - schema: Arc::new(schema.clone()), - column_indices, - }; - Ok(projection) - } - - /// Creates a projection that reads the entire file - /// - /// If the schema provided is not the schema of the entire file then - /// the projection will be invalid and the read will fail. - /// If the field is a `struct datatype` with `packed` set to true in the field metadata, - /// the whole struct has one column index. - /// To support nested `packed-struct encoding`, this method need to be further adjusted. - pub fn from_whole_schema(schema: &Schema, version: LanceFileVersion) -> Self { - let schema = Arc::new(schema.clone()); - let is_structural = version >= LanceFileVersion::V2_1; - let mut column_indices = vec![]; - let mut curr_column_idx = 0; - let mut packed_struct_fields_num = 0; - for field in schema.fields_pre_order() { - if packed_struct_fields_num > 0 { - packed_struct_fields_num -= 1; - continue; - } - if field.is_packed_struct() { - column_indices.push(curr_column_idx); - curr_column_idx += 1; - packed_struct_fields_num = field.children.len(); - } else if field.children.is_empty() || !is_structural { - column_indices.push(curr_column_idx); - curr_column_idx += 1; - } - } - Self { - schema, - column_indices, - } - } - - /// Creates a projection that reads the specified columns provided by name - /// - /// The syntax for column names is the same as [`lance_core::datatypes::Schema::project`] + /// Returns whether this projection is selective enough to benefit from + /// loading column metadata through the file's metadata index. /// - /// If the schema provided is not the schema of the entire file then - /// the projection will be invalid and the read will fail. - pub fn from_column_names( - file_version: LanceFileVersion, - schema: &Schema, - column_names: &[&str], - ) -> Result { - let field_id_to_column_index = schema - .fields_pre_order() - // In the 2.0 system we needed ids for intermediate fields. In 2.1+ - // we only need ids for leaf fields. - .filter(|field| { - file_version < LanceFileVersion::V2_1 || field.is_leaf() || field.is_packed_struct() - }) - .enumerate() - .map(|(idx, field)| (field.id as u32, idx as u32)) - .collect::>(); - let projected = schema.project(column_names)?; - let mut column_indices = Vec::new(); - Self::from_field_ids_helper( - file_version, - projected.fields.iter(), - &field_id_to_column_index, - &mut column_indices, - )?; - Ok(Self { - schema: Arc::new(projected), - column_indices, - }) + /// The caller must already have selected a file format that supports indexed + /// metadata. This method only evaluates the projection shape and selectivity. + pub fn prefers_indexed_metadata(&self, total_columns: usize) -> bool { + FileMetadataProvider::projection_matches_indexed_metadata(self) + && self.column_indices.len().saturating_mul(4) < total_columns } } @@ -335,6 +369,14 @@ 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. The row-based `batch_size` remains an + /// independent upper bound, and the limit reached first determines the 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 { @@ -342,62 +384,281 @@ impl Default for FileReaderOptions { Self { decoder_config: DecoderConfig::default(), read_chunk_size: DEFAULT_READ_CHUNK_SIZE, + batch_size_bytes: None, } } } -#[derive(Debug)] +#[derive(Debug, Clone)] +pub(crate) struct PreparedProjection { + pub column_infos: Vec>, + pub decoder_projection: ReaderProjection, +} + +#[derive(Debug, Clone)] +pub(crate) enum FileMetadataProvider { + Full(Arc), + Indexed(Arc), +} + +/// Executable projection behavior selected by an exact file-version module. +/// +/// The shared reader invokes this behavior but never interprets a version or +/// accepted-grammar profile. +#[async_trait] +pub(crate) trait ReadProjection: Debug + Send + Sync { + fn validate_indexed( + &self, + projection: &ReaderProjection, + metadata_index: &FileMetadataIndex, + ) -> Result<()>; + + fn read_length(&self, prepared: &PreparedProjection) -> Result; + + async fn prepare( + &self, + metadata_provider: &FileMetadataProvider, + projection: &ReaderProjection, + io: &Arc, + cache: &Arc, + ) -> Result<(PreparedProjection, u64)>; +} + +#[derive(Debug, Clone)] +pub(crate) struct DecodeEngine { + pub scheduler: Arc, + pub base_projection: ReaderProjection, + pub metadata_provider: FileMetadataProvider, + pub read_projection: Arc, + pub decoder_plugins: Arc, + pub cache: Arc, + pub options: FileReaderOptions, +} + +/// A projection-scoped reader for a current-format Lance file. +/// +/// This reader fixes a base projection at construction time. All later reads +/// must stay within that projection, which lets the reader load only the column +/// metadata needed by the base projection when opening from a [`FileMetadataIndex`]. +/// It intentionally does not expose APIs that require synchronous access to full +/// file metadata. +#[derive(Debug, Clone)] +pub struct ProjectedFileReader { + core: DecodeEngine, +} + +/// A current-format Lance file reader backed by fully decoded metadata. +#[derive(Debug, Clone)] pub struct FileReader { - scheduler: Arc, - // The default projection to be applied to all reads - base_projection: ReaderProjection, - num_rows: u64, - metadata: Arc, - decoder_plugins: Arc, - cache: Arc, - options: FileReaderOptions, + pub(crate) core: DecodeEngine, + pub(crate) metadata: Arc, +} + +pub(crate) fn tasks_to_record_batch_stream( + schema: Arc, + tasks: Pin + Send>>, + batch_readahead: u32, +) -> Pin> { + let arrow_schema = Arc::new(ArrowSchema::from(schema.as_ref())); + let batches = tasks + .map(|task| task.task) + .buffered(batch_readahead as usize) + .boxed(); + Box::pin(RecordBatchStreamAdapter::new(arrow_schema, batches)) +} + +pub(crate) enum RawFileMetadataOpen { + Legacy { + major_version: u16, + minor_version: u16, + }, + Current { + version: ConcreteFileVersion, + metadata: RawFileMetadata, + }, +} + +pub(crate) struct RawFileMetadata { + pub file_schema: Arc, + pub column_metadatas: Vec, + pub num_rows: u64, + pub file_buffers: Vec, + pub num_data_bytes: u64, + pub num_column_metadata_bytes: u64, + pub num_global_buffer_bytes: u64, + pub num_footer_bytes: u64, + pub footer: Footer, + pub file_size_bytes: u64, + pub retained_global_buffers: BTreeMap, } + #[derive(Debug)] -struct Footer { +pub(crate) struct Footer { #[allow(dead_code)] - column_meta_start: u64, + pub column_meta_start: u64, // We don't use this today because we always load metadata for every column // and don't yet support "metadata projection" #[allow(dead_code)] - column_meta_offsets_start: u64, - global_buff_offsets_start: u64, - num_global_buffers: u32, - num_columns: u32, - major_version: u16, - minor_version: u16, + pub column_meta_offsets_start: u64, + pub global_buff_offsets_start: u64, + pub num_global_buffers: u32, + pub num_columns: u32, + pub major_version: u16, + pub minor_version: u16, } const FOOTER_LEN: usize = 40; +// Count the V2.1 physical columns required to reconstruct a projected field. +// This is the same DFS shape consumed by `ColumnInfoIter`: ordinary structural +// nodes are transparent and leaves contribute columns. Indexed metadata loading +// can therefore compact any ordinary structural projection into 0..N while +// preserving this order. +// +// Blob and packed-struct fields remain unsupported by indexed projection. Their +// opaque decode semantics are handled by the existing full-metadata reader. +fn indexed_projection_column_count(field: &Field) -> Option { + if field.is_blob() || field.is_packed_struct() { + return None; + } + + if field.children.is_empty() { + return Some(1); + } + + field.children.iter().try_fold(0usize, |count, child| { + count.checked_add(indexed_projection_column_count(child)?) + }) +} + +// The reader combines a projection's columns into rectangular batches, so they +// must all have the same length. Returns that common length, or a descriptive +// error (naming each column's length) when they differ. Ordinary files always +// pass; only files written with `FileWriter::write_column` whose columns ended up +// unequal can fail, and those must be read separately. +pub(crate) fn normalized_column_num_rows(info: &ColumnInfo) -> Result { + info.page_infos.iter().try_fold(0_u64, |rows, page| { + let page_rows = match &page.encoding { + PageEncoding::Structural(layout) => match &layout.layout { + Some(pbenc21::page_layout::Layout::SparseLayout(sparse)) => sparse + .structural_layers + .first() + .and_then(|layer| layer.layer.as_ref()) + .map_or(page.num_rows, |layer| match layer { + pbenc21::sparse_structural_layer::Layer::Validity(layer) => layer.num_slots, + pbenc21::sparse_structural_layer::Layer::List(layer) => layer.num_slots, + pbenc21::sparse_structural_layer::Layer::FixedSizeList(layer) => { + layer.num_slots + } + }), + _ => page.num_rows, + }, + _ => page.num_rows, + }; + rows.checked_add(page_rows) + .ok_or_else(|| Error::invalid_input_source("Column row count overflows u64".into())) + }) +} + +pub(crate) fn verify_uniform_lengths(field_lengths: &[(&str, u64)]) -> Result { + let first = field_lengths.first().map_or(0, |&(_, len)| len); + if field_lengths.iter().all(|&(_, len)| len == first) { + return Ok(first); + } + let columns = field_lengths + .iter() + .map(|(name, len)| format!("{name}={len}")) + .collect::>() + .join(", "); + Err(Error::invalid_input(format!( + "cannot read columns of differing lengths together ({columns}); \ + read each column (or equal-length group) separately" + ))) +} + impl FileReader { + pub(crate) fn base_projection(&self) -> &ReaderProjection { + &self.core.base_projection + } + + pub(crate) fn full_projection(&self, projection: ReaderProjection) -> PreparedProjection { + PreparedProjection { + column_infos: self.metadata.column_infos.clone(), + decoder_projection: projection, + } + } + + pub(crate) async fn read_prepared_tasks( + &self, + params: ReadBatchParams, + batch_size: u32, + prepared: PreparedProjection, + read_len: u64, + filter: FilterExpression, + ) -> Result + Send>>> { + self.core + .read_prepared_tasks(params, batch_size, prepared, read_len, filter) + .await + } + pub fn with_scheduler(&self, scheduler: Arc) -> Self { Self { - scheduler, - base_projection: self.base_projection.clone(), - cache: self.cache.clone(), - decoder_plugins: self.decoder_plugins.clone(), + core: self.core.with_scheduler(scheduler), metadata: self.metadata.clone(), - options: self.options.clone(), - num_rows: self.num_rows, + } + } + + /// Returns a clone of this reader whose I/O is additionally recorded into + /// `stats`, on top of the scheduler's global accounting. + /// + /// All cached metadata is shared with `self`, so no file is re-opened and + /// only a few `Arc` clones are performed. If the underlying I/O service + /// does not support per-scope statistics (e.g. an in-memory scheduler), the + /// returned reader is an ordinary, uninstrumented clone. + pub fn with_io_stats( + &self, + stats: Arc, + ) -> Self { + match self.core.scheduler.with_io_stats(stats) { + Some(scheduler) => self.with_scheduler(scheduler), + None => self.clone(), } } pub fn num_rows(&self) -> u64 { - self.num_rows + self.core.num_rows() + } + + /// The number of rows stored in a single physical column. + /// + /// For ordinary (rectangular) files every column has the same length, equal + /// to [`num_rows`](Self::num_rows). Files written with + /// [`FileWriter::write_column`](crate::writer::FileWriter::write_column) + /// may have columns of differing lengths; this returns the length of one + /// such column, derived by summing its pages' row counts. Errors if + /// `column_index` is out of bounds. + pub fn column_num_rows(&self, column_index: usize) -> Result { + let column = self + .metadata + .column_metadatas + .get(column_index) + .ok_or_else(|| { + Error::invalid_input(format!( + "column index {} is out of bounds (file has {} columns)", + column_index, + self.metadata.column_metadatas.len() + )) + })?; + Ok(column.pages.iter().map(|page| page.length).sum()) } pub fn metadata(&self) -> &Arc { &self.metadata } - pub fn file_statistics(&self) -> FileStatistics { - let column_metadatas = &self.metadata().column_metadatas; - + fn statistics_from_column_metadata( + column_metadatas: &[pbfile::ColumnMetadata], + ) -> FileStatistics { let column_stats = column_metadatas .iter() .map(|col_metadata| { @@ -419,14 +680,12 @@ impl FileReader { } } + pub fn file_statistics(&self) -> FileStatistics { + Self::statistics_from_column_metadata(&self.metadata().column_metadatas) + } + pub async fn read_global_buffer(&self, index: u32) -> Result { - let buffer_desc = self.metadata.file_buffers.get(index as usize).ok_or_else(||Error::invalid_input(format!("request for global buffer at index {} but there were only {} global buffers in the file", index, self.metadata.file_buffers.len()), location!()))?; - self.scheduler - .submit_single( - buffer_desc.position..buffer_desc.position + buffer_desc.size, - 0, - ) - .await + self.core.read_global_buffer(index).await } async fn read_tail(scheduler: &FileScheduler) -> Result<(Bytes, u64)> { @@ -440,18 +699,53 @@ impl FileReader { Ok((tail_bytes, file_size)) } + async fn read_range_from_tail_or_scheduler( + tail_bytes: &Bytes, + tail_offset: u64, + scheduler: &FileScheduler, + range: Range, + ) -> Result { + let tail_end = tail_offset + tail_bytes.len() as u64; + if range.start >= tail_offset && range.end <= tail_end { + let rel_start = (range.start - tail_offset) as usize; + let rel_end = (range.end - tail_offset) as usize; + Ok(tail_bytes.slice(rel_start..rel_end)) + } else { + scheduler.submit_single(range, 0).await + } + } + + fn retained_global_buffers_from_tail( + gbo_table: &[BufferDescriptor], + tail_bytes: &Bytes, + tail_offset: u64, + file_len: u64, + ) -> Result> { + let tail_end = tail_offset + .checked_add(tail_bytes.len() as u64) + .ok_or_else(|| Error::invalid_input_source("Tail byte range overflows".into()))?; + let mut retained_buffers = BTreeMap::new(); + for (index, buffer) in gbo_table.iter().enumerate().skip(1) { + let range = buffer.checked_range(index, file_len)?; + if range.start >= tail_offset && range.end <= tail_end { + let rel_start = (range.start - tail_offset) as usize; + let rel_end = (range.end - tail_offset) as usize; + let bytes = Bytes::copy_from_slice(&tail_bytes[rel_start..rel_end]); + retained_buffers.insert(index as u32, bytes); + } + } + Ok(retained_buffers) + } + // Checks to make sure the footer is written correctly and returns the // position of the file descriptor (which comes from the footer) fn decode_footer(footer_bytes: &Bytes) -> Result